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  /about`, `/standard-site`, `/stats`, `/privacy`, `/terms` — static
15//!   and status pages.
16//! * `GET  /manage` — feed and folder management.
17//! * `GET  /` — the reader: a folders/feeds sidebar (from the PDS records layer)
18//!   plus the main article list. Query params pick the scope (`?feed=…` /
19//!   `?folder=…` / all) and the view (`?view=unread|all|starred`).
20//! * `GET  /entries/{id}` — the clean, distraction-free reader for one entry,
21//!   with prev/next within the current list.
22//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
23//! * `POST /entries/{id}/star` — star/unstar; writes a
24//!   `community.lexicon.rss.saved` record to the user's PDS.
25//! * `POST /saved/{rkey}/delete` — unsave a `saved` record.
26//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
27//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
28//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
29//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
30//! * `POST /folders` — create a folder record.
31//! * `POST /folders/{rkey}/rename` — rename a folder record.
32//! * `POST /folders/{rkey}/delete` — delete a folder record.
33//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
34//!   subscription records in the PDS.
35//! * `GET  /opml/export` — OPML export (records → a downloadable document).
36//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
37//!   OAuth sign-in flow, on whichever backend `FEATHERREADER_REPO_BACKEND`
38//!   selects. `GET /oauth/client-metadata.json` and `/oauth/jwks.json` publish
39//!   the Rust client's identity.
40//! * `POST /account/delete` — revoke the session and drop this DID's local state.
41//! * `GET|POST /beta/redeem` — closed-beta invite redemption.
42//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
43//!   reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
44//!   (sets the reserving cookie → `/login`).
45//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
46//!   code + token/url for the bot to post. Cap-aware (409 when full).
47//! * `POST /admin/invites`, `GET /admin/metrics` — admin-only invite minting and
48//!   the backend metrics view.
49//!
50//! ## Identity — a cookie-resolved atproto session
51//!
52//! Per-request identity comes from a **signed session cookie** (`fr_session`)
53//! keyed by the logged-in DID, set by `oauth_callback` and read by
54//! `current_session` / `current_did`. For local runs without the sidecar,
55//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
56//! All PDS reads and writes route through [`AppState::repo`], which dispatches to
57//! the sidecar or the Rust OAuth client by `FEATHERREADER_REPO_BACKEND`.
58
59use std::collections::HashMap;
60use std::net::IpAddr;
61use std::sync::Mutex;
62use std::time::{Duration, Instant};
63
64use askama::Template;
65use axum::{
66    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
67    http::{header, HeaderMap, StatusCode},
68    middleware::{self, Next},
69    response::{Html, IntoResponse, Redirect, Response},
70    routing::{get, post},
71    Form, Router,
72};
73use serde::Deserialize;
74use std::net::SocketAddr;
75use tower_http::services::{ServeDir, ServeFile};
76use tower_http::set_header::SetResponseHeaderLayer;
77use tower_http::trace::TraceLayer;
78use tracing::{info, warn};
79
80use crate::config::Config;
81use crate::lexicon::{self, Folder, Saved, Subscription};
82use crate::safe_link::SafeLink;
83use crate::{feed, store, AppState, Session, VERSION};
84
85// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
86// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
87// here via an explicit path so the reader's OPML routes can use the canonical
88// `parse_opml` / `to_opml` without duplicating that logic.
89#[path = "opml.rs"]
90mod opml;
91
92/// The name of the signed session cookie.
93const SESSION_COOKIE: &str = "fr_session";
94
95/// The name of the short-lived signed **invite** cookie.
96///
97/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
98/// OAuth callback. It reserves *intent* to redeem a specific code before the
99/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
100/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
101/// signed with the same key as the session cookie.
102const INVITE_COOKIE: &str = "fr_invite";
103
104/// Browser-binding cookie for an in-flight OAuth login (Rust backend only).
105///
106/// `state` alone cannot stop a login CSRF: it lives in a server-global table, so
107/// a stolen `state` replayed from ANOTHER browser matches just as well as from
108/// the one that started the flow. This cookie is what makes the callback
109/// browser-specific — the pending row stores only its hash, and a callback that
110/// cannot present it is refused.
111const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
112
113/// How long an in-flight login may sit, matching the pending row's own TTL.
114const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
115
116/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
117/// Short enough that a reserved-but-unclaimed seat frees quickly.
118const INVITE_TTL_SECS: i64 = 1800;
119
120/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
121/// sign-in pitch, and `/about`.
122const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
123
124/// The tip / support link (cloud plan public-experiment UI).
125const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
126
127/// The published crate on crates.io — surfaced on the signed-out landing page.
128const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
129
130/// The Content-Security-Policy applied to every response.
131///
132/// Tuned to keep the app fully working while neutralising injected script:
133/// * `default-src 'self'` — same-origin baseline.
134/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
135///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
136///   `javascript:` href (F4) cannot execute. (The design's templates carry no
137///   inline event handlers — every control is wired in `keyboard.js`.)
138/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
139///   inline styles htmx toggles for its request indicators.
140/// * `img-src 'self' https: data:` — feed content routinely embeds remote
141///   images; allow https + data URIs but not other schemes.
142/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
143///   down form posts, `<base>` hijacking, and clickjacking.
144/// * `object-src 'none'` — no plugins.
145const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
146     script-src 'self'; \
147     style-src 'self' 'unsafe-inline'; \
148     img-src 'self' https: data:; \
149     font-src 'self'; \
150     connect-src 'self'; \
151     form-action 'self'; \
152     base-uri 'self'; \
153     frame-ancestors 'none'; \
154     object-src 'none'";
155
156/// The resolved identity for the current request.
157///
158/// `did` is the primary key for all per-user local state; `handle` is display
159/// only; `sid` is the opaque server-side session id the cookie carried (needed
160/// so logout can revoke exactly this session). Sourced from the signed cookie
161/// (real login) or, if none, the configured dev DID fallback.
162#[derive(Clone, Debug)]
163struct CurrentUser {
164    did: String,
165    handle: Option<String>,
166    /// The opaque session id, if this identity came from a real cookie session
167    /// (absent for the dev-DID fallback, which has no server-side session row).
168    sid: Option<String>,
169}
170
171/// Resolve the current request's session from the signed cookie, falling back to
172/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
173///
174/// The cookie carries an opaque server-minted session id (not the DID). We
175/// verify its HMAC, look the id up in the registry, and — crucially —
176/// **re-check the DID against the closed-beta gate on every request**
177/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
178/// DID's beta seat takes effect immediately for already-issued cookies. (The
179/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
180/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
181async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
182    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
183        if let Some(session) = state.sessions.get(&sid) {
184            if store::has_beta_access(&state.db, &session.did)
185                .await
186                .unwrap_or(false)
187            {
188                return Some(CurrentUser {
189                    did: session.did,
190                    handle: session.handle,
191                    sid: Some(sid),
192                });
193            }
194            // DID no longer holds a beta seat: treat as logged out (and drop the
195            // stale server-side session so the dead cookie can't linger).
196            state.sessions.remove(&sid);
197        }
198    }
199    // No valid cookie: dev fallback only if explicitly configured *and* still
200    // inside the beta gate (seeded via ensure_seed / a redeemed code).
201    if let Some(did) = state.config.dev_did.clone() {
202        if store::has_beta_access(&state.db, &did)
203            .await
204            .unwrap_or(false)
205        {
206            return Some(CurrentUser {
207                did,
208                handle: None,
209                sid: None,
210            });
211        }
212    }
213    None
214}
215
216/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
217async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
218    current_session(state, headers).await.map(|u| u.did)
219}
220
221/// Build the application router over shared [`AppState`].
222///
223/// Wires the reader routes, the health check, and the `/static` asset mount
224/// (the stylesheet, vendored htmx, and the keyboard handler, served from
225/// `static/` via `ServeDir`). A `TraceLayer` gives per-request tracing.
226pub fn router(state: AppState) -> Router {
227    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
228    // and the write endpoints). One instance is cloned into the state closure of
229    // the `rate_limit` middleware.
230    let limiter = RateLimiter::shared();
231    // The trusted client-IP source for the limiter (a proxy header the operator
232    // controls, or the socket peer when unset). Bundled with the limiter so the
233    // middleware derives a spoof-resistant IP.
234    let rl_state = RateLimitState {
235        limiter,
236        trusted_header: state.config.trusted_ip_header.clone(),
237    };
238
239    Router::new()
240        .route("/health", get(health))
241        .route("/about", get(about))
242        .route("/standard-site", get(standard_site))
243        .route("/stats", get(stats))
244        .route("/privacy", get(privacy))
245        .route("/terms", get(terms))
246        .route("/manage", get(manage))
247        .route("/", get(index))
248        .route("/entries/{id}", get(entry_view))
249        .route("/entries/{id}/read", post(mark_read))
250        .route("/entries/{id}/star", post(toggle_star))
251        .route("/saved/{rkey}/delete", post(unsave_record))
252        .route("/read-all", post(mark_all_read))
253        .route("/subscriptions", post(add_subscription))
254        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
255        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
256        .route("/folders", post(create_folder))
257        .route("/folders/{rkey}/rename", post(rename_folder))
258        .route("/folders/{rkey}/delete", post(delete_folder))
259        // OPML import takes untrusted uploads: cap the body so a huge upload
260        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
261        .route(
262            "/opml",
263            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
264        )
265        .route("/opml/export", get(export_opml))
266        .route("/login", get(login_form).post(login_submit))
267        .route(
268            "/beta/redeem",
269            get(beta_redeem_form).post(beta_redeem_submit),
270        )
271        // The follow→invite bot's claim link: a public skeet points a new
272        // follower here with an opaque token that reserves a pre-minted code.
273        .route("/claim", get(claim))
274        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
275        // code + returns its token/url for the bot to post.
276        .route("/bot/claims", post(bot_mint_claim))
277        .route("/admin/invites", post(admin_mint_invites))
278        .route("/admin/metrics", get(admin_metrics))
279        .route("/oauth/client-metadata.json", get(oauth_client_metadata))
280        .route("/oauth/jwks.json", get(oauth_jwks))
281        .route("/account/delete", post(account_delete))
282        .route("/oauth/callback", get(oauth_callback))
283        .route("/logout", post(logout))
284        .nest_service("/static", ServeDir::new("static"))
285        // Browsers (and some feed clients) request /favicon.ico at the root
286        // regardless of the <link rel="icon"> tags; serve the same icon that
287        // lives under /static so the bare path stops 404-ing.
288        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
289        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
290        // logged-out landing + static assets, `no-store` on anything that
291        // rendered a session's private view. Runs *inside* the security layers so
292        // the CSP/nosniff/frame headers are untouched.
293        .layer(middleware::from_fn(cache_control))
294        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
295        // as a middleware so it sees the matched path + the peer IP.
296        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
297        .layer(TraceLayer::new_for_http())
298        // Baseline security headers on *every* response (F4). The CSP is the
299        // backstop that neutralises any XSS that slips past sanitization; the
300        // others harden sniffing, framing, and referrer leakage.
301        .layer(static_header_layer(
302            "content-security-policy",
303            CONTENT_SECURITY_POLICY,
304        ))
305        .layer(static_header_layer("x-content-type-options", "nosniff"))
306        .layer(static_header_layer(
307            "referrer-policy",
308            "strict-origin-when-cross-origin",
309        ))
310        .layer(static_header_layer("x-frame-options", "DENY"))
311        .with_state(state)
312}
313
314/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
315/// axum's 2 MiB default.**
316///
317/// The value used to BE the framework default, which made the route's own
318/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
319/// nothing could test it, and the ceiling this route wanted was whatever the
320/// framework happened to pick. Sized to this route instead — one outline is
321/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
322/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
323/// larger is not a subscription list.
324///
325/// Being strictly tighter than the default is what makes the layer both real
326/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
327/// uploads a payload that only this limit refuses.
328const OPML_BODY_LIMIT: usize = 1024 * 1024;
329
330/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
331/// uploads a payload between the two ceilings.
332///
333/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
334/// number is exact — axum does not export it, so it cannot be imported. The
335/// exceeding is what the test's mutation demonstrates: with the route's layer
336/// removed, a payload of this size is accepted. If axum ever lowers its
337/// default below ours, that mutation stops failing and the compile-time
338/// assertion below is the thing to revisit.
339#[cfg(test)]
340const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
341
342/// The route's cap must stay strictly tighter than the framework's, or its
343/// layer is a no-op again. A compile error, not a test failure: this is a
344/// property of the two constants, and nothing should be able to build a binary
345/// where it is false.
346#[cfg(test)]
347const _: () = assert!(
348    OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
349    "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
350);
351
352/// A response-header layer that sets `name: value` on every response, overriding
353/// any existing header of that name. `name`/`value` must be valid static header
354/// tokens (they are, for our fixed security headers).
355fn static_header_layer(
356    name: &'static str,
357    value: &'static str,
358) -> SetResponseHeaderLayer<header::HeaderValue> {
359    SetResponseHeaderLayer::overriding(
360        header::HeaderName::from_static(name),
361        header::HeaderValue::from_static(value),
362    )
363}
364
365// ---------------------------------------------------------------------------
366// Per-IP rate limiting (token bucket, self-contained — no extra crate)
367// ---------------------------------------------------------------------------
368
369/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
370/// kick-off and callback, the invite redeem, logout, the mutating write
371/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
372/// intentionally *not* limited.
373///
374/// The criterion is **does this path make an outbound request**, not "does it
375/// mutate" — the two diverge, and every miss so far has been on the outbound
376/// side. This is an allowlist a new route has to be added to by hand, which is
377/// exactly why it has now been missed three times: `/saved/` (fixed), then
378/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
379/// only path here reachable with no session at all.
380///
381/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
382/// make PDS calls but are ordinary authenticated navigation, and throttling them
383/// would degrade normal reading. They are bounded by needing a valid session.
384fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
385    use axum::http::Method;
386    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
387    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
388    // like the other abuse-prone entry points — not just `/login`.
389    // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
390    // real outbound round-trip — a sidecar `resolve_session` or a full token
391    // exchange against a PDS. Anyone could spend one outbound request per hit.
392    // It is the only entry point here that needs no session at all.
393    if method != Method::POST
394        && !(method == Method::GET
395            && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
396    {
397        return false;
398    }
399    match path {
400        // `/logout` and `/oauth/callback` are here because they make outbound
401        // calls, not because they mutate: logout revokes at the PDS (up to two
402        // round-trips) and the callback exchanges a code. The list is by
403        // *network cost*, which is what the limiter is actually for.
404        "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
405        | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
406        | "/folders" => true,
407        // Every per-record subscription/folder mutation (delete/rename) and the
408        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
409        p => {
410            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
411                // Unsaving makes a DPoP-signed deleteRecord round-trip to the
412                // PDS, which is exactly the reason the neighbours above are
413                // limited. It was added as a new route and not added here.
414                || p.starts_with("/saved/")
415                || p.starts_with("/subscriptions/")
416                || p.starts_with("/folders/")
417        }
418    }
419}
420
421/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
422/// client-IP header (if any). Cloned into every request; both fields are cheap.
423#[derive(Clone)]
424struct RateLimitState {
425    limiter: RateLimiter,
426    /// The lowercased proxy header the operator trusts for the client IP, or
427    /// `None` to trust only the socket peer. See [`client_ip`].
428    trusted_header: Option<String>,
429}
430
431/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
432/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
433/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
434/// dependency → no network fetch at build, deterministic offline CI).
435#[derive(Clone)]
436struct RateLimiter {
437    inner: std::sync::Arc<Mutex<RateLimiterState>>,
438}
439
440/// The limiter's shared state: the buckets plus when they were last swept.
441struct RateLimiterState {
442    buckets: HashMap<IpAddr, Bucket>,
443    last_sweep: Instant,
444}
445
446/// One IP's token bucket: a fractional token count + the last-refill instant.
447struct Bucket {
448    tokens: f64,
449    last: Instant,
450}
451
452/// Burst capacity per IP — how many requests can arrive back-to-back.
453const RATE_BURST: f64 = 20.0;
454/// Steady-state refill rate (tokens/sec) once the burst is spent.
455const RATE_REFILL_PER_SEC: f64 = 1.0;
456/// Evict idle buckets older than this so the map can't grow unbounded.
457const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
458
459/// How often the idle sweep may actually run.
460///
461/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
462/// map to find entries that, by construction, can only age out on an hour
463/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
464/// under any volume of distinct source IPs the server spent its single shared
465/// core re-walking a map whose contents had not changed. Once a minute is
466/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
467const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
468
469/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
470/// target, sized so ordinary traffic never reaches it.
471///
472/// The idle eviction above was the only bound, and it is a TIME bound, which
473/// says nothing about how many distinct IPs can arrive inside one hour.
474/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
475/// this one did not.
476const MAX_RATE_BUCKETS: usize = 10_000;
477
478/// When the cap is hit, evict down to this fraction of it rather than removing
479/// a single entry — so the O(n) eviction happens once per `cap/8` requests
480/// instead of once per request while the map sits full.
481const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
482
483impl RateLimiter {
484    /// A fresh, shared limiter (cloned into the middleware state).
485    fn shared() -> Self {
486        Self {
487            inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
488                buckets: HashMap::new(),
489                last_sweep: Instant::now(),
490            })),
491        }
492    }
493
494    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
495    /// bucket is empty (→ 429).
496    fn check(&self, ip: IpAddr) -> bool {
497        self.check_at(ip, Instant::now())
498    }
499
500    /// [`check`](Self::check) with the clock injected, so the sweep and eviction
501    /// paths below are reachable in a test without sleeping through an hour.
502    fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
503        let mut state = match self.inner.lock() {
504            Ok(m) => m,
505            // A poisoned lock shouldn't take the site down — fail open.
506            Err(p) => p.into_inner(),
507        };
508
509        // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
510        if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
511            state
512                .buckets
513                .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
514            state.last_sweep = now;
515        }
516
517        // Hard size bound, independent of the time bound above.
518        //
519        // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
520        // attacker cannot use eviction to clear their OWN throttled bucket: that
521        // bucket is by definition the most recently touched, so it is the last
522        // thing this removes. Going quiet long enough to become the oldest entry
523        // is exactly what the refill already grants for free.
524        if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
525            let mut by_age: Vec<(IpAddr, Instant)> =
526                state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
527            by_age.sort_unstable_by_key(|(_, last)| *last);
528            for (victim, _) in by_age
529                .into_iter()
530                .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
531            {
532                state.buckets.remove(&victim);
533            }
534            warn!(
535                buckets = state.buckets.len(),
536                "rate-limit bucket cap reached; evicted the least recently seen clients"
537            );
538        }
539
540        let bucket = state.buckets.entry(ip).or_insert(Bucket {
541            tokens: RATE_BURST,
542            last: now,
543        });
544        let elapsed = now.duration_since(bucket.last).as_secs_f64();
545        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
546        bucket.last = now;
547        if bucket.tokens >= 1.0 {
548            bucket.tokens -= 1.0;
549            true
550        } else {
551            false
552        }
553    }
554}
555
556/// The **trusted** client IP for a request.
557///
558/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
559/// is fully bypassable — the left-most value is attacker-supplied (any client
560/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
561/// bucket and the per-IP limit never bites. We therefore derive the IP only from
562/// a source the operator controls:
563///
564/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
565///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
566///   set by the proxy we run in front and overwrites any client-supplied copy.
567///   We take the LAST value if the header happens to be a comma list (the hop
568///   the trusted proxy appended), which is also the correct read for a
569///   right-most-`X-Forwarded-For` deployment where the operator points
570///   `trusted_header` at `x-forwarded-for`.
571/// * Otherwise we ignore all forwarding headers and use the socket peer
572///   (`ConnectInfo`) — correct for a direct bind with no proxy.
573///
574/// Returns `None` only when neither source yields a parseable IP (the limiter
575/// then fails open for that one request).
576fn client_ip(
577    headers: &HeaderMap,
578    conn: Option<&SocketAddr>,
579    trusted_header: Option<&str>,
580) -> Option<IpAddr> {
581    if let Some(name) = trusted_header {
582        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
583            // Right-most hop is the one the trusted proxy appended; earlier
584            // entries may be client-forged, so never trust the left-most.
585            if let Some(last) = raw.split(',').next_back() {
586                if let Ok(ip) = last.trim().parse::<IpAddr>() {
587                    return Some(ip);
588                }
589            }
590        }
591        // Trusted header absent/unparseable → fall through to the socket peer.
592    }
593    conn.map(|s| s.ip())
594}
595
596/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
597/// empty; every other request (and every non-guarded path) passes through. The
598/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
599/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
600async fn rate_limit(
601    State(rl): State<RateLimitState>,
602    req: axum::extract::Request,
603    next: Next,
604) -> Response {
605    let path = req.uri().path().to_string();
606    let method = req.method().clone();
607    if is_rate_limited_path(&path, &method) {
608        let conn = req
609            .extensions()
610            .get::<ConnectInfo<SocketAddr>>()
611            .map(|c| c.0);
612        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
613        // Deliberately fail OPEN when no client IP is derivable (no trusted
614        // header / no socket peer): there is no per-IP key to enforce, and a
615        // blanket 429 would self-DoS every guarded path (incl. /login). This is
616        // safe precisely because we never key on an attacker-forged XFF — see
617        // `rate_limit_ignores_spoofed_xff_rotation`.
618        if let Some(ip) = ip {
619            if !rl.limiter.check(ip) {
620                warn!(%ip, %path, "rate limit exceeded");
621                return (
622                    StatusCode::TOO_MANY_REQUESTS,
623                    [(header::RETRY_AFTER, "1")],
624                    "rate limit exceeded\n",
625                )
626                    .into_response();
627            }
628        }
629    }
630    next.run(req).await
631}
632
633// ---------------------------------------------------------------------------
634// Cache-Control (viral / CDN vs. private authenticated views)
635// ---------------------------------------------------------------------------
636
637/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
638/// logged-out surfaces (the `/login` landing without a handle, `/about`,
639/// `/standard-site`, `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
640/// authenticated app pages, so a CDN /
641/// browser can hold the viral landing while never caching a signed-in user's
642/// private view. Never overrides a handler that already set Cache-Control.
643async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
644    let path = req.uri().path().to_string();
645    // The logged-out landing is only cacheable when it's the bare form — a
646    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
647    let is_login_landing = path == "/login"
648        && req.method() == axum::http::Method::GET
649        && !req.uri().query().unwrap_or("").contains("handle=");
650    let public = is_login_landing
651        || path == "/about"
652        || path == "/standard-site"
653        || path == "/privacy"
654        || path == "/terms"
655        || path.starts_with("/static/");
656
657    let mut resp = next.run(req).await;
658    if resp.headers().contains_key(header::CACHE_CONTROL) {
659        return resp;
660    }
661    let value = if public {
662        "public, max-age=300"
663    } else {
664        "no-store"
665    };
666    if let Ok(hv) = header::HeaderValue::from_str(value) {
667        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
668    }
669    resp
670}
671
672// ---------------------------------------------------------------------------
673// Health
674// ---------------------------------------------------------------------------
675
676/// Run `/health`'s database probe. **The single path, so a test cannot assert
677/// on a string the handler is free to ignore** — a named constant alone was not
678/// enough: the test read the constant while the handler passed `query_scalar`
679/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
680async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
681    sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
682        .fetch_optional(pool)
683        .await
684}
685
686/// The statement `/health` uses to prove the database is readable.
687///
688/// **A named constant so the test can assert on the query that actually runs.**
689/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
690/// this string, so degrading the real probe to `SELECT 1` — which opens no page
691/// and therefore cannot detect a broken database — left the suite green.
692const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
693
694/// How long `/health` will wait for its database ping before calling it broken.
695///
696/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
697/// handler chose rather than a timeout Fly inferred — the difference between a
698/// log line that says why and one that says nothing.
699const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
700
701/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
702/// see the handler for why.
703///
704/// The threshold itself is derived from the configured tick
705/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
706/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
707/// stale` in the body the deployment docs now tell them to alert on.
708const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
709
710/// How long without a completed tick before the poller reads as stale: several
711/// tick intervals, floored, so a normally-paced loop never trips it and a
712/// genuinely wedged one always does.
713fn health_tick_stale_secs(tick: Duration) -> i64 {
714    let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
715    tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
716}
717
718/// The poll tick this instance is configured for. Read from the same env var
719/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
720/// handler cannot see its constants.
721fn configured_poll_tick() -> Duration {
722    std::env::var("FEATHERREADER_POLL_TICK_SECS")
723        .ok()
724        .and_then(|v| v.trim().parse::<u64>().ok())
725        .filter(|s| *s > 0)
726        .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
727}
728
729/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
730/// so cannot be imported here. Duplicated deliberately and named, rather than
731/// left as a bare `60` inside the parse chain, so the drift is at least visible
732/// if the scheduler's value ever moves.
733const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
734
735/// Grace period after boot before a poller that has never ticked is called
736/// `stale` rather than `not-yet-ticked`.
737///
738/// Without this the two are indistinguishable forever, which matters precisely
739/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
740/// cycles the poller never reaches its first tick, so `/health` reported the
741/// benign `not-yet-ticked` on every single probe and the heartbeat could not
742/// detect the failure mode it exists for. `run_poller` returning early — a failed
743/// HTTP client build — has the same shape and was equally invisible.
744///
745/// Sized off the poller's own startup delay plus its tick, with slack.
746const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
747
748/// `GET /health` — does this process still work, and what are its loops doing?
749///
750/// This used to return a constant string, touching no database, no pool and no
751/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
752/// sole other failure detector is a child process exiting. It proved the HTTP
753/// listener was up and nothing else.
754///
755/// **What can fail the check: the database, and only the database.** A process
756/// that cannot reach its store serves nothing, so a restart is the right
757/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
758/// mode is not blocked by any writer — so the retention sweep, the poller and a
759/// login burst cannot make this flap. That property is the reason it is a read
760/// and not, say, a write canary.
761///
762/// **What is reported but never fails the check: everything else.** A stale poll
763/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
764/// and none of them a reason to stop serving.
765///
766/// That last clause is the whole justification, and it is NOT the one this
767/// comment used to give. It said "Fly restarts on a failed check", which is
768/// false — verified against Fly's own docs, which state it three times: *"your
769/// Machines won't automatically restart or stop due to failing their health
770/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
771/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
772/// V1 (`restart_limit`) and has no successor on Machines.
773///
774/// The corrected model makes the conclusion stronger, not weaker. With one
775/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
776/// failover — it is a total outage that lasts exactly as long as the condition,
777/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
778/// question the status code answers is not "would a restart fix this" but **"can
779/// this process still serve a useful request at all"**. A stale poller can. A
780/// database it cannot read cannot.
781///
782/// Re-registration is automatic: the proxy keeps probing and routes again the
783/// moment the check passes. That is what makes a 503 recoverable without
784/// intervention — not a restart, which never comes.
785///
786/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
787/// it is publishable on the same terms as `/stats`. It is also the non-session
788/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
789/// (which needs a live admin session) is exactly as unreachable as the thing it
790/// would diagnose, while this is reachable with `curl`.
791async fn health(State(state): State<AppState>) -> Response {
792    let now = chrono::Utc::now().timestamp();
793    let rh = &state.runtime_health;
794
795    use crate::runtime_health::DbProbe;
796    let db = match rh.begin_db_probe() {
797        // A probe is already in flight; report its predecessor rather than
798        // starting a second one. See `RuntimeHealth::begin_db_probe`.
799        Err(borrowed) => borrowed,
800        Ok(probe) => {
801            // **Spawned, so the probe cannot be cancelled by the caller.**
802            //
803            // Axum drops the handler future when a client disconnects. With the
804            // probe inline, that dropped it mid-flight and released the claim
805            // WITHOUT recording a verdict — which let an unauthenticated caller
806            // manufacture the no-verdict state on demand and freeze what every
807            // other caller, Fly's check included, reads. Running it detached
808            // means the verdict is always recorded and the claim is always
809            // released after it.
810            let pool = state.db.clone();
811            let task = tokio::spawn(async move {
812                // **`SELECT 1` was not a database probe.** It compiles to
813                // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
814                // never touches a b-tree, never reads a page, and never consults
815                // the file. Against a corrupted database it returns success
816                // while every real query returns SQLITE_CORRUPT. Reading one row
817                // from a real table costs the same and actually proves what the
818                // check claims. `LIMIT 1` keeps it to a single page; an empty
819                // table still opens the b-tree root, which is the part that
820                // matters.
821                let verdict =
822                    match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
823                        Ok(Ok(_)) => DbProbe::Ok,
824                        // Coarse, not the raw error. An unauthenticated caller
825                        // learning exactly which failure it hit is an
826                        // attack-progress oracle; the detail belongs in the log,
827                        // which gets it here.
828                        Ok(Err(err)) => {
829                            warn!(%err, "health: database probe failed");
830                            DbProbe::Failed("unavailable".to_string())
831                        }
832                        Err(_) => {
833                            warn!(
834                                timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
835                                "health: database probe timed out (pool exhausted?)"
836                            );
837                            DbProbe::Failed("timeout".to_string())
838                        }
839                    };
840                probe.record(verdict.clone());
841                verdict
842            });
843            // A panicking task drops the guard, which releases the claim without
844            // a verdict — the only remaining path to that state, and not one a
845            // caller can drive.
846            task.await.unwrap_or(DbProbe::Unknown)
847        }
848    };
849
850    let uptime = rh.uptime_secs(now);
851    let poller = if !rh.schedulers_enabled() {
852        // Not a fault. Dev runs and the seam tests disable the loops on purpose,
853        // and reporting that as "stale" would be a false alarm on every one.
854        "disabled".to_string()
855    } else {
856        match rh.secs_since_poll_tick(now) {
857            // "Never ticked" is benign right after boot and alarming well after
858            // it — so it is read against UPTIME, not left permanently benign.
859            None => match uptime {
860                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
861                    format!("stale never-ticked {up}s")
862                }
863                _ => "not-yet-ticked".to_string(),
864            },
865            Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
866                format!("stale {secs}s")
867            }
868            Some(secs) => format!("ok {secs}s"),
869        }
870    };
871
872    // **Only a MEASURED failure fails the check.**
873    //
874    // `Unknown` means no probe has completed — a concurrent request arrived
875    // before the first one finished, or a previous owner was cancelled before
876    // recording. It is reported and returns 200, because an unmeasured database
877    // is not evidence of a broken one, and this endpoint is reachable by
878    // unauthenticated callers who can manufacture that state. Treating it as a
879    // failure handed them a lever on the only signal the platform acts on.
880    let mut body = String::new();
881    let status = match &db {
882        DbProbe::Ok => {
883            body.push_str(&format!("ok featherreader/{VERSION}\n"));
884            body.push_str("db: ok\n");
885            StatusCode::OK
886        }
887        // **Not `ok`.** The first token is the state, and this one is neither
888        // healthy nor failed. It used to print a line byte-identical to the
889        // healthy branch, which mattered because `fly.toml` tells operators to
890        // alert on the BODY for everything the status code deliberately ignores
891        // — so a monitor keying on `^ok` read green in exactly the state this
892        // enum exists to make visible.
893        DbProbe::Unknown => {
894            body.push_str(&format!("unknown featherreader/{VERSION}\n"));
895            body.push_str("db: unknown (no probe has completed yet)\n");
896            StatusCode::OK
897        }
898        DbProbe::Failed(why) => {
899            body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
900            body.push_str(&format!("db: {why}\n"));
901            StatusCode::SERVICE_UNAVAILABLE
902        }
903    };
904    // Uptime answers the first question anyone asks about a container under a
905    // supervisor that tears the machine down whenever a child exits: is this
906    // thing restarting? Nothing else on any surface could tell you.
907    body.push_str(&format!(
908        "uptime: {}\n",
909        match uptime {
910            Some(secs) => format!("{secs}s"),
911            None => "unknown".to_string(),
912        }
913    ));
914    body.push_str(&format!("poller: {poller}\n"));
915    body.push_str(&format!(
916        "polling-paused: {}\n",
917        if rh.watermark_paused() { "yes" } else { "no" }
918    ));
919    // Deliberately NOT the measured database size. `/health` is the one path
920    // exempted from the Caddy origin lock, so it answers direct hits to the Fly
921    // IP that never passed Cloudflare — which caps what belongs here at the
922    // class of facts `/stats` already publishes to anyone. "Polling is paused"
923    // is that; the exact byte count is a precise internal number that adds
924    // nothing an operator cannot get from `/stats` or the logs.
925    body.push_str(&format!(
926        "backend: {}\n",
927        state.config.repo_backend.as_str()
928    ));
929    body.push_str(&format!(
930        "oauth-runtime: {}\n",
931        if state.oauth.is_some() {
932            "built"
933        } else {
934            "absent"
935        }
936    ));
937
938    // Never cached: a stale health response is worse than none, and Cloudflare
939    // sits in front of this.
940    let mut resp = (status, body).into_response();
941    if let Ok(hv) = header::HeaderValue::from_str("no-store") {
942        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
943    }
944    resp
945}
946
947/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
948/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
949/// Readable whether or not a session exists.
950///
951/// Optionally carries one quiet line about network adoption
952/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
953/// default — the handler issues **zero** queries and the page is byte-identical
954/// to what it was before the probe existed.
955async fn about(State(state): State<AppState>) -> Response {
956    let adoption = if state.config.show_adoption {
957        adoption_line(&state).await
958    } else {
959        None
960    };
961    render(&AboutTemplate {
962        card: Card::public(
963            &state.config,
964            "/about",
965            "About — FeatherReader",
966            "What FeatherReader is and isn't: an open-source, atproto-native reader for \
967             RSS feeds and standard.site publications, run as an experiment, free to \
968             self-host under the AGPL.",
969        ),
970        version: VERSION,
971        repo_url: REPO_URL,
972        kofi_url: KOFI_URL,
973        adoption,
974        standard_site: state.config.standard_site,
975    })
976}
977
978/// `GET /standard-site` — the public feature page for standard.site
979/// publications: what a publication is, what FeatherReader shows from one, how
980/// to subscribe, the limits, and the latest releases. Readable whether or not
981/// a session exists, like `/about`. Every how-to-subscribe line is conditional
982/// on `Config::standard_site`, as on the other public pages.
983async fn standard_site(State(state): State<AppState>) -> Response {
984    render(&StandardSiteTemplate {
985        card: Card::public(
986            &state.config,
987            "/standard-site",
988            "standard.site — FeatherReader",
989            "Read standard.site publications beside your RSS feeds: articles \
990             published as atproto records, followed with the same portable \
991             subscription record.",
992        ),
993        version: VERSION,
994        repo_url: REPO_URL,
995        kofi_url: KOFI_URL,
996        standard_site: state.config.standard_site,
997        releases: RELEASES,
998    })
999}
1000
1001/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
1002///
1003/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
1004/// does not have. This deletes the record straight from the repo by its rkey,
1005/// and then clears any LOCAL star for the same article.
1006///
1007/// That second step is not belt-and-braces. "Has no local entry" is how the
1008/// starred view classifies a record, and it decides that through `sub_ref` — so
1009/// an article that really is cached, and really is starred, lands here whenever
1010/// the reader has unsubscribed from its feed. Deleting only the record left
1011/// `entry_state.starred = 1` behind: invisible, because the starred list is
1012/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
1013/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
1014/// places it lives.
1015async fn unsave_record(
1016    State(state): State<AppState>,
1017    headers: HeaderMap,
1018    Path(rkey): Path<String>,
1019) -> Response {
1020    let Some(did) = current_did(&state, &headers).await else {
1021        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1022    };
1023
1024    // Read the record's identity BEFORE deleting it — afterwards there is
1025    // nothing left to learn it from. Best-effort: a failure here must not block
1026    // the deletion the reader actually asked for, so it degrades to the old
1027    // behaviour (record gone, local star possibly stale) and says so.
1028    let identity = match state.repo().list_saved(&did).await {
1029        Ok(records) => records
1030            .into_iter()
1031            .find(|(k, _)| *k == rkey)
1032            .map(|(_, rec)| (rec.url, rec.entry_id)),
1033        Err(err) => {
1034            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1035                                      a local star for the same article may survive");
1036            None
1037        }
1038    };
1039
1040    match state.repo().remove_saved(&did, &rkey).await {
1041        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1042        Err(err) => {
1043            warn!(%err, %did, %rkey, "could not remove the saved record");
1044            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1045        }
1046    }
1047
1048    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1049    // saved, so clearing the local star before knowing the record is gone would
1050    // be the desync in the other direction.
1051    if let Some((url, guid)) = identity {
1052        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1053            Ok(0) => {}
1054            Ok(n) => {
1055                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1056            }
1057            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1058        }
1059    }
1060    // htmx swaps the row out; a plain form post goes back to the starred list.
1061    if is_htmx(&headers) {
1062        return (StatusCode::OK, "").into_response();
1063    }
1064    Redirect::to("/?view=starred").into_response()
1065}
1066
1067/// What the poller is doing, as one word for `/stats`.
1068///
1069/// **Parity with `/health` is the point.** `polling_paused` alone reported
1070/// "running" for three different states including the two where nothing polls,
1071/// on the page added to answer exactly that. The first attempt at fixing it
1072/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1073/// poll loop that ticked once at boot and then WEDGED still read as running.
1074/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1075/// original finding's exact shape surviving its own fix.
1076///
1077/// Shares the staleness threshold with `/health` rather than picking its own, so
1078/// the two pages cannot disagree about what "stale" means.
1079fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1080    if !rh.schedulers_enabled() {
1081        return "off";
1082    }
1083    // Checked before the pause: a wedged poller cannot clear a pause either, so
1084    // reporting "paused" would name the symptom and hide the cause.
1085    match rh.secs_since_poll_tick(now_unix) {
1086        None => {
1087            // Never ticked. Benign at boot, a dead loop long after — read
1088            // against uptime, exactly as `/health` does.
1089            match rh.uptime_secs(now_unix) {
1090                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1091                _ => "starting",
1092            }
1093        }
1094        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1095        _ if rh.watermark_paused() => "paused",
1096        _ => "running",
1097    }
1098}
1099
1100/// `GET /stats` — public poll health.
1101async fn stats(State(state): State<AppState>) -> Response {
1102    let now = chrono::Utc::now();
1103    let health = match store::poll_health(
1104        &state.db,
1105        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1106        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1107    )
1108    .await
1109    {
1110        Ok(health) => health,
1111        Err(err) => {
1112            warn!(%err, "could not compute poll health");
1113            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1114        }
1115    };
1116
1117    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1118    // instance is not behind on anything.
1119    let polled_pct = if health.feeds_tracked == 0 {
1120        100
1121    } else {
1122        health.polled_last_hour * 100 / health.feeds_tracked
1123    };
1124
1125    render(&StatsTemplate {
1126        card: Card::public(
1127            &state.config,
1128            "/stats",
1129            "Stats — FeatherReader",
1130            "Is this instance's poller keeping up? Aggregate feed-polling health — \
1131             counts only; no feed and no reader is named.",
1132        ),
1133        version: VERSION,
1134        repo_url: REPO_URL,
1135        kofi_url: KOFI_URL,
1136        feeds_tracked: health.feeds_tracked,
1137        polled_last_hour: health.polled_last_hour,
1138        polled_pct,
1139        overdue: health.overdue,
1140        last_poll: humanise_ago(health.last_poll_secs_ago),
1141        oldest_poll: if health.never_polled > 0 {
1142            "never".to_string()
1143        } else {
1144            humanise_ago(health.oldest_poll_secs_ago)
1145        },
1146        never_polled: health.never_polled,
1147        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1148        // **The two states that actually stop feeds updating.**
1149        //
1150        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1151        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1152        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1153        // feed failing every fetch drops out of the backlog and makes the page
1154        // read healthier. Both of these are machine facts with no per-feed
1155        // detail, so they sit inside the page's stated contract.
1156        in_backoff: health.in_backoff,
1157        badly_broken: health.badly_broken,
1158        failure_kinds: health.failure_kinds,
1159        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1160    })
1161}
1162
1163/// "3h 11m ago", or "never" when there has been no poll at all.
1164///
1165/// `None` must not render as `0` — on a fresh instance that would read as
1166/// "polled just now", which is the opposite of the truth.
1167fn humanise_ago(secs: Option<i64>) -> String {
1168    let Some(secs) = secs else {
1169        return "never".to_string();
1170    };
1171    match secs {
1172        s if s < 60 => format!("{s}s ago"),
1173        s if s < 3600 => format!("{}m ago", s / 60),
1174        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1175    }
1176}
1177
1178/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1179/// observation of zero, or a store failure).
1180///
1181/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1182/// probe is never allowed to affect the reader, and that rule applies at the
1183/// display end too — a locked or corrupt DB costs the About page one log line,
1184/// not a 500.
1185async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1186    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1187        // A legitimate zero renders nothing rather than a sad "0 accounts".
1188        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1189            repos: stat.value,
1190            truncated: stat.truncated,
1191            observed_on: stat
1192                .observed_at
1193                .split('T')
1194                .next()
1195                .unwrap_or_default()
1196                .to_string(),
1197        }),
1198        Ok(_) => None,
1199        Err(err) => {
1200            warn!(%err, "about: adoption stat read failed; omitting the line");
1201            None
1202        }
1203    }
1204}
1205
1206/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1207/// lives in the user's PDS, what the server caches, and the session-token
1208/// handling. A static render; readable whether or not a session exists.
1209async fn privacy(State(state): State<AppState>) -> Response {
1210    render(&PrivacyTemplate {
1211        card: Card::public(
1212            &state.config,
1213            "/privacy",
1214            "Privacy — FeatherReader",
1215            "No account and no tracking: your subscriptions and reading state live in \
1216             your own PDS. What this server caches, for how long, and how the session \
1217             token is handled.",
1218        ),
1219        version: VERSION,
1220        repo_url: REPO_URL,
1221        kofi_url: KOFI_URL,
1222    })
1223}
1224
1225/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1226/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1227/// static render; readable whether or not a session exists.
1228async fn terms(State(state): State<AppState>) -> Response {
1229    render(&TermsTemplate {
1230        card: Card::public(
1231            &state.config,
1232            "/terms",
1233            "Terms — FeatherReader",
1234            "The terms of use: an experimental service offered as-is with no warranty, \
1235             what acceptable use means here, and the AGPL self-host note.",
1236        ),
1237        version: VERSION,
1238        repo_url: REPO_URL,
1239        kofi_url: KOFI_URL,
1240    })
1241}
1242
1243// ---------------------------------------------------------------------------
1244// View models
1245// ---------------------------------------------------------------------------
1246
1247/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1248/// and the PDS subscription rkey for management actions).
1249struct FeedView {
1250    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1251    rkey: String,
1252    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1253    url: String,
1254    title: String,
1255    unread: i64,
1256    /// Whether this feed is the currently-selected scope.
1257    selected: bool,
1258    /// The feed's current folder `at://` URI (from its subscription record), or
1259    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1260    /// rename row so an untouched folder dropdown does not silently un-folder the
1261    /// feed on save.
1262    folder: Option<String>,
1263}
1264
1265/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1266struct FolderView {
1267    /// PDS folder rkey — addresses the record for rename/delete.
1268    rkey: String,
1269    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1270    uri: String,
1271    name: String,
1272    feeds: Vec<FeedView>,
1273    /// Whether this folder is the currently-selected scope.
1274    selected: bool,
1275}
1276
1277/// One entry as shown in the article list / after an htmx swap.
1278struct EntryRow {
1279    id: i64,
1280    title: String,
1281    feed_title: String,
1282    published: String,
1283    read: bool,
1284    starred: bool,
1285    /// The reader link href, already carrying the scope/view query so opening an
1286    /// entry and paging back stays within the list it came from.
1287    link: SafeLink,
1288    /// Whether the article itself is in this instance's cache.
1289    ///
1290    /// `false` for a saved record that exists in the reader's PDS but whose
1291    /// entry was never cached here — starred in another atproto reader, or
1292    /// starred here and since evicted. There is no local row, so the row has no
1293    /// usable `id`: it links straight out to the article and carries no
1294    /// mark-read control, because there is nothing local to mark.
1295    cached: bool,
1296    /// The PDS record key, for un-saving a row that has no local entry.
1297    rkey: String,
1298}
1299
1300/// A folder as an option in the "move feed to folder" select.
1301struct FolderOption {
1302    uri: String,
1303    name: String,
1304}
1305
1306/// The shared navigation "rail" model: the same DOM element is the
1307/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1308/// manage) renders it from this one struct. Feed management lives on `/manage`,
1309/// not here — the rail is navigation only.
1310struct Nav {
1311    /// `@handle` for the identity chip (falls back to the DID's tail).
1312    handle: String,
1313    /// Two-letter avatar initials for the identity chip.
1314    avatar: String,
1315    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1316    view: String,
1317    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1318    /// empty for the unscoped "everything" views.
1319    scope_qs: String,
1320    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1321    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1322    folders: Vec<FolderView>,
1323    loose_feeds: Vec<FeedView>,
1324    /// Whether the "Manage feeds" rail tool is the current page.
1325    manage_active: bool,
1326}
1327
1328/// The subscribe input's `pattern` when standard.site is on, and the input is
1329/// `type="text"` (see `templates/manage.html`). It keeps the browser asking for
1330/// a scheme, as `type="url"` did, while admitting `at://`. Matched in any case,
1331/// because the handler canonicalises the scheme. Browsers compile `pattern`
1332/// with the `v` flag and anchor it at both ends. Unlike `type="url"`, a text
1333/// input does not strip surrounding whitespace before checking, so the pattern
1334/// allows it: a URL pasted with a leading space is common, and the handler
1335/// trims it.
1336pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1337
1338// ---------------------------------------------------------------------------
1339// Link cards (Open Graph / Twitter / Bluesky)
1340// ---------------------------------------------------------------------------
1341
1342/// The site's own title: the landing page's, and the one every private view
1343/// shows instead of its own.
1344const SITE_TITLE: &str = "FeatherReader — read, quietly";
1345
1346/// The site's one-paragraph description: the landing page's, and the one every
1347/// private view shows instead of its own.
1348const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1349standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1350password, no tracking.";
1351
1352/// Where the share image is served, relative to the public origin. The file is
1353/// `static/social-card.png`, rendered from `static/social-card.svg` by
1354/// `scripts/social-card.sh`; `base.html` advertises its dimensions, and a test
1355/// checks the PNG's own header agrees.
1356const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1357
1358/// What a link to a page unfurls as when it is posted — on Bluesky, in a chat,
1359/// anywhere that reads Open Graph tags. `base.html` renders it into `<head>`.
1360///
1361/// Measured before this existed: Bluesky's card service
1362/// (`cardyb.bsky.app/v1/extract?url=https://feather-reader.com/`) returned
1363/// `{"title":"FeatherReader — read, quietly","description":"","image":""}`,
1364/// because `<title>` was the only tag it could find. Card fetchers read the
1365/// initial HTML server-side, run no JS, and resolve nothing relative, so every
1366/// URL here is absolute on [`Config::public_url`] — `https://feather-reader.com`
1367/// in production, whatever `FEATHERREADER_PUBLIC_URL` says elsewhere.
1368#[derive(Debug, Clone)]
1369pub(crate) struct Card {
1370    /// `og:title`. On a public page, the same text as its `<title>`.
1371    pub title: String,
1372    /// `og:description` and `<meta name="description">`: one or two plain
1373    /// sentences about THIS page, not the site.
1374    pub description: String,
1375    /// `og:url` and `<link rel="canonical">`: absolute, on the public origin.
1376    pub url: String,
1377    /// `og:image`: absolute, on the public origin.
1378    pub image: String,
1379    /// Set on a page that renders a session's private view. The card is then
1380    /// the site's generic one — nothing from the view reaches `<head>` — and
1381    /// the page is `noindex`.
1382    pub private: bool,
1383}
1384
1385impl Card {
1386    /// The card of the public page at `path` (leading slash) on this instance.
1387    fn public(
1388        config: &Config,
1389        path: &str,
1390        title: impl Into<String>,
1391        description: impl Into<String>,
1392    ) -> Self {
1393        let origin = config.public_url.trim_end_matches('/');
1394        Card {
1395            title: title.into(),
1396            description: description.into(),
1397            url: format!("{origin}{path}"),
1398            image: format!("{origin}{SHARE_IMAGE_PATH}"),
1399            private: false,
1400        }
1401    }
1402
1403    /// The landing page's card: the site's own title and description.
1404    fn site(config: &Config) -> Self {
1405        Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1406    }
1407
1408    /// The card of a page that renders a session's private view: the site's
1409    /// generic card pointing at the front door, plus `noindex`. The view's
1410    /// heading, feed names and handle stay out of `<head>`.
1411    fn private(config: &Config) -> Self {
1412        Card {
1413            private: true,
1414            ..Card::site(config)
1415        }
1416    }
1417}
1418
1419/// The reader index (`GET /`).
1420#[derive(Template)]
1421#[template(path = "index.html")]
1422struct IndexTemplate {
1423    /// The link card. A private view: the site's generic card, `noindex`.
1424    card: Card,
1425    version: &'static str,
1426    repo_url: &'static str,
1427    kofi_url: &'static str,
1428    flash: String,
1429    /// Shown as `role="alert"` when the subscription list is the cached one
1430    /// because the PDS listing failed; empty otherwise.
1431    alert: String,
1432    /// The shared rail (drawer + desktop sidebar) navigation model.
1433    nav: Nav,
1434    /// The article list for the selected scope + view.
1435    entries: Vec<EntryRow>,
1436    /// The list heading (the selected view/feed/folder name).
1437    heading: String,
1438    /// Whether a feed scope is active (enables per-feed mark-all-read).
1439    feed_scope: Option<String>,
1440    /// Total CACHED entries in this scope + view across ALL pages. The count used
1441    /// to be `entries.len()`, which was the same number only because the list was
1442    /// unpaged — the thing this change exists to stop.
1443    ///
1444    /// The pager is derived from this, so it must not include the uncached PDS
1445    /// rows below: they are appended to the last page rather than paged, and
1446    /// counting them here advertised a page the clamp could never reach.
1447    total: i64,
1448    /// How many of `total` are PDS saved records the cache cannot show.
1449    ///
1450    /// A subset of `total`, not an addition to it — the heading says "N entries
1451    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1452    /// which double counted once `total` started including them, against an M
1453    /// that had become page-local in the same commit while the template stayed
1454    /// put.
1455    uncached_total: i64,
1456    /// 1-based current page.
1457    page: i64,
1458    /// Total pages, at least 1 (an empty list is page 1 of 1).
1459    page_count: i64,
1460    /// Link to the previous (newer) page, or `None` on the first.
1461    prev_href: Option<String>,
1462    /// Link to the next (older) page, or `None` on the last.
1463    next_href: Option<String>,
1464}
1465
1466/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1467#[derive(Template)]
1468#[template(path = "manage.html")]
1469struct ManageTemplate {
1470    /// The link card. A private view: the site's generic card, `noindex`.
1471    card: Card,
1472    version: &'static str,
1473    repo_url: &'static str,
1474    kofi_url: &'static str,
1475    flash: String,
1476    /// See [`IndexTemplate::alert`].
1477    alert: String,
1478    nav: Nav,
1479    /// All folders as move-targets for the subscribe folder select.
1480    folder_options: Vec<FolderOption>,
1481    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1482    folders: Vec<FolderView>,
1483    loose_feeds: Vec<FeedView>,
1484    /// `Config::standard_site`. With it on, the subscribe form says a
1485    /// `site.standard.publication` URI is accepted and its input drops
1486    /// `type="url"`, whose browser validation rejects the DID form. With it off
1487    /// `add_subscription` refuses every `at://` paste, so the form must not
1488    /// advertise one.
1489    standard_site: bool,
1490}
1491
1492/// The optional one-line adoption fact at the bottom of `/about`
1493/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1494/// probe has succeeded yet, or the read failed — the line then simply does not
1495/// render.
1496struct AdoptionLine {
1497    /// Repos a relay has indexed as holding the subscription collection.
1498    repos: i64,
1499    /// The probe hit its page cap, so the copy must say "at least".
1500    truncated: bool,
1501    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1502    observed_on: String,
1503}
1504
1505/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1506/// the optional adoption line.
1507#[derive(Template)]
1508#[template(path = "about.html")]
1509struct AboutTemplate {
1510    /// The link card: this page's own title and description.
1511    card: Card,
1512    version: &'static str,
1513    repo_url: &'static str,
1514    kofi_url: &'static str,
1515    adoption: Option<AdoptionLine>,
1516    /// `Config::standard_site`: whether the publications section may tell the
1517    /// reader how to subscribe to one here. See [`ManageTemplate::standard_site`].
1518    standard_site: bool,
1519}
1520
1521/// The public `/standard-site` feature page. Carries the same footer fields
1522/// as the other public pages, the standard.site flag, and the release list for
1523/// the "latest releases" call-out.
1524#[derive(Template)]
1525#[template(path = "standard_site.html")]
1526struct StandardSiteTemplate {
1527    /// The link card: this page's own title and description.
1528    card: Card,
1529    version: &'static str,
1530    repo_url: &'static str,
1531    kofi_url: &'static str,
1532    /// `Config::standard_site`: whether the page may tell a visitor how to
1533    /// subscribe to a publication here. See [`ManageTemplate::standard_site`].
1534    standard_site: bool,
1535    /// [`RELEASES`], newest first, for `templates/releases.html`.
1536    releases: &'static [Release],
1537}
1538
1539/// One tagged release, as the "latest releases" call-out
1540/// (`templates/releases.html`) shows it on `/standard-site` and the landing
1541/// page. The links are derived from `version` and `date`, so a release is
1542/// described in exactly one place: an entry in [`RELEASES`].
1543pub(crate) struct Release {
1544    /// The crate version, without the `v` (`"0.4.1"`). The tag is `v{version}`.
1545    pub(crate) version: &'static str,
1546    /// The release date, `YYYY-MM-DD`, as the CHANGELOG heading has it.
1547    pub(crate) date: &'static str,
1548    /// One or two plain sentences for a visitor. No markup: the template escapes it.
1549    pub(crate) summary: &'static str,
1550}
1551
1552impl Release {
1553    /// The GitHub release page: `{REPO_URL}/releases/tag/v{version}`.
1554    pub(crate) fn url(&self) -> String {
1555        format!("{REPO_URL}/releases/tag/v{}", self.version)
1556    }
1557
1558    /// The release's section of `CHANGELOG.md` on `main`. GitHub derives the
1559    /// anchor for a heading `## 0.4.1 — 2026-10-04` as `041--2026-10-04`: the
1560    /// dots dropped, the em dash dropped, each space a hyphen.
1561    pub(crate) fn changelog_url(&self) -> String {
1562        format!(
1563            "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1564            self.version.replace('.', ""),
1565            self.date
1566        )
1567    }
1568}
1569
1570/// **The one place a release is described for the website.** Newest first.
1571/// To announce the next release, add one entry at the top; the call-out on
1572/// `/standard-site` and the landing page, and both links, follow from it.
1573/// `releases_are_newest_first_and_link_the_tag_and_changelog` pins the shape.
1574pub(crate) const RELEASES: &[Release] = &[
1575    Release {
1576        version: "0.4.5",
1577        date: "2026-10-06",
1578        summary: "An operator teardown now signs every user out at their own \
1579                  server before deleting anything, and the session-writing \
1580                  code is hardened against the races that work exposed.",
1581    },
1582    Release {
1583        version: "0.4.4",
1584        date: "2026-10-05",
1585        summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1586                  links unchanged and real RSS bylines, and the address guard \
1587                  refuses the reserved ranges it missed.",
1588    },
1589    Release {
1590        version: "0.4.3",
1591        date: "2026-10-05",
1592        summary: "Two write-path fixes for any PDS: large OPML imports and \
1593                  read-state syncs are sent in calls the PDS accepts, and a \
1594                  read-state sync that disagreed with the PDS recovers instead \
1595                  of failing every round.",
1596    },
1597    Release {
1598        version: "0.4.2",
1599        date: "2026-10-04",
1600        summary: "A public standard.site feature page with this list of recent \
1601                  releases, and link cards: a posted feather-reader.com link \
1602                  now unfurls with a description and an image.",
1603    },
1604    Release {
1605        version: "0.4.1",
1606        date: "2026-10-04",
1607        summary: "The public pages explain standard.site publications, and the \
1608                  subscribe form can submit the DID form of a publication URI, \
1609                  which browsers refused in 0.4.0.",
1610    },
1611    Release {
1612        version: "0.4.0",
1613        date: "2026-10-03",
1614        summary: "standard.site support: publications are read from their \
1615                  authors' atproto repos as subscriptions, beside RSS, on their \
1616                  own polling loop. Every stored field from a feed or a \
1617                  publication now has a size bound.",
1618    },
1619];
1620
1621/// The public `/stats` page — is the poller keeping up?
1622///
1623/// Aggregate only, deliberately. It is published to anyone, so it carries no
1624/// user counts and no per-feed detail: a reader does not need to know how many
1625/// people use an instance or which feeds are failing. What it does answer is the
1626/// question that decides whether an instance can take more readers — whether the
1627/// poller is servicing the feeds it already has.
1628///
1629/// The counts below are aggregate machine facts, which is why they fit that
1630/// contract: "12 feeds are in backoff" names no feed and no reader, while
1631/// answering the question the page was previously unable to answer at all.
1632#[derive(Template)]
1633#[template(path = "stats.html")]
1634struct StatsTemplate {
1635    /// The link card: this page's own title and description.
1636    card: Card,
1637    version: &'static str,
1638    repo_url: &'static str,
1639    kofi_url: &'static str,
1640    feeds_tracked: i64,
1641    polled_last_hour: i64,
1642    polled_pct: i64,
1643    overdue: i64,
1644    last_poll: String,
1645    oldest_poll: String,
1646    never_polled: i64,
1647    poll_interval_mins: i64,
1648    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1649    in_backoff: i64,
1650    /// Of those, the ones retried hours apart rather than minutes. **Not
1651    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1652    /// their next successful poll, and most of this instance's did.
1653    badly_broken: i64,
1654    /// Failing feeds by cause, descending — counts only, never which feed.
1655    failure_kinds: Vec<(String, i64)>,
1656    /// What the poller is actually doing: `running`, `paused` (at the size
1657    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1658    /// disabled). Three of those four used to render as "running".
1659    fetching: &'static str,
1660}
1661
1662/// The public `/privacy` page — what the server holds vs. what lives in the
1663/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1664/// footer include needs.
1665#[derive(Template)]
1666#[template(path = "privacy.html")]
1667struct PrivacyTemplate {
1668    /// The link card: this page's own title and description.
1669    card: Card,
1670    version: &'static str,
1671    repo_url: &'static str,
1672    kofi_url: &'static str,
1673}
1674
1675/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1676/// same fields the shared footer include needs.
1677#[derive(Template)]
1678#[template(path = "terms.html")]
1679struct TermsTemplate {
1680    /// The link card: this page's own title and description.
1681    card: Card,
1682    version: &'static str,
1683    repo_url: &'static str,
1684    kofi_url: &'static str,
1685}
1686
1687/// The signed-out landing page (`GET /` with no session) — the public front
1688/// door at feather-reader.com. A static render, no session required.
1689#[derive(Template)]
1690#[template(path = "landing.html")]
1691struct LandingTemplate {
1692    /// The link card: the site's own title and description.
1693    card: Card,
1694    version: &'static str,
1695    repo_url: &'static str,
1696    crates_url: &'static str,
1697    kofi_url: &'static str,
1698    /// `Config::standard_site`: whether the publications point may tell a
1699    /// visitor how to subscribe to one here. See [`ManageTemplate::standard_site`].
1700    standard_site: bool,
1701    /// [`RELEASES`], newest first, for the quiet "latest releases" strip.
1702    releases: &'static [Release],
1703}
1704
1705/// The single-entry reader view (`GET /entries/:id`).
1706#[derive(Template)]
1707#[template(path = "entry.html")]
1708struct EntryTemplate {
1709    /// The link card. A private view: the site's generic card, `noindex`.
1710    card: Card,
1711    version: &'static str,
1712    repo_url: &'static str,
1713    kofi_url: &'static str,
1714    nav: Nav,
1715    id: i64,
1716    title: String,
1717    feed_title: String,
1718    author: Option<String>,
1719    published: String,
1720    /// The entry's own link, for `entry.html`'s two `href`s.
1721    ///
1722    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1723    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1724    /// long way from the `href` and holds only while every future writer to
1725    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1726    /// defence that, on the saved-record row, turned out to be deletable with
1727    /// all 679 tests still green. `None` is the refusal: the template's
1728    /// no-URL branch already renders a disabled open-original button.
1729    url: Option<SafeLink>,
1730    content_html: Option<String>,
1731    read: bool,
1732    starred: bool,
1733    /// The query string to carry the reading context back to the list.
1734    back_qs: String,
1735    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1736    prev_id: Option<i64>,
1737    next_id: Option<i64>,
1738    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1739    oob: bool,
1740}
1741
1742/// The htmx swap fragment for a single entry row (`entry_row.html`).
1743#[derive(Template)]
1744#[template(path = "entry_row.html")]
1745struct EntryRowTemplate {
1746    e: EntryRow,
1747}
1748
1749/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1750/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1751/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1752/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1753#[derive(Template)]
1754#[template(path = "entry_actionbar.html")]
1755struct EntryActionBarTemplate {
1756    id: i64,
1757    read: bool,
1758    starred: bool,
1759    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1760    oob: bool,
1761}
1762
1763/// The login stub (`GET /login`).
1764#[derive(Template)]
1765#[template(path = "login.html")]
1766struct LoginTemplate {
1767    /// The link card: this page's own title and description.
1768    card: Card,
1769    repo_url: &'static str,
1770    error: String,
1771    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1772    /// distinct from `error`. Empty renders nothing.
1773    flash: String,
1774}
1775
1776/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1777#[derive(Template)]
1778#[template(path = "beta_redeem.html")]
1779struct BetaRedeemTemplate {
1780    /// The link card: this page's own title and description.
1781    card: Card,
1782    repo_url: &'static str,
1783    error: String,
1784    /// When true the seat cap is full: hide the form and show the "capacity
1785    /// full — try self-hosting" message instead.
1786    capacity_full: bool,
1787}
1788
1789// ---------------------------------------------------------------------------
1790// Rendering + error helpers
1791// ---------------------------------------------------------------------------
1792
1793/// Render an askama template into an HTML response, mapping a render failure to
1794/// a `500` rather than panicking (no `unwrap` in the request path).
1795fn render<T: Template>(tmpl: &T) -> Response {
1796    match tmpl.render() {
1797        Ok(body) => Html(body).into_response(),
1798        Err(err) => {
1799            warn!(%err, "template render failed");
1800            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1801        }
1802    }
1803}
1804
1805/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1806/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1807/// by default; a handler may override the status (e.g. `413` for an over-cap
1808/// upload) via [`WebError::with_status`].
1809struct WebError {
1810    err: anyhow::Error,
1811    status: StatusCode,
1812}
1813
1814impl<E: Into<anyhow::Error>> From<E> for WebError {
1815    fn from(err: E) -> Self {
1816        WebError {
1817            err: err.into(),
1818            status: StatusCode::INTERNAL_SERVER_ERROR,
1819        }
1820    }
1821}
1822
1823impl WebError {
1824    /// Attach an explicit HTTP status to render instead of the default `500`.
1825    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1826        WebError {
1827            err: err.into(),
1828            status,
1829        }
1830    }
1831}
1832
1833impl IntoResponse for WebError {
1834    fn into_response(self) -> Response {
1835        warn!(error = %self.err, status = %self.status, "request failed");
1836        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1837            "internal error"
1838        } else {
1839            self.status.canonical_reason().unwrap_or("error")
1840        };
1841        (self.status, body).into_response()
1842    }
1843}
1844
1845/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1846/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1847/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1848/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1849fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1850    let status = err.status();
1851    WebError::with_status(err, status)
1852}
1853
1854/// A short, human display of a feed/site title for the sidebar/list, falling
1855/// back to the host of a URL and finally to the raw string.
1856fn display_title(title: Option<&str>, url: &str) -> String {
1857    if let Some(t) = title {
1858        let t = t.trim();
1859        if !t.is_empty() {
1860            return t.to_string();
1861        }
1862    }
1863    url::Url::parse(url)
1864        .ok()
1865        .and_then(|u| u.host_str().map(str::to_string))
1866        .unwrap_or_else(|| url.to_string())
1867}
1868
1869/// A display `@handle` for the identity chip: the stored handle if present,
1870/// else the tail of the DID so the chip is never empty.
1871fn display_handle(handle: Option<&str>, did: &str) -> String {
1872    match handle {
1873        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1874        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1875    }
1876}
1877
1878/// Two-letter, lowercase avatar initials from a handle/DID.
1879fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1880    let source = handle
1881        .map(|h| h.trim().trim_start_matches('@'))
1882        .filter(|h| !h.is_empty())
1883        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1884    let letters: String = source
1885        .chars()
1886        .filter(|c| c.is_alphanumeric())
1887        .take(2)
1888        .collect::<String>()
1889        .to_lowercase();
1890    if letters.is_empty() {
1891        "fr".to_string()
1892    } else {
1893        letters
1894    }
1895}
1896
1897/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1898/// low-noise display. Falls back to the raw string if it doesn't look like one.
1899fn display_date(published: Option<&str>) -> String {
1900    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1901    // multi-byte character, and every caller used to pass a timestamp the feed
1902    // parser had produced. The saved-record path passes `createdAt` straight off
1903    // a PDS record, which the lexicon types as a bare string with no validation
1904    // — written by whatever atproto client the reader used. A `createdAt` of
1905    // "日本語日本語日本" took down the whole starred view, and there is no
1906    // catch-panic layer in the stack, so the page stayed down until the record
1907    // was removed from the very view that would not render.
1908    match published {
1909        Some(p) => p.chars().take(10).collect(),
1910        None => String::new(),
1911    }
1912}
1913
1914/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1915/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1916/// a bare value, and this keeps the scope-preserving links honest.
1917fn qenc(s: &str) -> String {
1918    let mut out = String::with_capacity(s.len() * 3);
1919    for b in s.bytes() {
1920        match b {
1921            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1922                out.push(b as char)
1923            }
1924            _ => out.push_str(&format!("%{b:02X}")),
1925        }
1926    }
1927    out
1928}
1929
1930// ---------------------------------------------------------------------------
1931// Reader: index
1932// ---------------------------------------------------------------------------
1933
1934/// Query for `GET /` — the scope + view selector.
1935#[derive(Debug, Deserialize, Default)]
1936struct IndexQuery {
1937    /// Filter to a single feed by its canonical URL.
1938    #[serde(default)]
1939    feed: Option<String>,
1940    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1941    #[serde(default)]
1942    folder: Option<String>,
1943    /// `unread` (default) | `all` | `starred`.
1944    #[serde(default)]
1945    view: Option<String>,
1946    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1947    #[serde(default)]
1948    page: Option<u32>,
1949    /// Optional flash message (e.g. after an action redirect).
1950    #[serde(default)]
1951    flash: Option<String>,
1952}
1953
1954/// Rows per page in the reader's list views.
1955///
1956/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1957/// so a page is on the order of tens of kilobytes rather than the tens or
1958/// hundreds of megabytes an unbounded list of full entries could reach. The page
1959/// bound is the second half of that fix: without it, a reader with a long
1960/// backlog still decides how much memory a single request allocates.
1961const ENTRIES_PER_PAGE: i64 = 100;
1962
1963/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1964/// pager reads "1 / 1" rather than "1 / 0".
1965fn page_count_for(total: i64) -> i64 {
1966    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1967}
1968
1969/// Ceiling on the reader's prev/next id list.
1970///
1971/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1972/// reader's position within it — so it is bounded by count rather than paged. At
1973/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1974/// resolving; the article itself still opens, and the list view still pages.
1975const PREV_NEXT_MAX: i64 = 5_000;
1976
1977/// Ceiling on the cached-starred identity set matched against PDS saved records.
1978///
1979/// Deliberately generous: under-reading this set makes a cached article look
1980/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1981/// than un-starring the entry. Truncating here would change what a click
1982/// destroys, so the cap exists only as a backstop against an absurd starred
1983/// count, not as a routine bound.
1984const STARRED_IDENTITY_MAX: i64 = 20_000;
1985
1986/// Most uncached PDS saved records this handler will hold in memory for one
1987/// request.
1988///
1989/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1990/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1991/// this only caps how many are collected before slicing. An earlier version used
1992/// it to cap what was SHOWN, which left everything past it invisible and —
1993/// because the un-save control lives on the row, and nothing else in the app
1994/// lists these — unremovable.
1995///
1996/// Well above the PDS list ceiling's practical reach for one reader, so a reader
1997/// meeting it has thousands of saved records and gets a logged, ordered prefix
1998/// rather than a failure.
1999const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
2000
2001/// A subscription resolved against the local cache: the PDS record + its
2002/// (possibly-missing) cached feed row.
2003struct ResolvedSub {
2004    rkey: String,
2005    sub: Subscription,
2006    feed: Option<store::Feed>,
2007}
2008
2009/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
2010/// local cache row so unread counts work, and return them resolved. Best-effort
2011/// on the sidecar: a failure falls back to the local cache alone.
2012async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
2013    resolve_subscriptions_noting(state, did).await.0
2014}
2015
2016/// What to tell a reader whose subscription list could not be read from their
2017/// PDS, so the last-known list being shown does not pass for a fresh one.
2018///
2019/// **A malformed record is named as such** (#177): the walk refuses rather than
2020/// drop that subscription, and "unreachable" would send the reader looking at
2021/// their network when the cause is a record some client wrote into their repo.
2022fn subscriptions_alert(err: &anyhow::Error) -> String {
2023    match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2024        Some(m) => format!(
2025            "{} record(s) in your subscription list could not be read, so it was not \
2026             refreshed. Showing your last-known subscriptions; nothing was removed.",
2027            m.count
2028        ),
2029        None => "Your subscription list could not be read from your PDS just now. \
2030                 Showing your last-known subscriptions."
2031            .to_string(),
2032    }
2033}
2034
2035/// [`resolve_subscriptions`], plus the alert to show when the list shown is the
2036/// cached one because the PDS listing failed.
2037async fn resolve_subscriptions_noting(
2038    state: &AppState,
2039    did: &str,
2040) -> (Vec<ResolvedSub>, Option<String>) {
2041    let pool = &state.db;
2042    let subs = match state.repo().list_subscriptions_sorted(did).await {
2043        Ok(s) => s,
2044        Err(err) => {
2045            let alert = subscriptions_alert(&err);
2046            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2047            // Fail CLOSED: the PDS is the source of truth for what this DID
2048            // follows. When it is unreachable we must NOT widen the caller's
2049            // authorization surface. Serve from the DID's OWN last-known
2050            // `sub_ref` projection (its own feeds, possibly stale) and leave
2051            // `sub_ref` untouched — never synthesize from every cached feed,
2052            // which would grant cross-tenant read+mutate during any outage.
2053            // A DB failure here is NOT the same as "this DID follows nothing",
2054            // but `unwrap_or_default` rendered it as exactly that: an empty
2055            // sidebar and an empty reader, which arrives as "all my feeds
2056            // vanished". It still degrades to empty — there is nothing better to
2057            // show — but it says so, so the support ticket and the log line can
2058            // be matched up.
2059            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2060                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2061                                   projection could not be read; rendering an EMPTY \
2062                                   feed list, which is not the same as having none");
2063                Vec::new()
2064            });
2065            let cached = feeds
2066                .into_iter()
2067                .map(|f| ResolvedSub {
2068                    rkey: String::new(),
2069                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
2070                    feed: Some(f),
2071                })
2072                .collect();
2073            return (cached, Some(alert));
2074        }
2075    };
2076
2077    // **Deliberately NOT truncated to `max_subs_per_did`.**
2078    //
2079    // The PDS list is unbounded in practice — any client can write subscription
2080    // records, and only the 20,000-record list ceiling stops it — and the first
2081    // attempt at bounding it truncated the list right here. That was the wrong
2082    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
2083    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
2084    // removed the reader's ability to read OR mutate those feeds. A query-shape
2085    // problem would have become an access problem.
2086    //
2087    // The shape problem was the scope filter emitting one SQL placeholder per
2088    // feed; `store::list_query_sql` now passes the whole set as a single
2089    // `json_each` bind, so there is no size to defend against here and nothing
2090    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
2091    // feeds — rather than becoming a silent read-time filter.
2092    let mut out = Vec::with_capacity(subs.len());
2093    for (rkey, sub) in subs {
2094        let feed = match store::get_feed_by_url(pool, &sub.url).await {
2095            Ok(Some(f)) => Some(f),
2096            Ok(None) => {
2097                // `sub.url` came out of an atproto record. The lexicon is open —
2098                // ANY client can write a subscription into a user's repo — so
2099                // this is untrusted input on the hot path of `GET /`, and it was
2100                // being stored with none of the three checks the add and import
2101                // paths apply. Two of those are capacity ceilings; this one is
2102                // the invariant in `FeedPrivacy`'s doc comment, which promises a
2103                // private feed URL is "never stored". Writing a
2104                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
2105                // that promise even though `net::guarded_get` still refuses to
2106                // fetch it.
2107                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2108                    || feed::classify_feed_privacy(&sub.url).is_private()
2109                {
2110                    warn!(
2111                        %did,
2112                        "skipping cache row for a subscription URL that is private or not http(s)"
2113                    );
2114                    out.push(ResolvedSub {
2115                        rkey,
2116                        sub,
2117                        feed: None,
2118                    });
2119                    continue;
2120                }
2121                // Upsert a cache row so the sidebar reflects the real follow-list.
2122                //
2123                // A silent failure here is a support ticket with no evidence: no
2124                // `feeds` row means the poller never selects this subscription,
2125                // so the reader sees "I added a feed and it never updates" while
2126                // the PDS record looks perfect. Logged with the URL so the
2127                // failing subscription is identifiable.
2128                if let Err(err) = store::upsert_feed(
2129                    pool,
2130                    &store::NewFeed {
2131                        url: sub.url.clone(),
2132                        title: sub.title.clone(),
2133                        site_url: sub.site_url.clone(),
2134                        ..Default::default()
2135                    },
2136                )
2137                .await
2138                {
2139                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2140                                                       it will not be polled");
2141                }
2142                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2143            }
2144            Err(err) => {
2145                warn!(%err, url = %sub.url, "get_feed_by_url failed");
2146                None
2147            }
2148        };
2149        out.push(ResolvedSub { rkey, sub, feed });
2150    }
2151    // Mirror the caller's resolved subscription set into `sub_ref`, so every
2152    // scoped entry/feed read + read/star mutation authorizes against exactly
2153    // the feeds this DID follows right now. This is THE per-DID isolation hook.
2154    sync_sub_refs(pool, did, &out).await;
2155    (out, None)
2156}
2157
2158/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
2159/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
2160/// fail closed / show fewer rows), never leaks another user's entries.
2161async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2162    let feed_ids: Vec<i64> = subs
2163        .iter()
2164        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2165        .collect();
2166    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2167        warn!(%err, %did, "failed to sync sub_ref projection");
2168    }
2169}
2170
2171/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
2172/// records layer) and the article list for the selected scope + view.
2173async fn index(
2174    State(state): State<AppState>,
2175    headers: HeaderMap,
2176    Query(q): Query<IndexQuery>,
2177) -> Result<Response, WebError> {
2178    let user = match current_session(&state, &headers).await {
2179        Some(u) => u,
2180        // Signed out: serve the public landing page rather than bouncing to
2181        // /login. /login remains the entry point for the actual OAuth sign-in.
2182        None => {
2183            return Ok(render(&LandingTemplate {
2184                card: Card::site(&state.config),
2185                version: VERSION,
2186                repo_url: REPO_URL,
2187                crates_url: CRATES_URL,
2188                kofi_url: KOFI_URL,
2189                standard_site: state.config.standard_site,
2190                releases: RELEASES,
2191            }))
2192        }
2193    };
2194    let did = user.did.clone();
2195    let pool = &state.db;
2196
2197    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2198
2199    // View: unread (default) | all | starred.
2200    let view = match q.view.as_deref() {
2201        Some("all") => "all",
2202        Some("starred") => "starred",
2203        _ => "unread",
2204    }
2205    .to_string();
2206    let list_view = list_view_of(q.view.as_deref());
2207
2208    // Which feed URLs are in scope?
2209    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2210    // …and the feed ids they resolve to. Scope is applied inside the query now,
2211    // so a page is a page of rows the reader will actually see. Filtering after
2212    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
2213    // any scope narrower than the whole subscription list.
2214    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2215
2216    let feed_title_by_id = |id: i64| -> String {
2217        subs.iter()
2218            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2219            .map(|s| {
2220                display_title(
2221                    s.sub
2222                        .title
2223                        .as_deref()
2224                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2225                    &s.sub.url,
2226                )
2227            })
2228            .unwrap_or_default()
2229    };
2230
2231    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
2232    //
2233    // All three views used to materialize every matching entry — `SELECT e.*`,
2234    // no `LIMIT`, article bodies included — and the "all" view additionally ran
2235    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
2236    // of the row fields below read the body. See `store::EntryListRow`.
2237    // **Saved records the cache cannot show.**
2238    //
2239    // The starred view is built from local `entries`, so a saved record whose
2240    // article was never cached here is invisible — the case that matters is
2241    // starring in ANOTHER atproto reader, which is the portability the shared
2242    // lexicon exists for. Those rows are rendered from the PDS record alone.
2243    let mut uncached: Vec<EntryRow> = Vec::new();
2244    if view == "starred" {
2245        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
2246        //
2247        // `source` has already been filtered by feed/folder. Matching against it
2248        // meant an entry that IS cached but sits outside the current filter
2249        // looked uncached — so it rendered as a "not cached" row whose star
2250        // button deletes the PDS RECORD instead of un-starring the entry. A
2251        // scope filter must not change what is destroyed. Paging is the same
2252        // hazard in a new form: matching against the visible PAGE would make
2253        // every cached article outside it look uncached. Hence a dedicated
2254        // identity query over the whole starred set — urls and guids only, no
2255        // bodies — rather than reusing `source`.
2256        //
2257        // One gap remains BY DESIGN, and is handled at the other end. This query
2258        // still carries the `sub_ref` predicate, so a starred, cached entry in a
2259        // feed the reader has UNSUBSCRIBED from is absent here and its record
2260        // renders as uncached. That is the right rendering — the article is no
2261        // longer part of any feed the reader follows, and the PDS record is what
2262        // still holds it — but it means the un-save button is the record-deleting
2263        // one. `unsave_record` therefore clears the local star too, so the two
2264        // stores agree however the row got classified. Dropping the predicate
2265        // here instead would have made the row link to `/entries/{id}`, which is
2266        // `sub_ref`-scoped and would 404.
2267        //
2268        // **Three ways this can be unusable, and all three fail CLOSED.** With an
2269        // incomplete identity set, a cached article looks uncached and renders an
2270        // un-save button that deletes the PDS RECORD. Showing no uncached rows
2271        // loses rows for one render; getting this wrong loses data permanently,
2272        // so every uncertain case suppresses them.
2273        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2274            Ok(store::StarredIdentities::All(rows)) => Some(rows),
2275            // The cap is a memory backstop, and reaching it means the set is an
2276            // arbitrary subset. It used to return that subset with no way to
2277            // tell, so every starred article outside it got the destructive
2278            // button.
2279            Ok(store::StarredIdentities::Truncated) => {
2280                warn!(
2281                    %did,
2282                    cap = STARRED_IDENTITY_MAX,
2283                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
2284                     rather than rendering record-deleting buttons for cached articles"
2285                );
2286                None
2287            }
2288            Err(err) => {
2289                warn!(%err, %did, "cached-starred identity lookup failed; \
2290                                    suppressing uncached saved rows this render");
2291                None
2292            }
2293        };
2294        // The escape hatch asks whether this DID has ANY cached starred entry —
2295        // not whether the current SCOPE does. `total` is narrowed by
2296        // `?feed=`/`?folder=` while the identity set spans every feed, so
2297        // comparing them waved the fail-closed condition through for any narrow
2298        // scope: a record whose `feedUrl` matched the filter while its cached
2299        // entry lived under another feed rendered as uncached.
2300        let identities_ok = identities.is_some();
2301        let identities = identities.unwrap_or_default();
2302        let cached_urls: std::collections::HashSet<&str> = identities
2303            .iter()
2304            .filter_map(|(url, _)| url.as_deref())
2305            .collect();
2306        let cached_guids: std::collections::HashSet<&str> =
2307            identities.iter().map(|(_, guid)| guid.as_str()).collect();
2308
2309        // Collected in full here, sliced per page later. They sort after every
2310        // cached row, so the two lists form one sequence that the pager walks —
2311        // see the slice below. Collected BEFORE the page is chosen because the
2312        // page count depends on how many there are.
2313        // Bounded like everything else on this page. These come from the PDS
2314        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
2315        // `backend=rust`, whose caps are a quarter of the other's) and are
2316        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
2317        // constrain them at all. The
2318        // cap is generous — a reader with more saved-elsewhere records than this
2319        // is not the case being designed for — but a response has to have a size
2320        // an operator can reason about.
2321        let mut uncached_dropped = 0usize;
2322        match state.repo().list_saved_sorted(&did).await {
2323            Ok(saved) if identities_ok => {
2324                for (rkey, item) in saved {
2325                    let known = cached_urls.contains(item.url.as_str())
2326                        || item
2327                            .entry_id
2328                            .as_deref()
2329                            .is_some_and(|g| cached_guids.contains(g));
2330                    if known {
2331                        continue;
2332                    }
2333                    // And the scope filter applies to these rows too. Without
2334                    // it, `?feed=X` still listed saved records from every other
2335                    // feed — the filter silently did nothing for them.
2336                    if let Some(urls) = &scope_urls {
2337                        match item.feed_url.as_deref() {
2338                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2339                            // A saved record with no `feedUrl` cannot be placed
2340                            // in any feed's scope, so it belongs only to the
2341                            // unfiltered view.
2342                            _ => continue,
2343                        }
2344                    }
2345                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2346                    //
2347                    // `item.url` is attacker-controlled — a saved record written
2348                    // by any client — and it lands in an `href`. Askama escapes
2349                    // HTML metacharacters but not SCHEMES, so `javascript:`
2350                    // survives escaping intact. This project already built the
2351                    // helper for exactly that, and `feed.rs` uses it on the
2352                    // equivalent link; this path was simply not routed through it.
2353                    //
2354                    // The real defect was what a failure DID: it `continue`d, so
2355                    // the row vanished entirely — no badge, no count, nothing —
2356                    // and the only trace was a `debug!` below any realistic
2357                    // filter. That makes the record unremovable FROM HERE, because
2358                    // the un-save button lives on the row; the reader has to open
2359                    // a different atproto client to get rid of it. A bad URL is a
2360                    // reason to withhold the LINK, not the row.
2361                    //
2362                    // The check also moved ABOVE the poll nudge. That is ordering
2363                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2364                    // on the URL being rejected here, and is already gated on the
2365                    // reader actually subscribing to that feed — so it was never
2366                    // reachable by an unusable `item.url`. Deciding whether a
2367                    // record is renderable before doing anything outbound on its
2368                    // behalf is simply the order that stays correct if either of
2369                    // those two facts later stops being true.
2370                    let link = SafeLink::external(&item.url);
2371                    if link.is_empty() {
2372                        warn!(
2373                            %did, %rkey,
2374                            "a saved record has an unusable URL; rendering it without a link \
2375                             so it can still be removed"
2376                        );
2377                    }
2378
2379                    // Opportunistic re-fetch: if the reader still subscribes to
2380                    // the feed, make it due now. If the article is still inside
2381                    // the feed's window the poller caches it normally and this
2382                    // row becomes a real entry on its own — no synthetic rows in
2383                    // the shared cache, which every subscriber would otherwise
2384                    // see as a content-less entry.
2385                    // **Bound the WORK, not just the response.** This check sat
2386                    // after the nudge and the `subs` scan below, so every render
2387                    // still walked all ≤20,000 PDS records, ran a subs-length
2388                    // string scan per record, and issued up to that many
2389                    // `mark_feed_due` round-trips on a 5-connection pool — then
2390                    // discarded everything past the cap. A cap that runs after
2391                    // the expensive part is a cap on the output only.
2392                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2393                        uncached_dropped += 1;
2394                        continue;
2395                    }
2396                    if let Some(feed_url) = item.feed_url.as_deref() {
2397                        if subs.iter().any(|s| s.sub.url == feed_url) {
2398                            // Bounded to one nudge per feed per poll interval —
2399                            // see `mark_feed_due`. Unbounded, a reload loop here
2400                            // becomes outbound amplification.
2401                            let stale_before = (chrono::Utc::now()
2402                                - chrono::Duration::from_std(state.config.poll_interval)
2403                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2404                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2405                            if let Err(err) =
2406                                store::mark_feed_due(pool, feed_url, &stale_before).await
2407                            {
2408                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2409                            }
2410                        }
2411                    }
2412                    uncached.push(EntryRow {
2413                        id: 0,
2414                        title: item
2415                            .title
2416                            .clone()
2417                            .filter(|t| !t.trim().is_empty())
2418                            // Falling back to the URL is fine for a link we are
2419                            // willing to render, and wrong for one we are not:
2420                            // it would put the exact string `safe_link` just
2421                            // rejected into the page as the record's name. The
2422                            // rkey is what the un-save button acts on, so it is
2423                            // the honest identifier for a row that has nothing
2424                            // else trustworthy to show.
2425                            .unwrap_or_else(|| {
2426                                if link.is_empty() {
2427                                    format!("Saved item {rkey}")
2428                                } else {
2429                                    item.url.clone()
2430                                }
2431                            }),
2432                        feed_title: item.feed_url.clone().unwrap_or_default(),
2433                        published: display_date(Some(&item.created_at)),
2434                        read: false,
2435                        starred: true,
2436                        // Empty = "render this row without an anchor". The
2437                        // template branches on it, so the rejected URL never
2438                        // reaches an `href` even as an escaped string.
2439                        link,
2440                        cached: false,
2441                        rkey,
2442                    });
2443                }
2444            }
2445            // Identity lookup was unusable — see the fail-closed note above.
2446            Ok(_) => {}
2447            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2448        }
2449        if uncached_dropped > 0 {
2450            warn!(
2451                %did,
2452                dropped = uncached_dropped,
2453                cap = MAX_UNCACHED_SAVED_ROWS,
2454                "more saved records than this instance will hold in one response; the \
2455                 rest are not reachable from here"
2456            );
2457        }
2458    }
2459
2460    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2461    // PDS records follow them, and the pager walks the concatenation.
2462    //
2463    // The first version appended the uncached rows to the last page only and
2464    // kept them out of `total`, which left everything past a cap invisible AND
2465    // unremovable — the un-save button lives on the row, and there is no other
2466    // surface in the app that lists these. That is the same "unremovable FROM
2467    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2468    // forty lines later by a bound meant to protect memory.
2469    //
2470    // Paging the concatenation makes every record reachable and needs no cap on
2471    // what is RENDERED — one page is one page either way. The version before
2472    // that inflated `total` while clamping on the cached count, which advertised
2473    // a page the clamp could never reach; both numbers come from the same total
2474    // now, which is what makes that impossible rather than merely fixed.
2475    let total_cached =
2476        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2477    let uncached_len = uncached.len();
2478    let total = total_cached + uncached_len as i64;
2479    // Clamped to the range that exists. Past the end the list is empty, and the
2480    // empty state renders instead of the pager — which would strand a reader who
2481    // typed a page number, or who paged to the end and then marked entries read
2482    // out from under their own URL. Showing the last page is the answer to both.
2483    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2484    let offset = (page - 1) * ENTRIES_PER_PAGE;
2485    // Past the cached rows this returns nothing, which is exactly right: the
2486    // page is then made up entirely of uncached ones.
2487    let source = store::list_entries(
2488        pool,
2489        &did,
2490        list_view,
2491        scope_ids.as_deref(),
2492        ENTRIES_PER_PAGE,
2493        offset,
2494    )
2495    .await?;
2496    // **Both halves of the page are computed from the COUNT alone.**
2497    //
2498    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2499    // queries, so they can disagree about how many cached rows exist. Any part of
2500    // the page composition that reads `source.len()` inherits that disagreement.
2501    //
2502    // `cached_allotment` is this page's cached share according to the snapshot,
2503    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2504    // pages tile the uncached list exactly, whichever way the count drifted.
2505    // `source` is then truncated to it only to avoid rendering rows the next page
2506    // will also claim.
2507    //
2508    // The previous version took `skip` from the count but `take` from
2509    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2510    // an un-star or a retention delete landing between the two queries — made
2511    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2512    // putting twenty rows, each carrying the record-DELETING un-save button, on
2513    // two pages at once. The comment claimed that shape was impossible; it was
2514    // merely rarer.
2515    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2516    let cached_here = cached_allotment.min(source.len());
2517    // Only compose when there is something to compose WITH. `uncached` is empty
2518    // on every view but `starred`, and truncating there just drops trailing rows
2519    // that no page then shows — the poller inserting between the COUNT and the
2520    // SELECT was enough to trigger it.
2521    let source = if uncached_len == 0 {
2522        &source[..]
2523    } else {
2524        &source[..cached_here]
2525    };
2526    let uncached_page: Vec<EntryRow> = {
2527        let skip = (offset - total_cached).max(0) as usize;
2528        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2529        uncached.into_iter().skip(skip).take(take).collect()
2530    };
2531    // This page's slice, used only to append below. The heading needs the
2532    // WHOLE-list figure, which is the set's size before slicing.
2533    let uncached_total = uncached_len as i64;
2534
2535    // The scope/view suffix carried onto every entry link (built once).
2536    let entry_scope_qs = {
2537        let mut parts = Vec::new();
2538        if let Some(f) = q.feed.as_deref() {
2539            parts.push(format!("feed={}", qenc(f)));
2540        }
2541        if let Some(f) = q.folder.as_deref() {
2542            parts.push(format!("folder={}", qenc(f)));
2543        }
2544        if view != "unread" {
2545            parts.push(format!("view={}", qenc(&view)));
2546        }
2547        parts.join("&")
2548    };
2549    let entries: Vec<EntryRow> = source
2550        .iter()
2551        .map(|e| EntryRow {
2552            id: e.id,
2553            title: e
2554                .title
2555                .clone()
2556                .filter(|t| !t.trim().is_empty())
2557                .unwrap_or_else(|| "(untitled)".to_string()),
2558            feed_title: feed_title_by_id(e.feed_id),
2559            published: display_date(e.published.as_deref()),
2560            // Both bits ride along on the row's own `entry_state` join now. They
2561            // used to be membership tests against the full unread and starred
2562            // sets, which is why those two lists were fetched in their entirety
2563            // on every render even when the page showed a hundred rows.
2564            read: e.read,
2565            starred: e.starred,
2566            link: SafeLink::entry(e.id, &entry_scope_qs),
2567            cached: true,
2568            rkey: String::new(),
2569        })
2570        .collect();
2571
2572    // The uncached slice for this page follows the cached rows.
2573    let mut entries = entries;
2574    entries.extend(uncached_page);
2575    let entries = entries;
2576
2577    let selected_feed = q.feed.as_deref();
2578    let selected_folder = q.folder.as_deref();
2579
2580    // Build the shared sidebar (folders + loose feeds, with unread counts).
2581    let (folder_views, loose_feeds, _folder_options) =
2582        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2583
2584    // Heading + scope query-string suffix.
2585    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2586        let name = subs
2587            .iter()
2588            .find(|s| s.sub.url == feed_url)
2589            .map(|s| {
2590                display_title(
2591                    s.sub
2592                        .title
2593                        .as_deref()
2594                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2595                    &s.sub.url,
2596                )
2597            })
2598            .unwrap_or_else(|| display_title(None, feed_url));
2599        (name, format!("feed={}", qenc(feed_url)))
2600    } else if let Some(folder_uri) = selected_folder {
2601        let name = folder_views
2602            .iter()
2603            .find(|f| f.uri == folder_uri)
2604            .map(|f| f.name.clone())
2605            .unwrap_or_else(|| "Folder".to_string());
2606        (name, format!("folder={}", qenc(folder_uri)))
2607    } else {
2608        let h = match view.as_str() {
2609            "all" => "All",
2610            "starred" => "Starred",
2611            _ => "Unread",
2612        };
2613        (h.to_string(), String::new())
2614    };
2615
2616    let feed_scope = selected_feed.map(str::to_string);
2617    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2618
2619    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2620    // page number is the only thing appended — which keeps a paged link
2621    // identical to an unpaged one in every other respect.
2622    let page_href = |n: i64| -> String {
2623        let mut parts = Vec::new();
2624        if !entry_scope_qs.is_empty() {
2625            parts.push(entry_scope_qs.clone());
2626        }
2627        if n > 1 {
2628            parts.push(format!("page={n}"));
2629        }
2630        if parts.is_empty() {
2631            "/".to_string()
2632        } else {
2633            format!("/?{}", parts.join("&"))
2634        }
2635    };
2636    let prev_href = (page > 1).then(|| page_href(page - 1));
2637    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2638
2639    let tmpl = IndexTemplate {
2640        card: Card::private(&state.config),
2641        version: VERSION,
2642        repo_url: REPO_URL,
2643        kofi_url: KOFI_URL,
2644        flash: q.flash.unwrap_or_default(),
2645        alert: alert.unwrap_or_default(),
2646        nav,
2647        entries,
2648        heading,
2649        feed_scope,
2650        total,
2651        // Whole-list figure, so it sits beside `total` without double counting.
2652        // The per-page slice is composed above and is not a heading number.
2653        uncached_total,
2654        page,
2655        page_count: page_count_for(total),
2656        prev_href,
2657        next_href,
2658    };
2659    Ok(render(&tmpl))
2660}
2661
2662/// Query for `GET /manage` — carries an optional flash after an action redirect.
2663#[derive(Debug, Deserialize, Default)]
2664struct ManageQuery {
2665    #[serde(default)]
2666    flash: Option<String>,
2667}
2668
2669/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2670/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2671/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2672/// mutation logic of its own.
2673async fn manage(
2674    State(state): State<AppState>,
2675    headers: HeaderMap,
2676    Query(q): Query<ManageQuery>,
2677) -> Result<Response, WebError> {
2678    let user = match current_session(&state, &headers).await {
2679        Some(u) => u,
2680        None => return Ok(Redirect::to("/login").into_response()),
2681    };
2682    let did = user.did.clone();
2683
2684    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2685    let (folder_views, loose_feeds, folder_options) =
2686        build_sidebar(&state, &did, &subs, None, None).await;
2687
2688    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2689    let nav = build_nav(
2690        &user,
2691        "unread",
2692        String::new(),
2693        folder_views.iter().map(clone_folder_view).collect(),
2694        loose_feeds.iter().map(clone_feed_view).collect(),
2695        true,
2696    );
2697
2698    let tmpl = ManageTemplate {
2699        card: Card::private(&state.config),
2700        version: VERSION,
2701        repo_url: REPO_URL,
2702        kofi_url: KOFI_URL,
2703        flash: q.flash.unwrap_or_default(),
2704        alert: alert.unwrap_or_default(),
2705        nav,
2706        folder_options,
2707        folders: folder_views,
2708        loose_feeds,
2709        standard_site: state.config.standard_site,
2710    };
2711    Ok(render(&tmpl))
2712}
2713
2714/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2715/// (`Nav`) and the page body without an extra DB round-trip.
2716fn clone_feed_view(f: &FeedView) -> FeedView {
2717    FeedView {
2718        rkey: f.rkey.clone(),
2719        url: f.url.clone(),
2720        title: f.title.clone(),
2721        unread: f.unread,
2722        selected: f.selected,
2723        folder: f.folder.clone(),
2724    }
2725}
2726
2727fn clone_folder_view(f: &FolderView) -> FolderView {
2728    FolderView {
2729        rkey: f.rkey.clone(),
2730        uri: f.uri.clone(),
2731        name: f.name.clone(),
2732        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2733        selected: f.selected,
2734    }
2735}
2736
2737/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2738/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2739/// unscoped "everything" view. A folder scope takes the feed scope when both are
2740/// somehow present (feed wins, matching the query precedence elsewhere).
2741fn scope_urls_for(
2742    subs: &[ResolvedSub],
2743    feed: Option<&str>,
2744    folder: Option<&str>,
2745) -> Option<Vec<String>> {
2746    if let Some(feed_url) = feed {
2747        Some(vec![feed_url.to_string()])
2748    } else {
2749        folder.map(|folder_uri| {
2750            subs.iter()
2751                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2752                .map(|s| s.sub.url.clone())
2753                .collect()
2754        })
2755    }
2756}
2757
2758/// The `at://` URI for a folder record given the owner DID + rkey.
2759fn folder_uri(did: &str, rkey: &str) -> String {
2760    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2761}
2762
2763/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2764/// DID — the shared source for both the reader index and the rail on every
2765/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2766async fn build_sidebar(
2767    state: &AppState,
2768    did: &str,
2769    subs: &[ResolvedSub],
2770    selected_feed: Option<&str>,
2771    selected_folder: Option<&str>,
2772) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2773    let pool = &state.db;
2774    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2775    // all — purely to `.filter().count()` them in Rust, on every page that
2776    // renders chrome, which made the sidebar the most frequently executed
2777    // instance of the unbounded-projection problem.
2778    let unread_counts = store::unread_counts_by_feed(pool, did)
2779        .await
2780        .unwrap_or_else(|err| {
2781            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2782            Default::default()
2783        });
2784    let folders = state
2785        .repo()
2786        .list_folders_sorted(did)
2787        .await
2788        .unwrap_or_default();
2789
2790    let unread_count = |feed_id: Option<i64>| -> i64 {
2791        feed_id
2792            .and_then(|id| unread_counts.get(&id).copied())
2793            .unwrap_or(0)
2794    };
2795    let mk_feed_view = |s: &ResolvedSub| FeedView {
2796        rkey: s.rkey.clone(),
2797        url: s.sub.url.clone(),
2798        title: display_title(
2799            s.sub
2800                .title
2801                .as_deref()
2802                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2803            &s.sub.url,
2804        ),
2805        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2806        selected: selected_feed == Some(s.sub.url.as_str()),
2807        folder: s.sub.folder.clone(),
2808    };
2809
2810    let mut folder_views = Vec::with_capacity(folders.len());
2811    for (rkey, folder) in &folders {
2812        let uri = folder_uri(did, rkey);
2813        let feeds: Vec<FeedView> = subs
2814            .iter()
2815            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2816            .map(mk_feed_view)
2817            .collect();
2818        folder_views.push(FolderView {
2819            rkey: rkey.clone(),
2820            uri: uri.clone(),
2821            name: folder.name.clone(),
2822            feeds,
2823            selected: selected_folder == Some(uri.as_str()),
2824        });
2825    }
2826
2827    let known_uris: std::collections::HashSet<String> =
2828        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2829    let loose_feeds: Vec<FeedView> = subs
2830        .iter()
2831        .filter(|s| {
2832            s.sub
2833                .folder
2834                .as_deref()
2835                .map(|f| !known_uris.contains(f))
2836                .unwrap_or(true)
2837        })
2838        .map(mk_feed_view)
2839        .collect();
2840
2841    let folder_options: Vec<FolderOption> = folders
2842        .iter()
2843        .map(|(rkey, folder)| FolderOption {
2844            name: folder.name.clone(),
2845            uri: folder_uri(did, rkey),
2846        })
2847        .collect();
2848
2849    (folder_views, loose_feeds, folder_options)
2850}
2851
2852/// Assemble the shared rail [`Nav`] for a chrome page.
2853fn build_nav(
2854    user: &CurrentUser,
2855    view: &str,
2856    scope_qs: String,
2857    folders: Vec<FolderView>,
2858    loose_feeds: Vec<FeedView>,
2859    manage_active: bool,
2860) -> Nav {
2861    Nav {
2862        handle: display_handle(user.handle.as_deref(), &user.did),
2863        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2864        view: view.to_string(),
2865        scope_qs,
2866        folders,
2867        loose_feeds,
2868        manage_active,
2869    }
2870}
2871
2872// ---------------------------------------------------------------------------
2873// Reader: single entry
2874// ---------------------------------------------------------------------------
2875
2876/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2877/// prev/next and "back" stay within the list the reader came from.
2878#[derive(Debug, Deserialize, Default)]
2879struct EntryQuery {
2880    #[serde(default)]
2881    feed: Option<String>,
2882    #[serde(default)]
2883    folder: Option<String>,
2884    #[serde(default)]
2885    view: Option<String>,
2886}
2887
2888/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2889/// within the current reading list.
2890async fn entry_view(
2891    State(state): State<AppState>,
2892    headers: HeaderMap,
2893    Path(id): Path<i64>,
2894    Query(q): Query<EntryQuery>,
2895) -> Result<Response, WebError> {
2896    let user = match current_session(&state, &headers).await {
2897        Some(u) => u,
2898        None => return Ok(Redirect::to("/login").into_response()),
2899    };
2900    let did = user.did.clone();
2901    let pool = &state.db;
2902
2903    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2904    // the per-DID entry gate below authorizes against the caller's current PDS
2905    // subscription set (not another user's cached feeds).
2906    let subs = resolve_subscriptions(&state, &did).await;
2907
2908    let entry = match get_entry_by_id(pool, &did, id).await? {
2909        Some(e) => e,
2910        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2911    };
2912
2913    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2914
2915    let read = entry_is_read(pool, &did, id).await?;
2916    let starred = entry_is_starred(pool, &did, id).await?;
2917
2918    // Reconstruct the current list to compute prev/next, so paging in the reader
2919    // matches what the list showed.
2920    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2921
2922    let back_qs = scope_query(&q);
2923
2924    let (folder_views, loose_feeds, _) =
2925        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2926    let nav_view = match q.view.as_deref() {
2927        Some("all") => "all",
2928        Some("starred") => "starred",
2929        _ => "unread",
2930    };
2931    let nav = build_nav(
2932        &user,
2933        nav_view,
2934        back_qs.clone(),
2935        folder_views,
2936        loose_feeds,
2937        false,
2938    );
2939
2940    let tmpl = EntryTemplate {
2941        card: Card::private(&state.config),
2942        version: VERSION,
2943        repo_url: REPO_URL,
2944        kofi_url: KOFI_URL,
2945        nav,
2946        id: entry.id,
2947        title: entry
2948            .title
2949            .clone()
2950            .filter(|t| !t.trim().is_empty())
2951            .unwrap_or_else(|| "(untitled)".to_string()),
2952        feed_title,
2953        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2954        published: display_date(entry.published.as_deref()),
2955        url: entry.url.as_deref().and_then(SafeLink::external_opt),
2956        content_html: entry.content_html.clone(),
2957        read,
2958        starred,
2959        back_qs,
2960        prev_id,
2961        next_id,
2962        oob: false,
2963    };
2964    Ok(render(&tmpl))
2965}
2966
2967/// Compute the prev/next entry ids around `current` within the reader's current
2968/// scope + view, so the reader view can offer keyboard/paging navigation.
2969async fn neighbors_in_scope(
2970    state: &AppState,
2971    did: &str,
2972    q: &EntryQuery,
2973    current: i64,
2974) -> (Option<i64>, Option<i64>) {
2975    let idx_q = IndexQuery {
2976        feed: q.feed.clone(),
2977        folder: q.folder.clone(),
2978        view: q.view.clone(),
2979        // Neighbours span the whole list, not the page the reader arrived from.
2980        page: None,
2981        flash: None,
2982    };
2983    let ids = list_entry_ids(state, did, &idx_q).await;
2984    let pos = ids.iter().position(|&x| x == current);
2985    match pos {
2986        Some(p) => {
2987            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2988            let next = ids.get(p + 1).copied();
2989            (prev, next)
2990        }
2991        None => (None, None),
2992    }
2993}
2994
2995/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2996/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2997async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2998    let pool = &state.db;
2999    let subs = resolve_subscriptions(state, did).await;
3000
3001    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
3002
3003    // Ids only, and bounded. This used to fetch whole entries — bodies included
3004    // — for all three views and then throw everything but `id` away; the "all"
3005    // branch additionally ran one unbounded query PER FEED and sorted the union
3006    // in memory. Scope is now a feed-id restriction inside the query, so the
3007    // database does the filtering and the ordering exactly once.
3008    store::list_entry_ids(
3009        pool,
3010        did,
3011        list_view_of(q.view.as_deref()),
3012        scoped_feed_ids(&subs, &scope_urls).as_deref(),
3013        PREV_NEXT_MAX,
3014    )
3015    .await
3016    .unwrap_or_else(|err| {
3017        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3018        Vec::new()
3019    })
3020}
3021
3022/// Map the `?view=` query value onto the store's list view. Anything
3023/// unrecognised is the unread default, matching `index`.
3024fn list_view_of(view: Option<&str>) -> store::ListView {
3025    match view {
3026        Some("all") => store::ListView::All,
3027        Some("starred") => store::ListView::Starred,
3028        _ => store::ListView::Unread,
3029    }
3030}
3031
3032/// Translate a feed/folder scope into the feed ids to restrict a list query to.
3033///
3034/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
3035/// matched no local feed, which must return nothing rather than everything — so
3036/// the empty vec is deliberately preserved, not collapsed back into `None`.
3037fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3038    let urls = scope_urls.as_ref()?;
3039    Some(
3040        subs.iter()
3041            .filter(|s| urls.contains(&s.sub.url))
3042            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3043            .collect(),
3044    )
3045}
3046
3047/// Build a `?…` query string that preserves the reading scope + view for links.
3048fn scope_query(q: &EntryQuery) -> String {
3049    let mut parts = Vec::new();
3050    if let Some(f) = q.feed.as_deref() {
3051        parts.push(format!("feed={}", qenc(f)));
3052    }
3053    if let Some(f) = q.folder.as_deref() {
3054        parts.push(format!("folder={}", qenc(f)));
3055    }
3056    if let Some(v) = q.view.as_deref() {
3057        if v != "unread" {
3058            parts.push(format!("view={}", qenc(v)));
3059        }
3060    }
3061    parts.join("&")
3062}
3063
3064// ---------------------------------------------------------------------------
3065// Mark read / unread
3066// ---------------------------------------------------------------------------
3067
3068/// Form body for `POST /entries/:id/read`.
3069#[derive(Debug, Deserialize)]
3070struct ReadForm {
3071    #[serde(default)]
3072    read: Option<String>,
3073}
3074
3075/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
3076async fn mark_read(
3077    State(state): State<AppState>,
3078    Path(id): Path<i64>,
3079    headers: HeaderMap,
3080    Form(form): Form<ReadForm>,
3081) -> Result<Response, WebError> {
3082    let did = match current_did(&state, &headers).await {
3083        Some(d) => d,
3084        None => return Ok(Redirect::to("/login").into_response()),
3085    };
3086    let pool = &state.db;
3087
3088    let read = matches!(
3089        form.read.as_deref(),
3090        Some("true") | Some("1") | Some("on") | None
3091    );
3092
3093    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3094    // mutation: `mark_read` only writes when `did` subscribes to the entry's
3095    // feed. A non-subscriber gets a 404, never a mutation of someone else's
3096    // (or the shared cache's) state.
3097    resolve_subscriptions(&state, &did).await;
3098    if !store::mark_read(pool, &did, id, read).await? {
3099        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3100    }
3101
3102    if !is_htmx(&headers) {
3103        return Ok(Redirect::to("/").into_response());
3104    }
3105
3106    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
3107    // in the DOM), so its button's hidden value + aria-pressed update in place
3108    // and a second keypress can reverse the toggle. The list view swaps the row.
3109    if is_reader_request(&headers) {
3110        let starred = entry_is_starred(pool, &did, id).await?;
3111        return Ok(render(&EntryActionBarTemplate {
3112            id,
3113            read,
3114            starred,
3115            oob: true,
3116        }));
3117    }
3118
3119    let row = build_entry_row(pool, &did, id, Some(read)).await?;
3120    match row {
3121        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3122        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3123    }
3124}
3125
3126// ---------------------------------------------------------------------------
3127// Star / save
3128// ---------------------------------------------------------------------------
3129
3130/// Form body for `POST /entries/:id/star`.
3131#[derive(Debug, Deserialize)]
3132struct StarForm {
3133    #[serde(default)]
3134    starred: Option<String>,
3135}
3136
3137/// `POST /entries/:id/star` — star/unstar an entry.
3138///
3139/// Sets the local `starred` bit (fast working copy) and writes/removes a
3140/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
3141/// owning). The PDS write is best-effort — the local star still lands.
3142async fn toggle_star(
3143    State(state): State<AppState>,
3144    Path(id): Path<i64>,
3145    headers: HeaderMap,
3146    Form(form): Form<StarForm>,
3147) -> Result<Response, WebError> {
3148    let did = match current_did(&state, &headers).await {
3149        Some(d) => d,
3150        None => return Ok(Redirect::to("/login").into_response()),
3151    };
3152    let pool = &state.db;
3153
3154    let starred = matches!(
3155        form.starred.as_deref(),
3156        Some("true") | Some("1") | Some("on") | None
3157    );
3158
3159    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3160    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
3161    // feed. A non-subscriber gets a 404, never a mutation.
3162    resolve_subscriptions(&state, &did).await;
3163    if !store::mark_starred(pool, &did, id, starred).await? {
3164        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3165    }
3166
3167    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
3168    // to the caller's subscriptions, so this only ever acts on the caller's feed.
3169    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3170        let entry_url = entry.url.clone().unwrap_or_default();
3171        if !entry_url.is_empty() {
3172            if starred {
3173                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3174                saved.title = entry.title.clone();
3175                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3176                saved.entry_id = Some(entry.guid.clone());
3177                match state.repo().add_saved(&did, &saved).await {
3178                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3179                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3180                }
3181            } else {
3182                // Un-star: find and delete the matching saved record by URL.
3183                match state.repo().list_saved(&did).await {
3184                    Ok(records) => {
3185                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3186                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3187                                warn!(%err, %did, %rkey, "PDS saved delete failed");
3188                            }
3189                        }
3190                    }
3191                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3192                }
3193            }
3194        }
3195    }
3196
3197    if !is_htmx(&headers) {
3198        return Ok(Redirect::to("/").into_response());
3199    }
3200
3201    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
3202    if is_reader_request(&headers) {
3203        let read = entry_is_read(pool, &did, id).await?;
3204        return Ok(render(&EntryActionBarTemplate {
3205            id,
3206            read,
3207            starred,
3208            oob: true,
3209        }));
3210    }
3211
3212    let row = build_entry_row(pool, &did, id, None).await?;
3213    match row {
3214        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3215        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3216    }
3217}
3218
3219/// The feed URL for a cached feed id, if the row exists.
3220async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3221    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3222        .bind(feed_id)
3223        .fetch_optional(pool)
3224        .await
3225        .ok()
3226        .flatten()
3227}
3228
3229// ---------------------------------------------------------------------------
3230// Mark-all-read
3231// ---------------------------------------------------------------------------
3232
3233/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
3234/// absent means mark everything read.
3235#[derive(Debug, Deserialize, Default)]
3236struct ReadAllQuery {
3237    #[serde(default)]
3238    feed: Option<String>,
3239}
3240
3241/// `POST /read-all` — mark every entry read for the current DID, optionally
3242/// scoped to one feed (mark-all-read per feed or globally).
3243async fn mark_all_read(
3244    State(state): State<AppState>,
3245    headers: HeaderMap,
3246    Query(q): Query<ReadAllQuery>,
3247) -> Result<Response, WebError> {
3248    let did = match current_did(&state, &headers).await {
3249        Some(d) => d,
3250        None => return Ok(Redirect::to("/login").into_response()),
3251    };
3252    let pool = &state.db;
3253
3254    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
3255    // only ever touch feeds this DID actually subscribes to.
3256    resolve_subscriptions(&state, &did).await;
3257
3258    if let Some(feed_url) = q.feed.as_deref() {
3259        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3260            store::mark_feed_read(pool, &did, feed.id, true).await?;
3261        }
3262        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3263    }
3264
3265    // Global: mark every subscribed feed read. Fan out over the DID's feeds
3266    // (bounded by the per-DID subscription cap) using the batched per-feed path,
3267    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
3268    // state, but O(feeds) statements instead of O(unread entries).
3269    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3270        store::mark_feed_read(pool, &did, feed_id, true).await?;
3271    }
3272    Ok(Redirect::to("/").into_response())
3273}
3274
3275// ---------------------------------------------------------------------------
3276// Subscribe by URL
3277// ---------------------------------------------------------------------------
3278
3279/// Flash for a URL this instance cannot store as a feed — not private, just
3280/// not a kind of feed it supports (an `at://` publication with
3281/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
3282/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
3283/// false promise for a record that may already exist in the user's PDS.
3284const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3285    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3286
3287/// Shown when an OPML export is refused because the subscription list could not
3288/// be read in full.
3289///
3290/// **An empty export is worse than no export.** This path used to
3291/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
3292/// file — a blank backup, handed over at the moment the reader reached for one.
3293const EXPORT_INCOMPLETE_REFUSAL: &str =
3294    "Could not read your subscriptions in full, so nothing was exported. Your \
3295     feeds are unchanged — try again, and if it keeps failing the list may be \
3296     larger than this reader can page through.";
3297
3298/// Refusal message shown when a private/paid feed is submitted. FeatherReader
3299/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
3300/// only for now — a private feed's secret URL is never saved, fetched, or sent
3301/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
3302/// and the boot-smoke can assert on it.
3303const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3304    FeatherReader stores your subscriptions in your public PDS, so it supports public \
3305    feeds for now — private-feed support arrives when atproto's private data \
3306    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3307
3308/// Form body for `POST /subscriptions`.
3309#[derive(Debug, Deserialize)]
3310struct SubscribeForm {
3311    url: String,
3312    /// Optional folder `at://` URI to file the new feed under.
3313    #[serde(default)]
3314    folder: Option<String>,
3315}
3316
3317/// The DID-form URL to store for a pasted `at://` publication, or the flash
3318/// to refuse it with.
3319///
3320/// - The scheme is canonicalised: `At://` is the same publication, and
3321///   storing a second spelling makes a second row for it (#183).
3322/// - It must name a `site.standard.publication`; anything else is not a feed
3323///   this instance can read.
3324/// - A handle is resolved to its DID: a handle is a mutable name, and
3325///   `feeds.url` is keyed on identity, so only the DID form is stored.
3326async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3327    let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3328    let canonical = format!(
3329        "{}{}",
3330        crate::atproto::AT_URI_PREFIX,
3331        &input[crate::atproto::AT_URI_PREFIX.len()..]
3332    );
3333    let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3334    if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3335        return Err(unsupported());
3336    }
3337    let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3338        uri.authority.clone()
3339    } else {
3340        let handle =
3341            // Validated as a handle before it is sent anywhere: an authority
3342            // that is neither a DID nor a handle (`did:plc:TOOSHORT`, an
3343            // uppercase DID, a newline) is unsupported, not a lookup (found in
3344            // review).
3345            crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3346        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3347            .await
3348            .map_err(|err| {
3349                warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3350                format!("Couldn't resolve the handle {} to an account.", uri.authority)
3351            })?
3352    };
3353    let url = format!(
3354        "{}{did}/{}/{}",
3355        crate::atproto::AT_URI_PREFIX,
3356        uri.collection,
3357        uri.rkey
3358    );
3359    if !feed::is_storable_feed_url(&url, true) {
3360        return Err(unsupported());
3361    }
3362    Ok(url)
3363}
3364
3365/// `POST /subscriptions` — subscribe by URL.
3366async fn add_subscription(
3367    State(state): State<AppState>,
3368    headers: HeaderMap,
3369    Form(form): Form<SubscribeForm>,
3370) -> Result<Response, WebError> {
3371    let did = match current_did(&state, &headers).await {
3372        Some(d) => d,
3373        None => return Ok(Redirect::to("/login").into_response()),
3374    };
3375    let pool = &state.db;
3376    let input = form.url.trim().to_string();
3377    if input.is_empty() {
3378        return Ok(Redirect::to("/").into_response());
3379    }
3380
3381    // Per-DID subscription cap: bound one account's storage/poller footprint on
3382    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3383    // can't even trigger an outbound request. `<= 0` disables the cap.
3384    let cap = state.config.max_subs_per_did;
3385    if cap > 0 {
3386        match store::count_subscriptions_for_did(pool, &did).await {
3387            Ok(n) if n >= cap => {
3388                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3389                return Ok(Redirect::to(&format!(
3390                    "/?flash={}",
3391                    qenc(&format!(
3392                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3393                    ))
3394                ))
3395                .into_response());
3396            }
3397            Ok(_) => {}
3398            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3399        }
3400    }
3401
3402    // **An at:// paste is a standard.site publication, read by the poller
3403    // since 0.4.0**: canonicalised and resolved to its DID form here, then it
3404    // joins the ordinary path below. With the flag off it is refused as it
3405    // always was — the flag gates what may be stored.
3406    let is_at_uri = input
3407        .get(..crate::atproto::AT_URI_PREFIX.len())
3408        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3409    let publication_url = if is_at_uri {
3410        if !state.config.standard_site {
3411            info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3412            return Ok(
3413                Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3414                    .into_response(),
3415            );
3416        }
3417        match publication_url_from_paste(&state, &input).await {
3418            Ok(url) => Some(url),
3419            Err(flash) => {
3420                info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3421                return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3422            }
3423        }
3424    } else {
3425        None
3426    };
3427
3428    if let feed::FeedPrivacy::Private(reason) =
3429        feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3430    {
3431        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3432        return Ok(
3433            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3434        );
3435    }
3436
3437    let resolved = match publication_url {
3438        Some(url) => Ok(url),
3439        None => resolve_feed_url(&state.config, &input).await,
3440    };
3441    let feed_url = match resolved {
3442        Ok(u) => u,
3443        Err(err) => {
3444            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3445            return Ok(Redirect::to(&format!(
3446                "/?flash={}",
3447                qenc("Couldn't find a feed at that URL")
3448            ))
3449            .into_response());
3450        }
3451    };
3452
3453    // Defensive: resolution may have discovered a feed URL that itself carries a
3454    // secret (e.g. a public site page linking a tokened feed). Re-check the
3455    // resolved URL and refuse before storing/writing anything.
3456    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3457        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3458        return Ok(
3459            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3460        );
3461    }
3462
3463    // The URL about to be STORED is what must be storable — not the one the
3464    // user typed. Autodiscovery already yields only http(s), but this is the
3465    // path that writes the row and the PDS record, so the check lives here too:
3466    // the same gate the OPML and rename paths apply, on the same terms.
3467    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3468        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3469        return Ok(
3470            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3471                .into_response(),
3472        );
3473    }
3474
3475    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3476    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3477    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3478    let feeds_cap = state.config.max_feeds_global;
3479    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3480        match store::count_feeds(pool).await {
3481            Ok(n) if n >= feeds_cap => {
3482                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3483                return Ok(Redirect::to(&format!(
3484                    "/?flash={}",
3485                    qenc(
3486                        "This instance is at its feed capacity right now. Please try again later."
3487                    )
3488                ))
3489                .into_response());
3490            }
3491            Ok(_) => {}
3492            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3493        }
3494    }
3495
3496    store::upsert_feed(
3497        pool,
3498        &store::NewFeed {
3499            url: feed_url.clone(),
3500            ..Default::default()
3501        },
3502    )
3503    .await?;
3504
3505    if let Ok(client) = feed::build_client() {
3506        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3507            match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3508                Ok(outcome) => {
3509                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3510                    // **This path is not the scheduler, so it must settle the
3511                    // error columns itself.** `poll_feed` writes validators and
3512                    // `last_polled` and nothing else.
3513                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3514                }
3515                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3516            }
3517        }
3518    }
3519
3520    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3521    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3522        sub.title = feed_row.title.clone();
3523        sub.site_url = feed_row.site_url.clone();
3524    }
3525    sub.folder = form
3526        .folder
3527        .map(|f| f.trim().to_string())
3528        .filter(|f| !f.is_empty());
3529
3530    match state.repo().add_subscription(&did, &sub).await {
3531        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3532        Err(err) => {
3533            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3534        }
3535    }
3536
3537    Ok(Redirect::to("/").into_response())
3538}
3539
3540/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3541async fn delete_subscription(
3542    State(state): State<AppState>,
3543    headers: HeaderMap,
3544    Path(rkey): Path<String>,
3545) -> Result<Response, WebError> {
3546    let did = match current_did(&state, &headers).await {
3547        Some(d) => d,
3548        None => return Ok(Redirect::to("/login").into_response()),
3549    };
3550    match state.repo().remove_subscription(&did, &rkey).await {
3551        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3552        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3553    }
3554    Ok(Redirect::to("/").into_response())
3555}
3556
3557/// Form body for `POST /subscriptions/:rkey/rename`.
3558#[derive(Debug, Deserialize)]
3559struct RenameSubForm {
3560    url: String,
3561    #[serde(default)]
3562    title: Option<String>,
3563    #[serde(default)]
3564    site_url: Option<String>,
3565    #[serde(default)]
3566    folder: Option<String>,
3567}
3568
3569/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3570/// folder, rewriting the whole subscription record via `putRecord`.
3571async fn rename_subscription(
3572    State(state): State<AppState>,
3573    headers: HeaderMap,
3574    Path(rkey): Path<String>,
3575    Form(form): Form<RenameSubForm>,
3576) -> Result<Response, WebError> {
3577    let did = match current_did(&state, &headers).await {
3578        Some(d) => d,
3579        None => return Ok(Redirect::to("/login").into_response()),
3580    };
3581    let feed_url = form.url.trim().to_string();
3582
3583    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3584    // write a junk row to the cache or a malformed subscription record to the
3585    // PDS (add_subscription refuses an empty input the same way).
3586    if feed_url.is_empty() {
3587        return Ok(Redirect::to("/").into_response());
3588    }
3589
3590    // **Read before write — `update_subscription` is a `putRecord`, and a
3591    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3592    //
3593    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3594    // and hand that over, so every field the form does not carry was written
3595    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3596    // `folder` — and nothing else — so a rename silently destroyed four fields:
3597    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3598    //
3599    // `createdAt` is the one that matters most: it is the reader's subscribe
3600    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3601    // repo rather than our cache, and once overwritten it is gone with nothing
3602    // in the UI to say so.
3603    //
3604    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3605    // and filters. That is one extra round trip on an action that is already
3606    // doing a PDS write, and it is bounded; a `get_subscription` would be
3607    // strictly better if this ever measures badly.
3608    //
3609    // **A failed read refuses the rename.** Falling back to the old
3610    // rebuild-from-scratch here would reinstate the data loss on exactly the
3611    // flaky path, which is the worst place to have it. The write below already
3612    // takes this stance — "a failure here means nothing was renamed or moved" —
3613    // and the read gets the same one.
3614    let existing = match state.repo().list_subscriptions_sorted(&did).await {
3615        Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3616        Err(err) => {
3617            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3618            return Ok(Redirect::to(&format!(
3619                "/?flash={}",
3620                qenc("Could not reach your PDS — nothing was renamed or moved.")
3621            ))
3622            .into_response());
3623        }
3624    };
3625    let Some(existing) = existing else {
3626        // The rkey is not in the reader's repo. Renaming a record that is not
3627        // there would CREATE one, which is not what "rename" means and would
3628        // give it a fresh `createdAt` — the bug this read exists to prevent.
3629        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3630        return Ok(Redirect::to(&format!(
3631            "/?flash={}",
3632            qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3633        ))
3634        .into_response());
3635    };
3636
3637    // The subscription can be repointed at a different feed URL. **Every gate
3638    // on the URL applies to a repoint and only a repoint** — the three below
3639    // were each, at one time, run before this line on the URL as posted, and
3640    // each refused a pure retitle of a record that already existed:
3641    //
3642    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3643    //   at-URI that is not a publication (a feed generator another client
3644    //   subscribed to), so the record became un-editable with a flash saying
3645    //   it "was not saved or sent anywhere";
3646    // - the global feeds ceiling keyed on "URL not in the cache", and an
3647    //   at:// record is never cached with the flag off, so at capacity a
3648    //   retitle was refused for a row the handler would not insert;
3649    // - storability, the same way.
3650    //
3651    // An unchanged URL is already in the reader's repo; refusing to retitle
3652    // it protects nothing and takes their own record away from them.
3653    // Like for like: the form value is trimmed, and a record another client
3654    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3655    let url_changed = existing.url.trim() != feed_url;
3656
3657    // **Storability, on the same terms as the add and OPML paths — for a
3658    // REPOINT, and FIRST.** A target this instance cannot store gets that
3659    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3660    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3661    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3662    // here; a review found it by enumerating every writer of the table. The
3663    // first fix ran this check before the repo lookup, on the URL as posted —
3664    // which refused a pure retitle of a subscription that already IS an
3665    // at-URI, on every instance with the flag off. The flag gates what the
3666    // cache may store, not whether a reader may edit their own record: an
3667    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3668    // row below.
3669    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3670    if url_changed && !storable {
3671        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3672        return Ok(
3673            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3674                .into_response(),
3675        );
3676    }
3677
3678    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3679    // and rename both upserts it to the local cache AND rewrites the PDS
3680    // subscription record (a public `putRecord`), so without this guard a
3681    // crafted rename could land a secret-bearing URL in the public PDS — the
3682    // exact leak the add and OPML paths already prevent.
3683    if url_changed {
3684        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3685            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3686            return Ok(
3687                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3688            );
3689        }
3690    }
3691
3692    // Global feeds ceiling parity with add_subscription: a repoint to a
3693    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3694    // shared cache is at capacity (an existing/duplicate URL adds no row and
3695    // is always fine). `<= 0` disables.
3696    let feeds_cap = state.config.max_feeds_global;
3697    if url_changed
3698        && feeds_cap > 0
3699        && store::get_feed_by_url(&state.db, &feed_url)
3700            .await?
3701            .is_none()
3702    {
3703        match store::count_feeds(&state.db).await {
3704            Ok(n) if n >= feeds_cap => {
3705                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3706                return Ok(Redirect::to(&format!(
3707                    "/?flash={}",
3708                    qenc(
3709                        "This instance is at its feed capacity right now. Please try again later."
3710                    )
3711                ))
3712                .into_response());
3713            }
3714            Ok(_) => {}
3715            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3716        }
3717    }
3718
3719    let mut sub = existing;
3720    sub.url = feed_url;
3721    sub.title = form
3722        .title
3723        .map(|t| t.trim().to_string())
3724        .filter(|t| !t.is_empty());
3725    sub.folder = form
3726        .folder
3727        .map(|f| f.trim().to_string())
3728        .filter(|f| !f.is_empty());
3729    // `createdAt` and `private` carry over untouched — neither is a property of
3730    // which feed URL the subscription points at.
3731    //
3732    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3733    // repoint drops them rather than leaving a site link for the old feed
3734    // hanging off the new one. An explicit form value still wins if the form
3735    // ever starts carrying one.
3736    match form
3737        .site_url
3738        .map(|t| t.trim().to_string())
3739        .filter(|t| !t.is_empty())
3740    {
3741        Some(site) => sub.site_url = Some(site),
3742        None if url_changed => sub.site_url = None,
3743        None => {}
3744    }
3745    if url_changed {
3746        sub.fetch_hint = None;
3747    }
3748
3749    // Keep the local cache title in step for the loose-feed fallback path —
3750    // for a row this instance would have. Two cases write nothing:
3751    //
3752    // - not storable (an existing at-URI with the flag off): the record is the
3753    //   reader's to edit, the cache row is not this instance's to create;
3754    // - an unchanged URL with no cache row: a retitle is never the write that
3755    //   CREATES a row. That covers two findings at once — the ceiling is
3756    //   checked on a repoint only, so a retitle must not insert past it; and
3757    //   a secret-bearing URL another client subscribed to has no row (the
3758    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
3759    //   refuses to cache it), so it cannot enter the shared table here, be
3760    //   polled, fail, and be printed on the admin page. A privacy re-check on
3761    //   this write was the first draft; mutation showed it dead — the row
3762    //   rule already refused every case it would have.
3763    let cache_write =
3764        storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3765    if !cache_write {
3766        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3767    } else if let Err(err) = store::upsert_feed(
3768        &state.db,
3769        &store::NewFeed {
3770            url: sub.url.clone(),
3771            title: sub.title.clone(),
3772            site_url: sub.site_url.clone(),
3773            ..Default::default()
3774        },
3775    )
3776    .await
3777    {
3778        // Not fatal to the rename — the PDS record below is the source of truth
3779        // — but a missing `feeds` row means this subscription is never polled.
3780        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3781    }
3782
3783    // **The PDS write decides what the reader is told.**
3784    //
3785    // This used to `warn!` on failure and then redirect exactly as it does on
3786    // success, so a rename that did not happen was indistinguishable from one
3787    // that did — the reader saw their old title come back and had no reason to
3788    // think anything had gone wrong. The PDS record IS the subscription; a
3789    // failure here means nothing was renamed or moved.
3790    match state.repo().update_subscription(&did, &rkey, &sub).await {
3791        Ok(res) => {
3792            info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3793            Ok(Redirect::to("/").into_response())
3794        }
3795        Err(err) => {
3796            warn!(%err, %did, %rkey, "PDS subscription update failed");
3797            Ok(Redirect::to(&format!(
3798                "/?flash={}",
3799                qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3800            ))
3801            .into_response())
3802        }
3803    }
3804}
3805
3806// ---------------------------------------------------------------------------
3807// Folders
3808// ---------------------------------------------------------------------------
3809
3810/// Form body for `POST /folders`.
3811#[derive(Debug, Deserialize)]
3812struct FolderForm {
3813    name: String,
3814}
3815
3816/// `POST /folders` — create a folder record.
3817async fn create_folder(
3818    State(state): State<AppState>,
3819    headers: HeaderMap,
3820    Form(form): Form<FolderForm>,
3821) -> Result<Response, WebError> {
3822    let did = match current_did(&state, &headers).await {
3823        Some(d) => d,
3824        None => return Ok(Redirect::to("/login").into_response()),
3825    };
3826    let name = form.name.trim();
3827    if name.is_empty() {
3828        return Ok(Redirect::to("/").into_response());
3829    }
3830    let folder = Folder::new(name.to_string(), now_rfc3339());
3831    match state.repo().add_folder(&did, &folder).await {
3832        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3833        Err(err) => warn!(%err, %did, "PDS folder create failed"),
3834    }
3835    Ok(Redirect::to("/").into_response())
3836}
3837
3838/// `POST /folders/:rkey/rename` — rename a folder record.
3839async fn rename_folder(
3840    State(state): State<AppState>,
3841    headers: HeaderMap,
3842    Path(rkey): Path<String>,
3843    Form(form): Form<FolderForm>,
3844) -> Result<Response, WebError> {
3845    let did = match current_did(&state, &headers).await {
3846        Some(d) => d,
3847        None => return Ok(Redirect::to("/login").into_response()),
3848    };
3849    let name = form.name.trim();
3850    if name.is_empty() {
3851        return Ok(Redirect::to("/").into_response());
3852    }
3853    let folder = Folder::new(name.to_string(), now_rfc3339());
3854    match state.repo().rename_folder(&did, &rkey, &folder).await {
3855        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3856        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3857    }
3858    Ok(Redirect::to("/").into_response())
3859}
3860
3861/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3862/// simply become un-foldered).
3863async fn delete_folder(
3864    State(state): State<AppState>,
3865    headers: HeaderMap,
3866    Path(rkey): Path<String>,
3867) -> Result<Response, WebError> {
3868    let did = match current_did(&state, &headers).await {
3869        Some(d) => d,
3870        None => return Ok(Redirect::to("/login").into_response()),
3871    };
3872    match state.repo().remove_folder(&did, &rkey).await {
3873        Ok(()) => info!(%did, %rkey, "deleted folder record"),
3874        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3875    }
3876    Ok(Redirect::to("/").into_response())
3877}
3878
3879/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3880/// feed document we take it as-is; if it yields an HTML page we run
3881/// autodiscovery over its `<link rel="alternate">` tags.
3882async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3883    let parsed =
3884        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3885
3886    let client = feed::build_client()?;
3887    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3888    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3889    // loopback / private hosts.
3890    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3891    let final_url = resp.url().clone();
3892    let content_type = resp
3893        .headers()
3894        .get(axum::http::header::CONTENT_TYPE)
3895        .and_then(|v| v.to_str().ok())
3896        .unwrap_or("")
3897        .to_ascii_lowercase();
3898    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3899    // gzip strips it, and this response is reflected into the UI.
3900    let raw = crate::net::read_capped(resp).await?;
3901    let body = String::from_utf8_lossy(&raw).into_owned();
3902
3903    let looks_like_feed = content_type.contains("xml")
3904        || content_type.contains("rss")
3905        || content_type.contains("atom")
3906        || content_type.contains("application/feed+json")
3907        || {
3908            let head = body.trim_start();
3909            head.starts_with("<?xml")
3910                || head.starts_with("<rss")
3911                || head.starts_with("<feed")
3912                || head.contains("<rss")
3913                || head.contains("<feed")
3914        };
3915    if looks_like_feed {
3916        return Ok(final_url.to_string());
3917    }
3918
3919    match feed::discover_feed(&body, Some(&final_url)) {
3920        Some(u) => Ok(u.to_string()),
3921        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3922    }
3923}
3924
3925// ---------------------------------------------------------------------------
3926// Login (atproto OAuth via the sidecar)
3927// ---------------------------------------------------------------------------
3928
3929/// Query for `GET /login`.
3930#[derive(Debug, Deserialize, Default)]
3931struct LoginQuery {
3932    #[serde(default)]
3933    handle: Option<String>,
3934    #[serde(default)]
3935    error: Option<String>,
3936    #[serde(default)]
3937    flash: Option<String>,
3938}
3939
3940/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3941///
3942/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3943/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3944/// session cookie *or* the submitted handle resolving to a seated DID) or a
3945/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3946/// form (no handle) always renders.
3947async fn login_form(
3948    State(state): State<AppState>,
3949    headers: HeaderMap,
3950    Query(q): Query<LoginQuery>,
3951) -> Response {
3952    if let Some(handle) = q
3953        .handle
3954        .map(|h| h.trim().to_string())
3955        .filter(|h| !h.is_empty())
3956    {
3957        if !may_start_oauth(&state, &headers, &handle).await {
3958            return Redirect::to("/beta/redeem").into_response();
3959        }
3960        return start_oauth(&state, &handle).await;
3961    }
3962    render(&LoginTemplate {
3963        card: login_card(&state.config),
3964        repo_url: REPO_URL,
3965        error: q.error.unwrap_or_default(),
3966        flash: q.flash.unwrap_or_default(),
3967    })
3968}
3969
3970/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3971/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3972async fn login_submit(
3973    State(state): State<AppState>,
3974    headers: HeaderMap,
3975    Form(form): Form<LoginForm>,
3976) -> Response {
3977    let handle = form.handle.trim();
3978    if handle.is_empty() {
3979        return login_error(&state, "Enter your atproto handle.");
3980    }
3981    if !may_start_oauth(&state, &headers, handle).await {
3982        return Redirect::to("/beta/redeem").into_response();
3983    }
3984    start_oauth(&state, handle).await
3985}
3986
3987/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3988/// admits, in order of cost:
3989///
3990/// 1. an existing beta member's cookie session whose DID already holds a seat;
3991/// 2. a fresh visitor carrying a valid reserving invite cookie;
3992/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3993///    already holds a seat — this honors the **seeded admin's first login** on a
3994///    fresh deploy (and any returning member who cleared cookies) without a
3995///    session cookie or an invite code.
3996///
3997/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3998/// handle→DID resolution is only attempted when neither applies. It fails
3999/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
4000/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
4001/// This keeps the anti-abuse intent — a rando now pays a cheap handle
4002/// resolution instead of a burned sidecar handshake (and `/login` is already in
4003/// the rate-limited path set).
4004async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
4005    // The production resolver is the app's existing atproto handle→DID path,
4006    // routed through the SSRF guard. Resolution is injected so tests can exercise
4007    // the gate without a live network call (the guard forbids loopback mocks).
4008    may_start_oauth_with(state, headers, handle, |h| async move {
4009        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
4010            .await
4011            .ok()
4012    })
4013    .await
4014}
4015
4016/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
4017/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
4018/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
4019/// only called when neither admits — keeping the network round-trip off the hot
4020/// path and preserving the fail-closed contract on resolution failure.
4021async fn may_start_oauth_with<F, Fut>(
4022    state: &AppState,
4023    headers: &HeaderMap,
4024    handle: &str,
4025    resolve: F,
4026) -> bool
4027where
4028    F: FnOnce(String) -> Fut,
4029    Fut: std::future::Future<Output = Option<String>>,
4030{
4031    // 1. An already-beta'd session may re-auth freely.
4032    if let Some(did) = current_did(state, headers).await {
4033        if store::has_beta_access(&state.db, &did)
4034            .await
4035            .unwrap_or(false)
4036        {
4037            return true;
4038        }
4039    }
4040    // 2. A valid reserving invite cookie.
4041    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4042        return true;
4043    }
4044    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
4045    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
4046    //    on any resolution error or unresolvable/malformed handle.
4047    match resolve(handle.to_string()).await {
4048        Some(did) => store::has_beta_access(&state.db, &did)
4049            .await
4050            .unwrap_or(false),
4051        None => {
4052            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4053            false
4054        }
4055    }
4056}
4057
4058/// Begin the OAuth handshake for `handle`, on whichever backend is live.
4059///
4060/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
4061/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
4062/// carries `form-action 'self'`. Browsers have historically disagreed about
4063/// whether that directive applies to redirects following a form submission, and
4064/// if it did here, login would break in a browser while every test passed.
4065///
4066/// It does not, and the evidence is the SIDECAR path, which is live in
4067/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
4068/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
4069/// whole redirect chain would already be blocking that. One checking only the
4070/// form's action URL sees `/login` in both cases. The two arms differ only in
4071/// how many same-origin hops precede the cross-origin one, so any policy that
4072/// permits the sidecar flow permits this one.
4073///
4074/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
4075/// its own `/login` and its own callback, so starting a login is one redirect
4076/// and nothing is stored here. The Rust backend pushes the authorization
4077/// request itself, which means this app now holds the pending login — and must
4078/// set the browser-binding cookie that the callback will be checked against.
4079async fn start_oauth(state: &AppState, handle: &str) -> Response {
4080    match state.config.repo_backend {
4081        crate::metrics::Backend::Sidecar => {
4082            let url = state.sidecar.login_url(handle, None);
4083            info!(%handle, "redirecting to OAuth sidecar login");
4084            Redirect::to(&url).into_response()
4085        }
4086        crate::metrics::Backend::Rust => {
4087            let Some(runtime) = state.oauth.as_deref() else {
4088                warn!("the rust backend is live but its OAuth runtime is absent");
4089                return login_error(state, "Login is not available right now.");
4090            };
4091            match crate::oauth::login::start(
4092                runtime,
4093                &state.http,
4094                &state.db,
4095                handle,
4096                crate::store::now_unix(),
4097            )
4098            .await
4099            {
4100                Ok(started) => {
4101                    info!(%handle, "pushed authorization request; redirecting to the PDS");
4102                    let mut resp = Redirect::to(&started.authorize_url).into_response();
4103                    set_cookie(
4104                        &mut resp,
4105                        &cookie::sign_value(
4106                            OAUTH_BINDING_COOKIE,
4107                            &started.binding_token,
4108                            &state.config.cookie_secret,
4109                            OAUTH_BINDING_MAX_AGE_SECS,
4110                        ),
4111                    );
4112                    resp
4113                }
4114                Err(err) => {
4115                    // The handle the user typed is logged; the error is not shown
4116                    // to them verbatim, since it can name internal hosts.
4117                    warn!(%err, %handle, "could not start the OAuth login");
4118                    login_error(state, "Could not start login for that handle.")
4119                }
4120            }
4121        }
4122    }
4123}
4124
4125/// Clear the browser-binding cookie. Called on every terminal outcome of a
4126/// callback, successful or not: the pending row is consumed either way, so a
4127/// lingering cookie can only ever match a login that no longer exists.
4128fn clear_binding_cookie(resp: &mut Response) {
4129    set_cookie(
4130        resp,
4131        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4132    );
4133}
4134
4135/// Form body for `POST /login`.
4136#[derive(Debug, Deserialize)]
4137struct LoginForm {
4138    handle: String,
4139}
4140
4141/// Query for `GET /oauth/callback`.
4142///
4143/// Carries BOTH shapes, because the two backends deliver different things to
4144/// the same URL: the sidecar hands back a one-shot `session_id` it has already
4145/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
4146/// for this app to exchange itself. Which fields are populated is decided by
4147/// which backend started the login, not by which is live now — so a flip with a
4148/// login already in flight still lands in the right arm.
4149#[derive(Debug, Deserialize, Default)]
4150struct CallbackQuery {
4151    /// Sidecar backend: the handoff id.
4152    #[serde(default)]
4153    session_id: Option<String>,
4154    /// Rust backend: the authorization code and its envelope.
4155    #[serde(default)]
4156    code: Option<String>,
4157    #[serde(default)]
4158    state: Option<String>,
4159    #[serde(default)]
4160    iss: Option<String>,
4161    /// JARM, which is not supported — carried only so it can be refused
4162    /// explicitly rather than read as "no code".
4163    #[serde(default)]
4164    response: Option<String>,
4165    #[serde(default)]
4166    error: Option<String>,
4167    #[serde(default)]
4168    error_description: Option<String>,
4169}
4170
4171/// `GET /oauth/callback` — establish the cookie session.
4172///
4173/// **Invite gate:** the verified DID must hold beta access. If it already does
4174/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
4175/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
4176/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
4177async fn oauth_callback(
4178    State(state): State<AppState>,
4179    headers: HeaderMap,
4180    Query(q): Query<CallbackQuery>,
4181) -> Response {
4182    // An error response is handled by the SAME arm that would have handled a
4183    // success, not short-circuited here.
4184    //
4185    // Returning early looks obviously right and is wrong on the Rust path: it
4186    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
4187    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
4188    // error originates from the intended AS". It also leaves the pending row
4189    // unconsumed, so a `state` that has already produced a callback stays usable
4190    // until it expires.
4191    //
4192    // The sidecar arm has no such check to reach, so it is short-circuited
4193    // below, preserving exactly what it did before.
4194    // **The arm is chosen by what the SERVER knows, not by what the caller
4195    // sent.** A `session_id` in the query used to select the sidecar arm on its
4196    // own — so a caller could pick which code path ran, and the sidecar arm has
4197    // no browser-binding check at all. It also short-circuited the error path
4198    // below, skipping the `iss` validation.
4199    //
4200    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
4201    // configured one, means the selection follows this deployment's own
4202    // configuration. A login started before a flip still completes, because the
4203    // Rust arm is reached whenever the Rust runtime exists and can match the
4204    // `state` against a pending row it actually wrote.
4205    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
4206    // and `?error=…&error_description=…` on its own failure. Keying only on
4207    // `session_id` sent the failure shape down the Rust arm, which then failed
4208    // with "no `state`" and replaced the specific reason with a generic one —
4209    // and `error_description` is exactly what the sidecar Caddy routing matches
4210    // to send that request here in the first place.
4211    let sidecar_shape =
4212        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4213    let sidecar_handoff = sidecar_shape
4214        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4215    if let Some(err) = q.error.clone() {
4216        // **Neither the code nor the description is echoed as sent.**
4217        //
4218        // Both are server-controlled free text arriving on a public GET, so
4219        // anyone who can make a browser fetch this URL chooses them. The raw
4220        // `error` used to go into a `warn!` AND into the rendered login page,
4221        // and `error_description` — arbitrary text, newlines included — went
4222        // into the log verbatim: a log-injection surface on one side and
4223        // attacker-chosen copy in the product's own voice on the other.
4224        //
4225        // `oauth::flow` already decided this exact question for the Rust arm:
4226        // reduce the code to a known slug, drop the description entirely. That
4227        // reasoning is not specific to which arm handles the callback, and this
4228        // one simply never got the same treatment. The description's LENGTH is
4229        // kept, because "the server sent a 4 KB explanation" is occasionally
4230        // worth knowing and cannot be used to inject anything.
4231        let slug = crate::oauth::flow::known_error_slug(&err);
4232        warn!(
4233            error = slug,
4234            desc_len = q.error_description.as_deref().map_or(0, str::len),
4235            "OAuth callback returned an error"
4236        );
4237        if sidecar_handoff || state.oauth.is_none() {
4238            return login_error(&state, &format!("Login failed: {slug}"));
4239        }
4240        // Fall through: the Rust arm consumes the pending row and validates
4241        // `iss` against it, and reports the failure afterwards.
4242    }
4243
4244    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
4245    // currently selected: a login started before a flip must still complete.
4246    let session = if sidecar_handoff {
4247        let session_id = q.session_id.clone().unwrap_or_default();
4248        match state.sidecar.resolve_session(&session_id).await {
4249            Ok(Some(s)) => s,
4250            Ok(None) => {
4251                warn!("OAuth callback session_id did not resolve (expired/unknown)");
4252                return login_error(&state, "Login session expired — please try again.");
4253            }
4254            Err(err) => {
4255                warn!(%err, "failed to resolve OAuth session via the sidecar");
4256                return login_error(&state, "Login failed talking to the auth service.");
4257            }
4258        }
4259    } else {
4260        let Some(runtime) = state.oauth.as_deref() else {
4261            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4262            return login_error(&state, "Login failed: this login could not be completed.");
4263        };
4264        let params = crate::oauth::flow::CallbackParams {
4265            code: q.code.clone(),
4266            state: q.state.clone(),
4267            iss: q.iss.clone(),
4268            // Passed through, NOT dropped: `verify_callback` checks `iss`
4269            // against the pending row's issuer before it reports the error, and
4270            // it cannot do that for an error it never sees.
4271            error: q.error.clone(),
4272            error_description: q.error_description.clone(),
4273            response: q.response.clone(),
4274        };
4275        let binding =
4276            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4277        match crate::oauth::login::complete(
4278            runtime,
4279            &state.http,
4280            &state.db,
4281            &params,
4282            binding.as_deref(),
4283            crate::store::now_unix(),
4284        )
4285        .await
4286        {
4287            Ok(done) => crate::atproto::SidecarSession {
4288                did: done.did,
4289                handle: done.handle,
4290            },
4291            Err(err) => {
4292                // Never echoed to the browser: the message can name the issuer,
4293                // the PDS, and why a binding check failed.
4294                warn!(%err, "could not complete the OAuth callback");
4295                let mut resp = login_error(&state, "Login failed — please try again.");
4296                clear_binding_cookie(&mut resp);
4297                return resp;
4298            }
4299        }
4300    };
4301
4302    // Bind the verified DID to the invite gate. Returns a response only on the
4303    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
4304    let mut clear_invite = false;
4305    if !store::has_beta_access(&state.db, &session.did)
4306        .await
4307        .unwrap_or(false)
4308    {
4309        // Not yet a member: consume the reserved invite code, if any.
4310        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4311            Some(c) => c,
4312            None => {
4313                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4314                return Redirect::to("/beta/redeem").into_response();
4315            }
4316        };
4317        match store::redeem_code(
4318            &state.db,
4319            &code,
4320            &session.did,
4321            session.handle.as_deref(),
4322            state.config.beta_cap,
4323        )
4324        .await
4325        {
4326            Ok(Ok(())) => {
4327                clear_invite = true;
4328                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4329            }
4330            Ok(Err(policy)) => {
4331                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4332                let mut resp = redeem_bounce(&state, &policy).into_response();
4333                // The reservation is spent/invalid — drop the stale invite cookie.
4334                clear_invite_cookie(&mut resp);
4335                return resp;
4336            }
4337            Err(err) => {
4338                warn!(%err, did = %session.did, "invite redeem infra error at callback");
4339                return login_error(&state, "Login failed while confirming your invite.");
4340            }
4341        }
4342    }
4343
4344    // Mint an opaque, random server-side session id and store the identity under
4345    // it; the cookie carries the (HMAC-signed) sid, never the DID.
4346    let sid = state.sessions.create(Session {
4347        did: session.did.clone(),
4348        handle: session.handle.clone(),
4349    });
4350    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4351    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4352
4353    let mut resp = Redirect::to("/").into_response();
4354    set_cookie(&mut resp, &cookie);
4355    clear_binding_cookie(&mut resp);
4356    if clear_invite {
4357        clear_invite_cookie(&mut resp);
4358    }
4359    resp
4360}
4361
4362/// Revoke a DID's OAuth session on BOTH backends, best-effort.
4363///
4364/// Not "whichever backend is live": during a cutover a user's tokens can be in
4365/// either store — they logged in under one backend and are logging out under
4366/// the other. Revoking only the live one would leave a live refresh token
4367/// behind in the other, which is the exact failure sign-out exists to prevent,
4368/// and it would be invisible because the sign-out itself looks successful.
4369///
4370/// Both arms are best-effort. The caller has already decided to sign the user
4371/// out, and a network failure must not trap them in a half-logged-out state.
4372/// How long sign-out will wait for a final read-state flush before revoking
4373/// anyway.
4374///
4375/// Bounded because the flush talks to the user's PDS, and a user trying to leave
4376/// must never be held by a server that is not answering. Three seconds is long
4377/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
4378/// and short enough that a dead PDS is an inconvenience rather than a trap.
4379const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4380
4381/// Flush whatever read-state is still dirty for `did`, then give up quietly.
4382///
4383/// **Called before revoking, because revoking first strands it (#117).**
4384/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
4385/// session cannot be sent by anyone — it parks until the user signs in again,
4386/// which may be never. Flushing first is what stops the common case from
4387/// becoming that.
4388///
4389/// Best-effort by construction: every failure path here falls through to the
4390/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4391/// the parked state the flusher now handles deliberately rather than retrying
4392/// forever.
4393async fn flush_before_revoke(state: &AppState, did: &str) {
4394    match tokio::time::timeout(
4395        SIGN_OUT_FLUSH_BUDGET,
4396        crate::readstate::flush_did(state, did),
4397    )
4398    .await
4399    {
4400        Ok(Ok(())) => {}
4401        Ok(Err(err)) => {
4402            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4403        }
4404        Err(_) => warn!(
4405            %did,
4406            budget = ?SIGN_OUT_FLUSH_BUDGET,
4407            "sign-out: final read-state flush timed out; it will park until next sign-in"
4408        ),
4409    }
4410}
4411
4412async fn revoke_everywhere(state: &AppState, did: &str) {
4413    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4414    // that recording only the rust arm let `oauth_revoke` report a clean success
4415    // while every sidecar revocation failed — and for anyone who logged in before
4416    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4417    // rust arm correctly returns NoSession and the metric reads all-clear while
4418    // live refresh tokens sit at the PDS.
4419    //
4420    // Same op name, different backend: the backend column is what distinguishes
4421    // them, so "no revocation failures" means checking both rows, not one.
4422    let sidecar_started = std::time::Instant::now();
4423    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4424        Ok(res) => {
4425            info!(%did, revoked = res.revoked, "sidecar session revoked");
4426            true
4427        }
4428        Err(err) => {
4429            warn!(%did, %err, "sidecar revoke failed; continuing");
4430            false
4431        }
4432    };
4433    state.metrics.record(
4434        crate::metrics::Backend::Sidecar,
4435        "oauth_revoke",
4436        sidecar_started.elapsed().as_micros() as u64,
4437        sidecar_ok,
4438    );
4439
4440    if let Some(runtime) = state.oauth.as_deref() {
4441        let revoke_started = std::time::Instant::now();
4442        let outcome = crate::oauth::revoke::sign_out_discovering(
4443            runtime,
4444            &state.http,
4445            &state.db,
4446            did,
4447            crate::store::now_unix(),
4448        )
4449        .await;
4450        // **Counted, because a warn! nobody reads is not observability.** Until
4451        // this existed, a revocation failure left exactly one trace: a log line.
4452        // "No revocation failures this week" was therefore a statement about
4453        // nobody having looked, which is not the same claim.
4454        //
4455        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4456        // there being nothing to revoke is the correct outcome, not a failure,
4457        // and counting it as an error would make the metric noisy in exactly
4458        // the case that is fine. Only `Failed` means the PDS still holds live
4459        // tokens we asked it to drop.
4460        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4461        state.metrics.record(
4462            crate::metrics::Backend::Rust,
4463            "oauth_revoke",
4464            revoke_started.elapsed().as_micros() as u64,
4465            revoke_ok,
4466        );
4467        match outcome {
4468            crate::oauth::revoke::Revocation::Revoked => {
4469                info!(%did, "rust OAuth session revoked at the PDS")
4470            }
4471            crate::oauth::revoke::Revocation::NoSession => {}
4472            crate::oauth::revoke::Revocation::Failed(reason) => {
4473                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4474            }
4475        }
4476    }
4477}
4478
4479/// `POST /logout` — end the session everywhere, not just in this browser.
4480///
4481/// Clearing the cookie only stops *this* device from presenting the session;
4482/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4483/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4484/// access tokens at the PDS and drops the sidecar's session rows. The local
4485/// registry entry is dropped and the cookie cleared regardless of whether the
4486/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4487/// user in a half-logged-out state).
4488async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4489    if let Some(user) = current_session(&state, &headers).await {
4490        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4491        // revoke; the dev-DID fallback never handshook the sidecar.
4492        if let Some(sid) = user.sid {
4493            state.sessions.remove(&sid);
4494            // BEFORE the revoke: afterwards there is no session to send it with.
4495            flush_before_revoke(&state, &user.did).await;
4496            revoke_everywhere(&state, &user.did).await;
4497        }
4498    }
4499    let mut resp = Redirect::to("/login").into_response();
4500    set_cookie(
4501        &mut resp,
4502        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4503    );
4504    resp
4505}
4506
4507/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4508/// `DELETE` into this field for the purge to run.
4509#[derive(Debug, Deserialize)]
4510struct DeleteAccountForm {
4511    #[serde(default)]
4512    confirm: String,
4513}
4514
4515/// The literal a user must type to confirm the destructive delete.
4516const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4517
4518/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4519///
4520/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4521/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4522///   1. purges **every** local row owned by the caller DID (`entry_state`,
4523///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4524///      DID created) via [`store::purge_did_data`], then
4525///   2. revokes the OAuth session at the PDS via `revoke_everywhere` — the
4526///      sidecar's `POST /internal/revoke {did}` and, when the Rust OAuth runtime
4527///      is configured, its RFC 7009 revocation too — then
4528///   3. drops the in-memory session and clears the cookie, signing the user out.
4529///
4530/// The subscription/folder/saved *records* in the user's own PDS are
4531/// intentionally left alone — they are the user's data on their own server; the
4532/// `/about` copy and this page's UI both say so, and export stays available.
4533async fn account_delete(
4534    State(state): State<AppState>,
4535    headers: HeaderMap,
4536    Form(form): Form<DeleteAccountForm>,
4537) -> Result<Response, WebError> {
4538    let user = match current_session(&state, &headers).await {
4539        Some(u) => u,
4540        None => return Ok(Redirect::to("/login").into_response()),
4541    };
4542    let did = user.did.clone();
4543
4544    // Confirm-gate: require the exact typed phrase before doing anything.
4545    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4546        return Ok(Redirect::to(&format!(
4547            "/manage?flash={}",
4548            qenc("Type DELETE to confirm — nothing was deleted.")
4549        ))
4550        .into_response());
4551    }
4552
4553    // 1. Purge every local row this DID owns (single transaction).
4554    let counts = store::purge_did_data(&state.db, &did).await?;
4555    info!(
4556        %did,
4557        total = counts.total(),
4558        entry_state = counts.entry_state,
4559        read_cursor = counts.read_cursor,
4560        sub_ref = counts.sub_ref,
4561        beta_access = counts.beta_access,
4562        invite_codes = counts.invite_codes,
4563        "account/delete: local rows purged"
4564    );
4565
4566    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4567    //    rows are already gone; a network blip must not block the sign-out).
4568    revoke_everywhere(&state, &did).await;
4569
4570    // 3. Drop the in-memory session and clear the cookie: sign the user out.
4571    if let Some(sid) = user.sid {
4572        state.sessions.remove(&sid);
4573    }
4574    let mut resp = Redirect::to(&format!(
4575        "/login?flash={}",
4576        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4577    ))
4578    .into_response();
4579    set_cookie(
4580        &mut resp,
4581        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4582    );
4583    Ok(resp)
4584}
4585
4586/// The `/login` card, shared by the form and its error re-render.
4587fn login_card(config: &Config) -> Card {
4588    Card::public(
4589        config,
4590        "/login",
4591        "Sign in — FeatherReader",
4592        "Sign in to FeatherReader with your atproto handle. You approve access on \
4593         your own server — no signup, no password.",
4594    )
4595}
4596
4597/// Re-render the login form with an error banner.
4598fn login_error(state: &AppState, msg: &str) -> Response {
4599    render(&LoginTemplate {
4600        card: login_card(&state.config),
4601        repo_url: REPO_URL,
4602        error: msg.to_string(),
4603        flash: String::new(),
4604    })
4605}
4606
4607// ---------------------------------------------------------------------------
4608// Closed-beta invite gate (self-serve redeem + admin mint)
4609// ---------------------------------------------------------------------------
4610
4611/// Form body for `POST /beta/redeem`.
4612#[derive(Debug, Deserialize)]
4613struct RedeemForm {
4614    code: String,
4615}
4616
4617/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4618/// already full we render the "capacity full" variant (no form).
4619async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4620    let full = store::count_beta_access(&state.db)
4621        .await
4622        .map(|n| n >= state.config.beta_cap)
4623        .unwrap_or(false);
4624    render(&BetaRedeemTemplate {
4625        card: redeem_card(&state.config),
4626        repo_url: REPO_URL,
4627        error: String::new(),
4628        capacity_full: full,
4629    })
4630}
4631
4632/// `POST /beta/redeem` — the **pre-handshake** reservation.
4633///
4634/// Validates the pasted code is *redeemable right now* (exists, active,
4635/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4636/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4637/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4638/// OAuth callback later binds the verified DID and atomically consumes the code
4639/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4640/// start OAuth (and burn a sidecar handshake).
4641async fn beta_redeem_submit(
4642    State(state): State<AppState>,
4643    Form(form): Form<RedeemForm>,
4644) -> Response {
4645    let code = form.code.trim().to_uppercase();
4646    if code.is_empty() {
4647        return render(&BetaRedeemTemplate {
4648            card: redeem_card(&state.config),
4649            repo_url: REPO_URL,
4650            error: "Enter your invite code.".to_string(),
4651            capacity_full: false,
4652        });
4653    }
4654
4655    match preflight_code(&state, &code).await {
4656        Ok(()) => {
4657            let cookie = sign_invite(&code, &state.config.cookie_secret);
4658            let mut resp = Redirect::to("/login").into_response();
4659            set_cookie(&mut resp, &cookie);
4660            info!("invite code preflight OK; reserving intent + redirecting to /login");
4661            resp
4662        }
4663        Err(policy) => {
4664            warn!(?policy, "invite code preflight rejected");
4665            redeem_bounce(&state, &policy)
4666        }
4667    }
4668}
4669
4670/// Read-only preflight of an invite code for the pre-handshake reservation:
4671/// verify it exists, is active, is not past `expires_at`, and that a seat is
4672/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4673/// callback time. Does NOT consume the code or grant a seat. Returns the same
4674/// typed [`store::RedeemError`] variants so the two paths share one message map.
4675async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4676    // Cap check first: a clear "capacity full" beats "code invalid" when both.
4677    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4678    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4679    // still backstops the real cap inside its tx, so this is a consistency /
4680    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4681    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4682    // that might overrun the cap.
4683    let count = match store::count_beta_access(&state.db).await {
4684        Ok(n) => n,
4685        Err(err) => {
4686            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4687            return Err(store::RedeemError::CapacityFull);
4688        }
4689    };
4690    if count >= state.config.beta_cap {
4691        return Err(store::RedeemError::CapacityFull);
4692    }
4693    // Look up the code's current status + expiry (read-only).
4694    let row = sqlx::query_as::<_, (String, i64)>(
4695        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4696    )
4697    .bind(code)
4698    .fetch_optional(&state.db)
4699    .await
4700    .ok()
4701    .flatten();
4702    let (status, expires_at) = match row {
4703        Some(r) => r,
4704        None => return Err(store::RedeemError::NotFound),
4705    };
4706    let now = chrono::Utc::now().timestamp();
4707    match status.as_str() {
4708        "active" if expires_at >= now => Ok(()),
4709        "active" => Err(store::RedeemError::Expired),
4710        "expired" => Err(store::RedeemError::Expired),
4711        // "redeemed" or anything else non-active.
4712        _ => Err(store::RedeemError::AlreadyRedeemed),
4713    }
4714}
4715
4716/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4717/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4718fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
4719    use store::RedeemError::*;
4720    let (msg, capacity_full) = match policy {
4721        NotFound => ("That invite code isn't valid.", false),
4722        Expired => ("That invite code has expired.", false),
4723        AlreadyRedeemed => ("That invite code has already been used.", false),
4724        CapacityFull => ("", true),
4725    };
4726    render(&BetaRedeemTemplate {
4727        card: redeem_card(&state.config),
4728        repo_url: REPO_URL,
4729        error: msg.to_string(),
4730        capacity_full,
4731    })
4732}
4733
4734/// The `/beta/redeem` card, shared by the form, its re-renders and the claim
4735/// link's bounce.
4736fn redeem_card(config: &Config) -> Card {
4737    Card::public(
4738        config,
4739        "/beta/redeem",
4740        "Redeem an invite — FeatherReader",
4741        "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
4742         in with your atproto handle.",
4743    )
4744}
4745
4746/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4747#[derive(Debug, Deserialize, Default)]
4748struct MintQuery {
4749    #[serde(default)]
4750    n: Option<u32>,
4751}
4752
4753/// `POST /admin/invites?n=N` — mint N invite codes.
4754///
4755/// `GET /oauth/client-metadata.json` — the client's published identity.
4756///
4757/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4758/// caches it against every existing grant, so it must keep answering at exactly
4759/// this path across the cutover — the sidecar serves the same document at the
4760/// same URL today, proxied by the edge.
4761///
4762/// Served whatever backend is live: a request that arrives here is from a PDS
4763/// resolving our identity, and it has no idea which of our two implementations
4764/// is currently answering repo calls.
4765async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4766    let Some(runtime) = state.oauth.as_deref() else {
4767        // The sidecar is serving this path in front of us, or nothing is.
4768        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4769    };
4770    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4771}
4772
4773/// `GET /oauth/jwks.json` — the client's public signing key.
4774///
4775/// Production only. The localhost dev client is a PUBLIC client: it registers no
4776/// key and signs no assertions, so publishing a JWKS there would advertise a
4777/// credential that is never used — and would make a dev deployment look like a
4778/// confidential client to anyone reading it.
4779async fn oauth_jwks(State(state): State<AppState>) -> Response {
4780    let Some(runtime) = state.oauth.as_deref() else {
4781        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4782    };
4783    match runtime.client_key.as_ref() {
4784        Some(key) => match key.jwks_document() {
4785            Ok(doc) => axum::Json(doc).into_response(),
4786            Err(err) => {
4787                warn!(%err, "could not render the client JWKS");
4788                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4789            }
4790        },
4791        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4792    }
4793}
4794
4795/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4796const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4797
4798/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4799///
4800/// Admin-gated on the same rule as the invite minter: the table names every
4801/// operation the reader performs and how often each fails, which is an
4802/// operational picture rather than public information.
4803///
4804/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4805/// is safe, and the comparison is two rows side by side.
4806async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4807    let did = match current_did(&state, &headers).await {
4808        Some(d) => d,
4809        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4810    };
4811    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4812        warn!(%did, "admin metrics denied: not an admin-seed DID");
4813        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4814    }
4815
4816    // Flush first, so the table includes this process's traffic up to now.
4817    // Then read the PERSISTED rows, which is the only place both backends can
4818    // appear at once -- a flip is a restart, and in-process memory only ever
4819    // holds the backend currently running.
4820    if let Err(err) =
4821        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4822    {
4823        warn!(%err, "could not flush repo timings before rendering");
4824    }
4825    let rows = match crate::metrics::persisted_rows(&state.db).await {
4826        Ok(rows) => rows,
4827        Err(err) => {
4828            warn!(%err, "could not read persisted repo timings");
4829            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4830        }
4831    };
4832
4833    // The live backend is named at the top: a table of two populated rows is
4834    // ambiguous about which one is currently serving users.
4835    // Parked read-state, alongside the timings. The flusher no longer logs
4836    // these every round (#117), so without a number here the state would be
4837    // silent — which is the failure the noisy loop at least did not have.
4838    let parked = match crate::store::parked_readstate_dids(&state.db).await {
4839        Ok(n) => n.to_string(),
4840        Err(err) => {
4841            warn!(%err, "could not count parked read-state DIDs");
4842            "unknown".to_string()
4843        }
4844    };
4845    // **The half the public histogram cannot carry.** `/stats` reports counts by
4846    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4847    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4848    // cannot separate "the publishers are gone" from "we are broken". #159 was
4849    // the latter and took a production investigation to establish. Named feeds
4850    // and their error text belong here, behind ALLOWED_DIDS.
4851    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4852        Ok(f) => f,
4853        Err(err) => {
4854            warn!(%err, "could not list failing feeds");
4855            Vec::new()
4856        }
4857    };
4858    let mut failing_block = String::new();
4859    if !failing.is_empty() {
4860        failing_block.push_str("\nfailing feeds (worst first)\n");
4861        for f in &failing {
4862            failing_block.push_str(&format!(
4863                "  {:>4}x  {:<8}  {}\n          {}\n",
4864                f.consecutive_errors,
4865                f.kind.as_deref().unwrap_or("unknown"),
4866                f.url,
4867                f.detail.as_deref().unwrap_or("(no detail recorded)"),
4868            ));
4869        }
4870    }
4871
4872    // **Capacity that no other page can show.** The global ceiling counts every
4873    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
4874    // unpollable ones — so an instance can be at its cap with every public
4875    // number saying otherwise. A review found exactly that gap.
4876    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4877        Ok(n) => n,
4878        Err(err) => {
4879            warn!(%err, "could not count unpollable feeds");
4880            -1
4881        }
4882    };
4883    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4884
4885    let body = format!(
4886        "live backend: {}\nparked read-state DIDs: {}\n\
4887         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4888        state.config.repo_backend.as_str(),
4889        parked,
4890        cached,
4891        state.config.max_feeds_global,
4892        unpollable,
4893        crate::metrics::render(&rows),
4894        failing_block,
4895    );
4896    (StatusCode::OK, body).into_response()
4897}
4898
4899/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4900/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4901/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4902async fn admin_mint_invites(
4903    State(state): State<AppState>,
4904    headers: HeaderMap,
4905    Query(q): Query<MintQuery>,
4906) -> Response {
4907    // Require a real, current session (not just a DID string) whose DID is an
4908    // admin-seed DID. `current_did` already re-checks the beta gate.
4909    let did = match current_did(&state, &headers).await {
4910        Some(d) => d,
4911        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4912    };
4913    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4914        warn!(%did, "admin mint denied: not an admin-seed DID");
4915        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4916    }
4917
4918    let n = q.n.unwrap_or(1).clamp(1, 100);
4919    let mut codes = Vec::with_capacity(n as usize);
4920    for _ in 0..n {
4921        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4922            Ok(code) => codes.push(code),
4923            Err(err) => {
4924                warn!(%err, %did, "admin mint_code failed");
4925                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4926            }
4927        }
4928    }
4929    info!(%did, count = codes.len(), "admin minted invite codes");
4930    let mut body = codes.join("\n");
4931    body.push('\n');
4932    (StatusCode::OK, body).into_response()
4933}
4934
4935// ---------------------------------------------------------------------------
4936// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
4937// ---------------------------------------------------------------------------
4938
4939/// Query for `GET /claim`.
4940#[derive(Debug, Deserialize)]
4941struct ClaimQuery {
4942    /// The opaque claim token from the bot's public follow-back skeet.
4943    t: Option<String>,
4944}
4945
4946/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4947///
4948/// The follow→invite bot posts a public skeet mentioning a new follower with a
4949/// link here. The token wraps a pre-minted invite code (never the raw code — see
4950/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4951/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4952/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4953/// callback atomically consumes the code (`store::redeem_code`) — the same
4954/// machinery as a pasted code. On any failure it bounces to the invite page with
4955/// the matching message.
4956///
4957/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4958/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4959/// here rejects an already-used / expired / capacity-full code before reserving,
4960/// so a replayed link past the first successful claim is refused. The residual
4961/// window is the same as any pasted invite code: whoever completes OAuth *first*
4962/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4963/// blunts brute-force enumeration.
4964async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4965    let token = match q.t {
4966        Some(t) if !t.is_empty() => t,
4967        _ => {
4968            warn!("claim link with no token");
4969            return redeem_bounce(&state, &store::RedeemError::NotFound);
4970        }
4971    };
4972
4973    // Unwrap the token → the invite code it reserves. A tampered/forged token
4974    // yields nothing → treat as an invalid code (don't leak whether it parsed).
4975    let code = match claim_token_code(&token, &state.config.cookie_secret) {
4976        Some(c) => c,
4977        None => {
4978            warn!("claim token invalid (bad signature / malformed)");
4979            return redeem_bounce(&state, &store::RedeemError::NotFound);
4980        }
4981    };
4982
4983    // Re-run the same preflight as the pasted-code path: exists, active,
4984    // unexpired, seat free. This is what makes a replayed link past first-claim
4985    // (or past cap) fail cleanly.
4986    match preflight_code(&state, &code).await {
4987        Ok(()) => {
4988            let cookie = sign_invite(&code, &state.config.cookie_secret);
4989            let mut resp = Redirect::to("/login").into_response();
4990            set_cookie(&mut resp, &cookie);
4991            info!("claim token preflight OK; reserving intent + redirecting to /login");
4992            resp
4993        }
4994        Err(policy) => {
4995            warn!(?policy, "claim token preflight rejected");
4996            redeem_bounce(&state, &policy)
4997        }
4998    }
4999}
5000
5001/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
5002///
5003/// Passing the follower DID makes the APP the authoritative deduper: the app can
5004/// short-circuit a DID that already holds a seat, and return the SAME code for a
5005/// DID that already has an outstanding claim — so a bot-host state loss cannot
5006/// re-mint or re-post per follower. Handle is advisory (logs only).
5007#[derive(Debug, Default, Deserialize)]
5008struct BotClaimRequest {
5009    /// The follower's DID (the idempotency key). Optional for backward-compat: an
5010    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
5011    #[serde(default)]
5012    did: Option<String>,
5013    /// The follower's handle (advisory; recorded for operator logs only).
5014    #[serde(default)]
5015    #[allow(dead_code)]
5016    handle: Option<String>,
5017}
5018
5019/// The JSON body `POST /bot/claims` returns on success.
5020#[derive(Debug, serde::Serialize)]
5021struct BotClaimResponse {
5022    /// Server-side dedupe outcome, so the bot knows whether to post:
5023    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
5024    /// already had an outstanding claim; the SAME code/token/url is returned, so an
5025    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
5026    /// beta access; code/token/url are empty and the bot should post NOTHING).
5027    status: &'static str,
5028    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
5029    /// store. NEVER post this publicly; post the `url` instead. Empty when
5030    /// `already_seated`.
5031    code: String,
5032    /// The opaque claim token (the code wrapped + signed). Empty when
5033    /// `already_seated`.
5034    token: String,
5035    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
5036    /// Empty when `already_seated`.
5037    url: String,
5038}
5039
5040/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
5041///
5042/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
5043/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
5044/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
5045/// (503), so a bare/dev instance never exposes an unauthenticated mint.
5046///
5047/// Server-side DID idempotency (the authoritative dedupe backstop): the request
5048/// body carries the follower `did`. The app — not the bot's local SQLite — is the
5049/// source of truth, so a bot-host state loss cannot re-mint or re-post per
5050/// follower:
5051///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
5052///     code/url; the bot marks it handled and posts NOTHING);
5053///   * DID already has an outstanding active claim → `200 {status:"existing"}`
5054///     returning the SAME code/token/url (idempotent — never a second mint);
5055///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
5056///
5057/// Cap accounting: the bot must not promise more claims than seats remain, so
5058/// this refuses with `409 Conflict {"error":"full"}` when
5059/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
5060/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
5061/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
5062/// minting past the cap.
5063///
5064/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
5065/// default 14d — the admin browser flow's 30-min TTL would expire before the
5066/// follower taps an async-delivered link).
5067async fn bot_mint_claim(
5068    State(state): State<AppState>,
5069    headers: HeaderMap,
5070    body: axum::body::Bytes,
5071) -> Response {
5072    // 1. The endpoint is OFF unless a bot secret is configured.
5073    let bot_secret = match state.config.bot_secret.as_deref() {
5074        Some(s) => s,
5075        None => {
5076            warn!(
5077                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5078            );
5079            return (
5080                StatusCode::SERVICE_UNAVAILABLE,
5081                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5082            )
5083                .into_response();
5084        }
5085    };
5086
5087    // 2. Constant-time bearer check on the X-Bot-Secret header.
5088    let presented = headers
5089        .get("x-bot-secret")
5090        .and_then(|v| v.to_str().ok())
5091        .unwrap_or("");
5092    if !bot_secret_matches(presented, bot_secret) {
5093        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5094        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5095    }
5096
5097    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
5098    // (legacy caller) parses to an all-None request; a malformed body is a 400.
5099    let req: BotClaimRequest = if body.is_empty() {
5100        BotClaimRequest::default()
5101    } else {
5102        match serde_json::from_slice(&body) {
5103            Ok(r) => r,
5104            Err(err) => {
5105                warn!(%err, "POST /bot/claims: bad JSON body");
5106                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5107            }
5108        }
5109    };
5110    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5111
5112    // 3. Server-side DID idempotency (only when a DID was supplied):
5113    if let Some(did) = follower_did {
5114        // 3a. Already seated → tell the bot to post nothing.
5115        match store::has_beta_access(&state.db, did).await {
5116            Ok(true) => {
5117                info!("bot mint: DID already holds beta access; already_seated");
5118                return bot_claim_json(BotClaimResponse {
5119                    status: "already_seated",
5120                    code: String::new(),
5121                    token: String::new(),
5122                    url: String::new(),
5123                });
5124            }
5125            Ok(false) => {}
5126            Err(err) => {
5127                // Fail closed: a DB error must not fall through to a fresh mint.
5128                warn!(%err, "bot mint: has_beta_access failed");
5129                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5130            }
5131        }
5132        // 3b. Outstanding active claim for this DID → return the SAME code (no
5133        // second mint). This is what survives a bot-host state loss.
5134        match store::find_active_code_for_did(&state.db, did).await {
5135            Ok(Some(code)) => {
5136                info!("bot mint: existing outstanding claim for DID; returning same code");
5137                let token = sign_claim_token(&code, &state.config.cookie_secret);
5138                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5139                return bot_claim_json(BotClaimResponse {
5140                    status: "existing",
5141                    code,
5142                    token,
5143                    url,
5144                });
5145            }
5146            Ok(None) => {}
5147            Err(err) => {
5148                warn!(%err, "bot mint: find_active_code_for_did failed");
5149                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5150            }
5151        }
5152    }
5153
5154    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
5155    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
5156    let granted = match store::count_beta_access(&state.db).await {
5157        Ok(n) => n,
5158        Err(err) => {
5159            warn!(%err, "bot mint: count_beta_access failed; failing closed");
5160            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5161        }
5162    };
5163    let outstanding = match store::count_active_codes(&state.db).await {
5164        Ok(n) => n,
5165        Err(err) => {
5166            warn!(%err, "bot mint: count_active_codes failed; failing closed");
5167            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5168        }
5169    };
5170    if granted + outstanding >= state.config.beta_cap {
5171        info!(
5172            granted,
5173            outstanding,
5174            cap = state.config.beta_cap,
5175            "bot mint refused: at capacity"
5176        );
5177        return (
5178            StatusCode::CONFLICT,
5179            [(header::CONTENT_TYPE, "application/json")],
5180            "{\"error\":\"full\"}\n",
5181        )
5182            .into_response();
5183    }
5184
5185    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
5186    //    so a re-request for the same DID returns THIS code idempotently.
5187    let bot_did = state
5188        .config
5189        .admin_seed_dids()
5190        .first()
5191        .cloned()
5192        .unwrap_or_else(|| "did:bot:featherreader".to_string());
5193    let minted = match follower_did {
5194        Some(did) => {
5195            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5196        }
5197        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5198    };
5199    let code = match minted {
5200        Ok(c) => c,
5201        // S4: the dedupe check (3b) and this mint are separate statements, so two
5202        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
5203        // The partial unique index `idx_invite_codes_intended_active` makes the
5204        // loser's INSERT fail (only one active row per intended DID), which
5205        // surfaces here as a conflict. Recover by returning the winner's existing
5206        // code (same shape as the 3b idempotent path) instead of a 500.
5207        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5208            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5209                Ok(Some(code)) => {
5210                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
5211                    let token = sign_claim_token(&code, &state.config.cookie_secret);
5212                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5213                    return bot_claim_json(BotClaimResponse {
5214                        status: "existing",
5215                        code,
5216                        token,
5217                        url,
5218                    });
5219                }
5220                // The winner's row vanished between the conflict and this lookup
5221                // (redeemed/expired/purged in the gap) — nothing to hand back.
5222                // Fail closed rather than silently mint past the just-hit guard.
5223                Ok(None) => {
5224                    warn!("bot mint: conflict but no active code found on recovery");
5225                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5226                }
5227                Err(err) => {
5228                    warn!(%err, "bot mint: recovery lookup after conflict failed");
5229                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5230                }
5231            }
5232        }
5233        Err(err) => {
5234            warn!(%err, "bot mint_code failed");
5235            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5236        }
5237    };
5238    let token = sign_claim_token(&code, &state.config.cookie_secret);
5239    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5240    info!("bot minted a claim code + token");
5241
5242    bot_claim_json(BotClaimResponse {
5243        status: "minted",
5244        code,
5245        token,
5246        url,
5247    })
5248}
5249
5250/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
5251/// `500` if serialization somehow fails).
5252fn bot_claim_json(resp: BotClaimResponse) -> Response {
5253    match serde_json::to_string(&resp) {
5254        Ok(body) => (
5255            StatusCode::OK,
5256            [(header::CONTENT_TYPE, "application/json")],
5257            body,
5258        )
5259            .into_response(),
5260        Err(err) => {
5261            warn!(%err, "serializing bot claim response failed");
5262            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5263        }
5264    }
5265}
5266
5267/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
5268/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
5269/// by the HMAC checks so there is one comparator to audit; a length mismatch
5270/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
5271fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5272    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5273}
5274
5275// ---------------------------------------------------------------------------
5276// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
5277// ---------------------------------------------------------------------------
5278
5279/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
5280/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
5281/// itself (base64url) rather than an opaque sid, since the code IS the reserved
5282/// intent the callback consumes.
5283fn sign_invite(code: &str, secret: &str) -> String {
5284    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5285}
5286
5287/// Verify + read the reserved invite code out of the request's invite cookie
5288/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
5289/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
5290/// authority on the code's live status.
5291fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5292    cookie::verify_value(headers, INVITE_COOKIE, secret)
5293}
5294
5295/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
5296/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
5297/// cookie value and vice-versa.
5298const CLAIM_TOKEN_LABEL: &str = "claim-token";
5299
5300/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
5301/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
5302///
5303/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
5304/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
5305/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
5306/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
5307/// code won't verify), the wrapped code is single-use (redeem flips
5308/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
5309/// token one self-contained string needing no server-side token table; it does
5310/// NOT hide the code.
5311fn sign_claim_token(code: &str, secret: &str) -> String {
5312    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5313}
5314
5315/// Verify a claim token and return the invite code it wraps (`None` on a tampered
5316/// / forged / malformed token). The code's live status (active/unexpired/seat
5317/// free) is re-checked by `preflight_code`; this only proves the token was minted
5318/// by this instance.
5319fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5320    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5321}
5322
5323/// Clear the invite cookie on a response (after a successful bind, or when the
5324/// reservation turned out to be stale).
5325fn clear_invite_cookie(resp: &mut Response) {
5326    set_cookie(
5327        resp,
5328        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5329    );
5330}
5331
5332// ---------------------------------------------------------------------------
5333// OPML import + export
5334// ---------------------------------------------------------------------------
5335
5336/// `POST /opml` — import subscriptions from an OPML document.
5337///
5338/// Accepts either a multipart file upload (field `file`) or a pasted textarea
5339/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
5340/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
5341/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
5342/// `applyWrites` round-trip per 200 feeds). Feeds are also upserted into the local cache so
5343/// they show immediately; polling is left to the background poller.
5344async fn import_opml(
5345    State(state): State<AppState>,
5346    headers: HeaderMap,
5347    mut multipart: Multipart,
5348) -> Result<Response, WebError> {
5349    let did = match current_did(&state, &headers).await {
5350        Some(d) => d,
5351        None => return Ok(Redirect::to("/login").into_response()),
5352    };
5353    let pool = &state.db;
5354
5355    // Collect the OPML text from whichever field carried it. Multipart errors
5356    // are mapped to their axum-native response so that an over-cap upload (the
5357    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
5358    // `413 Payload Too Large` rather than being swallowed by the blanket
5359    // `WebError` → `500` conversion.
5360    let mut opml_text = String::new();
5361    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5362        let name = field.name().unwrap_or("").to_string();
5363        if name == "opml" || name == "file" {
5364            let bytes = field.bytes().await.map_err(multipart_response)?;
5365            if !bytes.is_empty() {
5366                opml_text = String::from_utf8_lossy(&bytes).into_owned();
5367                if name == "file" {
5368                    break;
5369                }
5370            }
5371        }
5372    }
5373
5374    // A parse FAILURE and an empty-but-valid file are different things, and
5375    // `unwrap_or_default` collapsed them: a malformed export was reported to the
5376    // reader as "No feeds found in that OPML", which sends them looking at their
5377    // old reader for feeds that are right there in the file.
5378    let feeds =
5379        match opml::parse_opml(&opml_text) {
5380            Ok(feeds) => feeds,
5381            Err(err) => {
5382                warn!(%err, %did, "OPML import could not parse the uploaded file");
5383                return Ok(Redirect::to(&format!(
5384                "/?flash={}",
5385                qenc("That file could not be read as OPML. Export it again from your other reader?")
5386            ))
5387                .into_response());
5388            }
5389        };
5390    if feeds.is_empty() {
5391        info!(%did, "OPML import found no feeds");
5392        return Ok(
5393            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5394                .into_response(),
5395        );
5396    }
5397
5398    // Create any named folders first, mapping folder name → at:// URI so
5399    // subscriptions can reference them.
5400    let now = now_rfc3339();
5401    let mut folder_uris: std::collections::HashMap<String, String> =
5402        std::collections::HashMap::new();
5403    // Reuse existing folders where the name already exists.
5404    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5405        for (rkey, folder) in existing {
5406            folder_uris
5407                .entry(folder.name.clone())
5408                .or_insert_with(|| folder_uri(&did, &rkey));
5409        }
5410    }
5411    let mut wanted_folders: Vec<String> = feeds
5412        .iter()
5413        .filter_map(|f| f.folder.clone())
5414        .filter(|n| !n.is_empty())
5415        .collect();
5416    wanted_folders.sort();
5417    wanted_folders.dedup();
5418    for name in wanted_folders {
5419        if folder_uris.contains_key(&name) {
5420            continue;
5421        }
5422        let folder = Folder::new(name.clone(), now.clone());
5423        match state.repo().add_folder(&did, &folder).await {
5424            Ok(rkey) => {
5425                folder_uris.insert(name, folder_uri(&did, &rkey));
5426            }
5427            Err(err) => warn!(%err, %did, "OPML folder create failed"),
5428        }
5429    }
5430
5431    // Build one subscription record per PUBLIC feed + upsert the local cache row.
5432    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5433    // and reported back to the user — the same public-feeds-only stance as the
5434    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5435    // token onto the public network either.
5436    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5437    // the remaining headroom (cap − existing) once; public feeds beyond it are
5438    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5439    let sub_cap = state.config.max_subs_per_did;
5440    let mut headroom: Option<i64> = if sub_cap > 0 {
5441        let existing = store::count_subscriptions_for_did(pool, &did)
5442            .await
5443            .unwrap_or(0);
5444        Some((sub_cap - existing).max(0))
5445    } else {
5446        None
5447    };
5448    let mut trimmed_over_cap: usize = 0;
5449
5450    // Global feeds ceiling: an OPML import must not blow past the shared cache
5451    // ceiling any more than the single-add path may. Seed the remaining global
5452    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5453    // not already cached) consumes it. Existing/duplicate URLs add no row and
5454    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5455    // `<= 0` disables the ceiling.
5456    let feeds_cap = state.config.max_feeds_global;
5457    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5458        let existing = store::count_feeds(pool).await.unwrap_or(0);
5459        Some((feeds_cap - existing).max(0))
5460    } else {
5461        None
5462    };
5463    let mut trimmed_over_global: usize = 0;
5464
5465    let mut subs = Vec::with_capacity(feeds.len());
5466    let mut skipped_private: Vec<String> = Vec::new();
5467    // Imported into the PDS but not cached locally, so not pollable until the
5468    // next import touches them. Counted rather than only logged — see below.
5469    let mut uncached: usize = 0;
5470    // Entries this instance cannot store at all (an `at://` publication with
5471    // the flag off, an unsupported scheme). Counted, because the `continue`
5472    // below used to increment nothing while the privacy branch beside it
5473    // produced a label — so an OPML from a standard.site-enabled instance
5474    // imported "successfully" with entries missing and no reason given.
5475    let mut skipped_unsupported: usize = 0;
5476    for f in &feeds {
5477        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5478        // ever parsed it — the single-add path can't reach here because
5479        // `resolve_feed_url` must parse AND successfully fetch first. So
5480        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5481        // cached, and published as records to the user's PUBLIC repo. Note that
5482        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5483        // it returns `Public` for anything unparseable by design.
5484        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5485            info!(
5486                %did,
5487                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5488            );
5489            skipped_unsupported += 1;
5490            continue;
5491        }
5492        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5493            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5494            // Report by title where we have one, else the (public-safe) host.
5495            let label = f
5496                .title
5497                .clone()
5498                .filter(|t| !t.trim().is_empty())
5499                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5500            skipped_private.push(label);
5501            continue;
5502        }
5503
5504        // Over-cap: stop importing once headroom is exhausted (count the rest so
5505        // we can tell the user how many were dropped).
5506        if let Some(h) = headroom.as_mut() {
5507            if *h <= 0 {
5508                trimmed_over_cap += 1;
5509                continue;
5510            }
5511        }
5512
5513        // Global ceiling: a brand-new feed URL consumes global headroom. Once
5514        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5515        // free — they add no row). Checked before decrementing the per-DID
5516        // headroom so a dropped feed doesn't burn the caller's own quota.
5517        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5518            Ok(existing) => existing.is_none(),
5519            // On a lookup error, treat as existing (don't consume global
5520            // headroom) but still allow the upsert to proceed.
5521            Err(err) => {
5522                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5523                false
5524            }
5525        };
5526        if is_new {
5527            if let Some(g) = global_headroom.as_mut() {
5528                if *g <= 0 {
5529                    trimmed_over_global += 1;
5530                    continue;
5531                }
5532                *g -= 1;
5533            }
5534        }
5535
5536        // Passed both caps: consume the per-DID headroom now that the feed is
5537        // actually being imported.
5538        if let Some(h) = headroom.as_mut() {
5539            *h -= 1;
5540        }
5541
5542        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5543        sub.title = f.title.clone();
5544        sub.site_url = f.site_url.clone();
5545        sub.folder = f
5546            .folder
5547            .as_ref()
5548            .and_then(|name| folder_uris.get(name).cloned());
5549        subs.push(sub);
5550        // Same support ticket as the single-add path: no `feeds` row means the
5551        // poller never selects this subscription, so the import looks like it
5552        // worked and the feed silently never updates. Counted as well as logged,
5553        // because one line per feed in a 200-feed import is not something anyone
5554        // reads — the count goes to the reader.
5555        if let Err(err) = store::upsert_feed(
5556            pool,
5557            &store::NewFeed {
5558                url: f.feed_url.clone(),
5559                title: f.title.clone(),
5560                site_url: f.site_url.clone(),
5561                ..Default::default()
5562            },
5563        )
5564        .await
5565        {
5566            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5567                                                  it will not be polled");
5568            uncached += 1;
5569        }
5570    }
5571
5572    // **A failed PDS write is not an import.**
5573    //
5574    // The subscriptions live in the reader's repo; a local `feeds` row is just a
5575    // poller hint. This used to `warn!` and then report "Imported N feeds"
5576    // regardless, so a total failure read as a total success — and the reader
5577    // would only discover otherwise on their next visit, with an empty sidebar.
5578    //
5579    // **And a part-landed write is not a failed one.** The batch goes out in
5580    // calls of at most 200 (#240: the reference PDS refuses more), sent in
5581    // order and stopped at the first failure, so what landed is a prefix of
5582    // `subs` and the error says how long. Saying "nothing was imported" after
5583    // the first 200 of 450 landed would send the reader to import the file
5584    // again, which adds those 200 a second time. Nothing local needs undoing
5585    // either way: the reader's `sub_ref` projection is rebuilt from the repo on
5586    // the next read, and a cached `feeds` row with no subscriber is the same
5587    // poller hint the total-failure path has always left behind.
5588    let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5589        Ok(rkeys) => {
5590            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5591            rkeys.len()
5592        }
5593        Err(err) => {
5594            let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
5595            warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
5596            landed
5597        }
5598    };
5599    if landed == 0 && !subs.is_empty() {
5600        return Ok(Redirect::to(&format!(
5601            "/?flash={}",
5602            qenc(
5603                "Could not save those subscriptions to your PDS, so nothing was imported. \
5604                 Try again in a moment."
5605            )
5606        ))
5607        .into_response());
5608    }
5609
5610    // Report the import count, plus any private/paid feeds skipped as unsupported.
5611    let mut flash = if landed < subs.len() {
5612        format!(
5613            "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
5614             other {} may not have been saved. Importing the same file again would add the first \
5615             {landed} a second time",
5616            subs.len(),
5617            subs.len() - landed
5618        )
5619    } else {
5620        format!("Imported {} feeds", subs.len())
5621    };
5622    if uncached > 0 {
5623        flash.push_str(&format!(
5624            ". {uncached} of them could not be cached locally and may not update until the next import."
5625        ));
5626    }
5627    if trimmed_over_cap > 0 {
5628        flash.push_str(&format!(
5629            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5630        ));
5631    }
5632    if trimmed_over_global > 0 {
5633        flash.push_str(&format!(
5634            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5635        ));
5636    }
5637    if !skipped_private.is_empty() {
5638        flash.push_str(&format!(
5639            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5640            skipped_private.len(),
5641            skipped_private.join(", ")
5642        ));
5643    }
5644    if skipped_unsupported > 0 {
5645        // By count only — the URL is whatever the file said, and unlike the
5646        // private branch there is no public-safe label to give.
5647        flash.push_str(&format!(
5648            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5649        ));
5650    }
5651    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5652}
5653
5654/// A public-safe label for a skipped private feed when it has no title: just the
5655/// host, so we never echo the secret-bearing path/query back to the user.
5656fn private_feed_label(url: &str) -> String {
5657    url::Url::parse(url)
5658        .ok()
5659        .and_then(|u| u.host_str().map(str::to_string))
5660        .unwrap_or_else(|| "a private feed".to_string())
5661}
5662
5663/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5664async fn export_opml(
5665    State(state): State<AppState>,
5666    headers: HeaderMap,
5667) -> Result<Response, WebError> {
5668    let did = match current_did(&state, &headers).await {
5669        Some(d) => d,
5670        None => return Ok(Redirect::to("/login").into_response()),
5671    };
5672
5673    // **An export must never be silently empty.** `unwrap_or_default` here turned
5674    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
5675    // backup, blank, at exactly the moment they reached for it. That was survivable
5676    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
5677    // this is the one caller that converts a refusal into data loss, and it is also
5678    // the recovery route the changelog points a locked-out reader at.
5679    let subs = match state.repo().list_subscriptions_sorted(&did).await {
5680        Ok(subs) => subs,
5681        Err(err) => {
5682            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5683            return Ok(Redirect::to(&format!(
5684                "/manage?flash={}",
5685                qenc(EXPORT_INCOMPLETE_REFUSAL)
5686            ))
5687            .into_response());
5688        }
5689    };
5690    let folders = match state.repo().list_folders_sorted(&did).await {
5691        Ok(folders) => folders,
5692        Err(err) => {
5693            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5694            return Ok(Redirect::to(&format!(
5695                "/manage?flash={}",
5696                qenc(EXPORT_INCOMPLETE_REFUSAL)
5697            ))
5698            .into_response());
5699        }
5700    };
5701    // The exporter matches a subscription's `folder` at-uri against the folder's
5702    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5703    let folder_pairs: Vec<(String, Folder)> = folders
5704        .into_iter()
5705        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5706        .collect();
5707
5708    let body = opml::to_opml(&subs, &folder_pairs);
5709    let mut resp = (StatusCode::OK, body).into_response();
5710    resp.headers_mut().insert(
5711        header::CONTENT_TYPE,
5712        "text/x-opml; charset=utf-8".parse().unwrap(),
5713    );
5714    resp.headers_mut().insert(
5715        header::CONTENT_DISPOSITION,
5716        "attachment; filename=\"featherreader-subscriptions.opml\""
5717            .parse()
5718            .unwrap(),
5719    );
5720    Ok(resp)
5721}
5722
5723// ---------------------------------------------------------------------------
5724// Signed session cookie (HMAC-SHA256, dependency-free)
5725// ---------------------------------------------------------------------------
5726
5727/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5728fn set_cookie(resp: &mut Response, cookie: &str) {
5729    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5730        resp.headers_mut()
5731            .append(axum::http::header::SET_COOKIE, value);
5732    }
5733}
5734
5735/// Whether the request came from htmx (the `HX-Request` header).
5736fn is_htmx(headers: &HeaderMap) -> bool {
5737    headers
5738        .get("HX-Request")
5739        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5740}
5741
5742/// Whether a mark-read / star request originated from the single-entry READER
5743/// (as opposed to the list view). The reader's forms tag themselves with
5744/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5745/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5746/// isn't in the DOM), the list gets the row (`entry_row.html`).
5747fn is_reader_request(headers: &HeaderMap) -> bool {
5748    headers
5749        .get("X-FR-Reader")
5750        .is_some_and(|v| v.as_bytes() == b"1")
5751}
5752
5753/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5754/// server-minted **session id** (never the DID — so the cookie can't be forged
5755/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5756/// server-side session id).
5757mod cookie {
5758    use super::{HeaderMap, SESSION_COOKIE};
5759
5760    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5761    pub fn sign_session(sid: &str, secret: &str) -> String {
5762        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5763    }
5764
5765    /// Verify the request's session cookie and return the session id it carries.
5766    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5767        verify_value(headers, SESSION_COOKIE, secret)
5768    }
5769
5770    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5771    /// value`), so a signature minted for one cookie can't verify under another —
5772    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5773    /// The NUL separator can't appear in a cookie name, so the encoding is
5774    /// unambiguous.
5775    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5776        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5777        msg.extend_from_slice(name.as_bytes());
5778        msg.push(0);
5779        msg.extend_from_slice(value.as_bytes());
5780        msg
5781    }
5782
5783    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5784    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5785    /// generic form behind both the session cookie and the short-lived invite
5786    /// cookie; domain-separating by name keeps a signature valid only for the
5787    /// cookie it was minted for.
5788    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5789        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5790        let b64 = b64url_encode(value.as_bytes());
5791        format!(
5792            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5793        )
5794    }
5795
5796    /// Verify + read a value out of the named signed cookie (`None` on absent /
5797    /// tampered / forged / cross-cookie). The generic form behind both readers.
5798    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5799        let raw = cookie_value(headers, name)?;
5800        let (b64, sig) = raw.split_once('.')?;
5801        let bytes = b64url_decode(b64)?;
5802        let value = String::from_utf8(bytes).ok()?;
5803        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5804        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5805            Some(value)
5806        } else {
5807            None
5808        }
5809    }
5810
5811    /// Sign an arbitrary `value` into an opaque, URL-safe token string
5812    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5813    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5814    /// URL query param (the bot's claim link). `label` domain-separates it from
5815    /// the cookies so a token can't be replayed as a cookie value.
5816    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5817        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5818        let b64 = b64url_encode(value.as_bytes());
5819        format!("{b64}.{sig}")
5820    }
5821
5822    /// Verify a token minted by [`sign_token`] and return the wrapped value
5823    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5824    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5825        let (b64, sig) = token.split_once('.')?;
5826        let bytes = b64url_decode(b64)?;
5827        let value = String::from_utf8(bytes).ok()?;
5828        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5829        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5830            Some(value)
5831        } else {
5832            None
5833        }
5834    }
5835
5836    /// Pull one cookie value out of the `Cookie` request header.
5837    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5838        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5839        for part in header.split(';') {
5840            let part = part.trim();
5841            if let Some((k, v)) = part.split_once('=') {
5842                if k == name {
5843                    return Some(v.to_string());
5844                }
5845            }
5846        }
5847        None
5848    }
5849
5850    /// Constant-time byte comparison (avoid signature-timing leaks). Public
5851    /// within the module so the bot-secret bearer check reuses the exact same
5852    /// comparator as the cookie/token HMAC checks (one implementation to audit).
5853    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5854        if a.len() != b.len() {
5855            return false;
5856        }
5857        let mut diff = 0u8;
5858        for (x, y) in a.iter().zip(b.iter()) {
5859            diff |= x ^ y;
5860        }
5861        diff == 0
5862    }
5863
5864    // -- URL-safe base64 (no padding), std-only --------------------------------
5865
5866    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5867
5868    fn b64url_encode(input: &[u8]) -> String {
5869        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5870        for chunk in input.chunks(3) {
5871            let b = [
5872                chunk[0],
5873                *chunk.get(1).unwrap_or(&0),
5874                *chunk.get(2).unwrap_or(&0),
5875            ];
5876            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5877            out.push(B64[((n >> 18) & 63) as usize] as char);
5878            out.push(B64[((n >> 12) & 63) as usize] as char);
5879            if chunk.len() > 1 {
5880                out.push(B64[((n >> 6) & 63) as usize] as char);
5881            }
5882            if chunk.len() > 2 {
5883                out.push(B64[(n & 63) as usize] as char);
5884            }
5885        }
5886        out
5887    }
5888
5889    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5890        fn val(c: u8) -> Option<u32> {
5891            match c {
5892                b'A'..=b'Z' => Some((c - b'A') as u32),
5893                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5894                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5895                b'-' => Some(62),
5896                b'_' => Some(63),
5897                _ => None,
5898            }
5899        }
5900        let bytes = input.as_bytes();
5901        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5902        for chunk in bytes.chunks(4) {
5903            let mut n = 0u32;
5904            let mut valid = 0;
5905            for (i, &c) in chunk.iter().enumerate() {
5906                n |= val(c)? << (18 - 6 * i);
5907                valid += 1;
5908            }
5909            out.push((n >> 16) as u8);
5910            if valid > 2 {
5911                out.push((n >> 8) as u8);
5912            }
5913            if valid > 3 {
5914                out.push(n as u8);
5915            }
5916        }
5917        Some(out)
5918    }
5919
5920    // -- HMAC-SHA256, std-only -------------------------------------------------
5921
5922    /// HMAC-SHA256(key, msg) as lowercase hex.
5923    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5924        const BLOCK: usize = 64;
5925        let mut k = [0u8; BLOCK];
5926        if key.len() > BLOCK {
5927            let d = sha256(key);
5928            k[..32].copy_from_slice(&d);
5929        } else {
5930            k[..key.len()].copy_from_slice(key);
5931        }
5932        let mut ipad = [0x36u8; BLOCK];
5933        let mut opad = [0x5cu8; BLOCK];
5934        for i in 0..BLOCK {
5935            ipad[i] ^= k[i];
5936            opad[i] ^= k[i];
5937        }
5938        let mut inner = Vec::with_capacity(BLOCK + msg.len());
5939        inner.extend_from_slice(&ipad);
5940        inner.extend_from_slice(msg);
5941        let inner_hash = sha256(&inner);
5942        let mut outer = Vec::with_capacity(BLOCK + 32);
5943        outer.extend_from_slice(&opad);
5944        outer.extend_from_slice(&inner_hash);
5945        let mac = sha256(&outer);
5946        let mut hex = String::with_capacity(64);
5947        for b in mac {
5948            hex.push_str(&format!("{b:02x}"));
5949        }
5950        hex
5951    }
5952
5953    /// SHA-256 (FIPS 180-4), std-only.
5954    fn sha256(data: &[u8]) -> [u8; 32] {
5955        const K: [u32; 64] = [
5956            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5957            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5958            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5959            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5960            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5961            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5962            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5963            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5964            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5965            0xc67178f2,
5966        ];
5967        let mut h: [u32; 8] = [
5968            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5969            0x5be0cd19,
5970        ];
5971
5972        let bit_len = (data.len() as u64) * 8;
5973        let mut msg = data.to_vec();
5974        msg.push(0x80);
5975        while msg.len() % 64 != 56 {
5976            msg.push(0);
5977        }
5978        msg.extend_from_slice(&bit_len.to_be_bytes());
5979
5980        for block in msg.chunks(64) {
5981            let mut w = [0u32; 64];
5982            for i in 0..16 {
5983                w[i] = u32::from_be_bytes([
5984                    block[i * 4],
5985                    block[i * 4 + 1],
5986                    block[i * 4 + 2],
5987                    block[i * 4 + 3],
5988                ]);
5989            }
5990            for i in 16..64 {
5991                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5992                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5993                w[i] = w[i - 16]
5994                    .wrapping_add(s0)
5995                    .wrapping_add(w[i - 7])
5996                    .wrapping_add(s1);
5997            }
5998            let mut a = h;
5999            for i in 0..64 {
6000                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
6001                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
6002                let t1 = a[7]
6003                    .wrapping_add(s1)
6004                    .wrapping_add(ch)
6005                    .wrapping_add(K[i])
6006                    .wrapping_add(w[i]);
6007                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
6008                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
6009                let t2 = s0.wrapping_add(maj);
6010                a[7] = a[6];
6011                a[6] = a[5];
6012                a[5] = a[4];
6013                a[4] = a[3].wrapping_add(t1);
6014                a[3] = a[2];
6015                a[2] = a[1];
6016                a[1] = a[0];
6017                a[0] = t1.wrapping_add(t2);
6018            }
6019            for i in 0..8 {
6020                h[i] = h[i].wrapping_add(a[i]);
6021            }
6022        }
6023
6024        let mut out = [0u8; 32];
6025        for (i, word) in h.iter().enumerate() {
6026            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6027        }
6028        out
6029    }
6030
6031    #[cfg(test)]
6032    mod tests {
6033        use super::*;
6034
6035        #[test]
6036        fn sha256_known_vector() {
6037            let d = sha256(b"abc");
6038            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6039            assert_eq!(
6040                hex,
6041                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6042            );
6043        }
6044
6045        #[test]
6046        fn hmac_known_vector() {
6047            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6048            assert_eq!(
6049                mac,
6050                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6051            );
6052        }
6053
6054        #[test]
6055        fn sign_verify_round_trips() {
6056            let secret = "test-secret";
6057            let sid = "9f2c-opaque-session-id";
6058            let cookie = sign_session(sid, secret);
6059            let pair = cookie.split(';').next().unwrap().to_string();
6060            let mut headers = HeaderMap::new();
6061            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6062            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6063            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
6064            assert!(verify_session(&headers, "other-secret").is_none());
6065        }
6066
6067        #[test]
6068        fn forged_and_tampered_cookies_are_rejected() {
6069            let secret = "test-secret";
6070
6071            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
6072            //    the secret, so an arbitrary signature must not verify.
6073            let forged = format!(
6074                "{SESSION_COOKIE}={}.{}",
6075                b64url_encode(b"attacker-chosen-sid"),
6076                "deadbeef".repeat(8) // 64 hex chars, wrong sig
6077            );
6078            let mut headers = HeaderMap::new();
6079            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6080            assert!(verify_session(&headers, secret).is_none());
6081
6082            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
6083            //    keeping the original signature — must not verify.
6084            let cookie = sign_session("real-sid", secret);
6085            let pair = cookie.split(';').next().unwrap();
6086            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6087            let tampered = format!(
6088                "{SESSION_COOKIE}={}.{}",
6089                b64url_encode(b"different-sid"),
6090                sig
6091            );
6092            let mut headers2 = HeaderMap::new();
6093            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6094            assert!(verify_session(&headers2, secret).is_none());
6095        }
6096
6097        #[test]
6098        fn b64url_round_trips() {
6099            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6100                let enc = b64url_encode(s.as_bytes());
6101                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6102            }
6103        }
6104    }
6105}
6106
6107// ---------------------------------------------------------------------------
6108// Small store helpers local to the web layer
6109// ---------------------------------------------------------------------------
6110
6111/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
6112///
6113/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
6114/// `did` does not subscribe to its feed. This is the per-DID read gate for the
6115/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
6116/// deduped by URL, but no DID can read another DID's cached article.
6117///
6118/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
6119/// that renders `content_html`, and it fetches exactly one row. The list views
6120/// go through [`store::list_entries`], which is both paged and body-free — see
6121/// [`store::EntryListRow`] for why they had to stop sharing this projection.
6122async fn get_entry_by_id(
6123    pool: &store::Pool,
6124    did: &str,
6125    id: i64,
6126) -> anyhow::Result<Option<store::Entry>> {
6127    let entry = sqlx::query_as::<_, store::Entry>(
6128        r#"
6129        SELECT e.* FROM entries e
6130        WHERE e.id = ?2
6131          AND EXISTS (
6132              SELECT 1 FROM sub_ref sr
6133              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6134          )
6135        "#,
6136    )
6137    .bind(did)
6138    .bind(id)
6139    .fetch_optional(pool)
6140    .await?;
6141    Ok(entry)
6142}
6143
6144/// Whether `entry_id` is marked read for `did` (absent state row = unread).
6145async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6146    let read: Option<bool> =
6147        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6148            .bind(did)
6149            .bind(entry_id)
6150            .fetch_optional(pool)
6151            .await?
6152            .flatten();
6153    Ok(read.unwrap_or(false))
6154}
6155
6156/// Whether `entry_id` is starred for `did` (absent state row = not starred).
6157async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6158    let starred: Option<bool> =
6159        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6160            .bind(did)
6161            .bind(entry_id)
6162            .fetch_optional(pool)
6163            .await?
6164            .flatten();
6165    Ok(starred.unwrap_or(false))
6166}
6167
6168/// Feed display title for one entry's feed id (via a single lookup).
6169async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6170    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6171        .bind(feed_id)
6172        .fetch_optional(pool)
6173        .await
6174    {
6175        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6176        _ => String::new(),
6177    }
6178}
6179
6180/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
6181/// be forced (mark-read path) or looked up (`None` — star path).
6182async fn build_entry_row(
6183    pool: &store::Pool,
6184    did: &str,
6185    id: i64,
6186    read: Option<bool>,
6187) -> anyhow::Result<Option<EntryRow>> {
6188    let entry = match get_entry_by_id(pool, did, id).await? {
6189        Some(e) => e,
6190        None => return Ok(None),
6191    };
6192    let read = match read {
6193        Some(r) => r,
6194        None => entry_is_read(pool, did, id).await?,
6195    };
6196    let starred = entry_is_starred(pool, did, id).await?;
6197    Ok(Some(EntryRow {
6198        id: entry.id,
6199        title: entry
6200            .title
6201            .clone()
6202            .filter(|t| !t.trim().is_empty())
6203            .unwrap_or_else(|| "(untitled)".to_string()),
6204        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6205        published: display_date(entry.published.as_deref()),
6206        read,
6207        starred,
6208        link: SafeLink::entry(id, ""),
6209        cached: true,
6210        rkey: String::new(),
6211    }))
6212}
6213
6214/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
6215fn now_rfc3339() -> String {
6216    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6217}
6218
6219#[cfg(test)]
6220mod tests {
6221    use super::*;
6222
6223    #[test]
6224    fn qenc_encodes_reserved() {
6225        assert_eq!(qenc("a b"), "a%20b");
6226        assert_eq!(
6227            qenc("https://example.com/feed.xml"),
6228            "https%3A%2F%2Fexample.com%2Ffeed.xml"
6229        );
6230        assert_eq!(
6231            qenc("at://did:plc:x/c/r"),
6232            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6233        );
6234        // Unreserved chars pass through untouched.
6235        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6236    }
6237
6238    #[test]
6239    fn folder_uri_shape() {
6240        assert_eq!(
6241            folder_uri("did:plc:abc", "3kfolder"),
6242            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6243        );
6244    }
6245
6246    // -- public-feeds-only: private/paid feeds are refused --------------------
6247
6248    #[test]
6249    fn private_feeds_are_classified_private_across_providers() {
6250        // The add + OPML paths both gate on this classifier; assert it flags a
6251        // spread of paid providers (newsletters + private podcasts) and the
6252        // generic credential-in-URL shapes.
6253        for url in [
6254            "https://author.substack.com/feed/private/deadbeefcafe1234",
6255            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6256            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6257            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6258            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6259            "https://user:pass@example.com/feed",
6260        ] {
6261            assert!(
6262                feed::classify_feed_privacy(url).is_private(),
6263                "expected private: {url}"
6264            );
6265        }
6266    }
6267
6268    #[test]
6269    fn public_feeds_stay_public() {
6270        for url in [
6271            "https://author.substack.com/feed",
6272            "https://wordpress.example.com/feed/",
6273            "https://example.com/rss.xml",
6274            "https://example.org/atom.xml",
6275            // YouTube channel/playlist RSS is fully public — must not false-block.
6276            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6277            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6278        ] {
6279            assert!(
6280                !feed::classify_feed_privacy(url).is_private(),
6281                "expected public: {url}"
6282            );
6283        }
6284    }
6285
6286    #[test]
6287    fn private_feed_label_is_public_safe_host_only() {
6288        // The OPML skip report must never echo the secret path/query, only the host.
6289        let label =
6290            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6291        assert_eq!(label, "author.substack.com");
6292        assert!(!label.contains("deadbeefcafe1234token"));
6293        assert!(!label.contains("/private/"));
6294        // An unparseable URL degrades to a generic label.
6295        assert_eq!(private_feed_label("not a url"), "a private feed");
6296    }
6297
6298    #[test]
6299    fn refusal_message_promises_nothing_stored() {
6300        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6301        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6302    }
6303
6304    #[test]
6305    fn scope_query_preserves_context() {
6306        let q = EntryQuery {
6307            feed: Some("https://example.com/feed.xml".to_string()),
6308            folder: None,
6309            view: Some("all".to_string()),
6310        };
6311        let s = scope_query(&q);
6312        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6313        assert!(s.contains("view=all"));
6314
6315        // Default view is omitted.
6316        let q2 = EntryQuery {
6317            feed: None,
6318            folder: None,
6319            view: Some("unread".to_string()),
6320        };
6321        assert_eq!(scope_query(&q2), "");
6322    }
6323
6324    // -- closed-beta invite gate + rate-limit + cache-control ------------------
6325
6326    use axum::body::Body;
6327    use axum::http::Request;
6328    use tower::ServiceExt; // for `oneshot`
6329
6330    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
6331    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
6332    /// can forge matching cookies.
6333    async fn test_state(allowed: &[&str]) -> AppState {
6334        let db = store::init_url("sqlite::memory:").await.unwrap();
6335        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6336        store::ensure_seed(&db, &dids).await.unwrap();
6337        let config = Config {
6338            allowed_dids: dids,
6339            cookie_secret: "test-cookie-secret-000".to_string(),
6340            beta_cap: 3,
6341            ..Config::default()
6342        };
6343        AppState::new(config, db).unwrap()
6344    }
6345
6346    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
6347    /// looked up in the registry, so create the session first).
6348    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6349        let sid = state.sessions.create(Session {
6350            did: did.to_string(),
6351            handle: handle.map(str::to_string),
6352        });
6353        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6354        sc.split(';').next().unwrap().to_string()
6355    }
6356
6357    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
6358    /// long time to accept distinct source IPs on two unauthenticated guarded
6359    /// routes.
6360    #[test]
6361    fn the_rate_limit_map_is_bounded() {
6362        let rl = RateLimiter::shared();
6363        let now = Instant::now();
6364        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6365            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
6366            // ordering below is well-defined.
6367            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6368            rl.check_at(ip, now + Duration::from_millis(i as u64));
6369        }
6370        let len = rl.inner.lock().unwrap().buckets.len();
6371        assert!(
6372            len <= MAX_RATE_BUCKETS,
6373            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6374        );
6375    }
6376
6377    /// Eviction must not hand a throttled attacker a fresh burst.
6378    ///
6379    /// The bound is LRU, so the one bucket an attacker can never evict is their
6380    /// own — it is the most recently touched thing in the map. If this inverted,
6381    /// the size cap would become a rate-limit bypass: spray addresses until the
6382    /// map overflows, then resume.
6383    #[test]
6384    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6385        let rl = RateLimiter::shared();
6386        let base = Instant::now();
6387        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6388        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
6389        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
6390        // millisecond step made the whole flood take a second, and the refill —
6391        // working correctly — then looked exactly like an eviction bypass.
6392        let at = |n: u64| base + Duration::from_nanos(n);
6393
6394        // Spend the burst. `RATE_BURST` allowed, then refused.
6395        for i in 0..(RATE_BURST as u64) {
6396            assert!(rl.check_at(attacker, at(i)));
6397        }
6398        assert!(
6399            !rl.check_at(attacker, at(RATE_BURST as u64)),
6400            "burst was not exhausted; the rest of this test proves nothing"
6401        );
6402
6403        // Now overflow the map from other addresses, interleaving the attacker
6404        // so their bucket stays hot — the realistic shape of the attack.
6405        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6406            let t = at(100 + i as u64 * 2);
6407            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6408            rl.check_at(ip, t);
6409            assert!(
6410                !rl.check_at(attacker, t),
6411                "the attacker got a token back after evictions at i={i}"
6412            );
6413        }
6414    }
6415
6416    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
6417    /// of the whole map on every guarded request, on one shared core.
6418    #[test]
6419    fn the_idle_sweep_does_not_run_on_every_request() {
6420        let rl = RateLimiter::shared();
6421        let start = Instant::now();
6422        let a: IpAddr = "198.51.100.1".parse().unwrap();
6423        let b: IpAddr = "198.51.100.2".parse().unwrap();
6424
6425        rl.check_at(a, start);
6426        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
6427        // the sweep interval has elapsed too, so this request does sweep it.
6428        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6429        assert!(
6430            !rl.inner.lock().unwrap().buckets.contains_key(&a),
6431            "an idle bucket survived a sweep that was due"
6432        );
6433
6434        // A second request moments later must NOT re-sweep — `b` is still there,
6435        // and the recorded sweep time must not have moved.
6436        let before = rl.inner.lock().unwrap().last_sweep;
6437        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6438        assert_eq!(
6439            rl.inner.lock().unwrap().last_sweep,
6440            before,
6441            "the sweep ran again within the interval"
6442        );
6443    }
6444
6445    #[test]
6446    fn rate_limited_paths_match_expected() {
6447        use axum::http::Method;
6448        assert!(is_rate_limited_path("/login", &Method::GET));
6449        assert!(is_rate_limited_path("/login", &Method::POST));
6450        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6451        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6452        assert!(is_rate_limited_path("/opml", &Method::POST));
6453        assert!(is_rate_limited_path("/read-all", &Method::POST));
6454        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6455        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6456        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6457        // Read-only navigation is NOT limited.
6458        assert!(!is_rate_limited_path("/", &Method::GET));
6459        assert!(!is_rate_limited_path("/about", &Method::GET));
6460        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6461        assert!(!is_rate_limited_path("/login", &Method::HEAD));
6462    }
6463
6464    #[test]
6465    fn rate_limiter_allows_burst_then_429s() {
6466        let rl = RateLimiter::shared();
6467        let ip: IpAddr = "203.0.113.7".parse().unwrap();
6468        // The full burst passes.
6469        for _ in 0..(RATE_BURST as usize) {
6470            assert!(rl.check(ip));
6471        }
6472        // The next one (no time elapsed → no refill) is rejected.
6473        assert!(!rl.check(ip));
6474        // A different IP has its own bucket.
6475        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6476        assert!(rl.check(ip2));
6477    }
6478
6479    #[test]
6480    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6481        // With NO trusted header configured, a client-supplied X-Forwarded-For
6482        // must be ignored entirely — the limiter keys on the real socket peer,
6483        // so an attacker can't mint a fresh bucket per forged XFF value.
6484        let mut h = HeaderMap::new();
6485        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6486        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6487        assert_eq!(
6488            client_ip(&h, Some(&sock), None),
6489            Some("203.0.113.55".parse().unwrap()),
6490            "spoofed XFF must not override the socket peer"
6491        );
6492    }
6493
6494    #[test]
6495    fn client_ip_uses_trusted_header_last_hop() {
6496        // With a trusted proxy header configured, the client IP comes from THAT
6497        // header (the proxy overwrites any client copy). On a comma list we take
6498        // the RIGHT-most hop — the one the trusted proxy appended — so a
6499        // client-forged left-most value is ignored.
6500        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6501
6502        let mut h = HeaderMap::new();
6503        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6504        assert_eq!(
6505            client_ip(&h, Some(&sock), Some("fly-client-ip")),
6506            Some("198.51.100.9".parse().unwrap())
6507        );
6508
6509        // Attacker prepends a forged hop; the trusted proxy appends the real one.
6510        let mut h2 = HeaderMap::new();
6511        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6512        assert_eq!(
6513            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6514            Some("198.51.100.9".parse().unwrap()),
6515            "must take the right-most (trusted) hop, not the forged left-most"
6516        );
6517
6518        // Trusted header absent → fall back to the socket peer.
6519        let h3 = HeaderMap::new();
6520        assert_eq!(
6521            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6522            Some("10.0.0.1".parse().unwrap())
6523        );
6524    }
6525
6526    #[test]
6527    fn invite_cookie_round_trips_and_rejects_tamper() {
6528        let secret = "test-cookie-secret-000";
6529        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6530        let pair = sc.split(';').next().unwrap();
6531        let mut h = HeaderMap::new();
6532        h.insert(header::COOKIE, pair.parse().unwrap());
6533        assert_eq!(
6534            invite_cookie_code(&h, secret).as_deref(),
6535            Some("FEATHER-ABCDWXYZ")
6536        );
6537        // Wrong secret → rejected.
6538        assert!(invite_cookie_code(&h, "other").is_none());
6539    }
6540
6541    #[tokio::test]
6542    async fn preflight_valid_expired_and_full() {
6543        let state = test_state(&["did:plc:admin"]).await;
6544        // A minted, active code preflights OK.
6545        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6546            .await
6547            .unwrap();
6548        assert!(preflight_code(&state, &code).await.is_ok());
6549
6550        // A code whose expiry is in the past preflights as Expired. (mint_code
6551        // clamps negative ttl to 0, so back-date the row directly for a
6552        // deterministic past expiry.)
6553        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6554            .await
6555            .unwrap();
6556        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6557            .bind(chrono::Utc::now().timestamp() - 3600)
6558            .bind(&expired)
6559            .execute(&state.db)
6560            .await
6561            .unwrap();
6562        assert_eq!(
6563            preflight_code(&state, &expired).await,
6564            Err(store::RedeemError::Expired)
6565        );
6566
6567        // Unknown code → NotFound.
6568        assert_eq!(
6569            preflight_code(&state, "FEATHER-NOPENOPE").await,
6570            Err(store::RedeemError::NotFound)
6571        );
6572
6573        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6574        // must report CapacityFull.
6575        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6576            .await
6577            .unwrap();
6578        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6579            .await
6580            .unwrap();
6581        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6582        assert_eq!(
6583            preflight_code(&state, &code).await,
6584            Err(store::RedeemError::CapacityFull)
6585        );
6586    }
6587
6588    // -- Bot claim link + shared-secret mint ---------------------------------
6589
6590    /// A test state with a configured bot secret (so `/bot/claims` is live).
6591    async fn bot_state(bot_secret: &str) -> AppState {
6592        let db = store::init_url("sqlite::memory:").await.unwrap();
6593        store::ensure_seed(&db, &["did:plc:admin".to_string()])
6594            .await
6595            .unwrap();
6596        let config = Config {
6597            allowed_dids: vec!["did:plc:admin".to_string()],
6598            cookie_secret: "test-cookie-secret-000".to_string(),
6599            beta_cap: 3,
6600            bot_secret: Some(bot_secret.to_string()),
6601            public_url: "https://feather-reader.com".to_string(),
6602            ..Config::default()
6603        };
6604        AppState::new(config, db).unwrap()
6605    }
6606
6607    #[test]
6608    fn claim_token_round_trips_and_rejects_tamper() {
6609        let secret = "test-cookie-secret-000";
6610        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6611        // No cookie framing — a bare URL-safe token.
6612        assert!(!token.contains(';'));
6613        assert_eq!(
6614            claim_token_code(&token, secret).as_deref(),
6615            Some("FEATHER-ABCDWXYZ")
6616        );
6617        // Wrong secret → rejected.
6618        assert!(claim_token_code(&token, "other").is_none());
6619        // Tampered token → rejected.
6620        let mut bad = token.clone();
6621        bad.push('x');
6622        assert!(claim_token_code(&bad, secret).is_none());
6623        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6624        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6625        // recover it WITHOUT the secret). The security is single-use + HMAC
6626        // integrity + rate-limit, not secrecy of the code. Assert the code half is
6627        // publicly decodable (a plain base64url decode, no secret involved).
6628        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6629        assert_eq!(
6630            test_b64url_decode(b64).as_deref(),
6631            Some("FEATHER-ABCDWXYZ".as_bytes()),
6632            "the code half of the token is plain base64url, decodable by anyone"
6633        );
6634    }
6635
6636    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6637    /// claim token's code half needs NO secret to recover (it is not confidential).
6638    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6639        fn val(c: u8) -> Option<u32> {
6640            match c {
6641                b'A'..=b'Z' => Some((c - b'A') as u32),
6642                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6643                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6644                b'-' => Some(62),
6645                b'_' => Some(63),
6646                _ => None,
6647            }
6648        }
6649        let mut out = Vec::with_capacity(input.len() / 4 * 3);
6650        for chunk in input.as_bytes().chunks(4) {
6651            let mut n = 0u32;
6652            let mut bits = 0;
6653            for &c in chunk {
6654                n = (n << 6) | val(c)?;
6655                bits += 6;
6656            }
6657            let bytes = bits / 8;
6658            n <<= 24 - bits;
6659            for i in 0..bytes {
6660                out.push((n >> (16 - i * 8)) as u8);
6661            }
6662        }
6663        Some(out)
6664    }
6665
6666    #[tokio::test]
6667    async fn bot_mint_then_claim_grants_a_seat() {
6668        let state = bot_state("bot-secret-abcdef").await;
6669        let app = router(state.clone());
6670
6671        // 1. Mint a claim via the shared-secret endpoint.
6672        let resp = app
6673            .clone()
6674            .oneshot(
6675                Request::builder()
6676                    .method("POST")
6677                    .uri("/bot/claims")
6678                    .header("x-bot-secret", "bot-secret-abcdef")
6679                    .body(Body::empty())
6680                    .unwrap(),
6681            )
6682            .await
6683            .unwrap();
6684        assert_eq!(resp.status(), StatusCode::OK);
6685        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6686            .await
6687            .unwrap();
6688        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6689        let token = json["token"].as_str().unwrap().to_string();
6690        let url = json["url"].as_str().unwrap();
6691        assert!(url.starts_with("https://feather-reader.com/claim?t="));
6692        // The raw code is returned for the bot's records but not embedded in url.
6693        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6694        assert!(!url.contains("FEATHER-"));
6695
6696        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6697        let resp = app
6698            .clone()
6699            .oneshot(
6700                Request::builder()
6701                    .method("GET")
6702                    .uri(format!("/claim?t={}", qenc(&token)))
6703                    .body(Body::empty())
6704                    .unwrap(),
6705            )
6706            .await
6707            .unwrap();
6708        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6709        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6710        let set_cookie = resp
6711            .headers()
6712            .get(header::SET_COOKIE)
6713            .unwrap()
6714            .to_str()
6715            .unwrap();
6716        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6717
6718        // 3. The reserved cookie carries the same code the token wrapped, and
6719        //    redeeming it (the callback's machinery) grants a seat.
6720        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6721        let out = store::redeem_code(
6722            &state.db,
6723            &code,
6724            "did:plc:follower",
6725            None,
6726            state.config.beta_cap,
6727        )
6728        .await
6729        .unwrap();
6730        assert_eq!(out, Ok(()));
6731        assert!(store::has_beta_access(&state.db, "did:plc:follower")
6732            .await
6733            .unwrap());
6734    }
6735
6736    #[tokio::test]
6737    async fn claim_with_invalid_token_bounces() {
6738        let state = bot_state("bot-secret-abcdef").await;
6739        let app = router(state);
6740        let resp = app
6741            .oneshot(
6742                Request::builder()
6743                    .method("GET")
6744                    .uri("/claim?t=not-a-real-token")
6745                    .body(Body::empty())
6746                    .unwrap(),
6747            )
6748            .await
6749            .unwrap();
6750        // Renders the invite page (200), NOT a redirect to /login.
6751        assert_eq!(resp.status(), StatusCode::OK);
6752    }
6753
6754    #[tokio::test]
6755    async fn claim_with_used_token_is_refused() {
6756        let state = bot_state("bot-secret-abcdef").await;
6757        // Mint a code + wrap it, then redeem it out from under the token.
6758        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6759            .await
6760            .unwrap();
6761        let token = sign_claim_token(&code, &state.config.cookie_secret);
6762        store::redeem_code(
6763            &state.db,
6764            &code,
6765            "did:plc:someone",
6766            None,
6767            state.config.beta_cap,
6768        )
6769        .await
6770        .unwrap()
6771        .unwrap();
6772        let app = router(state);
6773        let resp = app
6774            .oneshot(
6775                Request::builder()
6776                    .method("GET")
6777                    .uri(format!("/claim?t={}", qenc(&token)))
6778                    .body(Body::empty())
6779                    .unwrap(),
6780            )
6781            .await
6782            .unwrap();
6783        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6784        assert_eq!(resp.status(), StatusCode::OK);
6785        assert!(resp.headers().get(header::SET_COOKIE).is_none());
6786    }
6787
6788    #[tokio::test]
6789    async fn bot_claims_rejects_bad_and_missing_secret() {
6790        let state = bot_state("bot-secret-abcdef").await;
6791        let app = router(state);
6792        // Wrong secret.
6793        let resp = app
6794            .clone()
6795            .oneshot(
6796                Request::builder()
6797                    .method("POST")
6798                    .uri("/bot/claims")
6799                    .header("x-bot-secret", "wrong")
6800                    .body(Body::empty())
6801                    .unwrap(),
6802            )
6803            .await
6804            .unwrap();
6805        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6806        // Missing secret.
6807        let resp = app
6808            .oneshot(
6809                Request::builder()
6810                    .method("POST")
6811                    .uri("/bot/claims")
6812                    .body(Body::empty())
6813                    .unwrap(),
6814            )
6815            .await
6816            .unwrap();
6817        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6818    }
6819
6820    #[tokio::test]
6821    async fn bot_claims_disabled_when_secret_unset() {
6822        // test_state configures NO bot secret → the endpoint is off (503).
6823        let state = test_state(&["did:plc:admin"]).await;
6824        let app = router(state);
6825        let resp = app
6826            .oneshot(
6827                Request::builder()
6828                    .method("POST")
6829                    .uri("/bot/claims")
6830                    .header("x-bot-secret", "anything")
6831                    .body(Body::empty())
6832                    .unwrap(),
6833            )
6834            .await
6835            .unwrap();
6836        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6837    }
6838
6839    #[tokio::test]
6840    async fn bot_claims_refuses_at_capacity() {
6841        let state = bot_state("bot-secret-abcdef").await;
6842        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6843        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6844            .await
6845            .unwrap();
6846        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6847            .await
6848            .unwrap();
6849        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6850        let app = router(state);
6851        let resp = app
6852            .oneshot(
6853                Request::builder()
6854                    .method("POST")
6855                    .uri("/bot/claims")
6856                    .header("x-bot-secret", "bot-secret-abcdef")
6857                    .body(Body::empty())
6858                    .unwrap(),
6859            )
6860            .await
6861            .unwrap();
6862        assert_eq!(resp.status(), StatusCode::CONFLICT);
6863        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6864            .await
6865            .unwrap();
6866        assert!(String::from_utf8_lossy(&bytes).contains("full"));
6867    }
6868
6869    #[tokio::test]
6870    async fn bot_claims_counts_outstanding_codes_against_cap() {
6871        let state = bot_state("bot-secret-abcdef").await;
6872        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6873        store::mint_code(&state.db, "did:plc:admin", 3600)
6874            .await
6875            .unwrap();
6876        store::mint_code(&state.db, "did:plc:admin", 3600)
6877            .await
6878            .unwrap();
6879        let app = router(state);
6880        let resp = app
6881            .oneshot(
6882                Request::builder()
6883                    .method("POST")
6884                    .uri("/bot/claims")
6885                    .header("x-bot-secret", "bot-secret-abcdef")
6886                    .body(Body::empty())
6887                    .unwrap(),
6888            )
6889            .await
6890            .unwrap();
6891        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6892        assert_eq!(resp.status(), StatusCode::CONFLICT);
6893    }
6894
6895    /// POST /bot/claims with a JSON body carrying the follower DID.
6896    async fn post_bot_claim_for(
6897        app: &axum::Router,
6898        secret: &str,
6899        did: &str,
6900    ) -> (StatusCode, serde_json::Value) {
6901        let resp = app
6902            .clone()
6903            .oneshot(
6904                Request::builder()
6905                    .method("POST")
6906                    .uri("/bot/claims")
6907                    .header("x-bot-secret", secret)
6908                    .header("content-type", "application/json")
6909                    .body(Body::from(format!(
6910                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6911                    )))
6912                    .unwrap(),
6913            )
6914            .await
6915            .unwrap();
6916        let status = resp.status();
6917        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6918            .await
6919            .unwrap();
6920        let json = if bytes.is_empty() {
6921            serde_json::Value::Null
6922        } else {
6923            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6924        };
6925        (status, json)
6926    }
6927
6928    #[tokio::test]
6929    async fn bot_claims_returns_already_seated_for_a_member() {
6930        // A DID that already holds beta access must get `already_seated` with NO
6931        // code/url — the bot posts nothing. This is the server-side backstop that
6932        // survives a bot-host state loss (it would otherwise re-mint + re-post).
6933        let state = bot_state("bot-secret-abcdef").await;
6934        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6935            .await
6936            .unwrap();
6937        let app = router(state.clone());
6938        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6939        assert_eq!(status, StatusCode::OK);
6940        assert_eq!(json["status"], "already_seated");
6941        assert_eq!(json["code"], "");
6942        assert_eq!(json["url"], "");
6943        // No new invite code was minted for the seated DID.
6944        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6945            .await
6946            .unwrap()
6947            .is_none());
6948    }
6949
6950    #[tokio::test]
6951    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6952        // Two mint requests for the SAME follower DID must return the SAME code
6953        // (the app is authoritative), never a second one — so a bot-host state loss
6954        // re-requesting cannot double-mint or double-post.
6955        let state = bot_state("bot-secret-abcdef").await;
6956        let app = router(state.clone());
6957
6958        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6959        assert_eq!(s1, StatusCode::OK);
6960        assert_eq!(j1["status"], "minted");
6961        let code1 = j1["code"].as_str().unwrap().to_string();
6962
6963        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6964        assert_eq!(s2, StatusCode::OK);
6965        assert_eq!(j2["status"], "existing");
6966        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6967        assert_eq!(j2["url"], j1["url"], "same url returned");
6968
6969        // Exactly ONE active code exists for that DID.
6970        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6971    }
6972
6973    #[tokio::test]
6974    async fn bot_claims_records_intended_did_at_mint() {
6975        // A fresh mint records the follower DID so the lookup finds it.
6976        let state = bot_state("bot-secret-abcdef").await;
6977        let app = router(state.clone());
6978        let (status, json) =
6979            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6980        assert_eq!(status, StatusCode::OK);
6981        let code = json["code"].as_str().unwrap();
6982        assert_eq!(
6983            store::find_active_code_for_did(&state.db, "did:plc:follower2")
6984                .await
6985                .unwrap()
6986                .as_deref(),
6987            Some(code)
6988        );
6989    }
6990
6991    #[tokio::test]
6992    async fn bot_claims_concurrent_same_did_never_double_mints() {
6993        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6994        // active code. The dedupe check (3b) and the mint are separate statements,
6995        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6996        // then makes the loser's INSERT conflict, and the handler recovers by
6997        // returning the winner's code (status `existing`) rather than 500-ing.
6998        // Result: exactly ONE active code, and BOTH callers get a usable code.
6999        let state = bot_state("bot-secret-abcdef").await;
7000        let app = router(state.clone());
7001
7002        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7003        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7004        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
7005
7006        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
7007        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
7008
7009        // Exactly one active code for the DID — the whole point of the fix.
7010        assert_eq!(
7011            store::count_active_codes(&state.db).await.unwrap(),
7012            1,
7013            "concurrent mints must not create two active codes"
7014        );
7015
7016        // Both callers received the SAME (single) code, and neither got a 500.
7017        let ca = ja["code"].as_str().unwrap_or("");
7018        let cb = jb["code"].as_str().unwrap_or("");
7019        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7020        assert_eq!(ca, cb, "both callers must get the one minted code");
7021        // One is `minted` (the winner), the other `minted` or `existing` depending
7022        // on interleaving — but never an error status.
7023        for st in [&ja["status"], &jb["status"]] {
7024            let s = st.as_str().unwrap_or("");
7025            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7026        }
7027    }
7028
7029    #[tokio::test]
7030    async fn bot_claims_rejects_malformed_json_body() {
7031        let state = bot_state("bot-secret-abcdef").await;
7032        let app = router(state);
7033        let resp = app
7034            .oneshot(
7035                Request::builder()
7036                    .method("POST")
7037                    .uri("/bot/claims")
7038                    .header("x-bot-secret", "bot-secret-abcdef")
7039                    .header("content-type", "application/json")
7040                    .body(Body::from("{not json"))
7041                    .unwrap(),
7042            )
7043            .await
7044            .unwrap();
7045        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7046    }
7047
7048    #[tokio::test]
7049    async fn favicon_ico_served_at_root() {
7050        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
7051        // tags in <head>; the root route must serve the icon, not 404.
7052        let state = test_state(&[]).await;
7053        let app = router(state);
7054        let resp = app
7055            .oneshot(
7056                Request::builder()
7057                    .uri("/favicon.ico")
7058                    .body(Body::empty())
7059                    .unwrap(),
7060            )
7061            .await
7062            .unwrap();
7063        assert_eq!(resp.status(), StatusCode::OK);
7064        let ct = resp
7065            .headers()
7066            .get(header::CONTENT_TYPE)
7067            .unwrap()
7068            .to_str()
7069            .unwrap();
7070        assert!(
7071            ct.contains("icon") || ct.starts_with("image/"),
7072            "content-type = {ct}"
7073        );
7074    }
7075
7076    #[tokio::test]
7077    async fn login_without_invite_redirects_to_beta_redeem() {
7078        // No allow-list seed, no invite cookie: starting OAuth must be refused.
7079        let state = test_state(&[]).await;
7080        let app = router(state);
7081        let resp = app
7082            .oneshot(
7083                Request::builder()
7084                    .method("POST")
7085                    .uri("/login")
7086                    .header("content-type", "application/x-www-form-urlencoded")
7087                    .body(Body::from("handle=alice.bsky.social"))
7088                    .unwrap(),
7089            )
7090            .await
7091            .unwrap();
7092        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7093        assert_eq!(
7094            resp.headers().get(header::LOCATION).unwrap(),
7095            "/beta/redeem"
7096        );
7097    }
7098
7099    #[tokio::test]
7100    async fn login_with_valid_invite_cookie_starts_oauth() {
7101        let state = test_state(&[]).await;
7102        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7103        let cookie = cookie.split(';').next().unwrap().to_string();
7104        let app = router(state);
7105        let resp = app
7106            .oneshot(
7107                Request::builder()
7108                    .method("POST")
7109                    .uri("/login")
7110                    .header("content-type", "application/x-www-form-urlencoded")
7111                    .header(header::COOKIE, cookie)
7112                    .body(Body::from("handle=alice.bsky.social"))
7113                    .unwrap(),
7114            )
7115            .await
7116            .unwrap();
7117        // Redirects into the sidecar login (not to /beta/redeem).
7118        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7119        let loc = resp
7120            .headers()
7121            .get(header::LOCATION)
7122            .unwrap()
7123            .to_str()
7124            .unwrap();
7125        assert!(loc.contains("/login"), "loc = {loc}");
7126        assert_ne!(loc, "/beta/redeem");
7127    }
7128
7129    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
7130    /// any network resolution and that a resolution failure fails closed.
7131    async fn resolver_never(_handle: String) -> Option<String> {
7132        None
7133    }
7134
7135    /// A resolver that maps every handle to `did`.
7136    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7137        move |_handle| std::future::ready(Some(did.to_string()))
7138    }
7139
7140    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
7141    /// that already holds a seat (the seeded-admin first-login case) passes the
7142    /// gate — no session cookie, no invite code.
7143    #[tokio::test]
7144    async fn may_start_oauth_honors_seat_via_resolved_handle() {
7145        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
7146        // no cookie on a fresh deploy.
7147        let state = test_state(&["did:plc:admin"]).await;
7148        let headers = HeaderMap::new();
7149        assert!(
7150            may_start_oauth_with(
7151                &state,
7152                &headers,
7153                "admin.example",
7154                resolver_to("did:plc:admin")
7155            )
7156            .await,
7157            "a handle resolving to a seated DID must pass the gate"
7158        );
7159    }
7160
7161    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
7162    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
7163    /// fails).
7164    #[tokio::test]
7165    async fn may_start_oauth_bounces_non_member_handle() {
7166        let state = test_state(&["did:plc:admin"]).await;
7167        let headers = HeaderMap::new();
7168        assert!(
7169            !may_start_oauth_with(
7170                &state,
7171                &headers,
7172                "rando.example",
7173                resolver_to("did:plc:rando")
7174            )
7175            .await,
7176            "a resolved DID with no seat must be bounced"
7177        );
7178    }
7179
7180    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
7181    /// bounces gracefully — no panic, no handshake.
7182    #[tokio::test]
7183    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7184        let state = test_state(&["did:plc:admin"]).await;
7185        let headers = HeaderMap::new();
7186        assert!(
7187            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7188            "an unresolvable handle must fail closed"
7189        );
7190    }
7191
7192    /// The session-cookie fast path admits a seated member WITHOUT calling the
7193    /// resolver (proven by injecting `resolver_never`, which would otherwise
7194    /// bounce).
7195    #[tokio::test]
7196    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7197        let state = test_state(&[]).await;
7198        let did = "did:plc:member";
7199        store::grant_access(&state.db, did, Some("member.example"), "test", None)
7200            .await
7201            .unwrap();
7202        let cookie = session_cookie(&state, did, Some("member.example"));
7203        let mut headers = HeaderMap::new();
7204        headers.insert(header::COOKIE, cookie.parse().unwrap());
7205        assert!(
7206            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7207            "a seated session cookie must pass without resolution"
7208        );
7209    }
7210
7211    /// The invite-cookie fast path admits WITHOUT calling the resolver.
7212    #[tokio::test]
7213    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7214        let state = test_state(&[]).await;
7215        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7216        let cookie = cookie.split(';').next().unwrap().to_string();
7217        let mut headers = HeaderMap::new();
7218        headers.insert(header::COOKIE, cookie.parse().unwrap());
7219        assert!(
7220            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7221            "a valid invite cookie must pass without resolution"
7222        );
7223    }
7224
7225    #[tokio::test]
7226    async fn admin_mint_requires_admin_seed_did() {
7227        let state = test_state(&["did:plc:admin"]).await;
7228        // A non-admin (but beta'd) session is forbidden.
7229        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7230            .await
7231            .unwrap();
7232        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7233        // An admin session is allowed.
7234        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7235        let app = router(state);
7236
7237        let forbidden = app
7238            .clone()
7239            .oneshot(
7240                Request::builder()
7241                    .method("POST")
7242                    .uri("/admin/invites?n=2")
7243                    .header(header::COOKIE, rando_cookie)
7244                    .body(Body::empty())
7245                    .unwrap(),
7246            )
7247            .await
7248            .unwrap();
7249        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7250
7251        let ok = app
7252            .oneshot(
7253                Request::builder()
7254                    .method("POST")
7255                    .uri("/admin/invites?n=2")
7256                    .header(header::COOKIE, admin_cookie)
7257                    .body(Body::empty())
7258                    .unwrap(),
7259            )
7260            .await
7261            .unwrap();
7262        assert_eq!(ok.status(), StatusCode::OK);
7263        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7264            .await
7265            .unwrap();
7266        let body = String::from_utf8(bytes.to_vec()).unwrap();
7267        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7268        assert_eq!(minted.len(), 2);
7269        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7270    }
7271
7272    #[tokio::test]
7273    async fn admin_mint_unauthenticated_is_401() {
7274        let state = test_state(&["did:plc:admin"]).await;
7275        let app = router(state);
7276        let resp = app
7277            .oneshot(
7278                Request::builder()
7279                    .method("POST")
7280                    .uri("/admin/invites")
7281                    .body(Body::empty())
7282                    .unwrap(),
7283            )
7284            .await
7285            .unwrap();
7286        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7287    }
7288
7289    /// A state whose `/about` renders the adoption line, seeded with one
7290    /// observation.
7291    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7292        let db = store::init_url("sqlite::memory:").await.unwrap();
7293        store::record_network_stat(
7294            &db,
7295            &store::NetworkStat {
7296                key: store::ADOPTION_STAT_KEY.to_string(),
7297                source: "https://relay1.us-west.bsky.network".to_string(),
7298                value: repos,
7299                truncated,
7300                observed_at: "2026-08-13T04:05:06Z".to_string(),
7301            },
7302        )
7303        .await
7304        .unwrap();
7305        let config = Config {
7306            cookie_secret: "test-cookie-secret-000".to_string(),
7307            show_adoption: true,
7308            ..Config::default()
7309        };
7310        AppState::new(config, db).unwrap()
7311    }
7312
7313    async fn about_body(state: AppState) -> String {
7314        let resp = router(state)
7315            .oneshot(
7316                Request::builder()
7317                    .uri("/about")
7318                    .body(Body::empty())
7319                    .unwrap(),
7320            )
7321            .await
7322            .unwrap();
7323        assert_eq!(resp.status(), StatusCode::OK);
7324        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7325            .await
7326            .unwrap();
7327        String::from_utf8(bytes.to_vec()).unwrap()
7328    }
7329
7330    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
7331    #[tokio::test]
7332    async fn about_omits_adoption_line_by_default() {
7333        let state = test_state(&[]).await;
7334        assert!(!state.config.show_adoption);
7335        let body = about_body(state).await;
7336        assert!(
7337            !body.contains("atproto network"),
7338            "the adoption line must not render by default"
7339        );
7340    }
7341
7342    #[tokio::test]
7343    async fn about_renders_adoption_line_when_enabled() {
7344        // **A distinctive count, and asserted IN ITS SENTENCE.**
7345        //
7346        // This used to seed 4 and assert `body.contains("4")`, which the
7347        // colophon's `width="44"` satisfies whatever the count is — so
7348        // hardcoding the rendered number passed. Both halves are needed: a
7349        // digit that does not occur incidentally, and the assertion tied to the
7350        // phrase it belongs to.
7351        let body = about_body(adoption_state(7_318, false).await).await;
7352        // The count and its phrase are on separate template lines, so compare
7353        // against a whitespace-collapsed copy rather than the raw HTML.
7354        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7355        assert!(
7356            flat.contains("7318 accounts on the atproto network hold"),
7357            "the count did not render in its own sentence: {flat}",
7358        );
7359        assert!(
7360            body.contains("accounts on the atproto network hold"),
7361            "{body}"
7362        );
7363        assert!(
7364            body.contains("2026-08-13"),
7365            "the observation date must render"
7366        );
7367        assert!(
7368            body.contains("lower bound"),
7369            "the non-archival caveat must ride along with the number"
7370        );
7371        assert!(
7372            !body.contains("At least"),
7373            "an untruncated count is exact-ish"
7374        );
7375    }
7376
7377    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
7378    #[tokio::test]
7379    async fn about_adoption_line_is_singular_at_one() {
7380        let body = about_body(adoption_state(1, false).await).await;
7381        assert!(
7382            body.contains("account on the atproto network holds"),
7383            "{body}"
7384        );
7385    }
7386
7387    /// A truncated observation is a floor, and must say so.
7388    #[tokio::test]
7389    async fn about_adoption_line_says_at_least_when_truncated() {
7390        let body = about_body(adoption_state(25_000, true).await).await;
7391        assert!(body.contains("At least"), "{body}");
7392    }
7393
7394    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
7395    #[tokio::test]
7396    async fn about_omits_line_when_enabled_with_no_observation() {
7397        let db = store::init_url("sqlite::memory:").await.unwrap();
7398        let config = Config {
7399            cookie_secret: "test-cookie-secret-000".to_string(),
7400            show_adoption: true,
7401            ..Config::default()
7402        };
7403        let body = about_body(AppState::new(config, db).unwrap()).await;
7404        assert!(!body.contains("atproto network"));
7405    }
7406
7407    // ---- standard.site on the public pages and the subscribe form ----------
7408    //
7409    // `FEATHERREADER_STANDARD_SITE` gates what may be STORED (`add_subscription`
7410    // refuses every `at://` paste with it off), so a page that tells the reader
7411    // to paste a publication URI is advertising a form that will be refused
7412    // unless the flag is on. These pin both halves: with the flag on the pages
7413    // say how; with it off they do not.
7414
7415    /// A state with the standard.site flag chosen, and `did` holding a seat so
7416    /// `/manage` renders for it.
7417    async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7418        let db = store::init_url("sqlite::memory:").await.unwrap();
7419        store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7420        let config = Config {
7421            allowed_dids: vec![did.to_string()],
7422            cookie_secret: "test-cookie-secret-000".to_string(),
7423            beta_cap: 3,
7424            standard_site,
7425            ..Config::default()
7426        };
7427        AppState::new(config, db).unwrap()
7428    }
7429
7430    /// `GET path` as `did`, asserted 200, body as a string.
7431    async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7432        let cookie = session_cookie(&state, did, Some("reader.example"));
7433        let resp = router(state)
7434            .oneshot(
7435                Request::builder()
7436                    .uri(path)
7437                    .header(header::COOKIE, cookie)
7438                    .body(Body::empty())
7439                    .unwrap(),
7440            )
7441            .await
7442            .unwrap();
7443        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7444        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7445            .await
7446            .unwrap();
7447        String::from_utf8(bytes.to_vec()).unwrap()
7448    }
7449
7450    /// `GET path` signed out, asserted 200, body as a string.
7451    async fn public_body(state: AppState, path: &str) -> String {
7452        let resp = router(state)
7453            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7454            .await
7455            .unwrap();
7456        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7457        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7458            .await
7459            .unwrap();
7460        String::from_utf8(bytes.to_vec()).unwrap()
7461    }
7462
7463    /// The `<input … id="feed-url" …>` tag of the subscribe form, whole.
7464    fn feed_url_input(body: &str) -> &str {
7465        let start = body
7466            .find("id=\"feed-url\"")
7467            .and_then(|i| body[..i].rfind("<input"))
7468            .expect("the subscribe form's URL input renders");
7469        let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7470        &body[start..end]
7471    }
7472
7473    /// Flag on: the subscribe form says a publication URI is accepted, and shows
7474    /// both spellings the handler takes (DID and handle).
7475    #[tokio::test]
7476    async fn manage_hints_at_publications_when_the_flag_is_on() {
7477        let did = "did:plc:reader";
7478        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7479        assert!(
7480            body.contains("at://did:plc:…/site.standard.publication/…"),
7481            "the DID form must be shown: {body}"
7482        );
7483        assert!(
7484            body.contains("at://alice.example.com/site.standard.publication/…"),
7485            "the handle form must be shown: {body}"
7486        );
7487    }
7488
7489    /// Flag on: the URL input must not be `type="url"`. A browser validates
7490    /// that type with the WHATWG URL parser, which REJECTS the DID form —
7491    /// `at://did:plc:…/…` fails as an invalid port, the same failure
7492    /// `url::Url::parse` has (see `feed::is_storable_feed_url`) — so the form
7493    /// would refuse to submit the very string the hint asks for.
7494    #[tokio::test]
7495    async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7496        let did = "did:plc:reader";
7497        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7498        let input = feed_url_input(&body);
7499        assert!(
7500            input.contains("type=\"text\""),
7501            "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7502        );
7503        assert!(
7504            input.contains("inputmode=\"url\""),
7505            "the URL keyboard is still wanted: {input}"
7506        );
7507    }
7508
7509    /// Flag on, `type="text"` drops the browser's scheme check, so a pasted
7510    /// `example.com/blog` would reach the handler and come back as "Couldn't
7511    /// find a feed" — wrong, the site likely has one. A `pattern` keeps the
7512    /// browser asking for a scheme while still admitting `at://` (both cases:
7513    /// the handler canonicalises the scheme).
7514    #[tokio::test]
7515    async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7516        let did = "did:plc:reader";
7517        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7518        let input = feed_url_input(&body);
7519        assert!(
7520            input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7521            "the text input must keep a scheme check: {input}"
7522        );
7523    }
7524
7525    /// Flag off: every `at://` paste is refused, so the form must not say
7526    /// publications are accepted — and the input keeps browser URL validation.
7527    #[tokio::test]
7528    async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7529        let did = "did:plc:reader";
7530        let state = standard_site_state(false, did).await;
7531        assert!(!state.config.standard_site);
7532        let page = signed_in_body(state, "/manage", did).await;
7533        // The `<head>` carries the site's link card, whose one-line description
7534        // names standard.site whatever the flag says — as the landing page does
7535        // with the flag off (a stored publication is polled regardless). What
7536        // must not advertise is the page: everything after `</head>`.
7537        let body = &page[page.find("</head>").expect("a <head>")..];
7538        assert!(
7539            !body.contains("site.standard.publication"),
7540            "a refused form must not be advertised: {body}"
7541        );
7542        // The shared footer links the `/standard-site` feature page on every
7543        // page, flag on or off — that page itself says the instance isn't
7544        // accepting new publication subscriptions — so the check is on the
7545        // page above the footer, where the form and its hints are.
7546        let above_footer = body
7547            .split("<footer")
7548            .next()
7549            .expect("split yields at least one piece");
7550        assert!(
7551            above_footer.contains("id=\"feed-url\""),
7552            "the form must be above the footer: {body}"
7553        );
7554        assert!(
7555            !above_footer.contains("standard.site"),
7556            "a refused form must not be advertised: {body}"
7557        );
7558        assert!(
7559            feed_url_input(body).contains("type=\"url\""),
7560            "with the flag off the input is unchanged"
7561        );
7562    }
7563
7564    /// Flag on: the landing page says publications sit beside feeds AND how to
7565    /// subscribe to one.
7566    #[tokio::test]
7567    async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7568        let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7569        assert!(body.contains("standard.site"), "{body}");
7570        assert!(
7571            body.contains("at://did:plc:…/site.standard.publication/…"),
7572            "the landing page must show the DID form: {body}"
7573        );
7574        assert!(
7575            body.contains("at://alice.example.com/site.standard.publication/…"),
7576            "the landing page must show the handle form: {body}"
7577        );
7578    }
7579
7580    /// Flag off: the landing page still says what a publication is (a stored
7581    /// one is polled whatever the flag says), but shows no paste instructions
7582    /// and says new ones are not accepted here.
7583    #[tokio::test]
7584    async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7585        let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7586        assert!(body.contains("standard.site"), "{body}");
7587        assert!(
7588            !body.contains("at://did:plc:…/site.standard.publication/…"),
7589            "no paste instructions with the flag off: {body}"
7590        );
7591        assert!(
7592            !body.contains("at://alice.example.com/site.standard.publication/…"),
7593            "no paste instructions with the flag off: {body}"
7594        );
7595        assert!(
7596            body.contains("isn't accepting new publication subscriptions"),
7597            "the page must say the form is closed here: {body}"
7598        );
7599    }
7600
7601    /// Flag on: /about has a publications section with both spellings.
7602    #[tokio::test]
7603    async fn about_describes_publications_and_how_to_subscribe_when_on() {
7604        let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7605        assert!(body.contains("site.standard.publication"), "{body}");
7606        assert!(body.contains("site.standard.document"), "{body}");
7607        assert!(
7608            body.contains("at://did:plc:…/site.standard.publication/…"),
7609            "{body}"
7610        );
7611        assert!(
7612            body.contains("at://alice.example.com/site.standard.publication/…"),
7613            "{body}"
7614        );
7615    }
7616
7617    /// Flag off: /about keeps the description, drops the paste instructions.
7618    #[tokio::test]
7619    async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7620        let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7621        assert!(body.contains("site.standard.publication"), "{body}");
7622        assert!(
7623            !body.contains("at://did:plc:…/site.standard.publication/…"),
7624            "no paste instructions with the flag off: {body}"
7625        );
7626        assert!(
7627            !body.contains("at://alice.example.com/site.standard.publication/…"),
7628            "no paste instructions with the flag off: {body}"
7629        );
7630        assert!(
7631            body.contains("isn't accepting new publication subscriptions"),
7632            "{body}"
7633        );
7634    }
7635
7636    // ---- the standard.site feature page (`/standard-site`) -----------------
7637    //
7638    // A public page, like `/about`: what a publication is, what is shown from
7639    // it, how to subscribe (flag-conditional, as on the other public pages),
7640    // and the honest limits. It also carries the "latest releases" call-out.
7641
7642    /// Signed out, with the default config, the page renders.
7643    #[tokio::test]
7644    async fn standard_site_page_renders_signed_out() {
7645        let body = public_body(test_state(&[]).await, "/standard-site").await;
7646        assert!(body.contains("site.standard.publication"), "{body}");
7647        assert!(body.contains("site.standard.document"), "{body}");
7648        assert!(
7649            body.contains("<title>standard.site — FeatherReader</title>"),
7650            "{body}"
7651        );
7652    }
7653
7654    /// Flag on: the page says how to subscribe, in both spellings, and that a
7655    /// handle is resolved to its DID.
7656    #[tokio::test]
7657    async fn standard_site_page_tells_how_to_subscribe_when_on() {
7658        let body = public_body(
7659            standard_site_state(true, "did:plc:x").await,
7660            "/standard-site",
7661        )
7662        .await;
7663        assert!(
7664            body.contains("at://did:plc:…/site.standard.publication/…"),
7665            "the DID form must be shown: {body}"
7666        );
7667        assert!(
7668            body.contains("at://alice.example.com/site.standard.publication/…"),
7669            "the handle form must be shown: {body}"
7670        );
7671        assert!(
7672            body.contains("resolved to its DID"),
7673            "the handle resolution must be stated: {body}"
7674        );
7675        assert!(
7676            !body.contains("isn't accepting new publication subscriptions"),
7677            "{body}"
7678        );
7679    }
7680
7681    /// Flag off: `add_subscription` refuses every `at://` paste, so the page
7682    /// must not tell visitors to paste one — it says new publication
7683    /// subscriptions are not accepted here, and that stored ones are still read.
7684    #[tokio::test]
7685    async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
7686        let state = standard_site_state(false, "did:plc:x").await;
7687        assert!(!state.config.standard_site);
7688        let body = public_body(state, "/standard-site").await;
7689        assert!(body.contains("site.standard.publication"), "{body}");
7690        assert!(
7691            !body.contains("at://did:plc:…/site.standard.publication/…"),
7692            "no paste instructions with the flag off: {body}"
7693        );
7694        assert!(
7695            !body.contains("at://alice.example.com/site.standard.publication/…"),
7696            "no paste instructions with the flag off: {body}"
7697        );
7698        assert!(
7699            body.contains("isn't accepting new publication subscriptions"),
7700            "the page must say the form is closed here: {body}"
7701        );
7702        assert!(
7703            body.contains("already follows are still read"),
7704            "stored publications are polled whatever the flag says: {body}"
7705        );
7706    }
7707
7708    /// The releases call-out links each release's GitHub page and the
7709    /// changelog, on the feature page and on the landing page.
7710    #[tokio::test]
7711    async fn releases_callout_links_the_release_pages() {
7712        for path in ["/standard-site", "/"] {
7713            let body = public_body(test_state(&[]).await, path).await;
7714            for tag in ["v0.4.1", "v0.4.0"] {
7715                let href = format!(
7716                    "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
7717                );
7718                assert!(body.contains(&href), "{path} must link {tag}: {body}");
7719            }
7720            assert!(
7721                body.contains(
7722                    "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
7723                ),
7724                "{path} must link the changelog: {body}"
7725            );
7726        }
7727    }
7728
7729    /// The feature page is reachable from the landing page, from `/about`, and
7730    /// from the shared footer (`/privacy` renders nothing but prose and that
7731    /// footer, so it stands in for every page that includes it).
7732    #[tokio::test]
7733    async fn landing_about_and_footer_link_the_standard_site_page() {
7734        for path in ["/", "/about", "/privacy"] {
7735            let body = public_body(test_state(&[]).await, path).await;
7736            assert!(
7737                body.contains("href=\"/standard-site\""),
7738                "{path} must link the feature page: {body}"
7739            );
7740        }
7741    }
7742
7743    /// `RELEASES` is the one place a release is described, so its shape is
7744    /// pinned: newest first, dates as the CHANGELOG headings spell them, and
7745    /// both derived links pointing where the template promises.
7746    #[test]
7747    fn releases_are_newest_first_and_link_the_tag_and_changelog() {
7748        assert!(!RELEASES.is_empty());
7749        let parse = |v: &str| -> Vec<u32> {
7750            v.split('.')
7751                .map(|p| p.parse::<u32>().expect("a numeric version part"))
7752                .collect()
7753        };
7754        for pair in RELEASES.windows(2) {
7755            assert!(
7756                parse(pair[0].version) > parse(pair[1].version),
7757                "{} must come before {}",
7758                pair[0].version,
7759                pair[1].version
7760            );
7761        }
7762        for r in RELEASES {
7763            assert_eq!(parse(r.version).len(), 3, "{}", r.version);
7764            assert!(
7765                chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
7766                "{} is not YYYY-MM-DD",
7767                r.date
7768            );
7769            assert!(!r.summary.trim().is_empty());
7770            assert!(!r.summary.contains('<'), "the summary is plain text");
7771            assert_eq!(
7772                r.url(),
7773                format!(
7774                    "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
7775                    r.version
7776                )
7777            );
7778        }
7779        // The newest entry is this build's own version, so a release cannot
7780        // ship without adding itself to the call-out.
7781        let latest = &RELEASES[0];
7782        assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
7783        assert_eq!(
7784            latest.changelog_url(),
7785            "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#045--2026-10-06"
7786        );
7787    }
7788
7789    /// Public and static like `/about`, so it is cacheable on the same terms.
7790    #[tokio::test]
7791    async fn standard_site_page_is_publicly_cacheable() {
7792        let resp = router(test_state(&[]).await)
7793            .oneshot(
7794                Request::builder()
7795                    .uri("/standard-site")
7796                    .body(Body::empty())
7797                    .unwrap(),
7798            )
7799            .await
7800            .unwrap();
7801        assert_eq!(resp.status(), StatusCode::OK);
7802        assert_eq!(
7803            resp.headers().get(header::CACHE_CONTROL).unwrap(),
7804            "public, max-age=300"
7805        );
7806    }
7807
7808    #[tokio::test]
7809    async fn cache_control_public_on_about_no_store_on_authed() {
7810        let state = test_state(&["did:plc:admin"]).await;
7811        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7812        let app = router(state);
7813
7814        // /about → public, cacheable.
7815        let about = app
7816            .clone()
7817            .oneshot(
7818                Request::builder()
7819                    .uri("/about")
7820                    .body(Body::empty())
7821                    .unwrap(),
7822            )
7823            .await
7824            .unwrap();
7825        assert_eq!(
7826            about.headers().get(header::CACHE_CONTROL).unwrap(),
7827            "public, max-age=300"
7828        );
7829        // The security headers are still intact.
7830        // The VALUE, spelled out here rather than compared to the constant —
7831        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
7832        // used to assert only that the header existed, which a policy of
7833        // `default-src *` satisfies.
7834        assert_eq!(
7835            about.headers()["content-security-policy"],
7836            EXPECTED_CSP,
7837            "the CSP is not the policy the router promises"
7838        );
7839        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7840
7841        // /privacy and /terms are static public pages → public, cacheable.
7842        for path in ["/privacy", "/terms"] {
7843            let resp = app
7844                .clone()
7845                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7846                .await
7847                .unwrap();
7848            assert_eq!(resp.status(), StatusCode::OK);
7849            assert_eq!(
7850                resp.headers().get(header::CACHE_CONTROL).unwrap(),
7851                "public, max-age=300",
7852                "{path} should be publicly cacheable"
7853            );
7854            // Security headers apply to these pages too.
7855            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7856            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7857        }
7858
7859        // The bare /login landing → public, cacheable.
7860        let login = app
7861            .clone()
7862            .oneshot(
7863                Request::builder()
7864                    .uri("/login")
7865                    .body(Body::empty())
7866                    .unwrap(),
7867            )
7868            .await
7869            .unwrap();
7870        assert_eq!(
7871            login.headers().get(header::CACHE_CONTROL).unwrap(),
7872            "public, max-age=300"
7873        );
7874
7875        // An authenticated page → no-store.
7876        let home = app
7877            .oneshot(
7878                Request::builder()
7879                    .uri("/")
7880                    .header(header::COOKIE, admin_cookie)
7881                    .body(Body::empty())
7882                    .unwrap(),
7883            )
7884            .await
7885            .unwrap();
7886        assert_eq!(
7887            home.headers().get(header::CACHE_CONTROL).unwrap(),
7888            "no-store"
7889        );
7890    }
7891
7892    // -- link cards (Open Graph) -----------------------------------------------
7893    //
7894    // Bluesky's card service fetches the HTML server-side, runs no JS, and
7895    // resolves nothing relative. Measured before these tags existed:
7896    // `cardyb.bsky.app/v1/extract?url=https://feather-reader.com/` returned
7897    // `{"title":"FeatherReader — read, quietly","description":"","image":""}`.
7898
7899    /// Everything up to `</head>` — the only part a card fetcher reads.
7900    fn head(body: &str) -> &str {
7901        let end = body.find("</head>").expect("a <head>");
7902        &body[..end]
7903    }
7904
7905    /// The `content` of the first `<meta …>` tag carrying `attr` (e.g.
7906    /// `property="og:title"`), or `None` when no tag carries it.
7907    fn meta(head: &str, attr: &str) -> Option<String> {
7908        let tag_start = head.find(attr)?;
7909        let rest = &head[tag_start..];
7910        let tag_end = rest.find('>')?;
7911        let tag = &rest[..tag_end];
7912        let content = tag.find("content=\"")? + "content=\"".len();
7913        let close = tag[content..].find('"')?;
7914        Some(tag[content..content + close].to_string())
7915    }
7916
7917    /// A state whose public origin is production's. The card URLs must be
7918    /// absolute on THAT origin: a relative `/static/…` is what the card
7919    /// fetcher cannot use.
7920    async fn production_origin_state() -> AppState {
7921        let db = store::init_url("sqlite::memory:").await.unwrap();
7922        store::ensure_seed(&db, &["did:plc:admin".to_string()])
7923            .await
7924            .unwrap();
7925        let config = Config {
7926            allowed_dids: vec!["did:plc:admin".to_string()],
7927            cookie_secret: "test-cookie-secret-000".to_string(),
7928            beta_cap: 3,
7929            public_url: "https://feather-reader.com".to_string(),
7930            ..Config::default()
7931        };
7932        AppState::new(config, db).unwrap()
7933    }
7934
7935    /// The landing page and /about each carry a complete card with absolute
7936    /// https URLs, and the two describe different things.
7937    #[tokio::test]
7938    async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
7939        let landing = public_body(production_origin_state().await, "/").await;
7940        let about = public_body(production_origin_state().await, "/about").await;
7941        let (lh, ah) = (head(&landing), head(&about));
7942
7943        assert_eq!(
7944            meta(lh, "property=\"og:title\"").as_deref(),
7945            Some("FeatherReader — read, quietly"),
7946            "{lh}"
7947        );
7948        assert_eq!(
7949            meta(ah, "property=\"og:title\"").as_deref(),
7950            Some("About — FeatherReader"),
7951            "{ah}"
7952        );
7953        for (h, path) in [(lh, "/"), (ah, "/about")] {
7954            let url = format!("https://feather-reader.com{path}");
7955            assert_eq!(
7956                meta(h, "property=\"og:url\"").as_deref(),
7957                Some(url.as_str())
7958            );
7959            assert!(
7960                h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
7961                "{path} must carry a canonical link: {h}"
7962            );
7963            let image = meta(h, "property=\"og:image\"").unwrap_or_default();
7964            assert!(
7965                image.starts_with("https://feather-reader.com/static/"),
7966                "{path}: og:image must be absolute on the public origin, got {image:?}"
7967            );
7968            assert_eq!(
7969                meta(h, "name=\"twitter:card\"").as_deref(),
7970                Some("summary_large_image")
7971            );
7972            assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
7973            assert_eq!(
7974                meta(h, "property=\"og:site_name\"").as_deref(),
7975                Some("FeatherReader")
7976            );
7977            let description = meta(h, "property=\"og:description\"").unwrap_or_default();
7978            assert!(!description.is_empty(), "{path}: og:description is empty");
7979            assert_eq!(
7980                meta(h, "name=\"description\"").as_deref(),
7981                Some(description.as_str()),
7982                "{path}: the meta description and og:description must agree"
7983            );
7984        }
7985        assert_ne!(
7986            meta(lh, "property=\"og:description\""),
7987            meta(ah, "property=\"og:description\""),
7988            "the landing page and /about must not share a description"
7989        );
7990    }
7991
7992    /// The origin comes from `FEATHERREADER_PUBLIC_URL`, not a constant.
7993    #[tokio::test]
7994    async fn card_urls_follow_the_configured_public_url() {
7995        let db = store::init_url("sqlite::memory:").await.unwrap();
7996        store::ensure_seed(&db, &[]).await.unwrap();
7997        let config = Config {
7998            cookie_secret: "test-cookie-secret-000".to_string(),
7999            public_url: "https://reader.example.org".to_string(),
8000            ..Config::default()
8001        };
8002        let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
8003        let h = head(&body);
8004        assert_eq!(
8005            meta(h, "property=\"og:url\"").as_deref(),
8006            Some("https://reader.example.org/privacy")
8007        );
8008        assert_eq!(
8009            meta(h, "property=\"og:image\"").as_deref(),
8010            Some("https://reader.example.org/static/social-card.png")
8011        );
8012    }
8013
8014    /// Every signed-out page describes itself: no two share a description,
8015    /// and each `og:url` is its own path.
8016    #[tokio::test]
8017    async fn public_pages_each_carry_their_own_description() {
8018        let paths = [
8019            "/",
8020            "/about",
8021            "/privacy",
8022            "/terms",
8023            "/stats",
8024            "/standard-site",
8025            "/login",
8026            "/beta/redeem",
8027        ];
8028        let mut seen = std::collections::HashSet::new();
8029        for path in paths {
8030            let body = public_body(production_origin_state().await, path).await;
8031            let h = head(&body);
8032            let description = meta(h, "name=\"description\"").unwrap_or_default();
8033            assert!(!description.is_empty(), "{path} has no description: {h}");
8034            assert!(
8035                seen.insert(description.clone()),
8036                "{path} repeats another page's description: {description:?}"
8037            );
8038            assert_eq!(
8039                meta(h, "property=\"og:url\"").as_deref(),
8040                Some(format!("https://feather-reader.com{path}").as_str()),
8041                "{path}"
8042            );
8043            assert!(
8044                !h.contains("name=\"robots\""),
8045                "{path} is public and must not be noindex: {h}"
8046            );
8047        }
8048    }
8049
8050    /// The share image is served from `/static` as a PNG of the dimensions the
8051    /// tags promise, well under the 1 MB card fetchers tolerate, and cacheable.
8052    #[tokio::test]
8053    async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8054        let landing = public_body(production_origin_state().await, "/").await;
8055        let h = head(&landing);
8056        let image = meta(h, "property=\"og:image\"").unwrap();
8057        let path = image.strip_prefix("https://feather-reader.com").unwrap();
8058        let width: u32 = meta(h, "property=\"og:image:width\"")
8059            .unwrap()
8060            .parse()
8061            .unwrap();
8062        let height: u32 = meta(h, "property=\"og:image:height\"")
8063            .unwrap()
8064            .parse()
8065            .unwrap();
8066        assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8067        assert_eq!(
8068            meta(h, "property=\"og:image:type\"").as_deref(),
8069            Some("image/png")
8070        );
8071        assert!(
8072            !meta(h, "property=\"og:image:alt\"")
8073                .unwrap_or_default()
8074                .is_empty(),
8075            "the image needs alt text"
8076        );
8077
8078        let resp = router(production_origin_state().await)
8079            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8080            .await
8081            .unwrap();
8082        assert_eq!(resp.status(), StatusCode::OK, "{path}");
8083        assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8084        assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8085        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8086            .await
8087            .expect("the image is under 1 MB");
8088        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8089        // IHDR: width and height, big-endian, at offsets 16 and 20.
8090        let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8091        assert_eq!(
8092            (be(16), be(20)),
8093            (width, height),
8094            "the PNG's own dimensions must match the tags"
8095        );
8096    }
8097
8098    /// A page that renders a session's private view carries the site's generic
8099    /// card — nothing from the view reaches `<head>` — and is `noindex`.
8100    #[tokio::test]
8101    async fn private_pages_keep_user_data_out_of_the_card() {
8102        for path in ["/", "/manage"] {
8103            let state = production_origin_state().await;
8104            let body = signed_in_body(state, path, "did:plc:admin").await;
8105            let h = head(&body);
8106            assert!(
8107                h.contains("<meta name=\"robots\" content=\"noindex\""),
8108                "{path}: a private view must be noindex: {h}"
8109            );
8110            assert_eq!(
8111                meta(h, "property=\"og:title\"").as_deref(),
8112                Some("FeatherReader — read, quietly"),
8113                "{path}: the card of a private view is the site's generic one"
8114            );
8115            assert_eq!(
8116                meta(h, "property=\"og:url\"").as_deref(),
8117                Some("https://feather-reader.com/"),
8118                "{path}: og:url of a private view is the front door, not the private path"
8119            );
8120            for private in ["reader.example", "did:plc:admin"] {
8121                assert!(
8122                    !h.contains(private),
8123                    "{path}: {private:?} must not reach <head>: {h}"
8124                );
8125            }
8126        }
8127    }
8128
8129    #[tokio::test]
8130    async fn beta_redeem_page_renders() {
8131        let state = test_state(&[]).await;
8132        let app = router(state);
8133        let resp = app
8134            .oneshot(
8135                Request::builder()
8136                    .uri("/beta/redeem")
8137                    .body(Body::empty())
8138                    .unwrap(),
8139            )
8140            .await
8141            .unwrap();
8142        assert_eq!(resp.status(), StatusCode::OK);
8143        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8144            .await
8145            .unwrap();
8146        let html = String::from_utf8(bytes.to_vec()).unwrap();
8147        assert!(html.contains("Invite code"));
8148        assert!(html.contains("/beta/redeem"));
8149    }
8150
8151    #[tokio::test]
8152    async fn rate_limit_returns_429_after_burst() {
8153        // Configure a trusted proxy header so the limiter keys on the forwarded
8154        // IP (the oneshot harness sets no ConnectInfo socket peer).
8155        let db = store::init_url("sqlite::memory:").await.unwrap();
8156        store::ensure_seed(&db, &[]).await.unwrap();
8157        let config = Config {
8158            cookie_secret: "test-cookie-secret-000".to_string(),
8159            beta_cap: 3,
8160            trusted_ip_header: Some("cf-connecting-ip".to_string()),
8161            ..Config::default()
8162        };
8163        let state = AppState::new(config, db).unwrap();
8164        let app = router(state);
8165        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
8166        // handler itself returns 200 (re-render) on a bad code; the limiter is
8167        // what eventually yields 429.
8168        let mut saw_429 = false;
8169        for _ in 0..(RATE_BURST as usize + 5) {
8170            let resp = app
8171                .clone()
8172                .oneshot(
8173                    Request::builder()
8174                        .method("POST")
8175                        .uri("/beta/redeem")
8176                        .header("content-type", "application/x-www-form-urlencoded")
8177                        .header("cf-connecting-ip", "203.0.113.200")
8178                        .body(Body::from("code=FEATHER-NOPENOPE"))
8179                        .unwrap(),
8180                )
8181                .await
8182                .unwrap();
8183            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8184                saw_429 = true;
8185                break;
8186            }
8187        }
8188        assert!(saw_429, "expected a 429 after exhausting the burst");
8189    }
8190
8191    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
8192    /// the middleware's comment cites this test as proof of.
8193    ///
8194    /// The previous version rotated the forged header and asserted that no
8195    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
8196    /// burst, so that assertion held whether the header was trusted or
8197    /// ignored — it passed in the vulnerable configuration too. And with no
8198    /// socket peer the limiter fails open, so nothing could have been keyed on
8199    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
8200    /// a DIFFERENT forged header, and the last must be 429: they all landed in
8201    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
8202    /// per request and never trips — which is exactly what the mutation does.
8203    #[tokio::test]
8204    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8205        let state = test_state(&[]).await;
8206        assert!(
8207            state.config.trusted_ip_header.is_none(),
8208            "no proxy header is trusted here"
8209        );
8210        let app = router(state);
8211        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8212        let mut saw_429 = false;
8213        for i in 0..(RATE_BURST as usize + 5) {
8214            let forged = format!("10.9.8.{}", i % 250);
8215            let resp = app
8216                .clone()
8217                .oneshot(
8218                    Request::builder()
8219                        .method("POST")
8220                        .uri("/beta/redeem")
8221                        .header("content-type", "application/x-www-form-urlencoded")
8222                        .header("x-forwarded-for", forged)
8223                        .extension(axum::extract::ConnectInfo(peer))
8224                        .body(Body::from("code=FEATHER-NOPENOPE"))
8225                        .unwrap(),
8226                )
8227                .await
8228                .unwrap();
8229            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8230                saw_429 = true;
8231                break;
8232            }
8233        }
8234        assert!(
8235            saw_429,
8236            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8237        );
8238    }
8239
8240    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
8241
8242    /// **A private feed is refused BEFORE it is fetched.** The add path's
8243    /// privacy gate had no test at all — `private_feeds_are_classified_private_
8244    /// across_providers` says "the add + OPML paths both gate on this
8245    /// classifier" and nothing checked either. The gate exists so a
8246    /// token-bearing URL never reaches the network; the assertion that
8247    /// matters is the server's hit count: zero.
8248    #[tokio::test]
8249    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8250        let did = "did:plc:privateadder";
8251        let state = test_state_with_caps(did, 0, 0).await;
8252        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8253        let port: u16 = base
8254            .trim_end_matches('/')
8255            .rsplit(':')
8256            .next()
8257            .unwrap()
8258            .parse()
8259            .unwrap();
8260        crate::net::test_host_override(
8261            "private-add.test",
8262            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8263        );
8264        let cookie = session_cookie(&state, did, None);
8265        let resp = router(state.clone())
8266            .oneshot(
8267                Request::builder()
8268                    .method("POST")
8269                    .uri("/subscriptions")
8270                    .header(header::COOKIE, cookie)
8271                    .header("content-type", "application/x-www-form-urlencoded")
8272                    .body(Body::from(format!(
8273                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8274                    )))
8275                    .unwrap(),
8276            )
8277            .await
8278            .unwrap();
8279        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8280        let loc = resp
8281            .headers()
8282            .get(header::LOCATION)
8283            .unwrap()
8284            .to_str()
8285            .unwrap();
8286        assert!(loc.contains("Private"), "not refused as private: {loc}");
8287        assert_eq!(
8288            hits.load(std::sync::atomic::Ordering::SeqCst),
8289            0,
8290            "the private feed was FETCHED before being refused"
8291        );
8292        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8293    }
8294
8295    /// **OPML import skips a private feed without storing or publishing it.**
8296    /// The import path does not fetch, so "never fetched" is not the signal
8297    /// here; "never stored, never written to the PDS" is. The batch write's
8298    /// bytes are captured and must not carry the URL.
8299    #[tokio::test]
8300    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8301        let did = "did:plc:renamer4";
8302        let (sidecar, bodies) = spawn_logging_sidecar().await;
8303        let state = test_state_with_sidecar(&[did], &sidecar).await;
8304        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8305        let opml = format!(
8306            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8307             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8308             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8309             </body></opml>"
8310        );
8311        let (ct, body) = opml_multipart(opml.as_bytes());
8312        let cookie = session_cookie(&state, did, None);
8313        let resp = router(state.clone())
8314            .oneshot(
8315                Request::builder()
8316                    .method("POST")
8317                    .uri("/opml")
8318                    .header(header::COOKIE, cookie)
8319                    .header("content-type", ct)
8320                    .body(Body::from(body))
8321                    .unwrap(),
8322            )
8323            .await
8324            .unwrap();
8325        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8326        let loc = resp
8327            .headers()
8328            .get(header::LOCATION)
8329            .unwrap()
8330            .to_str()
8331            .unwrap();
8332        assert!(
8333            loc.contains("skipped%20as%20private"),
8334            "not reported as skipped: {loc}"
8335        );
8336        assert!(store::get_feed_by_url(&state.db, tokened)
8337            .await
8338            .unwrap()
8339            .is_none());
8340        let sent = bodies.lock().unwrap().join("\n");
8341        assert!(
8342            sent.contains("public.example"),
8343            "the public feed was not written: {sent}"
8344        );
8345        assert!(
8346            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8347            "the secret was PUBLISHED to the PDS: {sent}"
8348        );
8349    }
8350
8351    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
8352    /// tested; the GET form starts the same handshake and had no test, so
8353    /// deleting its gate left the suite green.
8354    #[tokio::test]
8355    async fn get_login_without_a_seat_is_refused() {
8356        let state = test_state(&[]).await;
8357        let resp = router(state)
8358            .oneshot(
8359                Request::builder()
8360                    .method("GET")
8361                    .uri("/login?handle=alice.bsky.social")
8362                    .body(Body::empty())
8363                    .unwrap(),
8364            )
8365            .await
8366            .unwrap();
8367        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8368        assert_eq!(
8369            resp.headers().get(header::LOCATION).unwrap(),
8370            "/beta/redeem"
8371        );
8372    }
8373
8374    /// A sidecar fake that answers every request `ok` and records the PATH of
8375    /// each in arrival order, plus every body — for asserting what was sent,
8376    /// and in what order.
8377    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8378        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8379        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8380        let addr = listener.local_addr().unwrap();
8381        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8382        let sink = log.clone();
8383        tokio::spawn(async move {
8384            loop {
8385                let Ok((mut sock, _)) = listener.accept().await else {
8386                    break;
8387                };
8388                let mut raw: Vec<u8> = Vec::new();
8389                let mut chunk = [0u8; 4096];
8390                let text = loop {
8391                    let Ok(n) = sock.read(&mut chunk).await else {
8392                        break String::new();
8393                    };
8394                    if n == 0 {
8395                        break String::from_utf8_lossy(&raw).to_string();
8396                    }
8397                    raw.extend_from_slice(&chunk[..n]);
8398                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8399                        continue;
8400                    };
8401                    let (head, body) = raw.split_at(split + 4);
8402                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8403                        let (k, v) = l.split_once(':')?;
8404                        k.eq_ignore_ascii_case("content-length")
8405                            .then(|| v.trim().parse::<usize>().ok())?
8406                    });
8407                    if want.is_none_or(|w| body.len() >= w) {
8408                        break String::from_utf8_lossy(&raw).to_string();
8409                    }
8410                };
8411                let path = text
8412                    .lines()
8413                    .next()
8414                    .and_then(|l| l.split_whitespace().nth(1))
8415                    .unwrap_or("")
8416                    .to_string();
8417                let body_text = text
8418                    .split_once("\r\n\r\n")
8419                    .map(|(_, b)| b)
8420                    .unwrap_or("")
8421                    .to_string();
8422                sink.lock().unwrap().push(format!("{path} {body_text}"));
8423                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();
8424                let resp = format!(
8425                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8426                    body.len(),
8427                    body
8428                );
8429                let _ = sock.write_all(resp.as_bytes()).await;
8430                let _ = sock.flush().await;
8431            }
8432        });
8433        (format!("http://{addr}"), log)
8434    }
8435
8436    /// **The sign-out flush settles a split flush's landed prefix too.** It is
8437    /// `readstate::flush_did` under a timeout, so it shares the fix — pinned
8438    /// here so a sign-out path that grew its own flush would not silently lose
8439    /// it. Call 1 of 2 lands, call 2's connection drops: the landed cursors are
8440    /// created and clean, the rest stay dirty to park until the next sign-in.
8441    #[tokio::test]
8442    async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8443        use crate::readstate::tests as rs;
8444        for backend in [
8445            crate::metrics::Backend::Sidecar,
8446            crate::metrics::Backend::Rust,
8447        ] {
8448            let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8449            let state = rs::state_on(backend, &fake).await;
8450            for i in 0..250 {
8451                rs::mark_read(&state, i, "1").await;
8452            }
8453            fake.lock().unwrap().drop_call = Some(2);
8454
8455            flush_before_revoke(&state, rs::DID).await;
8456
8457            let order = rs::send_order(250);
8458            let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8459            for &i in landed {
8460                let c = rs::cursor(&state, i).await;
8461                assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8462            }
8463            for &i in rest {
8464                let c = rs::cursor(&state, i).await;
8465                assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8466            }
8467            assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8468        }
8469    }
8470
8471    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
8472    /// route.** The previous version of this test called
8473    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
8474    /// flush attempt; its doc claimed deleting the call from the handler
8475    /// "drops that to zero", which was false — the handler was never run.
8476    /// Deleting the call left the suite green: #117 regressing in full, with
8477    /// the test named after it still passing. Now `POST /logout` is driven and
8478    /// the sidecar's log must show a repo write BEFORE the revoke.
8479    #[tokio::test]
8480    async fn signing_out_flushes_before_it_revokes_through_the_route() {
8481        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8482        let (sidecar, log) = spawn_logging_sidecar().await;
8483        let state = test_state_with_sidecar(&[did], &sidecar).await;
8484        crate::store::upsert_cursor(
8485            &state.db,
8486            &crate::store::ReadCursor {
8487                did: did.to_string(),
8488                feed_url: "https://example.com/feed.xml".into(),
8489                read_through: None,
8490                read_ids: "[\"1\"]".into(),
8491                unread_ids: "[]".into(),
8492                dirty: true,
8493                pds_created: false,
8494                updated_at: "2026-09-13T21:22:40Z".into(),
8495            },
8496        )
8497        .await
8498        .unwrap();
8499        let cookie = session_cookie(&state, did, None);
8500        let resp = router(state.clone())
8501            .oneshot(
8502                Request::builder()
8503                    .method("POST")
8504                    .uri("/logout")
8505                    .header(header::COOKIE, cookie)
8506                    .body(Body::empty())
8507                    .unwrap(),
8508            )
8509            .await
8510            .unwrap();
8511        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8512
8513        let entries = log.lock().unwrap().clone();
8514        let flush = entries
8515            .iter()
8516            .position(|e| e.starts_with("/internal/repo "));
8517        let revoke = entries
8518            .iter()
8519            .position(|e| e.starts_with("/internal/revoke "));
8520        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8521        assert!(
8522            flush.is_some(),
8523            "sign-out did not attempt a flush before revoking: {entries:?}"
8524        );
8525        assert!(
8526            flush < revoke,
8527            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8528        );
8529    }
8530
8531    /// The policy, as a literal: the backstop the router calls "neutralises any
8532    /// XSS that slips past sanitization". `script-src 'self'` and no
8533    /// `'unsafe-inline'` on it are the two clauses that make it one.
8534    const EXPECTED_CSP: &str = "default-src 'self'; \
8535     script-src 'self'; \
8536     style-src 'self' 'unsafe-inline'; \
8537     img-src 'self' https: data:; \
8538     font-src 'self'; \
8539     connect-src 'self'; \
8540     form-action 'self'; \
8541     base-uri 'self'; \
8542     frame-ancestors 'none'; \
8543     object-src 'none'";
8544
8545    /// Build a `multipart/form-data` body carrying a single `file` field whose
8546    /// contents are `payload`, returning `(content_type, body_bytes)`.
8547    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
8548        let boundary = "----featherreadertestboundary";
8549        let mut body = Vec::new();
8550        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
8551        body.extend_from_slice(
8552            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
8553        );
8554        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
8555        body.extend_from_slice(payload);
8556        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
8557        (format!("multipart/form-data; boundary={boundary}"), body)
8558    }
8559
8560    #[tokio::test]
8561    async fn opml_import_oversize_upload_returns_413() {
8562        let state = test_state(&["did:plc:admin"]).await;
8563        let cookie = session_cookie(&state, "did:plc:admin", None);
8564        let app = router(state);
8565
8566        // A payload comfortably above the route cap.
8567        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
8568        let (content_type, body) = opml_multipart(&payload);
8569
8570        let resp = app
8571            .oneshot(
8572                Request::builder()
8573                    .method("POST")
8574                    .uri("/opml")
8575                    .header("content-type", content_type)
8576                    .header(header::COOKIE, cookie)
8577                    .body(Body::from(body))
8578                    .unwrap(),
8579            )
8580            .await
8581            .unwrap();
8582        assert_eq!(
8583            resp.status(),
8584            StatusCode::PAYLOAD_TOO_LARGE,
8585            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
8586        );
8587    }
8588
8589    /// **The route's own cap is what refuses this, not the framework's.**
8590    ///
8591    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
8592    /// the route's layer was a no-op — deleting it left every test green, and
8593    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
8594    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
8595    /// sits BETWEEN the two: over ours, under the framework's. Only the
8596    /// route's layer can refuse it — remove the layer and this payload is
8597    /// accepted, which is also what demonstrates the framework's default is
8598    /// the larger of the two.
8599    #[tokio::test]
8600    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
8601        let state = test_state(&["did:plc:admin"]).await;
8602        let cookie = session_cookie(&state, "did:plc:admin", None);
8603        let app = router(state);
8604
8605        // Between the two ceilings: the framework would accept this.
8606        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
8607        let (content_type, body) = opml_multipart(&payload);
8608
8609        let resp = app
8610            .oneshot(
8611                Request::builder()
8612                    .method("POST")
8613                    .uri("/opml")
8614                    .header("content-type", content_type)
8615                    .header(header::COOKIE, cookie)
8616                    .body(Body::from(body))
8617                    .unwrap(),
8618            )
8619            .await
8620            .unwrap();
8621        assert_eq!(
8622            resp.status(),
8623            StatusCode::PAYLOAD_TOO_LARGE,
8624            "a payload over the route's cap but under the framework's was accepted — \
8625             the route's own DefaultBodyLimit layer is not doing anything"
8626        );
8627    }
8628
8629    #[tokio::test]
8630    async fn opml_import_under_limit_upload_is_accepted() {
8631        let state = test_state(&["did:plc:admin"]).await;
8632        let cookie = session_cookie(&state, "did:plc:admin", None);
8633        let db = state.db.clone();
8634        let app = router(state);
8635
8636        // A small, valid OPML well under the cap: must be accepted (the handler
8637        // redirects to `/` or a flash), i.e. never 413.
8638        let opml = br#"<?xml version="1.0"?>
8639<opml version="2.0"><body>
8640  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
8641</body></opml>"#;
8642        let (content_type, body) = opml_multipart(opml);
8643
8644        let resp = app
8645            .oneshot(
8646                Request::builder()
8647                    .method("POST")
8648                    .uri("/opml")
8649                    .header("content-type", content_type)
8650                    .header(header::COOKIE, cookie)
8651                    .body(Body::from(body))
8652                    .unwrap(),
8653            )
8654            .await
8655            .unwrap();
8656        // **Assert it was ACCEPTED, not merely that it was not a 413.**
8657        //
8658        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
8659        // 500 satisfies — so making `import_opml` fail unconditionally left this
8660        // green. Three other OPML tests caught that mutation; the one whose name
8661        // promises to cover the under-cap case did not.
8662        assert_eq!(
8663            resp.status(),
8664            StatusCode::SEE_OTHER,
8665            "an under-cap OPML upload was not accepted (status {})",
8666            resp.status(),
8667        );
8668        // **303 alone is not acceptance.** `import_opml` redirects on several
8669        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
8670        // by a cap — so an import that stored nothing satisfied the status check.
8671        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
8672            .bind("https://example.com/feed.xml")
8673            .fetch_one(&db)
8674            .await
8675            .unwrap();
8676        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
8677        let location = resp
8678            .headers()
8679            .get(header::LOCATION)
8680            .and_then(|v| v.to_str().ok())
8681            .unwrap_or_default()
8682            .to_string();
8683        assert!(
8684            !location.starts_with("/login"),
8685            "the import bounced to login instead of being accepted: {location}",
8686        );
8687    }
8688
8689    #[tokio::test]
8690    async fn opml_import_logged_out_redirects_to_login() {
8691        // Logged-out callers are redirected before the body is consumed; assert
8692        // the auth short-circuit rather than a body-cap rejection.
8693        let state = test_state(&["did:plc:admin"]).await;
8694        let app = router(state);
8695
8696        let opml = b"<opml version=\"2.0\"><body></body></opml>";
8697        let (content_type, body) = opml_multipart(opml);
8698
8699        let resp = app
8700            .oneshot(
8701                Request::builder()
8702                    .method("POST")
8703                    .uri("/opml")
8704                    .header("content-type", content_type)
8705                    .body(Body::from(body))
8706                    .unwrap(),
8707            )
8708            .await
8709            .unwrap();
8710        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8711        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
8712    }
8713
8714    // -- delete-my-data (POST /account/delete) --------------------------------
8715
8716    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
8717    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
8718    /// channel) the DID it was asked to revoke. Enough to prove the delete
8719    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
8720    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
8721        use tokio::io::{AsyncReadExt, AsyncWriteExt};
8722        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8723        let addr = listener.local_addr().unwrap();
8724        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
8725        tokio::spawn(async move {
8726            let (mut sock, _) = listener.accept().await.unwrap();
8727            let mut buf = vec![0u8; 4096];
8728            let n = sock.read(&mut buf).await.unwrap();
8729            let req = String::from_utf8_lossy(&buf[..n]).to_string();
8730            // Pull the DID out of the JSON body (last line of the request).
8731            let did = req
8732                .split("\r\n\r\n")
8733                .nth(1)
8734                .and_then(|body| {
8735                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
8736                    v.get("did")?.as_str().map(str::to_string)
8737                })
8738                .unwrap_or_default();
8739            let is_revoke = req.starts_with("POST /internal/revoke");
8740            let body = serde_json::json!({
8741                "ok": true, "did": did, "revoked": true, "hadSession": true
8742            })
8743            .to_string();
8744            let resp = format!(
8745                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8746                body.len(),
8747                body
8748            );
8749            sock.write_all(resp.as_bytes()).await.unwrap();
8750            sock.flush().await.unwrap();
8751            let _ = tx.send(if is_revoke { did } else { String::new() });
8752        });
8753        (format!("http://{addr}"), rx)
8754    }
8755
8756    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
8757    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
8758        let defaults = Config::default();
8759        test_state_with_sidecar_and(
8760            allowed,
8761            sidecar_url,
8762            defaults.standard_site,
8763            defaults.max_feeds_global,
8764        )
8765        .await
8766    }
8767
8768    /// [`test_state_with_sidecar`] with the standard.site flag and the global
8769    /// feeds ceiling chosen — the two settings the at:// paths branch on.
8770    async fn test_state_with_sidecar_and(
8771        allowed: &[&str],
8772        sidecar_url: &str,
8773        standard_site: bool,
8774        max_feeds_global: i64,
8775    ) -> AppState {
8776        let db = store::init_url("sqlite::memory:").await.unwrap();
8777        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
8778        store::ensure_seed(&db, &dids).await.unwrap();
8779        let mut config = Config {
8780            allowed_dids: dids,
8781            cookie_secret: "test-cookie-secret-000".to_string(),
8782            beta_cap: 3,
8783            standard_site,
8784            max_feeds_global,
8785            ..Config::default()
8786        };
8787        config.sidecar.public_url = sidecar_url.to_string();
8788        config.sidecar.internal_url = sidecar_url.to_string();
8789        AppState::new(config, db).unwrap()
8790    }
8791
8792    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
8793    /// the sidecar revoke for that DID, and clears the session cookie.
8794    #[tokio::test]
8795    async fn account_delete_purges_rows_and_triggers_revoke() {
8796        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8797        let did = "did:plc:leaver";
8798        let state = test_state_with_sidecar(&[], &sidecar_url).await;
8799
8800        // Seed the DID with local rows across the per-DID tables.
8801        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8802            .await
8803            .unwrap();
8804        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8805        store::mint_code(&state.db, did, 3600).await.unwrap();
8806        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8807
8808        let cookie = session_cookie(&state, did, Some("leaver.example"));
8809        let app = router(state.clone());
8810
8811        let resp = app
8812            .oneshot(
8813                Request::builder()
8814                    .method("POST")
8815                    .uri("/account/delete")
8816                    .header(header::COOKIE, cookie)
8817                    .header("content-type", "application/x-www-form-urlencoded")
8818                    .body(Body::from("confirm=DELETE"))
8819                    .unwrap(),
8820            )
8821            .await
8822            .unwrap();
8823
8824        // Signed out: redirect to /login with the cookie cleared.
8825        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8826        assert!(resp
8827            .headers()
8828            .get(header::LOCATION)
8829            .unwrap()
8830            .to_str()
8831            .unwrap()
8832            .starts_with("/login"));
8833        let set_cookie = resp
8834            .headers()
8835            .get(header::SET_COOKIE)
8836            .unwrap()
8837            .to_str()
8838            .unwrap();
8839        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8840
8841        // The sidecar revoke was called for exactly this DID.
8842        //
8843        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
8844        // that simply never called the sidecar — hung this test forever instead
8845        // of failing it: a wedged CI job rather than a red one, which is the
8846        // worse of the two signals because nobody reads it as a defect.
8847        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8848            .await
8849            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8850            .unwrap();
8851        assert_eq!(
8852            revoked_did, did,
8853            "sidecar revoke must fire for the caller DID"
8854        );
8855
8856        // Local rows are gone.
8857        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8858        let codes: i64 =
8859            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8860                .bind(did)
8861                .fetch_one(&state.db)
8862                .await
8863                .unwrap();
8864        assert_eq!(codes, 0);
8865    }
8866
8867    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
8868    /// nothing and bounces back to /manage.
8869    #[tokio::test]
8870    async fn account_delete_without_confirm_is_a_noop() {
8871        let did = "did:plc:staying";
8872        let state = test_state(&[]).await;
8873        store::grant_access(&state.db, did, None, "test", None)
8874            .await
8875            .unwrap();
8876        let cookie = session_cookie(&state, did, None);
8877        let app = router(state.clone());
8878
8879        let resp = app
8880            .oneshot(
8881                Request::builder()
8882                    .method("POST")
8883                    .uri("/account/delete")
8884                    .header(header::COOKIE, cookie)
8885                    .header("content-type", "application/x-www-form-urlencoded")
8886                    .body(Body::from("confirm=nope"))
8887                    .unwrap(),
8888            )
8889            .await
8890            .unwrap();
8891
8892        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8893        assert!(resp
8894            .headers()
8895            .get(header::LOCATION)
8896            .unwrap()
8897            .to_str()
8898            .unwrap()
8899            .starts_with("/manage"));
8900        // Nothing deleted.
8901        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8902    }
8903
8904    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
8905    /// this harness — the default sidecar URL is not served), a DID must STILL
8906    /// be unable to read or mutate an entry in a feed it does not subscribe to.
8907    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
8908    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
8909    /// every cached feed.
8910    #[tokio::test]
8911    async fn pds_outage_does_not_widen_cross_did_access() {
8912        let did_a = "did:plc:aaaa";
8913        let state = test_state(&[]).await;
8914        store::grant_access(&state.db, did_a, None, "test", None)
8915            .await
8916            .unwrap();
8917
8918        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
8919        // lives in feed_b — the one A must never touch during the outage.
8920        let feed_a = store::upsert_feed(
8921            &state.db,
8922            &store::NewFeed {
8923                url: "https://a.example/feed.xml".to_string(),
8924                title: Some("A".to_string()),
8925                ..Default::default()
8926            },
8927        )
8928        .await
8929        .unwrap();
8930        let feed_b = store::upsert_feed(
8931            &state.db,
8932            &store::NewFeed {
8933                url: "https://b.example/feed.xml".to_string(),
8934                title: Some("B".to_string()),
8935                ..Default::default()
8936            },
8937        )
8938        .await
8939        .unwrap();
8940        store::insert_entries(
8941            &state.db,
8942            feed_b,
8943            &[store::NewEntry {
8944                guid: "b-1".to_string(),
8945                url: Some("https://b.example/1".to_string()),
8946                title: Some("B one".to_string()),
8947                published: Some("2026-07-11T00:00:00Z".to_string()),
8948                content_html: Some("<p>secret B body</p>".to_string()),
8949                ..Default::default()
8950            }],
8951            0,
8952        )
8953        .await
8954        .unwrap();
8955        // A subscribes ONLY to feed_a.
8956        store::replace_sub_refs(&state.db, did_a, &[feed_a])
8957            .await
8958            .unwrap();
8959        // Read B's entry id via a transient sub_ref, then drop it so only the
8960        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
8961        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8962            .await
8963            .unwrap();
8964        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8965            .await
8966            .unwrap()[0]
8967            .id;
8968        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8969            .await
8970            .unwrap();
8971
8972        let cookie = session_cookie(&state, did_a, None);
8973        let app = router(state.clone());
8974
8975        // GET /entries/{b} as A → 404 even during the outage.
8976        let get_b = app
8977            .clone()
8978            .oneshot(
8979                Request::builder()
8980                    .method("GET")
8981                    .uri(format!("/entries/{b_entry_id}"))
8982                    .header(header::COOKIE, cookie.clone())
8983                    .body(Body::empty())
8984                    .unwrap(),
8985            )
8986            .await
8987            .unwrap();
8988        assert_eq!(
8989            get_b.status(),
8990            StatusCode::NOT_FOUND,
8991            "A must not read B's entry during a PDS outage"
8992        );
8993
8994        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
8995        let read_b = app
8996            .oneshot(
8997                Request::builder()
8998                    .method("POST")
8999                    .uri(format!("/entries/{b_entry_id}/read"))
9000                    .header(header::COOKIE, cookie)
9001                    .header("content-type", "application/x-www-form-urlencoded")
9002                    .body(Body::from("read=true"))
9003                    .unwrap(),
9004            )
9005            .await
9006            .unwrap();
9007        assert_eq!(
9008            read_b.status(),
9009            StatusCode::NOT_FOUND,
9010            "A must not mark B's entry read during a PDS outage"
9011        );
9012
9013        // The fallback must NOT have widened A's sub_ref to feed_b.
9014        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
9015            .bind(did_a)
9016            .fetch_all(&state.db)
9017            .await
9018            .unwrap();
9019        assert_eq!(
9020            a_feed_ids,
9021            vec![feed_a],
9022            "outage fallback must not add feeds A never subscribed to"
9023        );
9024        // And B's entry has zero read-state (A's attempt did not mutate).
9025        let es_count: i64 =
9026            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9027                .bind(did_a)
9028                .bind(b_entry_id)
9029                .fetch_one(&state.db)
9030                .await
9031                .unwrap();
9032        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9033    }
9034
9035    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
9036    /// nothing. The other arm is counted separately.**
9037    ///
9038    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
9039    /// error would make the metric noisy in exactly the case that is fine.
9040    ///
9041    /// But `revoke_everywhere` has TWO arms, and a review found that counting
9042    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
9043    /// revocation failed. For anyone who logged in before the cutover the sidecar
9044    /// store is the only one holding tokens, so the rust arm correctly says
9045    /// NoSession and the metric said nothing was wrong. Both arms are now
9046    /// recorded, distinguished by the backend column — so this test pins the
9047    /// BACKEND as well as the outcome.
9048    #[tokio::test]
9049    async fn a_logout_with_no_session_counts_as_success() {
9050        let did = "did:plc:aaaa";
9051        let state = test_state(&[]).await;
9052        assert!(
9053            state.oauth.is_some(),
9054            "meaningless without an oauth runtime; the revoke arm would be skipped",
9055        );
9056
9057        revoke_everywhere(&state, did).await;
9058        let rows = state.metrics.snapshot();
9059        let find = |b: crate::metrics::Backend| {
9060            rows.iter()
9061                .find(|r| r.op == "oauth_revoke" && r.backend == b)
9062                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9063        };
9064
9065        // Rust arm: nothing stored for this DID, so NoSession -> ok.
9066        let rust = find(crate::metrics::Backend::Rust);
9067        assert_eq!(
9068            rust.stats.err_count, 0,
9069            "NoSession was counted as a failure; logout is idempotent",
9070        );
9071        assert_eq!(rust.stats.ok_count, 1);
9072
9073        // Sidecar arm: unreachable in a test, so it must be recorded as an
9074        // ERROR under its own backend — not silently dropped, and not folded
9075        // into the rust row.
9076        let sidecar = find(crate::metrics::Backend::Sidecar);
9077        assert_eq!(
9078            sidecar.stats.err_count, 1,
9079            "a failed sidecar revoke was not counted",
9080        );
9081    }
9082
9083    /// **`Failed` must count as an error — the half the metric exists for.**
9084    ///
9085    /// A review found this unpinned: replacing the mapping with
9086    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
9087    /// asserted the `NoSession -> ok` half, so the branch that actually means
9088    /// "the PDS still holds tokens we asked it to drop" was untested.
9089    ///
9090    /// Driven through the same handler, with a session present but the PDS
9091    /// unreachable, so `sign_out_discovering` returns `Failed`.
9092    #[tokio::test]
9093    async fn a_failed_rust_revoke_counts_as_an_error() {
9094        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9095        let state = test_state(&[]).await;
9096        let runtime = state.oauth.as_deref().expect("oauth runtime");
9097        crate::oauth::store::put_session(
9098            &state.db,
9099            &runtime.codec,
9100            &crate::oauth::store::OAuthSession {
9101                sub: did.into(),
9102                issuer: "https://auth.invalid".into(),
9103                aud: "https://pds.invalid".into(),
9104                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9105                    .to_jwk_json()
9106                    .unwrap(),
9107                access_token: "at".into(),
9108                refresh_token: "rt".into(),
9109                token_type: "DPoP".into(),
9110                granted_scope: "atproto".into(),
9111                expires_at: Some(crate::store::now_unix() + 3600),
9112            },
9113        )
9114        .await
9115        .unwrap();
9116
9117        revoke_everywhere(&state, did).await;
9118
9119        let rows = state.metrics.snapshot();
9120        let rust = rows
9121            .iter()
9122            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9123            .expect("no rust oauth_revoke row");
9124        assert_eq!(
9125            rust.stats.err_count, 1,
9126            "an unreachable PDS must count as a revocation failure",
9127        );
9128        assert_eq!(rust.stats.ok_count, 0);
9129    }
9130
9131    /// **The `href` defence is now carried by the TYPE, not by remembering.**
9132    ///
9133    /// `EntryRow.link` used to be a `String`, and the guard was "call
9134    /// `net::safe_link` before assigning it". Deleting that call left all 679
9135    /// tests passing — a live XSS defence with nothing protecting it.
9136    ///
9137    /// `SafeLink` has no `From<String>` and no public member, so the only way to
9138    /// get foreign input into an `href` is `external`, which does the check
9139    /// itself. This test pins that constructor; the *wiring* is now pinned by
9140    /// the compiler, which is the part a test could never hold down.
9141    ///
9142    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
9143    /// so the template renders the row WITHOUT an anchor. Dropping the row
9144    /// instead would make the record unremovable, because the un-save button
9145    /// lives on it.
9146    #[test]
9147    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9148        for hostile in [
9149            "javascript:alert(1)",
9150            "JavaScript:alert(1)",
9151            "  javascript:alert(1)",
9152            "data:text/html;base64,PHNjcmlwdD4=",
9153            "vbscript:msgbox(1)",
9154            "file:///etc/passwd",
9155            // Protocol-relative: inherits the page's scheme, so it is an
9156            // off-site link wearing a same-site costume. Carried over from the
9157            // test this one replaces, which was its only unique input.
9158            "//evil.example/path",
9159        ] {
9160            let link = SafeLink::external(hostile);
9161            assert!(
9162                link.is_empty(),
9163                "{hostile:?} produced a non-empty href: {link}",
9164            );
9165            assert!(
9166                !link.to_string().to_ascii_lowercase().contains("script"),
9167                "{hostile:?} leaked into the rendered link",
9168            );
9169        }
9170
9171        // And the other direction: a check that rejects everything would satisfy
9172        // the loop above while breaking every real saved record.
9173        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9174            let link = SafeLink::external(good);
9175            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9176            assert_eq!(link.to_string(), good);
9177        }
9178    }
9179
9180    /// **The WIRING, not the helper — this is the one that catches the real
9181    /// mistake.**
9182    ///
9183    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
9184    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
9185    /// *calls* it, and a review proved that gap was live twice over: swapping
9186    /// `external` for the app-path constructor, and constructing the tuple
9187    /// directly, both restored the whole `javascript:` hole with every test
9188    /// green. The type now blocks both — `entry` takes an `i64`, and the field
9189    /// lives in another module — but the wiring deserves a test of its own
9190    /// rather than resting on the shape of a signature.
9191    ///
9192    /// Renders the actual row through the actual handler, from a record whose
9193    /// URL is hostile.
9194    #[tokio::test]
9195    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9196        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9197        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9198        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9199        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9200
9201        let resp = router(state)
9202            .oneshot(
9203                Request::builder()
9204                    .uri("/?view=starred")
9205                    .body(Body::empty())
9206                    .unwrap(),
9207            )
9208            .await
9209            .unwrap();
9210        assert_eq!(resp.status(), StatusCode::OK);
9211        let body = String::from_utf8(
9212            axum::body::to_bytes(resp.into_body(), usize::MAX)
9213                .await
9214                .unwrap()
9215                .to_vec(),
9216        )
9217        .unwrap();
9218
9219        // Not in an href, and not as the title either — the title falls back to
9220        // the URL for links we DO render, so both paths must withhold it.
9221        assert!(
9222            !body.to_ascii_lowercase().contains("javascript:"),
9223            "the hostile scheme reached the rendered page",
9224        );
9225        // But the row must survive: the un-save button lives on it, so dropping
9226        // the row would make the record unremovable from here.
9227        assert!(
9228            body.contains("unusable link"),
9229            "the row was dropped instead of rendering without an anchor",
9230        );
9231    }
9232
9233    /// **The reader view's two `href`s, through the actual handler.**
9234    ///
9235    /// The sibling above covers the LIST row. `entry.html` has its own pair of
9236    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
9237    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
9238    ///
9239    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
9240    /// this was never a live hole. But that guard is procedural and sits a long
9241    /// way from the `href`: it holds only as long as every future writer to
9242    /// `entries.url` remembers to go through `feed.rs`. This test does not
9243    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
9244    /// is precisely the state the ingest check cannot speak for.
9245    ///
9246    /// **Both directions, deliberately.** A fix that renders no link at all
9247    /// satisfies every negative assertion here, and would break every real
9248    /// entry. The second half is what makes the first half mean something.
9249    #[tokio::test]
9250    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9251        let did = "did:plc:readerhref";
9252        let state = test_state(&[]).await;
9253        store::grant_access(&state.db, did, None, "test", None)
9254            .await
9255            .unwrap();
9256        let feed = store::upsert_feed(
9257            &state.db,
9258            &store::NewFeed {
9259                url: "https://href.example/feed.xml".to_string(),
9260                title: Some("Href".to_string()),
9261                ..Default::default()
9262            },
9263        )
9264        .await
9265        .unwrap();
9266        // Straight into the column, bypassing `feed.rs` — the whole point.
9267        store::insert_entries(
9268            &state.db,
9269            feed,
9270            &[
9271                store::NewEntry {
9272                    guid: "hostile-1".to_string(),
9273                    url: Some("javascript:alert(1)".to_string()),
9274                    title: Some("Hostile entry".to_string()),
9275                    published: Some("2026-07-11T00:00:00Z".to_string()),
9276                    ..Default::default()
9277                },
9278                store::NewEntry {
9279                    guid: "benign-1".to_string(),
9280                    url: Some("https://href.example/post".to_string()),
9281                    title: Some("Benign entry".to_string()),
9282                    published: Some("2026-07-10T00:00:00Z".to_string()),
9283                    ..Default::default()
9284                },
9285            ],
9286            0,
9287        )
9288        .await
9289        .unwrap();
9290        store::replace_sub_refs(&state.db, did, &[feed])
9291            .await
9292            .unwrap();
9293        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9294        let id_of = |guid: &str| {
9295            rows.iter()
9296                .find(|r| r.guid == guid)
9297                .unwrap_or_else(|| panic!("{guid} was not inserted"))
9298                .id
9299        };
9300
9301        let cookie = session_cookie(&state, did, None);
9302        let app = router(state.clone());
9303
9304        let render = |id: i64| {
9305            let app = app.clone();
9306            let cookie = cookie.clone();
9307            async move {
9308                let resp = app
9309                    .oneshot(
9310                        Request::builder()
9311                            .method("GET")
9312                            .uri(format!("/entries/{id}"))
9313                            .header(header::COOKIE, cookie)
9314                            .body(Body::empty())
9315                            .unwrap(),
9316                    )
9317                    .await
9318                    .unwrap();
9319                assert_eq!(resp.status(), StatusCode::OK);
9320                String::from_utf8(
9321                    axum::body::to_bytes(resp.into_body(), usize::MAX)
9322                        .await
9323                        .unwrap()
9324                        .to_vec(),
9325                )
9326                .unwrap()
9327            }
9328        };
9329
9330        let hostile = render(id_of("hostile-1")).await;
9331        // The reader page for THIS entry actually rendered. Without this the
9332        // three negatives below are satisfied by an empty body.
9333        assert!(
9334            hostile.contains("Hostile entry"),
9335            "the reader did not render the entry: {hostile}",
9336        );
9337        assert!(
9338            !hostile.to_ascii_lowercase().contains("javascript:"),
9339            "the hostile scheme reached the reader page: {hostile}",
9340        );
9341        // Not merely escaped — the template took its no-link branch. Both
9342        // `href`s are gated on the same `Option`, so this covers the byline
9343        // link and the action-bar button together.
9344        assert!(
9345            !hostile.contains("actionbar-open"),
9346            "the action bar rendered an open-original link for a refused URL: {hostile}",
9347        );
9348        assert!(
9349            !hostile.contains("Original \u{2197}"),
9350            "the byline rendered an original link for a refused URL: {hostile}",
9351        );
9352
9353        // The other direction: a legitimate entry still links out, so "render
9354        // nothing" cannot pass as a fix.
9355        let benign = render(id_of("benign-1")).await;
9356        assert!(
9357            benign.contains("Benign entry"),
9358            "the reader did not render the benign entry: {benign}",
9359        );
9360        // BOTH `href`s, counted. The negatives above fire on the action bar
9361        // first, so without this the byline needle `Original \u{2197}` is never
9362        // once observed failing — a misspelled needle would pass forever.
9363        assert_eq!(
9364            benign
9365                .matches(r#"href="https://href.example/post""#)
9366                .count(),
9367            2,
9368            "entry.html has two `href`s for the entry URL — the byline link and \
9369             the action-bar button — and this render produced a different \
9370             number: {benign}",
9371        );
9372        assert!(
9373            benign.contains("actionbar-open"),
9374            "a legitimate entry lost its open-original button: {benign}",
9375        );
9376        assert!(
9377            benign.contains("Original \u{2197}"),
9378            "a legitimate entry lost its byline link: {benign}",
9379        );
9380    }
9381
9382    /// **The outage fallback must not widen what the caller can READ — and the
9383    /// sibling test above can only see what it WRITES.**
9384    ///
9385    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
9386    /// on `entry_state`: the fallback's side effects. But the fail-open it names
9387    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
9388    /// leaks through the list it *hands back* — the sidebar and the reader render
9389    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
9390    /// perfectly honest and every existing assertion stays green.
9391    ///
9392    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
9393    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
9394    /// exact historical bug the fallback's comment describes — left **all 663
9395    /// tests passing**. Cross-tenant isolation is the one property this project
9396    /// cannot regress quietly, and nothing observed it.
9397    ///
9398    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
9399    /// user, and it deliberately does not look at `sub_ref` at all — that half is
9400    /// already covered above.
9401    #[tokio::test]
9402    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9403        let did_a = "did:plc:aaaa";
9404        let state = test_state(&[]).await;
9405        store::grant_access(&state.db, did_a, None, "test", None)
9406            .await
9407            .unwrap();
9408
9409        let feed_a = store::upsert_feed(
9410            &state.db,
9411            &store::NewFeed {
9412                url: "https://a.example/feed.xml".to_string(),
9413                title: Some("A".to_string()),
9414                ..Default::default()
9415            },
9416        )
9417        .await
9418        .unwrap();
9419        let _feed_b = store::upsert_feed(
9420            &state.db,
9421            &store::NewFeed {
9422                url: "https://b.example/feed.xml".to_string(),
9423                title: Some("B".to_string()),
9424                ..Default::default()
9425            },
9426        )
9427        .await
9428        .unwrap();
9429        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
9430        // to nobody — exactly the row a whole-cache fallback would hand to A.
9431        store::replace_sub_refs(&state.db, did_a, &[feed_a])
9432            .await
9433            .unwrap();
9434
9435        // No sidecar and no PDS are reachable from a test, so
9436        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
9437        // that, rather than assuming it: if the repo ever starts succeeding here,
9438        // this test would silently stop exercising the fallback at all.
9439        assert!(
9440            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9441            "this test is only meaningful on the outage path; the repo answered",
9442        );
9443
9444        let resolved = resolve_subscriptions(&state, did_a).await;
9445
9446        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9447        assert_eq!(
9448            urls,
9449            vec!["https://a.example/feed.xml"],
9450            "the outage fallback must return the caller's OWN subscriptions only; \
9451             any other feed here is cross-tenant read access granted by an outage",
9452        );
9453    }
9454
9455    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
9456    /// seeding `did` a beta seat + session-capable state.
9457    async fn test_state_with_caps(
9458        did: &str,
9459        max_subs_per_did: i64,
9460        max_feeds_global: i64,
9461    ) -> AppState {
9462        let db = store::init_url("sqlite::memory:").await.unwrap();
9463        let config = Config {
9464            cookie_secret: "test-cookie-secret-000".to_string(),
9465            beta_cap: 100,
9466            max_subs_per_did,
9467            max_feeds_global,
9468            ..Config::default()
9469        };
9470        store::grant_access(&db, did, None, "test", None)
9471            .await
9472            .unwrap();
9473        AppState::new(config, db).unwrap()
9474    }
9475
9476    /// An OPML document with `n` distinct public feeds.
9477    fn opml_with_feeds(n: usize) -> String {
9478        let mut outlines = String::new();
9479        for i in 0..n {
9480            outlines.push_str(&format!(
9481                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9482            ));
9483        }
9484        format!(
9485            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9486        )
9487    }
9488
9489    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
9490    /// distinct new feeds than the shared cache can hold caches only up to the
9491    /// ceiling — the rest are trimmed. (Regression: the import loop previously
9492    /// bypassed `max_feeds_global` entirely.)
9493    #[tokio::test]
9494    async fn opml_import_enforces_global_feeds_ceiling() {
9495        let did = "did:plc:importer";
9496        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
9497        let state = test_state_with_caps(did, 0, 3).await;
9498        let cookie = session_cookie(&state, did, None);
9499        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9500        let app = router(state.clone());
9501
9502        let resp = app
9503            .oneshot(
9504                Request::builder()
9505                    .method("POST")
9506                    .uri("/opml")
9507                    .header(header::COOKIE, cookie)
9508                    .header("content-type", ct)
9509                    .body(Body::from(body))
9510                    .unwrap(),
9511            )
9512            .await
9513            .unwrap();
9514        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9515
9516        let feeds = store::count_feeds(&state.db).await.unwrap();
9517        assert!(
9518            feeds <= 3,
9519            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9520        );
9521    }
9522
9523    /// POST an OPML of `n` feeds as `did` against a strict fake PDS behind the
9524    /// sidecar, and return the flash it redirected with plus the fake's log.
9525    async fn import_against_strict_pds(
9526        did: &str,
9527        n: usize,
9528        fail_call: Option<usize>,
9529    ) -> (String, crate::atproto::tests::ApplyWritesLog) {
9530        let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
9531        let state = test_state_with_sidecar(&[did], &sidecar).await;
9532        let cookie = session_cookie(&state, did, None);
9533        let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
9534        let resp = router(state)
9535            .oneshot(
9536                Request::builder()
9537                    .method("POST")
9538                    .uri("/opml")
9539                    .header(header::COOKIE, cookie)
9540                    .header("content-type", ct)
9541                    .body(Body::from(body))
9542                    .unwrap(),
9543            )
9544            .await
9545            .unwrap();
9546        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9547        let loc = resp.headers()[header::LOCATION].to_str().unwrap();
9548        let flash = url::Url::parse(&format!("http://x{loc}"))
9549            .unwrap()
9550            .query_pairs()
9551            .find(|(k, _)| k == "flash")
9552            .map(|(_, v)| v.into_owned())
9553            .unwrap_or_default();
9554        (flash, log)
9555    }
9556
9557    /// **An OPML import of more than 200 feeds succeeds** against a PDS that
9558    /// refuses more than 200 writes a call, as the reference PDS does. It used
9559    /// to go out as one `applyWrites` and fail outright, importing nothing.
9560    #[tokio::test]
9561    async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
9562        let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
9563        assert_eq!(flash, "Imported 450 feeds", "{flash}");
9564        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
9565    }
9566
9567    /// **A part-landed import says so.** Chunk 2 of 3 fails: chunk 1's 200
9568    /// feeds are in the reader's repo, and "nothing was imported" — what the
9569    /// handler said for any failure — would be false.
9570    #[tokio::test]
9571    async fn opml_import_that_part_lands_reports_what_landed() {
9572        let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
9573        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
9574        assert!(
9575            flash.contains("200 of 450"),
9576            "the landed count is not reported: {flash}"
9577        );
9578        assert!(
9579            !flash.contains("nothing was imported"),
9580            "200 feeds landed and the reader was told none did: {flash}"
9581        );
9582    }
9583
9584    /// A batch that failed on its first call still reports that nothing was
9585    /// imported — true, since nothing after a failed call is sent.
9586    #[tokio::test]
9587    async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
9588        let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
9589        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
9590        assert!(flash.contains("nothing was imported"), "{flash}");
9591    }
9592
9593    /// **A malformed `at://` on the add path is "not a kind of feed we take",
9594    /// not "private/paid".** The first gate was the privacy classifier, whose
9595    /// at:// arm fails closed as `Private` for anything not a well-formed
9596    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
9597    /// the private-feed flash and a "refused private/paid feed" log line. On
9598    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
9599    /// feed". Storability is decided first for an at:// input, with its own
9600    /// message.
9601    #[tokio::test]
9602    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
9603        let did = "did:plc:typoist";
9604        let state = test_state_with_caps(did, 0, 0).await;
9605        let cookie = session_cookie(&state, did, None);
9606        for input in [
9607            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
9608            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9609        ] {
9610            let resp = router(state.clone())
9611                .oneshot(
9612                    Request::builder()
9613                        .method("POST")
9614                        .uri("/subscriptions")
9615                        .header(header::COOKIE, cookie.clone())
9616                        .header("content-type", "application/x-www-form-urlencoded")
9617                        .body(Body::from(format!("url={input}")))
9618                        .unwrap(),
9619                )
9620                .await
9621                .unwrap();
9622            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9623            let loc = resp
9624                .headers()
9625                .get(header::LOCATION)
9626                .unwrap()
9627                .to_str()
9628                .unwrap();
9629            assert!(
9630                loc.contains("kind%20of%20feed"),
9631                "expected the unsupported-feed flash for {input}, got {loc}"
9632            );
9633            assert!(
9634                !loc.contains("Private"),
9635                "a storability refusal was reported as a privacy one for {input}: {loc}"
9636            );
9637        }
9638        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9639    }
9640
9641    /// **An OPML entry this instance cannot store is counted and reported, not
9642    /// silently dropped.** The storability `continue` incremented nothing,
9643    /// while the privacy branch beside it produced a user-visible label — so
9644    /// an OPML exported from a standard.site-enabled instance imported
9645    /// "successfully" with entries missing and no reason given. The reader is
9646    /// told how many, and why.
9647    #[tokio::test]
9648    async fn opml_import_reports_entries_this_instance_cannot_store() {
9649        let did = "did:plc:renamer4";
9650        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9651        let state = test_state_with_sidecar(&[did], &sidecar).await;
9652        assert!(!state.config.standard_site);
9653        let opml = format!(
9654            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9655             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9656             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9657             </body></opml>"
9658        );
9659        let (ct, body) = opml_multipart(opml.as_bytes());
9660        let cookie = session_cookie(&state, did, None);
9661        let resp = router(state.clone())
9662            .oneshot(
9663                Request::builder()
9664                    .method("POST")
9665                    .uri("/opml")
9666                    .header(header::COOKIE, cookie)
9667                    .header("content-type", ct)
9668                    .body(Body::from(body))
9669                    .unwrap(),
9670            )
9671            .await
9672            .unwrap();
9673        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9674        let loc = resp
9675            .headers()
9676            .get(header::LOCATION)
9677            .unwrap()
9678            .to_str()
9679            .unwrap();
9680        assert!(
9681            loc.contains("Imported%201%20feed"),
9682            "unexpected flash: {loc}"
9683        );
9684        assert!(
9685            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
9686            "the dropped entry was not reported: {loc}"
9687        );
9688        // Reported by count only: the at-URI itself is not echoed back.
9689        assert!(
9690            !loc.contains("site.standard.publication"),
9691            "the URI was echoed: {loc}"
9692        );
9693    }
9694
9695    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
9696    /// cap imports zero new feeds.
9697    #[tokio::test]
9698    async fn opml_import_enforces_per_did_cap() {
9699        let did = "did:plc:capped";
9700        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
9701        let state = test_state_with_caps(did, 2, 0).await;
9702        let existing_a = store::upsert_feed(
9703            &state.db,
9704            &store::NewFeed {
9705                url: "https://have-a.example/feed.xml".to_string(),
9706                ..Default::default()
9707            },
9708        )
9709        .await
9710        .unwrap();
9711        let existing_b = store::upsert_feed(
9712            &state.db,
9713            &store::NewFeed {
9714                url: "https://have-b.example/feed.xml".to_string(),
9715                ..Default::default()
9716            },
9717        )
9718        .await
9719        .unwrap();
9720        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
9721            .await
9722            .unwrap();
9723        let before = store::count_feeds(&state.db).await.unwrap();
9724
9725        let cookie = session_cookie(&state, did, None);
9726        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9727        let app = router(state.clone());
9728        let resp = app
9729            .oneshot(
9730                Request::builder()
9731                    .method("POST")
9732                    .uri("/opml")
9733                    .header(header::COOKIE, cookie)
9734                    .header("content-type", ct)
9735                    .body(Body::from(body))
9736                    .unwrap(),
9737            )
9738            .await
9739            .unwrap();
9740        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9741        // Headroom was 0 → no new feeds imported into the shared cache.
9742        let after = store::count_feeds(&state.db).await.unwrap();
9743        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
9744    }
9745
9746    /// Single-add per-DID cap: a DID at its subscription cap is refused before
9747    /// any fetch, with the limit flash.
9748    #[tokio::test]
9749    async fn single_add_enforces_per_did_cap() {
9750        let did = "did:plc:subcapped";
9751        let state = test_state_with_caps(did, 1, 0).await;
9752        let f = store::upsert_feed(
9753            &state.db,
9754            &store::NewFeed {
9755                url: "https://have.example/feed.xml".to_string(),
9756                ..Default::default()
9757            },
9758        )
9759        .await
9760        .unwrap();
9761        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
9762        let cookie = session_cookie(&state, did, None);
9763        let app = router(state.clone());
9764        let resp = app
9765            .oneshot(
9766                Request::builder()
9767                    .method("POST")
9768                    .uri("/subscriptions")
9769                    .header(header::COOKIE, cookie)
9770                    .header("content-type", "application/x-www-form-urlencoded")
9771                    .body(Body::from("url=https://another.example/feed.xml"))
9772                    .unwrap(),
9773            )
9774            .await
9775            .unwrap();
9776        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9777        let loc = resp
9778            .headers()
9779            .get(header::LOCATION)
9780            .unwrap()
9781            .to_str()
9782            .unwrap();
9783        assert!(
9784            loc.contains("Subscription%20limit%20reached"),
9785            "expected sub-limit flash, got {loc}"
9786        );
9787    }
9788
9789    /// `GET /` renders at most one page of rows and offers a way to the rest.
9790    ///
9791    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
9792    /// `LIMIT`, article bodies included — and hand the lot to the template. With
9793    /// 250 entries that is the whole list in one response; with a real backlog on
9794    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
9795    /// is capped, the heading still reports the true total, and page 2 is
9796    /// reachable and disjoint.
9797    #[tokio::test]
9798    async fn the_reader_index_pages_instead_of_rendering_everything() {
9799        let did = "did:plc:pager";
9800        let state = test_state(&[]).await;
9801        store::grant_access(&state.db, did, None, "test", None)
9802            .await
9803            .unwrap();
9804        let feed = store::upsert_feed(
9805            &state.db,
9806            &store::NewFeed {
9807                url: "https://pager.example/feed.xml".to_string(),
9808                title: Some("Pager".to_string()),
9809                ..Default::default()
9810            },
9811        )
9812        .await
9813        .unwrap();
9814        let total = 250_usize;
9815        let entries: Vec<store::NewEntry> = (0..total)
9816            .map(|i| store::NewEntry {
9817                guid: format!("p-{i:04}"),
9818                url: Some(format!("https://pager.example/{i}")),
9819                title: Some(format!("Article {i:04}")),
9820                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
9821                content_html: Some("x".repeat(4_000)),
9822                ..Default::default()
9823            })
9824            .collect();
9825        store::insert_entries(&state.db, feed, &entries, 0)
9826            .await
9827            .unwrap();
9828        store::replace_sub_refs(&state.db, did, &[feed])
9829            .await
9830            .unwrap();
9831
9832        let cookie = session_cookie(&state, did, None);
9833        let app = router(state.clone());
9834        let get = |uri: &str| {
9835            let app = app.clone();
9836            let cookie = cookie.clone();
9837            let uri = uri.to_string();
9838            async move {
9839                let resp = app
9840                    .oneshot(
9841                        Request::builder()
9842                            .uri(uri)
9843                            .header(header::COOKIE, cookie)
9844                            .body(Body::empty())
9845                            .unwrap(),
9846                    )
9847                    .await
9848                    .unwrap();
9849                assert_eq!(resp.status(), StatusCode::OK);
9850                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
9851                    .await
9852                    .unwrap();
9853                String::from_utf8(bytes.to_vec()).unwrap()
9854            }
9855        };
9856
9857        let page1 = get("/").await;
9858        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
9859        // over-count: each row carries several (the link plus the read/star
9860        // forms).
9861        let rows1 = page1.matches("<li class=\"entry").count();
9862        assert!(
9863            rows1 <= ENTRIES_PER_PAGE as usize,
9864            "page 1 rendered {rows1} entry links; the list is unbounded"
9865        );
9866        assert!(
9867            rows1 > 0,
9868            "page 1 rendered nothing at all: the page bound swallowed the list"
9869        );
9870        // The count is the TRUE total, not the page size — otherwise paging
9871        // would quietly relabel a 250-entry backlog as a 100-entry one.
9872        assert!(
9873            page1.contains("250 entries"),
9874            "heading must report the full total, not the page"
9875        );
9876        assert!(
9877            page1.contains("page=2"),
9878            "no way to reach the rest of the list: {}",
9879            &page1[..page1.len().min(400)]
9880        );
9881        // The body never belongs in a list response.
9882        assert!(
9883            !page1.contains(&"x".repeat(4_000)),
9884            "the list response carried an article body"
9885        );
9886
9887        let page2 = get("/?page=2").await;
9888        assert!(
9889            page2.matches("<li class=\"entry").count() > 0,
9890            "page 2 rendered no rows at all"
9891        );
9892        assert!(
9893            page2.contains("page=1") || page2.contains("Newer"),
9894            "page 2 offers no way back"
9895        );
9896        // Disjoint: an article on page 1 must not reappear on page 2.
9897        let first_title = (0..total)
9898            .map(|i| format!("Article {i:04}"))
9899            .find(|t| page1.contains(t))
9900            .expect("page 1 shows at least one titled article");
9901        assert!(
9902            !page2.contains(&first_title),
9903            "{first_title} appears on both pages"
9904        );
9905
9906        // A page past the end must not be a dead end. The empty state renders
9907        // instead of the pager, so an out-of-range page would leave a reader
9908        // with no link back — reachable by typing a number, and reachable
9909        // WITHOUT typing anything by paging to the end and then marking entries
9910        // read, which shrinks the list under the URL already in the address bar.
9911        let past_end = get("/?page=999").await;
9912        assert!(
9913            past_end.matches("<li class=\"entry").count() > 0,
9914            "an out-of-range page rendered nothing and offered no way back"
9915        );
9916        assert!(
9917            past_end.contains("page=2"),
9918            "the clamped page offers no pager"
9919        );
9920    }
9921
9922    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
9923    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
9924    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
9925    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
9926    /// view (no reader header) instead swaps the row. This guards the reader OOB
9927    /// toggle wiring, which had no test.
9928    #[tokio::test]
9929    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9930        let did = "did:plc:reader";
9931        let state = test_state(&[]).await;
9932        store::grant_access(&state.db, did, None, "test", None)
9933            .await
9934            .unwrap();
9935        let feed = store::upsert_feed(
9936            &state.db,
9937            &store::NewFeed {
9938                url: "https://reader.example/feed.xml".to_string(),
9939                title: Some("Reader".to_string()),
9940                ..Default::default()
9941            },
9942        )
9943        .await
9944        .unwrap();
9945        store::insert_entries(
9946            &state.db,
9947            feed,
9948            &[store::NewEntry {
9949                guid: "r-1".to_string(),
9950                url: Some("https://reader.example/1".to_string()),
9951                title: Some("Article".to_string()),
9952                published: Some("2026-07-11T00:00:00Z".to_string()),
9953                content_html: Some("<p>body</p>".to_string()),
9954                ..Default::default()
9955            }],
9956            0,
9957        )
9958        .await
9959        .unwrap();
9960        store::replace_sub_refs(&state.db, did, &[feed])
9961            .await
9962            .unwrap();
9963        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9964
9965        let cookie = session_cookie(&state, did, None);
9966        let app = router(state.clone());
9967
9968        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
9969        let resp = app
9970            .clone()
9971            .oneshot(
9972                Request::builder()
9973                    .method("POST")
9974                    .uri(format!("/entries/{entry_id}/read"))
9975                    .header(header::COOKIE, cookie.clone())
9976                    .header("HX-Request", "true")
9977                    .header("X-FR-Reader", "1")
9978                    .header("content-type", "application/x-www-form-urlencoded")
9979                    .body(Body::from("read=true"))
9980                    .unwrap(),
9981            )
9982            .await
9983            .unwrap();
9984        assert_eq!(resp.status(), StatusCode::OK);
9985        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9986            .await
9987            .unwrap();
9988        let html = String::from_utf8(bytes.to_vec()).unwrap();
9989        assert!(
9990            html.contains("hx-swap-oob=\"outerHTML\""),
9991            "reader response must be an OOB swap: {html}"
9992        );
9993        assert!(
9994            html.contains(r#"id="entry-actionbar""#),
9995            "reader response must be the action-bar fragment: {html}"
9996        );
9997        // Now READ: the read button reflects it (aria-pressed=true) and the
9998        // hidden value flips to `false` so the next tap marks it UNREAD.
9999        assert!(
10000            html.contains(r#"aria-pressed="true""#),
10001            "read button must show pressed after marking read: {html}"
10002        );
10003        assert!(
10004            html.contains(r#"name="read" value="false""#),
10005            "hidden read value must flip to false so a second tap reverses: {html}"
10006        );
10007
10008        // A second reader mark-read (submitting the flipped `read=false`) marks
10009        // it UNREAD again — the toggle reverses.
10010        let resp2 = app
10011            .oneshot(
10012                Request::builder()
10013                    .method("POST")
10014                    .uri(format!("/entries/{entry_id}/read"))
10015                    .header(header::COOKIE, cookie)
10016                    .header("HX-Request", "true")
10017                    .header("X-FR-Reader", "1")
10018                    .header("content-type", "application/x-www-form-urlencoded")
10019                    .body(Body::from("read=false"))
10020                    .unwrap(),
10021            )
10022            .await
10023            .unwrap();
10024        assert_eq!(resp2.status(), StatusCode::OK);
10025        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10026            .await
10027            .unwrap();
10028        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10029        assert!(
10030            html2.contains(r#"aria-pressed="false""#),
10031            "read button must show un-pressed after reversing: {html2}"
10032        );
10033        assert!(
10034            html2.contains(r#"name="read" value="true""#),
10035            "hidden read value must flip back to true: {html2}"
10036        );
10037    }
10038
10039    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
10040    /// action-bar — the counterpart to the reader-OOB test above.
10041    #[tokio::test]
10042    async fn list_mark_read_returns_row_not_oob_actionbar() {
10043        let did = "did:plc:listv";
10044        let state = test_state(&[]).await;
10045        store::grant_access(&state.db, did, None, "test", None)
10046            .await
10047            .unwrap();
10048        let feed = store::upsert_feed(
10049            &state.db,
10050            &store::NewFeed {
10051                url: "https://list.example/feed.xml".to_string(),
10052                title: Some("List".to_string()),
10053                ..Default::default()
10054            },
10055        )
10056        .await
10057        .unwrap();
10058        store::insert_entries(
10059            &state.db,
10060            feed,
10061            &[store::NewEntry {
10062                guid: "l-1".to_string(),
10063                url: Some("https://list.example/1".to_string()),
10064                title: Some("Article".to_string()),
10065                published: Some("2026-07-11T00:00:00Z".to_string()),
10066                ..Default::default()
10067            }],
10068            0,
10069        )
10070        .await
10071        .unwrap();
10072        store::replace_sub_refs(&state.db, did, &[feed])
10073            .await
10074            .unwrap();
10075        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10076
10077        let cookie = session_cookie(&state, did, None);
10078        let app = router(state.clone());
10079
10080        let resp = app
10081            .oneshot(
10082                Request::builder()
10083                    .method("POST")
10084                    .uri(format!("/entries/{entry_id}/read"))
10085                    .header(header::COOKIE, cookie)
10086                    .header("HX-Request", "true")
10087                    .header("content-type", "application/x-www-form-urlencoded")
10088                    .body(Body::from("read=true"))
10089                    .unwrap(),
10090            )
10091            .await
10092            .unwrap();
10093        assert_eq!(resp.status(), StatusCode::OK);
10094        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10095            .await
10096            .unwrap();
10097        let html = String::from_utf8(bytes.to_vec()).unwrap();
10098        assert!(
10099            !html.contains("hx-swap-oob"),
10100            "list-view response must NOT be an OOB swap: {html}"
10101        );
10102        // **And it must actually BE the row.** The assertion above is satisfied
10103        // by an empty body, or by any response that simply omits the attribute —
10104        // so on its own it pins half a property and the name promises the other
10105        // half.
10106        assert!(
10107            html.contains(&format!("/entries/{entry_id}")),
10108            "the response is not the row for this entry: {html}",
10109        );
10110        assert!(
10111            html.contains("Article"),
10112            "the row rendered without its title: {html}",
10113        );
10114        // **The row comes back carrying read state. That is all this proves.**
10115        //
10116        // It does NOT prove the state was persisted: the handler renders
10117        // `Some(read)` from the form value, so making `mark_read` roll back
10118        // instead of commit fails 11 store tests and leaves this one green.
10119        //
10120        // It does not prove the OVERRIDE either, which an earlier version of
10121        // this comment claimed. Verified: changing the call site to
10122        // `build_entry_row(pool, &did, id, None)` — deleting the override
10123        // wholesale — keeps the whole suite green, because `mark_read` has
10124        // already persisted the same value two lines earlier, so reading it back
10125        // from the database produces an identical row.
10126        //
10127        // Distinguishing the two needs a case where the override and the stored
10128        // state DISAGREE, which this handler never produces: it writes the value
10129        // it then renders. Left as a known gap rather than described as covered.
10130        assert!(
10131            html.contains("is-read"),
10132            "the row came back without the read state it was just given: {html}",
10133        );
10134    }
10135
10136    // -----------------------------------------------------------------------
10137    // Rename parity (POST /subscriptions/{rkey}/rename)
10138    // -----------------------------------------------------------------------
10139
10140    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
10141    ///
10142    /// The add path gates the URL the user *typed*; the URL it *stores* is
10143    /// whatever `resolve_feed_url` returns, which for an HTML page is a
10144    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
10145    /// that: `discover_feed` yields only http(s), and the add path re-checks
10146    /// storability on the resolved URL. This test pins the DISJUNCTION —
10147    /// each layer alone holds it, both removed fails it — driven through the
10148    /// real route against a real local server.
10149    ///
10150    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
10151    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
10152    /// form: once storage became DID-only the privacy classifier refused it
10153    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
10154    /// — the colons in the DID), so `discover_feed` drops it before either
10155    /// layer exists. An at:// link cannot come out of autodiscovery under
10156    /// ANY mutation of the layers, so no test through this route can pin
10157    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
10158    /// structure and pinned where it lives: `discover_skips_a_non_http_
10159    /// alternate` and the storability tests in `feed.rs`.
10160    #[tokio::test]
10161    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10162        let did = "did:plc:autodiscovered";
10163        // Access granted, both caps disabled — the only gates left are the
10164        // two under test.
10165        let state = test_state_with_caps(did, 0, 0).await;
10166
10167        let page = r#"<!doctype html><html><head><title>Blog</title>
10168            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10169            </head><body>hi</body></html>"#;
10170        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10171        let port: u16 = base
10172            .trim_end_matches('/')
10173            .rsplit(':')
10174            .next()
10175            .unwrap()
10176            .parse()
10177            .unwrap();
10178        crate::net::test_host_override(
10179            "autodiscover-ftp.test",
10180            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10181        );
10182
10183        let cookie = session_cookie(&state, did, None);
10184        let resp = router(state.clone())
10185            .oneshot(
10186                Request::builder()
10187                    .method("POST")
10188                    .uri("/subscriptions")
10189                    .header(header::COOKIE, cookie)
10190                    .header("content-type", "application/x-www-form-urlencoded")
10191                    .body(Body::from(format!(
10192                        "url=http://autodiscover-ftp.test:{port}/"
10193                    )))
10194                    .unwrap(),
10195            )
10196            .await
10197            .unwrap();
10198        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10199        let loc = resp
10200            .headers()
10201            .get(header::LOCATION)
10202            .unwrap()
10203            .to_str()
10204            .unwrap();
10205        assert_ne!(loc, "/login", "the test never reached the add path");
10206        assert_ne!(loc, "/", "the subscribe succeeded");
10207
10208        assert_eq!(
10209            store::count_feeds(&state.db).await.unwrap(),
10210            0,
10211            "a non-http(s) URL from autodiscovery was stored"
10212        );
10213        assert_eq!(
10214            store::count_subscriptions_for_did(&state.db, did)
10215                .await
10216                .unwrap(),
10217            0
10218        );
10219    }
10220
10221    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
10222    /// its global ceiling must be refused (capacity flash) and must NOT insert a
10223    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
10224    /// rename loop can't inflate the shared cache past the cap.
10225    #[tokio::test]
10226    async fn rename_to_new_url_refused_at_global_feeds_cap() {
10227        let did = "did:plc:renamer4";
10228        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10229        // Global cap 1; pre-fill it with one feed so headroom is 0.
10230        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10231        store::upsert_feed(
10232            &state.db,
10233            &store::NewFeed {
10234                url: "https://existing.example/feed.xml".to_string(),
10235                ..Default::default()
10236            },
10237        )
10238        .await
10239        .unwrap();
10240        let before = store::count_feeds(&state.db).await.unwrap();
10241        assert_eq!(before, 1);
10242
10243        let cookie = session_cookie(&state, did, None);
10244        let resp = router(state.clone())
10245            .oneshot(
10246                Request::builder()
10247                    .method("POST")
10248                    .uri("/subscriptions/rk-keep/rename")
10249                    .header(header::COOKIE, cookie)
10250                    .header("content-type", "application/x-www-form-urlencoded")
10251                    // A URL not in the cache → would be a NEW feeds row.
10252                    .body(Body::from(
10253                        "url=https://brand-new.example/feed.xml&title=Renamed",
10254                    ))
10255                    .unwrap(),
10256            )
10257            .await
10258            .unwrap();
10259        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10260        let loc = resp
10261            .headers()
10262            .get(header::LOCATION)
10263            .unwrap()
10264            .to_str()
10265            .unwrap();
10266        assert!(
10267            loc.contains("feed%20capacity"),
10268            "expected the feed-capacity flash, got {loc}"
10269        );
10270        // No new feeds row was inserted, and nothing reached the PDS.
10271        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10272        assert!(
10273            puts.lock().unwrap().is_empty(),
10274            "a refused repoint reached the PDS"
10275        );
10276    }
10277
10278    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
10279    /// global cap (only new URLs are gated) — the other half of the guard.
10280    ///
10281    /// On the sidecar fake, so "allowed" means the put actually happened: the
10282    /// earlier harness had no sidecar, and this passed on a "could not reach
10283    /// your PDS" flash that merely was not the capacity one.
10284    #[tokio::test]
10285    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10286        let did = "did:plc:renamer4";
10287        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10288        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10289        store::upsert_feed(
10290            &state.db,
10291            &store::NewFeed {
10292                url: "https://existing.example/feed.xml".to_string(),
10293                ..Default::default()
10294            },
10295        )
10296        .await
10297        .unwrap();
10298        let before = store::count_feeds(&state.db).await.unwrap();
10299
10300        let cookie = session_cookie(&state, did, None);
10301        let resp = router(state.clone())
10302            .oneshot(
10303                Request::builder()
10304                    .method("POST")
10305                    .uri("/subscriptions/rk-keep/rename")
10306                    .header(header::COOKIE, cookie)
10307                    .header("content-type", "application/x-www-form-urlencoded")
10308                    .body(Body::from(
10309                        "url=https://existing.example/feed.xml&title=Retitled",
10310                    ))
10311                    .unwrap(),
10312            )
10313            .await
10314            .unwrap();
10315        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10316        let loc = resp
10317            .headers()
10318            .get(header::LOCATION)
10319            .unwrap()
10320            .to_str()
10321            .unwrap();
10322        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10323        assert_eq!(
10324            puts.lock().unwrap().len(),
10325            1,
10326            "the repoint did not reach the PDS"
10327        );
10328        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10329    }
10330
10331    /// A rename with a blank URL writes nothing anywhere.
10332    #[tokio::test]
10333    async fn rename_with_blank_url_writes_nothing() {
10334        let did = "did:plc:renamer3";
10335        let state = test_state_with_caps(did, 0, 0).await;
10336        let before = store::count_feeds(&state.db).await.unwrap();
10337        assert_eq!(before, 0);
10338
10339        let cookie = session_cookie(&state, did, None);
10340        let app = router(state.clone());
10341        let resp = app
10342            .oneshot(
10343                Request::builder()
10344                    .method("POST")
10345                    .uri("/subscriptions/rkey123/rename")
10346                    .header(header::COOKIE, cookie)
10347                    .header("content-type", "application/x-www-form-urlencoded")
10348                    // Whitespace-only URL trims to empty.
10349                    .body(Body::from("url=%20%20&title=Nope"))
10350                    .unwrap(),
10351            )
10352            .await
10353            .unwrap();
10354        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10355        assert_eq!(
10356            resp.headers()
10357                .get(header::LOCATION)
10358                .unwrap()
10359                .to_str()
10360                .unwrap(),
10361            "/",
10362        );
10363        // Nothing was cached.
10364        assert_eq!(
10365            store::count_feeds(&state.db).await.unwrap(),
10366            0,
10367            "blank-URL rename wrote a junk feeds row"
10368        );
10369    }
10370
10371    /// A sidecar mock that serves ONE existing subscription record and captures
10372    /// every `put` body a rename produces.
10373    ///
10374    /// **Reads to `content-length` rather than taking one `read`.** A single
10375    /// read gets whatever one segment carried; if the head and body land
10376    /// separately the capture holds no record and every field assertion below
10377    /// passes for the wrong reason. Each captured body must also mention the
10378    /// collection, so an empty capture fails loudly instead of quietly.
10379    async fn spawn_rename_sidecar(
10380        existing: serde_json::Value,
10381    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10382        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10383        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10384        let addr = listener.local_addr().unwrap();
10385        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10386        let sink = puts.clone();
10387        tokio::spawn(async move {
10388            loop {
10389                let Ok((mut sock, _)) = listener.accept().await else {
10390                    break;
10391                };
10392                let mut raw: Vec<u8> = Vec::new();
10393                let mut chunk = [0u8; 4096];
10394                let body_text = loop {
10395                    let Ok(n) = sock.read(&mut chunk).await else {
10396                        break String::new();
10397                    };
10398                    if n == 0 {
10399                        break String::from_utf8_lossy(&raw).to_string();
10400                    }
10401                    raw.extend_from_slice(&chunk[..n]);
10402                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10403                        continue;
10404                    };
10405                    let (head, body) = raw.split_at(split + 4);
10406                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10407                        let (k, v) = l.split_once(':')?;
10408                        k.eq_ignore_ascii_case("content-length")
10409                            .then(|| v.trim().parse::<usize>().ok())?
10410                    });
10411                    if want.is_none_or(|want| body.len() >= want) {
10412                        break String::from_utf8_lossy(body).to_string();
10413                    }
10414                };
10415
10416                // `"action":"put"` is the rename write; anything else is the read.
10417                let is_put = body_text.contains("\"action\":\"put\"");
10418                let data = if is_put {
10419                    sink.lock().unwrap().push(body_text.clone());
10420                    serde_json::json!({
10421                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10422                        "cid": "bafyreiafter"
10423                    })
10424                } else {
10425                    serde_json::json!({ "records": [existing.clone()] })
10426                };
10427                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10428                let resp = format!(
10429                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10430                    body.len(),
10431                    body
10432                );
10433                let _ = sock.write_all(resp.as_bytes()).await;
10434                let _ = sock.flush().await;
10435            }
10436        });
10437        (format!("http://{addr}"), puts)
10438    }
10439
10440    /// The existing record a rename must not destroy.
10441    fn seeded_subscription() -> serde_json::Value {
10442        serde_json::json!({
10443            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10444            "cid": "bafyreibefore",
10445            "value": {
10446                "$type": "community.lexicon.rss.subscription",
10447                "url": "https://example.com/feed.xml",
10448                "title": "Old title",
10449                "siteUrl": "https://example.com/blog",
10450                "fetchHint": "hourly",
10451                "private": false,
10452                "createdAt": "2024-03-01T00:00:00.000Z"
10453            }
10454        })
10455    }
10456
10457    /// An existing standard.site subscription, as the 19 in production are:
10458    /// written before this reader refused the scheme, still in the repo.
10459    fn seeded_at_uri_subscription() -> serde_json::Value {
10460        seeded_subscription_with_url(AT_URI_SUB)
10461    }
10462    /// An existing subscription record at `rk-keep` with the given URL.
10463    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10464        serde_json::json!({
10465            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10466            "cid": "bafyreibefore",
10467            "value": {
10468                "$type": "community.lexicon.rss.subscription",
10469                "url": url,
10470                "title": "Old title",
10471                "private": false,
10472                "createdAt": "2024-03-01T00:00:00.000Z"
10473            }
10474        })
10475    }
10476    const AT_URI_SUB: &str =
10477        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10478    const AT_URI_SUB_ENC: &str =
10479        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10480
10481    /// **Retitling an existing `at://` subscription must work with the flag off.**
10482    ///
10483    /// The storability guard was placed before the repo lookup, so it refused
10484    /// any rename whose URL is an at-URI — including a pure title or folder
10485    /// change on a record that already exists. On main that rename succeeded;
10486    /// the 19 production records would have become un-editable. The flag gates
10487    /// what may be STORED in the cache, not whether a reader may edit their own
10488    /// record: the PDS write goes through, the cache row is simply not created.
10489    #[tokio::test]
10490    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10491        let did = "did:plc:renamer5";
10492        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10493        let state = test_state_with_sidecar(&[did], &sidecar).await;
10494        assert!(
10495            !state.config.standard_site,
10496            "the flag must be off for this test"
10497        );
10498        let cookie = session_cookie(&state, did, None);
10499        let resp = router(state.clone())
10500            .oneshot(
10501                Request::builder()
10502                    .method("POST")
10503                    .uri("/subscriptions/rk-keep/rename")
10504                    .header(header::COOKIE, cookie)
10505                    .header("content-type", "application/x-www-form-urlencoded")
10506                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10507                    .unwrap(),
10508            )
10509            .await
10510            .unwrap();
10511        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10512        let loc = resp
10513            .headers()
10514            .get(header::LOCATION)
10515            .unwrap()
10516            .to_str()
10517            .unwrap();
10518        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10519
10520        let bodies = puts.lock().unwrap().clone();
10521        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10522        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10523        assert_eq!(
10524            sent["record"]["title"], "New title",
10525            "the rename did not apply"
10526        );
10527        assert_eq!(
10528            sent["record"]["url"], AT_URI_SUB,
10529            "the rename changed the URL"
10530        );
10531
10532        // The flag still means what it says for the CACHE: no at:// row.
10533        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10534        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10535    }
10536
10537    /// **Repointing a subscription AT an `at://` URI is still refused with the
10538    /// flag off** — the half of the guard that has to survive the fix above.
10539    /// Nothing reaches the PDS and nothing reaches the cache.
10540    #[tokio::test]
10541    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
10542        let did = "did:plc:renamer4";
10543        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10544        let state = test_state_with_sidecar(&[did], &sidecar).await;
10545        let cookie = session_cookie(&state, did, None);
10546        let resp = router(state.clone())
10547            .oneshot(
10548                Request::builder()
10549                    .method("POST")
10550                    .uri("/subscriptions/rk-keep/rename")
10551                    .header(header::COOKIE, cookie)
10552                    .header("content-type", "application/x-www-form-urlencoded")
10553                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10554                    .unwrap(),
10555            )
10556            .await
10557            .unwrap();
10558        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10559        let loc = resp
10560            .headers()
10561            .get(header::LOCATION)
10562            .unwrap()
10563            .to_str()
10564            .unwrap();
10565        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
10566        assert!(
10567            !loc.contains("Private"),
10568            "a storability refusal was reported as a privacy one: {loc}"
10569        );
10570        assert!(
10571            puts.lock().unwrap().is_empty(),
10572            "the repoint reached the PDS"
10573        );
10574        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10575        assert_eq!(cached, 0);
10576    }
10577
10578    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
10579    /// redirect location.
10580    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
10581        let cookie = session_cookie(state, did, None);
10582        let resp = router(state.clone())
10583            .oneshot(
10584                Request::builder()
10585                    .method("POST")
10586                    .uri("/subscriptions/rk-keep/rename")
10587                    .header(header::COOKIE, cookie)
10588                    .header("content-type", "application/x-www-form-urlencoded")
10589                    .body(Body::from(format!("url={url_enc}&title=New+title")))
10590                    .unwrap(),
10591            )
10592            .await
10593            .unwrap();
10594        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10595        resp.headers()
10596            .get(header::LOCATION)
10597            .unwrap()
10598            .to_str()
10599            .unwrap()
10600            .to_string()
10601    }
10602
10603    /// **The privacy gate has the same ordering bug the storable gate had.**
10604    ///
10605    /// Another client can write a subscription whose URL is an at-URI that is
10606    /// not a well-formed publication URI at all — a feed generator, say. On
10607    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
10608    /// the classifier reads as `Public`). The narrowed at:// arm now fails
10609    /// closed as `Private` for it, and the gate ran before `url_changed` was
10610    /// known — so the record became un-editable, with a flash claiming it "was
10611    /// not saved or sent anywhere". Both gates now apply to a repoint only.
10612    #[tokio::test]
10613    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
10614        let did = "did:plc:renamer5";
10615        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
10616        let other_enc =
10617            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
10618        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
10619        let state = test_state_with_sidecar(&[did], &sidecar).await;
10620        let loc = retitle_unchanged(&state, did, other_enc).await;
10621        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10622        let bodies = puts.lock().unwrap().clone();
10623        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10624        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10625        assert_eq!(sent["record"]["title"], "New title");
10626        assert_eq!(sent["record"]["url"], other);
10627    }
10628
10629    /// **A repoint to a secret-bearing URL is still refused** — the half of
10630    /// the privacy gate that has to survive moving it behind `url_changed`.
10631    /// Found by mutation: with the gate deleted outright, nothing failed.
10632    #[tokio::test]
10633    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
10634        let did = "did:plc:renamer4";
10635        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10636        let state = test_state_with_sidecar(&[did], &sidecar).await;
10637        let cookie = session_cookie(&state, did, None);
10638        let resp = router(state.clone())
10639            .oneshot(
10640                Request::builder()
10641                    .method("POST")
10642                    .uri("/subscriptions/rk-keep/rename")
10643                    .header(header::COOKIE, cookie)
10644                    .header("content-type", "application/x-www-form-urlencoded")
10645                    .body(Body::from(
10646                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
10647                    ))
10648                    .unwrap(),
10649            )
10650            .await
10651            .unwrap();
10652        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10653        let loc = resp
10654            .headers()
10655            .get(header::LOCATION)
10656            .unwrap()
10657            .to_str()
10658            .unwrap();
10659        assert!(
10660            loc.contains("Private"),
10661            "the private repoint was not refused: {loc}"
10662        );
10663        assert!(
10664            puts.lock().unwrap().is_empty(),
10665            "a secret-bearing URL reached the PDS"
10666        );
10667        // The repo's fixture token: opaque enough for the classifier, not a real
10668        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
10669        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
10670        assert!(store::get_feed_by_url(&state.db, leaked)
10671            .await
10672            .unwrap()
10673            .is_none());
10674    }
10675
10676    /// **A retitle of a never-cached at:// subscription is not "at feed
10677    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
10678    /// and an at:// record is never cached with the flag off — so at capacity,
10679    /// a pure retitle was refused for a row the handler would not insert. The
10680    /// check now runs once `url_changed` is known and only for a repoint.
10681    #[tokio::test]
10682    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
10683        let did = "did:plc:renamer5";
10684        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10685        // Ceiling 1, and one real feed already fills it.
10686        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10687        store::upsert_feed(
10688            &state.db,
10689            &store::NewFeed {
10690                url: "https://filler.example/feed.xml".to_string(),
10691                ..Default::default()
10692            },
10693        )
10694        .await
10695        .unwrap();
10696        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10697        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10698        assert_eq!(
10699            puts.lock().unwrap().len(),
10700            1,
10701            "the retitle did not reach the PDS"
10702        );
10703        assert_eq!(
10704            store::count_feeds(&state.db).await.unwrap(),
10705            1,
10706            "a row was inserted"
10707        );
10708    }
10709
10710    /// POST `/subscriptions` with `url`, returning the redirect target.
10711    async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
10712        let cookie = session_cookie(state, did, None);
10713        let resp = router(state.clone())
10714            .oneshot(
10715                Request::builder()
10716                    .method("POST")
10717                    .uri("/subscriptions")
10718                    .header(header::COOKIE, cookie)
10719                    .header("content-type", "application/x-www-form-urlencoded")
10720                    .body(Body::from(format!("url={url_enc}")))
10721                    .unwrap(),
10722            )
10723            .await
10724            .unwrap();
10725        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10726        resp.headers()
10727            .get(header::LOCATION)
10728            .unwrap()
10729            .to_str()
10730            .unwrap()
10731            .to_string()
10732    }
10733
10734    /// A `com.atproto.identity.resolveHandle` that answers `did` for anything.
10735    async fn serve_resolver(did: &str) -> String {
10736        let base = crate::net::tests::serve_body(
10737            serde_json::json!({ "did": did }).to_string().into_bytes(),
10738        )
10739        .await;
10740        let port: u16 = base
10741            .trim_end_matches('/')
10742            .rsplit(':')
10743            .next()
10744            .unwrap()
10745            .parse()
10746            .unwrap();
10747        let host = format!("resolver-{port}.test");
10748        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10749        format!("http://{host}:{port}")
10750    }
10751
10752    fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
10753        let mut config = (*state.config).clone();
10754        f(&mut config);
10755        state.config = std::sync::Arc::new(config);
10756        state
10757    }
10758
10759    /// **0.4.0 step 3: with the flag ON, a well-formed at:// paste is
10760    /// subscribed.** It was refused as unsupported while nothing could read a
10761    /// publication; the poller reads them now. Stored in DID form, as a
10762    /// `publication`, and written to the reader's PDS like any subscription.
10763    #[tokio::test]
10764    async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
10765        let did = "did:plc:renamer5";
10766        let (sidecar, log) = spawn_logging_sidecar().await;
10767        let state = with_config(
10768            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10769            |c| {
10770                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10771            },
10772        );
10773        let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
10774        assert_eq!(loc, "/", "the paste was refused: {loc}");
10775        let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
10776            .await
10777            .unwrap()
10778            .expect("no feed row");
10779        assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
10780        let sent = log.lock().unwrap().join("\n");
10781        assert!(
10782            sent.contains(AT_URI_SUB),
10783            "the subscription was not written to the PDS: {sent}"
10784        );
10785    }
10786
10787    /// **A0 through the form** — the 0.4.0 exit test, end to end: a reader
10788    /// pastes a publication, it is stored and written to their PDS, and the
10789    /// first poll — the one subscribing runs at once — stores its documents.
10790    #[tokio::test]
10791    async fn a0_subscribing_from_the_form_delivers_entries() {
10792        let did = "did:plc:renamer5";
10793        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10794        let site = AT_URI_SUB;
10795        let (plc, _) = crate::standard_site::tests::serve_repo(
10796            author,
10797            vec![
10798                (
10799                    lexicon::nsid::STANDARD_PUBLICATION,
10800                    "3lab2c4d5e6f7g8h",
10801                    serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
10802                ),
10803                (
10804                    lexicon::nsid::STANDARD_DOCUMENT,
10805                    "3l2a0frmaaa2a",
10806                    serde_json::json!({ "title": "From the form", "path": "/f",
10807                        "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
10808                ),
10809            ],
10810        )
10811        .await;
10812        let (sidecar, _log) = spawn_logging_sidecar().await;
10813        let state = with_config(
10814            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10815            |c| {
10816                c.oauth.plc_directory = plc;
10817            },
10818        );
10819        assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
10820        let row = store::get_feed_by_url(&state.db, site)
10821            .await
10822            .unwrap()
10823            .unwrap();
10824        let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
10825            .bind(row.id)
10826            .fetch_all(&state.db)
10827            .await
10828            .unwrap();
10829        assert_eq!(
10830            titles,
10831            vec!["From the form".to_string()],
10832            "the first poll stored nothing"
10833        );
10834        assert_eq!(row.title.as_deref(), Some("A0 Journal"));
10835    }
10836
10837    /// A handle-form paste is resolved to the DID before it is stored: a
10838    /// handle is a mutable name, and `feeds.url` is keyed on identity.
10839    #[tokio::test]
10840    async fn a_handle_form_paste_is_stored_by_its_did() {
10841        let did = "did:plc:renamer5";
10842        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10843        let (sidecar, _log) = spawn_logging_sidecar().await;
10844        let resolver = serve_resolver(author).await;
10845        let state = with_config(
10846            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10847            |c| {
10848                c.resolver_base = resolver;
10849                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10850            },
10851        );
10852        let loc = subscribe(
10853            &state,
10854            did,
10855            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10856        )
10857        .await;
10858        assert_eq!(loc, "/", "the paste was refused: {loc}");
10859        assert!(
10860            store::get_feed_by_url(&state.db, AT_URI_SUB)
10861                .await
10862                .unwrap()
10863                .is_some(),
10864            "not stored by its DID"
10865        );
10866        assert_eq!(
10867            store::count_feeds(&state.db).await.unwrap(),
10868            1,
10869            "the handle form was stored too"
10870        );
10871    }
10872
10873    /// A resolver answering `did` that counts how often it was asked.
10874    async fn serve_counting_resolver(
10875        did: &str,
10876    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10877        let (base, hits) = crate::net::tests::serve_body_counted(
10878            serde_json::json!({ "did": did }).to_string().into_bytes(),
10879        )
10880        .await;
10881        let port: u16 = base
10882            .trim_end_matches('/')
10883            .rsplit(':')
10884            .next()
10885            .unwrap()
10886            .parse()
10887            .unwrap();
10888        let host = format!("counting-resolver-{port}.test");
10889        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10890        (format!("http://{host}:{port}"), hits)
10891    }
10892
10893    /// Review of #230: the per-DID cap is documented as checked "BEFORE any
10894    /// fetch/resolve so an over-cap account can't even trigger an outbound
10895    /// request" — a handle paste resolved the handle first.
10896    #[tokio::test]
10897    async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10898        let did = "did:plc:renamer5";
10899        let (sidecar, _log) = spawn_logging_sidecar().await;
10900        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10901        let state = with_config(
10902            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10903            |c| {
10904                c.resolver_base = resolver;
10905                c.max_subs_per_did = 1;
10906            },
10907        );
10908        let feed_id = store::upsert_feed(
10909            &state.db,
10910            &store::NewFeed {
10911                url: "https://already.example/feed.xml".into(),
10912                ..Default::default()
10913            },
10914        )
10915        .await
10916        .unwrap();
10917        store::replace_sub_refs(&state.db, did, &[feed_id])
10918            .await
10919            .unwrap();
10920        let loc = subscribe(
10921            &state,
10922            did,
10923            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10924        )
10925        .await;
10926        assert!(
10927            loc.contains("Subscription%20limit"),
10928            "expected the cap flash: {loc}"
10929        );
10930        assert_eq!(
10931            hits.load(std::sync::atomic::Ordering::SeqCst),
10932            0,
10933            "an over-cap paste resolved a handle"
10934        );
10935    }
10936
10937    /// Review of #230: an authority that is neither a valid DID nor a valid
10938    /// handle — `did:plc:TOOSHORT`, an uppercase DID — went to the resolver as
10939    /// a "handle". It is unsupported, and asks nobody anything.
10940    #[tokio::test]
10941    async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10942        let did = "did:plc:renamer5";
10943        let (sidecar, _log) = spawn_logging_sidecar().await;
10944        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10945        let state = with_config(
10946            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10947            |c| {
10948                c.resolver_base = resolver;
10949            },
10950        );
10951        for authority in [
10952            "did%3Aplc%3ATOOSHORT",
10953            "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10954            "bad%0Ahandle.example",
10955        ] {
10956            let loc = subscribe(
10957                &state,
10958                did,
10959                &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10960            )
10961            .await;
10962            assert!(
10963                loc.contains("kind%20of%20feed"),
10964                "{authority}: expected the unsupported flash: {loc}"
10965            );
10966        }
10967        assert_eq!(
10968            hits.load(std::sync::atomic::Ordering::SeqCst),
10969            0,
10970            "a malformed authority reached the resolver"
10971        );
10972        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10973    }
10974
10975    /// A handle that does not resolve is refused, and nothing is stored.
10976    #[tokio::test]
10977    async fn an_unresolvable_handle_paste_is_refused() {
10978        let did = "did:plc:renamer5";
10979        let (sidecar, _log) = spawn_logging_sidecar().await;
10980        let state = with_config(
10981            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10982            |c| {
10983                c.resolver_base = "http://resolver.nowhere.invalid".into();
10984            },
10985        );
10986        let loc = subscribe(
10987            &state,
10988            did,
10989            "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10990        )
10991        .await;
10992        assert!(
10993            loc.contains("resolve%20the%20handle"),
10994            "expected the unresolvable-handle flash: {loc}"
10995        );
10996        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10997    }
10998
10999    /// An at:// URI that is not a publication is refused, flag on or off.
11000    #[tokio::test]
11001    async fn a_non_publication_at_uri_paste_is_refused() {
11002        let did = "did:plc:renamer5";
11003        let (sidecar, _log) = spawn_logging_sidecar().await;
11004        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11005        let loc = subscribe(
11006            &state,
11007            did,
11008            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
11009        )
11010        .await;
11011        assert!(
11012            loc.contains("kind%20of%20feed"),
11013            "expected the unsupported flash: {loc}"
11014        );
11015        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11016    }
11017
11018    /// A mixed-case scheme is canonicalised at input, not refused and not
11019    /// stored as a second spelling of the same publication.
11020    #[tokio::test]
11021    async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
11022        let did = "did:plc:renamer5";
11023        let (sidecar, _log) = spawn_logging_sidecar().await;
11024        let state = with_config(
11025            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11026            |c| {
11027                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11028            },
11029        );
11030        let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11031        assert_eq!(loc, "/", "the paste was refused: {loc}");
11032        assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11033            .await
11034            .unwrap()
11035            .is_some());
11036    }
11037
11038    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
11039    /// path that is meant to work today, asserted with the flag actually on.
11040    #[tokio::test]
11041    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11042        let did = "did:plc:renamer5";
11043        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11044        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11045        let opml = format!(
11046            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11047             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11048             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11049             </body></opml>"
11050        );
11051        let (ct, body) = opml_multipart(opml.as_bytes());
11052        let cookie = session_cookie(&state, did, None);
11053        let resp = router(state.clone())
11054            .oneshot(
11055                Request::builder()
11056                    .method("POST")
11057                    .uri("/opml")
11058                    .header(header::COOKIE, cookie)
11059                    .header("content-type", ct)
11060                    .body(Body::from(body))
11061                    .unwrap(),
11062            )
11063            .await
11064            .unwrap();
11065        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11066        let loc = resp
11067            .headers()
11068            .get(header::LOCATION)
11069            .unwrap()
11070            .to_str()
11071            .unwrap();
11072        assert!(
11073            loc.contains("Imported%202%20feeds"),
11074            "unexpected flash: {loc}"
11075        );
11076        assert!(
11077            !loc.contains("skipped"),
11078            "the at:// entry was skipped with the flag on: {loc}"
11079        );
11080        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11081        assert!(
11082            stored.is_some(),
11083            "the at:// entry was not stored with the flag on"
11084        );
11085    }
11086
11087    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
11088    /// gate behind `url_changed` was right for the PDS write — the record is
11089    /// the reader's — but the cache write was gated only on `storable`, which
11090    /// any http(s) URL is. So a retitle of a record another client wrote with
11091    /// a tokened feed URL inserted that URL into the shared `feeds` table,
11092    /// where the poller would fail it every cycle and print it on the admin
11093    /// page. main refused the whole rename; this keeps the record editable and
11094    /// the cache clean, as `resolve_subscriptions` already does for the same
11095    /// record.
11096    #[tokio::test]
11097    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11098        let did = "did:plc:renamer5";
11099        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11100        let tokened_enc =
11101            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11102        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11103        let state = test_state_with_sidecar(&[did], &sidecar).await;
11104        let loc = retitle_unchanged(&state, did, tokened_enc).await;
11105        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11106        assert_eq!(
11107            puts.lock().unwrap().len(),
11108            1,
11109            "the retitle did not reach the PDS"
11110        );
11111        assert!(
11112            store::get_feed_by_url(&state.db, tokened)
11113                .await
11114                .unwrap()
11115                .is_none(),
11116            "a secret-bearing URL was written to the shared cache by a retitle"
11117        );
11118    }
11119
11120    /// **On a repoint, storability is decided before privacy and capacity** —
11121    /// the same ordering the add path got. A malformed at:// target drew the
11122    /// private/paid flash, and at capacity a well-formed one drew "try again
11123    /// later" for a URL that can never be accepted with the flag off.
11124    #[tokio::test]
11125    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11126        let did = "did:plc:renamer4";
11127        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11128        let state = test_state_with_sidecar(&[did], &sidecar).await;
11129        let cookie = session_cookie(&state, did, None);
11130        let resp = router(state.clone())
11131            .oneshot(
11132                Request::builder()
11133                    .method("POST")
11134                    .uri("/subscriptions/rk-keep/rename")
11135                    .header(header::COOKIE, cookie)
11136                    .header("content-type", "application/x-www-form-urlencoded")
11137                    .body(Body::from(
11138                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11139                    ))
11140                    .unwrap(),
11141            )
11142            .await
11143            .unwrap();
11144        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11145        let loc = resp
11146            .headers()
11147            .get(header::LOCATION)
11148            .unwrap()
11149            .to_str()
11150            .unwrap();
11151        assert!(
11152            loc.contains("kind%20of%20feed"),
11153            "expected the unsupported flash: {loc}"
11154        );
11155        assert!(
11156            !loc.contains("Private"),
11157            "a typo was reported as a paid feed: {loc}"
11158        );
11159        assert!(puts.lock().unwrap().is_empty());
11160    }
11161
11162    #[tokio::test]
11163    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11164        let did = "did:plc:renamer4";
11165        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11166        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11167        store::upsert_feed(
11168            &state.db,
11169            &store::NewFeed {
11170                url: "https://filler.example/feed.xml".to_string(),
11171                ..Default::default()
11172            },
11173        )
11174        .await
11175        .unwrap();
11176        let cookie = session_cookie(&state, did, None);
11177        let resp = router(state.clone())
11178            .oneshot(
11179                Request::builder()
11180                    .method("POST")
11181                    .uri("/subscriptions/rk-keep/rename")
11182                    .header(header::COOKIE, cookie)
11183                    .header("content-type", "application/x-www-form-urlencoded")
11184                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11185                    .unwrap(),
11186            )
11187            .await
11188            .unwrap();
11189        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11190        let loc = resp
11191            .headers()
11192            .get(header::LOCATION)
11193            .unwrap()
11194            .to_str()
11195            .unwrap();
11196        assert!(
11197            loc.contains("kind%20of%20feed"),
11198            "expected the unsupported flash: {loc}"
11199        );
11200        assert!(
11201            !loc.contains("capacity"),
11202            "an unacceptable URL was reported as a capacity problem: {loc}"
11203        );
11204        assert!(puts.lock().unwrap().is_empty());
11205    }
11206
11207    /// **`url_changed` compares like for like.** The form value is trimmed;
11208    /// the record's URL was compared raw, so a record another client wrote
11209    /// with a trailing space read as a repoint on every retitle and re-armed
11210    /// every gate — including the one that made an at:// record un-editable.
11211    #[tokio::test]
11212    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11213        let did = "did:plc:renamer5";
11214        let padded = format!("{AT_URI_SUB} ");
11215        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11216        let state = test_state_with_sidecar(&[did], &sidecar).await;
11217        // The manage row posts the record's URL verbatim, padding included.
11218        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11219        assert_eq!(
11220            loc, "/",
11221            "the retitle was treated as a repoint and refused: {loc}"
11222        );
11223        let bodies = puts.lock().unwrap().clone();
11224        assert_eq!(bodies.len(), 1);
11225        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11226        assert_eq!(
11227            sent["record"]["url"], AT_URI_SUB,
11228            "the padding was not normalised away"
11229        );
11230    }
11231
11232    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
11233    /// only, so the trailing upsert must not create a row for an unchanged URL
11234    /// that has none — with the flag on and the cache full, each retitle of a
11235    /// never-cached at:// record was a row past the cap. An existing row still
11236    /// gets its title kept in step.
11237    #[tokio::test]
11238    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11239        let did = "did:plc:renamer5";
11240        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11241        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11242        store::upsert_feed(
11243            &state.db,
11244            &store::NewFeed {
11245                url: "https://filler.example/feed.xml".to_string(),
11246                ..Default::default()
11247            },
11248        )
11249        .await
11250        .unwrap();
11251        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11252        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11253        assert_eq!(puts.lock().unwrap().len(), 1);
11254        assert_eq!(
11255            store::count_feeds(&state.db).await.unwrap(),
11256            1,
11257            "a retitle inserted a cache row past the ceiling"
11258        );
11259    }
11260
11261    /// **The add path's at:// pre-check is about the MESSAGE, so it is
11262    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
11263    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
11264    /// tripped the secret heuristic on the rkey — the private/paid flash the
11265    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
11266    /// touch it.
11267    #[tokio::test]
11268    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11269        let did = "did:plc:typoist";
11270        let state = test_state_with_caps(did, 0, 0).await;
11271        let cookie = session_cookie(&state, did, None);
11272        let resp = router(state.clone())
11273            .oneshot(
11274                Request::builder()
11275                    .method("POST")
11276                    .uri("/subscriptions")
11277                    .header(header::COOKIE, cookie)
11278                    .header("content-type", "application/x-www-form-urlencoded")
11279                    .body(Body::from(
11280                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11281                    ))
11282                    .unwrap(),
11283            )
11284            .await
11285            .unwrap();
11286        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11287        let loc = resp
11288            .headers()
11289            .get(header::LOCATION)
11290            .unwrap()
11291            .to_str()
11292            .unwrap();
11293        assert!(
11294            loc.contains("kind%20of%20feed"),
11295            "expected the unsupported flash: {loc}"
11296        );
11297        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11298    }
11299
11300    /// **A rename must not destroy the fields the form never carries.**
11301    ///
11302    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
11303    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
11304    /// every field absent from `templates/manage_row.html` (which posts only
11305    /// `url`, `title`, `folder`) was written back as its default:
11306    ///
11307    /// | field | before | after |
11308    /// |---|---|---|
11309    /// | `siteUrl` | whatever the feed advertised | gone |
11310    /// | `fetchHint` | as set | gone |
11311    /// | `private` | as set | gone |
11312    /// | `createdAt` | original subscribe time | reset to now |
11313    ///
11314    /// `createdAt` is the worst of the four: it is the sort key for "when did I
11315    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
11316    /// tells the reader it moved.
11317    ///
11318    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
11319    /// in the test — the record only becomes wrong on the way out, so checking
11320    /// the value we passed in would pass just as happily with the fix removed.
11321    #[tokio::test]
11322    async fn renaming_preserves_the_fields_the_form_never_carries() {
11323        let did = "did:plc:renamer4";
11324        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11325        let state = test_state_with_sidecar(&[did], &sidecar).await;
11326        let cookie = session_cookie(&state, did, None);
11327
11328        let resp = router(state.clone())
11329            .oneshot(
11330                Request::builder()
11331                    .method("POST")
11332                    .uri("/subscriptions/rk-keep/rename")
11333                    .header(header::COOKIE, cookie)
11334                    .header("content-type", "application/x-www-form-urlencoded")
11335                    // Exactly what the manage row posts: url, title, folder.
11336                    .body(Body::from(
11337                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11338                    ))
11339                    .unwrap(),
11340            )
11341            .await
11342            .unwrap();
11343        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11344
11345        let bodies = puts.lock().unwrap().clone();
11346        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11347        let body = &bodies[0];
11348        // Anchors the negative assertions: an empty capture would satisfy them.
11349        assert!(
11350            body.contains("community.lexicon.rss.subscription"),
11351            "captured no usable put body: {body:?}"
11352        );
11353
11354        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
11355        let record = &sent["record"];
11356
11357        // What the form DID carry must be applied.
11358        assert_eq!(record["title"], "New title", "the rename did not apply");
11359        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
11360
11361        // What the form did NOT carry must survive.
11362        assert_eq!(
11363            record["createdAt"], "2024-03-01T00:00:00.000Z",
11364            "the rename reset createdAt — the reader's subscribe time is gone \
11365             from their own repo, and nothing told them"
11366        );
11367        assert_eq!(
11368            record["siteUrl"], "https://example.com/blog",
11369            "the rename erased siteUrl"
11370        );
11371        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
11372        assert_eq!(record["private"], false, "the rename erased private");
11373    }
11374
11375    /// **Repointing at a different feed drops that feed's properties, but not
11376    /// the subscription's.**
11377    ///
11378    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
11379    /// so carrying them onto a different URL would leave a site link for the old
11380    /// feed hanging off the new one. `createdAt` and `private` are properties of
11381    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
11382    /// subscribed, whatever the URL was later corrected to.
11383    #[tokio::test]
11384    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
11385        let did = "did:plc:renamer4";
11386        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11387        let state = test_state_with_sidecar(&[did], &sidecar).await;
11388        let cookie = session_cookie(&state, did, None);
11389
11390        let resp = router(state.clone())
11391            .oneshot(
11392                Request::builder()
11393                    .method("POST")
11394                    .uri("/subscriptions/rk-keep/rename")
11395                    .header(header::COOKIE, cookie)
11396                    .header("content-type", "application/x-www-form-urlencoded")
11397                    // A DIFFERENT feed URL from the seeded record.
11398                    .body(Body::from(
11399                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
11400                    ))
11401                    .unwrap(),
11402            )
11403            .await
11404            .unwrap();
11405        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11406
11407        let bodies = puts.lock().unwrap().clone();
11408        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11409        assert!(
11410            bodies[0].contains("community.lexicon.rss.subscription"),
11411            "captured no usable put body: {:?}",
11412            bodies[0]
11413        );
11414        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11415        let record = &sent["record"];
11416
11417        assert_eq!(record["url"], "https://other.example/feed.xml");
11418        // The old feed's properties are gone rather than misattributed.
11419        assert!(
11420            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
11421            "the old feed's site link followed the subscription to a new feed: {record}"
11422        );
11423        assert!(
11424            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
11425            "the old feed's fetch hint followed the subscription to a new feed: {record}"
11426        );
11427        // The subscription's own properties survive.
11428        assert_eq!(
11429            record["createdAt"], "2024-03-01T00:00:00.000Z",
11430            "a repoint is still not a new subscription; createdAt must not move"
11431        );
11432        assert_eq!(record["private"], false, "the repoint erased private");
11433    }
11434
11435    /// **A rename against an rkey that is not in the repo writes NOTHING.**
11436    ///
11437    /// `update_subscription` is a `putRecord`, which CREATES the record when the
11438    /// rkey does not exist — with whatever `createdAt` we hand it. So without
11439    /// this refusal a rename against a stale or wrong rkey manufactures a
11440    /// subscription dated today, which is the bug this whole change exists to
11441    /// fix, arriving by a different door.
11442    ///
11443    /// The guard was untested when first written: removing it left all 733 tests
11444    /// green. An untested guard against the exact defect being fixed is how the
11445    /// two previous rounds of this problem got through.
11446    #[tokio::test]
11447    async fn renaming_an_unknown_rkey_writes_nothing() {
11448        let did = "did:plc:renamer4";
11449        // The sidecar serves exactly one record, at rkey `rk-keep`.
11450        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11451        let state = test_state_with_sidecar(&[did], &sidecar).await;
11452        let cookie = session_cookie(&state, did, None);
11453
11454        let resp = router(state.clone())
11455            .oneshot(
11456                Request::builder()
11457                    .method("POST")
11458                    // ...and this is not it.
11459                    .uri("/subscriptions/rk-does-not-exist/rename")
11460                    .header(header::COOKIE, cookie)
11461                    .header("content-type", "application/x-www-form-urlencoded")
11462                    .body(Body::from(
11463                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
11464                    ))
11465                    .unwrap(),
11466            )
11467            .await
11468            .unwrap();
11469
11470        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11471        let loc = resp
11472            .headers()
11473            .get(header::LOCATION)
11474            .unwrap()
11475            .to_str()
11476            .unwrap();
11477        assert!(
11478            loc.contains("flash="),
11479            "an unknown rkey redirected as though the rename had worked: {loc}"
11480        );
11481        assert!(
11482            puts.lock().unwrap().is_empty(),
11483            "a rename against an unknown rkey wrote a record — putRecord would \
11484             CREATE it, dated today: {:?}",
11485            puts.lock().unwrap()
11486        );
11487    }
11488
11489    /// **A `site_url` the client actually sends is applied, not dropped.**
11490    ///
11491    /// `templates/manage_row.html` does not post this field, so it is tempting
11492    /// to read the arm that handles it as dead code. It is not:
11493    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
11494    /// today. Discarding the value instead of applying it left all 733 tests
11495    /// green.
11496    ///
11497    /// The value is scheme-checked on the way out by the repo-boundary vet, so
11498    /// this is a coverage gap rather than an exposure — but an untested path
11499    /// that writes a URL into the reader's PDS should not stay untested.
11500    #[tokio::test]
11501    async fn a_client_supplied_site_url_reaches_the_record() {
11502        let did = "did:plc:renamer4";
11503        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11504        let state = test_state_with_sidecar(&[did], &sidecar).await;
11505        let cookie = session_cookie(&state, did, None);
11506
11507        let resp = router(state.clone())
11508            .oneshot(
11509                Request::builder()
11510                    .method("POST")
11511                    .uri("/subscriptions/rk-keep/rename")
11512                    .header(header::COOKIE, cookie)
11513                    .header("content-type", "application/x-www-form-urlencoded")
11514                    // Same feed URL, but carrying a site_url the manage row
11515                    // never sends.
11516                    .body(Body::from(
11517                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
11518                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
11519                    ))
11520                    .unwrap(),
11521            )
11522            .await
11523            .unwrap();
11524        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11525
11526        let bodies = puts.lock().unwrap().clone();
11527        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11528        assert!(
11529            bodies[0].contains("community.lexicon.rss.subscription"),
11530            "captured no usable put body: {:?}",
11531            bodies[0]
11532        );
11533        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11534        assert_eq!(
11535            sent["record"]["siteUrl"], "https://typed.example/site",
11536            "the client's siteUrl was dropped; the seeded record's survived instead"
11537        );
11538    }
11539
11540    /// **A rename whose read fails writes NOTHING.**
11541    ///
11542    /// This is the property most easily lost when someone later touches this
11543    /// handler: falling back to `Subscription::new` on a read error looks like
11544    /// graceful degradation and is in fact the original bug, reinstated on
11545    /// exactly the path where it is hardest to notice. The reader must be told
11546    /// instead.
11547    #[tokio::test]
11548    async fn a_rename_whose_read_fails_writes_nothing() {
11549        let did = "did:plc:renamer5";
11550        // A port that accepts nothing: the read cannot succeed.
11551        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11552        let dead = format!("http://{}", listener.local_addr().unwrap());
11553        drop(listener);
11554
11555        let state = test_state_with_sidecar(&[did], &dead).await;
11556        let cookie = session_cookie(&state, did, None);
11557        let before = store::count_feeds(&state.db).await.unwrap();
11558
11559        let resp = router(state.clone())
11560            .oneshot(
11561                Request::builder()
11562                    .method("POST")
11563                    .uri("/subscriptions/rk-keep/rename")
11564                    .header(header::COOKIE, cookie)
11565                    .header("content-type", "application/x-www-form-urlencoded")
11566                    .body(Body::from(
11567                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
11568                    ))
11569                    .unwrap(),
11570            )
11571            .await
11572            .unwrap();
11573
11574        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11575        let loc = resp
11576            .headers()
11577            .get(header::LOCATION)
11578            .unwrap()
11579            .to_str()
11580            .unwrap();
11581        assert!(
11582            loc.contains("flash="),
11583            "a failed read redirected as though the rename had worked: {loc}"
11584        );
11585        assert_eq!(
11586            store::count_feeds(&state.db).await.unwrap(),
11587            before,
11588            "a rename that could not read the record still wrote to the cache"
11589        );
11590    }
11591
11592    /// Folder pre-selection regression: the manage rename row must mark the
11593    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
11594    /// re-submits the current folder instead of silently un-foldering the feed.
11595    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
11596    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
11597    #[test]
11598    fn manage_rename_row_preselects_current_folder() {
11599        let nav = Nav {
11600            handle: "@reader.example".to_string(),
11601            avatar: "RE".to_string(),
11602            view: "unread".to_string(),
11603            scope_qs: String::new(),
11604            folders: Vec::new(),
11605            loose_feeds: Vec::new(),
11606            manage_active: true,
11607        };
11608        let folder_options = vec![
11609            FolderOption {
11610                uri: "at://did:plc:x/app.folder/work".to_string(),
11611                name: "Work".to_string(),
11612            },
11613            FolderOption {
11614                uri: "at://did:plc:x/app.folder/fun".to_string(),
11615                name: "Fun".to_string(),
11616            },
11617        ];
11618        // A foldered feed (in "Work") and a loose feed (no folder), each with a
11619        // non-empty rkey so the rename form renders.
11620        let foldered = FeedView {
11621            rkey: "sub-foldered".to_string(),
11622            url: "https://work.example/feed.xml".to_string(),
11623            title: "Work Feed".to_string(),
11624            unread: 0,
11625            selected: false,
11626            folder: Some("at://did:plc:x/app.folder/work".to_string()),
11627        };
11628        let loose = FeedView {
11629            rkey: "sub-loose".to_string(),
11630            url: "https://loose.example/feed.xml".to_string(),
11631            title: "Loose Feed".to_string(),
11632            unread: 0,
11633            selected: false,
11634            folder: None,
11635        };
11636        let tmpl = ManageTemplate {
11637            card: Card::private(&Config::default()),
11638            version: VERSION,
11639            repo_url: REPO_URL,
11640            kofi_url: KOFI_URL,
11641            flash: String::new(),
11642            alert: String::new(),
11643            nav,
11644            folder_options,
11645            folders: vec![FolderView {
11646                rkey: "folder-work".to_string(),
11647                uri: "at://did:plc:x/app.folder/work".to_string(),
11648                name: "Work".to_string(),
11649                feeds: vec![foldered],
11650                selected: false,
11651            }],
11652            loose_feeds: vec![loose],
11653            standard_site: false,
11654        };
11655        let html = tmpl.render().unwrap();
11656
11657        // The foldered feed's "Work" option is pre-selected.
11658        assert!(
11659            html.contains(
11660                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
11661            ),
11662            "foldered feed must pre-select its current folder: {html}"
11663        );
11664        // The loose feed's "No folder" option is pre-selected (appears for the
11665        // loose row, which has folder=None).
11666        assert!(
11667            html.contains(r#"<option value="" selected>No folder</option>"#),
11668            "loose feed must pre-select 'No folder': {html}"
11669        );
11670    }
11671
11672    /// **The public stats page carries no user data.**
11673    ///
11674    /// It is reachable by anyone, so the thing worth pinning is what it does
11675    /// NOT say: nothing about how many people use the instance, nothing about
11676    /// which feeds fail, nothing about who reads what.
11677    #[tokio::test]
11678    async fn the_public_stats_page_exposes_no_user_data() {
11679        let state = test_state(&[]).await;
11680        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
11681            .await
11682            .unwrap();
11683
11684        let resp = router(state)
11685            .oneshot(
11686                Request::builder()
11687                    .uri("/stats")
11688                    .body(Body::empty())
11689                    .unwrap(),
11690            )
11691            .await
11692            .unwrap();
11693        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
11694
11695        let body = String::from_utf8(
11696            axum::body::to_bytes(resp.into_body(), usize::MAX)
11697                .await
11698                .unwrap()
11699                .to_vec(),
11700        )
11701        .unwrap();
11702
11703        // Structural checks, not word checks. The page's own prose says it
11704        // publishes no error rates, so searching for that PHRASE finds the
11705        // disclaimer rather than a leak — the first version of this test failed
11706        // on exactly that. What matters is whether identifiers or the
11707        // admin-only figures are present.
11708        assert!(
11709            !body.contains("did:"),
11710            "the public stats page leaked an identifier"
11711        );
11712        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
11713            assert!(
11714                !body.contains(admin_only),
11715                "the public page is showing the admin metrics column {admin_only:?}"
11716            );
11717        }
11718        // And it does render the aggregate it exists for.
11719        assert!(body.contains("Feeds tracked"));
11720        assert!(body.contains("Waiting to be polled"));
11721    }
11722
11723    /// **The two states that stop feeds updating must be visible.**
11724    ///
11725    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
11726    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
11727    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
11728    /// the backlog and makes the page read healthier. That inversion is what this
11729    /// test pins: a broken feed must raise a number, not lower one.
11730    #[tokio::test]
11731    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
11732        let state = test_state(&[]).await;
11733        // Three feeds: one healthy, one flaky, one long dead.
11734        for (url, errors) in [
11735            ("https://ok.example/f.xml", 0),
11736            ("https://flaky.example/f.xml", 2),
11737            ("https://dead.example/f.xml", 9),
11738        ] {
11739            store::upsert_feed(
11740                &state.db,
11741                &store::NewFeed {
11742                    url: url.to_string(),
11743                    // Pushed forward, exactly as backoff does — so none of these
11744                    // are counted as `overdue`.
11745                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11746                    ..Default::default()
11747                },
11748            )
11749            .await
11750            .unwrap();
11751            for _ in 0..errors {
11752                store::bump_feed_errors(
11753                    &state.db,
11754                    url,
11755                    feed::FailureKind::Fetch,
11756                    "connection refused",
11757                )
11758                .await
11759                .unwrap();
11760            }
11761        }
11762
11763        let render_stats = |state: AppState| async move {
11764            let resp = router(state)
11765                .oneshot(
11766                    Request::builder()
11767                        .uri("/stats")
11768                        .body(Body::empty())
11769                        .unwrap(),
11770                )
11771                .await
11772                .unwrap();
11773            assert_eq!(resp.status(), StatusCode::OK);
11774            String::from_utf8(
11775                axum::body::to_bytes(resp.into_body(), usize::MAX)
11776                    .await
11777                    .unwrap()
11778                    .to_vec(),
11779            )
11780            .unwrap()
11781        };
11782
11783        // **The fixture must actually be RUNNING, or this test measures nothing.**
11784        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
11785        // checks that BEFORE the watermark — so without these two lines every
11786        // render below reports "off" and the watermark can never surface. The
11787        // assertions still passed, for reasons unrelated to what they name: see
11788        // the two comments below.
11789        state.runtime_health.set_schedulers_enabled(true);
11790        state
11791            .runtime_health
11792            .poll_tick_completed(crate::store::now_unix());
11793
11794        let body = render_stats(state.clone()).await;
11795        assert!(
11796            body.contains("Failing"),
11797            "backoff is still invisible on the public page"
11798        );
11799        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
11800        // value rather than on surrounding whitespace, so re-indenting the
11801        // template cannot break this.
11802        assert!(
11803            body.contains("2, 1 badly"),
11804            "expected '2, 1 badly' in the failing row; got:\n{}",
11805            body.split("Failing")
11806                .nth(1)
11807                .unwrap_or("")
11808                .chars()
11809                .take(300)
11810                .collect::<String>()
11811        );
11812        // Not paused, and the backlog is genuinely empty — which is exactly the
11813        // reading that used to be indistinguishable from healthy.
11814        //
11815        // **Asserted by EXCLUDING the other states, not by matching "running".**
11816        // The `off` row reads "the poller is not running on this instance", which
11817        // contains "running" — so the bare substring passed while the page was
11818        // reporting the exact opposite of what this line claims to check.
11819        assert!(
11820            !body.contains("the poller is not running")
11821                && !body.contains("the cache is at its size limit")
11822                && !body.contains("has not completed a round"),
11823            "expected the running state; the page reported a stopped one",
11824        );
11825
11826        // Now trip the watermark. Nothing in the database changes; only the
11827        // recorded runtime state does — which is the whole reason it needed a
11828        // home outside the log stream.
11829        state.runtime_health.set_watermark(true);
11830        let paused = render_stats(state.clone()).await;
11831        // Matched on the paused row's OWN sentence. The bare word "paused" also
11832        // appeared in the page's explanatory prose, so this assertion passed
11833        // whether or not the row rendered — and trimming that prose is what
11834        // exposed it. This phrase exists only inside the `paused` branch.
11835        assert!(
11836            paused.contains("the cache is at its size limit"),
11837            "a watermark pause is still invisible on the public page"
11838        );
11839
11840        // Still no identifiers: these are counts, not feeds.
11841        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
11842            assert!(
11843                !paused.contains(leak),
11844                "the public page leaked {leak:?} while reporting failures"
11845            );
11846        }
11847    }
11848
11849    /// **`/admin/metrics` is gated, and nothing checked that it was.**
11850    ///
11851    /// Deleting the `admin_seed_dids` check left the entire suite green. That
11852    /// was survivable while the page held only aggregate timings; it is not now,
11853    /// because this branch puts **per-feed URLs and remote error text** behind
11854    /// that gate. A guarantee nothing checks is a comment, and this one is now
11855    /// the only thing standing between a signed-in stranger and the operational
11856    /// picture the handler's own doc says is not public.
11857    ///
11858    /// All three doors: no session, a session that is not an admin, and the
11859    /// admin itself.
11860    #[tokio::test]
11861    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11862        let admin = "did:plc:adminseed";
11863        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
11864        // IS that list — deliberately, per its doc: "the same people I trust on
11865        // this instance". Production sets it to the bootstrap DID alone.
11866        //
11867        // A genuine non-admin is therefore someone holding a beta seat granted
11868        // by an invite, not by the allow-list. Seeding both would have made
11869        // both admins and quietly turned the 403 assertion below into a test of
11870        // nothing — which is exactly what the first draft of this did.
11871        let state = test_state(&[admin]).await;
11872        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11873            .await
11874            .unwrap();
11875        let url = "https://broken.example/f.xml";
11876        store::upsert_feed(
11877            &state.db,
11878            &store::NewFeed {
11879                url: url.to_string(),
11880                ..Default::default()
11881            },
11882        )
11883        .await
11884        .unwrap();
11885        store::bump_feed_errors(
11886            &state.db,
11887            url,
11888            feed::FailureKind::Fetch,
11889            "SENTINEL_ADMIN_ONLY",
11890        )
11891        .await
11892        .unwrap();
11893
11894        let get = |state: AppState, cookie: Option<String>| async move {
11895            let mut req = Request::builder().uri("/admin/metrics");
11896            if let Some(c) = cookie {
11897                req = req.header(header::COOKIE, c);
11898            }
11899            let resp = router(state)
11900                .oneshot(req.body(Body::empty()).unwrap())
11901                .await
11902                .unwrap();
11903            let status = resp.status();
11904            let body = String::from_utf8(
11905                axum::body::to_bytes(resp.into_body(), usize::MAX)
11906                    .await
11907                    .unwrap()
11908                    .to_vec(),
11909            )
11910            .unwrap();
11911            (status, body)
11912        };
11913
11914        // No session at all.
11915        let (status, body) = get(state.clone(), None).await;
11916        assert_eq!(status, StatusCode::UNAUTHORIZED);
11917        assert!(
11918            !body.contains("SENTINEL_ADMIN_ONLY"),
11919            "leaked to anonymous: {body}"
11920        );
11921
11922        // A real, signed-in user who is not an admin.
11923        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11924        let (status, body) = get(state.clone(), Some(ordinary)).await;
11925        assert_eq!(
11926            status,
11927            StatusCode::FORBIDDEN,
11928            "a non-admin session was let in"
11929        );
11930        assert!(
11931            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11932            "leaked to a non-admin: {body}",
11933        );
11934
11935        // The admin does get it — otherwise the two refusals above are
11936        // satisfied by the endpoint being broken for everyone.
11937        let admin_cookie = session_cookie(&state, admin, None);
11938        let (status, body) = get(state, Some(admin_cookie)).await;
11939        assert_eq!(status, StatusCode::OK);
11940        assert!(
11941            body.contains("SENTINEL_ADMIN_ONLY"),
11942            "admin cannot see it: {body}"
11943        );
11944    }
11945
11946    /// **The cause a public count cannot carry belongs on the admin page.**
11947    ///
11948    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
11949    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
11950    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
11951    /// have separated "sixty dead publishers" from "one bug here", which is the
11952    /// case it was justified by.
11953    ///
11954    /// The answer is not a finer public vocabulary — `/stats` promises never
11955    /// which feed and never whose, and a bucket per error string would break
11956    /// that. It is to put the detail where per-feed data is already allowed.
11957    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
11958    /// operational picture.
11959    ///
11960    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
11961    /// public one.
11962    #[tokio::test]
11963    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11964        let admin = "did:plc:adminseed";
11965        let state = test_state(&[admin]).await;
11966        let url = "https://broken.example/f.xml";
11967        store::upsert_feed(
11968            &state.db,
11969            &store::NewFeed {
11970                url: url.to_string(),
11971                ..Default::default()
11972            },
11973        )
11974        .await
11975        .unwrap();
11976        store::bump_feed_errors(
11977            &state.db,
11978            url,
11979            feed::FailureKind::Fetch,
11980            "SENTINEL_REDIRECT_NO_LOCATION",
11981        )
11982        .await
11983        .unwrap();
11984
11985        let cookie = session_cookie(&state, admin, None);
11986        let resp = router(state.clone())
11987            .oneshot(
11988                Request::builder()
11989                    .uri("/admin/metrics")
11990                    .header(header::COOKIE, cookie)
11991                    .body(Body::empty())
11992                    .unwrap(),
11993            )
11994            .await
11995            .unwrap();
11996        assert_eq!(resp.status(), StatusCode::OK);
11997        let admin_body = String::from_utf8(
11998            axum::body::to_bytes(resp.into_body(), usize::MAX)
11999                .await
12000                .unwrap()
12001                .to_vec(),
12002        )
12003        .unwrap();
12004        assert!(
12005            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
12006            "the admin page does not carry the failure detail: {admin_body}",
12007        );
12008        assert!(
12009            admin_body.contains("broken.example"),
12010            "the admin page does not name the failing feed: {admin_body}",
12011        );
12012
12013        // The public page still carries neither.
12014        let resp = router(state)
12015            .oneshot(
12016                Request::builder()
12017                    .uri("/stats")
12018                    .body(Body::empty())
12019                    .unwrap(),
12020            )
12021            .await
12022            .unwrap();
12023        let public = String::from_utf8(
12024            axum::body::to_bytes(resp.into_body(), usize::MAX)
12025                .await
12026                .unwrap()
12027                .to_vec(),
12028        )
12029        .unwrap();
12030        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
12031            assert!(
12032                !public.contains(secret),
12033                "{secret:?} reached the PUBLIC stats page: {public}",
12034            );
12035        }
12036    }
12037
12038    /// **A direct poll must settle the error columns, like the scheduler does.**
12039    ///
12040    /// `add_subscription` polls through `feed::poll_feed` rather than the
12041    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
12042    /// touches `consecutive_errors` — that is the scheduler's job, and this path
12043    /// is not the scheduler.
12044    ///
12045    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
12046    /// its old count and its old cause: the public page went on reporting it
12047    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
12048    /// the stale backoff horizon lasted — up to 24h — while the reader was
12049    /// demonstrably fetching it.
12050    #[tokio::test]
12051    async fn a_successful_direct_poll_clears_a_stale_failure() {
12052        let state = test_state(&[]).await;
12053        let url = "https://recovered.example/f.xml";
12054        store::upsert_feed(
12055            &state.db,
12056            &store::NewFeed {
12057                url: url.to_string(),
12058                ..Default::default()
12059            },
12060        )
12061        .await
12062        .unwrap();
12063        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
12064            .await
12065            .unwrap();
12066        // Park it on a stale backoff horizon, as a real failing feed would be.
12067        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
12068            .bind(url)
12069            .execute(&state.db)
12070            .await
12071            .unwrap();
12072
12073        // The publisher is fixed: a successful poll happens on this path.
12074        feed::settle_poll(
12075            &state.db,
12076            url,
12077            &feed::PollOutcome::NotModified,
12078            state.config.poll_interval,
12079        )
12080        .await;
12081
12082        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12083            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12084        )
12085        .bind(url)
12086        .fetch_one(&state.db)
12087        .await
12088        .unwrap();
12089        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
12090        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
12091        // **The half the first fix missed.** Clearing the count fixed the
12092        // REPORTING; the feed stayed parked until 2099. A working feed must be
12093        // rescheduled on its normal cadence, not left on the failure horizon.
12094        let next = row.2.expect("next_poll was cleared to NULL");
12095        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
12096        // backoff. A mutation that reschedules successes with backoff_for(1)
12097        // (5 min) also moves it off 2099, so the interval is asserted.
12098        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12099        let delta = parsed
12100            .signed_duration_since(chrono::Utc::now())
12101            .num_seconds();
12102        let cadence = state.config.poll_interval.as_secs() as i64;
12103        assert!(
12104            (cadence - 60..=cadence + 60).contains(&delta),
12105            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
12106        );
12107    }
12108
12109    /// The mirror case: a first poll that FAILS must be visible at all.
12110    ///
12111    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
12112    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
12113    /// with a NULL cause — invisible to the page built to count exactly that.
12114    #[tokio::test]
12115    async fn a_failing_direct_poll_is_recorded() {
12116        let state = test_state(&[]).await;
12117        let url = "https://born-broken.example/f.xml";
12118        store::upsert_feed(
12119            &state.db,
12120            &store::NewFeed {
12121                url: url.to_string(),
12122                ..Default::default()
12123            },
12124        )
12125        .await
12126        .unwrap();
12127
12128        feed::settle_poll(
12129            &state.db,
12130            url,
12131            &feed::PollOutcome::Failed {
12132                backoff: std::time::Duration::from_secs(300),
12133                kind: feed::FailureKind::Parse,
12134                detail: "SENTINEL_BORN_BROKEN".to_string(),
12135            },
12136            state.config.poll_interval,
12137        )
12138        .await;
12139
12140        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12141            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12142        )
12143        .bind(url)
12144        .fetch_one(&state.db)
12145        .await
12146        .unwrap();
12147        assert_eq!(row.0, 1, "a failed first poll was not counted");
12148        assert_eq!(
12149            row.1.as_deref(),
12150            Some("parse"),
12151            "its cause was not recorded"
12152        );
12153        // And it is BACKED OFF on the schedule the scheduler would use — not
12154        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
12155        // on the very next tick.
12156        let next = row.2.expect("a failed direct poll left next_poll NULL");
12157        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12158        let delta = parsed
12159            .signed_duration_since(chrono::Utc::now())
12160            .num_seconds();
12161        assert!(
12162            (240..=360).contains(&delta),
12163            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
12164        );
12165    }
12166
12167    /// **The breakdown must sum to the Failing figure above it.**
12168    ///
12169    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
12170    /// `consecutive_errors > 0`. On a migrated database every row that was
12171    /// already failing has a NULL kind — correctly, it was never recorded — so
12172    /// the two do not reconcile and the page shows "70 failing" beside "3
12173    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
12174    /// entirely while the prose still promises a breakdown.
12175    ///
12176    /// An explicit `unknown` bucket is the honest shape: the page says how many
12177    /// it cannot explain rather than omitting them.
12178    #[tokio::test]
12179    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
12180        let state = test_state(&[]).await;
12181        // Two legacy rows: failing, with no recorded cause.
12182        for url in [
12183            "https://legacy1.example/f.xml",
12184            "https://legacy2.example/f.xml",
12185        ] {
12186            store::upsert_feed(
12187                &state.db,
12188                &store::NewFeed {
12189                    url: url.to_string(),
12190                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12191                    ..Default::default()
12192                },
12193            )
12194            .await
12195            .unwrap();
12196            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
12197                .bind(url)
12198                .execute(&state.db)
12199                .await
12200                .unwrap();
12201        }
12202        // One row with a recorded cause.
12203        store::upsert_feed(
12204            &state.db,
12205            &store::NewFeed {
12206                url: "https://known.example/f.xml".to_string(),
12207                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12208                ..Default::default()
12209            },
12210        )
12211        .await
12212        .unwrap();
12213        store::bump_feed_errors(
12214            &state.db,
12215            "https://known.example/f.xml",
12216            feed::FailureKind::Status,
12217            "SENTINEL",
12218        )
12219        .await
12220        .unwrap();
12221
12222        let now = chrono::Utc::now();
12223        let health = store::poll_health(
12224            &state.db,
12225            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12226            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12227        )
12228        .await
12229        .unwrap();
12230        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
12231        assert_eq!(
12232            counted, health.in_backoff,
12233            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
12234            health.in_backoff, health.failure_kinds,
12235        );
12236        assert!(
12237            health
12238                .failure_kinds
12239                .iter()
12240                .any(|(k, n)| k == "unknown" && *n == 2),
12241            "no unknown bucket for the legacy rows: {:?}",
12242            health.failure_kinds,
12243        );
12244    }
12245
12246    /// **The breakdown is ordered by count, and the assertion can see it.**
12247    ///
12248    /// The first version of this asserted with three `contains` calls, which
12249    /// cannot observe order — deleting `ORDER BY` from the query passed.
12250    #[tokio::test]
12251    async fn the_failure_breakdown_is_ordered_by_count() {
12252        let state = test_state(&[]).await;
12253        for (url, kind, n) in [
12254            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
12255            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
12256            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
12257            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
12258            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
12259            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
12260        ] {
12261            store::upsert_feed(
12262                &state.db,
12263                &store::NewFeed {
12264                    url: url.to_string(),
12265                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12266                    ..Default::default()
12267                },
12268            )
12269            .await
12270            .unwrap();
12271            for _ in 0..n {
12272                store::bump_feed_errors(&state.db, url, kind, "d")
12273                    .await
12274                    .unwrap();
12275            }
12276        }
12277        let now = chrono::Utc::now();
12278        let health = store::poll_health(
12279            &state.db,
12280            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12281            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12282        )
12283        .await
12284        .unwrap();
12285        let labels: Vec<&str> = health
12286            .failure_kinds
12287            .iter()
12288            .map(|(k, _)| k.as_str())
12289            .collect();
12290        assert_eq!(
12291            labels,
12292            ["fetch", "status", "parse"],
12293            "not ordered by count, descending: {:?}",
12294            health.failure_kinds,
12295        );
12296    }
12297
12298    /// **Failing feeds are grouped by CAUSE, and still never named.**
12299    ///
12300    /// `badly_broken` could say that sixty feeds were failing and not whether
12301    /// that was sixty dead publishers or one bug here. It was the latter — #159,
12302    /// a `304 Not Modified` read as a malformed redirect — and the page could
12303    /// not say so, which is most of why it went unexamined.
12304    ///
12305    /// The second half of this test is the constraint that shapes the first:
12306    /// `/stats` is public and promises machines-not-people, *never which feed
12307    /// and never whose*. A histogram of causes keeps that promise; a list of
12308    /// failing URLs would break it, and is the obvious way to build this.
12309    #[tokio::test]
12310    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
12311        let state = test_state(&[]).await;
12312        for (url, kind, detail, errors) in [
12313            // Detail strings are distinctive SENTINELS, not plausible English.
12314            // A first pass used "not a feed", which the page's own explanation
12315            // of the `parse` kind contains verbatim — the privacy assertion
12316            // fired on static copy rather than on a leak. A sentinel cannot
12317            // collide with prose.
12318            (
12319                "https://a.example/f.xml",
12320                feed::FailureKind::Fetch,
12321                "SENTINEL_CONNREFUSED",
12322                3,
12323            ),
12324            (
12325                "https://b.example/f.xml",
12326                feed::FailureKind::Fetch,
12327                "SENTINEL_DNSFAIL",
12328                2,
12329            ),
12330            (
12331                "https://c.example/f.xml",
12332                feed::FailureKind::Status,
12333                "SENTINEL_404",
12334                1,
12335            ),
12336            (
12337                "https://d.example/f.xml",
12338                feed::FailureKind::Parse,
12339                "SENTINEL_UNPARSEABLE",
12340                1,
12341            ),
12342        ] {
12343            store::upsert_feed(
12344                &state.db,
12345                &store::NewFeed {
12346                    url: url.to_string(),
12347                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12348                    ..Default::default()
12349                },
12350            )
12351            .await
12352            .unwrap();
12353            for _ in 0..errors {
12354                store::bump_feed_errors(&state.db, url, kind, detail)
12355                    .await
12356                    .unwrap();
12357            }
12358        }
12359
12360        let resp = router(state.clone())
12361            .oneshot(
12362                Request::builder()
12363                    .uri("/stats")
12364                    .body(Body::empty())
12365                    .unwrap(),
12366            )
12367            .await
12368            .unwrap();
12369        assert_eq!(resp.status(), StatusCode::OK);
12370        let body = String::from_utf8(
12371            axum::body::to_bytes(resp.into_body(), usize::MAX)
12372                .await
12373                .unwrap()
12374                .to_vec(),
12375        )
12376        .unwrap();
12377
12378        // Descending by count: two fetch, then one each, tie-broken by name.
12379        assert!(
12380            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
12381            "the cause histogram did not render: {body}",
12382        );
12383
12384        // **The privacy half.** No feed URL, host, or error detail reaches the
12385        // public page — only counts by kind.
12386        for secret in [
12387            "a.example",
12388            "b.example",
12389            "c.example",
12390            "d.example",
12391            "SENTINEL_CONNREFUSED",
12392            "SENTINEL_DNSFAIL",
12393            "SENTINEL_404",
12394            "SENTINEL_UNPARSEABLE",
12395        ] {
12396            assert!(
12397                !body.contains(secret),
12398                "{secret:?} reached the PUBLIC stats page: {body}",
12399            );
12400        }
12401    }
12402
12403    /// `/health` must prove the process can reach its database, and must report
12404    /// the loop state without letting it change the status code.
12405    #[tokio::test]
12406    async fn health_checks_the_database_and_reports_the_loops() {
12407        let state = test_state(&[]).await;
12408        let body_of = |state: AppState| async move {
12409            let resp = router(state)
12410                .oneshot(
12411                    Request::builder()
12412                        .uri("/health")
12413                        .body(Body::empty())
12414                        .unwrap(),
12415                )
12416                .await
12417                .unwrap();
12418            let status = resp.status();
12419            let body = String::from_utf8(
12420                axum::body::to_bytes(resp.into_body(), usize::MAX)
12421                    .await
12422                    .unwrap()
12423                    .to_vec(),
12424            )
12425            .unwrap();
12426            (status, body)
12427        };
12428
12429        // The boot stamp is what `main` sets; the router alone does not, so this
12430        // starts "unknown" and the uptime branch below drives it explicitly.
12431        state
12432            .runtime_health
12433            .set_started_at(chrono::Utc::now().timestamp());
12434
12435        let (status, body) = body_of(state.clone()).await;
12436        assert_eq!(status, StatusCode::OK);
12437        assert!(
12438            body.contains("db: ok"),
12439            "health did not probe the DB: {body}"
12440        );
12441        assert!(
12442            body.contains("uptime:"),
12443            "no uptime — the first thing anyone asks about a container that may \
12444             be restarting: {body}"
12445        );
12446        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
12447        assert!(body.contains("polling-paused: no"), "{body}");
12448        assert!(body.contains("backend:"), "{body}");
12449        assert!(body.contains("oauth-runtime:"), "{body}");
12450
12451        // A watermark pause is REPORTED but must not fail the check. A failed
12452        // check DEREGISTERS this machine from the proxy — and it is the only
12453        // machine — so it would turn "feeds are behind" into "the site is down"
12454        // for as long as the disk stays full.
12455        state.runtime_health.set_watermark(true);
12456        state.runtime_health.set_schedulers_enabled(true);
12457        let (status, body) = body_of(state.clone()).await;
12458        assert_eq!(
12459            status,
12460            StatusCode::OK,
12461            "a watermark pause must not fail the liveness check: {body}"
12462        );
12463        assert!(body.contains("polling-paused: yes"), "{body}");
12464        // Schedulers on but no tick yet — and that must not read as "0s ago",
12465        // which is the healthiest possible answer to an unanswered question.
12466        assert!(
12467            body.contains("poller: not-yet-ticked"),
12468            "a never-ticked poller must say so: {body}"
12469        );
12470
12471        // A stale heartbeat is likewise reported, not fatal.
12472        let stale_after = health_tick_stale_secs(configured_poll_tick());
12473        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
12474        state.runtime_health.poll_tick_completed(long_ago);
12475        let (status, body) = body_of(state.clone()).await;
12476        assert_eq!(
12477            status,
12478            StatusCode::OK,
12479            "a stale poller must not 503: {body}"
12480        );
12481        assert!(body.contains("poller: stale"), "{body}");
12482
12483        // **A poller that has never ticked stops being benign.**
12484        //
12485        // In a crash loop with 30 s+ boot cycles the poller never reaches its
12486        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
12487        // could not detect the one failure mode the startup delays were added
12488        // for. It is read against uptime now.
12489        state.runtime_health.poll_tick_completed(0); // reset to "never"
12490        state
12491            .runtime_health
12492            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
12493        let (status, body) = body_of(state.clone()).await;
12494        assert_eq!(status, StatusCode::OK);
12495        assert!(
12496            body.contains("poller: stale never-ticked"),
12497            "a poller that never ticked long after boot still reads as benign: {body}"
12498        );
12499
12500        // A closed pool is a real outage: nothing can be served, and a restart is
12501        // the correct response. THIS is what the status code is for.
12502        state.db.close().await;
12503        let (status, body) = body_of(state.clone()).await;
12504        assert_eq!(
12505            status,
12506            StatusCode::SERVICE_UNAVAILABLE,
12507            "an unreachable database must fail the check: {body}"
12508        );
12509        assert!(body.starts_with("FAIL"), "{body}");
12510        // Coarse, not the raw sqlx error: an unauthenticated caller learning
12511        // exactly which failure it hit is an attack-progress oracle, and this
12512        // endpoint is exempt from the origin lock.
12513        assert!(
12514            !body.contains("PoolClosed") && !body.contains("sqlx"),
12515            "health leaked the raw database error to an unauthenticated caller: {body}"
12516        );
12517    }
12518
12519    /// The staleness threshold must track the configured tick.
12520    ///
12521    /// Hardcoded at 15 minutes, an operator who raised
12522    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
12523    /// in the body the deployment docs tell them to alert on.
12524    #[test]
12525    fn the_stale_threshold_follows_the_poll_tick() {
12526        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
12527        // alerting that early would fire on any brief hiccup.
12528        assert_eq!(
12529            health_tick_stale_secs(Duration::from_secs(60)),
12530            HEALTH_TICK_STALE_FLOOR_SECS
12531        );
12532        // A slow tick raises it, so a legitimately-configured loop is never
12533        // permanently "stale".
12534        let slow = Duration::from_secs(30 * 60);
12535        assert!(
12536            health_tick_stale_secs(slow) > slow.as_secs() as i64,
12537            "a 30-minute tick must not be stale after one interval"
12538        );
12539        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
12540        // And it cannot overflow into nonsense on an absurd value.
12541        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
12542    }
12543
12544    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
12545    ///
12546    /// `polling_paused` alone rendered "running" for three different states,
12547    /// including the two where nothing polls at all — on the page added to
12548    /// answer exactly that question.
12549    #[tokio::test]
12550    async fn stats_does_not_call_a_stopped_poller_running() {
12551        let state = test_state(&[]).await;
12552        let render = |state: AppState| async move {
12553            let resp = router(state)
12554                .oneshot(
12555                    Request::builder()
12556                        .uri("/stats")
12557                        .body(Body::empty())
12558                        .unwrap(),
12559                )
12560                .await
12561                .unwrap();
12562            assert_eq!(resp.status(), StatusCode::OK);
12563            String::from_utf8(
12564                axum::body::to_bytes(resp.into_body(), usize::MAX)
12565                    .await
12566                    .unwrap()
12567                    .to_vec(),
12568            )
12569            .unwrap()
12570        };
12571
12572        // Schedulers never started: not "running".
12573        let body = render(state.clone()).await;
12574        assert!(
12575            body.contains("the poller is not running on this instance"),
12576            "a disabled poller renders as healthy"
12577        );
12578
12579        // Started, but no tick has finished yet.
12580        state.runtime_health.set_schedulers_enabled(true);
12581        let body = render(state.clone()).await;
12582        assert!(
12583            body.contains("no poll has finished since this instance booted"),
12584            "a poller that has not ticked renders as healthy"
12585        );
12586
12587        // Ticking: running.
12588        state
12589            .runtime_health
12590            .poll_tick_completed(chrono::Utc::now().timestamp());
12591        let body = render(state.clone()).await;
12592        assert!(
12593            body.contains("running"),
12594            "a healthy poller must read as running"
12595        );
12596
12597        // Paused at the watermark still wins over "running".
12598        state.runtime_health.set_watermark(true);
12599        let body = render(state.clone()).await;
12600        assert!(
12601            body.contains("the cache is at its size limit"),
12602            "a watermark pause is hidden once the poller is ticking"
12603        );
12604    }
12605
12606    /// **An UNMEASURED database must not fail the check.**
12607    ///
12608    /// `/health` is the one path exempt from the Cloudflare origin lock and
12609    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
12610    /// drop WITHOUT recording a verdict — so a cancelled request (a client
12611    /// disconnect is enough) leaves the verdict at "none", and a concurrent
12612    /// caller reads it. Treating that as a failure turned an unauthenticated
12613    /// request into a lever on the only signal the platform acts on. The
12614    /// previous version of this code had the opposite bug and reported `ok` for
12615    /// a database nothing had read; "unknown" is neither.
12616    #[tokio::test]
12617    async fn health_reports_an_unmeasured_database_without_failing() {
12618        use crate::runtime_health::DbProbe;
12619        let state = test_state(&[]).await;
12620
12621        // Hold the probe claim, exactly as an in-flight request would, and never
12622        // record a verdict — the cancelled-request state.
12623        let held = state
12624            .runtime_health
12625            .begin_db_probe()
12626            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
12627
12628        let resp = router(state.clone())
12629            .oneshot(
12630                Request::builder()
12631                    .uri("/health")
12632                    .body(Body::empty())
12633                    .unwrap(),
12634            )
12635            .await
12636            .unwrap();
12637        let status = resp.status();
12638        let body = String::from_utf8(
12639            axum::body::to_bytes(resp.into_body(), usize::MAX)
12640                .await
12641                .unwrap()
12642                .to_vec(),
12643        )
12644        .unwrap();
12645        drop(held);
12646
12647        assert_eq!(
12648            status,
12649            StatusCode::OK,
12650            "an unmeasured database failed the check, which an unauthenticated \
12651             caller can cause on demand: {body}"
12652        );
12653        assert!(
12654            body.contains("db: unknown"),
12655            "the unmeasured state must still be REPORTED: {body}"
12656        );
12657        assert!(!body.starts_with("FAIL"), "{body}");
12658        // **And it must not read as `ok` either.** `fly.toml` tells operators to
12659        // alert on the BODY for everything the status code ignores, so a first
12660        // line identical to the healthy one makes a monitor keying on `^ok` read
12661        // green in exactly the state this enum exists to surface.
12662        assert!(
12663            !body.starts_with("ok"),
12664            "the unmeasured state is indistinguishable from healthy to a \
12665             body-matching monitor: {body}"
12666        );
12667        assert!(body.starts_with("unknown"), "{body}");
12668
12669        // **A BORROWED failure must 503 too.**
12670        //
12671        // This previously recorded `Failed` and then closed the pool — but
12672        // `record` consumes the guard and releases the claim, so the request won
12673        // it, ran a live probe against the closed pool, and failed on its own.
12674        // The 503 passed for the wrong reason and the borrow path — the whole
12675        // point of the three-state enum on the read side — had no coverage.
12676        //
12677        // Holding the claim forces the borrow, so the recorded verdict is what
12678        // gets reported.
12679        let held = state
12680            .runtime_health
12681            .begin_db_probe()
12682            .unwrap_or_else(|_| panic!("claim"));
12683        state
12684            .runtime_health
12685            .record_for_test(DbProbe::Failed("unavailable".to_string()));
12686        let resp = router(state.clone())
12687            .oneshot(
12688                Request::builder()
12689                    .uri("/health")
12690                    .body(Body::empty())
12691                    .unwrap(),
12692            )
12693            .await
12694            .unwrap();
12695        let status = resp.status();
12696        let body = String::from_utf8(
12697            axum::body::to_bytes(resp.into_body(), usize::MAX)
12698                .await
12699                .unwrap()
12700                .to_vec(),
12701        )
12702        .unwrap();
12703        drop(held);
12704        assert_eq!(
12705            status,
12706            StatusCode::SERVICE_UNAVAILABLE,
12707            "a BORROWED failure verdict must fail the check, not just a freshly \
12708             measured one: {body}"
12709        );
12710        assert!(body.starts_with("FAIL"), "{body}");
12711
12712        state.db.close().await;
12713        let resp = router(state.clone())
12714            .oneshot(
12715                Request::builder()
12716                    .uri("/health")
12717                    .body(Body::empty())
12718                    .unwrap(),
12719            )
12720            .await
12721            .unwrap();
12722        assert_eq!(
12723            resp.status(),
12724            StatusCode::SERVICE_UNAVAILABLE,
12725            "a measured database failure must still fail the check"
12726        );
12727    }
12728
12729    /// **A disconnected client must not be able to cancel the probe.**
12730    ///
12731    /// Axum drops the handler future when a caller goes away. With the probe
12732    /// inline that dropped it mid-flight and released the claim WITHOUT
12733    /// recording a verdict — which let an unauthenticated caller manufacture the
12734    /// no-verdict state on demand and freeze what every other caller, including
12735    /// Fly's own check, reads. The probe runs detached now, so the verdict is
12736    /// recorded whatever happens to the request that started it.
12737    #[tokio::test]
12738    async fn an_abandoned_request_still_records_its_probe() {
12739        use crate::runtime_health::DbProbe;
12740        let state = test_state(&[]).await;
12741        let rh = state.runtime_health.clone();
12742
12743        // Drive /health and abandon it immediately — the disconnect case.
12744        let app = router(state.clone());
12745        let fut = app.oneshot(
12746            Request::builder()
12747                .uri("/health")
12748                .body(Body::empty())
12749                .unwrap(),
12750        );
12751        let handle = tokio::spawn(fut);
12752        handle.abort();
12753        let _ = handle.await;
12754
12755        // The detached probe still completes and publishes a verdict, so the
12756        // claim is free and the next caller gets a MEASURED answer.
12757        for _ in 0..50 {
12758            if rh.begin_db_probe().is_ok() {
12759                break;
12760            }
12761            tokio::time::sleep(Duration::from_millis(20)).await;
12762        }
12763        let resp = router(state.clone())
12764            .oneshot(
12765                Request::builder()
12766                    .uri("/health")
12767                    .body(Body::empty())
12768                    .unwrap(),
12769            )
12770            .await
12771            .unwrap();
12772        let body = String::from_utf8(
12773            axum::body::to_bytes(resp.into_body(), usize::MAX)
12774                .await
12775                .unwrap()
12776                .to_vec(),
12777        )
12778        .unwrap();
12779        assert!(
12780            body.contains("db: ok"),
12781            "after an abandoned request the next caller still reads an \
12782             unmeasured database — the probe was cancelled with it: {body}"
12783        );
12784        // Sanity: the type still distinguishes the three states.
12785        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
12786    }
12787
12788    /// **The probe must read a real page.**
12789    ///
12790    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
12791    /// it never touches a b-tree and returns success against a corrupted
12792    /// database. Asserted by asking SQLite what the statement actually compiles
12793    /// to, so it survives someone "simplifying" the query later.
12794    #[tokio::test]
12795    async fn the_health_probe_opens_a_real_table() {
12796        use sqlx::Row;
12797        let state = test_state(&[]).await;
12798        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
12799        let opcodes = |sql: &'static str| {
12800            let db = state.db.clone();
12801            async move {
12802                sqlx::query(sql)
12803                    .fetch_all(&db)
12804                    .await
12805                    .unwrap()
12806                    .into_iter()
12807                    .map(|r| r.get::<String, _>("opcode"))
12808                    .collect::<Vec<String>>()
12809            }
12810        };
12811
12812        // The statement `health_db_probe` really runs — it is the sole path, so
12813        // there is no second string for the handler to use instead.
12814        let explain: &'static str =
12815            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
12816        let probe = opcodes(explain).await;
12817        // And the probe itself works against a real schema.
12818        assert!(
12819            health_db_probe(&state.db).await.is_ok(),
12820            "the probe does not run against the real schema",
12821        );
12822        assert!(
12823            probe.iter().any(|op| op == "OpenRead"),
12824            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
12825        );
12826        // And the bare form genuinely does not, which is the whole point.
12827        let bare = opcodes("EXPLAIN SELECT 1").await;
12828        assert!(
12829            !bare.iter().any(|op| op == "OpenRead"),
12830            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
12831        );
12832    }
12833
12834    /// A fresh instance says "never", not "0" — which would read as "polled
12835    /// just now", the opposite of the truth.
12836    #[test]
12837    fn an_instance_that_has_never_polled_says_so() {
12838        assert_eq!(humanise_ago(None), "never");
12839        assert_eq!(humanise_ago(Some(0)), "0s ago");
12840        assert_eq!(humanise_ago(Some(59)), "59s ago");
12841        assert_eq!(humanise_ago(Some(60)), "1m ago");
12842        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
12843        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
12844    }
12845
12846    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
12847    /// record, and anything else with an empty list. Serves repeatedly.
12848    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
12849        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12850        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12851        let addr = listener.local_addr().unwrap();
12852        let (url, title) = (saved_url.to_string(), saved_title.to_string());
12853        tokio::spawn(async move {
12854            loop {
12855                let Ok((mut sock, _)) = listener.accept().await else {
12856                    break;
12857                };
12858                let mut buf = vec![0u8; 8192];
12859                let Ok(n) = sock.read(&mut buf).await else {
12860                    continue;
12861                };
12862                let req = String::from_utf8_lossy(&buf[..n]).to_string();
12863                let wants_saved = req.contains("community.lexicon.rss.saved");
12864                let records = if wants_saved {
12865                    serde_json::json!([{
12866                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12867                        "cid": "bafy",
12868                        "value": {
12869                            "$type": "community.lexicon.rss.saved",
12870                            "url": url,
12871                            "title": title,
12872                            "createdAt": "2026-01-01T00:00:00Z"
12873                        }
12874                    }])
12875                } else {
12876                    serde_json::json!([])
12877                };
12878                let body = serde_json::json!({
12879                    "ok": true, "data": { "records": records }
12880                })
12881                .to_string();
12882                let resp = format!(
12883                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12884                    body.len(), body
12885                );
12886                let _ = sock.write_all(resp.as_bytes()).await;
12887                let _ = sock.flush().await;
12888            }
12889        });
12890        format!("http://{addr}")
12891    }
12892
12893    /// A sidecar mock serving `n` distinct saved records, none of them cached
12894    /// locally — the shape that exercises the uncached-row append.
12895    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12896        let feed = subscribed_feed.to_string();
12897        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12898        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12899        let addr = listener.local_addr().unwrap();
12900        tokio::spawn(async move {
12901            loop {
12902                let Ok((mut sock, _)) = listener.accept().await else {
12903                    break;
12904                };
12905                let mut buf = vec![0u8; 8192];
12906                let Ok(read) = sock.read(&mut buf).await else {
12907                    continue;
12908                };
12909                let req = String::from_utf8_lossy(&buf[..read]).to_string();
12910                let records = if req.contains("community.lexicon.rss.saved") {
12911                    serde_json::Value::Array(
12912                        (0..n)
12913                            .map(|i| {
12914                                serde_json::json!({
12915                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12916                                    "cid": "bafy",
12917                                    "value": {
12918                                        "$type": "community.lexicon.rss.saved",
12919                                        "url": format!("https://elsewhere.example/{i}"),
12920                                        "title": format!("Elsewhere {i}"),
12921                                        "createdAt": "2026-01-01T00:00:00Z"
12922                                    }
12923                                })
12924                            })
12925                            .collect(),
12926                    )
12927                } else if req.contains("community.lexicon.rss.subscription") {
12928                    // Without this the handler's `sync_sub_refs` would REPLACE
12929                    // sub_ref with an empty set on every render, and every
12930                    // sub_ref-scoped read — including the cached starred list
12931                    // this test is about — would come back empty.
12932                    serde_json::json!([{
12933                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12934                        "cid": "bafy",
12935                        "value": {
12936                            "$type": "community.lexicon.rss.subscription",
12937                            "url": feed,
12938                            "createdAt": "2026-01-01T00:00:00Z"
12939                        }
12940                    }])
12941                } else {
12942                    serde_json::json!([])
12943                };
12944                let body =
12945                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12946                let resp = format!(
12947                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12948                    body.len(), body
12949                );
12950                let _ = sock.write_all(resp.as_bytes()).await;
12951                let _ = sock.flush().await;
12952            }
12953        });
12954        format!("http://{addr}")
12955    }
12956
12957    /// **The pager must not advertise a page the clamp cannot reach.**
12958    ///
12959    /// The page clamp is computed from the CACHED total; the uncached PDS rows
12960    /// are appended to the last page rather than paged. Inflating `total` with
12961    /// them made `page_count` and the "Older →" link point one page past the end:
12962    /// requesting it clamped straight back, re-rendered the same last page, and
12963    /// still offered the link. An infinite "next" that never advances.
12964    #[tokio::test]
12965    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12966        let did = "did:plc:pagerloop";
12967        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12968        let state = test_state_with_sidecar(&[], &sidecar).await;
12969        store::grant_access(&state.db, did, None, "test", None)
12970            .await
12971            .unwrap();
12972        let feed = store::upsert_feed(
12973            &state.db,
12974            &store::NewFeed {
12975                url: "https://loop.example/feed.xml".to_string(),
12976                title: Some("Loop".to_string()),
12977                ..Default::default()
12978            },
12979        )
12980        .await
12981        .unwrap();
12982        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
12983        // and the old arithmetic reported a fourth page.
12984        let entries: Vec<store::NewEntry> = (0..250)
12985            .map(|i| store::NewEntry {
12986                guid: format!("s-{i:04}"),
12987                url: Some(format!("https://loop.example/{i}")),
12988                title: Some(format!("Starred {i:04}")),
12989                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12990                ..Default::default()
12991            })
12992            .collect();
12993        store::insert_entries(&state.db, feed, &entries, 0)
12994            .await
12995            .unwrap();
12996        store::replace_sub_refs(&state.db, did, &[feed])
12997            .await
12998            .unwrap();
12999        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
13000            .await
13001            .unwrap()
13002        {
13003            store::mark_starred(&state.db, did, row.id, true)
13004                .await
13005                .unwrap();
13006        }
13007
13008        let cookie = session_cookie(&state, did, None);
13009        let app = router(state.clone());
13010        let get = |uri: &str| {
13011            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
13012            async move {
13013                let resp = app
13014                    .oneshot(
13015                        Request::builder()
13016                            .uri(uri)
13017                            .header(header::COOKIE, cookie)
13018                            .body(Body::empty())
13019                            .unwrap(),
13020                    )
13021                    .await
13022                    .unwrap();
13023                assert_eq!(resp.status(), StatusCode::OK);
13024                String::from_utf8(
13025                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
13026                        .await
13027                        .unwrap()
13028                        .to_vec(),
13029                )
13030                .unwrap()
13031            }
13032        };
13033
13034        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
13035        // clamp must agree on that, and EVERY page it offers must have content —
13036        // the original bug advertised a fourth page that clamped back to the
13037        // third and re-rendered it, still offering the link.
13038        let p3 = get("/?view=starred&page=3").await;
13039        assert!(
13040            p3.contains("Page 3 of 4"),
13041            "the pager and the clamp disagree on the total: {}",
13042            p3.split("pager-pos")
13043                .nth(1)
13044                .unwrap_or("")
13045                .chars()
13046                .take(120)
13047                .collect::<String>()
13048        );
13049        // Page 3 is the boundary: the last 50 cached rows, then the first 50
13050        // uncached ones.
13051        assert!(
13052            p3.contains("Elsewhere 0"),
13053            "page 3 should start the uncached run"
13054        );
13055        assert_eq!(
13056            p3.matches("<li class=\"entry").count(),
13057            ENTRIES_PER_PAGE as usize,
13058            "the boundary page is not full"
13059        );
13060
13061        // **The heading, which the previous round broke by deleting this.**
13062        //
13063        // `total` includes the uncached records, so the parenthetical is a
13064        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
13065        // The version that said "plus N" double counted once `total` started
13066        // including them, and N had become page-local in the same commit while
13067        // the template stayed put. It shipped because this assertion was deleted
13068        // rather than updated.
13069        {
13070            let body = &p3;
13071            assert!(
13072                body.contains("330 entries"),
13073                "the heading must count the whole sequence: {}",
13074                body.split("content-count")
13075                    .nth(1)
13076                    .unwrap_or("")
13077                    .chars()
13078                    .take(120)
13079                    .collect::<String>()
13080            );
13081            assert!(
13082                body.contains("(80 saved elsewhere)"),
13083                "the heading must say how many of the total the cache cannot show, \
13084                 as a whole-list figure and not a per-page one: {}",
13085                body.split("content-count")
13086                    .nth(1)
13087                    .unwrap_or("")
13088                    .chars()
13089                    .take(120)
13090                    .collect::<String>()
13091            );
13092            assert!(
13093                !body.contains("plus 50") && !body.contains("plus 80"),
13094                "the heading is adding the uncached rows to a total that already \
13095                 includes them"
13096            );
13097        }
13098
13099        let p4 = get("/?view=starred&page=4").await;
13100        assert!(
13101            p4.contains("Page 4 of 4"),
13102            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
13103        );
13104        assert_eq!(
13105            p4.matches("<li class=\"entry").count(),
13106            30,
13107            "page 4 should hold the remaining 30 uncached records"
13108        );
13109        assert!(
13110            p4.contains("Elsewhere 79"),
13111            "the LAST saved record is unreachable — it can only be removed from here"
13112        );
13113
13114        // No uncached record appears on two pages.
13115        assert!(
13116            !p4.contains("Elsewhere 0"),
13117            "an uncached record was rendered on more than one page"
13118        );
13119        // Page 1 is all cached — and still reports the same whole-list heading,
13120        // because the parenthetical describes the LIST, not the page.
13121        let first = get("/?view=starred").await;
13122        assert!(
13123            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
13124            "the heading changed between pages; it describes the list, not the page"
13125        );
13126        assert!(
13127            !first.contains("Elsewhere "),
13128            "uncached saved records leaked onto the first page"
13129        );
13130    }
13131
13132    /// **A saved record whose article is not cached here is still shown.**
13133    ///
13134    /// The starred view is built from local `entries`, so before this a record
13135    /// starred in ANOTHER atproto reader — the portability the shared lexicon
13136    /// exists for — was simply invisible. It now renders from the PDS record,
13137    /// visually distinct, linking straight out.
13138    #[tokio::test]
13139    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
13140        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
13141        let sidecar =
13142            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
13143        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
13144        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
13145
13146        let resp = router(state)
13147            .oneshot(
13148                Request::builder()
13149                    .uri("/?view=starred")
13150                    .body(Body::empty())
13151                    .unwrap(),
13152            )
13153            .await
13154            .unwrap();
13155        assert_eq!(resp.status(), StatusCode::OK);
13156        let body = String::from_utf8(
13157            axum::body::to_bytes(resp.into_body(), usize::MAX)
13158                .await
13159                .unwrap()
13160                .to_vec(),
13161        )
13162        .unwrap();
13163
13164        assert!(
13165            body.contains("Starred elsewhere"),
13166            "the saved record was not rendered at all"
13167        );
13168        assert!(
13169            body.contains("entry-uncached"),
13170            "it was not marked as uncached, so it looks like a normal entry"
13171        );
13172        assert!(
13173            body.contains("https://elsewhere.example/article"),
13174            "the row must link straight to the article"
13175        );
13176        assert!(
13177            !body.contains("/entries/0/"),
13178            "an uncached row must not offer entry actions against a nonexistent id"
13179        );
13180    }
13181
13182    /// **A PDS `createdAt` must not be able to panic the starred view.**
13183    ///
13184    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
13185    /// timestamp the feed parser produced; the saved-record path passes a bare
13186    /// string off a PDS record, written by whatever client the reader used. A
13187    /// multi-byte value panicked the handler, and with no catch-panic layer the
13188    /// view stayed down until the record was removed — from that same view.
13189    #[test]
13190    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
13191        for hostile in [
13192            "日本語日本語日本",
13193            "é",
13194            "",
13195            "2026",
13196            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
13197        ] {
13198            let out = display_date(Some(hostile));
13199            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
13200        }
13201        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
13202        assert_eq!(display_date(None), "");
13203    }
13204
13205    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
13206    /// its neighbours are limited. It was added as a route and not added here.
13207    #[test]
13208    fn the_unsave_route_is_rate_limited() {
13209        use axum::http::Method;
13210        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
13211        // And the neighbours still are.
13212        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
13213    }
13214
13215    /// **The probe detects a broken database — asserted through `/health`
13216    /// itself, not through a string.**
13217    ///
13218    /// A named constant did not bind the handler: it stayed free to call
13219    /// `query_scalar` with a different literal, so degrading the real probe to
13220    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
13221    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
13222    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
13223    #[tokio::test]
13224    async fn health_reports_a_broken_database() {
13225        let state = test_state(&[]).await;
13226        // Sanity: healthy first, so the assertion below is about the damage.
13227        assert!(
13228            health_db_probe(&state.db).await.is_ok(),
13229            "the fixture was not healthy to begin with",
13230        );
13231
13232        sqlx::query("DROP TABLE feeds")
13233            .execute(&state.db)
13234            .await
13235            .unwrap();
13236
13237        assert!(
13238            health_db_probe(&state.db).await.is_err(),
13239            "the probe reported success against a database missing the table it \
13240             claims to read; `SELECT 1` would do exactly this",
13241        );
13242
13243        let resp = router(state)
13244            .oneshot(
13245                Request::builder()
13246                    .uri("/health")
13247                    .body(Body::empty())
13248                    .unwrap(),
13249            )
13250            .await
13251            .unwrap();
13252        let body = String::from_utf8(
13253            axum::body::to_bytes(resp.into_body(), usize::MAX)
13254                .await
13255                .unwrap()
13256                .to_vec(),
13257        )
13258        .unwrap();
13259        // The documented contract: the FIRST token is the state.
13260        assert!(
13261            body.starts_with("FAIL"),
13262            "/health did not report FAIL for a broken database: {body}",
13263        );
13264        assert!(
13265            !body.contains("db: ok"),
13266            "/health still called the database ok: {body}",
13267        );
13268    }
13269
13270    /// A sidecar mock for the OPML export: serves one subscription and one
13271    /// folder, except for the collection named in `fail_on`, which answers
13272    /// `500` — the shape a refused (short or unreadable) walk takes at this
13273    /// boundary.
13274    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
13275        use tokio::io::{AsyncReadExt, AsyncWriteExt};
13276        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13277        let addr = listener.local_addr().unwrap();
13278        tokio::spawn(async move {
13279            loop {
13280                let Ok((mut sock, _)) = listener.accept().await else {
13281                    break;
13282                };
13283                let mut buf = vec![0u8; 8192];
13284                let Ok(n) = sock.read(&mut buf).await else {
13285                    continue;
13286                };
13287                let req = String::from_utf8_lossy(&buf[..n]).to_string();
13288                let wants = |c: &str| req.contains(c);
13289                if fail_on.is_some_and(wants) {
13290                    let body = r#"{"ok":false,"error":"ShortList"}"#;
13291                    let resp = format!(
13292                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13293                        body.len(),
13294                        body
13295                    );
13296                    let _ = sock.write_all(resp.as_bytes()).await;
13297                    let _ = sock.flush().await;
13298                    continue;
13299                }
13300                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
13301                    serde_json::json!([{
13302                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
13303                        "cid": "bafy",
13304                        "value": {
13305                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
13306                            "url": "https://kept.example/feed.xml",
13307                            "title": "Kept",
13308                            // Inside the folder, so the healthy export has to
13309                            // carry BOTH walks' results: an exporter that lost
13310                            // the folder list would flatten this outline out of
13311                            // its group with nothing else changing.
13312                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13313                            "createdAt": "2026-01-01T00:00:00Z"
13314                        }
13315                    }])
13316                } else if wants(crate::lexicon::nsid::FOLDER) {
13317                    serde_json::json!([{
13318                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13319                        "cid": "bafy",
13320                        "value": {
13321                            "$type": crate::lexicon::nsid::FOLDER,
13322                            "name": "Kept folder",
13323                            "createdAt": "2026-01-01T00:00:00Z"
13324                        }
13325                    }])
13326                } else {
13327                    serde_json::json!([])
13328                };
13329                let body =
13330                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
13331                let resp = format!(
13332                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13333                    body.len(),
13334                    body
13335                );
13336                let _ = sock.write_all(resp.as_bytes()).await;
13337                let _ = sock.flush().await;
13338            }
13339        });
13340        format!("http://{addr}")
13341    }
13342
13343    /// A sidecar whose every `listRecords` page carries one good record and
13344    /// one with no `uri` — the #177 shape — for any collection.
13345    async fn spawn_malformed_sidecar() -> String {
13346        use tokio::io::{AsyncReadExt, AsyncWriteExt};
13347        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13348        let addr = listener.local_addr().unwrap();
13349        tokio::spawn(async move {
13350            loop {
13351                let Ok((mut sock, _)) = listener.accept().await else {
13352                    break;
13353                };
13354                let mut buf = vec![0u8; 8192];
13355                let _ = sock.read(&mut buf).await;
13356                let body = serde_json::json!({ "ok": true, "data": { "records": [
13357                    { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
13358                    { "cid": "bafy", "value": {} },
13359                ]}})
13360                .to_string();
13361                let resp = format!(
13362                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13363                    body.len(),
13364                    body
13365                );
13366                let _ = sock.write_all(resp.as_bytes()).await;
13367                let _ = sock.flush().await;
13368            }
13369        });
13370        format!("http://{addr}")
13371    }
13372
13373    async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
13374        let cookie = session_cookie(&state, did, None);
13375        let resp = router(state)
13376            .oneshot(
13377                Request::builder()
13378                    .uri(uri)
13379                    .header(header::COOKIE, cookie)
13380                    .body(Body::empty())
13381                    .unwrap(),
13382            )
13383            .await
13384            .unwrap();
13385        let status = resp.status();
13386        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
13387            .await
13388            .unwrap();
13389        (status, String::from_utf8_lossy(&body).to_string())
13390    }
13391
13392    /// **0.4.0 step 4: a publication document with neither summary field
13393    /// renders as a title, a date and a link** — 8% of measured documents
13394    /// (37 of 449) carry neither `description` nor `textContent`. That is what
13395    /// an RSS reader shows for a title-only feed, not an error, in the list and
13396    /// on the article page alike.
13397    #[tokio::test]
13398    async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
13399        let did = "did:plc:displayer";
13400        let state = test_state(&[did]).await;
13401        let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
13402        let feed_id = store::upsert_feed(
13403            &state.db,
13404            &store::NewFeed {
13405                url: url.into(),
13406                title: Some("Quiet Journal".into()),
13407                ..Default::default()
13408            },
13409        )
13410        .await
13411        .unwrap();
13412        store::replace_sub_refs(&state.db, did, &[feed_id])
13413            .await
13414            .unwrap();
13415        store::insert_entries(
13416            &state.db,
13417            feed_id,
13418            &[store::NewEntry {
13419                guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
13420                    .into(),
13421                url: Some("https://quiet.example/no-summary".into()),
13422                title: Some("A title-only article".into()),
13423                published: Some("2026-07-11T00:00:00Z".into()),
13424                content_html: None,
13425                ..Default::default()
13426            }],
13427            0,
13428        )
13429        .await
13430        .unwrap();
13431        let (status, list) = page_body(state.clone(), did, "/?view=all").await;
13432        assert_eq!(status, StatusCode::OK);
13433        assert!(
13434            list.contains("A title-only article"),
13435            "the entry is missing from the list"
13436        );
13437
13438        let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
13439            .bind(feed_id)
13440            .fetch_one(&state.db)
13441            .await
13442            .unwrap();
13443        let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
13444        assert_eq!(
13445            status,
13446            StatusCode::OK,
13447            "the article page failed for an entry with no body"
13448        );
13449        assert!(page.contains("A title-only article"));
13450        assert!(
13451            page.contains("https://quiet.example/no-summary"),
13452            "no link to the original"
13453        );
13454        assert!(
13455            page.contains(r#"<time datetime=""#),
13456            "no date on the article page"
13457        );
13458    }
13459
13460    /// **#177: a malformed record in the reader's own repo is refused, and the
13461    /// reader is told.** Refusing keeps `replace_sub_refs` from dropping the
13462    /// subscription that record was; telling them keeps the stale list from
13463    /// looking like the real one. Both the reading page and the manage page.
13464    #[tokio::test]
13465    async fn a_malformed_subscription_record_raises_an_alert() {
13466        let did = "did:plc:alerted";
13467        for page in ["/", "/manage"] {
13468            let sidecar = spawn_malformed_sidecar().await;
13469            let state = test_state_with_sidecar(&[did], &sidecar).await;
13470            let (status, body) = page_body(state, did, page).await;
13471            assert_eq!(status, StatusCode::OK, "{page} did not render");
13472            assert!(
13473                body.contains(r#"role="alert""#) && body.contains("could not be read"),
13474                "{page} rendered no alert for a refused subscription list"
13475            );
13476            assert!(
13477                body.contains("1 record(s) in your subscription list"),
13478                "{page} gave the generic alert, not the malformed-record one"
13479            );
13480        }
13481    }
13482
13483    /// The control: a healthy listing raises no alert.
13484    #[tokio::test]
13485    async fn a_healthy_subscription_listing_raises_no_alert() {
13486        let did = "did:plc:exporter";
13487        let sidecar = spawn_export_sidecar(None).await;
13488        let state = test_state_with_sidecar(&[did], &sidecar).await;
13489        let (status, body) = page_body(state, did, "/").await;
13490        assert_eq!(status, StatusCode::OK);
13491        assert!(
13492            !body.contains("could not be read"),
13493            "a healthy listing raised an alert"
13494        );
13495    }
13496
13497    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
13498    async fn export_opml_response(
13499        fail_on: Option<&'static str>,
13500    ) -> (StatusCode, HeaderMap, String) {
13501        let did = "did:plc:exporter";
13502        let sidecar = spawn_export_sidecar(fail_on).await;
13503        let state = test_state_with_sidecar(&[did], &sidecar).await;
13504        let cookie = session_cookie(&state, did, None);
13505        let resp = router(state)
13506            .oneshot(
13507                Request::builder()
13508                    .uri("/opml/export")
13509                    .header(header::COOKIE, cookie)
13510                    .body(Body::empty())
13511                    .unwrap(),
13512            )
13513            .await
13514            .unwrap();
13515        let status = resp.status();
13516        let headers = resp.headers().clone();
13517        let body = String::from_utf8_lossy(
13518            &axum::body::to_bytes(resp.into_body(), usize::MAX)
13519                .await
13520                .unwrap(),
13521        )
13522        .to_string();
13523        (status, headers, body)
13524    }
13525
13526    /// **An empty export is worse than no export, and this is the caller that
13527    /// used to produce one.**
13528    ///
13529    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
13530    /// truncated walk refuses instead of returning a short list, that turned the
13531    /// refusal into `200 OK` carrying a zero-feed
13532    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
13533    /// the moment a locked-out reader reached for one, and the changelog points
13534    /// them at this route as the recovery path.
13535    ///
13536    /// Asserts the three things a reader can actually observe: no success status,
13537    /// no download offered, and no OPML document in the body.
13538    #[tokio::test]
13539    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
13540        let (status, headers, body) =
13541            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
13542
13543        assert_ne!(
13544            status,
13545            StatusCode::OK,
13546            "a failed subscription walk answered 200: {body}",
13547        );
13548        assert!(
13549            !headers.contains_key(header::CONTENT_DISPOSITION),
13550            "a failed subscription walk still offered a download: {headers:?}",
13551        );
13552        assert!(
13553            !body.contains("<opml"),
13554            "a failed subscription walk still served an OPML document: {body}",
13555        );
13556    }
13557
13558    /// The folders half of the same hole. The two walks are separate calls, and
13559    /// fixing only the first leaves an export that silently loses every folder —
13560    /// a flat list that reimports as one, with no sign anything was lost.
13561    #[tokio::test]
13562    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
13563        let (status, headers, body) =
13564            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
13565
13566        assert_ne!(
13567            status,
13568            StatusCode::OK,
13569            "a failed folder walk answered 200: {body}",
13570        );
13571        assert!(
13572            !headers.contains_key(header::CONTENT_DISPOSITION),
13573            "a failed folder walk still offered a download: {headers:?}",
13574        );
13575        assert!(
13576            !body.contains("<opml"),
13577            "a failed folder walk still served an OPML document: {body}",
13578        );
13579    }
13580
13581    /// The other direction, without which "refuse everything" would pass both
13582    /// tests above: a healthy read still serves the file, with the feed in it.
13583    #[tokio::test]
13584    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
13585        let (status, headers, body) = export_opml_response(None).await;
13586
13587        assert_eq!(
13588            status,
13589            StatusCode::OK,
13590            "a healthy export did not answer 200"
13591        );
13592        assert_eq!(
13593            headers
13594                .get(header::CONTENT_DISPOSITION)
13595                .and_then(|v| v.to_str().ok()),
13596            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
13597            "a healthy export did not offer the download",
13598        );
13599        assert!(
13600            body.contains("https://kept.example/feed.xml"),
13601            "the exported OPML lost the subscription: {body}",
13602        );
13603        assert!(
13604            body.contains("Kept folder"),
13605            "the exported OPML lost the folder: {body}",
13606        );
13607    }
13608}