Skip to main content

feather_reader/
web.rs

1//! The axum web layer — server-rendered HTML + a dash of htmx, **no SPA**.
2//!
3//! This module owns the HTTP surface: [`router`] builds an `axum::Router` over
4//! the shared [`AppState`], wiring the store, feed, atproto, and config seams into
5//! a small set of typography-first, dark-mode-ready views rendered with
6//! `askama` templates (under `templates/`). Progressive enhancement is a single
7//! vendored `htmx` script plus a tiny keyboard handler (`static/keyboard.js`);
8//! every interaction also works as a plain HTML form POST, so the reader is fully
9//! usable with JavaScript disabled.
10//!
11//! ## HTTP surface
12//!
13//! * `GET  /health` — liveness + version, as `text/plain`.
14//! * `GET  /` — the reader: a folders/feeds sidebar (from the PDS records layer)
15//!   plus the main article list. Query params pick the scope (`?feed=…` /
16//!   `?folder=…` / all) and the view (`?view=unread|all|starred`).
17//! * `GET  /entries/{id}` — the clean, distraction-free reader for one entry,
18//!   with prev/next within the current list.
19//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
20//! * `POST /entries/{id}/star` — star/unstar; writes a
21//!   `community.lexicon.rss.saved` record to the user's PDS.
22//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
23//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
24//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
25//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
26//! * `POST /folders` — create a folder record.
27//! * `POST /folders/{rkey}/rename` — rename a folder record.
28//! * `POST /folders/{rkey}/delete` — delete a folder record.
29//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
30//!   subscription records in the PDS.
31//! * `GET  /opml/export` — OPML export (records → a downloadable document).
32//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
33//!   OAuth sign-in flow (routed through the sidecar).
34//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
35//!   reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
36//!   (sets the reserving cookie → `/login`).
37//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
38//!   code + token/url for the bot to post. Cap-aware (409 when full).
39//!
40//! ## Identity — a cookie-resolved atproto session
41//!
42//! Per-request identity comes from a **signed session cookie** (`fr_session`)
43//! keyed by the logged-in DID, set by `oauth_callback` and read by
44//! `current_session` / `current_did`. For local runs without the sidecar,
45//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
46//! All PDS writes route through the [`crate::atproto::SidecarClient`]; a live-PDS
47//! write needs a real OAuth session, but the full write path is built and unit-
48//! tested to the sidecar boundary.
49
50use std::collections::HashMap;
51use std::net::IpAddr;
52use std::sync::Mutex;
53use std::time::{Duration, Instant};
54
55use askama::Template;
56use axum::{
57    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
58    http::{header, HeaderMap, StatusCode},
59    middleware::{self, Next},
60    response::{Html, IntoResponse, Redirect, Response},
61    routing::{get, post},
62    Form, Router,
63};
64use serde::Deserialize;
65use std::net::SocketAddr;
66use tower_http::services::{ServeDir, ServeFile};
67use tower_http::set_header::SetResponseHeaderLayer;
68use tower_http::trace::TraceLayer;
69use tracing::{info, warn};
70
71use crate::config::Config;
72use crate::lexicon::{self, Folder, Saved, Subscription};
73use crate::safe_link::SafeLink;
74use crate::{feed, store, AppState, Session, VERSION};
75
76// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
77// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
78// here via an explicit path so the reader's OPML routes can use the canonical
79// `parse_opml` / `to_opml` without duplicating that logic.
80#[path = "opml.rs"]
81mod opml;
82
83/// The name of the signed session cookie.
84const SESSION_COOKIE: &str = "fr_session";
85
86/// The name of the short-lived signed **invite** cookie.
87///
88/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
89/// OAuth callback. It reserves *intent* to redeem a specific code before the
90/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
91/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
92/// signed with the same key as the session cookie.
93const INVITE_COOKIE: &str = "fr_invite";
94
95/// Browser-binding cookie for an in-flight OAuth login (Rust backend only).
96///
97/// `state` alone cannot stop a login CSRF: it lives in a server-global table, so
98/// a stolen `state` replayed from ANOTHER browser matches just as well as from
99/// the one that started the flow. This cookie is what makes the callback
100/// browser-specific — the pending row stores only its hash, and a callback that
101/// cannot present it is refused.
102const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
103
104/// How long an in-flight login may sit, matching the pending row's own TTL.
105const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
106
107/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
108/// Short enough that a reserved-but-unclaimed seat frees quickly.
109const INVITE_TTL_SECS: i64 = 1800;
110
111/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
112/// sign-in pitch, and `/about`.
113const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
114
115/// The tip / support link (cloud plan public-experiment UI).
116const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
117
118/// The published crate on crates.io — surfaced on the signed-out landing page.
119const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
120
121/// The Content-Security-Policy applied to every response.
122///
123/// Tuned to keep the app fully working while neutralising injected script:
124/// * `default-src 'self'` — same-origin baseline.
125/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
126///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
127///   `javascript:` href (F4) cannot execute. (The design's templates carry no
128///   inline event handlers — every control is wired in `keyboard.js`.)
129/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
130///   inline styles htmx toggles for its request indicators.
131/// * `img-src 'self' https: data:` — feed content routinely embeds remote
132///   images; allow https + data URIs but not other schemes.
133/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
134///   down form posts, `<base>` hijacking, and clickjacking.
135/// * `object-src 'none'` — no plugins.
136const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
137     script-src 'self'; \
138     style-src 'self' 'unsafe-inline'; \
139     img-src 'self' https: data:; \
140     font-src 'self'; \
141     connect-src 'self'; \
142     form-action 'self'; \
143     base-uri 'self'; \
144     frame-ancestors 'none'; \
145     object-src 'none'";
146
147/// The resolved identity for the current request.
148///
149/// `did` is the primary key for all per-user local state; `handle` is display
150/// only; `sid` is the opaque server-side session id the cookie carried (needed
151/// so logout can revoke exactly this session). Sourced from the signed cookie
152/// (real login) or, if none, the configured dev DID fallback.
153#[derive(Clone, Debug)]
154struct CurrentUser {
155    did: String,
156    handle: Option<String>,
157    /// The opaque session id, if this identity came from a real cookie session
158    /// (absent for the dev-DID fallback, which has no server-side session row).
159    sid: Option<String>,
160}
161
162/// Resolve the current request's session from the signed cookie, falling back to
163/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
164///
165/// The cookie carries an opaque server-minted session id (not the DID). We
166/// verify its HMAC, look the id up in the registry, and — crucially —
167/// **re-check the DID against the closed-beta gate on every request**
168/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
169/// DID's beta seat takes effect immediately for already-issued cookies. (The
170/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
171/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
172async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
173    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
174        if let Some(session) = state.sessions.get(&sid) {
175            if store::has_beta_access(&state.db, &session.did)
176                .await
177                .unwrap_or(false)
178            {
179                return Some(CurrentUser {
180                    did: session.did,
181                    handle: session.handle,
182                    sid: Some(sid),
183                });
184            }
185            // DID no longer holds a beta seat: treat as logged out (and drop the
186            // stale server-side session so the dead cookie can't linger).
187            state.sessions.remove(&sid);
188        }
189    }
190    // No valid cookie: dev fallback only if explicitly configured *and* still
191    // inside the beta gate (seeded via ensure_seed / a redeemed code).
192    if let Some(did) = state.config.dev_did.clone() {
193        if store::has_beta_access(&state.db, &did)
194            .await
195            .unwrap_or(false)
196        {
197            return Some(CurrentUser {
198                did,
199                handle: None,
200                sid: None,
201            });
202        }
203    }
204    None
205}
206
207/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
208async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
209    current_session(state, headers).await.map(|u| u.did)
210}
211
212/// Build the application router over shared [`AppState`].
213///
214/// Wires the reader routes, the health check, and the `/static` asset mount
215/// (the stylesheet, vendored htmx, and the keyboard handler, served from
216/// `static/` via `ServeDir`). A `TraceLayer` gives per-request tracing.
217pub fn router(state: AppState) -> Router {
218    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
219    // and the write endpoints). One instance is cloned into the state closure of
220    // the `rate_limit` middleware.
221    let limiter = RateLimiter::shared();
222    // The trusted client-IP source for the limiter (a proxy header the operator
223    // controls, or the socket peer when unset). Bundled with the limiter so the
224    // middleware derives a spoof-resistant IP.
225    let rl_state = RateLimitState {
226        limiter,
227        trusted_header: state.config.trusted_ip_header.clone(),
228    };
229
230    Router::new()
231        .route("/health", get(health))
232        .route("/about", get(about))
233        .route("/standard-site", get(standard_site))
234        .route("/stats", get(stats))
235        .route("/privacy", get(privacy))
236        .route("/terms", get(terms))
237        .route("/manage", get(manage))
238        .route("/", get(index))
239        .route("/entries/{id}", get(entry_view))
240        .route("/entries/{id}/read", post(mark_read))
241        .route("/entries/{id}/star", post(toggle_star))
242        .route("/saved/{rkey}/delete", post(unsave_record))
243        .route("/read-all", post(mark_all_read))
244        .route("/subscriptions", post(add_subscription))
245        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
246        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
247        .route("/folders", post(create_folder))
248        .route("/folders/{rkey}/rename", post(rename_folder))
249        .route("/folders/{rkey}/delete", post(delete_folder))
250        // OPML import takes untrusted uploads: cap the body so a huge upload
251        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
252        .route(
253            "/opml",
254            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
255        )
256        .route("/opml/export", get(export_opml))
257        .route("/login", get(login_form).post(login_submit))
258        .route(
259            "/beta/redeem",
260            get(beta_redeem_form).post(beta_redeem_submit),
261        )
262        // The follow→invite bot's claim link: a public skeet points a new
263        // follower here with an opaque token that reserves a pre-minted code.
264        .route("/claim", get(claim))
265        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
266        // code + returns its token/url for the bot to post.
267        .route("/bot/claims", post(bot_mint_claim))
268        .route("/admin/invites", post(admin_mint_invites))
269        .route("/admin/metrics", get(admin_metrics))
270        .route("/oauth/client-metadata.json", get(oauth_client_metadata))
271        .route("/oauth/jwks.json", get(oauth_jwks))
272        .route("/account/delete", post(account_delete))
273        .route("/oauth/callback", get(oauth_callback))
274        .route("/logout", post(logout))
275        .nest_service("/static", ServeDir::new("static"))
276        // Browsers (and some feed clients) request /favicon.ico at the root
277        // regardless of the <link rel="icon"> tags; serve the same icon that
278        // lives under /static so the bare path stops 404-ing.
279        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
280        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
281        // logged-out landing + static assets, `no-store` on anything that
282        // rendered a session's private view. Runs *inside* the security layers so
283        // the CSP/nosniff/frame headers are untouched.
284        .layer(middleware::from_fn(cache_control))
285        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
286        // as a middleware so it sees the matched path + the peer IP.
287        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
288        .layer(TraceLayer::new_for_http())
289        // Baseline security headers on *every* response (F4). The CSP is the
290        // backstop that neutralises any XSS that slips past sanitization; the
291        // others harden sniffing, framing, and referrer leakage.
292        .layer(static_header_layer(
293            "content-security-policy",
294            CONTENT_SECURITY_POLICY,
295        ))
296        .layer(static_header_layer("x-content-type-options", "nosniff"))
297        .layer(static_header_layer(
298            "referrer-policy",
299            "strict-origin-when-cross-origin",
300        ))
301        .layer(static_header_layer("x-frame-options", "DENY"))
302        .with_state(state)
303}
304
305/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
306/// axum's 2 MiB default.**
307///
308/// The value used to BE the framework default, which made the route's own
309/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
310/// nothing could test it, and the ceiling this route wanted was whatever the
311/// framework happened to pick. Sized to this route instead — one outline is
312/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
313/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
314/// larger is not a subscription list.
315///
316/// Being strictly tighter than the default is what makes the layer both real
317/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
318/// uploads a payload that only this limit refuses.
319const OPML_BODY_LIMIT: usize = 1024 * 1024;
320
321/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
322/// uploads a payload between the two ceilings.
323///
324/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
325/// number is exact — axum does not export it, so it cannot be imported. The
326/// exceeding is what the test's mutation demonstrates: with the route's layer
327/// removed, a payload of this size is accepted. If axum ever lowers its
328/// default below ours, that mutation stops failing and the compile-time
329/// assertion below is the thing to revisit.
330#[cfg(test)]
331const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
332
333/// The route's cap must stay strictly tighter than the framework's, or its
334/// layer is a no-op again. A compile error, not a test failure: this is a
335/// property of the two constants, and nothing should be able to build a binary
336/// where it is false.
337#[cfg(test)]
338const _: () = assert!(
339    OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
340    "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
341);
342
343/// A response-header layer that sets `name: value` on every response, overriding
344/// any existing header of that name. `name`/`value` must be valid static header
345/// tokens (they are, for our fixed security headers).
346fn static_header_layer(
347    name: &'static str,
348    value: &'static str,
349) -> SetResponseHeaderLayer<header::HeaderValue> {
350    SetResponseHeaderLayer::overriding(
351        header::HeaderName::from_static(name),
352        header::HeaderValue::from_static(value),
353    )
354}
355
356// ---------------------------------------------------------------------------
357// Per-IP rate limiting (token bucket, self-contained — no extra crate)
358// ---------------------------------------------------------------------------
359
360/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
361/// kick-off and callback, the invite redeem, logout, the mutating write
362/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
363/// intentionally *not* limited.
364///
365/// The criterion is **does this path make an outbound request**, not "does it
366/// mutate" — the two diverge, and every miss so far has been on the outbound
367/// side. This is an allowlist a new route has to be added to by hand, which is
368/// exactly why it has now been missed three times: `/saved/` (fixed), then
369/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
370/// only path here reachable with no session at all.
371///
372/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
373/// make PDS calls but are ordinary authenticated navigation, and throttling them
374/// would degrade normal reading. They are bounded by needing a valid session.
375fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
376    use axum::http::Method;
377    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
378    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
379    // like the other abuse-prone entry points — not just `/login`.
380    // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
381    // real outbound round-trip — a sidecar `resolve_session` or a full token
382    // exchange against a PDS. Anyone could spend one outbound request per hit.
383    // It is the only entry point here that needs no session at all.
384    if method != Method::POST
385        && !(method == Method::GET
386            && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
387    {
388        return false;
389    }
390    match path {
391        // `/logout` and `/oauth/callback` are here because they make outbound
392        // calls, not because they mutate: logout revokes at the PDS (up to two
393        // round-trips) and the callback exchanges a code. The list is by
394        // *network cost*, which is what the limiter is actually for.
395        "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
396        | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
397        | "/folders" => true,
398        // Every per-record subscription/folder mutation (delete/rename) and the
399        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
400        p => {
401            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
402                // Unsaving makes a DPoP-signed deleteRecord round-trip to the
403                // PDS, which is exactly the reason the neighbours above are
404                // limited. It was added as a new route and not added here.
405                || p.starts_with("/saved/")
406                || p.starts_with("/subscriptions/")
407                || p.starts_with("/folders/")
408        }
409    }
410}
411
412/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
413/// client-IP header (if any). Cloned into every request; both fields are cheap.
414#[derive(Clone)]
415struct RateLimitState {
416    limiter: RateLimiter,
417    /// The lowercased proxy header the operator trusts for the client IP, or
418    /// `None` to trust only the socket peer. See [`client_ip`].
419    trusted_header: Option<String>,
420}
421
422/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
423/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
424/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
425/// dependency → no network fetch at build, deterministic offline CI).
426#[derive(Clone)]
427struct RateLimiter {
428    inner: std::sync::Arc<Mutex<RateLimiterState>>,
429}
430
431/// The limiter's shared state: the buckets plus when they were last swept.
432struct RateLimiterState {
433    buckets: HashMap<IpAddr, Bucket>,
434    last_sweep: Instant,
435}
436
437/// One IP's token bucket: a fractional token count + the last-refill instant.
438struct Bucket {
439    tokens: f64,
440    last: Instant,
441}
442
443/// Burst capacity per IP — how many requests can arrive back-to-back.
444const RATE_BURST: f64 = 20.0;
445/// Steady-state refill rate (tokens/sec) once the burst is spent.
446const RATE_REFILL_PER_SEC: f64 = 1.0;
447/// Evict idle buckets older than this so the map can't grow unbounded.
448const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
449
450/// How often the idle sweep may actually run.
451///
452/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
453/// map to find entries that, by construction, can only age out on an hour
454/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
455/// under any volume of distinct source IPs the server spent its single shared
456/// core re-walking a map whose contents had not changed. Once a minute is
457/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
458const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
459
460/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
461/// target, sized so ordinary traffic never reaches it.
462///
463/// The idle eviction above was the only bound, and it is a TIME bound, which
464/// says nothing about how many distinct IPs can arrive inside one hour.
465/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
466/// this one did not.
467const MAX_RATE_BUCKETS: usize = 10_000;
468
469/// When the cap is hit, evict down to this fraction of it rather than removing
470/// a single entry — so the O(n) eviction happens once per `cap/8` requests
471/// instead of once per request while the map sits full.
472const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
473
474impl RateLimiter {
475    /// A fresh, shared limiter (cloned into the middleware state).
476    fn shared() -> Self {
477        Self {
478            inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
479                buckets: HashMap::new(),
480                last_sweep: Instant::now(),
481            })),
482        }
483    }
484
485    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
486    /// bucket is empty (→ 429).
487    fn check(&self, ip: IpAddr) -> bool {
488        self.check_at(ip, Instant::now())
489    }
490
491    /// [`check`](Self::check) with the clock injected, so the sweep and eviction
492    /// paths below are reachable in a test without sleeping through an hour.
493    fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
494        let mut state = match self.inner.lock() {
495            Ok(m) => m,
496            // A poisoned lock shouldn't take the site down — fail open.
497            Err(p) => p.into_inner(),
498        };
499
500        // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
501        if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
502            state
503                .buckets
504                .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
505            state.last_sweep = now;
506        }
507
508        // Hard size bound, independent of the time bound above.
509        //
510        // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
511        // attacker cannot use eviction to clear their OWN throttled bucket: that
512        // bucket is by definition the most recently touched, so it is the last
513        // thing this removes. Going quiet long enough to become the oldest entry
514        // is exactly what the refill already grants for free.
515        if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
516            let mut by_age: Vec<(IpAddr, Instant)> =
517                state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
518            by_age.sort_unstable_by_key(|(_, last)| *last);
519            for (victim, _) in by_age
520                .into_iter()
521                .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
522            {
523                state.buckets.remove(&victim);
524            }
525            warn!(
526                buckets = state.buckets.len(),
527                "rate-limit bucket cap reached; evicted the least recently seen clients"
528            );
529        }
530
531        let bucket = state.buckets.entry(ip).or_insert(Bucket {
532            tokens: RATE_BURST,
533            last: now,
534        });
535        let elapsed = now.duration_since(bucket.last).as_secs_f64();
536        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
537        bucket.last = now;
538        if bucket.tokens >= 1.0 {
539            bucket.tokens -= 1.0;
540            true
541        } else {
542            false
543        }
544    }
545}
546
547/// The **trusted** client IP for a request.
548///
549/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
550/// is fully bypassable — the left-most value is attacker-supplied (any client
551/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
552/// bucket and the per-IP limit never bites. We therefore derive the IP only from
553/// a source the operator controls:
554///
555/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
556///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
557///   set by the proxy we run in front and overwrites any client-supplied copy.
558///   We take the LAST value if the header happens to be a comma list (the hop
559///   the trusted proxy appended), which is also the correct read for a
560///   right-most-`X-Forwarded-For` deployment where the operator points
561///   `trusted_header` at `x-forwarded-for`.
562/// * Otherwise we ignore all forwarding headers and use the socket peer
563///   (`ConnectInfo`) — correct for a direct bind with no proxy.
564///
565/// Returns `None` only when neither source yields a parseable IP (the limiter
566/// then fails open for that one request).
567fn client_ip(
568    headers: &HeaderMap,
569    conn: Option<&SocketAddr>,
570    trusted_header: Option<&str>,
571) -> Option<IpAddr> {
572    if let Some(name) = trusted_header {
573        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
574            // Right-most hop is the one the trusted proxy appended; earlier
575            // entries may be client-forged, so never trust the left-most.
576            if let Some(last) = raw.split(',').next_back() {
577                if let Ok(ip) = last.trim().parse::<IpAddr>() {
578                    return Some(ip);
579                }
580            }
581        }
582        // Trusted header absent/unparseable → fall through to the socket peer.
583    }
584    conn.map(|s| s.ip())
585}
586
587/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
588/// empty; every other request (and every non-guarded path) passes through. The
589/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
590/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
591async fn rate_limit(
592    State(rl): State<RateLimitState>,
593    req: axum::extract::Request,
594    next: Next,
595) -> Response {
596    let path = req.uri().path().to_string();
597    let method = req.method().clone();
598    if is_rate_limited_path(&path, &method) {
599        let conn = req
600            .extensions()
601            .get::<ConnectInfo<SocketAddr>>()
602            .map(|c| c.0);
603        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
604        // Deliberately fail OPEN when no client IP is derivable (no trusted
605        // header / no socket peer): there is no per-IP key to enforce, and a
606        // blanket 429 would self-DoS every guarded path (incl. /login). This is
607        // safe precisely because we never key on an attacker-forged XFF — see
608        // `rate_limit_ignores_spoofed_xff_rotation`.
609        if let Some(ip) = ip {
610            if !rl.limiter.check(ip) {
611                warn!(%ip, %path, "rate limit exceeded");
612                return (
613                    StatusCode::TOO_MANY_REQUESTS,
614                    [(header::RETRY_AFTER, "1")],
615                    "rate limit exceeded\n",
616                )
617                    .into_response();
618            }
619        }
620    }
621    next.run(req).await
622}
623
624// ---------------------------------------------------------------------------
625// Cache-Control (viral / CDN vs. private authenticated views)
626// ---------------------------------------------------------------------------
627
628/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
629/// logged-out surfaces (the `/login` landing without a handle, `/about`,
630/// `/standard-site`, `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
631/// authenticated app pages, so a CDN /
632/// browser can hold the viral landing while never caching a signed-in user's
633/// private view. Never overrides a handler that already set Cache-Control.
634async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
635    let path = req.uri().path().to_string();
636    // The logged-out landing is only cacheable when it's the bare form — a
637    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
638    let is_login_landing = path == "/login"
639        && req.method() == axum::http::Method::GET
640        && !req.uri().query().unwrap_or("").contains("handle=");
641    let public = is_login_landing
642        || path == "/about"
643        || path == "/standard-site"
644        || path == "/privacy"
645        || path == "/terms"
646        || path.starts_with("/static/");
647
648    let mut resp = next.run(req).await;
649    if resp.headers().contains_key(header::CACHE_CONTROL) {
650        return resp;
651    }
652    let value = if public {
653        "public, max-age=300"
654    } else {
655        "no-store"
656    };
657    if let Ok(hv) = header::HeaderValue::from_str(value) {
658        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
659    }
660    resp
661}
662
663// ---------------------------------------------------------------------------
664// Health
665// ---------------------------------------------------------------------------
666
667/// Run `/health`'s database probe. **The single path, so a test cannot assert
668/// on a string the handler is free to ignore** — a named constant alone was not
669/// enough: the test read the constant while the handler passed `query_scalar`
670/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
671async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
672    sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
673        .fetch_optional(pool)
674        .await
675}
676
677/// The statement `/health` uses to prove the database is readable.
678///
679/// **A named constant so the test can assert on the query that actually runs.**
680/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
681/// this string, so degrading the real probe to `SELECT 1` — which opens no page
682/// and therefore cannot detect a broken database — left the suite green.
683const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
684
685/// How long `/health` will wait for its database ping before calling it broken.
686///
687/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
688/// handler chose rather than a timeout Fly inferred — the difference between a
689/// log line that says why and one that says nothing.
690const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
691
692/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
693/// see the handler for why.
694///
695/// The threshold itself is derived from the configured tick
696/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
697/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
698/// stale` in the body the deployment docs now tell them to alert on.
699const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
700
701/// How long without a completed tick before the poller reads as stale: several
702/// tick intervals, floored, so a normally-paced loop never trips it and a
703/// genuinely wedged one always does.
704fn health_tick_stale_secs(tick: Duration) -> i64 {
705    let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
706    tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
707}
708
709/// The poll tick this instance is configured for. Read from the same env var
710/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
711/// handler cannot see its constants.
712fn configured_poll_tick() -> Duration {
713    std::env::var("FEATHERREADER_POLL_TICK_SECS")
714        .ok()
715        .and_then(|v| v.trim().parse::<u64>().ok())
716        .filter(|s| *s > 0)
717        .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
718}
719
720/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
721/// so cannot be imported here. Duplicated deliberately and named, rather than
722/// left as a bare `60` inside the parse chain, so the drift is at least visible
723/// if the scheduler's value ever moves.
724const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
725
726/// Grace period after boot before a poller that has never ticked is called
727/// `stale` rather than `not-yet-ticked`.
728///
729/// Without this the two are indistinguishable forever, which matters precisely
730/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
731/// cycles the poller never reaches its first tick, so `/health` reported the
732/// benign `not-yet-ticked` on every single probe and the heartbeat could not
733/// detect the failure mode it exists for. `run_poller` returning early — a failed
734/// HTTP client build — has the same shape and was equally invisible.
735///
736/// Sized off the poller's own startup delay plus its tick, with slack.
737const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
738
739/// `GET /health` — does this process still work, and what are its loops doing?
740///
741/// This used to return a constant string, touching no database, no pool and no
742/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
743/// sole other failure detector is a child process exiting. It proved the HTTP
744/// listener was up and nothing else.
745///
746/// **What can fail the check: the database, and only the database.** A process
747/// that cannot reach its store serves nothing, so a restart is the right
748/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
749/// mode is not blocked by any writer — so the retention sweep, the poller and a
750/// login burst cannot make this flap. That property is the reason it is a read
751/// and not, say, a write canary.
752///
753/// **What is reported but never fails the check: everything else.** A stale poll
754/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
755/// and none of them a reason to stop serving.
756///
757/// That last clause is the whole justification, and it is NOT the one this
758/// comment used to give. It said "Fly restarts on a failed check", which is
759/// false — verified against Fly's own docs, which state it three times: *"your
760/// Machines won't automatically restart or stop due to failing their health
761/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
762/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
763/// V1 (`restart_limit`) and has no successor on Machines.
764///
765/// The corrected model makes the conclusion stronger, not weaker. With one
766/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
767/// failover — it is a total outage that lasts exactly as long as the condition,
768/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
769/// question the status code answers is not "would a restart fix this" but **"can
770/// this process still serve a useful request at all"**. A stale poller can. A
771/// database it cannot read cannot.
772///
773/// Re-registration is automatic: the proxy keeps probing and routes again the
774/// moment the check passes. That is what makes a 503 recoverable without
775/// intervention — not a restart, which never comes.
776///
777/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
778/// it is publishable on the same terms as `/stats`. It is also the non-session
779/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
780/// (which needs a live admin session) is exactly as unreachable as the thing it
781/// would diagnose, while this is reachable with `curl`.
782async fn health(State(state): State<AppState>) -> Response {
783    let now = chrono::Utc::now().timestamp();
784    let rh = &state.runtime_health;
785
786    use crate::runtime_health::DbProbe;
787    let db = match rh.begin_db_probe() {
788        // A probe is already in flight; report its predecessor rather than
789        // starting a second one. See `RuntimeHealth::begin_db_probe`.
790        Err(borrowed) => borrowed,
791        Ok(probe) => {
792            // **Spawned, so the probe cannot be cancelled by the caller.**
793            //
794            // Axum drops the handler future when a client disconnects. With the
795            // probe inline, that dropped it mid-flight and released the claim
796            // WITHOUT recording a verdict — which let an unauthenticated caller
797            // manufacture the no-verdict state on demand and freeze what every
798            // other caller, Fly's check included, reads. Running it detached
799            // means the verdict is always recorded and the claim is always
800            // released after it.
801            let pool = state.db.clone();
802            let task = tokio::spawn(async move {
803                // **`SELECT 1` was not a database probe.** It compiles to
804                // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
805                // never touches a b-tree, never reads a page, and never consults
806                // the file. Against a corrupted database it returns success
807                // while every real query returns SQLITE_CORRUPT. Reading one row
808                // from a real table costs the same and actually proves what the
809                // check claims. `LIMIT 1` keeps it to a single page; an empty
810                // table still opens the b-tree root, which is the part that
811                // matters.
812                let verdict =
813                    match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
814                        Ok(Ok(_)) => DbProbe::Ok,
815                        // Coarse, not the raw error. An unauthenticated caller
816                        // learning exactly which failure it hit is an
817                        // attack-progress oracle; the detail belongs in the log,
818                        // which gets it here.
819                        Ok(Err(err)) => {
820                            warn!(%err, "health: database probe failed");
821                            DbProbe::Failed("unavailable".to_string())
822                        }
823                        Err(_) => {
824                            warn!(
825                                timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
826                                "health: database probe timed out (pool exhausted?)"
827                            );
828                            DbProbe::Failed("timeout".to_string())
829                        }
830                    };
831                probe.record(verdict.clone());
832                verdict
833            });
834            // A panicking task drops the guard, which releases the claim without
835            // a verdict — the only remaining path to that state, and not one a
836            // caller can drive.
837            task.await.unwrap_or(DbProbe::Unknown)
838        }
839    };
840
841    let uptime = rh.uptime_secs(now);
842    let poller = if !rh.schedulers_enabled() {
843        // Not a fault. Dev runs and the seam tests disable the loops on purpose,
844        // and reporting that as "stale" would be a false alarm on every one.
845        "disabled".to_string()
846    } else {
847        match rh.secs_since_poll_tick(now) {
848            // "Never ticked" is benign right after boot and alarming well after
849            // it — so it is read against UPTIME, not left permanently benign.
850            None => match uptime {
851                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
852                    format!("stale never-ticked {up}s")
853                }
854                _ => "not-yet-ticked".to_string(),
855            },
856            Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
857                format!("stale {secs}s")
858            }
859            Some(secs) => format!("ok {secs}s"),
860        }
861    };
862
863    // **Only a MEASURED failure fails the check.**
864    //
865    // `Unknown` means no probe has completed — a concurrent request arrived
866    // before the first one finished, or a previous owner was cancelled before
867    // recording. It is reported and returns 200, because an unmeasured database
868    // is not evidence of a broken one, and this endpoint is reachable by
869    // unauthenticated callers who can manufacture that state. Treating it as a
870    // failure handed them a lever on the only signal the platform acts on.
871    let mut body = String::new();
872    let status = match &db {
873        DbProbe::Ok => {
874            body.push_str(&format!("ok featherreader/{VERSION}\n"));
875            body.push_str("db: ok\n");
876            StatusCode::OK
877        }
878        // **Not `ok`.** The first token is the state, and this one is neither
879        // healthy nor failed. It used to print a line byte-identical to the
880        // healthy branch, which mattered because `fly.toml` tells operators to
881        // alert on the BODY for everything the status code deliberately ignores
882        // — so a monitor keying on `^ok` read green in exactly the state this
883        // enum exists to make visible.
884        DbProbe::Unknown => {
885            body.push_str(&format!("unknown featherreader/{VERSION}\n"));
886            body.push_str("db: unknown (no probe has completed yet)\n");
887            StatusCode::OK
888        }
889        DbProbe::Failed(why) => {
890            body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
891            body.push_str(&format!("db: {why}\n"));
892            StatusCode::SERVICE_UNAVAILABLE
893        }
894    };
895    // Uptime answers the first question anyone asks about a container under a
896    // supervisor that tears the machine down whenever a child exits: is this
897    // thing restarting? Nothing else on any surface could tell you.
898    body.push_str(&format!(
899        "uptime: {}\n",
900        match uptime {
901            Some(secs) => format!("{secs}s"),
902            None => "unknown".to_string(),
903        }
904    ));
905    body.push_str(&format!("poller: {poller}\n"));
906    body.push_str(&format!(
907        "polling-paused: {}\n",
908        if rh.watermark_paused() { "yes" } else { "no" }
909    ));
910    // Deliberately NOT the measured database size. `/health` is the one path
911    // exempted from the Caddy origin lock, so it answers direct hits to the Fly
912    // IP that never passed Cloudflare — which caps what belongs here at the
913    // class of facts `/stats` already publishes to anyone. "Polling is paused"
914    // is that; the exact byte count is a precise internal number that adds
915    // nothing an operator cannot get from `/stats` or the logs.
916    body.push_str(&format!(
917        "backend: {}\n",
918        state.config.repo_backend.as_str()
919    ));
920    body.push_str(&format!(
921        "oauth-runtime: {}\n",
922        if state.oauth.is_some() {
923            "built"
924        } else {
925            "absent"
926        }
927    ));
928
929    // Never cached: a stale health response is worse than none, and Cloudflare
930    // sits in front of this.
931    let mut resp = (status, body).into_response();
932    if let Ok(hv) = header::HeaderValue::from_str("no-store") {
933        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
934    }
935    resp
936}
937
938/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
939/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
940/// Readable whether or not a session exists.
941///
942/// Optionally carries one quiet line about network adoption
943/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
944/// default — the handler issues **zero** queries and the page is byte-identical
945/// to what it was before the probe existed.
946async fn about(State(state): State<AppState>) -> Response {
947    let adoption = if state.config.show_adoption {
948        adoption_line(&state).await
949    } else {
950        None
951    };
952    render(&AboutTemplate {
953        card: Card::public(
954            &state.config,
955            "/about",
956            "About — FeatherReader",
957            "What FeatherReader is and isn't: an open-source, atproto-native reader for \
958             RSS feeds and standard.site publications, run as an experiment, free to \
959             self-host under the AGPL.",
960        ),
961        version: VERSION,
962        repo_url: REPO_URL,
963        kofi_url: KOFI_URL,
964        adoption,
965        standard_site: state.config.standard_site,
966    })
967}
968
969/// `GET /standard-site` — the public feature page for standard.site
970/// publications: what a publication is, what FeatherReader shows from one, how
971/// to subscribe, the limits, and the latest releases. Readable whether or not
972/// a session exists, like `/about`. Every how-to-subscribe line is conditional
973/// on `Config::standard_site`, as on the other public pages.
974async fn standard_site(State(state): State<AppState>) -> Response {
975    render(&StandardSiteTemplate {
976        card: Card::public(
977            &state.config,
978            "/standard-site",
979            "standard.site — FeatherReader",
980            "Read standard.site publications beside your RSS feeds: articles \
981             published as atproto records, followed with the same portable \
982             subscription record.",
983        ),
984        version: VERSION,
985        repo_url: REPO_URL,
986        kofi_url: KOFI_URL,
987        standard_site: state.config.standard_site,
988        releases: RELEASES,
989    })
990}
991
992/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
993///
994/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
995/// does not have. This deletes the record straight from the repo by its rkey,
996/// and then clears any LOCAL star for the same article.
997///
998/// That second step is not belt-and-braces. "Has no local entry" is how the
999/// starred view classifies a record, and it decides that through `sub_ref` — so
1000/// an article that really is cached, and really is starred, lands here whenever
1001/// the reader has unsubscribed from its feed. Deleting only the record left
1002/// `entry_state.starred = 1` behind: invisible, because the starred list is
1003/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
1004/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
1005/// places it lives.
1006async fn unsave_record(
1007    State(state): State<AppState>,
1008    headers: HeaderMap,
1009    Path(rkey): Path<String>,
1010) -> Response {
1011    let Some(did) = current_did(&state, &headers).await else {
1012        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1013    };
1014
1015    // Read the record's identity BEFORE deleting it — afterwards there is
1016    // nothing left to learn it from. Best-effort: a failure here must not block
1017    // the deletion the reader actually asked for, so it degrades to the old
1018    // behaviour (record gone, local star possibly stale) and says so.
1019    let identity = match state.repo().list_saved(&did).await {
1020        Ok(records) => records
1021            .into_iter()
1022            .find(|(k, _)| *k == rkey)
1023            .map(|(_, rec)| (rec.url, rec.entry_id)),
1024        Err(err) => {
1025            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1026                                      a local star for the same article may survive");
1027            None
1028        }
1029    };
1030
1031    match state.repo().remove_saved(&did, &rkey).await {
1032        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1033        Err(err) => {
1034            warn!(%err, %did, %rkey, "could not remove the saved record");
1035            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1036        }
1037    }
1038
1039    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1040    // saved, so clearing the local star before knowing the record is gone would
1041    // be the desync in the other direction.
1042    if let Some((url, guid)) = identity {
1043        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1044            Ok(0) => {}
1045            Ok(n) => {
1046                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1047            }
1048            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1049        }
1050    }
1051    // htmx swaps the row out; a plain form post goes back to the starred list.
1052    if is_htmx(&headers) {
1053        return (StatusCode::OK, "").into_response();
1054    }
1055    Redirect::to("/?view=starred").into_response()
1056}
1057
1058/// What the poller is doing, as one word for `/stats`.
1059///
1060/// **Parity with `/health` is the point.** `polling_paused` alone reported
1061/// "running" for three different states including the two where nothing polls,
1062/// on the page added to answer exactly that. The first attempt at fixing it
1063/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1064/// poll loop that ticked once at boot and then WEDGED still read as running.
1065/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1066/// original finding's exact shape surviving its own fix.
1067///
1068/// Shares the staleness threshold with `/health` rather than picking its own, so
1069/// the two pages cannot disagree about what "stale" means.
1070fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1071    if !rh.schedulers_enabled() {
1072        return "off";
1073    }
1074    // Checked before the pause: a wedged poller cannot clear a pause either, so
1075    // reporting "paused" would name the symptom and hide the cause.
1076    match rh.secs_since_poll_tick(now_unix) {
1077        None => {
1078            // Never ticked. Benign at boot, a dead loop long after — read
1079            // against uptime, exactly as `/health` does.
1080            match rh.uptime_secs(now_unix) {
1081                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1082                _ => "starting",
1083            }
1084        }
1085        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1086        _ if rh.watermark_paused() => "paused",
1087        _ => "running",
1088    }
1089}
1090
1091/// `GET /stats` — public poll health.
1092async fn stats(State(state): State<AppState>) -> Response {
1093    let now = chrono::Utc::now();
1094    let health = match store::poll_health(
1095        &state.db,
1096        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1097        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1098    )
1099    .await
1100    {
1101        Ok(health) => health,
1102        Err(err) => {
1103            warn!(%err, "could not compute poll health");
1104            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1105        }
1106    };
1107
1108    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1109    // instance is not behind on anything.
1110    let polled_pct = if health.feeds_tracked == 0 {
1111        100
1112    } else {
1113        health.polled_last_hour * 100 / health.feeds_tracked
1114    };
1115
1116    render(&StatsTemplate {
1117        card: Card::public(
1118            &state.config,
1119            "/stats",
1120            "Stats — FeatherReader",
1121            "Is this instance's poller keeping up? Aggregate feed-polling health — \
1122             counts only; no feed and no reader is named.",
1123        ),
1124        version: VERSION,
1125        repo_url: REPO_URL,
1126        kofi_url: KOFI_URL,
1127        feeds_tracked: health.feeds_tracked,
1128        polled_last_hour: health.polled_last_hour,
1129        polled_pct,
1130        overdue: health.overdue,
1131        last_poll: humanise_ago(health.last_poll_secs_ago),
1132        oldest_poll: if health.never_polled > 0 {
1133            "never".to_string()
1134        } else {
1135            humanise_ago(health.oldest_poll_secs_ago)
1136        },
1137        never_polled: health.never_polled,
1138        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1139        // **The two states that actually stop feeds updating.**
1140        //
1141        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1142        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1143        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1144        // feed failing every fetch drops out of the backlog and makes the page
1145        // read healthier. Both of these are machine facts with no per-feed
1146        // detail, so they sit inside the page's stated contract.
1147        in_backoff: health.in_backoff,
1148        badly_broken: health.badly_broken,
1149        failure_kinds: health.failure_kinds,
1150        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1151    })
1152}
1153
1154/// "3h 11m ago", or "never" when there has been no poll at all.
1155///
1156/// `None` must not render as `0` — on a fresh instance that would read as
1157/// "polled just now", which is the opposite of the truth.
1158fn humanise_ago(secs: Option<i64>) -> String {
1159    let Some(secs) = secs else {
1160        return "never".to_string();
1161    };
1162    match secs {
1163        s if s < 60 => format!("{s}s ago"),
1164        s if s < 3600 => format!("{}m ago", s / 60),
1165        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1166    }
1167}
1168
1169/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1170/// observation of zero, or a store failure).
1171///
1172/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1173/// probe is never allowed to affect the reader, and that rule applies at the
1174/// display end too — a locked or corrupt DB costs the About page one log line,
1175/// not a 500.
1176async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1177    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1178        // A legitimate zero renders nothing rather than a sad "0 accounts".
1179        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1180            repos: stat.value,
1181            truncated: stat.truncated,
1182            observed_on: stat
1183                .observed_at
1184                .split('T')
1185                .next()
1186                .unwrap_or_default()
1187                .to_string(),
1188        }),
1189        Ok(_) => None,
1190        Err(err) => {
1191            warn!(%err, "about: adoption stat read failed; omitting the line");
1192            None
1193        }
1194    }
1195}
1196
1197/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1198/// lives in the user's PDS, what the server caches, and the session-token
1199/// handling. A static render; readable whether or not a session exists.
1200async fn privacy(State(state): State<AppState>) -> Response {
1201    render(&PrivacyTemplate {
1202        card: Card::public(
1203            &state.config,
1204            "/privacy",
1205            "Privacy — FeatherReader",
1206            "No account and no tracking: your subscriptions and reading state live in \
1207             your own PDS. What this server caches, for how long, and how the session \
1208             token is handled.",
1209        ),
1210        version: VERSION,
1211        repo_url: REPO_URL,
1212        kofi_url: KOFI_URL,
1213    })
1214}
1215
1216/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1217/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1218/// static render; readable whether or not a session exists.
1219async fn terms(State(state): State<AppState>) -> Response {
1220    render(&TermsTemplate {
1221        card: Card::public(
1222            &state.config,
1223            "/terms",
1224            "Terms — FeatherReader",
1225            "The terms of use: an experimental service offered as-is with no warranty, \
1226             what acceptable use means here, and the AGPL self-host note.",
1227        ),
1228        version: VERSION,
1229        repo_url: REPO_URL,
1230        kofi_url: KOFI_URL,
1231    })
1232}
1233
1234// ---------------------------------------------------------------------------
1235// View models
1236// ---------------------------------------------------------------------------
1237
1238/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1239/// and the PDS subscription rkey for management actions).
1240struct FeedView {
1241    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1242    rkey: String,
1243    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1244    url: String,
1245    title: String,
1246    unread: i64,
1247    /// Whether this feed is the currently-selected scope.
1248    selected: bool,
1249    /// The feed's current folder `at://` URI (from its subscription record), or
1250    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1251    /// rename row so an untouched folder dropdown does not silently un-folder the
1252    /// feed on save.
1253    folder: Option<String>,
1254}
1255
1256/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1257struct FolderView {
1258    /// PDS folder rkey — addresses the record for rename/delete.
1259    rkey: String,
1260    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1261    uri: String,
1262    name: String,
1263    feeds: Vec<FeedView>,
1264    /// Whether this folder is the currently-selected scope.
1265    selected: bool,
1266}
1267
1268/// One entry as shown in the article list / after an htmx swap.
1269struct EntryRow {
1270    id: i64,
1271    title: String,
1272    feed_title: String,
1273    published: String,
1274    read: bool,
1275    starred: bool,
1276    /// The reader link href, already carrying the scope/view query so opening an
1277    /// entry and paging back stays within the list it came from.
1278    link: SafeLink,
1279    /// Whether the article itself is in this instance's cache.
1280    ///
1281    /// `false` for a saved record that exists in the reader's PDS but whose
1282    /// entry was never cached here — starred in another atproto reader, or
1283    /// starred here and since evicted. There is no local row, so the row has no
1284    /// usable `id`: it links straight out to the article and carries no
1285    /// mark-read control, because there is nothing local to mark.
1286    cached: bool,
1287    /// The PDS record key, for un-saving a row that has no local entry.
1288    rkey: String,
1289}
1290
1291/// A folder as an option in the "move feed to folder" select.
1292struct FolderOption {
1293    uri: String,
1294    name: String,
1295}
1296
1297/// The shared navigation "rail" model: the same DOM element is the
1298/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1299/// manage) renders it from this one struct. Feed management lives on `/manage`,
1300/// not here — the rail is navigation only.
1301struct Nav {
1302    /// `@handle` for the identity chip (falls back to the DID's tail).
1303    handle: String,
1304    /// Two-letter avatar initials for the identity chip.
1305    avatar: String,
1306    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1307    view: String,
1308    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1309    /// empty for the unscoped "everything" views.
1310    scope_qs: String,
1311    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1312    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1313    folders: Vec<FolderView>,
1314    loose_feeds: Vec<FeedView>,
1315    /// Whether the "Manage feeds" rail tool is the current page.
1316    manage_active: bool,
1317}
1318
1319/// The subscribe input's `pattern` when standard.site is on, and the input is
1320/// `type="text"` (see `templates/manage.html`). It keeps the browser asking for
1321/// a scheme, as `type="url"` did, while admitting `at://`. Matched in any case,
1322/// because the handler canonicalises the scheme. Browsers compile `pattern`
1323/// with the `v` flag and anchor it at both ends. Unlike `type="url"`, a text
1324/// input does not strip surrounding whitespace before checking, so the pattern
1325/// allows it: a URL pasted with a leading space is common, and the handler
1326/// trims it.
1327pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1328
1329// ---------------------------------------------------------------------------
1330// Link cards (Open Graph / Twitter / Bluesky)
1331// ---------------------------------------------------------------------------
1332
1333/// The site's own title: the landing page's, and the one every private view
1334/// shows instead of its own.
1335const SITE_TITLE: &str = "FeatherReader — read, quietly";
1336
1337/// The site's one-paragraph description: the landing page's, and the one every
1338/// private view shows instead of its own.
1339const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1340standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1341password, no tracking.";
1342
1343/// Where the share image is served, relative to the public origin. The file is
1344/// `static/social-card.png`, rendered from `static/social-card.svg` by
1345/// `scripts/social-card.sh`; `base.html` advertises its dimensions, and a test
1346/// checks the PNG's own header agrees.
1347const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1348
1349/// What a link to a page unfurls as when it is posted — on Bluesky, in a chat,
1350/// anywhere that reads Open Graph tags. `base.html` renders it into `<head>`.
1351///
1352/// Measured before this existed: Bluesky's card service
1353/// (`cardyb.bsky.app/v1/extract?url=https://feather-reader.com/`) returned
1354/// `{"title":"FeatherReader — read, quietly","description":"","image":""}`,
1355/// because `<title>` was the only tag it could find. Card fetchers read the
1356/// initial HTML server-side, run no JS, and resolve nothing relative, so every
1357/// URL here is absolute on [`Config::public_url`] — `https://feather-reader.com`
1358/// in production, whatever `FEATHERREADER_PUBLIC_URL` says elsewhere.
1359#[derive(Debug, Clone)]
1360pub(crate) struct Card {
1361    /// `og:title`. On a public page, the same text as its `<title>`.
1362    pub title: String,
1363    /// `og:description` and `<meta name="description">`: one or two plain
1364    /// sentences about THIS page, not the site.
1365    pub description: String,
1366    /// `og:url` and `<link rel="canonical">`: absolute, on the public origin.
1367    pub url: String,
1368    /// `og:image`: absolute, on the public origin.
1369    pub image: String,
1370    /// Set on a page that renders a session's private view. The card is then
1371    /// the site's generic one — nothing from the view reaches `<head>` — and
1372    /// the page is `noindex`.
1373    pub private: bool,
1374}
1375
1376impl Card {
1377    /// The card of the public page at `path` (leading slash) on this instance.
1378    fn public(
1379        config: &Config,
1380        path: &str,
1381        title: impl Into<String>,
1382        description: impl Into<String>,
1383    ) -> Self {
1384        let origin = config.public_url.trim_end_matches('/');
1385        Card {
1386            title: title.into(),
1387            description: description.into(),
1388            url: format!("{origin}{path}"),
1389            image: format!("{origin}{SHARE_IMAGE_PATH}"),
1390            private: false,
1391        }
1392    }
1393
1394    /// The landing page's card: the site's own title and description.
1395    fn site(config: &Config) -> Self {
1396        Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1397    }
1398
1399    /// The card of a page that renders a session's private view: the site's
1400    /// generic card pointing at the front door, plus `noindex`. The view's
1401    /// heading, feed names and handle stay out of `<head>`.
1402    fn private(config: &Config) -> Self {
1403        Card {
1404            private: true,
1405            ..Card::site(config)
1406        }
1407    }
1408}
1409
1410/// The reader index (`GET /`).
1411#[derive(Template)]
1412#[template(path = "index.html")]
1413struct IndexTemplate {
1414    /// The link card. A private view: the site's generic card, `noindex`.
1415    card: Card,
1416    version: &'static str,
1417    repo_url: &'static str,
1418    kofi_url: &'static str,
1419    flash: String,
1420    /// Shown as `role="alert"` when the subscription list is the cached one
1421    /// because the PDS listing failed; empty otherwise.
1422    alert: String,
1423    /// The shared rail (drawer + desktop sidebar) navigation model.
1424    nav: Nav,
1425    /// The article list for the selected scope + view.
1426    entries: Vec<EntryRow>,
1427    /// The list heading (the selected view/feed/folder name).
1428    heading: String,
1429    /// Whether a feed scope is active (enables per-feed mark-all-read).
1430    feed_scope: Option<String>,
1431    /// Total CACHED entries in this scope + view across ALL pages. The count used
1432    /// to be `entries.len()`, which was the same number only because the list was
1433    /// unpaged — the thing this change exists to stop.
1434    ///
1435    /// The pager is derived from this, so it must not include the uncached PDS
1436    /// rows below: they are appended to the last page rather than paged, and
1437    /// counting them here advertised a page the clamp could never reach.
1438    total: i64,
1439    /// How many of `total` are PDS saved records the cache cannot show.
1440    ///
1441    /// A subset of `total`, not an addition to it — the heading says "N entries
1442    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1443    /// which double counted once `total` started including them, against an M
1444    /// that had become page-local in the same commit while the template stayed
1445    /// put.
1446    uncached_total: i64,
1447    /// 1-based current page.
1448    page: i64,
1449    /// Total pages, at least 1 (an empty list is page 1 of 1).
1450    page_count: i64,
1451    /// Link to the previous (newer) page, or `None` on the first.
1452    prev_href: Option<String>,
1453    /// Link to the next (older) page, or `None` on the last.
1454    next_href: Option<String>,
1455}
1456
1457/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1458#[derive(Template)]
1459#[template(path = "manage.html")]
1460struct ManageTemplate {
1461    /// The link card. A private view: the site's generic card, `noindex`.
1462    card: Card,
1463    version: &'static str,
1464    repo_url: &'static str,
1465    kofi_url: &'static str,
1466    flash: String,
1467    /// See [`IndexTemplate::alert`].
1468    alert: String,
1469    nav: Nav,
1470    /// All folders as move-targets for the subscribe folder select.
1471    folder_options: Vec<FolderOption>,
1472    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1473    folders: Vec<FolderView>,
1474    loose_feeds: Vec<FeedView>,
1475    /// `Config::standard_site`. With it on, the subscribe form says a
1476    /// `site.standard.publication` URI is accepted and its input drops
1477    /// `type="url"`, whose browser validation rejects the DID form. With it off
1478    /// `add_subscription` refuses every `at://` paste, so the form must not
1479    /// advertise one.
1480    standard_site: bool,
1481}
1482
1483/// The optional one-line adoption fact at the bottom of `/about`
1484/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1485/// probe has succeeded yet, or the read failed — the line then simply does not
1486/// render.
1487struct AdoptionLine {
1488    /// Repos a relay has indexed as holding the subscription collection.
1489    repos: i64,
1490    /// The probe hit its page cap, so the copy must say "at least".
1491    truncated: bool,
1492    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1493    observed_on: String,
1494}
1495
1496/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1497/// the optional adoption line.
1498#[derive(Template)]
1499#[template(path = "about.html")]
1500struct AboutTemplate {
1501    /// The link card: this page's own title and description.
1502    card: Card,
1503    version: &'static str,
1504    repo_url: &'static str,
1505    kofi_url: &'static str,
1506    adoption: Option<AdoptionLine>,
1507    /// `Config::standard_site`: whether the publications section may tell the
1508    /// reader how to subscribe to one here. See [`ManageTemplate::standard_site`].
1509    standard_site: bool,
1510}
1511
1512/// The public `/standard-site` feature page. Carries the same footer fields
1513/// as the other public pages, the standard.site flag, and the release list for
1514/// the "latest releases" call-out.
1515#[derive(Template)]
1516#[template(path = "standard_site.html")]
1517struct StandardSiteTemplate {
1518    /// The link card: this page's own title and description.
1519    card: Card,
1520    version: &'static str,
1521    repo_url: &'static str,
1522    kofi_url: &'static str,
1523    /// `Config::standard_site`: whether the page may tell a visitor how to
1524    /// subscribe to a publication here. See [`ManageTemplate::standard_site`].
1525    standard_site: bool,
1526    /// [`RELEASES`], newest first, for `templates/releases.html`.
1527    releases: &'static [Release],
1528}
1529
1530/// One tagged release, as the "latest releases" call-out
1531/// (`templates/releases.html`) shows it on `/standard-site` and the landing
1532/// page. The links are derived from `version` and `date`, so a release is
1533/// described in exactly one place: an entry in [`RELEASES`].
1534pub(crate) struct Release {
1535    /// The crate version, without the `v` (`"0.4.1"`). The tag is `v{version}`.
1536    pub(crate) version: &'static str,
1537    /// The release date, `YYYY-MM-DD`, as the CHANGELOG heading has it.
1538    pub(crate) date: &'static str,
1539    /// One or two plain sentences for a visitor. No markup: the template escapes it.
1540    pub(crate) summary: &'static str,
1541}
1542
1543impl Release {
1544    /// The GitHub release page: `{REPO_URL}/releases/tag/v{version}`.
1545    pub(crate) fn url(&self) -> String {
1546        format!("{REPO_URL}/releases/tag/v{}", self.version)
1547    }
1548
1549    /// The release's section of `CHANGELOG.md` on `main`. GitHub derives the
1550    /// anchor for a heading `## 0.4.1 — 2026-10-04` as `041--2026-10-04`: the
1551    /// dots dropped, the em dash dropped, each space a hyphen.
1552    pub(crate) fn changelog_url(&self) -> String {
1553        format!(
1554            "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1555            self.version.replace('.', ""),
1556            self.date
1557        )
1558    }
1559}
1560
1561/// **The one place a release is described for the website.** Newest first.
1562/// To announce the next release, add one entry at the top; the call-out on
1563/// `/standard-site` and the landing page, and both links, follow from it.
1564/// `releases_are_newest_first_and_link_the_tag_and_changelog` pins the shape.
1565pub(crate) const RELEASES: &[Release] = &[
1566    Release {
1567        version: "0.4.2",
1568        date: "2026-10-04",
1569        summary: "A public standard.site feature page with this list of recent \
1570                  releases, and link cards: a posted feather-reader.com link \
1571                  now unfurls with a description and an image.",
1572    },
1573    Release {
1574        version: "0.4.1",
1575        date: "2026-10-04",
1576        summary: "The public pages explain standard.site publications, and the \
1577                  subscribe form can submit the DID form of a publication URI, \
1578                  which browsers refused in 0.4.0.",
1579    },
1580    Release {
1581        version: "0.4.0",
1582        date: "2026-10-03",
1583        summary: "standard.site support: publications are read from their \
1584                  authors' atproto repos as subscriptions, beside RSS, on their \
1585                  own polling loop. Every stored field from a feed or a \
1586                  publication now has a size bound.",
1587    },
1588];
1589
1590/// The public `/stats` page — is the poller keeping up?
1591///
1592/// Aggregate only, deliberately. It is published to anyone, so it carries no
1593/// user counts and no per-feed detail: a reader does not need to know how many
1594/// people use an instance or which feeds are failing. What it does answer is the
1595/// question that decides whether an instance can take more readers — whether the
1596/// poller is servicing the feeds it already has.
1597///
1598/// The counts below are aggregate machine facts, which is why they fit that
1599/// contract: "12 feeds are in backoff" names no feed and no reader, while
1600/// answering the question the page was previously unable to answer at all.
1601#[derive(Template)]
1602#[template(path = "stats.html")]
1603struct StatsTemplate {
1604    /// The link card: this page's own title and description.
1605    card: Card,
1606    version: &'static str,
1607    repo_url: &'static str,
1608    kofi_url: &'static str,
1609    feeds_tracked: i64,
1610    polled_last_hour: i64,
1611    polled_pct: i64,
1612    overdue: i64,
1613    last_poll: String,
1614    oldest_poll: String,
1615    never_polled: i64,
1616    poll_interval_mins: i64,
1617    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1618    in_backoff: i64,
1619    /// Of those, the ones retried hours apart rather than minutes. **Not
1620    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1621    /// their next successful poll, and most of this instance's did.
1622    badly_broken: i64,
1623    /// Failing feeds by cause, descending — counts only, never which feed.
1624    failure_kinds: Vec<(String, i64)>,
1625    /// What the poller is actually doing: `running`, `paused` (at the size
1626    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1627    /// disabled). Three of those four used to render as "running".
1628    fetching: &'static str,
1629}
1630
1631/// The public `/privacy` page — what the server holds vs. what lives in the
1632/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1633/// footer include needs.
1634#[derive(Template)]
1635#[template(path = "privacy.html")]
1636struct PrivacyTemplate {
1637    /// The link card: this page's own title and description.
1638    card: Card,
1639    version: &'static str,
1640    repo_url: &'static str,
1641    kofi_url: &'static str,
1642}
1643
1644/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1645/// same fields the shared footer include needs.
1646#[derive(Template)]
1647#[template(path = "terms.html")]
1648struct TermsTemplate {
1649    /// The link card: this page's own title and description.
1650    card: Card,
1651    version: &'static str,
1652    repo_url: &'static str,
1653    kofi_url: &'static str,
1654}
1655
1656/// The signed-out landing page (`GET /` with no session) — the public front
1657/// door at feather-reader.com. A static render, no session required.
1658#[derive(Template)]
1659#[template(path = "landing.html")]
1660struct LandingTemplate {
1661    /// The link card: the site's own title and description.
1662    card: Card,
1663    version: &'static str,
1664    repo_url: &'static str,
1665    crates_url: &'static str,
1666    kofi_url: &'static str,
1667    /// `Config::standard_site`: whether the publications point may tell a
1668    /// visitor how to subscribe to one here. See [`ManageTemplate::standard_site`].
1669    standard_site: bool,
1670    /// [`RELEASES`], newest first, for the quiet "latest releases" strip.
1671    releases: &'static [Release],
1672}
1673
1674/// The single-entry reader view (`GET /entries/:id`).
1675#[derive(Template)]
1676#[template(path = "entry.html")]
1677struct EntryTemplate {
1678    /// The link card. A private view: the site's generic card, `noindex`.
1679    card: Card,
1680    version: &'static str,
1681    repo_url: &'static str,
1682    kofi_url: &'static str,
1683    nav: Nav,
1684    id: i64,
1685    title: String,
1686    feed_title: String,
1687    author: Option<String>,
1688    published: String,
1689    /// The entry's own link, for `entry.html`'s two `href`s.
1690    ///
1691    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1692    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1693    /// long way from the `href` and holds only while every future writer to
1694    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1695    /// defence that, on the saved-record row, turned out to be deletable with
1696    /// all 679 tests still green. `None` is the refusal: the template's
1697    /// no-URL branch already renders a disabled open-original button.
1698    url: Option<SafeLink>,
1699    content_html: Option<String>,
1700    read: bool,
1701    starred: bool,
1702    /// The query string to carry the reading context back to the list.
1703    back_qs: String,
1704    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1705    prev_id: Option<i64>,
1706    next_id: Option<i64>,
1707    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1708    oob: bool,
1709}
1710
1711/// The htmx swap fragment for a single entry row (`entry_row.html`).
1712#[derive(Template)]
1713#[template(path = "entry_row.html")]
1714struct EntryRowTemplate {
1715    e: EntryRow,
1716}
1717
1718/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1719/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1720/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1721/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1722#[derive(Template)]
1723#[template(path = "entry_actionbar.html")]
1724struct EntryActionBarTemplate {
1725    id: i64,
1726    read: bool,
1727    starred: bool,
1728    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1729    oob: bool,
1730}
1731
1732/// The login stub (`GET /login`).
1733#[derive(Template)]
1734#[template(path = "login.html")]
1735struct LoginTemplate {
1736    /// The link card: this page's own title and description.
1737    card: Card,
1738    repo_url: &'static str,
1739    error: String,
1740    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1741    /// distinct from `error`. Empty renders nothing.
1742    flash: String,
1743}
1744
1745/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1746#[derive(Template)]
1747#[template(path = "beta_redeem.html")]
1748struct BetaRedeemTemplate {
1749    /// The link card: this page's own title and description.
1750    card: Card,
1751    repo_url: &'static str,
1752    error: String,
1753    /// When true the seat cap is full: hide the form and show the "capacity
1754    /// full — try self-hosting" message instead.
1755    capacity_full: bool,
1756}
1757
1758// ---------------------------------------------------------------------------
1759// Rendering + error helpers
1760// ---------------------------------------------------------------------------
1761
1762/// Render an askama template into an HTML response, mapping a render failure to
1763/// a `500` rather than panicking (no `unwrap` in the request path).
1764fn render<T: Template>(tmpl: &T) -> Response {
1765    match tmpl.render() {
1766        Ok(body) => Html(body).into_response(),
1767        Err(err) => {
1768            warn!(%err, "template render failed");
1769            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1770        }
1771    }
1772}
1773
1774/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1775/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1776/// by default; a handler may override the status (e.g. `413` for an over-cap
1777/// upload) via [`WebError::with_status`].
1778struct WebError {
1779    err: anyhow::Error,
1780    status: StatusCode,
1781}
1782
1783impl<E: Into<anyhow::Error>> From<E> for WebError {
1784    fn from(err: E) -> Self {
1785        WebError {
1786            err: err.into(),
1787            status: StatusCode::INTERNAL_SERVER_ERROR,
1788        }
1789    }
1790}
1791
1792impl WebError {
1793    /// Attach an explicit HTTP status to render instead of the default `500`.
1794    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1795        WebError {
1796            err: err.into(),
1797            status,
1798        }
1799    }
1800}
1801
1802impl IntoResponse for WebError {
1803    fn into_response(self) -> Response {
1804        warn!(error = %self.err, status = %self.status, "request failed");
1805        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1806            "internal error"
1807        } else {
1808            self.status.canonical_reason().unwrap_or("error")
1809        };
1810        (self.status, body).into_response()
1811    }
1812}
1813
1814/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1815/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1816/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1817/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1818fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1819    let status = err.status();
1820    WebError::with_status(err, status)
1821}
1822
1823/// A short, human display of a feed/site title for the sidebar/list, falling
1824/// back to the host of a URL and finally to the raw string.
1825fn display_title(title: Option<&str>, url: &str) -> String {
1826    if let Some(t) = title {
1827        let t = t.trim();
1828        if !t.is_empty() {
1829            return t.to_string();
1830        }
1831    }
1832    url::Url::parse(url)
1833        .ok()
1834        .and_then(|u| u.host_str().map(str::to_string))
1835        .unwrap_or_else(|| url.to_string())
1836}
1837
1838/// A display `@handle` for the identity chip: the stored handle if present,
1839/// else the tail of the DID so the chip is never empty.
1840fn display_handle(handle: Option<&str>, did: &str) -> String {
1841    match handle {
1842        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1843        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1844    }
1845}
1846
1847/// Two-letter, lowercase avatar initials from a handle/DID.
1848fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1849    let source = handle
1850        .map(|h| h.trim().trim_start_matches('@'))
1851        .filter(|h| !h.is_empty())
1852        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1853    let letters: String = source
1854        .chars()
1855        .filter(|c| c.is_alphanumeric())
1856        .take(2)
1857        .collect::<String>()
1858        .to_lowercase();
1859    if letters.is_empty() {
1860        "fr".to_string()
1861    } else {
1862        letters
1863    }
1864}
1865
1866/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1867/// low-noise display. Falls back to the raw string if it doesn't look like one.
1868fn display_date(published: Option<&str>) -> String {
1869    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1870    // multi-byte character, and every caller used to pass a timestamp the feed
1871    // parser had produced. The saved-record path passes `createdAt` straight off
1872    // a PDS record, which the lexicon types as a bare string with no validation
1873    // — written by whatever atproto client the reader used. A `createdAt` of
1874    // "日本語日本語日本" took down the whole starred view, and there is no
1875    // catch-panic layer in the stack, so the page stayed down until the record
1876    // was removed from the very view that would not render.
1877    match published {
1878        Some(p) => p.chars().take(10).collect(),
1879        None => String::new(),
1880    }
1881}
1882
1883/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1884/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1885/// a bare value, and this keeps the scope-preserving links honest.
1886fn qenc(s: &str) -> String {
1887    let mut out = String::with_capacity(s.len() * 3);
1888    for b in s.bytes() {
1889        match b {
1890            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1891                out.push(b as char)
1892            }
1893            _ => out.push_str(&format!("%{b:02X}")),
1894        }
1895    }
1896    out
1897}
1898
1899// ---------------------------------------------------------------------------
1900// Reader: index
1901// ---------------------------------------------------------------------------
1902
1903/// Query for `GET /` — the scope + view selector.
1904#[derive(Debug, Deserialize, Default)]
1905struct IndexQuery {
1906    /// Filter to a single feed by its canonical URL.
1907    #[serde(default)]
1908    feed: Option<String>,
1909    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1910    #[serde(default)]
1911    folder: Option<String>,
1912    /// `unread` (default) | `all` | `starred`.
1913    #[serde(default)]
1914    view: Option<String>,
1915    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1916    #[serde(default)]
1917    page: Option<u32>,
1918    /// Optional flash message (e.g. after an action redirect).
1919    #[serde(default)]
1920    flash: Option<String>,
1921}
1922
1923/// Rows per page in the reader's list views.
1924///
1925/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1926/// so a page is on the order of tens of kilobytes rather than the tens or
1927/// hundreds of megabytes an unbounded list of full entries could reach. The page
1928/// bound is the second half of that fix: without it, a reader with a long
1929/// backlog still decides how much memory a single request allocates.
1930const ENTRIES_PER_PAGE: i64 = 100;
1931
1932/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1933/// pager reads "1 / 1" rather than "1 / 0".
1934fn page_count_for(total: i64) -> i64 {
1935    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1936}
1937
1938/// Ceiling on the reader's prev/next id list.
1939///
1940/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1941/// reader's position within it — so it is bounded by count rather than paged. At
1942/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1943/// resolving; the article itself still opens, and the list view still pages.
1944const PREV_NEXT_MAX: i64 = 5_000;
1945
1946/// Ceiling on the cached-starred identity set matched against PDS saved records.
1947///
1948/// Deliberately generous: under-reading this set makes a cached article look
1949/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1950/// than un-starring the entry. Truncating here would change what a click
1951/// destroys, so the cap exists only as a backstop against an absurd starred
1952/// count, not as a routine bound.
1953const STARRED_IDENTITY_MAX: i64 = 20_000;
1954
1955/// Most uncached PDS saved records this handler will hold in memory for one
1956/// request.
1957///
1958/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1959/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1960/// this only caps how many are collected before slicing. An earlier version used
1961/// it to cap what was SHOWN, which left everything past it invisible and —
1962/// because the un-save control lives on the row, and nothing else in the app
1963/// lists these — unremovable.
1964///
1965/// Well above the PDS list ceiling's practical reach for one reader, so a reader
1966/// meeting it has thousands of saved records and gets a logged, ordered prefix
1967/// rather than a failure.
1968const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1969
1970/// A subscription resolved against the local cache: the PDS record + its
1971/// (possibly-missing) cached feed row.
1972struct ResolvedSub {
1973    rkey: String,
1974    sub: Subscription,
1975    feed: Option<store::Feed>,
1976}
1977
1978/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
1979/// local cache row so unread counts work, and return them resolved. Best-effort
1980/// on the sidecar: a failure falls back to the local cache alone.
1981async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1982    resolve_subscriptions_noting(state, did).await.0
1983}
1984
1985/// What to tell a reader whose subscription list could not be read from their
1986/// PDS, so the last-known list being shown does not pass for a fresh one.
1987///
1988/// **A malformed record is named as such** (#177): the walk refuses rather than
1989/// drop that subscription, and "unreachable" would send the reader looking at
1990/// their network when the cause is a record some client wrote into their repo.
1991fn subscriptions_alert(err: &anyhow::Error) -> String {
1992    match err.downcast_ref::<crate::atproto::MalformedRecords>() {
1993        Some(m) => format!(
1994            "{} record(s) in your subscription list could not be read, so it was not \
1995             refreshed. Showing your last-known subscriptions; nothing was removed.",
1996            m.count
1997        ),
1998        None => "Your subscription list could not be read from your PDS just now. \
1999                 Showing your last-known subscriptions."
2000            .to_string(),
2001    }
2002}
2003
2004/// [`resolve_subscriptions`], plus the alert to show when the list shown is the
2005/// cached one because the PDS listing failed.
2006async fn resolve_subscriptions_noting(
2007    state: &AppState,
2008    did: &str,
2009) -> (Vec<ResolvedSub>, Option<String>) {
2010    let pool = &state.db;
2011    let subs = match state.repo().list_subscriptions_sorted(did).await {
2012        Ok(s) => s,
2013        Err(err) => {
2014            let alert = subscriptions_alert(&err);
2015            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2016            // Fail CLOSED: the PDS is the source of truth for what this DID
2017            // follows. When it is unreachable we must NOT widen the caller's
2018            // authorization surface. Serve from the DID's OWN last-known
2019            // `sub_ref` projection (its own feeds, possibly stale) and leave
2020            // `sub_ref` untouched — never synthesize from every cached feed,
2021            // which would grant cross-tenant read+mutate during any outage.
2022            // A DB failure here is NOT the same as "this DID follows nothing",
2023            // but `unwrap_or_default` rendered it as exactly that: an empty
2024            // sidebar and an empty reader, which arrives as "all my feeds
2025            // vanished". It still degrades to empty — there is nothing better to
2026            // show — but it says so, so the support ticket and the log line can
2027            // be matched up.
2028            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2029                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2030                                   projection could not be read; rendering an EMPTY \
2031                                   feed list, which is not the same as having none");
2032                Vec::new()
2033            });
2034            let cached = feeds
2035                .into_iter()
2036                .map(|f| ResolvedSub {
2037                    rkey: String::new(),
2038                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
2039                    feed: Some(f),
2040                })
2041                .collect();
2042            return (cached, Some(alert));
2043        }
2044    };
2045
2046    // **Deliberately NOT truncated to `max_subs_per_did`.**
2047    //
2048    // The PDS list is unbounded in practice — any client can write subscription
2049    // records, and only the 20,000-record list ceiling stops it — and the first
2050    // attempt at bounding it truncated the list right here. That was the wrong
2051    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
2052    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
2053    // removed the reader's ability to read OR mutate those feeds. A query-shape
2054    // problem would have become an access problem.
2055    //
2056    // The shape problem was the scope filter emitting one SQL placeholder per
2057    // feed; `store::list_query_sql` now passes the whole set as a single
2058    // `json_each` bind, so there is no size to defend against here and nothing
2059    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
2060    // feeds — rather than becoming a silent read-time filter.
2061    let mut out = Vec::with_capacity(subs.len());
2062    for (rkey, sub) in subs {
2063        let feed = match store::get_feed_by_url(pool, &sub.url).await {
2064            Ok(Some(f)) => Some(f),
2065            Ok(None) => {
2066                // `sub.url` came out of an atproto record. The lexicon is open —
2067                // ANY client can write a subscription into a user's repo — so
2068                // this is untrusted input on the hot path of `GET /`, and it was
2069                // being stored with none of the three checks the add and import
2070                // paths apply. Two of those are capacity ceilings; this one is
2071                // the invariant in `FeedPrivacy`'s doc comment, which promises a
2072                // private feed URL is "never stored". Writing a
2073                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
2074                // that promise even though `net::guarded_get` still refuses to
2075                // fetch it.
2076                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2077                    || feed::classify_feed_privacy(&sub.url).is_private()
2078                {
2079                    warn!(
2080                        %did,
2081                        "skipping cache row for a subscription URL that is private or not http(s)"
2082                    );
2083                    out.push(ResolvedSub {
2084                        rkey,
2085                        sub,
2086                        feed: None,
2087                    });
2088                    continue;
2089                }
2090                // Upsert a cache row so the sidebar reflects the real follow-list.
2091                //
2092                // A silent failure here is a support ticket with no evidence: no
2093                // `feeds` row means the poller never selects this subscription,
2094                // so the reader sees "I added a feed and it never updates" while
2095                // the PDS record looks perfect. Logged with the URL so the
2096                // failing subscription is identifiable.
2097                if let Err(err) = store::upsert_feed(
2098                    pool,
2099                    &store::NewFeed {
2100                        url: sub.url.clone(),
2101                        title: sub.title.clone(),
2102                        site_url: sub.site_url.clone(),
2103                        ..Default::default()
2104                    },
2105                )
2106                .await
2107                {
2108                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2109                                                       it will not be polled");
2110                }
2111                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2112            }
2113            Err(err) => {
2114                warn!(%err, url = %sub.url, "get_feed_by_url failed");
2115                None
2116            }
2117        };
2118        out.push(ResolvedSub { rkey, sub, feed });
2119    }
2120    // Mirror the caller's resolved subscription set into `sub_ref`, so every
2121    // scoped entry/feed read + read/star mutation authorizes against exactly
2122    // the feeds this DID follows right now. This is THE per-DID isolation hook.
2123    sync_sub_refs(pool, did, &out).await;
2124    (out, None)
2125}
2126
2127/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
2128/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
2129/// fail closed / show fewer rows), never leaks another user's entries.
2130async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2131    let feed_ids: Vec<i64> = subs
2132        .iter()
2133        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2134        .collect();
2135    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2136        warn!(%err, %did, "failed to sync sub_ref projection");
2137    }
2138}
2139
2140/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
2141/// records layer) and the article list for the selected scope + view.
2142async fn index(
2143    State(state): State<AppState>,
2144    headers: HeaderMap,
2145    Query(q): Query<IndexQuery>,
2146) -> Result<Response, WebError> {
2147    let user = match current_session(&state, &headers).await {
2148        Some(u) => u,
2149        // Signed out: serve the public landing page rather than bouncing to
2150        // /login. /login remains the entry point for the actual OAuth sign-in.
2151        None => {
2152            return Ok(render(&LandingTemplate {
2153                card: Card::site(&state.config),
2154                version: VERSION,
2155                repo_url: REPO_URL,
2156                crates_url: CRATES_URL,
2157                kofi_url: KOFI_URL,
2158                standard_site: state.config.standard_site,
2159                releases: RELEASES,
2160            }))
2161        }
2162    };
2163    let did = user.did.clone();
2164    let pool = &state.db;
2165
2166    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2167
2168    // View: unread (default) | all | starred.
2169    let view = match q.view.as_deref() {
2170        Some("all") => "all",
2171        Some("starred") => "starred",
2172        _ => "unread",
2173    }
2174    .to_string();
2175    let list_view = list_view_of(q.view.as_deref());
2176
2177    // Which feed URLs are in scope?
2178    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2179    // …and the feed ids they resolve to. Scope is applied inside the query now,
2180    // so a page is a page of rows the reader will actually see. Filtering after
2181    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
2182    // any scope narrower than the whole subscription list.
2183    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2184
2185    let feed_title_by_id = |id: i64| -> String {
2186        subs.iter()
2187            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2188            .map(|s| {
2189                display_title(
2190                    s.sub
2191                        .title
2192                        .as_deref()
2193                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2194                    &s.sub.url,
2195                )
2196            })
2197            .unwrap_or_default()
2198    };
2199
2200    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
2201    //
2202    // All three views used to materialize every matching entry — `SELECT e.*`,
2203    // no `LIMIT`, article bodies included — and the "all" view additionally ran
2204    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
2205    // of the row fields below read the body. See `store::EntryListRow`.
2206    // **Saved records the cache cannot show.**
2207    //
2208    // The starred view is built from local `entries`, so a saved record whose
2209    // article was never cached here is invisible — the case that matters is
2210    // starring in ANOTHER atproto reader, which is the portability the shared
2211    // lexicon exists for. Those rows are rendered from the PDS record alone.
2212    let mut uncached: Vec<EntryRow> = Vec::new();
2213    if view == "starred" {
2214        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
2215        //
2216        // `source` has already been filtered by feed/folder. Matching against it
2217        // meant an entry that IS cached but sits outside the current filter
2218        // looked uncached — so it rendered as a "not cached" row whose star
2219        // button deletes the PDS RECORD instead of un-starring the entry. A
2220        // scope filter must not change what is destroyed. Paging is the same
2221        // hazard in a new form: matching against the visible PAGE would make
2222        // every cached article outside it look uncached. Hence a dedicated
2223        // identity query over the whole starred set — urls and guids only, no
2224        // bodies — rather than reusing `source`.
2225        //
2226        // One gap remains BY DESIGN, and is handled at the other end. This query
2227        // still carries the `sub_ref` predicate, so a starred, cached entry in a
2228        // feed the reader has UNSUBSCRIBED from is absent here and its record
2229        // renders as uncached. That is the right rendering — the article is no
2230        // longer part of any feed the reader follows, and the PDS record is what
2231        // still holds it — but it means the un-save button is the record-deleting
2232        // one. `unsave_record` therefore clears the local star too, so the two
2233        // stores agree however the row got classified. Dropping the predicate
2234        // here instead would have made the row link to `/entries/{id}`, which is
2235        // `sub_ref`-scoped and would 404.
2236        //
2237        // **Three ways this can be unusable, and all three fail CLOSED.** With an
2238        // incomplete identity set, a cached article looks uncached and renders an
2239        // un-save button that deletes the PDS RECORD. Showing no uncached rows
2240        // loses rows for one render; getting this wrong loses data permanently,
2241        // so every uncertain case suppresses them.
2242        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2243            Ok(store::StarredIdentities::All(rows)) => Some(rows),
2244            // The cap is a memory backstop, and reaching it means the set is an
2245            // arbitrary subset. It used to return that subset with no way to
2246            // tell, so every starred article outside it got the destructive
2247            // button.
2248            Ok(store::StarredIdentities::Truncated) => {
2249                warn!(
2250                    %did,
2251                    cap = STARRED_IDENTITY_MAX,
2252                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
2253                     rather than rendering record-deleting buttons for cached articles"
2254                );
2255                None
2256            }
2257            Err(err) => {
2258                warn!(%err, %did, "cached-starred identity lookup failed; \
2259                                    suppressing uncached saved rows this render");
2260                None
2261            }
2262        };
2263        // The escape hatch asks whether this DID has ANY cached starred entry —
2264        // not whether the current SCOPE does. `total` is narrowed by
2265        // `?feed=`/`?folder=` while the identity set spans every feed, so
2266        // comparing them waved the fail-closed condition through for any narrow
2267        // scope: a record whose `feedUrl` matched the filter while its cached
2268        // entry lived under another feed rendered as uncached.
2269        let identities_ok = identities.is_some();
2270        let identities = identities.unwrap_or_default();
2271        let cached_urls: std::collections::HashSet<&str> = identities
2272            .iter()
2273            .filter_map(|(url, _)| url.as_deref())
2274            .collect();
2275        let cached_guids: std::collections::HashSet<&str> =
2276            identities.iter().map(|(_, guid)| guid.as_str()).collect();
2277
2278        // Collected in full here, sliced per page later. They sort after every
2279        // cached row, so the two lists form one sequence that the pager walks —
2280        // see the slice below. Collected BEFORE the page is chosen because the
2281        // page count depends on how many there are.
2282        // Bounded like everything else on this page. These come from the PDS
2283        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
2284        // `backend=rust`, whose caps are a quarter of the other's) and are
2285        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
2286        // constrain them at all. The
2287        // cap is generous — a reader with more saved-elsewhere records than this
2288        // is not the case being designed for — but a response has to have a size
2289        // an operator can reason about.
2290        let mut uncached_dropped = 0usize;
2291        match state.repo().list_saved_sorted(&did).await {
2292            Ok(saved) if identities_ok => {
2293                for (rkey, item) in saved {
2294                    let known = cached_urls.contains(item.url.as_str())
2295                        || item
2296                            .entry_id
2297                            .as_deref()
2298                            .is_some_and(|g| cached_guids.contains(g));
2299                    if known {
2300                        continue;
2301                    }
2302                    // And the scope filter applies to these rows too. Without
2303                    // it, `?feed=X` still listed saved records from every other
2304                    // feed — the filter silently did nothing for them.
2305                    if let Some(urls) = &scope_urls {
2306                        match item.feed_url.as_deref() {
2307                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2308                            // A saved record with no `feedUrl` cannot be placed
2309                            // in any feed's scope, so it belongs only to the
2310                            // unfiltered view.
2311                            _ => continue,
2312                        }
2313                    }
2314                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2315                    //
2316                    // `item.url` is attacker-controlled — a saved record written
2317                    // by any client — and it lands in an `href`. Askama escapes
2318                    // HTML metacharacters but not SCHEMES, so `javascript:`
2319                    // survives escaping intact. This project already built the
2320                    // helper for exactly that, and `feed.rs` uses it on the
2321                    // equivalent link; this path was simply not routed through it.
2322                    //
2323                    // The real defect was what a failure DID: it `continue`d, so
2324                    // the row vanished entirely — no badge, no count, nothing —
2325                    // and the only trace was a `debug!` below any realistic
2326                    // filter. That makes the record unremovable FROM HERE, because
2327                    // the un-save button lives on the row; the reader has to open
2328                    // a different atproto client to get rid of it. A bad URL is a
2329                    // reason to withhold the LINK, not the row.
2330                    //
2331                    // The check also moved ABOVE the poll nudge. That is ordering
2332                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2333                    // on the URL being rejected here, and is already gated on the
2334                    // reader actually subscribing to that feed — so it was never
2335                    // reachable by an unusable `item.url`. Deciding whether a
2336                    // record is renderable before doing anything outbound on its
2337                    // behalf is simply the order that stays correct if either of
2338                    // those two facts later stops being true.
2339                    let link = SafeLink::external(&item.url);
2340                    if link.is_empty() {
2341                        warn!(
2342                            %did, %rkey,
2343                            "a saved record has an unusable URL; rendering it without a link \
2344                             so it can still be removed"
2345                        );
2346                    }
2347
2348                    // Opportunistic re-fetch: if the reader still subscribes to
2349                    // the feed, make it due now. If the article is still inside
2350                    // the feed's window the poller caches it normally and this
2351                    // row becomes a real entry on its own — no synthetic rows in
2352                    // the shared cache, which every subscriber would otherwise
2353                    // see as a content-less entry.
2354                    // **Bound the WORK, not just the response.** This check sat
2355                    // after the nudge and the `subs` scan below, so every render
2356                    // still walked all ≤20,000 PDS records, ran a subs-length
2357                    // string scan per record, and issued up to that many
2358                    // `mark_feed_due` round-trips on a 5-connection pool — then
2359                    // discarded everything past the cap. A cap that runs after
2360                    // the expensive part is a cap on the output only.
2361                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2362                        uncached_dropped += 1;
2363                        continue;
2364                    }
2365                    if let Some(feed_url) = item.feed_url.as_deref() {
2366                        if subs.iter().any(|s| s.sub.url == feed_url) {
2367                            // Bounded to one nudge per feed per poll interval —
2368                            // see `mark_feed_due`. Unbounded, a reload loop here
2369                            // becomes outbound amplification.
2370                            let stale_before = (chrono::Utc::now()
2371                                - chrono::Duration::from_std(state.config.poll_interval)
2372                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2373                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2374                            if let Err(err) =
2375                                store::mark_feed_due(pool, feed_url, &stale_before).await
2376                            {
2377                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2378                            }
2379                        }
2380                    }
2381                    uncached.push(EntryRow {
2382                        id: 0,
2383                        title: item
2384                            .title
2385                            .clone()
2386                            .filter(|t| !t.trim().is_empty())
2387                            // Falling back to the URL is fine for a link we are
2388                            // willing to render, and wrong for one we are not:
2389                            // it would put the exact string `safe_link` just
2390                            // rejected into the page as the record's name. The
2391                            // rkey is what the un-save button acts on, so it is
2392                            // the honest identifier for a row that has nothing
2393                            // else trustworthy to show.
2394                            .unwrap_or_else(|| {
2395                                if link.is_empty() {
2396                                    format!("Saved item {rkey}")
2397                                } else {
2398                                    item.url.clone()
2399                                }
2400                            }),
2401                        feed_title: item.feed_url.clone().unwrap_or_default(),
2402                        published: display_date(Some(&item.created_at)),
2403                        read: false,
2404                        starred: true,
2405                        // Empty = "render this row without an anchor". The
2406                        // template branches on it, so the rejected URL never
2407                        // reaches an `href` even as an escaped string.
2408                        link,
2409                        cached: false,
2410                        rkey,
2411                    });
2412                }
2413            }
2414            // Identity lookup was unusable — see the fail-closed note above.
2415            Ok(_) => {}
2416            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2417        }
2418        if uncached_dropped > 0 {
2419            warn!(
2420                %did,
2421                dropped = uncached_dropped,
2422                cap = MAX_UNCACHED_SAVED_ROWS,
2423                "more saved records than this instance will hold in one response; the \
2424                 rest are not reachable from here"
2425            );
2426        }
2427    }
2428
2429    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2430    // PDS records follow them, and the pager walks the concatenation.
2431    //
2432    // The first version appended the uncached rows to the last page only and
2433    // kept them out of `total`, which left everything past a cap invisible AND
2434    // unremovable — the un-save button lives on the row, and there is no other
2435    // surface in the app that lists these. That is the same "unremovable FROM
2436    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2437    // forty lines later by a bound meant to protect memory.
2438    //
2439    // Paging the concatenation makes every record reachable and needs no cap on
2440    // what is RENDERED — one page is one page either way. The version before
2441    // that inflated `total` while clamping on the cached count, which advertised
2442    // a page the clamp could never reach; both numbers come from the same total
2443    // now, which is what makes that impossible rather than merely fixed.
2444    let total_cached =
2445        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2446    let uncached_len = uncached.len();
2447    let total = total_cached + uncached_len as i64;
2448    // Clamped to the range that exists. Past the end the list is empty, and the
2449    // empty state renders instead of the pager — which would strand a reader who
2450    // typed a page number, or who paged to the end and then marked entries read
2451    // out from under their own URL. Showing the last page is the answer to both.
2452    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2453    let offset = (page - 1) * ENTRIES_PER_PAGE;
2454    // Past the cached rows this returns nothing, which is exactly right: the
2455    // page is then made up entirely of uncached ones.
2456    let source = store::list_entries(
2457        pool,
2458        &did,
2459        list_view,
2460        scope_ids.as_deref(),
2461        ENTRIES_PER_PAGE,
2462        offset,
2463    )
2464    .await?;
2465    // **Both halves of the page are computed from the COUNT alone.**
2466    //
2467    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2468    // queries, so they can disagree about how many cached rows exist. Any part of
2469    // the page composition that reads `source.len()` inherits that disagreement.
2470    //
2471    // `cached_allotment` is this page's cached share according to the snapshot,
2472    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2473    // pages tile the uncached list exactly, whichever way the count drifted.
2474    // `source` is then truncated to it only to avoid rendering rows the next page
2475    // will also claim.
2476    //
2477    // The previous version took `skip` from the count but `take` from
2478    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2479    // an un-star or a retention delete landing between the two queries — made
2480    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2481    // putting twenty rows, each carrying the record-DELETING un-save button, on
2482    // two pages at once. The comment claimed that shape was impossible; it was
2483    // merely rarer.
2484    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2485    let cached_here = cached_allotment.min(source.len());
2486    // Only compose when there is something to compose WITH. `uncached` is empty
2487    // on every view but `starred`, and truncating there just drops trailing rows
2488    // that no page then shows — the poller inserting between the COUNT and the
2489    // SELECT was enough to trigger it.
2490    let source = if uncached_len == 0 {
2491        &source[..]
2492    } else {
2493        &source[..cached_here]
2494    };
2495    let uncached_page: Vec<EntryRow> = {
2496        let skip = (offset - total_cached).max(0) as usize;
2497        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2498        uncached.into_iter().skip(skip).take(take).collect()
2499    };
2500    // This page's slice, used only to append below. The heading needs the
2501    // WHOLE-list figure, which is the set's size before slicing.
2502    let uncached_total = uncached_len as i64;
2503
2504    // The scope/view suffix carried onto every entry link (built once).
2505    let entry_scope_qs = {
2506        let mut parts = Vec::new();
2507        if let Some(f) = q.feed.as_deref() {
2508            parts.push(format!("feed={}", qenc(f)));
2509        }
2510        if let Some(f) = q.folder.as_deref() {
2511            parts.push(format!("folder={}", qenc(f)));
2512        }
2513        if view != "unread" {
2514            parts.push(format!("view={}", qenc(&view)));
2515        }
2516        parts.join("&")
2517    };
2518    let entries: Vec<EntryRow> = source
2519        .iter()
2520        .map(|e| EntryRow {
2521            id: e.id,
2522            title: e
2523                .title
2524                .clone()
2525                .filter(|t| !t.trim().is_empty())
2526                .unwrap_or_else(|| "(untitled)".to_string()),
2527            feed_title: feed_title_by_id(e.feed_id),
2528            published: display_date(e.published.as_deref()),
2529            // Both bits ride along on the row's own `entry_state` join now. They
2530            // used to be membership tests against the full unread and starred
2531            // sets, which is why those two lists were fetched in their entirety
2532            // on every render even when the page showed a hundred rows.
2533            read: e.read,
2534            starred: e.starred,
2535            link: SafeLink::entry(e.id, &entry_scope_qs),
2536            cached: true,
2537            rkey: String::new(),
2538        })
2539        .collect();
2540
2541    // The uncached slice for this page follows the cached rows.
2542    let mut entries = entries;
2543    entries.extend(uncached_page);
2544    let entries = entries;
2545
2546    let selected_feed = q.feed.as_deref();
2547    let selected_folder = q.folder.as_deref();
2548
2549    // Build the shared sidebar (folders + loose feeds, with unread counts).
2550    let (folder_views, loose_feeds, _folder_options) =
2551        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2552
2553    // Heading + scope query-string suffix.
2554    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2555        let name = subs
2556            .iter()
2557            .find(|s| s.sub.url == feed_url)
2558            .map(|s| {
2559                display_title(
2560                    s.sub
2561                        .title
2562                        .as_deref()
2563                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2564                    &s.sub.url,
2565                )
2566            })
2567            .unwrap_or_else(|| display_title(None, feed_url));
2568        (name, format!("feed={}", qenc(feed_url)))
2569    } else if let Some(folder_uri) = selected_folder {
2570        let name = folder_views
2571            .iter()
2572            .find(|f| f.uri == folder_uri)
2573            .map(|f| f.name.clone())
2574            .unwrap_or_else(|| "Folder".to_string());
2575        (name, format!("folder={}", qenc(folder_uri)))
2576    } else {
2577        let h = match view.as_str() {
2578            "all" => "All",
2579            "starred" => "Starred",
2580            _ => "Unread",
2581        };
2582        (h.to_string(), String::new())
2583    };
2584
2585    let feed_scope = selected_feed.map(str::to_string);
2586    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2587
2588    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2589    // page number is the only thing appended — which keeps a paged link
2590    // identical to an unpaged one in every other respect.
2591    let page_href = |n: i64| -> String {
2592        let mut parts = Vec::new();
2593        if !entry_scope_qs.is_empty() {
2594            parts.push(entry_scope_qs.clone());
2595        }
2596        if n > 1 {
2597            parts.push(format!("page={n}"));
2598        }
2599        if parts.is_empty() {
2600            "/".to_string()
2601        } else {
2602            format!("/?{}", parts.join("&"))
2603        }
2604    };
2605    let prev_href = (page > 1).then(|| page_href(page - 1));
2606    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2607
2608    let tmpl = IndexTemplate {
2609        card: Card::private(&state.config),
2610        version: VERSION,
2611        repo_url: REPO_URL,
2612        kofi_url: KOFI_URL,
2613        flash: q.flash.unwrap_or_default(),
2614        alert: alert.unwrap_or_default(),
2615        nav,
2616        entries,
2617        heading,
2618        feed_scope,
2619        total,
2620        // Whole-list figure, so it sits beside `total` without double counting.
2621        // The per-page slice is composed above and is not a heading number.
2622        uncached_total,
2623        page,
2624        page_count: page_count_for(total),
2625        prev_href,
2626        next_href,
2627    };
2628    Ok(render(&tmpl))
2629}
2630
2631/// Query for `GET /manage` — carries an optional flash after an action redirect.
2632#[derive(Debug, Deserialize, Default)]
2633struct ManageQuery {
2634    #[serde(default)]
2635    flash: Option<String>,
2636}
2637
2638/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2639/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2640/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2641/// mutation logic of its own.
2642async fn manage(
2643    State(state): State<AppState>,
2644    headers: HeaderMap,
2645    Query(q): Query<ManageQuery>,
2646) -> Result<Response, WebError> {
2647    let user = match current_session(&state, &headers).await {
2648        Some(u) => u,
2649        None => return Ok(Redirect::to("/login").into_response()),
2650    };
2651    let did = user.did.clone();
2652
2653    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2654    let (folder_views, loose_feeds, folder_options) =
2655        build_sidebar(&state, &did, &subs, None, None).await;
2656
2657    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2658    let nav = build_nav(
2659        &user,
2660        "unread",
2661        String::new(),
2662        folder_views.iter().map(clone_folder_view).collect(),
2663        loose_feeds.iter().map(clone_feed_view).collect(),
2664        true,
2665    );
2666
2667    let tmpl = ManageTemplate {
2668        card: Card::private(&state.config),
2669        version: VERSION,
2670        repo_url: REPO_URL,
2671        kofi_url: KOFI_URL,
2672        flash: q.flash.unwrap_or_default(),
2673        alert: alert.unwrap_or_default(),
2674        nav,
2675        folder_options,
2676        folders: folder_views,
2677        loose_feeds,
2678        standard_site: state.config.standard_site,
2679    };
2680    Ok(render(&tmpl))
2681}
2682
2683/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2684/// (`Nav`) and the page body without an extra DB round-trip.
2685fn clone_feed_view(f: &FeedView) -> FeedView {
2686    FeedView {
2687        rkey: f.rkey.clone(),
2688        url: f.url.clone(),
2689        title: f.title.clone(),
2690        unread: f.unread,
2691        selected: f.selected,
2692        folder: f.folder.clone(),
2693    }
2694}
2695
2696fn clone_folder_view(f: &FolderView) -> FolderView {
2697    FolderView {
2698        rkey: f.rkey.clone(),
2699        uri: f.uri.clone(),
2700        name: f.name.clone(),
2701        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2702        selected: f.selected,
2703    }
2704}
2705
2706/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2707/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2708/// unscoped "everything" view. A folder scope takes the feed scope when both are
2709/// somehow present (feed wins, matching the query precedence elsewhere).
2710fn scope_urls_for(
2711    subs: &[ResolvedSub],
2712    feed: Option<&str>,
2713    folder: Option<&str>,
2714) -> Option<Vec<String>> {
2715    if let Some(feed_url) = feed {
2716        Some(vec![feed_url.to_string()])
2717    } else {
2718        folder.map(|folder_uri| {
2719            subs.iter()
2720                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2721                .map(|s| s.sub.url.clone())
2722                .collect()
2723        })
2724    }
2725}
2726
2727/// The `at://` URI for a folder record given the owner DID + rkey.
2728fn folder_uri(did: &str, rkey: &str) -> String {
2729    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2730}
2731
2732/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2733/// DID — the shared source for both the reader index and the rail on every
2734/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2735async fn build_sidebar(
2736    state: &AppState,
2737    did: &str,
2738    subs: &[ResolvedSub],
2739    selected_feed: Option<&str>,
2740    selected_folder: Option<&str>,
2741) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2742    let pool = &state.db;
2743    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2744    // all — purely to `.filter().count()` them in Rust, on every page that
2745    // renders chrome, which made the sidebar the most frequently executed
2746    // instance of the unbounded-projection problem.
2747    let unread_counts = store::unread_counts_by_feed(pool, did)
2748        .await
2749        .unwrap_or_else(|err| {
2750            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2751            Default::default()
2752        });
2753    let folders = state
2754        .repo()
2755        .list_folders_sorted(did)
2756        .await
2757        .unwrap_or_default();
2758
2759    let unread_count = |feed_id: Option<i64>| -> i64 {
2760        feed_id
2761            .and_then(|id| unread_counts.get(&id).copied())
2762            .unwrap_or(0)
2763    };
2764    let mk_feed_view = |s: &ResolvedSub| FeedView {
2765        rkey: s.rkey.clone(),
2766        url: s.sub.url.clone(),
2767        title: display_title(
2768            s.sub
2769                .title
2770                .as_deref()
2771                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2772            &s.sub.url,
2773        ),
2774        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2775        selected: selected_feed == Some(s.sub.url.as_str()),
2776        folder: s.sub.folder.clone(),
2777    };
2778
2779    let mut folder_views = Vec::with_capacity(folders.len());
2780    for (rkey, folder) in &folders {
2781        let uri = folder_uri(did, rkey);
2782        let feeds: Vec<FeedView> = subs
2783            .iter()
2784            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2785            .map(mk_feed_view)
2786            .collect();
2787        folder_views.push(FolderView {
2788            rkey: rkey.clone(),
2789            uri: uri.clone(),
2790            name: folder.name.clone(),
2791            feeds,
2792            selected: selected_folder == Some(uri.as_str()),
2793        });
2794    }
2795
2796    let known_uris: std::collections::HashSet<String> =
2797        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2798    let loose_feeds: Vec<FeedView> = subs
2799        .iter()
2800        .filter(|s| {
2801            s.sub
2802                .folder
2803                .as_deref()
2804                .map(|f| !known_uris.contains(f))
2805                .unwrap_or(true)
2806        })
2807        .map(mk_feed_view)
2808        .collect();
2809
2810    let folder_options: Vec<FolderOption> = folders
2811        .iter()
2812        .map(|(rkey, folder)| FolderOption {
2813            name: folder.name.clone(),
2814            uri: folder_uri(did, rkey),
2815        })
2816        .collect();
2817
2818    (folder_views, loose_feeds, folder_options)
2819}
2820
2821/// Assemble the shared rail [`Nav`] for a chrome page.
2822fn build_nav(
2823    user: &CurrentUser,
2824    view: &str,
2825    scope_qs: String,
2826    folders: Vec<FolderView>,
2827    loose_feeds: Vec<FeedView>,
2828    manage_active: bool,
2829) -> Nav {
2830    Nav {
2831        handle: display_handle(user.handle.as_deref(), &user.did),
2832        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2833        view: view.to_string(),
2834        scope_qs,
2835        folders,
2836        loose_feeds,
2837        manage_active,
2838    }
2839}
2840
2841// ---------------------------------------------------------------------------
2842// Reader: single entry
2843// ---------------------------------------------------------------------------
2844
2845/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2846/// prev/next and "back" stay within the list the reader came from.
2847#[derive(Debug, Deserialize, Default)]
2848struct EntryQuery {
2849    #[serde(default)]
2850    feed: Option<String>,
2851    #[serde(default)]
2852    folder: Option<String>,
2853    #[serde(default)]
2854    view: Option<String>,
2855}
2856
2857/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2858/// within the current reading list.
2859async fn entry_view(
2860    State(state): State<AppState>,
2861    headers: HeaderMap,
2862    Path(id): Path<i64>,
2863    Query(q): Query<EntryQuery>,
2864) -> Result<Response, WebError> {
2865    let user = match current_session(&state, &headers).await {
2866        Some(u) => u,
2867        None => return Ok(Redirect::to("/login").into_response()),
2868    };
2869    let did = user.did.clone();
2870    let pool = &state.db;
2871
2872    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2873    // the per-DID entry gate below authorizes against the caller's current PDS
2874    // subscription set (not another user's cached feeds).
2875    let subs = resolve_subscriptions(&state, &did).await;
2876
2877    let entry = match get_entry_by_id(pool, &did, id).await? {
2878        Some(e) => e,
2879        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2880    };
2881
2882    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2883
2884    let read = entry_is_read(pool, &did, id).await?;
2885    let starred = entry_is_starred(pool, &did, id).await?;
2886
2887    // Reconstruct the current list to compute prev/next, so paging in the reader
2888    // matches what the list showed.
2889    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2890
2891    let back_qs = scope_query(&q);
2892
2893    let (folder_views, loose_feeds, _) =
2894        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2895    let nav_view = match q.view.as_deref() {
2896        Some("all") => "all",
2897        Some("starred") => "starred",
2898        _ => "unread",
2899    };
2900    let nav = build_nav(
2901        &user,
2902        nav_view,
2903        back_qs.clone(),
2904        folder_views,
2905        loose_feeds,
2906        false,
2907    );
2908
2909    let tmpl = EntryTemplate {
2910        card: Card::private(&state.config),
2911        version: VERSION,
2912        repo_url: REPO_URL,
2913        kofi_url: KOFI_URL,
2914        nav,
2915        id: entry.id,
2916        title: entry
2917            .title
2918            .clone()
2919            .filter(|t| !t.trim().is_empty())
2920            .unwrap_or_else(|| "(untitled)".to_string()),
2921        feed_title,
2922        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2923        published: display_date(entry.published.as_deref()),
2924        url: entry.url.as_deref().and_then(SafeLink::external_opt),
2925        content_html: entry.content_html.clone(),
2926        read,
2927        starred,
2928        back_qs,
2929        prev_id,
2930        next_id,
2931        oob: false,
2932    };
2933    Ok(render(&tmpl))
2934}
2935
2936/// Compute the prev/next entry ids around `current` within the reader's current
2937/// scope + view, so the reader view can offer keyboard/paging navigation.
2938async fn neighbors_in_scope(
2939    state: &AppState,
2940    did: &str,
2941    q: &EntryQuery,
2942    current: i64,
2943) -> (Option<i64>, Option<i64>) {
2944    let idx_q = IndexQuery {
2945        feed: q.feed.clone(),
2946        folder: q.folder.clone(),
2947        view: q.view.clone(),
2948        // Neighbours span the whole list, not the page the reader arrived from.
2949        page: None,
2950        flash: None,
2951    };
2952    let ids = list_entry_ids(state, did, &idx_q).await;
2953    let pos = ids.iter().position(|&x| x == current);
2954    match pos {
2955        Some(p) => {
2956            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2957            let next = ids.get(p + 1).copied();
2958            (prev, next)
2959        }
2960        None => (None, None),
2961    }
2962}
2963
2964/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2965/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2966async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2967    let pool = &state.db;
2968    let subs = resolve_subscriptions(state, did).await;
2969
2970    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2971
2972    // Ids only, and bounded. This used to fetch whole entries — bodies included
2973    // — for all three views and then throw everything but `id` away; the "all"
2974    // branch additionally ran one unbounded query PER FEED and sorted the union
2975    // in memory. Scope is now a feed-id restriction inside the query, so the
2976    // database does the filtering and the ordering exactly once.
2977    store::list_entry_ids(
2978        pool,
2979        did,
2980        list_view_of(q.view.as_deref()),
2981        scoped_feed_ids(&subs, &scope_urls).as_deref(),
2982        PREV_NEXT_MAX,
2983    )
2984    .await
2985    .unwrap_or_else(|err| {
2986        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2987        Vec::new()
2988    })
2989}
2990
2991/// Map the `?view=` query value onto the store's list view. Anything
2992/// unrecognised is the unread default, matching `index`.
2993fn list_view_of(view: Option<&str>) -> store::ListView {
2994    match view {
2995        Some("all") => store::ListView::All,
2996        Some("starred") => store::ListView::Starred,
2997        _ => store::ListView::Unread,
2998    }
2999}
3000
3001/// Translate a feed/folder scope into the feed ids to restrict a list query to.
3002///
3003/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
3004/// matched no local feed, which must return nothing rather than everything — so
3005/// the empty vec is deliberately preserved, not collapsed back into `None`.
3006fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3007    let urls = scope_urls.as_ref()?;
3008    Some(
3009        subs.iter()
3010            .filter(|s| urls.contains(&s.sub.url))
3011            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3012            .collect(),
3013    )
3014}
3015
3016/// Build a `?…` query string that preserves the reading scope + view for links.
3017fn scope_query(q: &EntryQuery) -> String {
3018    let mut parts = Vec::new();
3019    if let Some(f) = q.feed.as_deref() {
3020        parts.push(format!("feed={}", qenc(f)));
3021    }
3022    if let Some(f) = q.folder.as_deref() {
3023        parts.push(format!("folder={}", qenc(f)));
3024    }
3025    if let Some(v) = q.view.as_deref() {
3026        if v != "unread" {
3027            parts.push(format!("view={}", qenc(v)));
3028        }
3029    }
3030    parts.join("&")
3031}
3032
3033// ---------------------------------------------------------------------------
3034// Mark read / unread
3035// ---------------------------------------------------------------------------
3036
3037/// Form body for `POST /entries/:id/read`.
3038#[derive(Debug, Deserialize)]
3039struct ReadForm {
3040    #[serde(default)]
3041    read: Option<String>,
3042}
3043
3044/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
3045async fn mark_read(
3046    State(state): State<AppState>,
3047    Path(id): Path<i64>,
3048    headers: HeaderMap,
3049    Form(form): Form<ReadForm>,
3050) -> Result<Response, WebError> {
3051    let did = match current_did(&state, &headers).await {
3052        Some(d) => d,
3053        None => return Ok(Redirect::to("/login").into_response()),
3054    };
3055    let pool = &state.db;
3056
3057    let read = matches!(
3058        form.read.as_deref(),
3059        Some("true") | Some("1") | Some("on") | None
3060    );
3061
3062    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3063    // mutation: `mark_read` only writes when `did` subscribes to the entry's
3064    // feed. A non-subscriber gets a 404, never a mutation of someone else's
3065    // (or the shared cache's) state.
3066    resolve_subscriptions(&state, &did).await;
3067    if !store::mark_read(pool, &did, id, read).await? {
3068        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3069    }
3070
3071    if !is_htmx(&headers) {
3072        return Ok(Redirect::to("/").into_response());
3073    }
3074
3075    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
3076    // in the DOM), so its button's hidden value + aria-pressed update in place
3077    // and a second keypress can reverse the toggle. The list view swaps the row.
3078    if is_reader_request(&headers) {
3079        let starred = entry_is_starred(pool, &did, id).await?;
3080        return Ok(render(&EntryActionBarTemplate {
3081            id,
3082            read,
3083            starred,
3084            oob: true,
3085        }));
3086    }
3087
3088    let row = build_entry_row(pool, &did, id, Some(read)).await?;
3089    match row {
3090        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3091        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3092    }
3093}
3094
3095// ---------------------------------------------------------------------------
3096// Star / save
3097// ---------------------------------------------------------------------------
3098
3099/// Form body for `POST /entries/:id/star`.
3100#[derive(Debug, Deserialize)]
3101struct StarForm {
3102    #[serde(default)]
3103    starred: Option<String>,
3104}
3105
3106/// `POST /entries/:id/star` — star/unstar an entry.
3107///
3108/// Sets the local `starred` bit (fast working copy) and writes/removes a
3109/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
3110/// owning). The PDS write is best-effort — the local star still lands.
3111async fn toggle_star(
3112    State(state): State<AppState>,
3113    Path(id): Path<i64>,
3114    headers: HeaderMap,
3115    Form(form): Form<StarForm>,
3116) -> Result<Response, WebError> {
3117    let did = match current_did(&state, &headers).await {
3118        Some(d) => d,
3119        None => return Ok(Redirect::to("/login").into_response()),
3120    };
3121    let pool = &state.db;
3122
3123    let starred = matches!(
3124        form.starred.as_deref(),
3125        Some("true") | Some("1") | Some("on") | None
3126    );
3127
3128    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3129    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
3130    // feed. A non-subscriber gets a 404, never a mutation.
3131    resolve_subscriptions(&state, &did).await;
3132    if !store::mark_starred(pool, &did, id, starred).await? {
3133        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3134    }
3135
3136    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
3137    // to the caller's subscriptions, so this only ever acts on the caller's feed.
3138    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3139        let entry_url = entry.url.clone().unwrap_or_default();
3140        if !entry_url.is_empty() {
3141            if starred {
3142                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3143                saved.title = entry.title.clone();
3144                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3145                saved.entry_id = Some(entry.guid.clone());
3146                match state.repo().add_saved(&did, &saved).await {
3147                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3148                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3149                }
3150            } else {
3151                // Un-star: find and delete the matching saved record by URL.
3152                match state.repo().list_saved(&did).await {
3153                    Ok(records) => {
3154                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3155                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3156                                warn!(%err, %did, %rkey, "PDS saved delete failed");
3157                            }
3158                        }
3159                    }
3160                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3161                }
3162            }
3163        }
3164    }
3165
3166    if !is_htmx(&headers) {
3167        return Ok(Redirect::to("/").into_response());
3168    }
3169
3170    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
3171    if is_reader_request(&headers) {
3172        let read = entry_is_read(pool, &did, id).await?;
3173        return Ok(render(&EntryActionBarTemplate {
3174            id,
3175            read,
3176            starred,
3177            oob: true,
3178        }));
3179    }
3180
3181    let row = build_entry_row(pool, &did, id, None).await?;
3182    match row {
3183        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3184        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3185    }
3186}
3187
3188/// The feed URL for a cached feed id, if the row exists.
3189async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3190    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3191        .bind(feed_id)
3192        .fetch_optional(pool)
3193        .await
3194        .ok()
3195        .flatten()
3196}
3197
3198// ---------------------------------------------------------------------------
3199// Mark-all-read
3200// ---------------------------------------------------------------------------
3201
3202/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
3203/// absent means mark everything read.
3204#[derive(Debug, Deserialize, Default)]
3205struct ReadAllQuery {
3206    #[serde(default)]
3207    feed: Option<String>,
3208}
3209
3210/// `POST /read-all` — mark every entry read for the current DID, optionally
3211/// scoped to one feed (mark-all-read per feed or globally).
3212async fn mark_all_read(
3213    State(state): State<AppState>,
3214    headers: HeaderMap,
3215    Query(q): Query<ReadAllQuery>,
3216) -> Result<Response, WebError> {
3217    let did = match current_did(&state, &headers).await {
3218        Some(d) => d,
3219        None => return Ok(Redirect::to("/login").into_response()),
3220    };
3221    let pool = &state.db;
3222
3223    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
3224    // only ever touch feeds this DID actually subscribes to.
3225    resolve_subscriptions(&state, &did).await;
3226
3227    if let Some(feed_url) = q.feed.as_deref() {
3228        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3229            store::mark_feed_read(pool, &did, feed.id, true).await?;
3230        }
3231        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3232    }
3233
3234    // Global: mark every subscribed feed read. Fan out over the DID's feeds
3235    // (bounded by the per-DID subscription cap) using the batched per-feed path,
3236    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
3237    // state, but O(feeds) statements instead of O(unread entries).
3238    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3239        store::mark_feed_read(pool, &did, feed_id, true).await?;
3240    }
3241    Ok(Redirect::to("/").into_response())
3242}
3243
3244// ---------------------------------------------------------------------------
3245// Subscribe by URL
3246// ---------------------------------------------------------------------------
3247
3248/// Flash for a URL this instance cannot store as a feed — not private, just
3249/// not a kind of feed it supports (an `at://` publication with
3250/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
3251/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
3252/// false promise for a record that may already exist in the user's PDS.
3253const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3254    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3255
3256/// Shown when an OPML export is refused because the subscription list could not
3257/// be read in full.
3258///
3259/// **An empty export is worse than no export.** This path used to
3260/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
3261/// file — a blank backup, handed over at the moment the reader reached for one.
3262const EXPORT_INCOMPLETE_REFUSAL: &str =
3263    "Could not read your subscriptions in full, so nothing was exported. Your \
3264     feeds are unchanged — try again, and if it keeps failing the list may be \
3265     larger than this reader can page through.";
3266
3267/// Refusal message shown when a private/paid feed is submitted. FeatherReader
3268/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
3269/// only for now — a private feed's secret URL is never saved, fetched, or sent
3270/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
3271/// and the boot-smoke can assert on it.
3272const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3273    FeatherReader stores your subscriptions in your public PDS, so it supports public \
3274    feeds for now — private-feed support arrives when atproto's private data \
3275    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3276
3277/// Form body for `POST /subscriptions`.
3278#[derive(Debug, Deserialize)]
3279struct SubscribeForm {
3280    url: String,
3281    /// Optional folder `at://` URI to file the new feed under.
3282    #[serde(default)]
3283    folder: Option<String>,
3284}
3285
3286/// The DID-form URL to store for a pasted `at://` publication, or the flash
3287/// to refuse it with.
3288///
3289/// - The scheme is canonicalised: `At://` is the same publication, and
3290///   storing a second spelling makes a second row for it (#183).
3291/// - It must name a `site.standard.publication`; anything else is not a feed
3292///   this instance can read.
3293/// - A handle is resolved to its DID: a handle is a mutable name, and
3294///   `feeds.url` is keyed on identity, so only the DID form is stored.
3295async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3296    let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3297    let canonical = format!(
3298        "{}{}",
3299        crate::atproto::AT_URI_PREFIX,
3300        &input[crate::atproto::AT_URI_PREFIX.len()..]
3301    );
3302    let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3303    if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3304        return Err(unsupported());
3305    }
3306    let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3307        uri.authority.clone()
3308    } else {
3309        let handle =
3310            // Validated as a handle before it is sent anywhere: an authority
3311            // that is neither a DID nor a handle (`did:plc:TOOSHORT`, an
3312            // uppercase DID, a newline) is unsupported, not a lookup (found in
3313            // review).
3314            crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3315        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3316            .await
3317            .map_err(|err| {
3318                warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3319                format!("Couldn't resolve the handle {} to an account.", uri.authority)
3320            })?
3321    };
3322    let url = format!(
3323        "{}{did}/{}/{}",
3324        crate::atproto::AT_URI_PREFIX,
3325        uri.collection,
3326        uri.rkey
3327    );
3328    if !feed::is_storable_feed_url(&url, true) {
3329        return Err(unsupported());
3330    }
3331    Ok(url)
3332}
3333
3334/// `POST /subscriptions` — subscribe by URL.
3335async fn add_subscription(
3336    State(state): State<AppState>,
3337    headers: HeaderMap,
3338    Form(form): Form<SubscribeForm>,
3339) -> Result<Response, WebError> {
3340    let did = match current_did(&state, &headers).await {
3341        Some(d) => d,
3342        None => return Ok(Redirect::to("/login").into_response()),
3343    };
3344    let pool = &state.db;
3345    let input = form.url.trim().to_string();
3346    if input.is_empty() {
3347        return Ok(Redirect::to("/").into_response());
3348    }
3349
3350    // Per-DID subscription cap: bound one account's storage/poller footprint on
3351    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3352    // can't even trigger an outbound request. `<= 0` disables the cap.
3353    let cap = state.config.max_subs_per_did;
3354    if cap > 0 {
3355        match store::count_subscriptions_for_did(pool, &did).await {
3356            Ok(n) if n >= cap => {
3357                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3358                return Ok(Redirect::to(&format!(
3359                    "/?flash={}",
3360                    qenc(&format!(
3361                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3362                    ))
3363                ))
3364                .into_response());
3365            }
3366            Ok(_) => {}
3367            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3368        }
3369    }
3370
3371    // **An at:// paste is a standard.site publication, read by the poller
3372    // since 0.4.0**: canonicalised and resolved to its DID form here, then it
3373    // joins the ordinary path below. With the flag off it is refused as it
3374    // always was — the flag gates what may be stored.
3375    let is_at_uri = input
3376        .get(..crate::atproto::AT_URI_PREFIX.len())
3377        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3378    let publication_url = if is_at_uri {
3379        if !state.config.standard_site {
3380            info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3381            return Ok(
3382                Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3383                    .into_response(),
3384            );
3385        }
3386        match publication_url_from_paste(&state, &input).await {
3387            Ok(url) => Some(url),
3388            Err(flash) => {
3389                info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3390                return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3391            }
3392        }
3393    } else {
3394        None
3395    };
3396
3397    if let feed::FeedPrivacy::Private(reason) =
3398        feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3399    {
3400        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3401        return Ok(
3402            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3403        );
3404    }
3405
3406    let resolved = match publication_url {
3407        Some(url) => Ok(url),
3408        None => resolve_feed_url(&state.config, &input).await,
3409    };
3410    let feed_url = match resolved {
3411        Ok(u) => u,
3412        Err(err) => {
3413            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3414            return Ok(Redirect::to(&format!(
3415                "/?flash={}",
3416                qenc("Couldn't find a feed at that URL")
3417            ))
3418            .into_response());
3419        }
3420    };
3421
3422    // Defensive: resolution may have discovered a feed URL that itself carries a
3423    // secret (e.g. a public site page linking a tokened feed). Re-check the
3424    // resolved URL and refuse before storing/writing anything.
3425    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3426        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3427        return Ok(
3428            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3429        );
3430    }
3431
3432    // The URL about to be STORED is what must be storable — not the one the
3433    // user typed. Autodiscovery already yields only http(s), but this is the
3434    // path that writes the row and the PDS record, so the check lives here too:
3435    // the same gate the OPML and rename paths apply, on the same terms.
3436    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3437        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3438        return Ok(
3439            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3440                .into_response(),
3441        );
3442    }
3443
3444    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3445    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3446    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3447    let feeds_cap = state.config.max_feeds_global;
3448    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3449        match store::count_feeds(pool).await {
3450            Ok(n) if n >= feeds_cap => {
3451                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3452                return Ok(Redirect::to(&format!(
3453                    "/?flash={}",
3454                    qenc(
3455                        "This instance is at its feed capacity right now. Please try again later."
3456                    )
3457                ))
3458                .into_response());
3459            }
3460            Ok(_) => {}
3461            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3462        }
3463    }
3464
3465    store::upsert_feed(
3466        pool,
3467        &store::NewFeed {
3468            url: feed_url.clone(),
3469            ..Default::default()
3470        },
3471    )
3472    .await?;
3473
3474    if let Ok(client) = feed::build_client() {
3475        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3476            match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3477                Ok(outcome) => {
3478                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3479                    // **This path is not the scheduler, so it must settle the
3480                    // error columns itself.** `poll_feed` writes validators and
3481                    // `last_polled` and nothing else.
3482                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3483                }
3484                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3485            }
3486        }
3487    }
3488
3489    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3490    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3491        sub.title = feed_row.title.clone();
3492        sub.site_url = feed_row.site_url.clone();
3493    }
3494    sub.folder = form
3495        .folder
3496        .map(|f| f.trim().to_string())
3497        .filter(|f| !f.is_empty());
3498
3499    match state.repo().add_subscription(&did, &sub).await {
3500        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3501        Err(err) => {
3502            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3503        }
3504    }
3505
3506    Ok(Redirect::to("/").into_response())
3507}
3508
3509/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3510async fn delete_subscription(
3511    State(state): State<AppState>,
3512    headers: HeaderMap,
3513    Path(rkey): Path<String>,
3514) -> Result<Response, WebError> {
3515    let did = match current_did(&state, &headers).await {
3516        Some(d) => d,
3517        None => return Ok(Redirect::to("/login").into_response()),
3518    };
3519    match state.repo().remove_subscription(&did, &rkey).await {
3520        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3521        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3522    }
3523    Ok(Redirect::to("/").into_response())
3524}
3525
3526/// Form body for `POST /subscriptions/:rkey/rename`.
3527#[derive(Debug, Deserialize)]
3528struct RenameSubForm {
3529    url: String,
3530    #[serde(default)]
3531    title: Option<String>,
3532    #[serde(default)]
3533    site_url: Option<String>,
3534    #[serde(default)]
3535    folder: Option<String>,
3536}
3537
3538/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3539/// folder, rewriting the whole subscription record via `putRecord`.
3540async fn rename_subscription(
3541    State(state): State<AppState>,
3542    headers: HeaderMap,
3543    Path(rkey): Path<String>,
3544    Form(form): Form<RenameSubForm>,
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    let feed_url = form.url.trim().to_string();
3551
3552    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3553    // write a junk row to the cache or a malformed subscription record to the
3554    // PDS (add_subscription refuses an empty input the same way).
3555    if feed_url.is_empty() {
3556        return Ok(Redirect::to("/").into_response());
3557    }
3558
3559    // **Read before write — `update_subscription` is a `putRecord`, and a
3560    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3561    //
3562    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3563    // and hand that over, so every field the form does not carry was written
3564    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3565    // `folder` — and nothing else — so a rename silently destroyed four fields:
3566    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3567    //
3568    // `createdAt` is the one that matters most: it is the reader's subscribe
3569    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3570    // repo rather than our cache, and once overwritten it is gone with nothing
3571    // in the UI to say so.
3572    //
3573    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3574    // and filters. That is one extra round trip on an action that is already
3575    // doing a PDS write, and it is bounded; a `get_subscription` would be
3576    // strictly better if this ever measures badly.
3577    //
3578    // **A failed read refuses the rename.** Falling back to the old
3579    // rebuild-from-scratch here would reinstate the data loss on exactly the
3580    // flaky path, which is the worst place to have it. The write below already
3581    // takes this stance — "a failure here means nothing was renamed or moved" —
3582    // and the read gets the same one.
3583    let existing = match state.repo().list_subscriptions_sorted(&did).await {
3584        Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3585        Err(err) => {
3586            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3587            return Ok(Redirect::to(&format!(
3588                "/?flash={}",
3589                qenc("Could not reach your PDS — nothing was renamed or moved.")
3590            ))
3591            .into_response());
3592        }
3593    };
3594    let Some(existing) = existing else {
3595        // The rkey is not in the reader's repo. Renaming a record that is not
3596        // there would CREATE one, which is not what "rename" means and would
3597        // give it a fresh `createdAt` — the bug this read exists to prevent.
3598        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3599        return Ok(Redirect::to(&format!(
3600            "/?flash={}",
3601            qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3602        ))
3603        .into_response());
3604    };
3605
3606    // The subscription can be repointed at a different feed URL. **Every gate
3607    // on the URL applies to a repoint and only a repoint** — the three below
3608    // were each, at one time, run before this line on the URL as posted, and
3609    // each refused a pure retitle of a record that already existed:
3610    //
3611    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3612    //   at-URI that is not a publication (a feed generator another client
3613    //   subscribed to), so the record became un-editable with a flash saying
3614    //   it "was not saved or sent anywhere";
3615    // - the global feeds ceiling keyed on "URL not in the cache", and an
3616    //   at:// record is never cached with the flag off, so at capacity a
3617    //   retitle was refused for a row the handler would not insert;
3618    // - storability, the same way.
3619    //
3620    // An unchanged URL is already in the reader's repo; refusing to retitle
3621    // it protects nothing and takes their own record away from them.
3622    // Like for like: the form value is trimmed, and a record another client
3623    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3624    let url_changed = existing.url.trim() != feed_url;
3625
3626    // **Storability, on the same terms as the add and OPML paths — for a
3627    // REPOINT, and FIRST.** A target this instance cannot store gets that
3628    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3629    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3630    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3631    // here; a review found it by enumerating every writer of the table. The
3632    // first fix ran this check before the repo lookup, on the URL as posted —
3633    // which refused a pure retitle of a subscription that already IS an
3634    // at-URI, on every instance with the flag off. The flag gates what the
3635    // cache may store, not whether a reader may edit their own record: an
3636    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3637    // row below.
3638    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3639    if url_changed && !storable {
3640        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3641        return Ok(
3642            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3643                .into_response(),
3644        );
3645    }
3646
3647    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3648    // and rename both upserts it to the local cache AND rewrites the PDS
3649    // subscription record (a public `putRecord`), so without this guard a
3650    // crafted rename could land a secret-bearing URL in the public PDS — the
3651    // exact leak the add and OPML paths already prevent.
3652    if url_changed {
3653        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3654            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3655            return Ok(
3656                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3657            );
3658        }
3659    }
3660
3661    // Global feeds ceiling parity with add_subscription: a repoint to a
3662    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3663    // shared cache is at capacity (an existing/duplicate URL adds no row and
3664    // is always fine). `<= 0` disables.
3665    let feeds_cap = state.config.max_feeds_global;
3666    if url_changed
3667        && feeds_cap > 0
3668        && store::get_feed_by_url(&state.db, &feed_url)
3669            .await?
3670            .is_none()
3671    {
3672        match store::count_feeds(&state.db).await {
3673            Ok(n) if n >= feeds_cap => {
3674                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3675                return Ok(Redirect::to(&format!(
3676                    "/?flash={}",
3677                    qenc(
3678                        "This instance is at its feed capacity right now. Please try again later."
3679                    )
3680                ))
3681                .into_response());
3682            }
3683            Ok(_) => {}
3684            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3685        }
3686    }
3687
3688    let mut sub = existing;
3689    sub.url = feed_url;
3690    sub.title = form
3691        .title
3692        .map(|t| t.trim().to_string())
3693        .filter(|t| !t.is_empty());
3694    sub.folder = form
3695        .folder
3696        .map(|f| f.trim().to_string())
3697        .filter(|f| !f.is_empty());
3698    // `createdAt` and `private` carry over untouched — neither is a property of
3699    // which feed URL the subscription points at.
3700    //
3701    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3702    // repoint drops them rather than leaving a site link for the old feed
3703    // hanging off the new one. An explicit form value still wins if the form
3704    // ever starts carrying one.
3705    match form
3706        .site_url
3707        .map(|t| t.trim().to_string())
3708        .filter(|t| !t.is_empty())
3709    {
3710        Some(site) => sub.site_url = Some(site),
3711        None if url_changed => sub.site_url = None,
3712        None => {}
3713    }
3714    if url_changed {
3715        sub.fetch_hint = None;
3716    }
3717
3718    // Keep the local cache title in step for the loose-feed fallback path —
3719    // for a row this instance would have. Two cases write nothing:
3720    //
3721    // - not storable (an existing at-URI with the flag off): the record is the
3722    //   reader's to edit, the cache row is not this instance's to create;
3723    // - an unchanged URL with no cache row: a retitle is never the write that
3724    //   CREATES a row. That covers two findings at once — the ceiling is
3725    //   checked on a repoint only, so a retitle must not insert past it; and
3726    //   a secret-bearing URL another client subscribed to has no row (the
3727    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
3728    //   refuses to cache it), so it cannot enter the shared table here, be
3729    //   polled, fail, and be printed on the admin page. A privacy re-check on
3730    //   this write was the first draft; mutation showed it dead — the row
3731    //   rule already refused every case it would have.
3732    let cache_write =
3733        storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3734    if !cache_write {
3735        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3736    } else if let Err(err) = store::upsert_feed(
3737        &state.db,
3738        &store::NewFeed {
3739            url: sub.url.clone(),
3740            title: sub.title.clone(),
3741            site_url: sub.site_url.clone(),
3742            ..Default::default()
3743        },
3744    )
3745    .await
3746    {
3747        // Not fatal to the rename — the PDS record below is the source of truth
3748        // — but a missing `feeds` row means this subscription is never polled.
3749        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3750    }
3751
3752    // **The PDS write decides what the reader is told.**
3753    //
3754    // This used to `warn!` on failure and then redirect exactly as it does on
3755    // success, so a rename that did not happen was indistinguishable from one
3756    // that did — the reader saw their old title come back and had no reason to
3757    // think anything had gone wrong. The PDS record IS the subscription; a
3758    // failure here means nothing was renamed or moved.
3759    match state.repo().update_subscription(&did, &rkey, &sub).await {
3760        Ok(res) => {
3761            info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3762            Ok(Redirect::to("/").into_response())
3763        }
3764        Err(err) => {
3765            warn!(%err, %did, %rkey, "PDS subscription update failed");
3766            Ok(Redirect::to(&format!(
3767                "/?flash={}",
3768                qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3769            ))
3770            .into_response())
3771        }
3772    }
3773}
3774
3775// ---------------------------------------------------------------------------
3776// Folders
3777// ---------------------------------------------------------------------------
3778
3779/// Form body for `POST /folders`.
3780#[derive(Debug, Deserialize)]
3781struct FolderForm {
3782    name: String,
3783}
3784
3785/// `POST /folders` — create a folder record.
3786async fn create_folder(
3787    State(state): State<AppState>,
3788    headers: HeaderMap,
3789    Form(form): Form<FolderForm>,
3790) -> Result<Response, WebError> {
3791    let did = match current_did(&state, &headers).await {
3792        Some(d) => d,
3793        None => return Ok(Redirect::to("/login").into_response()),
3794    };
3795    let name = form.name.trim();
3796    if name.is_empty() {
3797        return Ok(Redirect::to("/").into_response());
3798    }
3799    let folder = Folder::new(name.to_string(), now_rfc3339());
3800    match state.repo().add_folder(&did, &folder).await {
3801        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3802        Err(err) => warn!(%err, %did, "PDS folder create failed"),
3803    }
3804    Ok(Redirect::to("/").into_response())
3805}
3806
3807/// `POST /folders/:rkey/rename` — rename a folder record.
3808async fn rename_folder(
3809    State(state): State<AppState>,
3810    headers: HeaderMap,
3811    Path(rkey): Path<String>,
3812    Form(form): Form<FolderForm>,
3813) -> Result<Response, WebError> {
3814    let did = match current_did(&state, &headers).await {
3815        Some(d) => d,
3816        None => return Ok(Redirect::to("/login").into_response()),
3817    };
3818    let name = form.name.trim();
3819    if name.is_empty() {
3820        return Ok(Redirect::to("/").into_response());
3821    }
3822    let folder = Folder::new(name.to_string(), now_rfc3339());
3823    match state.repo().rename_folder(&did, &rkey, &folder).await {
3824        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3825        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3826    }
3827    Ok(Redirect::to("/").into_response())
3828}
3829
3830/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3831/// simply become un-foldered).
3832async fn delete_folder(
3833    State(state): State<AppState>,
3834    headers: HeaderMap,
3835    Path(rkey): Path<String>,
3836) -> Result<Response, WebError> {
3837    let did = match current_did(&state, &headers).await {
3838        Some(d) => d,
3839        None => return Ok(Redirect::to("/login").into_response()),
3840    };
3841    match state.repo().remove_folder(&did, &rkey).await {
3842        Ok(()) => info!(%did, %rkey, "deleted folder record"),
3843        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3844    }
3845    Ok(Redirect::to("/").into_response())
3846}
3847
3848/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3849/// feed document we take it as-is; if it yields an HTML page we run
3850/// autodiscovery over its `<link rel="alternate">` tags.
3851async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3852    let parsed =
3853        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3854
3855    let client = feed::build_client()?;
3856    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3857    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3858    // loopback / private hosts.
3859    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3860    let final_url = resp.url().clone();
3861    let content_type = resp
3862        .headers()
3863        .get(axum::http::header::CONTENT_TYPE)
3864        .and_then(|v| v.to_str().ok())
3865        .unwrap_or("")
3866        .to_ascii_lowercase();
3867    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3868    // gzip strips it, and this response is reflected into the UI.
3869    let raw = crate::net::read_capped(resp).await?;
3870    let body = String::from_utf8_lossy(&raw).into_owned();
3871
3872    let looks_like_feed = content_type.contains("xml")
3873        || content_type.contains("rss")
3874        || content_type.contains("atom")
3875        || content_type.contains("application/feed+json")
3876        || {
3877            let head = body.trim_start();
3878            head.starts_with("<?xml")
3879                || head.starts_with("<rss")
3880                || head.starts_with("<feed")
3881                || head.contains("<rss")
3882                || head.contains("<feed")
3883        };
3884    if looks_like_feed {
3885        return Ok(final_url.to_string());
3886    }
3887
3888    match feed::discover_feed(&body, Some(&final_url)) {
3889        Some(u) => Ok(u.to_string()),
3890        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3891    }
3892}
3893
3894// ---------------------------------------------------------------------------
3895// Login (atproto OAuth via the sidecar)
3896// ---------------------------------------------------------------------------
3897
3898/// Query for `GET /login`.
3899#[derive(Debug, Deserialize, Default)]
3900struct LoginQuery {
3901    #[serde(default)]
3902    handle: Option<String>,
3903    #[serde(default)]
3904    error: Option<String>,
3905    #[serde(default)]
3906    flash: Option<String>,
3907}
3908
3909/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3910///
3911/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3912/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3913/// session cookie *or* the submitted handle resolving to a seated DID) or a
3914/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3915/// form (no handle) always renders.
3916async fn login_form(
3917    State(state): State<AppState>,
3918    headers: HeaderMap,
3919    Query(q): Query<LoginQuery>,
3920) -> Response {
3921    if let Some(handle) = q
3922        .handle
3923        .map(|h| h.trim().to_string())
3924        .filter(|h| !h.is_empty())
3925    {
3926        if !may_start_oauth(&state, &headers, &handle).await {
3927            return Redirect::to("/beta/redeem").into_response();
3928        }
3929        return start_oauth(&state, &handle).await;
3930    }
3931    render(&LoginTemplate {
3932        card: login_card(&state.config),
3933        repo_url: REPO_URL,
3934        error: q.error.unwrap_or_default(),
3935        flash: q.flash.unwrap_or_default(),
3936    })
3937}
3938
3939/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3940/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3941async fn login_submit(
3942    State(state): State<AppState>,
3943    headers: HeaderMap,
3944    Form(form): Form<LoginForm>,
3945) -> Response {
3946    let handle = form.handle.trim();
3947    if handle.is_empty() {
3948        return login_error(&state, "Enter your atproto handle.");
3949    }
3950    if !may_start_oauth(&state, &headers, handle).await {
3951        return Redirect::to("/beta/redeem").into_response();
3952    }
3953    start_oauth(&state, handle).await
3954}
3955
3956/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3957/// admits, in order of cost:
3958///
3959/// 1. an existing beta member's cookie session whose DID already holds a seat;
3960/// 2. a fresh visitor carrying a valid reserving invite cookie;
3961/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3962///    already holds a seat — this honors the **seeded admin's first login** on a
3963///    fresh deploy (and any returning member who cleared cookies) without a
3964///    session cookie or an invite code.
3965///
3966/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3967/// handle→DID resolution is only attempted when neither applies. It fails
3968/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
3969/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
3970/// This keeps the anti-abuse intent — a rando now pays a cheap handle
3971/// resolution instead of a burned sidecar handshake (and `/login` is already in
3972/// the rate-limited path set).
3973async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3974    // The production resolver is the app's existing atproto handle→DID path,
3975    // routed through the SSRF guard. Resolution is injected so tests can exercise
3976    // the gate without a live network call (the guard forbids loopback mocks).
3977    may_start_oauth_with(state, headers, handle, |h| async move {
3978        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3979            .await
3980            .ok()
3981    })
3982    .await
3983}
3984
3985/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
3986/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
3987/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
3988/// only called when neither admits — keeping the network round-trip off the hot
3989/// path and preserving the fail-closed contract on resolution failure.
3990async fn may_start_oauth_with<F, Fut>(
3991    state: &AppState,
3992    headers: &HeaderMap,
3993    handle: &str,
3994    resolve: F,
3995) -> bool
3996where
3997    F: FnOnce(String) -> Fut,
3998    Fut: std::future::Future<Output = Option<String>>,
3999{
4000    // 1. An already-beta'd session may re-auth freely.
4001    if let Some(did) = current_did(state, headers).await {
4002        if store::has_beta_access(&state.db, &did)
4003            .await
4004            .unwrap_or(false)
4005        {
4006            return true;
4007        }
4008    }
4009    // 2. A valid reserving invite cookie.
4010    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4011        return true;
4012    }
4013    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
4014    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
4015    //    on any resolution error or unresolvable/malformed handle.
4016    match resolve(handle.to_string()).await {
4017        Some(did) => store::has_beta_access(&state.db, &did)
4018            .await
4019            .unwrap_or(false),
4020        None => {
4021            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4022            false
4023        }
4024    }
4025}
4026
4027/// Begin the OAuth handshake for `handle`, on whichever backend is live.
4028///
4029/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
4030/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
4031/// carries `form-action 'self'`. Browsers have historically disagreed about
4032/// whether that directive applies to redirects following a form submission, and
4033/// if it did here, login would break in a browser while every test passed.
4034///
4035/// It does not, and the evidence is the SIDECAR path, which is live in
4036/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
4037/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
4038/// whole redirect chain would already be blocking that. One checking only the
4039/// form's action URL sees `/login` in both cases. The two arms differ only in
4040/// how many same-origin hops precede the cross-origin one, so any policy that
4041/// permits the sidecar flow permits this one.
4042///
4043/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
4044/// its own `/login` and its own callback, so starting a login is one redirect
4045/// and nothing is stored here. The Rust backend pushes the authorization
4046/// request itself, which means this app now holds the pending login — and must
4047/// set the browser-binding cookie that the callback will be checked against.
4048async fn start_oauth(state: &AppState, handle: &str) -> Response {
4049    match state.config.repo_backend {
4050        crate::metrics::Backend::Sidecar => {
4051            let url = state.sidecar.login_url(handle, None);
4052            info!(%handle, "redirecting to OAuth sidecar login");
4053            Redirect::to(&url).into_response()
4054        }
4055        crate::metrics::Backend::Rust => {
4056            let Some(runtime) = state.oauth.as_deref() else {
4057                warn!("the rust backend is live but its OAuth runtime is absent");
4058                return login_error(state, "Login is not available right now.");
4059            };
4060            match crate::oauth::login::start(
4061                runtime,
4062                &state.http,
4063                &state.db,
4064                handle,
4065                crate::store::now_unix(),
4066            )
4067            .await
4068            {
4069                Ok(started) => {
4070                    info!(%handle, "pushed authorization request; redirecting to the PDS");
4071                    let mut resp = Redirect::to(&started.authorize_url).into_response();
4072                    set_cookie(
4073                        &mut resp,
4074                        &cookie::sign_value(
4075                            OAUTH_BINDING_COOKIE,
4076                            &started.binding_token,
4077                            &state.config.cookie_secret,
4078                            OAUTH_BINDING_MAX_AGE_SECS,
4079                        ),
4080                    );
4081                    resp
4082                }
4083                Err(err) => {
4084                    // The handle the user typed is logged; the error is not shown
4085                    // to them verbatim, since it can name internal hosts.
4086                    warn!(%err, %handle, "could not start the OAuth login");
4087                    login_error(state, "Could not start login for that handle.")
4088                }
4089            }
4090        }
4091    }
4092}
4093
4094/// Clear the browser-binding cookie. Called on every terminal outcome of a
4095/// callback, successful or not: the pending row is consumed either way, so a
4096/// lingering cookie can only ever match a login that no longer exists.
4097fn clear_binding_cookie(resp: &mut Response) {
4098    set_cookie(
4099        resp,
4100        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4101    );
4102}
4103
4104/// Form body for `POST /login`.
4105#[derive(Debug, Deserialize)]
4106struct LoginForm {
4107    handle: String,
4108}
4109
4110/// Query for `GET /oauth/callback`.
4111///
4112/// Carries BOTH shapes, because the two backends deliver different things to
4113/// the same URL: the sidecar hands back a one-shot `session_id` it has already
4114/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
4115/// for this app to exchange itself. Which fields are populated is decided by
4116/// which backend started the login, not by which is live now — so a flip with a
4117/// login already in flight still lands in the right arm.
4118#[derive(Debug, Deserialize, Default)]
4119struct CallbackQuery {
4120    /// Sidecar backend: the handoff id.
4121    #[serde(default)]
4122    session_id: Option<String>,
4123    /// Rust backend: the authorization code and its envelope.
4124    #[serde(default)]
4125    code: Option<String>,
4126    #[serde(default)]
4127    state: Option<String>,
4128    #[serde(default)]
4129    iss: Option<String>,
4130    /// JARM, which is not supported — carried only so it can be refused
4131    /// explicitly rather than read as "no code".
4132    #[serde(default)]
4133    response: Option<String>,
4134    #[serde(default)]
4135    error: Option<String>,
4136    #[serde(default)]
4137    error_description: Option<String>,
4138}
4139
4140/// `GET /oauth/callback` — establish the cookie session.
4141///
4142/// **Invite gate:** the verified DID must hold beta access. If it already does
4143/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
4144/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
4145/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
4146async fn oauth_callback(
4147    State(state): State<AppState>,
4148    headers: HeaderMap,
4149    Query(q): Query<CallbackQuery>,
4150) -> Response {
4151    // An error response is handled by the SAME arm that would have handled a
4152    // success, not short-circuited here.
4153    //
4154    // Returning early looks obviously right and is wrong on the Rust path: it
4155    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
4156    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
4157    // error originates from the intended AS". It also leaves the pending row
4158    // unconsumed, so a `state` that has already produced a callback stays usable
4159    // until it expires.
4160    //
4161    // The sidecar arm has no such check to reach, so it is short-circuited
4162    // below, preserving exactly what it did before.
4163    // **The arm is chosen by what the SERVER knows, not by what the caller
4164    // sent.** A `session_id` in the query used to select the sidecar arm on its
4165    // own — so a caller could pick which code path ran, and the sidecar arm has
4166    // no browser-binding check at all. It also short-circuited the error path
4167    // below, skipping the `iss` validation.
4168    //
4169    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
4170    // configured one, means the selection follows this deployment's own
4171    // configuration. A login started before a flip still completes, because the
4172    // Rust arm is reached whenever the Rust runtime exists and can match the
4173    // `state` against a pending row it actually wrote.
4174    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
4175    // and `?error=…&error_description=…` on its own failure. Keying only on
4176    // `session_id` sent the failure shape down the Rust arm, which then failed
4177    // with "no `state`" and replaced the specific reason with a generic one —
4178    // and `error_description` is exactly what the sidecar Caddy routing matches
4179    // to send that request here in the first place.
4180    let sidecar_shape =
4181        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4182    let sidecar_handoff = sidecar_shape
4183        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4184    if let Some(err) = q.error.clone() {
4185        // **Neither the code nor the description is echoed as sent.**
4186        //
4187        // Both are server-controlled free text arriving on a public GET, so
4188        // anyone who can make a browser fetch this URL chooses them. The raw
4189        // `error` used to go into a `warn!` AND into the rendered login page,
4190        // and `error_description` — arbitrary text, newlines included — went
4191        // into the log verbatim: a log-injection surface on one side and
4192        // attacker-chosen copy in the product's own voice on the other.
4193        //
4194        // `oauth::flow` already decided this exact question for the Rust arm:
4195        // reduce the code to a known slug, drop the description entirely. That
4196        // reasoning is not specific to which arm handles the callback, and this
4197        // one simply never got the same treatment. The description's LENGTH is
4198        // kept, because "the server sent a 4 KB explanation" is occasionally
4199        // worth knowing and cannot be used to inject anything.
4200        let slug = crate::oauth::flow::known_error_slug(&err);
4201        warn!(
4202            error = slug,
4203            desc_len = q.error_description.as_deref().map_or(0, str::len),
4204            "OAuth callback returned an error"
4205        );
4206        if sidecar_handoff || state.oauth.is_none() {
4207            return login_error(&state, &format!("Login failed: {slug}"));
4208        }
4209        // Fall through: the Rust arm consumes the pending row and validates
4210        // `iss` against it, and reports the failure afterwards.
4211    }
4212
4213    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
4214    // currently selected: a login started before a flip must still complete.
4215    let session = if sidecar_handoff {
4216        let session_id = q.session_id.clone().unwrap_or_default();
4217        match state.sidecar.resolve_session(&session_id).await {
4218            Ok(Some(s)) => s,
4219            Ok(None) => {
4220                warn!("OAuth callback session_id did not resolve (expired/unknown)");
4221                return login_error(&state, "Login session expired — please try again.");
4222            }
4223            Err(err) => {
4224                warn!(%err, "failed to resolve OAuth session via the sidecar");
4225                return login_error(&state, "Login failed talking to the auth service.");
4226            }
4227        }
4228    } else {
4229        let Some(runtime) = state.oauth.as_deref() else {
4230            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4231            return login_error(&state, "Login failed: this login could not be completed.");
4232        };
4233        let params = crate::oauth::flow::CallbackParams {
4234            code: q.code.clone(),
4235            state: q.state.clone(),
4236            iss: q.iss.clone(),
4237            // Passed through, NOT dropped: `verify_callback` checks `iss`
4238            // against the pending row's issuer before it reports the error, and
4239            // it cannot do that for an error it never sees.
4240            error: q.error.clone(),
4241            error_description: q.error_description.clone(),
4242            response: q.response.clone(),
4243        };
4244        let binding =
4245            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4246        match crate::oauth::login::complete(
4247            runtime,
4248            &state.http,
4249            &state.db,
4250            &params,
4251            binding.as_deref(),
4252            crate::store::now_unix(),
4253        )
4254        .await
4255        {
4256            Ok(done) => crate::atproto::SidecarSession {
4257                did: done.did,
4258                handle: done.handle,
4259            },
4260            Err(err) => {
4261                // Never echoed to the browser: the message can name the issuer,
4262                // the PDS, and why a binding check failed.
4263                warn!(%err, "could not complete the OAuth callback");
4264                let mut resp = login_error(&state, "Login failed — please try again.");
4265                clear_binding_cookie(&mut resp);
4266                return resp;
4267            }
4268        }
4269    };
4270
4271    // Bind the verified DID to the invite gate. Returns a response only on the
4272    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
4273    let mut clear_invite = false;
4274    if !store::has_beta_access(&state.db, &session.did)
4275        .await
4276        .unwrap_or(false)
4277    {
4278        // Not yet a member: consume the reserved invite code, if any.
4279        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4280            Some(c) => c,
4281            None => {
4282                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4283                return Redirect::to("/beta/redeem").into_response();
4284            }
4285        };
4286        match store::redeem_code(
4287            &state.db,
4288            &code,
4289            &session.did,
4290            session.handle.as_deref(),
4291            state.config.beta_cap,
4292        )
4293        .await
4294        {
4295            Ok(Ok(())) => {
4296                clear_invite = true;
4297                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4298            }
4299            Ok(Err(policy)) => {
4300                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4301                let mut resp = redeem_bounce(&state, &policy).into_response();
4302                // The reservation is spent/invalid — drop the stale invite cookie.
4303                clear_invite_cookie(&mut resp);
4304                return resp;
4305            }
4306            Err(err) => {
4307                warn!(%err, did = %session.did, "invite redeem infra error at callback");
4308                return login_error(&state, "Login failed while confirming your invite.");
4309            }
4310        }
4311    }
4312
4313    // Mint an opaque, random server-side session id and store the identity under
4314    // it; the cookie carries the (HMAC-signed) sid, never the DID.
4315    let sid = state.sessions.create(Session {
4316        did: session.did.clone(),
4317        handle: session.handle.clone(),
4318    });
4319    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4320    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4321
4322    let mut resp = Redirect::to("/").into_response();
4323    set_cookie(&mut resp, &cookie);
4324    clear_binding_cookie(&mut resp);
4325    if clear_invite {
4326        clear_invite_cookie(&mut resp);
4327    }
4328    resp
4329}
4330
4331/// Revoke a DID's OAuth session on BOTH backends, best-effort.
4332///
4333/// Not "whichever backend is live": during a cutover a user's tokens can be in
4334/// either store — they logged in under one backend and are logging out under
4335/// the other. Revoking only the live one would leave a live refresh token
4336/// behind in the other, which is the exact failure sign-out exists to prevent,
4337/// and it would be invisible because the sign-out itself looks successful.
4338///
4339/// Both arms are best-effort. The caller has already decided to sign the user
4340/// out, and a network failure must not trap them in a half-logged-out state.
4341/// How long sign-out will wait for a final read-state flush before revoking
4342/// anyway.
4343///
4344/// Bounded because the flush talks to the user's PDS, and a user trying to leave
4345/// must never be held by a server that is not answering. Three seconds is long
4346/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
4347/// and short enough that a dead PDS is an inconvenience rather than a trap.
4348const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4349
4350/// Flush whatever read-state is still dirty for `did`, then give up quietly.
4351///
4352/// **Called before revoking, because revoking first strands it (#117).**
4353/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
4354/// session cannot be sent by anyone — it parks until the user signs in again,
4355/// which may be never. Flushing first is what stops the common case from
4356/// becoming that.
4357///
4358/// Best-effort by construction: every failure path here falls through to the
4359/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4360/// the parked state the flusher now handles deliberately rather than retrying
4361/// forever.
4362async fn flush_before_revoke(state: &AppState, did: &str) {
4363    match tokio::time::timeout(
4364        SIGN_OUT_FLUSH_BUDGET,
4365        crate::readstate::flush_did(state, did),
4366    )
4367    .await
4368    {
4369        Ok(Ok(())) => {}
4370        Ok(Err(err)) => {
4371            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4372        }
4373        Err(_) => warn!(
4374            %did,
4375            budget = ?SIGN_OUT_FLUSH_BUDGET,
4376            "sign-out: final read-state flush timed out; it will park until next sign-in"
4377        ),
4378    }
4379}
4380
4381async fn revoke_everywhere(state: &AppState, did: &str) {
4382    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4383    // that recording only the rust arm let `oauth_revoke` report a clean success
4384    // while every sidecar revocation failed — and for anyone who logged in before
4385    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4386    // rust arm correctly returns NoSession and the metric reads all-clear while
4387    // live refresh tokens sit at the PDS.
4388    //
4389    // Same op name, different backend: the backend column is what distinguishes
4390    // them, so "no revocation failures" means checking both rows, not one.
4391    let sidecar_started = std::time::Instant::now();
4392    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4393        Ok(res) => {
4394            info!(%did, revoked = res.revoked, "sidecar session revoked");
4395            true
4396        }
4397        Err(err) => {
4398            warn!(%did, %err, "sidecar revoke failed; continuing");
4399            false
4400        }
4401    };
4402    state.metrics.record(
4403        crate::metrics::Backend::Sidecar,
4404        "oauth_revoke",
4405        sidecar_started.elapsed().as_micros() as u64,
4406        sidecar_ok,
4407    );
4408
4409    if let Some(runtime) = state.oauth.as_deref() {
4410        let revoke_started = std::time::Instant::now();
4411        let outcome = crate::oauth::revoke::sign_out_discovering(
4412            runtime,
4413            &state.http,
4414            &state.db,
4415            did,
4416            crate::store::now_unix(),
4417        )
4418        .await;
4419        // **Counted, because a warn! nobody reads is not observability.** Until
4420        // this existed, a revocation failure left exactly one trace: a log line.
4421        // "No revocation failures this week" was therefore a statement about
4422        // nobody having looked, which is not the same claim.
4423        //
4424        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4425        // there being nothing to revoke is the correct outcome, not a failure,
4426        // and counting it as an error would make the metric noisy in exactly
4427        // the case that is fine. Only `Failed` means the PDS still holds live
4428        // tokens we asked it to drop.
4429        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4430        state.metrics.record(
4431            crate::metrics::Backend::Rust,
4432            "oauth_revoke",
4433            revoke_started.elapsed().as_micros() as u64,
4434            revoke_ok,
4435        );
4436        match outcome {
4437            crate::oauth::revoke::Revocation::Revoked => {
4438                info!(%did, "rust OAuth session revoked at the PDS")
4439            }
4440            crate::oauth::revoke::Revocation::NoSession => {}
4441            crate::oauth::revoke::Revocation::Failed(reason) => {
4442                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4443            }
4444        }
4445    }
4446}
4447
4448/// `POST /logout` — end the session everywhere, not just in this browser.
4449///
4450/// Clearing the cookie only stops *this* device from presenting the session;
4451/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4452/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4453/// access tokens at the PDS and drops the sidecar's session rows. The local
4454/// registry entry is dropped and the cookie cleared regardless of whether the
4455/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4456/// user in a half-logged-out state).
4457async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4458    if let Some(user) = current_session(&state, &headers).await {
4459        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4460        // revoke; the dev-DID fallback never handshook the sidecar.
4461        if let Some(sid) = user.sid {
4462            state.sessions.remove(&sid);
4463            // BEFORE the revoke: afterwards there is no session to send it with.
4464            flush_before_revoke(&state, &user.did).await;
4465            revoke_everywhere(&state, &user.did).await;
4466        }
4467    }
4468    let mut resp = Redirect::to("/login").into_response();
4469    set_cookie(
4470        &mut resp,
4471        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4472    );
4473    resp
4474}
4475
4476/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4477/// `DELETE` into this field for the purge to run.
4478#[derive(Debug, Deserialize)]
4479struct DeleteAccountForm {
4480    #[serde(default)]
4481    confirm: String,
4482}
4483
4484/// The literal a user must type to confirm the destructive delete.
4485const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4486
4487/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4488///
4489/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4490/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4491///   1. purges **every** local row owned by the caller DID (`entry_state`,
4492///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4493///      DID created) via [`store::purge_did_data`], then
4494///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
4495///      revoked at the PDS and the sidecar's session rows are dropped, then
4496///   3. drops the in-memory session and clears the cookie, signing the user out.
4497///
4498/// The subscription/folder/saved *records* in the user's own PDS are
4499/// intentionally left alone — they are the user's data on their own server; the
4500/// `/about` copy and this page's UI both say so, and export stays available.
4501async fn account_delete(
4502    State(state): State<AppState>,
4503    headers: HeaderMap,
4504    Form(form): Form<DeleteAccountForm>,
4505) -> Result<Response, WebError> {
4506    let user = match current_session(&state, &headers).await {
4507        Some(u) => u,
4508        None => return Ok(Redirect::to("/login").into_response()),
4509    };
4510    let did = user.did.clone();
4511
4512    // Confirm-gate: require the exact typed phrase before doing anything.
4513    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4514        return Ok(Redirect::to(&format!(
4515            "/manage?flash={}",
4516            qenc("Type DELETE to confirm — nothing was deleted.")
4517        ))
4518        .into_response());
4519    }
4520
4521    // 1. Purge every local row this DID owns (single transaction).
4522    let counts = store::purge_did_data(&state.db, &did).await?;
4523    info!(
4524        %did,
4525        total = counts.total(),
4526        entry_state = counts.entry_state,
4527        read_cursor = counts.read_cursor,
4528        sub_ref = counts.sub_ref,
4529        beta_access = counts.beta_access,
4530        invite_codes = counts.invite_codes,
4531        "account/delete: local rows purged"
4532    );
4533
4534    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4535    //    rows are already gone; a network blip must not block the sign-out).
4536    revoke_everywhere(&state, &did).await;
4537
4538    // 3. Drop the in-memory session and clear the cookie: sign the user out.
4539    if let Some(sid) = user.sid {
4540        state.sessions.remove(&sid);
4541    }
4542    let mut resp = Redirect::to(&format!(
4543        "/login?flash={}",
4544        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4545    ))
4546    .into_response();
4547    set_cookie(
4548        &mut resp,
4549        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4550    );
4551    Ok(resp)
4552}
4553
4554/// The `/login` card, shared by the form and its error re-render.
4555fn login_card(config: &Config) -> Card {
4556    Card::public(
4557        config,
4558        "/login",
4559        "Sign in — FeatherReader",
4560        "Sign in to FeatherReader with your atproto handle. You approve access on \
4561         your own server — no signup, no password.",
4562    )
4563}
4564
4565/// Re-render the login form with an error banner.
4566fn login_error(state: &AppState, msg: &str) -> Response {
4567    render(&LoginTemplate {
4568        card: login_card(&state.config),
4569        repo_url: REPO_URL,
4570        error: msg.to_string(),
4571        flash: String::new(),
4572    })
4573}
4574
4575// ---------------------------------------------------------------------------
4576// Closed-beta invite gate (self-serve redeem + admin mint)
4577// ---------------------------------------------------------------------------
4578
4579/// Form body for `POST /beta/redeem`.
4580#[derive(Debug, Deserialize)]
4581struct RedeemForm {
4582    code: String,
4583}
4584
4585/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4586/// already full we render the "capacity full" variant (no form).
4587async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4588    let full = store::count_beta_access(&state.db)
4589        .await
4590        .map(|n| n >= state.config.beta_cap)
4591        .unwrap_or(false);
4592    render(&BetaRedeemTemplate {
4593        card: redeem_card(&state.config),
4594        repo_url: REPO_URL,
4595        error: String::new(),
4596        capacity_full: full,
4597    })
4598}
4599
4600/// `POST /beta/redeem` — the **pre-handshake** reservation.
4601///
4602/// Validates the pasted code is *redeemable right now* (exists, active,
4603/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4604/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4605/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4606/// OAuth callback later binds the verified DID and atomically consumes the code
4607/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4608/// start OAuth (and burn a sidecar handshake).
4609async fn beta_redeem_submit(
4610    State(state): State<AppState>,
4611    Form(form): Form<RedeemForm>,
4612) -> Response {
4613    let code = form.code.trim().to_uppercase();
4614    if code.is_empty() {
4615        return render(&BetaRedeemTemplate {
4616            card: redeem_card(&state.config),
4617            repo_url: REPO_URL,
4618            error: "Enter your invite code.".to_string(),
4619            capacity_full: false,
4620        });
4621    }
4622
4623    match preflight_code(&state, &code).await {
4624        Ok(()) => {
4625            let cookie = sign_invite(&code, &state.config.cookie_secret);
4626            let mut resp = Redirect::to("/login").into_response();
4627            set_cookie(&mut resp, &cookie);
4628            info!("invite code preflight OK; reserving intent + redirecting to /login");
4629            resp
4630        }
4631        Err(policy) => {
4632            warn!(?policy, "invite code preflight rejected");
4633            redeem_bounce(&state, &policy)
4634        }
4635    }
4636}
4637
4638/// Read-only preflight of an invite code for the pre-handshake reservation:
4639/// verify it exists, is active, is not past `expires_at`, and that a seat is
4640/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4641/// callback time. Does NOT consume the code or grant a seat. Returns the same
4642/// typed [`store::RedeemError`] variants so the two paths share one message map.
4643async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4644    // Cap check first: a clear "capacity full" beats "code invalid" when both.
4645    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4646    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4647    // still backstops the real cap inside its tx, so this is a consistency /
4648    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4649    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4650    // that might overrun the cap.
4651    let count = match store::count_beta_access(&state.db).await {
4652        Ok(n) => n,
4653        Err(err) => {
4654            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4655            return Err(store::RedeemError::CapacityFull);
4656        }
4657    };
4658    if count >= state.config.beta_cap {
4659        return Err(store::RedeemError::CapacityFull);
4660    }
4661    // Look up the code's current status + expiry (read-only).
4662    let row = sqlx::query_as::<_, (String, i64)>(
4663        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4664    )
4665    .bind(code)
4666    .fetch_optional(&state.db)
4667    .await
4668    .ok()
4669    .flatten();
4670    let (status, expires_at) = match row {
4671        Some(r) => r,
4672        None => return Err(store::RedeemError::NotFound),
4673    };
4674    let now = chrono::Utc::now().timestamp();
4675    match status.as_str() {
4676        "active" if expires_at >= now => Ok(()),
4677        "active" => Err(store::RedeemError::Expired),
4678        "expired" => Err(store::RedeemError::Expired),
4679        // "redeemed" or anything else non-active.
4680        _ => Err(store::RedeemError::AlreadyRedeemed),
4681    }
4682}
4683
4684/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4685/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4686fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
4687    use store::RedeemError::*;
4688    let (msg, capacity_full) = match policy {
4689        NotFound => ("That invite code isn't valid.", false),
4690        Expired => ("That invite code has expired.", false),
4691        AlreadyRedeemed => ("That invite code has already been used.", false),
4692        CapacityFull => ("", true),
4693    };
4694    render(&BetaRedeemTemplate {
4695        card: redeem_card(&state.config),
4696        repo_url: REPO_URL,
4697        error: msg.to_string(),
4698        capacity_full,
4699    })
4700}
4701
4702/// The `/beta/redeem` card, shared by the form, its re-renders and the claim
4703/// link's bounce.
4704fn redeem_card(config: &Config) -> Card {
4705    Card::public(
4706        config,
4707        "/beta/redeem",
4708        "Redeem an invite — FeatherReader",
4709        "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
4710         in with your atproto handle.",
4711    )
4712}
4713
4714/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4715#[derive(Debug, Deserialize, Default)]
4716struct MintQuery {
4717    #[serde(default)]
4718    n: Option<u32>,
4719}
4720
4721/// `POST /admin/invites?n=N` — mint N invite codes.
4722///
4723/// `GET /oauth/client-metadata.json` — the client's published identity.
4724///
4725/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4726/// caches it against every existing grant, so it must keep answering at exactly
4727/// this path across the cutover — the sidecar serves the same document at the
4728/// same URL today, proxied by the edge.
4729///
4730/// Served whatever backend is live: a request that arrives here is from a PDS
4731/// resolving our identity, and it has no idea which of our two implementations
4732/// is currently answering repo calls.
4733async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4734    let Some(runtime) = state.oauth.as_deref() else {
4735        // The sidecar is serving this path in front of us, or nothing is.
4736        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4737    };
4738    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4739}
4740
4741/// `GET /oauth/jwks.json` — the client's public signing key.
4742///
4743/// Production only. The localhost dev client is a PUBLIC client: it registers no
4744/// key and signs no assertions, so publishing a JWKS there would advertise a
4745/// credential that is never used — and would make a dev deployment look like a
4746/// confidential client to anyone reading it.
4747async fn oauth_jwks(State(state): State<AppState>) -> Response {
4748    let Some(runtime) = state.oauth.as_deref() else {
4749        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4750    };
4751    match runtime.client_key.as_ref() {
4752        Some(key) => match key.jwks_document() {
4753            Ok(doc) => axum::Json(doc).into_response(),
4754            Err(err) => {
4755                warn!(%err, "could not render the client JWKS");
4756                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4757            }
4758        },
4759        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4760    }
4761}
4762
4763/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4764const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4765
4766/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4767///
4768/// Admin-gated on the same rule as the invite minter: the table names every
4769/// operation the reader performs and how often each fails, which is an
4770/// operational picture rather than public information.
4771///
4772/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4773/// is safe, and the comparison is two rows side by side.
4774async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4775    let did = match current_did(&state, &headers).await {
4776        Some(d) => d,
4777        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4778    };
4779    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4780        warn!(%did, "admin metrics denied: not an admin-seed DID");
4781        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4782    }
4783
4784    // Flush first, so the table includes this process's traffic up to now.
4785    // Then read the PERSISTED rows, which is the only place both backends can
4786    // appear at once -- a flip is a restart, and in-process memory only ever
4787    // holds the backend currently running.
4788    if let Err(err) =
4789        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4790    {
4791        warn!(%err, "could not flush repo timings before rendering");
4792    }
4793    let rows = match crate::metrics::persisted_rows(&state.db).await {
4794        Ok(rows) => rows,
4795        Err(err) => {
4796            warn!(%err, "could not read persisted repo timings");
4797            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4798        }
4799    };
4800
4801    // The live backend is named at the top: a table of two populated rows is
4802    // ambiguous about which one is currently serving users.
4803    // Parked read-state, alongside the timings. The flusher no longer logs
4804    // these every round (#117), so without a number here the state would be
4805    // silent — which is the failure the noisy loop at least did not have.
4806    let parked = match crate::store::parked_readstate_dids(&state.db).await {
4807        Ok(n) => n.to_string(),
4808        Err(err) => {
4809            warn!(%err, "could not count parked read-state DIDs");
4810            "unknown".to_string()
4811        }
4812    };
4813    // **The half the public histogram cannot carry.** `/stats` reports counts by
4814    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4815    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4816    // cannot separate "the publishers are gone" from "we are broken". #159 was
4817    // the latter and took a production investigation to establish. Named feeds
4818    // and their error text belong here, behind ALLOWED_DIDS.
4819    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4820        Ok(f) => f,
4821        Err(err) => {
4822            warn!(%err, "could not list failing feeds");
4823            Vec::new()
4824        }
4825    };
4826    let mut failing_block = String::new();
4827    if !failing.is_empty() {
4828        failing_block.push_str("\nfailing feeds (worst first)\n");
4829        for f in &failing {
4830            failing_block.push_str(&format!(
4831                "  {:>4}x  {:<8}  {}\n          {}\n",
4832                f.consecutive_errors,
4833                f.kind.as_deref().unwrap_or("unknown"),
4834                f.url,
4835                f.detail.as_deref().unwrap_or("(no detail recorded)"),
4836            ));
4837        }
4838    }
4839
4840    // **Capacity that no other page can show.** The global ceiling counts every
4841    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
4842    // unpollable ones — so an instance can be at its cap with every public
4843    // number saying otherwise. A review found exactly that gap.
4844    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4845        Ok(n) => n,
4846        Err(err) => {
4847            warn!(%err, "could not count unpollable feeds");
4848            -1
4849        }
4850    };
4851    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4852
4853    let body = format!(
4854        "live backend: {}\nparked read-state DIDs: {}\n\
4855         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4856        state.config.repo_backend.as_str(),
4857        parked,
4858        cached,
4859        state.config.max_feeds_global,
4860        unpollable,
4861        crate::metrics::render(&rows),
4862        failing_block,
4863    );
4864    (StatusCode::OK, body).into_response()
4865}
4866
4867/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4868/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4869/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4870async fn admin_mint_invites(
4871    State(state): State<AppState>,
4872    headers: HeaderMap,
4873    Query(q): Query<MintQuery>,
4874) -> Response {
4875    // Require a real, current session (not just a DID string) whose DID is an
4876    // admin-seed DID. `current_did` already re-checks the beta gate.
4877    let did = match current_did(&state, &headers).await {
4878        Some(d) => d,
4879        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4880    };
4881    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4882        warn!(%did, "admin mint denied: not an admin-seed DID");
4883        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4884    }
4885
4886    let n = q.n.unwrap_or(1).clamp(1, 100);
4887    let mut codes = Vec::with_capacity(n as usize);
4888    for _ in 0..n {
4889        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4890            Ok(code) => codes.push(code),
4891            Err(err) => {
4892                warn!(%err, %did, "admin mint_code failed");
4893                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4894            }
4895        }
4896    }
4897    info!(%did, count = codes.len(), "admin minted invite codes");
4898    let mut body = codes.join("\n");
4899    body.push('\n');
4900    (StatusCode::OK, body).into_response()
4901}
4902
4903// ---------------------------------------------------------------------------
4904// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
4905// ---------------------------------------------------------------------------
4906
4907/// Query for `GET /claim`.
4908#[derive(Debug, Deserialize)]
4909struct ClaimQuery {
4910    /// The opaque claim token from the bot's public follow-back skeet.
4911    t: Option<String>,
4912}
4913
4914/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4915///
4916/// The follow→invite bot posts a public skeet mentioning a new follower with a
4917/// link here. The token wraps a pre-minted invite code (never the raw code — see
4918/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4919/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4920/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4921/// callback atomically consumes the code (`store::redeem_code`) — the same
4922/// machinery as a pasted code. On any failure it bounces to the invite page with
4923/// the matching message.
4924///
4925/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4926/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4927/// here rejects an already-used / expired / capacity-full code before reserving,
4928/// so a replayed link past the first successful claim is refused. The residual
4929/// window is the same as any pasted invite code: whoever completes OAuth *first*
4930/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4931/// blunts brute-force enumeration.
4932async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4933    let token = match q.t {
4934        Some(t) if !t.is_empty() => t,
4935        _ => {
4936            warn!("claim link with no token");
4937            return redeem_bounce(&state, &store::RedeemError::NotFound);
4938        }
4939    };
4940
4941    // Unwrap the token → the invite code it reserves. A tampered/forged token
4942    // yields nothing → treat as an invalid code (don't leak whether it parsed).
4943    let code = match claim_token_code(&token, &state.config.cookie_secret) {
4944        Some(c) => c,
4945        None => {
4946            warn!("claim token invalid (bad signature / malformed)");
4947            return redeem_bounce(&state, &store::RedeemError::NotFound);
4948        }
4949    };
4950
4951    // Re-run the same preflight as the pasted-code path: exists, active,
4952    // unexpired, seat free. This is what makes a replayed link past first-claim
4953    // (or past cap) fail cleanly.
4954    match preflight_code(&state, &code).await {
4955        Ok(()) => {
4956            let cookie = sign_invite(&code, &state.config.cookie_secret);
4957            let mut resp = Redirect::to("/login").into_response();
4958            set_cookie(&mut resp, &cookie);
4959            info!("claim token preflight OK; reserving intent + redirecting to /login");
4960            resp
4961        }
4962        Err(policy) => {
4963            warn!(?policy, "claim token preflight rejected");
4964            redeem_bounce(&state, &policy)
4965        }
4966    }
4967}
4968
4969/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
4970///
4971/// Passing the follower DID makes the APP the authoritative deduper: the app can
4972/// short-circuit a DID that already holds a seat, and return the SAME code for a
4973/// DID that already has an outstanding claim — so a bot-host state loss cannot
4974/// re-mint or re-post per follower. Handle is advisory (logs only).
4975#[derive(Debug, Default, Deserialize)]
4976struct BotClaimRequest {
4977    /// The follower's DID (the idempotency key). Optional for backward-compat: an
4978    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
4979    #[serde(default)]
4980    did: Option<String>,
4981    /// The follower's handle (advisory; recorded for operator logs only).
4982    #[serde(default)]
4983    #[allow(dead_code)]
4984    handle: Option<String>,
4985}
4986
4987/// The JSON body `POST /bot/claims` returns on success.
4988#[derive(Debug, serde::Serialize)]
4989struct BotClaimResponse {
4990    /// Server-side dedupe outcome, so the bot knows whether to post:
4991    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
4992    /// already had an outstanding claim; the SAME code/token/url is returned, so an
4993    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
4994    /// beta access; code/token/url are empty and the bot should post NOTHING).
4995    status: &'static str,
4996    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
4997    /// store. NEVER post this publicly; post the `url` instead. Empty when
4998    /// `already_seated`.
4999    code: String,
5000    /// The opaque claim token (the code wrapped + signed). Empty when
5001    /// `already_seated`.
5002    token: String,
5003    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
5004    /// Empty when `already_seated`.
5005    url: String,
5006}
5007
5008/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
5009///
5010/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
5011/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
5012/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
5013/// (503), so a bare/dev instance never exposes an unauthenticated mint.
5014///
5015/// Server-side DID idempotency (the authoritative dedupe backstop): the request
5016/// body carries the follower `did`. The app — not the bot's local SQLite — is the
5017/// source of truth, so a bot-host state loss cannot re-mint or re-post per
5018/// follower:
5019///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
5020///     code/url; the bot marks it handled and posts NOTHING);
5021///   * DID already has an outstanding active claim → `200 {status:"existing"}`
5022///     returning the SAME code/token/url (idempotent — never a second mint);
5023///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
5024///
5025/// Cap accounting: the bot must not promise more claims than seats remain, so
5026/// this refuses with `409 Conflict {"error":"full"}` when
5027/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
5028/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
5029/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
5030/// minting past the cap.
5031///
5032/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
5033/// default 14d — the admin browser flow's 30-min TTL would expire before the
5034/// follower taps an async-delivered link).
5035async fn bot_mint_claim(
5036    State(state): State<AppState>,
5037    headers: HeaderMap,
5038    body: axum::body::Bytes,
5039) -> Response {
5040    // 1. The endpoint is OFF unless a bot secret is configured.
5041    let bot_secret = match state.config.bot_secret.as_deref() {
5042        Some(s) => s,
5043        None => {
5044            warn!(
5045                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5046            );
5047            return (
5048                StatusCode::SERVICE_UNAVAILABLE,
5049                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5050            )
5051                .into_response();
5052        }
5053    };
5054
5055    // 2. Constant-time bearer check on the X-Bot-Secret header.
5056    let presented = headers
5057        .get("x-bot-secret")
5058        .and_then(|v| v.to_str().ok())
5059        .unwrap_or("");
5060    if !bot_secret_matches(presented, bot_secret) {
5061        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5062        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5063    }
5064
5065    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
5066    // (legacy caller) parses to an all-None request; a malformed body is a 400.
5067    let req: BotClaimRequest = if body.is_empty() {
5068        BotClaimRequest::default()
5069    } else {
5070        match serde_json::from_slice(&body) {
5071            Ok(r) => r,
5072            Err(err) => {
5073                warn!(%err, "POST /bot/claims: bad JSON body");
5074                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5075            }
5076        }
5077    };
5078    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5079
5080    // 3. Server-side DID idempotency (only when a DID was supplied):
5081    if let Some(did) = follower_did {
5082        // 3a. Already seated → tell the bot to post nothing.
5083        match store::has_beta_access(&state.db, did).await {
5084            Ok(true) => {
5085                info!("bot mint: DID already holds beta access; already_seated");
5086                return bot_claim_json(BotClaimResponse {
5087                    status: "already_seated",
5088                    code: String::new(),
5089                    token: String::new(),
5090                    url: String::new(),
5091                });
5092            }
5093            Ok(false) => {}
5094            Err(err) => {
5095                // Fail closed: a DB error must not fall through to a fresh mint.
5096                warn!(%err, "bot mint: has_beta_access failed");
5097                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5098            }
5099        }
5100        // 3b. Outstanding active claim for this DID → return the SAME code (no
5101        // second mint). This is what survives a bot-host state loss.
5102        match store::find_active_code_for_did(&state.db, did).await {
5103            Ok(Some(code)) => {
5104                info!("bot mint: existing outstanding claim for DID; returning same code");
5105                let token = sign_claim_token(&code, &state.config.cookie_secret);
5106                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5107                return bot_claim_json(BotClaimResponse {
5108                    status: "existing",
5109                    code,
5110                    token,
5111                    url,
5112                });
5113            }
5114            Ok(None) => {}
5115            Err(err) => {
5116                warn!(%err, "bot mint: find_active_code_for_did failed");
5117                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5118            }
5119        }
5120    }
5121
5122    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
5123    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
5124    let granted = match store::count_beta_access(&state.db).await {
5125        Ok(n) => n,
5126        Err(err) => {
5127            warn!(%err, "bot mint: count_beta_access failed; failing closed");
5128            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5129        }
5130    };
5131    let outstanding = match store::count_active_codes(&state.db).await {
5132        Ok(n) => n,
5133        Err(err) => {
5134            warn!(%err, "bot mint: count_active_codes failed; failing closed");
5135            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5136        }
5137    };
5138    if granted + outstanding >= state.config.beta_cap {
5139        info!(
5140            granted,
5141            outstanding,
5142            cap = state.config.beta_cap,
5143            "bot mint refused: at capacity"
5144        );
5145        return (
5146            StatusCode::CONFLICT,
5147            [(header::CONTENT_TYPE, "application/json")],
5148            "{\"error\":\"full\"}\n",
5149        )
5150            .into_response();
5151    }
5152
5153    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
5154    //    so a re-request for the same DID returns THIS code idempotently.
5155    let bot_did = state
5156        .config
5157        .admin_seed_dids()
5158        .first()
5159        .cloned()
5160        .unwrap_or_else(|| "did:bot:featherreader".to_string());
5161    let minted = match follower_did {
5162        Some(did) => {
5163            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5164        }
5165        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5166    };
5167    let code = match minted {
5168        Ok(c) => c,
5169        // S4: the dedupe check (3b) and this mint are separate statements, so two
5170        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
5171        // The partial unique index `idx_invite_codes_intended_active` makes the
5172        // loser's INSERT fail (only one active row per intended DID), which
5173        // surfaces here as a conflict. Recover by returning the winner's existing
5174        // code (same shape as the 3b idempotent path) instead of a 500.
5175        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5176            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5177                Ok(Some(code)) => {
5178                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
5179                    let token = sign_claim_token(&code, &state.config.cookie_secret);
5180                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5181                    return bot_claim_json(BotClaimResponse {
5182                        status: "existing",
5183                        code,
5184                        token,
5185                        url,
5186                    });
5187                }
5188                // The winner's row vanished between the conflict and this lookup
5189                // (redeemed/expired/purged in the gap) — nothing to hand back.
5190                // Fail closed rather than silently mint past the just-hit guard.
5191                Ok(None) => {
5192                    warn!("bot mint: conflict but no active code found on recovery");
5193                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5194                }
5195                Err(err) => {
5196                    warn!(%err, "bot mint: recovery lookup after conflict failed");
5197                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5198                }
5199            }
5200        }
5201        Err(err) => {
5202            warn!(%err, "bot mint_code failed");
5203            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5204        }
5205    };
5206    let token = sign_claim_token(&code, &state.config.cookie_secret);
5207    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5208    info!("bot minted a claim code + token");
5209
5210    bot_claim_json(BotClaimResponse {
5211        status: "minted",
5212        code,
5213        token,
5214        url,
5215    })
5216}
5217
5218/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
5219/// `500` if serialization somehow fails).
5220fn bot_claim_json(resp: BotClaimResponse) -> Response {
5221    match serde_json::to_string(&resp) {
5222        Ok(body) => (
5223            StatusCode::OK,
5224            [(header::CONTENT_TYPE, "application/json")],
5225            body,
5226        )
5227            .into_response(),
5228        Err(err) => {
5229            warn!(%err, "serializing bot claim response failed");
5230            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5231        }
5232    }
5233}
5234
5235/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
5236/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
5237/// by the HMAC checks so there is one comparator to audit; a length mismatch
5238/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
5239fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5240    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5241}
5242
5243// ---------------------------------------------------------------------------
5244// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
5245// ---------------------------------------------------------------------------
5246
5247/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
5248/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
5249/// itself (base64url) rather than an opaque sid, since the code IS the reserved
5250/// intent the callback consumes.
5251fn sign_invite(code: &str, secret: &str) -> String {
5252    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5253}
5254
5255/// Verify + read the reserved invite code out of the request's invite cookie
5256/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
5257/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
5258/// authority on the code's live status.
5259fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5260    cookie::verify_value(headers, INVITE_COOKIE, secret)
5261}
5262
5263/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
5264/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
5265/// cookie value and vice-versa.
5266const CLAIM_TOKEN_LABEL: &str = "claim-token";
5267
5268/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
5269/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
5270///
5271/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
5272/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
5273/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
5274/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
5275/// code won't verify), the wrapped code is single-use (redeem flips
5276/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
5277/// token one self-contained string needing no server-side token table; it does
5278/// NOT hide the code.
5279fn sign_claim_token(code: &str, secret: &str) -> String {
5280    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5281}
5282
5283/// Verify a claim token and return the invite code it wraps (`None` on a tampered
5284/// / forged / malformed token). The code's live status (active/unexpired/seat
5285/// free) is re-checked by `preflight_code`; this only proves the token was minted
5286/// by this instance.
5287fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5288    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5289}
5290
5291/// Clear the invite cookie on a response (after a successful bind, or when the
5292/// reservation turned out to be stale).
5293fn clear_invite_cookie(resp: &mut Response) {
5294    set_cookie(
5295        resp,
5296        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5297    );
5298}
5299
5300// ---------------------------------------------------------------------------
5301// OPML import + export
5302// ---------------------------------------------------------------------------
5303
5304/// `POST /opml` — import subscriptions from an OPML document.
5305///
5306/// Accepts either a multipart file upload (field `file`) or a pasted textarea
5307/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
5308/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
5309/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
5310/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
5311/// they show immediately; polling is left to the background poller.
5312async fn import_opml(
5313    State(state): State<AppState>,
5314    headers: HeaderMap,
5315    mut multipart: Multipart,
5316) -> Result<Response, WebError> {
5317    let did = match current_did(&state, &headers).await {
5318        Some(d) => d,
5319        None => return Ok(Redirect::to("/login").into_response()),
5320    };
5321    let pool = &state.db;
5322
5323    // Collect the OPML text from whichever field carried it. Multipart errors
5324    // are mapped to their axum-native response so that an over-cap upload (the
5325    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
5326    // `413 Payload Too Large` rather than being swallowed by the blanket
5327    // `WebError` → `500` conversion.
5328    let mut opml_text = String::new();
5329    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5330        let name = field.name().unwrap_or("").to_string();
5331        if name == "opml" || name == "file" {
5332            let bytes = field.bytes().await.map_err(multipart_response)?;
5333            if !bytes.is_empty() {
5334                opml_text = String::from_utf8_lossy(&bytes).into_owned();
5335                if name == "file" {
5336                    break;
5337                }
5338            }
5339        }
5340    }
5341
5342    // A parse FAILURE and an empty-but-valid file are different things, and
5343    // `unwrap_or_default` collapsed them: a malformed export was reported to the
5344    // reader as "No feeds found in that OPML", which sends them looking at their
5345    // old reader for feeds that are right there in the file.
5346    let feeds =
5347        match opml::parse_opml(&opml_text) {
5348            Ok(feeds) => feeds,
5349            Err(err) => {
5350                warn!(%err, %did, "OPML import could not parse the uploaded file");
5351                return Ok(Redirect::to(&format!(
5352                "/?flash={}",
5353                qenc("That file could not be read as OPML. Export it again from your other reader?")
5354            ))
5355                .into_response());
5356            }
5357        };
5358    if feeds.is_empty() {
5359        info!(%did, "OPML import found no feeds");
5360        return Ok(
5361            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5362                .into_response(),
5363        );
5364    }
5365
5366    // Create any named folders first, mapping folder name → at:// URI so
5367    // subscriptions can reference them.
5368    let now = now_rfc3339();
5369    let mut folder_uris: std::collections::HashMap<String, String> =
5370        std::collections::HashMap::new();
5371    // Reuse existing folders where the name already exists.
5372    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5373        for (rkey, folder) in existing {
5374            folder_uris
5375                .entry(folder.name.clone())
5376                .or_insert_with(|| folder_uri(&did, &rkey));
5377        }
5378    }
5379    let mut wanted_folders: Vec<String> = feeds
5380        .iter()
5381        .filter_map(|f| f.folder.clone())
5382        .filter(|n| !n.is_empty())
5383        .collect();
5384    wanted_folders.sort();
5385    wanted_folders.dedup();
5386    for name in wanted_folders {
5387        if folder_uris.contains_key(&name) {
5388            continue;
5389        }
5390        let folder = Folder::new(name.clone(), now.clone());
5391        match state.repo().add_folder(&did, &folder).await {
5392            Ok(rkey) => {
5393                folder_uris.insert(name, folder_uri(&did, &rkey));
5394            }
5395            Err(err) => warn!(%err, %did, "OPML folder create failed"),
5396        }
5397    }
5398
5399    // Build one subscription record per PUBLIC feed + upsert the local cache row.
5400    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5401    // and reported back to the user — the same public-feeds-only stance as the
5402    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5403    // token onto the public network either.
5404    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5405    // the remaining headroom (cap − existing) once; public feeds beyond it are
5406    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5407    let sub_cap = state.config.max_subs_per_did;
5408    let mut headroom: Option<i64> = if sub_cap > 0 {
5409        let existing = store::count_subscriptions_for_did(pool, &did)
5410            .await
5411            .unwrap_or(0);
5412        Some((sub_cap - existing).max(0))
5413    } else {
5414        None
5415    };
5416    let mut trimmed_over_cap: usize = 0;
5417
5418    // Global feeds ceiling: an OPML import must not blow past the shared cache
5419    // ceiling any more than the single-add path may. Seed the remaining global
5420    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5421    // not already cached) consumes it. Existing/duplicate URLs add no row and
5422    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5423    // `<= 0` disables the ceiling.
5424    let feeds_cap = state.config.max_feeds_global;
5425    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5426        let existing = store::count_feeds(pool).await.unwrap_or(0);
5427        Some((feeds_cap - existing).max(0))
5428    } else {
5429        None
5430    };
5431    let mut trimmed_over_global: usize = 0;
5432
5433    let mut subs = Vec::with_capacity(feeds.len());
5434    let mut skipped_private: Vec<String> = Vec::new();
5435    // Imported into the PDS but not cached locally, so not pollable until the
5436    // next import touches them. Counted rather than only logged — see below.
5437    let mut uncached: usize = 0;
5438    // Entries this instance cannot store at all (an `at://` publication with
5439    // the flag off, an unsupported scheme). Counted, because the `continue`
5440    // below used to increment nothing while the privacy branch beside it
5441    // produced a label — so an OPML from a standard.site-enabled instance
5442    // imported "successfully" with entries missing and no reason given.
5443    let mut skipped_unsupported: usize = 0;
5444    for f in &feeds {
5445        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5446        // ever parsed it — the single-add path can't reach here because
5447        // `resolve_feed_url` must parse AND successfully fetch first. So
5448        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5449        // cached, and published as records to the user's PUBLIC repo. Note that
5450        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5451        // it returns `Public` for anything unparseable by design.
5452        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5453            info!(
5454                %did,
5455                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5456            );
5457            skipped_unsupported += 1;
5458            continue;
5459        }
5460        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5461            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5462            // Report by title where we have one, else the (public-safe) host.
5463            let label = f
5464                .title
5465                .clone()
5466                .filter(|t| !t.trim().is_empty())
5467                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5468            skipped_private.push(label);
5469            continue;
5470        }
5471
5472        // Over-cap: stop importing once headroom is exhausted (count the rest so
5473        // we can tell the user how many were dropped).
5474        if let Some(h) = headroom.as_mut() {
5475            if *h <= 0 {
5476                trimmed_over_cap += 1;
5477                continue;
5478            }
5479        }
5480
5481        // Global ceiling: a brand-new feed URL consumes global headroom. Once
5482        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5483        // free — they add no row). Checked before decrementing the per-DID
5484        // headroom so a dropped feed doesn't burn the caller's own quota.
5485        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5486            Ok(existing) => existing.is_none(),
5487            // On a lookup error, treat as existing (don't consume global
5488            // headroom) but still allow the upsert to proceed.
5489            Err(err) => {
5490                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5491                false
5492            }
5493        };
5494        if is_new {
5495            if let Some(g) = global_headroom.as_mut() {
5496                if *g <= 0 {
5497                    trimmed_over_global += 1;
5498                    continue;
5499                }
5500                *g -= 1;
5501            }
5502        }
5503
5504        // Passed both caps: consume the per-DID headroom now that the feed is
5505        // actually being imported.
5506        if let Some(h) = headroom.as_mut() {
5507            *h -= 1;
5508        }
5509
5510        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5511        sub.title = f.title.clone();
5512        sub.site_url = f.site_url.clone();
5513        sub.folder = f
5514            .folder
5515            .as_ref()
5516            .and_then(|name| folder_uris.get(name).cloned());
5517        subs.push(sub);
5518        // Same support ticket as the single-add path: no `feeds` row means the
5519        // poller never selects this subscription, so the import looks like it
5520        // worked and the feed silently never updates. Counted as well as logged,
5521        // because one line per feed in a 200-feed import is not something anyone
5522        // reads — the count goes to the reader.
5523        if let Err(err) = store::upsert_feed(
5524            pool,
5525            &store::NewFeed {
5526                url: f.feed_url.clone(),
5527                title: f.title.clone(),
5528                site_url: f.site_url.clone(),
5529                ..Default::default()
5530            },
5531        )
5532        .await
5533        {
5534            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5535                                                  it will not be polled");
5536            uncached += 1;
5537        }
5538    }
5539
5540    // **A failed PDS write is not an import.**
5541    //
5542    // The subscriptions live in the reader's repo; a local `feeds` row is just a
5543    // poller hint. This used to `warn!` and then report "Imported N feeds"
5544    // regardless, so a total failure read as a total success — and the reader
5545    // would only discover otherwise on their next visit, with an empty sidebar.
5546    let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5547        Ok(rkeys) => {
5548            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5549            true
5550        }
5551        Err(err) => {
5552            warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5553            false
5554        }
5555    };
5556    if !pds_written {
5557        return Ok(Redirect::to(&format!(
5558            "/?flash={}",
5559            qenc(
5560                "Could not save those subscriptions to your PDS, so nothing was imported. \
5561                 Try again in a moment."
5562            )
5563        ))
5564        .into_response());
5565    }
5566
5567    // Report the import count, plus any private/paid feeds skipped as unsupported.
5568    let mut flash = format!("Imported {} feeds", subs.len());
5569    if uncached > 0 {
5570        flash.push_str(&format!(
5571            ". {uncached} of them could not be cached locally and may not update until the next import."
5572        ));
5573    }
5574    if trimmed_over_cap > 0 {
5575        flash.push_str(&format!(
5576            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5577        ));
5578    }
5579    if trimmed_over_global > 0 {
5580        flash.push_str(&format!(
5581            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5582        ));
5583    }
5584    if !skipped_private.is_empty() {
5585        flash.push_str(&format!(
5586            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5587            skipped_private.len(),
5588            skipped_private.join(", ")
5589        ));
5590    }
5591    if skipped_unsupported > 0 {
5592        // By count only — the URL is whatever the file said, and unlike the
5593        // private branch there is no public-safe label to give.
5594        flash.push_str(&format!(
5595            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5596        ));
5597    }
5598    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5599}
5600
5601/// A public-safe label for a skipped private feed when it has no title: just the
5602/// host, so we never echo the secret-bearing path/query back to the user.
5603fn private_feed_label(url: &str) -> String {
5604    url::Url::parse(url)
5605        .ok()
5606        .and_then(|u| u.host_str().map(str::to_string))
5607        .unwrap_or_else(|| "a private feed".to_string())
5608}
5609
5610/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5611async fn export_opml(
5612    State(state): State<AppState>,
5613    headers: HeaderMap,
5614) -> Result<Response, WebError> {
5615    let did = match current_did(&state, &headers).await {
5616        Some(d) => d,
5617        None => return Ok(Redirect::to("/login").into_response()),
5618    };
5619
5620    // **An export must never be silently empty.** `unwrap_or_default` here turned
5621    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
5622    // backup, blank, at exactly the moment they reached for it. That was survivable
5623    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
5624    // this is the one caller that converts a refusal into data loss, and it is also
5625    // the recovery route the changelog points a locked-out reader at.
5626    let subs = match state.repo().list_subscriptions_sorted(&did).await {
5627        Ok(subs) => subs,
5628        Err(err) => {
5629            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5630            return Ok(Redirect::to(&format!(
5631                "/manage?flash={}",
5632                qenc(EXPORT_INCOMPLETE_REFUSAL)
5633            ))
5634            .into_response());
5635        }
5636    };
5637    let folders = match state.repo().list_folders_sorted(&did).await {
5638        Ok(folders) => folders,
5639        Err(err) => {
5640            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5641            return Ok(Redirect::to(&format!(
5642                "/manage?flash={}",
5643                qenc(EXPORT_INCOMPLETE_REFUSAL)
5644            ))
5645            .into_response());
5646        }
5647    };
5648    // The exporter matches a subscription's `folder` at-uri against the folder's
5649    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5650    let folder_pairs: Vec<(String, Folder)> = folders
5651        .into_iter()
5652        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5653        .collect();
5654
5655    let body = opml::to_opml(&subs, &folder_pairs);
5656    let mut resp = (StatusCode::OK, body).into_response();
5657    resp.headers_mut().insert(
5658        header::CONTENT_TYPE,
5659        "text/x-opml; charset=utf-8".parse().unwrap(),
5660    );
5661    resp.headers_mut().insert(
5662        header::CONTENT_DISPOSITION,
5663        "attachment; filename=\"featherreader-subscriptions.opml\""
5664            .parse()
5665            .unwrap(),
5666    );
5667    Ok(resp)
5668}
5669
5670// ---------------------------------------------------------------------------
5671// Signed session cookie (HMAC-SHA256, dependency-free)
5672// ---------------------------------------------------------------------------
5673
5674/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5675fn set_cookie(resp: &mut Response, cookie: &str) {
5676    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5677        resp.headers_mut()
5678            .append(axum::http::header::SET_COOKIE, value);
5679    }
5680}
5681
5682/// Whether the request came from htmx (the `HX-Request` header).
5683fn is_htmx(headers: &HeaderMap) -> bool {
5684    headers
5685        .get("HX-Request")
5686        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5687}
5688
5689/// Whether a mark-read / star request originated from the single-entry READER
5690/// (as opposed to the list view). The reader's forms tag themselves with
5691/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5692/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5693/// isn't in the DOM), the list gets the row (`entry_row.html`).
5694fn is_reader_request(headers: &HeaderMap) -> bool {
5695    headers
5696        .get("X-FR-Reader")
5697        .is_some_and(|v| v.as_bytes() == b"1")
5698}
5699
5700/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5701/// server-minted **session id** (never the DID — so the cookie can't be forged
5702/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5703/// server-side session id).
5704mod cookie {
5705    use super::{HeaderMap, SESSION_COOKIE};
5706
5707    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5708    pub fn sign_session(sid: &str, secret: &str) -> String {
5709        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5710    }
5711
5712    /// Verify the request's session cookie and return the session id it carries.
5713    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5714        verify_value(headers, SESSION_COOKIE, secret)
5715    }
5716
5717    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5718    /// value`), so a signature minted for one cookie can't verify under another —
5719    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5720    /// The NUL separator can't appear in a cookie name, so the encoding is
5721    /// unambiguous.
5722    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5723        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5724        msg.extend_from_slice(name.as_bytes());
5725        msg.push(0);
5726        msg.extend_from_slice(value.as_bytes());
5727        msg
5728    }
5729
5730    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5731    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5732    /// generic form behind both the session cookie and the short-lived invite
5733    /// cookie; domain-separating by name keeps a signature valid only for the
5734    /// cookie it was minted for.
5735    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5736        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5737        let b64 = b64url_encode(value.as_bytes());
5738        format!(
5739            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5740        )
5741    }
5742
5743    /// Verify + read a value out of the named signed cookie (`None` on absent /
5744    /// tampered / forged / cross-cookie). The generic form behind both readers.
5745    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5746        let raw = cookie_value(headers, name)?;
5747        let (b64, sig) = raw.split_once('.')?;
5748        let bytes = b64url_decode(b64)?;
5749        let value = String::from_utf8(bytes).ok()?;
5750        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5751        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5752            Some(value)
5753        } else {
5754            None
5755        }
5756    }
5757
5758    /// Sign an arbitrary `value` into an opaque, URL-safe token string
5759    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5760    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5761    /// URL query param (the bot's claim link). `label` domain-separates it from
5762    /// the cookies so a token can't be replayed as a cookie value.
5763    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5764        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5765        let b64 = b64url_encode(value.as_bytes());
5766        format!("{b64}.{sig}")
5767    }
5768
5769    /// Verify a token minted by [`sign_token`] and return the wrapped value
5770    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5771    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5772        let (b64, sig) = token.split_once('.')?;
5773        let bytes = b64url_decode(b64)?;
5774        let value = String::from_utf8(bytes).ok()?;
5775        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5776        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5777            Some(value)
5778        } else {
5779            None
5780        }
5781    }
5782
5783    /// Pull one cookie value out of the `Cookie` request header.
5784    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5785        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5786        for part in header.split(';') {
5787            let part = part.trim();
5788            if let Some((k, v)) = part.split_once('=') {
5789                if k == name {
5790                    return Some(v.to_string());
5791                }
5792            }
5793        }
5794        None
5795    }
5796
5797    /// Constant-time byte comparison (avoid signature-timing leaks). Public
5798    /// within the module so the bot-secret bearer check reuses the exact same
5799    /// comparator as the cookie/token HMAC checks (one implementation to audit).
5800    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5801        if a.len() != b.len() {
5802            return false;
5803        }
5804        let mut diff = 0u8;
5805        for (x, y) in a.iter().zip(b.iter()) {
5806            diff |= x ^ y;
5807        }
5808        diff == 0
5809    }
5810
5811    // -- URL-safe base64 (no padding), std-only --------------------------------
5812
5813    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5814
5815    fn b64url_encode(input: &[u8]) -> String {
5816        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5817        for chunk in input.chunks(3) {
5818            let b = [
5819                chunk[0],
5820                *chunk.get(1).unwrap_or(&0),
5821                *chunk.get(2).unwrap_or(&0),
5822            ];
5823            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5824            out.push(B64[((n >> 18) & 63) as usize] as char);
5825            out.push(B64[((n >> 12) & 63) as usize] as char);
5826            if chunk.len() > 1 {
5827                out.push(B64[((n >> 6) & 63) as usize] as char);
5828            }
5829            if chunk.len() > 2 {
5830                out.push(B64[(n & 63) as usize] as char);
5831            }
5832        }
5833        out
5834    }
5835
5836    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5837        fn val(c: u8) -> Option<u32> {
5838            match c {
5839                b'A'..=b'Z' => Some((c - b'A') as u32),
5840                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5841                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5842                b'-' => Some(62),
5843                b'_' => Some(63),
5844                _ => None,
5845            }
5846        }
5847        let bytes = input.as_bytes();
5848        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5849        for chunk in bytes.chunks(4) {
5850            let mut n = 0u32;
5851            let mut valid = 0;
5852            for (i, &c) in chunk.iter().enumerate() {
5853                n |= val(c)? << (18 - 6 * i);
5854                valid += 1;
5855            }
5856            out.push((n >> 16) as u8);
5857            if valid > 2 {
5858                out.push((n >> 8) as u8);
5859            }
5860            if valid > 3 {
5861                out.push(n as u8);
5862            }
5863        }
5864        Some(out)
5865    }
5866
5867    // -- HMAC-SHA256, std-only -------------------------------------------------
5868
5869    /// HMAC-SHA256(key, msg) as lowercase hex.
5870    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5871        const BLOCK: usize = 64;
5872        let mut k = [0u8; BLOCK];
5873        if key.len() > BLOCK {
5874            let d = sha256(key);
5875            k[..32].copy_from_slice(&d);
5876        } else {
5877            k[..key.len()].copy_from_slice(key);
5878        }
5879        let mut ipad = [0x36u8; BLOCK];
5880        let mut opad = [0x5cu8; BLOCK];
5881        for i in 0..BLOCK {
5882            ipad[i] ^= k[i];
5883            opad[i] ^= k[i];
5884        }
5885        let mut inner = Vec::with_capacity(BLOCK + msg.len());
5886        inner.extend_from_slice(&ipad);
5887        inner.extend_from_slice(msg);
5888        let inner_hash = sha256(&inner);
5889        let mut outer = Vec::with_capacity(BLOCK + 32);
5890        outer.extend_from_slice(&opad);
5891        outer.extend_from_slice(&inner_hash);
5892        let mac = sha256(&outer);
5893        let mut hex = String::with_capacity(64);
5894        for b in mac {
5895            hex.push_str(&format!("{b:02x}"));
5896        }
5897        hex
5898    }
5899
5900    /// SHA-256 (FIPS 180-4), std-only.
5901    fn sha256(data: &[u8]) -> [u8; 32] {
5902        const K: [u32; 64] = [
5903            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5904            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5905            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5906            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5907            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5908            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5909            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5910            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5911            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5912            0xc67178f2,
5913        ];
5914        let mut h: [u32; 8] = [
5915            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5916            0x5be0cd19,
5917        ];
5918
5919        let bit_len = (data.len() as u64) * 8;
5920        let mut msg = data.to_vec();
5921        msg.push(0x80);
5922        while msg.len() % 64 != 56 {
5923            msg.push(0);
5924        }
5925        msg.extend_from_slice(&bit_len.to_be_bytes());
5926
5927        for block in msg.chunks(64) {
5928            let mut w = [0u32; 64];
5929            for i in 0..16 {
5930                w[i] = u32::from_be_bytes([
5931                    block[i * 4],
5932                    block[i * 4 + 1],
5933                    block[i * 4 + 2],
5934                    block[i * 4 + 3],
5935                ]);
5936            }
5937            for i in 16..64 {
5938                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5939                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5940                w[i] = w[i - 16]
5941                    .wrapping_add(s0)
5942                    .wrapping_add(w[i - 7])
5943                    .wrapping_add(s1);
5944            }
5945            let mut a = h;
5946            for i in 0..64 {
5947                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5948                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5949                let t1 = a[7]
5950                    .wrapping_add(s1)
5951                    .wrapping_add(ch)
5952                    .wrapping_add(K[i])
5953                    .wrapping_add(w[i]);
5954                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5955                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5956                let t2 = s0.wrapping_add(maj);
5957                a[7] = a[6];
5958                a[6] = a[5];
5959                a[5] = a[4];
5960                a[4] = a[3].wrapping_add(t1);
5961                a[3] = a[2];
5962                a[2] = a[1];
5963                a[1] = a[0];
5964                a[0] = t1.wrapping_add(t2);
5965            }
5966            for i in 0..8 {
5967                h[i] = h[i].wrapping_add(a[i]);
5968            }
5969        }
5970
5971        let mut out = [0u8; 32];
5972        for (i, word) in h.iter().enumerate() {
5973            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5974        }
5975        out
5976    }
5977
5978    #[cfg(test)]
5979    mod tests {
5980        use super::*;
5981
5982        #[test]
5983        fn sha256_known_vector() {
5984            let d = sha256(b"abc");
5985            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5986            assert_eq!(
5987                hex,
5988                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5989            );
5990        }
5991
5992        #[test]
5993        fn hmac_known_vector() {
5994            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5995            assert_eq!(
5996                mac,
5997                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5998            );
5999        }
6000
6001        #[test]
6002        fn sign_verify_round_trips() {
6003            let secret = "test-secret";
6004            let sid = "9f2c-opaque-session-id";
6005            let cookie = sign_session(sid, secret);
6006            let pair = cookie.split(';').next().unwrap().to_string();
6007            let mut headers = HeaderMap::new();
6008            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6009            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6010            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
6011            assert!(verify_session(&headers, "other-secret").is_none());
6012        }
6013
6014        #[test]
6015        fn forged_and_tampered_cookies_are_rejected() {
6016            let secret = "test-secret";
6017
6018            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
6019            //    the secret, so an arbitrary signature must not verify.
6020            let forged = format!(
6021                "{SESSION_COOKIE}={}.{}",
6022                b64url_encode(b"attacker-chosen-sid"),
6023                "deadbeef".repeat(8) // 64 hex chars, wrong sig
6024            );
6025            let mut headers = HeaderMap::new();
6026            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6027            assert!(verify_session(&headers, secret).is_none());
6028
6029            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
6030            //    keeping the original signature — must not verify.
6031            let cookie = sign_session("real-sid", secret);
6032            let pair = cookie.split(';').next().unwrap();
6033            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6034            let tampered = format!(
6035                "{SESSION_COOKIE}={}.{}",
6036                b64url_encode(b"different-sid"),
6037                sig
6038            );
6039            let mut headers2 = HeaderMap::new();
6040            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6041            assert!(verify_session(&headers2, secret).is_none());
6042        }
6043
6044        #[test]
6045        fn b64url_round_trips() {
6046            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6047                let enc = b64url_encode(s.as_bytes());
6048                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6049            }
6050        }
6051    }
6052}
6053
6054// ---------------------------------------------------------------------------
6055// Small store helpers local to the web layer
6056// ---------------------------------------------------------------------------
6057
6058/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
6059///
6060/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
6061/// `did` does not subscribe to its feed. This is the per-DID read gate for the
6062/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
6063/// deduped by URL, but no DID can read another DID's cached article.
6064///
6065/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
6066/// that renders `content_html`, and it fetches exactly one row. The list views
6067/// go through [`store::list_entries`], which is both paged and body-free — see
6068/// [`store::EntryListRow`] for why they had to stop sharing this projection.
6069async fn get_entry_by_id(
6070    pool: &store::Pool,
6071    did: &str,
6072    id: i64,
6073) -> anyhow::Result<Option<store::Entry>> {
6074    let entry = sqlx::query_as::<_, store::Entry>(
6075        r#"
6076        SELECT e.* FROM entries e
6077        WHERE e.id = ?2
6078          AND EXISTS (
6079              SELECT 1 FROM sub_ref sr
6080              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6081          )
6082        "#,
6083    )
6084    .bind(did)
6085    .bind(id)
6086    .fetch_optional(pool)
6087    .await?;
6088    Ok(entry)
6089}
6090
6091/// Whether `entry_id` is marked read for `did` (absent state row = unread).
6092async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6093    let read: Option<bool> =
6094        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6095            .bind(did)
6096            .bind(entry_id)
6097            .fetch_optional(pool)
6098            .await?
6099            .flatten();
6100    Ok(read.unwrap_or(false))
6101}
6102
6103/// Whether `entry_id` is starred for `did` (absent state row = not starred).
6104async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6105    let starred: Option<bool> =
6106        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6107            .bind(did)
6108            .bind(entry_id)
6109            .fetch_optional(pool)
6110            .await?
6111            .flatten();
6112    Ok(starred.unwrap_or(false))
6113}
6114
6115/// Feed display title for one entry's feed id (via a single lookup).
6116async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6117    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6118        .bind(feed_id)
6119        .fetch_optional(pool)
6120        .await
6121    {
6122        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6123        _ => String::new(),
6124    }
6125}
6126
6127/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
6128/// be forced (mark-read path) or looked up (`None` — star path).
6129async fn build_entry_row(
6130    pool: &store::Pool,
6131    did: &str,
6132    id: i64,
6133    read: Option<bool>,
6134) -> anyhow::Result<Option<EntryRow>> {
6135    let entry = match get_entry_by_id(pool, did, id).await? {
6136        Some(e) => e,
6137        None => return Ok(None),
6138    };
6139    let read = match read {
6140        Some(r) => r,
6141        None => entry_is_read(pool, did, id).await?,
6142    };
6143    let starred = entry_is_starred(pool, did, id).await?;
6144    Ok(Some(EntryRow {
6145        id: entry.id,
6146        title: entry
6147            .title
6148            .clone()
6149            .filter(|t| !t.trim().is_empty())
6150            .unwrap_or_else(|| "(untitled)".to_string()),
6151        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6152        published: display_date(entry.published.as_deref()),
6153        read,
6154        starred,
6155        link: SafeLink::entry(id, ""),
6156        cached: true,
6157        rkey: String::new(),
6158    }))
6159}
6160
6161/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
6162fn now_rfc3339() -> String {
6163    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6164}
6165
6166#[cfg(test)]
6167mod tests {
6168    use super::*;
6169
6170    #[test]
6171    fn qenc_encodes_reserved() {
6172        assert_eq!(qenc("a b"), "a%20b");
6173        assert_eq!(
6174            qenc("https://example.com/feed.xml"),
6175            "https%3A%2F%2Fexample.com%2Ffeed.xml"
6176        );
6177        assert_eq!(
6178            qenc("at://did:plc:x/c/r"),
6179            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6180        );
6181        // Unreserved chars pass through untouched.
6182        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6183    }
6184
6185    #[test]
6186    fn folder_uri_shape() {
6187        assert_eq!(
6188            folder_uri("did:plc:abc", "3kfolder"),
6189            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6190        );
6191    }
6192
6193    // -- public-feeds-only: private/paid feeds are refused --------------------
6194
6195    #[test]
6196    fn private_feeds_are_classified_private_across_providers() {
6197        // The add + OPML paths both gate on this classifier; assert it flags a
6198        // spread of paid providers (newsletters + private podcasts) and the
6199        // generic credential-in-URL shapes.
6200        for url in [
6201            "https://author.substack.com/feed/private/deadbeefcafe1234",
6202            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6203            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6204            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6205            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6206            "https://user:pass@example.com/feed",
6207        ] {
6208            assert!(
6209                feed::classify_feed_privacy(url).is_private(),
6210                "expected private: {url}"
6211            );
6212        }
6213    }
6214
6215    #[test]
6216    fn public_feeds_stay_public() {
6217        for url in [
6218            "https://author.substack.com/feed",
6219            "https://wordpress.example.com/feed/",
6220            "https://example.com/rss.xml",
6221            "https://example.org/atom.xml",
6222            // YouTube channel/playlist RSS is fully public — must not false-block.
6223            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6224            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6225        ] {
6226            assert!(
6227                !feed::classify_feed_privacy(url).is_private(),
6228                "expected public: {url}"
6229            );
6230        }
6231    }
6232
6233    #[test]
6234    fn private_feed_label_is_public_safe_host_only() {
6235        // The OPML skip report must never echo the secret path/query, only the host.
6236        let label =
6237            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6238        assert_eq!(label, "author.substack.com");
6239        assert!(!label.contains("deadbeefcafe1234token"));
6240        assert!(!label.contains("/private/"));
6241        // An unparseable URL degrades to a generic label.
6242        assert_eq!(private_feed_label("not a url"), "a private feed");
6243    }
6244
6245    #[test]
6246    fn refusal_message_promises_nothing_stored() {
6247        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6248        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6249    }
6250
6251    #[test]
6252    fn scope_query_preserves_context() {
6253        let q = EntryQuery {
6254            feed: Some("https://example.com/feed.xml".to_string()),
6255            folder: None,
6256            view: Some("all".to_string()),
6257        };
6258        let s = scope_query(&q);
6259        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6260        assert!(s.contains("view=all"));
6261
6262        // Default view is omitted.
6263        let q2 = EntryQuery {
6264            feed: None,
6265            folder: None,
6266            view: Some("unread".to_string()),
6267        };
6268        assert_eq!(scope_query(&q2), "");
6269    }
6270
6271    // -- closed-beta invite gate + rate-limit + cache-control ------------------
6272
6273    use axum::body::Body;
6274    use axum::http::Request;
6275    use tower::ServiceExt; // for `oneshot`
6276
6277    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
6278    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
6279    /// can forge matching cookies.
6280    async fn test_state(allowed: &[&str]) -> AppState {
6281        let db = store::init_url("sqlite::memory:").await.unwrap();
6282        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6283        store::ensure_seed(&db, &dids).await.unwrap();
6284        let config = Config {
6285            allowed_dids: dids,
6286            cookie_secret: "test-cookie-secret-000".to_string(),
6287            beta_cap: 3,
6288            ..Config::default()
6289        };
6290        AppState::new(config, db).unwrap()
6291    }
6292
6293    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
6294    /// looked up in the registry, so create the session first).
6295    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6296        let sid = state.sessions.create(Session {
6297            did: did.to_string(),
6298            handle: handle.map(str::to_string),
6299        });
6300        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6301        sc.split(';').next().unwrap().to_string()
6302    }
6303
6304    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
6305    /// long time to accept distinct source IPs on two unauthenticated guarded
6306    /// routes.
6307    #[test]
6308    fn the_rate_limit_map_is_bounded() {
6309        let rl = RateLimiter::shared();
6310        let now = Instant::now();
6311        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6312            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
6313            // ordering below is well-defined.
6314            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6315            rl.check_at(ip, now + Duration::from_millis(i as u64));
6316        }
6317        let len = rl.inner.lock().unwrap().buckets.len();
6318        assert!(
6319            len <= MAX_RATE_BUCKETS,
6320            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6321        );
6322    }
6323
6324    /// Eviction must not hand a throttled attacker a fresh burst.
6325    ///
6326    /// The bound is LRU, so the one bucket an attacker can never evict is their
6327    /// own — it is the most recently touched thing in the map. If this inverted,
6328    /// the size cap would become a rate-limit bypass: spray addresses until the
6329    /// map overflows, then resume.
6330    #[test]
6331    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6332        let rl = RateLimiter::shared();
6333        let base = Instant::now();
6334        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6335        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
6336        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
6337        // millisecond step made the whole flood take a second, and the refill —
6338        // working correctly — then looked exactly like an eviction bypass.
6339        let at = |n: u64| base + Duration::from_nanos(n);
6340
6341        // Spend the burst. `RATE_BURST` allowed, then refused.
6342        for i in 0..(RATE_BURST as u64) {
6343            assert!(rl.check_at(attacker, at(i)));
6344        }
6345        assert!(
6346            !rl.check_at(attacker, at(RATE_BURST as u64)),
6347            "burst was not exhausted; the rest of this test proves nothing"
6348        );
6349
6350        // Now overflow the map from other addresses, interleaving the attacker
6351        // so their bucket stays hot — the realistic shape of the attack.
6352        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6353            let t = at(100 + i as u64 * 2);
6354            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6355            rl.check_at(ip, t);
6356            assert!(
6357                !rl.check_at(attacker, t),
6358                "the attacker got a token back after evictions at i={i}"
6359            );
6360        }
6361    }
6362
6363    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
6364    /// of the whole map on every guarded request, on one shared core.
6365    #[test]
6366    fn the_idle_sweep_does_not_run_on_every_request() {
6367        let rl = RateLimiter::shared();
6368        let start = Instant::now();
6369        let a: IpAddr = "198.51.100.1".parse().unwrap();
6370        let b: IpAddr = "198.51.100.2".parse().unwrap();
6371
6372        rl.check_at(a, start);
6373        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
6374        // the sweep interval has elapsed too, so this request does sweep it.
6375        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6376        assert!(
6377            !rl.inner.lock().unwrap().buckets.contains_key(&a),
6378            "an idle bucket survived a sweep that was due"
6379        );
6380
6381        // A second request moments later must NOT re-sweep — `b` is still there,
6382        // and the recorded sweep time must not have moved.
6383        let before = rl.inner.lock().unwrap().last_sweep;
6384        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6385        assert_eq!(
6386            rl.inner.lock().unwrap().last_sweep,
6387            before,
6388            "the sweep ran again within the interval"
6389        );
6390    }
6391
6392    #[test]
6393    fn rate_limited_paths_match_expected() {
6394        use axum::http::Method;
6395        assert!(is_rate_limited_path("/login", &Method::GET));
6396        assert!(is_rate_limited_path("/login", &Method::POST));
6397        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6398        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6399        assert!(is_rate_limited_path("/opml", &Method::POST));
6400        assert!(is_rate_limited_path("/read-all", &Method::POST));
6401        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6402        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6403        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6404        // Read-only navigation is NOT limited.
6405        assert!(!is_rate_limited_path("/", &Method::GET));
6406        assert!(!is_rate_limited_path("/about", &Method::GET));
6407        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6408        assert!(!is_rate_limited_path("/login", &Method::HEAD));
6409    }
6410
6411    #[test]
6412    fn rate_limiter_allows_burst_then_429s() {
6413        let rl = RateLimiter::shared();
6414        let ip: IpAddr = "203.0.113.7".parse().unwrap();
6415        // The full burst passes.
6416        for _ in 0..(RATE_BURST as usize) {
6417            assert!(rl.check(ip));
6418        }
6419        // The next one (no time elapsed → no refill) is rejected.
6420        assert!(!rl.check(ip));
6421        // A different IP has its own bucket.
6422        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6423        assert!(rl.check(ip2));
6424    }
6425
6426    #[test]
6427    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6428        // With NO trusted header configured, a client-supplied X-Forwarded-For
6429        // must be ignored entirely — the limiter keys on the real socket peer,
6430        // so an attacker can't mint a fresh bucket per forged XFF value.
6431        let mut h = HeaderMap::new();
6432        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6433        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6434        assert_eq!(
6435            client_ip(&h, Some(&sock), None),
6436            Some("203.0.113.55".parse().unwrap()),
6437            "spoofed XFF must not override the socket peer"
6438        );
6439    }
6440
6441    #[test]
6442    fn client_ip_uses_trusted_header_last_hop() {
6443        // With a trusted proxy header configured, the client IP comes from THAT
6444        // header (the proxy overwrites any client copy). On a comma list we take
6445        // the RIGHT-most hop — the one the trusted proxy appended — so a
6446        // client-forged left-most value is ignored.
6447        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6448
6449        let mut h = HeaderMap::new();
6450        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6451        assert_eq!(
6452            client_ip(&h, Some(&sock), Some("fly-client-ip")),
6453            Some("198.51.100.9".parse().unwrap())
6454        );
6455
6456        // Attacker prepends a forged hop; the trusted proxy appends the real one.
6457        let mut h2 = HeaderMap::new();
6458        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6459        assert_eq!(
6460            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6461            Some("198.51.100.9".parse().unwrap()),
6462            "must take the right-most (trusted) hop, not the forged left-most"
6463        );
6464
6465        // Trusted header absent → fall back to the socket peer.
6466        let h3 = HeaderMap::new();
6467        assert_eq!(
6468            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6469            Some("10.0.0.1".parse().unwrap())
6470        );
6471    }
6472
6473    #[test]
6474    fn invite_cookie_round_trips_and_rejects_tamper() {
6475        let secret = "test-cookie-secret-000";
6476        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6477        let pair = sc.split(';').next().unwrap();
6478        let mut h = HeaderMap::new();
6479        h.insert(header::COOKIE, pair.parse().unwrap());
6480        assert_eq!(
6481            invite_cookie_code(&h, secret).as_deref(),
6482            Some("FEATHER-ABCDWXYZ")
6483        );
6484        // Wrong secret → rejected.
6485        assert!(invite_cookie_code(&h, "other").is_none());
6486    }
6487
6488    #[tokio::test]
6489    async fn preflight_valid_expired_and_full() {
6490        let state = test_state(&["did:plc:admin"]).await;
6491        // A minted, active code preflights OK.
6492        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6493            .await
6494            .unwrap();
6495        assert!(preflight_code(&state, &code).await.is_ok());
6496
6497        // A code whose expiry is in the past preflights as Expired. (mint_code
6498        // clamps negative ttl to 0, so back-date the row directly for a
6499        // deterministic past expiry.)
6500        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6501            .await
6502            .unwrap();
6503        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6504            .bind(chrono::Utc::now().timestamp() - 3600)
6505            .bind(&expired)
6506            .execute(&state.db)
6507            .await
6508            .unwrap();
6509        assert_eq!(
6510            preflight_code(&state, &expired).await,
6511            Err(store::RedeemError::Expired)
6512        );
6513
6514        // Unknown code → NotFound.
6515        assert_eq!(
6516            preflight_code(&state, "FEATHER-NOPENOPE").await,
6517            Err(store::RedeemError::NotFound)
6518        );
6519
6520        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6521        // must report CapacityFull.
6522        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6523            .await
6524            .unwrap();
6525        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6526            .await
6527            .unwrap();
6528        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6529        assert_eq!(
6530            preflight_code(&state, &code).await,
6531            Err(store::RedeemError::CapacityFull)
6532        );
6533    }
6534
6535    // -- Bot claim link + shared-secret mint ---------------------------------
6536
6537    /// A test state with a configured bot secret (so `/bot/claims` is live).
6538    async fn bot_state(bot_secret: &str) -> AppState {
6539        let db = store::init_url("sqlite::memory:").await.unwrap();
6540        store::ensure_seed(&db, &["did:plc:admin".to_string()])
6541            .await
6542            .unwrap();
6543        let config = Config {
6544            allowed_dids: vec!["did:plc:admin".to_string()],
6545            cookie_secret: "test-cookie-secret-000".to_string(),
6546            beta_cap: 3,
6547            bot_secret: Some(bot_secret.to_string()),
6548            public_url: "https://feather-reader.com".to_string(),
6549            ..Config::default()
6550        };
6551        AppState::new(config, db).unwrap()
6552    }
6553
6554    #[test]
6555    fn claim_token_round_trips_and_rejects_tamper() {
6556        let secret = "test-cookie-secret-000";
6557        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6558        // No cookie framing — a bare URL-safe token.
6559        assert!(!token.contains(';'));
6560        assert_eq!(
6561            claim_token_code(&token, secret).as_deref(),
6562            Some("FEATHER-ABCDWXYZ")
6563        );
6564        // Wrong secret → rejected.
6565        assert!(claim_token_code(&token, "other").is_none());
6566        // Tampered token → rejected.
6567        let mut bad = token.clone();
6568        bad.push('x');
6569        assert!(claim_token_code(&bad, secret).is_none());
6570        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6571        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6572        // recover it WITHOUT the secret). The security is single-use + HMAC
6573        // integrity + rate-limit, not secrecy of the code. Assert the code half is
6574        // publicly decodable (a plain base64url decode, no secret involved).
6575        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6576        assert_eq!(
6577            test_b64url_decode(b64).as_deref(),
6578            Some("FEATHER-ABCDWXYZ".as_bytes()),
6579            "the code half of the token is plain base64url, decodable by anyone"
6580        );
6581    }
6582
6583    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6584    /// claim token's code half needs NO secret to recover (it is not confidential).
6585    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6586        fn val(c: u8) -> Option<u32> {
6587            match c {
6588                b'A'..=b'Z' => Some((c - b'A') as u32),
6589                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6590                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6591                b'-' => Some(62),
6592                b'_' => Some(63),
6593                _ => None,
6594            }
6595        }
6596        let mut out = Vec::with_capacity(input.len() / 4 * 3);
6597        for chunk in input.as_bytes().chunks(4) {
6598            let mut n = 0u32;
6599            let mut bits = 0;
6600            for &c in chunk {
6601                n = (n << 6) | val(c)?;
6602                bits += 6;
6603            }
6604            let bytes = bits / 8;
6605            n <<= 24 - bits;
6606            for i in 0..bytes {
6607                out.push((n >> (16 - i * 8)) as u8);
6608            }
6609        }
6610        Some(out)
6611    }
6612
6613    #[tokio::test]
6614    async fn bot_mint_then_claim_grants_a_seat() {
6615        let state = bot_state("bot-secret-abcdef").await;
6616        let app = router(state.clone());
6617
6618        // 1. Mint a claim via the shared-secret endpoint.
6619        let resp = app
6620            .clone()
6621            .oneshot(
6622                Request::builder()
6623                    .method("POST")
6624                    .uri("/bot/claims")
6625                    .header("x-bot-secret", "bot-secret-abcdef")
6626                    .body(Body::empty())
6627                    .unwrap(),
6628            )
6629            .await
6630            .unwrap();
6631        assert_eq!(resp.status(), StatusCode::OK);
6632        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6633            .await
6634            .unwrap();
6635        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6636        let token = json["token"].as_str().unwrap().to_string();
6637        let url = json["url"].as_str().unwrap();
6638        assert!(url.starts_with("https://feather-reader.com/claim?t="));
6639        // The raw code is returned for the bot's records but not embedded in url.
6640        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6641        assert!(!url.contains("FEATHER-"));
6642
6643        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6644        let resp = app
6645            .clone()
6646            .oneshot(
6647                Request::builder()
6648                    .method("GET")
6649                    .uri(format!("/claim?t={}", qenc(&token)))
6650                    .body(Body::empty())
6651                    .unwrap(),
6652            )
6653            .await
6654            .unwrap();
6655        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6656        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6657        let set_cookie = resp
6658            .headers()
6659            .get(header::SET_COOKIE)
6660            .unwrap()
6661            .to_str()
6662            .unwrap();
6663        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6664
6665        // 3. The reserved cookie carries the same code the token wrapped, and
6666        //    redeeming it (the callback's machinery) grants a seat.
6667        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6668        let out = store::redeem_code(
6669            &state.db,
6670            &code,
6671            "did:plc:follower",
6672            None,
6673            state.config.beta_cap,
6674        )
6675        .await
6676        .unwrap();
6677        assert_eq!(out, Ok(()));
6678        assert!(store::has_beta_access(&state.db, "did:plc:follower")
6679            .await
6680            .unwrap());
6681    }
6682
6683    #[tokio::test]
6684    async fn claim_with_invalid_token_bounces() {
6685        let state = bot_state("bot-secret-abcdef").await;
6686        let app = router(state);
6687        let resp = app
6688            .oneshot(
6689                Request::builder()
6690                    .method("GET")
6691                    .uri("/claim?t=not-a-real-token")
6692                    .body(Body::empty())
6693                    .unwrap(),
6694            )
6695            .await
6696            .unwrap();
6697        // Renders the invite page (200), NOT a redirect to /login.
6698        assert_eq!(resp.status(), StatusCode::OK);
6699    }
6700
6701    #[tokio::test]
6702    async fn claim_with_used_token_is_refused() {
6703        let state = bot_state("bot-secret-abcdef").await;
6704        // Mint a code + wrap it, then redeem it out from under the token.
6705        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6706            .await
6707            .unwrap();
6708        let token = sign_claim_token(&code, &state.config.cookie_secret);
6709        store::redeem_code(
6710            &state.db,
6711            &code,
6712            "did:plc:someone",
6713            None,
6714            state.config.beta_cap,
6715        )
6716        .await
6717        .unwrap()
6718        .unwrap();
6719        let app = router(state);
6720        let resp = app
6721            .oneshot(
6722                Request::builder()
6723                    .method("GET")
6724                    .uri(format!("/claim?t={}", qenc(&token)))
6725                    .body(Body::empty())
6726                    .unwrap(),
6727            )
6728            .await
6729            .unwrap();
6730        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6731        assert_eq!(resp.status(), StatusCode::OK);
6732        assert!(resp.headers().get(header::SET_COOKIE).is_none());
6733    }
6734
6735    #[tokio::test]
6736    async fn bot_claims_rejects_bad_and_missing_secret() {
6737        let state = bot_state("bot-secret-abcdef").await;
6738        let app = router(state);
6739        // Wrong secret.
6740        let resp = app
6741            .clone()
6742            .oneshot(
6743                Request::builder()
6744                    .method("POST")
6745                    .uri("/bot/claims")
6746                    .header("x-bot-secret", "wrong")
6747                    .body(Body::empty())
6748                    .unwrap(),
6749            )
6750            .await
6751            .unwrap();
6752        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6753        // Missing secret.
6754        let resp = app
6755            .oneshot(
6756                Request::builder()
6757                    .method("POST")
6758                    .uri("/bot/claims")
6759                    .body(Body::empty())
6760                    .unwrap(),
6761            )
6762            .await
6763            .unwrap();
6764        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6765    }
6766
6767    #[tokio::test]
6768    async fn bot_claims_disabled_when_secret_unset() {
6769        // test_state configures NO bot secret → the endpoint is off (503).
6770        let state = test_state(&["did:plc:admin"]).await;
6771        let app = router(state);
6772        let resp = app
6773            .oneshot(
6774                Request::builder()
6775                    .method("POST")
6776                    .uri("/bot/claims")
6777                    .header("x-bot-secret", "anything")
6778                    .body(Body::empty())
6779                    .unwrap(),
6780            )
6781            .await
6782            .unwrap();
6783        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6784    }
6785
6786    #[tokio::test]
6787    async fn bot_claims_refuses_at_capacity() {
6788        let state = bot_state("bot-secret-abcdef").await;
6789        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6790        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6791            .await
6792            .unwrap();
6793        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6794            .await
6795            .unwrap();
6796        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6797        let app = router(state);
6798        let resp = app
6799            .oneshot(
6800                Request::builder()
6801                    .method("POST")
6802                    .uri("/bot/claims")
6803                    .header("x-bot-secret", "bot-secret-abcdef")
6804                    .body(Body::empty())
6805                    .unwrap(),
6806            )
6807            .await
6808            .unwrap();
6809        assert_eq!(resp.status(), StatusCode::CONFLICT);
6810        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6811            .await
6812            .unwrap();
6813        assert!(String::from_utf8_lossy(&bytes).contains("full"));
6814    }
6815
6816    #[tokio::test]
6817    async fn bot_claims_counts_outstanding_codes_against_cap() {
6818        let state = bot_state("bot-secret-abcdef").await;
6819        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6820        store::mint_code(&state.db, "did:plc:admin", 3600)
6821            .await
6822            .unwrap();
6823        store::mint_code(&state.db, "did:plc:admin", 3600)
6824            .await
6825            .unwrap();
6826        let app = router(state);
6827        let resp = app
6828            .oneshot(
6829                Request::builder()
6830                    .method("POST")
6831                    .uri("/bot/claims")
6832                    .header("x-bot-secret", "bot-secret-abcdef")
6833                    .body(Body::empty())
6834                    .unwrap(),
6835            )
6836            .await
6837            .unwrap();
6838        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6839        assert_eq!(resp.status(), StatusCode::CONFLICT);
6840    }
6841
6842    /// POST /bot/claims with a JSON body carrying the follower DID.
6843    async fn post_bot_claim_for(
6844        app: &axum::Router,
6845        secret: &str,
6846        did: &str,
6847    ) -> (StatusCode, serde_json::Value) {
6848        let resp = app
6849            .clone()
6850            .oneshot(
6851                Request::builder()
6852                    .method("POST")
6853                    .uri("/bot/claims")
6854                    .header("x-bot-secret", secret)
6855                    .header("content-type", "application/json")
6856                    .body(Body::from(format!(
6857                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6858                    )))
6859                    .unwrap(),
6860            )
6861            .await
6862            .unwrap();
6863        let status = resp.status();
6864        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6865            .await
6866            .unwrap();
6867        let json = if bytes.is_empty() {
6868            serde_json::Value::Null
6869        } else {
6870            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6871        };
6872        (status, json)
6873    }
6874
6875    #[tokio::test]
6876    async fn bot_claims_returns_already_seated_for_a_member() {
6877        // A DID that already holds beta access must get `already_seated` with NO
6878        // code/url — the bot posts nothing. This is the server-side backstop that
6879        // survives a bot-host state loss (it would otherwise re-mint + re-post).
6880        let state = bot_state("bot-secret-abcdef").await;
6881        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6882            .await
6883            .unwrap();
6884        let app = router(state.clone());
6885        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6886        assert_eq!(status, StatusCode::OK);
6887        assert_eq!(json["status"], "already_seated");
6888        assert_eq!(json["code"], "");
6889        assert_eq!(json["url"], "");
6890        // No new invite code was minted for the seated DID.
6891        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6892            .await
6893            .unwrap()
6894            .is_none());
6895    }
6896
6897    #[tokio::test]
6898    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6899        // Two mint requests for the SAME follower DID must return the SAME code
6900        // (the app is authoritative), never a second one — so a bot-host state loss
6901        // re-requesting cannot double-mint or double-post.
6902        let state = bot_state("bot-secret-abcdef").await;
6903        let app = router(state.clone());
6904
6905        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6906        assert_eq!(s1, StatusCode::OK);
6907        assert_eq!(j1["status"], "minted");
6908        let code1 = j1["code"].as_str().unwrap().to_string();
6909
6910        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6911        assert_eq!(s2, StatusCode::OK);
6912        assert_eq!(j2["status"], "existing");
6913        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6914        assert_eq!(j2["url"], j1["url"], "same url returned");
6915
6916        // Exactly ONE active code exists for that DID.
6917        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6918    }
6919
6920    #[tokio::test]
6921    async fn bot_claims_records_intended_did_at_mint() {
6922        // A fresh mint records the follower DID so the lookup finds it.
6923        let state = bot_state("bot-secret-abcdef").await;
6924        let app = router(state.clone());
6925        let (status, json) =
6926            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6927        assert_eq!(status, StatusCode::OK);
6928        let code = json["code"].as_str().unwrap();
6929        assert_eq!(
6930            store::find_active_code_for_did(&state.db, "did:plc:follower2")
6931                .await
6932                .unwrap()
6933                .as_deref(),
6934            Some(code)
6935        );
6936    }
6937
6938    #[tokio::test]
6939    async fn bot_claims_concurrent_same_did_never_double_mints() {
6940        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6941        // active code. The dedupe check (3b) and the mint are separate statements,
6942        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6943        // then makes the loser's INSERT conflict, and the handler recovers by
6944        // returning the winner's code (status `existing`) rather than 500-ing.
6945        // Result: exactly ONE active code, and BOTH callers get a usable code.
6946        let state = bot_state("bot-secret-abcdef").await;
6947        let app = router(state.clone());
6948
6949        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6950        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6951        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6952
6953        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6954        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6955
6956        // Exactly one active code for the DID — the whole point of the fix.
6957        assert_eq!(
6958            store::count_active_codes(&state.db).await.unwrap(),
6959            1,
6960            "concurrent mints must not create two active codes"
6961        );
6962
6963        // Both callers received the SAME (single) code, and neither got a 500.
6964        let ca = ja["code"].as_str().unwrap_or("");
6965        let cb = jb["code"].as_str().unwrap_or("");
6966        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6967        assert_eq!(ca, cb, "both callers must get the one minted code");
6968        // One is `minted` (the winner), the other `minted` or `existing` depending
6969        // on interleaving — but never an error status.
6970        for st in [&ja["status"], &jb["status"]] {
6971            let s = st.as_str().unwrap_or("");
6972            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6973        }
6974    }
6975
6976    #[tokio::test]
6977    async fn bot_claims_rejects_malformed_json_body() {
6978        let state = bot_state("bot-secret-abcdef").await;
6979        let app = router(state);
6980        let resp = app
6981            .oneshot(
6982                Request::builder()
6983                    .method("POST")
6984                    .uri("/bot/claims")
6985                    .header("x-bot-secret", "bot-secret-abcdef")
6986                    .header("content-type", "application/json")
6987                    .body(Body::from("{not json"))
6988                    .unwrap(),
6989            )
6990            .await
6991            .unwrap();
6992        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6993    }
6994
6995    #[tokio::test]
6996    async fn favicon_ico_served_at_root() {
6997        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
6998        // tags in <head>; the root route must serve the icon, not 404.
6999        let state = test_state(&[]).await;
7000        let app = router(state);
7001        let resp = app
7002            .oneshot(
7003                Request::builder()
7004                    .uri("/favicon.ico")
7005                    .body(Body::empty())
7006                    .unwrap(),
7007            )
7008            .await
7009            .unwrap();
7010        assert_eq!(resp.status(), StatusCode::OK);
7011        let ct = resp
7012            .headers()
7013            .get(header::CONTENT_TYPE)
7014            .unwrap()
7015            .to_str()
7016            .unwrap();
7017        assert!(
7018            ct.contains("icon") || ct.starts_with("image/"),
7019            "content-type = {ct}"
7020        );
7021    }
7022
7023    #[tokio::test]
7024    async fn login_without_invite_redirects_to_beta_redeem() {
7025        // No allow-list seed, no invite cookie: starting OAuth must be refused.
7026        let state = test_state(&[]).await;
7027        let app = router(state);
7028        let resp = app
7029            .oneshot(
7030                Request::builder()
7031                    .method("POST")
7032                    .uri("/login")
7033                    .header("content-type", "application/x-www-form-urlencoded")
7034                    .body(Body::from("handle=alice.bsky.social"))
7035                    .unwrap(),
7036            )
7037            .await
7038            .unwrap();
7039        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7040        assert_eq!(
7041            resp.headers().get(header::LOCATION).unwrap(),
7042            "/beta/redeem"
7043        );
7044    }
7045
7046    #[tokio::test]
7047    async fn login_with_valid_invite_cookie_starts_oauth() {
7048        let state = test_state(&[]).await;
7049        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7050        let cookie = cookie.split(';').next().unwrap().to_string();
7051        let app = router(state);
7052        let resp = app
7053            .oneshot(
7054                Request::builder()
7055                    .method("POST")
7056                    .uri("/login")
7057                    .header("content-type", "application/x-www-form-urlencoded")
7058                    .header(header::COOKIE, cookie)
7059                    .body(Body::from("handle=alice.bsky.social"))
7060                    .unwrap(),
7061            )
7062            .await
7063            .unwrap();
7064        // Redirects into the sidecar login (not to /beta/redeem).
7065        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7066        let loc = resp
7067            .headers()
7068            .get(header::LOCATION)
7069            .unwrap()
7070            .to_str()
7071            .unwrap();
7072        assert!(loc.contains("/login"), "loc = {loc}");
7073        assert_ne!(loc, "/beta/redeem");
7074    }
7075
7076    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
7077    /// any network resolution and that a resolution failure fails closed.
7078    async fn resolver_never(_handle: String) -> Option<String> {
7079        None
7080    }
7081
7082    /// A resolver that maps every handle to `did`.
7083    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7084        move |_handle| std::future::ready(Some(did.to_string()))
7085    }
7086
7087    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
7088    /// that already holds a seat (the seeded-admin first-login case) passes the
7089    /// gate — no session cookie, no invite code.
7090    #[tokio::test]
7091    async fn may_start_oauth_honors_seat_via_resolved_handle() {
7092        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
7093        // no cookie on a fresh deploy.
7094        let state = test_state(&["did:plc:admin"]).await;
7095        let headers = HeaderMap::new();
7096        assert!(
7097            may_start_oauth_with(
7098                &state,
7099                &headers,
7100                "admin.example",
7101                resolver_to("did:plc:admin")
7102            )
7103            .await,
7104            "a handle resolving to a seated DID must pass the gate"
7105        );
7106    }
7107
7108    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
7109    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
7110    /// fails).
7111    #[tokio::test]
7112    async fn may_start_oauth_bounces_non_member_handle() {
7113        let state = test_state(&["did:plc:admin"]).await;
7114        let headers = HeaderMap::new();
7115        assert!(
7116            !may_start_oauth_with(
7117                &state,
7118                &headers,
7119                "rando.example",
7120                resolver_to("did:plc:rando")
7121            )
7122            .await,
7123            "a resolved DID with no seat must be bounced"
7124        );
7125    }
7126
7127    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
7128    /// bounces gracefully — no panic, no handshake.
7129    #[tokio::test]
7130    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7131        let state = test_state(&["did:plc:admin"]).await;
7132        let headers = HeaderMap::new();
7133        assert!(
7134            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7135            "an unresolvable handle must fail closed"
7136        );
7137    }
7138
7139    /// The session-cookie fast path admits a seated member WITHOUT calling the
7140    /// resolver (proven by injecting `resolver_never`, which would otherwise
7141    /// bounce).
7142    #[tokio::test]
7143    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7144        let state = test_state(&[]).await;
7145        let did = "did:plc:member";
7146        store::grant_access(&state.db, did, Some("member.example"), "test", None)
7147            .await
7148            .unwrap();
7149        let cookie = session_cookie(&state, did, Some("member.example"));
7150        let mut headers = HeaderMap::new();
7151        headers.insert(header::COOKIE, cookie.parse().unwrap());
7152        assert!(
7153            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7154            "a seated session cookie must pass without resolution"
7155        );
7156    }
7157
7158    /// The invite-cookie fast path admits WITHOUT calling the resolver.
7159    #[tokio::test]
7160    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7161        let state = test_state(&[]).await;
7162        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7163        let cookie = cookie.split(';').next().unwrap().to_string();
7164        let mut headers = HeaderMap::new();
7165        headers.insert(header::COOKIE, cookie.parse().unwrap());
7166        assert!(
7167            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7168            "a valid invite cookie must pass without resolution"
7169        );
7170    }
7171
7172    #[tokio::test]
7173    async fn admin_mint_requires_admin_seed_did() {
7174        let state = test_state(&["did:plc:admin"]).await;
7175        // A non-admin (but beta'd) session is forbidden.
7176        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7177            .await
7178            .unwrap();
7179        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7180        // An admin session is allowed.
7181        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7182        let app = router(state);
7183
7184        let forbidden = app
7185            .clone()
7186            .oneshot(
7187                Request::builder()
7188                    .method("POST")
7189                    .uri("/admin/invites?n=2")
7190                    .header(header::COOKIE, rando_cookie)
7191                    .body(Body::empty())
7192                    .unwrap(),
7193            )
7194            .await
7195            .unwrap();
7196        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7197
7198        let ok = app
7199            .oneshot(
7200                Request::builder()
7201                    .method("POST")
7202                    .uri("/admin/invites?n=2")
7203                    .header(header::COOKIE, admin_cookie)
7204                    .body(Body::empty())
7205                    .unwrap(),
7206            )
7207            .await
7208            .unwrap();
7209        assert_eq!(ok.status(), StatusCode::OK);
7210        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7211            .await
7212            .unwrap();
7213        let body = String::from_utf8(bytes.to_vec()).unwrap();
7214        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7215        assert_eq!(minted.len(), 2);
7216        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7217    }
7218
7219    #[tokio::test]
7220    async fn admin_mint_unauthenticated_is_401() {
7221        let state = test_state(&["did:plc:admin"]).await;
7222        let app = router(state);
7223        let resp = app
7224            .oneshot(
7225                Request::builder()
7226                    .method("POST")
7227                    .uri("/admin/invites")
7228                    .body(Body::empty())
7229                    .unwrap(),
7230            )
7231            .await
7232            .unwrap();
7233        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7234    }
7235
7236    /// A state whose `/about` renders the adoption line, seeded with one
7237    /// observation.
7238    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7239        let db = store::init_url("sqlite::memory:").await.unwrap();
7240        store::record_network_stat(
7241            &db,
7242            &store::NetworkStat {
7243                key: store::ADOPTION_STAT_KEY.to_string(),
7244                source: "https://relay1.us-west.bsky.network".to_string(),
7245                value: repos,
7246                truncated,
7247                observed_at: "2026-08-13T04:05:06Z".to_string(),
7248            },
7249        )
7250        .await
7251        .unwrap();
7252        let config = Config {
7253            cookie_secret: "test-cookie-secret-000".to_string(),
7254            show_adoption: true,
7255            ..Config::default()
7256        };
7257        AppState::new(config, db).unwrap()
7258    }
7259
7260    async fn about_body(state: AppState) -> String {
7261        let resp = router(state)
7262            .oneshot(
7263                Request::builder()
7264                    .uri("/about")
7265                    .body(Body::empty())
7266                    .unwrap(),
7267            )
7268            .await
7269            .unwrap();
7270        assert_eq!(resp.status(), StatusCode::OK);
7271        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7272            .await
7273            .unwrap();
7274        String::from_utf8(bytes.to_vec()).unwrap()
7275    }
7276
7277    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
7278    #[tokio::test]
7279    async fn about_omits_adoption_line_by_default() {
7280        let state = test_state(&[]).await;
7281        assert!(!state.config.show_adoption);
7282        let body = about_body(state).await;
7283        assert!(
7284            !body.contains("atproto network"),
7285            "the adoption line must not render by default"
7286        );
7287    }
7288
7289    #[tokio::test]
7290    async fn about_renders_adoption_line_when_enabled() {
7291        // **A distinctive count, and asserted IN ITS SENTENCE.**
7292        //
7293        // This used to seed 4 and assert `body.contains("4")`, which the
7294        // colophon's `width="44"` satisfies whatever the count is — so
7295        // hardcoding the rendered number passed. Both halves are needed: a
7296        // digit that does not occur incidentally, and the assertion tied to the
7297        // phrase it belongs to.
7298        let body = about_body(adoption_state(7_318, false).await).await;
7299        // The count and its phrase are on separate template lines, so compare
7300        // against a whitespace-collapsed copy rather than the raw HTML.
7301        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7302        assert!(
7303            flat.contains("7318 accounts on the atproto network hold"),
7304            "the count did not render in its own sentence: {flat}",
7305        );
7306        assert!(
7307            body.contains("accounts on the atproto network hold"),
7308            "{body}"
7309        );
7310        assert!(
7311            body.contains("2026-08-13"),
7312            "the observation date must render"
7313        );
7314        assert!(
7315            body.contains("lower bound"),
7316            "the non-archival caveat must ride along with the number"
7317        );
7318        assert!(
7319            !body.contains("At least"),
7320            "an untruncated count is exact-ish"
7321        );
7322    }
7323
7324    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
7325    #[tokio::test]
7326    async fn about_adoption_line_is_singular_at_one() {
7327        let body = about_body(adoption_state(1, false).await).await;
7328        assert!(
7329            body.contains("account on the atproto network holds"),
7330            "{body}"
7331        );
7332    }
7333
7334    /// A truncated observation is a floor, and must say so.
7335    #[tokio::test]
7336    async fn about_adoption_line_says_at_least_when_truncated() {
7337        let body = about_body(adoption_state(25_000, true).await).await;
7338        assert!(body.contains("At least"), "{body}");
7339    }
7340
7341    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
7342    #[tokio::test]
7343    async fn about_omits_line_when_enabled_with_no_observation() {
7344        let db = store::init_url("sqlite::memory:").await.unwrap();
7345        let config = Config {
7346            cookie_secret: "test-cookie-secret-000".to_string(),
7347            show_adoption: true,
7348            ..Config::default()
7349        };
7350        let body = about_body(AppState::new(config, db).unwrap()).await;
7351        assert!(!body.contains("atproto network"));
7352    }
7353
7354    // ---- standard.site on the public pages and the subscribe form ----------
7355    //
7356    // `FEATHERREADER_STANDARD_SITE` gates what may be STORED (`add_subscription`
7357    // refuses every `at://` paste with it off), so a page that tells the reader
7358    // to paste a publication URI is advertising a form that will be refused
7359    // unless the flag is on. These pin both halves: with the flag on the pages
7360    // say how; with it off they do not.
7361
7362    /// A state with the standard.site flag chosen, and `did` holding a seat so
7363    /// `/manage` renders for it.
7364    async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7365        let db = store::init_url("sqlite::memory:").await.unwrap();
7366        store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7367        let config = Config {
7368            allowed_dids: vec![did.to_string()],
7369            cookie_secret: "test-cookie-secret-000".to_string(),
7370            beta_cap: 3,
7371            standard_site,
7372            ..Config::default()
7373        };
7374        AppState::new(config, db).unwrap()
7375    }
7376
7377    /// `GET path` as `did`, asserted 200, body as a string.
7378    async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7379        let cookie = session_cookie(&state, did, Some("reader.example"));
7380        let resp = router(state)
7381            .oneshot(
7382                Request::builder()
7383                    .uri(path)
7384                    .header(header::COOKIE, cookie)
7385                    .body(Body::empty())
7386                    .unwrap(),
7387            )
7388            .await
7389            .unwrap();
7390        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7391        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7392            .await
7393            .unwrap();
7394        String::from_utf8(bytes.to_vec()).unwrap()
7395    }
7396
7397    /// `GET path` signed out, asserted 200, body as a string.
7398    async fn public_body(state: AppState, path: &str) -> String {
7399        let resp = router(state)
7400            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7401            .await
7402            .unwrap();
7403        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7404        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7405            .await
7406            .unwrap();
7407        String::from_utf8(bytes.to_vec()).unwrap()
7408    }
7409
7410    /// The `<input … id="feed-url" …>` tag of the subscribe form, whole.
7411    fn feed_url_input(body: &str) -> &str {
7412        let start = body
7413            .find("id=\"feed-url\"")
7414            .and_then(|i| body[..i].rfind("<input"))
7415            .expect("the subscribe form's URL input renders");
7416        let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7417        &body[start..end]
7418    }
7419
7420    /// Flag on: the subscribe form says a publication URI is accepted, and shows
7421    /// both spellings the handler takes (DID and handle).
7422    #[tokio::test]
7423    async fn manage_hints_at_publications_when_the_flag_is_on() {
7424        let did = "did:plc:reader";
7425        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7426        assert!(
7427            body.contains("at://did:plc:…/site.standard.publication/…"),
7428            "the DID form must be shown: {body}"
7429        );
7430        assert!(
7431            body.contains("at://alice.example.com/site.standard.publication/…"),
7432            "the handle form must be shown: {body}"
7433        );
7434    }
7435
7436    /// Flag on: the URL input must not be `type="url"`. A browser validates
7437    /// that type with the WHATWG URL parser, which REJECTS the DID form —
7438    /// `at://did:plc:…/…` fails as an invalid port, the same failure
7439    /// `url::Url::parse` has (see `feed::is_storable_feed_url`) — so the form
7440    /// would refuse to submit the very string the hint asks for.
7441    #[tokio::test]
7442    async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7443        let did = "did:plc:reader";
7444        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7445        let input = feed_url_input(&body);
7446        assert!(
7447            input.contains("type=\"text\""),
7448            "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7449        );
7450        assert!(
7451            input.contains("inputmode=\"url\""),
7452            "the URL keyboard is still wanted: {input}"
7453        );
7454    }
7455
7456    /// Flag on, `type="text"` drops the browser's scheme check, so a pasted
7457    /// `example.com/blog` would reach the handler and come back as "Couldn't
7458    /// find a feed" — wrong, the site likely has one. A `pattern` keeps the
7459    /// browser asking for a scheme while still admitting `at://` (both cases:
7460    /// the handler canonicalises the scheme).
7461    #[tokio::test]
7462    async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7463        let did = "did:plc:reader";
7464        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7465        let input = feed_url_input(&body);
7466        assert!(
7467            input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7468            "the text input must keep a scheme check: {input}"
7469        );
7470    }
7471
7472    /// Flag off: every `at://` paste is refused, so the form must not say
7473    /// publications are accepted — and the input keeps browser URL validation.
7474    #[tokio::test]
7475    async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7476        let did = "did:plc:reader";
7477        let state = standard_site_state(false, did).await;
7478        assert!(!state.config.standard_site);
7479        let page = signed_in_body(state, "/manage", did).await;
7480        // The `<head>` carries the site's link card, whose one-line description
7481        // names standard.site whatever the flag says — as the landing page does
7482        // with the flag off (a stored publication is polled regardless). What
7483        // must not advertise is the page: everything after `</head>`.
7484        let body = &page[page.find("</head>").expect("a <head>")..];
7485        assert!(
7486            !body.contains("site.standard.publication"),
7487            "a refused form must not be advertised: {body}"
7488        );
7489        // The shared footer links the `/standard-site` feature page on every
7490        // page, flag on or off — that page itself says the instance isn't
7491        // accepting new publication subscriptions — so the check is on the
7492        // page above the footer, where the form and its hints are.
7493        let above_footer = body
7494            .split("<footer")
7495            .next()
7496            .expect("split yields at least one piece");
7497        assert!(
7498            above_footer.contains("id=\"feed-url\""),
7499            "the form must be above the footer: {body}"
7500        );
7501        assert!(
7502            !above_footer.contains("standard.site"),
7503            "a refused form must not be advertised: {body}"
7504        );
7505        assert!(
7506            feed_url_input(body).contains("type=\"url\""),
7507            "with the flag off the input is unchanged"
7508        );
7509    }
7510
7511    /// Flag on: the landing page says publications sit beside feeds AND how to
7512    /// subscribe to one.
7513    #[tokio::test]
7514    async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7515        let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7516        assert!(body.contains("standard.site"), "{body}");
7517        assert!(
7518            body.contains("at://did:plc:…/site.standard.publication/…"),
7519            "the landing page must show the DID form: {body}"
7520        );
7521        assert!(
7522            body.contains("at://alice.example.com/site.standard.publication/…"),
7523            "the landing page must show the handle form: {body}"
7524        );
7525    }
7526
7527    /// Flag off: the landing page still says what a publication is (a stored
7528    /// one is polled whatever the flag says), but shows no paste instructions
7529    /// and says new ones are not accepted here.
7530    #[tokio::test]
7531    async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7532        let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7533        assert!(body.contains("standard.site"), "{body}");
7534        assert!(
7535            !body.contains("at://did:plc:…/site.standard.publication/…"),
7536            "no paste instructions with the flag off: {body}"
7537        );
7538        assert!(
7539            !body.contains("at://alice.example.com/site.standard.publication/…"),
7540            "no paste instructions with the flag off: {body}"
7541        );
7542        assert!(
7543            body.contains("isn't accepting new publication subscriptions"),
7544            "the page must say the form is closed here: {body}"
7545        );
7546    }
7547
7548    /// Flag on: /about has a publications section with both spellings.
7549    #[tokio::test]
7550    async fn about_describes_publications_and_how_to_subscribe_when_on() {
7551        let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7552        assert!(body.contains("site.standard.publication"), "{body}");
7553        assert!(body.contains("site.standard.document"), "{body}");
7554        assert!(
7555            body.contains("at://did:plc:…/site.standard.publication/…"),
7556            "{body}"
7557        );
7558        assert!(
7559            body.contains("at://alice.example.com/site.standard.publication/…"),
7560            "{body}"
7561        );
7562    }
7563
7564    /// Flag off: /about keeps the description, drops the paste instructions.
7565    #[tokio::test]
7566    async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7567        let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7568        assert!(body.contains("site.standard.publication"), "{body}");
7569        assert!(
7570            !body.contains("at://did:plc:…/site.standard.publication/…"),
7571            "no paste instructions with the flag off: {body}"
7572        );
7573        assert!(
7574            !body.contains("at://alice.example.com/site.standard.publication/…"),
7575            "no paste instructions with the flag off: {body}"
7576        );
7577        assert!(
7578            body.contains("isn't accepting new publication subscriptions"),
7579            "{body}"
7580        );
7581    }
7582
7583    // ---- the standard.site feature page (`/standard-site`) -----------------
7584    //
7585    // A public page, like `/about`: what a publication is, what is shown from
7586    // it, how to subscribe (flag-conditional, as on the other public pages),
7587    // and the honest limits. It also carries the "latest releases" call-out.
7588
7589    /// Signed out, with the default config, the page renders.
7590    #[tokio::test]
7591    async fn standard_site_page_renders_signed_out() {
7592        let body = public_body(test_state(&[]).await, "/standard-site").await;
7593        assert!(body.contains("site.standard.publication"), "{body}");
7594        assert!(body.contains("site.standard.document"), "{body}");
7595        assert!(
7596            body.contains("<title>standard.site — FeatherReader</title>"),
7597            "{body}"
7598        );
7599    }
7600
7601    /// Flag on: the page says how to subscribe, in both spellings, and that a
7602    /// handle is resolved to its DID.
7603    #[tokio::test]
7604    async fn standard_site_page_tells_how_to_subscribe_when_on() {
7605        let body = public_body(
7606            standard_site_state(true, "did:plc:x").await,
7607            "/standard-site",
7608        )
7609        .await;
7610        assert!(
7611            body.contains("at://did:plc:…/site.standard.publication/…"),
7612            "the DID form must be shown: {body}"
7613        );
7614        assert!(
7615            body.contains("at://alice.example.com/site.standard.publication/…"),
7616            "the handle form must be shown: {body}"
7617        );
7618        assert!(
7619            body.contains("resolved to its DID"),
7620            "the handle resolution must be stated: {body}"
7621        );
7622        assert!(
7623            !body.contains("isn't accepting new publication subscriptions"),
7624            "{body}"
7625        );
7626    }
7627
7628    /// Flag off: `add_subscription` refuses every `at://` paste, so the page
7629    /// must not tell visitors to paste one — it says new publication
7630    /// subscriptions are not accepted here, and that stored ones are still read.
7631    #[tokio::test]
7632    async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
7633        let state = standard_site_state(false, "did:plc:x").await;
7634        assert!(!state.config.standard_site);
7635        let body = public_body(state, "/standard-site").await;
7636        assert!(body.contains("site.standard.publication"), "{body}");
7637        assert!(
7638            !body.contains("at://did:plc:…/site.standard.publication/…"),
7639            "no paste instructions with the flag off: {body}"
7640        );
7641        assert!(
7642            !body.contains("at://alice.example.com/site.standard.publication/…"),
7643            "no paste instructions with the flag off: {body}"
7644        );
7645        assert!(
7646            body.contains("isn't accepting new publication subscriptions"),
7647            "the page must say the form is closed here: {body}"
7648        );
7649        assert!(
7650            body.contains("already follows are still read"),
7651            "stored publications are polled whatever the flag says: {body}"
7652        );
7653    }
7654
7655    /// The releases call-out links each release's GitHub page and the
7656    /// changelog, on the feature page and on the landing page.
7657    #[tokio::test]
7658    async fn releases_callout_links_the_release_pages() {
7659        for path in ["/standard-site", "/"] {
7660            let body = public_body(test_state(&[]).await, path).await;
7661            for tag in ["v0.4.1", "v0.4.0"] {
7662                let href = format!(
7663                    "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
7664                );
7665                assert!(body.contains(&href), "{path} must link {tag}: {body}");
7666            }
7667            assert!(
7668                body.contains(
7669                    "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
7670                ),
7671                "{path} must link the changelog: {body}"
7672            );
7673        }
7674    }
7675
7676    /// The feature page is reachable from the landing page, from `/about`, and
7677    /// from the shared footer (`/privacy` renders nothing but prose and that
7678    /// footer, so it stands in for every page that includes it).
7679    #[tokio::test]
7680    async fn landing_about_and_footer_link_the_standard_site_page() {
7681        for path in ["/", "/about", "/privacy"] {
7682            let body = public_body(test_state(&[]).await, path).await;
7683            assert!(
7684                body.contains("href=\"/standard-site\""),
7685                "{path} must link the feature page: {body}"
7686            );
7687        }
7688    }
7689
7690    /// `RELEASES` is the one place a release is described, so its shape is
7691    /// pinned: newest first, dates as the CHANGELOG headings spell them, and
7692    /// both derived links pointing where the template promises.
7693    #[test]
7694    fn releases_are_newest_first_and_link_the_tag_and_changelog() {
7695        assert!(!RELEASES.is_empty());
7696        let parse = |v: &str| -> Vec<u32> {
7697            v.split('.')
7698                .map(|p| p.parse::<u32>().expect("a numeric version part"))
7699                .collect()
7700        };
7701        for pair in RELEASES.windows(2) {
7702            assert!(
7703                parse(pair[0].version) > parse(pair[1].version),
7704                "{} must come before {}",
7705                pair[0].version,
7706                pair[1].version
7707            );
7708        }
7709        for r in RELEASES {
7710            assert_eq!(parse(r.version).len(), 3, "{}", r.version);
7711            assert!(
7712                chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
7713                "{} is not YYYY-MM-DD",
7714                r.date
7715            );
7716            assert!(!r.summary.trim().is_empty());
7717            assert!(!r.summary.contains('<'), "the summary is plain text");
7718            assert_eq!(
7719                r.url(),
7720                format!(
7721                    "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
7722                    r.version
7723                )
7724            );
7725        }
7726        // The newest entry is this build's own version, so a release cannot
7727        // ship without adding itself to the call-out.
7728        let latest = &RELEASES[0];
7729        assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
7730        assert_eq!(
7731            latest.changelog_url(),
7732            "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#042--2026-10-04"
7733        );
7734    }
7735
7736    /// Public and static like `/about`, so it is cacheable on the same terms.
7737    #[tokio::test]
7738    async fn standard_site_page_is_publicly_cacheable() {
7739        let resp = router(test_state(&[]).await)
7740            .oneshot(
7741                Request::builder()
7742                    .uri("/standard-site")
7743                    .body(Body::empty())
7744                    .unwrap(),
7745            )
7746            .await
7747            .unwrap();
7748        assert_eq!(resp.status(), StatusCode::OK);
7749        assert_eq!(
7750            resp.headers().get(header::CACHE_CONTROL).unwrap(),
7751            "public, max-age=300"
7752        );
7753    }
7754
7755    #[tokio::test]
7756    async fn cache_control_public_on_about_no_store_on_authed() {
7757        let state = test_state(&["did:plc:admin"]).await;
7758        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7759        let app = router(state);
7760
7761        // /about → public, cacheable.
7762        let about = app
7763            .clone()
7764            .oneshot(
7765                Request::builder()
7766                    .uri("/about")
7767                    .body(Body::empty())
7768                    .unwrap(),
7769            )
7770            .await
7771            .unwrap();
7772        assert_eq!(
7773            about.headers().get(header::CACHE_CONTROL).unwrap(),
7774            "public, max-age=300"
7775        );
7776        // The security headers are still intact.
7777        // The VALUE, spelled out here rather than compared to the constant —
7778        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
7779        // used to assert only that the header existed, which a policy of
7780        // `default-src *` satisfies.
7781        assert_eq!(
7782            about.headers()["content-security-policy"],
7783            EXPECTED_CSP,
7784            "the CSP is not the policy the router promises"
7785        );
7786        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7787
7788        // /privacy and /terms are static public pages → public, cacheable.
7789        for path in ["/privacy", "/terms"] {
7790            let resp = app
7791                .clone()
7792                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7793                .await
7794                .unwrap();
7795            assert_eq!(resp.status(), StatusCode::OK);
7796            assert_eq!(
7797                resp.headers().get(header::CACHE_CONTROL).unwrap(),
7798                "public, max-age=300",
7799                "{path} should be publicly cacheable"
7800            );
7801            // Security headers apply to these pages too.
7802            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7803            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7804        }
7805
7806        // The bare /login landing → public, cacheable.
7807        let login = app
7808            .clone()
7809            .oneshot(
7810                Request::builder()
7811                    .uri("/login")
7812                    .body(Body::empty())
7813                    .unwrap(),
7814            )
7815            .await
7816            .unwrap();
7817        assert_eq!(
7818            login.headers().get(header::CACHE_CONTROL).unwrap(),
7819            "public, max-age=300"
7820        );
7821
7822        // An authenticated page → no-store.
7823        let home = app
7824            .oneshot(
7825                Request::builder()
7826                    .uri("/")
7827                    .header(header::COOKIE, admin_cookie)
7828                    .body(Body::empty())
7829                    .unwrap(),
7830            )
7831            .await
7832            .unwrap();
7833        assert_eq!(
7834            home.headers().get(header::CACHE_CONTROL).unwrap(),
7835            "no-store"
7836        );
7837    }
7838
7839    // -- link cards (Open Graph) -----------------------------------------------
7840    //
7841    // Bluesky's card service fetches the HTML server-side, runs no JS, and
7842    // resolves nothing relative. Measured before these tags existed:
7843    // `cardyb.bsky.app/v1/extract?url=https://feather-reader.com/` returned
7844    // `{"title":"FeatherReader — read, quietly","description":"","image":""}`.
7845
7846    /// Everything up to `</head>` — the only part a card fetcher reads.
7847    fn head(body: &str) -> &str {
7848        let end = body.find("</head>").expect("a <head>");
7849        &body[..end]
7850    }
7851
7852    /// The `content` of the first `<meta …>` tag carrying `attr` (e.g.
7853    /// `property="og:title"`), or `None` when no tag carries it.
7854    fn meta(head: &str, attr: &str) -> Option<String> {
7855        let tag_start = head.find(attr)?;
7856        let rest = &head[tag_start..];
7857        let tag_end = rest.find('>')?;
7858        let tag = &rest[..tag_end];
7859        let content = tag.find("content=\"")? + "content=\"".len();
7860        let close = tag[content..].find('"')?;
7861        Some(tag[content..content + close].to_string())
7862    }
7863
7864    /// A state whose public origin is production's. The card URLs must be
7865    /// absolute on THAT origin: a relative `/static/…` is what the card
7866    /// fetcher cannot use.
7867    async fn production_origin_state() -> AppState {
7868        let db = store::init_url("sqlite::memory:").await.unwrap();
7869        store::ensure_seed(&db, &["did:plc:admin".to_string()])
7870            .await
7871            .unwrap();
7872        let config = Config {
7873            allowed_dids: vec!["did:plc:admin".to_string()],
7874            cookie_secret: "test-cookie-secret-000".to_string(),
7875            beta_cap: 3,
7876            public_url: "https://feather-reader.com".to_string(),
7877            ..Config::default()
7878        };
7879        AppState::new(config, db).unwrap()
7880    }
7881
7882    /// The landing page and /about each carry a complete card with absolute
7883    /// https URLs, and the two describe different things.
7884    #[tokio::test]
7885    async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
7886        let landing = public_body(production_origin_state().await, "/").await;
7887        let about = public_body(production_origin_state().await, "/about").await;
7888        let (lh, ah) = (head(&landing), head(&about));
7889
7890        assert_eq!(
7891            meta(lh, "property=\"og:title\"").as_deref(),
7892            Some("FeatherReader — read, quietly"),
7893            "{lh}"
7894        );
7895        assert_eq!(
7896            meta(ah, "property=\"og:title\"").as_deref(),
7897            Some("About — FeatherReader"),
7898            "{ah}"
7899        );
7900        for (h, path) in [(lh, "/"), (ah, "/about")] {
7901            let url = format!("https://feather-reader.com{path}");
7902            assert_eq!(
7903                meta(h, "property=\"og:url\"").as_deref(),
7904                Some(url.as_str())
7905            );
7906            assert!(
7907                h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
7908                "{path} must carry a canonical link: {h}"
7909            );
7910            let image = meta(h, "property=\"og:image\"").unwrap_or_default();
7911            assert!(
7912                image.starts_with("https://feather-reader.com/static/"),
7913                "{path}: og:image must be absolute on the public origin, got {image:?}"
7914            );
7915            assert_eq!(
7916                meta(h, "name=\"twitter:card\"").as_deref(),
7917                Some("summary_large_image")
7918            );
7919            assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
7920            assert_eq!(
7921                meta(h, "property=\"og:site_name\"").as_deref(),
7922                Some("FeatherReader")
7923            );
7924            let description = meta(h, "property=\"og:description\"").unwrap_or_default();
7925            assert!(!description.is_empty(), "{path}: og:description is empty");
7926            assert_eq!(
7927                meta(h, "name=\"description\"").as_deref(),
7928                Some(description.as_str()),
7929                "{path}: the meta description and og:description must agree"
7930            );
7931        }
7932        assert_ne!(
7933            meta(lh, "property=\"og:description\""),
7934            meta(ah, "property=\"og:description\""),
7935            "the landing page and /about must not share a description"
7936        );
7937    }
7938
7939    /// The origin comes from `FEATHERREADER_PUBLIC_URL`, not a constant.
7940    #[tokio::test]
7941    async fn card_urls_follow_the_configured_public_url() {
7942        let db = store::init_url("sqlite::memory:").await.unwrap();
7943        store::ensure_seed(&db, &[]).await.unwrap();
7944        let config = Config {
7945            cookie_secret: "test-cookie-secret-000".to_string(),
7946            public_url: "https://reader.example.org".to_string(),
7947            ..Config::default()
7948        };
7949        let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
7950        let h = head(&body);
7951        assert_eq!(
7952            meta(h, "property=\"og:url\"").as_deref(),
7953            Some("https://reader.example.org/privacy")
7954        );
7955        assert_eq!(
7956            meta(h, "property=\"og:image\"").as_deref(),
7957            Some("https://reader.example.org/static/social-card.png")
7958        );
7959    }
7960
7961    /// Every signed-out page describes itself: no two share a description,
7962    /// and each `og:url` is its own path.
7963    #[tokio::test]
7964    async fn public_pages_each_carry_their_own_description() {
7965        let paths = [
7966            "/",
7967            "/about",
7968            "/privacy",
7969            "/terms",
7970            "/stats",
7971            "/standard-site",
7972            "/login",
7973            "/beta/redeem",
7974        ];
7975        let mut seen = std::collections::HashSet::new();
7976        for path in paths {
7977            let body = public_body(production_origin_state().await, path).await;
7978            let h = head(&body);
7979            let description = meta(h, "name=\"description\"").unwrap_or_default();
7980            assert!(!description.is_empty(), "{path} has no description: {h}");
7981            assert!(
7982                seen.insert(description.clone()),
7983                "{path} repeats another page's description: {description:?}"
7984            );
7985            assert_eq!(
7986                meta(h, "property=\"og:url\"").as_deref(),
7987                Some(format!("https://feather-reader.com{path}").as_str()),
7988                "{path}"
7989            );
7990            assert!(
7991                !h.contains("name=\"robots\""),
7992                "{path} is public and must not be noindex: {h}"
7993            );
7994        }
7995    }
7996
7997    /// The share image is served from `/static` as a PNG of the dimensions the
7998    /// tags promise, well under the 1 MB card fetchers tolerate, and cacheable.
7999    #[tokio::test]
8000    async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8001        let landing = public_body(production_origin_state().await, "/").await;
8002        let h = head(&landing);
8003        let image = meta(h, "property=\"og:image\"").unwrap();
8004        let path = image.strip_prefix("https://feather-reader.com").unwrap();
8005        let width: u32 = meta(h, "property=\"og:image:width\"")
8006            .unwrap()
8007            .parse()
8008            .unwrap();
8009        let height: u32 = meta(h, "property=\"og:image:height\"")
8010            .unwrap()
8011            .parse()
8012            .unwrap();
8013        assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8014        assert_eq!(
8015            meta(h, "property=\"og:image:type\"").as_deref(),
8016            Some("image/png")
8017        );
8018        assert!(
8019            !meta(h, "property=\"og:image:alt\"")
8020                .unwrap_or_default()
8021                .is_empty(),
8022            "the image needs alt text"
8023        );
8024
8025        let resp = router(production_origin_state().await)
8026            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8027            .await
8028            .unwrap();
8029        assert_eq!(resp.status(), StatusCode::OK, "{path}");
8030        assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8031        assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8032        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8033            .await
8034            .expect("the image is under 1 MB");
8035        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8036        // IHDR: width and height, big-endian, at offsets 16 and 20.
8037        let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8038        assert_eq!(
8039            (be(16), be(20)),
8040            (width, height),
8041            "the PNG's own dimensions must match the tags"
8042        );
8043    }
8044
8045    /// A page that renders a session's private view carries the site's generic
8046    /// card — nothing from the view reaches `<head>` — and is `noindex`.
8047    #[tokio::test]
8048    async fn private_pages_keep_user_data_out_of_the_card() {
8049        for path in ["/", "/manage"] {
8050            let state = production_origin_state().await;
8051            let body = signed_in_body(state, path, "did:plc:admin").await;
8052            let h = head(&body);
8053            assert!(
8054                h.contains("<meta name=\"robots\" content=\"noindex\""),
8055                "{path}: a private view must be noindex: {h}"
8056            );
8057            assert_eq!(
8058                meta(h, "property=\"og:title\"").as_deref(),
8059                Some("FeatherReader — read, quietly"),
8060                "{path}: the card of a private view is the site's generic one"
8061            );
8062            assert_eq!(
8063                meta(h, "property=\"og:url\"").as_deref(),
8064                Some("https://feather-reader.com/"),
8065                "{path}: og:url of a private view is the front door, not the private path"
8066            );
8067            for private in ["reader.example", "did:plc:admin"] {
8068                assert!(
8069                    !h.contains(private),
8070                    "{path}: {private:?} must not reach <head>: {h}"
8071                );
8072            }
8073        }
8074    }
8075
8076    #[tokio::test]
8077    async fn beta_redeem_page_renders() {
8078        let state = test_state(&[]).await;
8079        let app = router(state);
8080        let resp = app
8081            .oneshot(
8082                Request::builder()
8083                    .uri("/beta/redeem")
8084                    .body(Body::empty())
8085                    .unwrap(),
8086            )
8087            .await
8088            .unwrap();
8089        assert_eq!(resp.status(), StatusCode::OK);
8090        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8091            .await
8092            .unwrap();
8093        let html = String::from_utf8(bytes.to_vec()).unwrap();
8094        assert!(html.contains("Invite code"));
8095        assert!(html.contains("/beta/redeem"));
8096    }
8097
8098    #[tokio::test]
8099    async fn rate_limit_returns_429_after_burst() {
8100        // Configure a trusted proxy header so the limiter keys on the forwarded
8101        // IP (the oneshot harness sets no ConnectInfo socket peer).
8102        let db = store::init_url("sqlite::memory:").await.unwrap();
8103        store::ensure_seed(&db, &[]).await.unwrap();
8104        let config = Config {
8105            cookie_secret: "test-cookie-secret-000".to_string(),
8106            beta_cap: 3,
8107            trusted_ip_header: Some("cf-connecting-ip".to_string()),
8108            ..Config::default()
8109        };
8110        let state = AppState::new(config, db).unwrap();
8111        let app = router(state);
8112        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
8113        // handler itself returns 200 (re-render) on a bad code; the limiter is
8114        // what eventually yields 429.
8115        let mut saw_429 = false;
8116        for _ in 0..(RATE_BURST as usize + 5) {
8117            let resp = app
8118                .clone()
8119                .oneshot(
8120                    Request::builder()
8121                        .method("POST")
8122                        .uri("/beta/redeem")
8123                        .header("content-type", "application/x-www-form-urlencoded")
8124                        .header("cf-connecting-ip", "203.0.113.200")
8125                        .body(Body::from("code=FEATHER-NOPENOPE"))
8126                        .unwrap(),
8127                )
8128                .await
8129                .unwrap();
8130            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8131                saw_429 = true;
8132                break;
8133            }
8134        }
8135        assert!(saw_429, "expected a 429 after exhausting the burst");
8136    }
8137
8138    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
8139    /// the middleware's comment cites this test as proof of.
8140    ///
8141    /// The previous version rotated the forged header and asserted that no
8142    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
8143    /// burst, so that assertion held whether the header was trusted or
8144    /// ignored — it passed in the vulnerable configuration too. And with no
8145    /// socket peer the limiter fails open, so nothing could have been keyed on
8146    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
8147    /// a DIFFERENT forged header, and the last must be 429: they all landed in
8148    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
8149    /// per request and never trips — which is exactly what the mutation does.
8150    #[tokio::test]
8151    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8152        let state = test_state(&[]).await;
8153        assert!(
8154            state.config.trusted_ip_header.is_none(),
8155            "no proxy header is trusted here"
8156        );
8157        let app = router(state);
8158        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8159        let mut saw_429 = false;
8160        for i in 0..(RATE_BURST as usize + 5) {
8161            let forged = format!("10.9.8.{}", i % 250);
8162            let resp = app
8163                .clone()
8164                .oneshot(
8165                    Request::builder()
8166                        .method("POST")
8167                        .uri("/beta/redeem")
8168                        .header("content-type", "application/x-www-form-urlencoded")
8169                        .header("x-forwarded-for", forged)
8170                        .extension(axum::extract::ConnectInfo(peer))
8171                        .body(Body::from("code=FEATHER-NOPENOPE"))
8172                        .unwrap(),
8173                )
8174                .await
8175                .unwrap();
8176            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8177                saw_429 = true;
8178                break;
8179            }
8180        }
8181        assert!(
8182            saw_429,
8183            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8184        );
8185    }
8186
8187    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
8188
8189    /// **A private feed is refused BEFORE it is fetched.** The add path's
8190    /// privacy gate had no test at all — `private_feeds_are_classified_private_
8191    /// across_providers` says "the add + OPML paths both gate on this
8192    /// classifier" and nothing checked either. The gate exists so a
8193    /// token-bearing URL never reaches the network; the assertion that
8194    /// matters is the server's hit count: zero.
8195    #[tokio::test]
8196    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8197        let did = "did:plc:privateadder";
8198        let state = test_state_with_caps(did, 0, 0).await;
8199        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8200        let port: u16 = base
8201            .trim_end_matches('/')
8202            .rsplit(':')
8203            .next()
8204            .unwrap()
8205            .parse()
8206            .unwrap();
8207        crate::net::test_host_override(
8208            "private-add.test",
8209            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8210        );
8211        let cookie = session_cookie(&state, did, None);
8212        let resp = router(state.clone())
8213            .oneshot(
8214                Request::builder()
8215                    .method("POST")
8216                    .uri("/subscriptions")
8217                    .header(header::COOKIE, cookie)
8218                    .header("content-type", "application/x-www-form-urlencoded")
8219                    .body(Body::from(format!(
8220                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8221                    )))
8222                    .unwrap(),
8223            )
8224            .await
8225            .unwrap();
8226        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8227        let loc = resp
8228            .headers()
8229            .get(header::LOCATION)
8230            .unwrap()
8231            .to_str()
8232            .unwrap();
8233        assert!(loc.contains("Private"), "not refused as private: {loc}");
8234        assert_eq!(
8235            hits.load(std::sync::atomic::Ordering::SeqCst),
8236            0,
8237            "the private feed was FETCHED before being refused"
8238        );
8239        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8240    }
8241
8242    /// **OPML import skips a private feed without storing or publishing it.**
8243    /// The import path does not fetch, so "never fetched" is not the signal
8244    /// here; "never stored, never written to the PDS" is. The batch write's
8245    /// bytes are captured and must not carry the URL.
8246    #[tokio::test]
8247    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8248        let did = "did:plc:renamer4";
8249        let (sidecar, bodies) = spawn_logging_sidecar().await;
8250        let state = test_state_with_sidecar(&[did], &sidecar).await;
8251        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8252        let opml = format!(
8253            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8254             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8255             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8256             </body></opml>"
8257        );
8258        let (ct, body) = opml_multipart(opml.as_bytes());
8259        let cookie = session_cookie(&state, did, None);
8260        let resp = router(state.clone())
8261            .oneshot(
8262                Request::builder()
8263                    .method("POST")
8264                    .uri("/opml")
8265                    .header(header::COOKIE, cookie)
8266                    .header("content-type", ct)
8267                    .body(Body::from(body))
8268                    .unwrap(),
8269            )
8270            .await
8271            .unwrap();
8272        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8273        let loc = resp
8274            .headers()
8275            .get(header::LOCATION)
8276            .unwrap()
8277            .to_str()
8278            .unwrap();
8279        assert!(
8280            loc.contains("skipped%20as%20private"),
8281            "not reported as skipped: {loc}"
8282        );
8283        assert!(store::get_feed_by_url(&state.db, tokened)
8284            .await
8285            .unwrap()
8286            .is_none());
8287        let sent = bodies.lock().unwrap().join("\n");
8288        assert!(
8289            sent.contains("public.example"),
8290            "the public feed was not written: {sent}"
8291        );
8292        assert!(
8293            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8294            "the secret was PUBLISHED to the PDS: {sent}"
8295        );
8296    }
8297
8298    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
8299    /// tested; the GET form starts the same handshake and had no test, so
8300    /// deleting its gate left the suite green.
8301    #[tokio::test]
8302    async fn get_login_without_a_seat_is_refused() {
8303        let state = test_state(&[]).await;
8304        let resp = router(state)
8305            .oneshot(
8306                Request::builder()
8307                    .method("GET")
8308                    .uri("/login?handle=alice.bsky.social")
8309                    .body(Body::empty())
8310                    .unwrap(),
8311            )
8312            .await
8313            .unwrap();
8314        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8315        assert_eq!(
8316            resp.headers().get(header::LOCATION).unwrap(),
8317            "/beta/redeem"
8318        );
8319    }
8320
8321    /// A sidecar fake that answers every request `ok` and records the PATH of
8322    /// each in arrival order, plus every body — for asserting what was sent,
8323    /// and in what order.
8324    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8325        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8326        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8327        let addr = listener.local_addr().unwrap();
8328        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8329        let sink = log.clone();
8330        tokio::spawn(async move {
8331            loop {
8332                let Ok((mut sock, _)) = listener.accept().await else {
8333                    break;
8334                };
8335                let mut raw: Vec<u8> = Vec::new();
8336                let mut chunk = [0u8; 4096];
8337                let text = loop {
8338                    let Ok(n) = sock.read(&mut chunk).await else {
8339                        break String::new();
8340                    };
8341                    if n == 0 {
8342                        break String::from_utf8_lossy(&raw).to_string();
8343                    }
8344                    raw.extend_from_slice(&chunk[..n]);
8345                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8346                        continue;
8347                    };
8348                    let (head, body) = raw.split_at(split + 4);
8349                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8350                        let (k, v) = l.split_once(':')?;
8351                        k.eq_ignore_ascii_case("content-length")
8352                            .then(|| v.trim().parse::<usize>().ok())?
8353                    });
8354                    if want.is_none_or(|w| body.len() >= w) {
8355                        break String::from_utf8_lossy(&raw).to_string();
8356                    }
8357                };
8358                let path = text
8359                    .lines()
8360                    .next()
8361                    .and_then(|l| l.split_whitespace().nth(1))
8362                    .unwrap_or("")
8363                    .to_string();
8364                let body_text = text
8365                    .split_once("\r\n\r\n")
8366                    .map(|(_, b)| b)
8367                    .unwrap_or("")
8368                    .to_string();
8369                sink.lock().unwrap().push(format!("{path} {body_text}"));
8370                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();
8371                let resp = format!(
8372                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8373                    body.len(),
8374                    body
8375                );
8376                let _ = sock.write_all(resp.as_bytes()).await;
8377                let _ = sock.flush().await;
8378            }
8379        });
8380        (format!("http://{addr}"), log)
8381    }
8382
8383    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
8384    /// route.** The previous version of this test called
8385    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
8386    /// flush attempt; its doc claimed deleting the call from the handler
8387    /// "drops that to zero", which was false — the handler was never run.
8388    /// Deleting the call left the suite green: #117 regressing in full, with
8389    /// the test named after it still passing. Now `POST /logout` is driven and
8390    /// the sidecar's log must show a repo write BEFORE the revoke.
8391    #[tokio::test]
8392    async fn signing_out_flushes_before_it_revokes_through_the_route() {
8393        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8394        let (sidecar, log) = spawn_logging_sidecar().await;
8395        let state = test_state_with_sidecar(&[did], &sidecar).await;
8396        crate::store::upsert_cursor(
8397            &state.db,
8398            &crate::store::ReadCursor {
8399                did: did.to_string(),
8400                feed_url: "https://example.com/feed.xml".into(),
8401                read_through: None,
8402                read_ids: "[\"1\"]".into(),
8403                unread_ids: "[]".into(),
8404                dirty: true,
8405                pds_created: false,
8406                updated_at: "2026-09-13T21:22:40Z".into(),
8407            },
8408        )
8409        .await
8410        .unwrap();
8411        let cookie = session_cookie(&state, did, None);
8412        let resp = router(state.clone())
8413            .oneshot(
8414                Request::builder()
8415                    .method("POST")
8416                    .uri("/logout")
8417                    .header(header::COOKIE, cookie)
8418                    .body(Body::empty())
8419                    .unwrap(),
8420            )
8421            .await
8422            .unwrap();
8423        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8424
8425        let entries = log.lock().unwrap().clone();
8426        let flush = entries
8427            .iter()
8428            .position(|e| e.starts_with("/internal/repo "));
8429        let revoke = entries
8430            .iter()
8431            .position(|e| e.starts_with("/internal/revoke "));
8432        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8433        assert!(
8434            flush.is_some(),
8435            "sign-out did not attempt a flush before revoking: {entries:?}"
8436        );
8437        assert!(
8438            flush < revoke,
8439            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8440        );
8441    }
8442
8443    /// The policy, as a literal: the backstop the router calls "neutralises any
8444    /// XSS that slips past sanitization". `script-src 'self'` and no
8445    /// `'unsafe-inline'` on it are the two clauses that make it one.
8446    const EXPECTED_CSP: &str = "default-src 'self'; \
8447     script-src 'self'; \
8448     style-src 'self' 'unsafe-inline'; \
8449     img-src 'self' https: data:; \
8450     font-src 'self'; \
8451     connect-src 'self'; \
8452     form-action 'self'; \
8453     base-uri 'self'; \
8454     frame-ancestors 'none'; \
8455     object-src 'none'";
8456
8457    /// Build a `multipart/form-data` body carrying a single `file` field whose
8458    /// contents are `payload`, returning `(content_type, body_bytes)`.
8459    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
8460        let boundary = "----featherreadertestboundary";
8461        let mut body = Vec::new();
8462        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
8463        body.extend_from_slice(
8464            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
8465        );
8466        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
8467        body.extend_from_slice(payload);
8468        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
8469        (format!("multipart/form-data; boundary={boundary}"), body)
8470    }
8471
8472    #[tokio::test]
8473    async fn opml_import_oversize_upload_returns_413() {
8474        let state = test_state(&["did:plc:admin"]).await;
8475        let cookie = session_cookie(&state, "did:plc:admin", None);
8476        let app = router(state);
8477
8478        // A payload comfortably above the route cap.
8479        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
8480        let (content_type, body) = opml_multipart(&payload);
8481
8482        let resp = app
8483            .oneshot(
8484                Request::builder()
8485                    .method("POST")
8486                    .uri("/opml")
8487                    .header("content-type", content_type)
8488                    .header(header::COOKIE, cookie)
8489                    .body(Body::from(body))
8490                    .unwrap(),
8491            )
8492            .await
8493            .unwrap();
8494        assert_eq!(
8495            resp.status(),
8496            StatusCode::PAYLOAD_TOO_LARGE,
8497            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
8498        );
8499    }
8500
8501    /// **The route's own cap is what refuses this, not the framework's.**
8502    ///
8503    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
8504    /// the route's layer was a no-op — deleting it left every test green, and
8505    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
8506    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
8507    /// sits BETWEEN the two: over ours, under the framework's. Only the
8508    /// route's layer can refuse it — remove the layer and this payload is
8509    /// accepted, which is also what demonstrates the framework's default is
8510    /// the larger of the two.
8511    #[tokio::test]
8512    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
8513        let state = test_state(&["did:plc:admin"]).await;
8514        let cookie = session_cookie(&state, "did:plc:admin", None);
8515        let app = router(state);
8516
8517        // Between the two ceilings: the framework would accept this.
8518        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
8519        let (content_type, body) = opml_multipart(&payload);
8520
8521        let resp = app
8522            .oneshot(
8523                Request::builder()
8524                    .method("POST")
8525                    .uri("/opml")
8526                    .header("content-type", content_type)
8527                    .header(header::COOKIE, cookie)
8528                    .body(Body::from(body))
8529                    .unwrap(),
8530            )
8531            .await
8532            .unwrap();
8533        assert_eq!(
8534            resp.status(),
8535            StatusCode::PAYLOAD_TOO_LARGE,
8536            "a payload over the route's cap but under the framework's was accepted — \
8537             the route's own DefaultBodyLimit layer is not doing anything"
8538        );
8539    }
8540
8541    #[tokio::test]
8542    async fn opml_import_under_limit_upload_is_accepted() {
8543        let state = test_state(&["did:plc:admin"]).await;
8544        let cookie = session_cookie(&state, "did:plc:admin", None);
8545        let db = state.db.clone();
8546        let app = router(state);
8547
8548        // A small, valid OPML well under the cap: must be accepted (the handler
8549        // redirects to `/` or a flash), i.e. never 413.
8550        let opml = br#"<?xml version="1.0"?>
8551<opml version="2.0"><body>
8552  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
8553</body></opml>"#;
8554        let (content_type, body) = opml_multipart(opml);
8555
8556        let resp = app
8557            .oneshot(
8558                Request::builder()
8559                    .method("POST")
8560                    .uri("/opml")
8561                    .header("content-type", content_type)
8562                    .header(header::COOKIE, cookie)
8563                    .body(Body::from(body))
8564                    .unwrap(),
8565            )
8566            .await
8567            .unwrap();
8568        // **Assert it was ACCEPTED, not merely that it was not a 413.**
8569        //
8570        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
8571        // 500 satisfies — so making `import_opml` fail unconditionally left this
8572        // green. Three other OPML tests caught that mutation; the one whose name
8573        // promises to cover the under-cap case did not.
8574        assert_eq!(
8575            resp.status(),
8576            StatusCode::SEE_OTHER,
8577            "an under-cap OPML upload was not accepted (status {})",
8578            resp.status(),
8579        );
8580        // **303 alone is not acceptance.** `import_opml` redirects on several
8581        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
8582        // by a cap — so an import that stored nothing satisfied the status check.
8583        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
8584            .bind("https://example.com/feed.xml")
8585            .fetch_one(&db)
8586            .await
8587            .unwrap();
8588        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
8589        let location = resp
8590            .headers()
8591            .get(header::LOCATION)
8592            .and_then(|v| v.to_str().ok())
8593            .unwrap_or_default()
8594            .to_string();
8595        assert!(
8596            !location.starts_with("/login"),
8597            "the import bounced to login instead of being accepted: {location}",
8598        );
8599    }
8600
8601    #[tokio::test]
8602    async fn opml_import_logged_out_redirects_to_login() {
8603        // Logged-out callers are redirected before the body is consumed; assert
8604        // the auth short-circuit rather than a body-cap rejection.
8605        let state = test_state(&["did:plc:admin"]).await;
8606        let app = router(state);
8607
8608        let opml = b"<opml version=\"2.0\"><body></body></opml>";
8609        let (content_type, body) = opml_multipart(opml);
8610
8611        let resp = app
8612            .oneshot(
8613                Request::builder()
8614                    .method("POST")
8615                    .uri("/opml")
8616                    .header("content-type", content_type)
8617                    .body(Body::from(body))
8618                    .unwrap(),
8619            )
8620            .await
8621            .unwrap();
8622        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8623        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
8624    }
8625
8626    // -- delete-my-data (POST /account/delete) --------------------------------
8627
8628    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
8629    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
8630    /// channel) the DID it was asked to revoke. Enough to prove the delete
8631    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
8632    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
8633        use tokio::io::{AsyncReadExt, AsyncWriteExt};
8634        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8635        let addr = listener.local_addr().unwrap();
8636        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
8637        tokio::spawn(async move {
8638            let (mut sock, _) = listener.accept().await.unwrap();
8639            let mut buf = vec![0u8; 4096];
8640            let n = sock.read(&mut buf).await.unwrap();
8641            let req = String::from_utf8_lossy(&buf[..n]).to_string();
8642            // Pull the DID out of the JSON body (last line of the request).
8643            let did = req
8644                .split("\r\n\r\n")
8645                .nth(1)
8646                .and_then(|body| {
8647                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
8648                    v.get("did")?.as_str().map(str::to_string)
8649                })
8650                .unwrap_or_default();
8651            let is_revoke = req.starts_with("POST /internal/revoke");
8652            let body = serde_json::json!({
8653                "ok": true, "did": did, "revoked": true, "hadSession": true
8654            })
8655            .to_string();
8656            let resp = format!(
8657                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8658                body.len(),
8659                body
8660            );
8661            sock.write_all(resp.as_bytes()).await.unwrap();
8662            sock.flush().await.unwrap();
8663            let _ = tx.send(if is_revoke { did } else { String::new() });
8664        });
8665        (format!("http://{addr}"), rx)
8666    }
8667
8668    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
8669    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
8670        let defaults = Config::default();
8671        test_state_with_sidecar_and(
8672            allowed,
8673            sidecar_url,
8674            defaults.standard_site,
8675            defaults.max_feeds_global,
8676        )
8677        .await
8678    }
8679
8680    /// [`test_state_with_sidecar`] with the standard.site flag and the global
8681    /// feeds ceiling chosen — the two settings the at:// paths branch on.
8682    async fn test_state_with_sidecar_and(
8683        allowed: &[&str],
8684        sidecar_url: &str,
8685        standard_site: bool,
8686        max_feeds_global: i64,
8687    ) -> AppState {
8688        let db = store::init_url("sqlite::memory:").await.unwrap();
8689        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
8690        store::ensure_seed(&db, &dids).await.unwrap();
8691        let mut config = Config {
8692            allowed_dids: dids,
8693            cookie_secret: "test-cookie-secret-000".to_string(),
8694            beta_cap: 3,
8695            standard_site,
8696            max_feeds_global,
8697            ..Config::default()
8698        };
8699        config.sidecar.public_url = sidecar_url.to_string();
8700        config.sidecar.internal_url = sidecar_url.to_string();
8701        AppState::new(config, db).unwrap()
8702    }
8703
8704    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
8705    /// the sidecar revoke for that DID, and clears the session cookie.
8706    #[tokio::test]
8707    async fn account_delete_purges_rows_and_triggers_revoke() {
8708        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8709        let did = "did:plc:leaver";
8710        let state = test_state_with_sidecar(&[], &sidecar_url).await;
8711
8712        // Seed the DID with local rows across the per-DID tables.
8713        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8714            .await
8715            .unwrap();
8716        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8717        store::mint_code(&state.db, did, 3600).await.unwrap();
8718        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8719
8720        let cookie = session_cookie(&state, did, Some("leaver.example"));
8721        let app = router(state.clone());
8722
8723        let resp = app
8724            .oneshot(
8725                Request::builder()
8726                    .method("POST")
8727                    .uri("/account/delete")
8728                    .header(header::COOKIE, cookie)
8729                    .header("content-type", "application/x-www-form-urlencoded")
8730                    .body(Body::from("confirm=DELETE"))
8731                    .unwrap(),
8732            )
8733            .await
8734            .unwrap();
8735
8736        // Signed out: redirect to /login with the cookie cleared.
8737        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8738        assert!(resp
8739            .headers()
8740            .get(header::LOCATION)
8741            .unwrap()
8742            .to_str()
8743            .unwrap()
8744            .starts_with("/login"));
8745        let set_cookie = resp
8746            .headers()
8747            .get(header::SET_COOKIE)
8748            .unwrap()
8749            .to_str()
8750            .unwrap();
8751        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8752
8753        // The sidecar revoke was called for exactly this DID.
8754        //
8755        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
8756        // that simply never called the sidecar — hung this test forever instead
8757        // of failing it: a wedged CI job rather than a red one, which is the
8758        // worse of the two signals because nobody reads it as a defect.
8759        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8760            .await
8761            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8762            .unwrap();
8763        assert_eq!(
8764            revoked_did, did,
8765            "sidecar revoke must fire for the caller DID"
8766        );
8767
8768        // Local rows are gone.
8769        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8770        let codes: i64 =
8771            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8772                .bind(did)
8773                .fetch_one(&state.db)
8774                .await
8775                .unwrap();
8776        assert_eq!(codes, 0);
8777    }
8778
8779    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
8780    /// nothing and bounces back to /manage.
8781    #[tokio::test]
8782    async fn account_delete_without_confirm_is_a_noop() {
8783        let did = "did:plc:staying";
8784        let state = test_state(&[]).await;
8785        store::grant_access(&state.db, did, None, "test", None)
8786            .await
8787            .unwrap();
8788        let cookie = session_cookie(&state, did, None);
8789        let app = router(state.clone());
8790
8791        let resp = app
8792            .oneshot(
8793                Request::builder()
8794                    .method("POST")
8795                    .uri("/account/delete")
8796                    .header(header::COOKIE, cookie)
8797                    .header("content-type", "application/x-www-form-urlencoded")
8798                    .body(Body::from("confirm=nope"))
8799                    .unwrap(),
8800            )
8801            .await
8802            .unwrap();
8803
8804        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8805        assert!(resp
8806            .headers()
8807            .get(header::LOCATION)
8808            .unwrap()
8809            .to_str()
8810            .unwrap()
8811            .starts_with("/manage"));
8812        // Nothing deleted.
8813        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8814    }
8815
8816    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
8817    /// this harness — the default sidecar URL is not served), a DID must STILL
8818    /// be unable to read or mutate an entry in a feed it does not subscribe to.
8819    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
8820    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
8821    /// every cached feed.
8822    #[tokio::test]
8823    async fn pds_outage_does_not_widen_cross_did_access() {
8824        let did_a = "did:plc:aaaa";
8825        let state = test_state(&[]).await;
8826        store::grant_access(&state.db, did_a, None, "test", None)
8827            .await
8828            .unwrap();
8829
8830        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
8831        // lives in feed_b — the one A must never touch during the outage.
8832        let feed_a = store::upsert_feed(
8833            &state.db,
8834            &store::NewFeed {
8835                url: "https://a.example/feed.xml".to_string(),
8836                title: Some("A".to_string()),
8837                ..Default::default()
8838            },
8839        )
8840        .await
8841        .unwrap();
8842        let feed_b = store::upsert_feed(
8843            &state.db,
8844            &store::NewFeed {
8845                url: "https://b.example/feed.xml".to_string(),
8846                title: Some("B".to_string()),
8847                ..Default::default()
8848            },
8849        )
8850        .await
8851        .unwrap();
8852        store::insert_entries(
8853            &state.db,
8854            feed_b,
8855            &[store::NewEntry {
8856                guid: "b-1".to_string(),
8857                url: Some("https://b.example/1".to_string()),
8858                title: Some("B one".to_string()),
8859                published: Some("2026-07-11T00:00:00Z".to_string()),
8860                content_html: Some("<p>secret B body</p>".to_string()),
8861                ..Default::default()
8862            }],
8863            0,
8864        )
8865        .await
8866        .unwrap();
8867        // A subscribes ONLY to feed_a.
8868        store::replace_sub_refs(&state.db, did_a, &[feed_a])
8869            .await
8870            .unwrap();
8871        // Read B's entry id via a transient sub_ref, then drop it so only the
8872        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
8873        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8874            .await
8875            .unwrap();
8876        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8877            .await
8878            .unwrap()[0]
8879            .id;
8880        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8881            .await
8882            .unwrap();
8883
8884        let cookie = session_cookie(&state, did_a, None);
8885        let app = router(state.clone());
8886
8887        // GET /entries/{b} as A → 404 even during the outage.
8888        let get_b = app
8889            .clone()
8890            .oneshot(
8891                Request::builder()
8892                    .method("GET")
8893                    .uri(format!("/entries/{b_entry_id}"))
8894                    .header(header::COOKIE, cookie.clone())
8895                    .body(Body::empty())
8896                    .unwrap(),
8897            )
8898            .await
8899            .unwrap();
8900        assert_eq!(
8901            get_b.status(),
8902            StatusCode::NOT_FOUND,
8903            "A must not read B's entry during a PDS outage"
8904        );
8905
8906        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
8907        let read_b = app
8908            .oneshot(
8909                Request::builder()
8910                    .method("POST")
8911                    .uri(format!("/entries/{b_entry_id}/read"))
8912                    .header(header::COOKIE, cookie)
8913                    .header("content-type", "application/x-www-form-urlencoded")
8914                    .body(Body::from("read=true"))
8915                    .unwrap(),
8916            )
8917            .await
8918            .unwrap();
8919        assert_eq!(
8920            read_b.status(),
8921            StatusCode::NOT_FOUND,
8922            "A must not mark B's entry read during a PDS outage"
8923        );
8924
8925        // The fallback must NOT have widened A's sub_ref to feed_b.
8926        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
8927            .bind(did_a)
8928            .fetch_all(&state.db)
8929            .await
8930            .unwrap();
8931        assert_eq!(
8932            a_feed_ids,
8933            vec![feed_a],
8934            "outage fallback must not add feeds A never subscribed to"
8935        );
8936        // And B's entry has zero read-state (A's attempt did not mutate).
8937        let es_count: i64 =
8938            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
8939                .bind(did_a)
8940                .bind(b_entry_id)
8941                .fetch_one(&state.db)
8942                .await
8943                .unwrap();
8944        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
8945    }
8946
8947    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
8948    /// nothing. The other arm is counted separately.**
8949    ///
8950    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
8951    /// error would make the metric noisy in exactly the case that is fine.
8952    ///
8953    /// But `revoke_everywhere` has TWO arms, and a review found that counting
8954    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
8955    /// revocation failed. For anyone who logged in before the cutover the sidecar
8956    /// store is the only one holding tokens, so the rust arm correctly says
8957    /// NoSession and the metric said nothing was wrong. Both arms are now
8958    /// recorded, distinguished by the backend column — so this test pins the
8959    /// BACKEND as well as the outcome.
8960    #[tokio::test]
8961    async fn a_logout_with_no_session_counts_as_success() {
8962        let did = "did:plc:aaaa";
8963        let state = test_state(&[]).await;
8964        assert!(
8965            state.oauth.is_some(),
8966            "meaningless without an oauth runtime; the revoke arm would be skipped",
8967        );
8968
8969        revoke_everywhere(&state, did).await;
8970        let rows = state.metrics.snapshot();
8971        let find = |b: crate::metrics::Backend| {
8972            rows.iter()
8973                .find(|r| r.op == "oauth_revoke" && r.backend == b)
8974                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
8975        };
8976
8977        // Rust arm: nothing stored for this DID, so NoSession -> ok.
8978        let rust = find(crate::metrics::Backend::Rust);
8979        assert_eq!(
8980            rust.stats.err_count, 0,
8981            "NoSession was counted as a failure; logout is idempotent",
8982        );
8983        assert_eq!(rust.stats.ok_count, 1);
8984
8985        // Sidecar arm: unreachable in a test, so it must be recorded as an
8986        // ERROR under its own backend — not silently dropped, and not folded
8987        // into the rust row.
8988        let sidecar = find(crate::metrics::Backend::Sidecar);
8989        assert_eq!(
8990            sidecar.stats.err_count, 1,
8991            "a failed sidecar revoke was not counted",
8992        );
8993    }
8994
8995    /// **`Failed` must count as an error — the half the metric exists for.**
8996    ///
8997    /// A review found this unpinned: replacing the mapping with
8998    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
8999    /// asserted the `NoSession -> ok` half, so the branch that actually means
9000    /// "the PDS still holds tokens we asked it to drop" was untested.
9001    ///
9002    /// Driven through the same handler, with a session present but the PDS
9003    /// unreachable, so `sign_out_discovering` returns `Failed`.
9004    #[tokio::test]
9005    async fn a_failed_rust_revoke_counts_as_an_error() {
9006        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9007        let state = test_state(&[]).await;
9008        let runtime = state.oauth.as_deref().expect("oauth runtime");
9009        crate::oauth::store::put_session(
9010            &state.db,
9011            &runtime.codec,
9012            &crate::oauth::store::OAuthSession {
9013                sub: did.into(),
9014                issuer: "https://auth.invalid".into(),
9015                aud: "https://pds.invalid".into(),
9016                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9017                    .to_jwk_json()
9018                    .unwrap(),
9019                access_token: "at".into(),
9020                refresh_token: "rt".into(),
9021                token_type: "DPoP".into(),
9022                granted_scope: "atproto".into(),
9023                expires_at: Some(crate::store::now_unix() + 3600),
9024            },
9025        )
9026        .await
9027        .unwrap();
9028
9029        revoke_everywhere(&state, did).await;
9030
9031        let rows = state.metrics.snapshot();
9032        let rust = rows
9033            .iter()
9034            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9035            .expect("no rust oauth_revoke row");
9036        assert_eq!(
9037            rust.stats.err_count, 1,
9038            "an unreachable PDS must count as a revocation failure",
9039        );
9040        assert_eq!(rust.stats.ok_count, 0);
9041    }
9042
9043    /// **The `href` defence is now carried by the TYPE, not by remembering.**
9044    ///
9045    /// `EntryRow.link` used to be a `String`, and the guard was "call
9046    /// `net::safe_link` before assigning it". Deleting that call left all 679
9047    /// tests passing — a live XSS defence with nothing protecting it.
9048    ///
9049    /// `SafeLink` has no `From<String>` and no public member, so the only way to
9050    /// get foreign input into an `href` is `external`, which does the check
9051    /// itself. This test pins that constructor; the *wiring* is now pinned by
9052    /// the compiler, which is the part a test could never hold down.
9053    ///
9054    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
9055    /// so the template renders the row WITHOUT an anchor. Dropping the row
9056    /// instead would make the record unremovable, because the un-save button
9057    /// lives on it.
9058    #[test]
9059    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9060        for hostile in [
9061            "javascript:alert(1)",
9062            "JavaScript:alert(1)",
9063            "  javascript:alert(1)",
9064            "data:text/html;base64,PHNjcmlwdD4=",
9065            "vbscript:msgbox(1)",
9066            "file:///etc/passwd",
9067            // Protocol-relative: inherits the page's scheme, so it is an
9068            // off-site link wearing a same-site costume. Carried over from the
9069            // test this one replaces, which was its only unique input.
9070            "//evil.example/path",
9071        ] {
9072            let link = SafeLink::external(hostile);
9073            assert!(
9074                link.is_empty(),
9075                "{hostile:?} produced a non-empty href: {link}",
9076            );
9077            assert!(
9078                !link.to_string().to_ascii_lowercase().contains("script"),
9079                "{hostile:?} leaked into the rendered link",
9080            );
9081        }
9082
9083        // And the other direction: a check that rejects everything would satisfy
9084        // the loop above while breaking every real saved record.
9085        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9086            let link = SafeLink::external(good);
9087            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9088            assert_eq!(link.to_string(), good);
9089        }
9090    }
9091
9092    /// **The WIRING, not the helper — this is the one that catches the real
9093    /// mistake.**
9094    ///
9095    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
9096    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
9097    /// *calls* it, and a review proved that gap was live twice over: swapping
9098    /// `external` for the app-path constructor, and constructing the tuple
9099    /// directly, both restored the whole `javascript:` hole with every test
9100    /// green. The type now blocks both — `entry` takes an `i64`, and the field
9101    /// lives in another module — but the wiring deserves a test of its own
9102    /// rather than resting on the shape of a signature.
9103    ///
9104    /// Renders the actual row through the actual handler, from a record whose
9105    /// URL is hostile.
9106    #[tokio::test]
9107    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9108        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9109        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9110        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9111        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9112
9113        let resp = router(state)
9114            .oneshot(
9115                Request::builder()
9116                    .uri("/?view=starred")
9117                    .body(Body::empty())
9118                    .unwrap(),
9119            )
9120            .await
9121            .unwrap();
9122        assert_eq!(resp.status(), StatusCode::OK);
9123        let body = String::from_utf8(
9124            axum::body::to_bytes(resp.into_body(), usize::MAX)
9125                .await
9126                .unwrap()
9127                .to_vec(),
9128        )
9129        .unwrap();
9130
9131        // Not in an href, and not as the title either — the title falls back to
9132        // the URL for links we DO render, so both paths must withhold it.
9133        assert!(
9134            !body.to_ascii_lowercase().contains("javascript:"),
9135            "the hostile scheme reached the rendered page",
9136        );
9137        // But the row must survive: the un-save button lives on it, so dropping
9138        // the row would make the record unremovable from here.
9139        assert!(
9140            body.contains("unusable link"),
9141            "the row was dropped instead of rendering without an anchor",
9142        );
9143    }
9144
9145    /// **The reader view's two `href`s, through the actual handler.**
9146    ///
9147    /// The sibling above covers the LIST row. `entry.html` has its own pair of
9148    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
9149    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
9150    ///
9151    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
9152    /// this was never a live hole. But that guard is procedural and sits a long
9153    /// way from the `href`: it holds only as long as every future writer to
9154    /// `entries.url` remembers to go through `feed.rs`. This test does not
9155    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
9156    /// is precisely the state the ingest check cannot speak for.
9157    ///
9158    /// **Both directions, deliberately.** A fix that renders no link at all
9159    /// satisfies every negative assertion here, and would break every real
9160    /// entry. The second half is what makes the first half mean something.
9161    #[tokio::test]
9162    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9163        let did = "did:plc:readerhref";
9164        let state = test_state(&[]).await;
9165        store::grant_access(&state.db, did, None, "test", None)
9166            .await
9167            .unwrap();
9168        let feed = store::upsert_feed(
9169            &state.db,
9170            &store::NewFeed {
9171                url: "https://href.example/feed.xml".to_string(),
9172                title: Some("Href".to_string()),
9173                ..Default::default()
9174            },
9175        )
9176        .await
9177        .unwrap();
9178        // Straight into the column, bypassing `feed.rs` — the whole point.
9179        store::insert_entries(
9180            &state.db,
9181            feed,
9182            &[
9183                store::NewEntry {
9184                    guid: "hostile-1".to_string(),
9185                    url: Some("javascript:alert(1)".to_string()),
9186                    title: Some("Hostile entry".to_string()),
9187                    published: Some("2026-07-11T00:00:00Z".to_string()),
9188                    ..Default::default()
9189                },
9190                store::NewEntry {
9191                    guid: "benign-1".to_string(),
9192                    url: Some("https://href.example/post".to_string()),
9193                    title: Some("Benign entry".to_string()),
9194                    published: Some("2026-07-10T00:00:00Z".to_string()),
9195                    ..Default::default()
9196                },
9197            ],
9198            0,
9199        )
9200        .await
9201        .unwrap();
9202        store::replace_sub_refs(&state.db, did, &[feed])
9203            .await
9204            .unwrap();
9205        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9206        let id_of = |guid: &str| {
9207            rows.iter()
9208                .find(|r| r.guid == guid)
9209                .unwrap_or_else(|| panic!("{guid} was not inserted"))
9210                .id
9211        };
9212
9213        let cookie = session_cookie(&state, did, None);
9214        let app = router(state.clone());
9215
9216        let render = |id: i64| {
9217            let app = app.clone();
9218            let cookie = cookie.clone();
9219            async move {
9220                let resp = app
9221                    .oneshot(
9222                        Request::builder()
9223                            .method("GET")
9224                            .uri(format!("/entries/{id}"))
9225                            .header(header::COOKIE, cookie)
9226                            .body(Body::empty())
9227                            .unwrap(),
9228                    )
9229                    .await
9230                    .unwrap();
9231                assert_eq!(resp.status(), StatusCode::OK);
9232                String::from_utf8(
9233                    axum::body::to_bytes(resp.into_body(), usize::MAX)
9234                        .await
9235                        .unwrap()
9236                        .to_vec(),
9237                )
9238                .unwrap()
9239            }
9240        };
9241
9242        let hostile = render(id_of("hostile-1")).await;
9243        // The reader page for THIS entry actually rendered. Without this the
9244        // three negatives below are satisfied by an empty body.
9245        assert!(
9246            hostile.contains("Hostile entry"),
9247            "the reader did not render the entry: {hostile}",
9248        );
9249        assert!(
9250            !hostile.to_ascii_lowercase().contains("javascript:"),
9251            "the hostile scheme reached the reader page: {hostile}",
9252        );
9253        // Not merely escaped — the template took its no-link branch. Both
9254        // `href`s are gated on the same `Option`, so this covers the byline
9255        // link and the action-bar button together.
9256        assert!(
9257            !hostile.contains("actionbar-open"),
9258            "the action bar rendered an open-original link for a refused URL: {hostile}",
9259        );
9260        assert!(
9261            !hostile.contains("Original \u{2197}"),
9262            "the byline rendered an original link for a refused URL: {hostile}",
9263        );
9264
9265        // The other direction: a legitimate entry still links out, so "render
9266        // nothing" cannot pass as a fix.
9267        let benign = render(id_of("benign-1")).await;
9268        assert!(
9269            benign.contains("Benign entry"),
9270            "the reader did not render the benign entry: {benign}",
9271        );
9272        // BOTH `href`s, counted. The negatives above fire on the action bar
9273        // first, so without this the byline needle `Original \u{2197}` is never
9274        // once observed failing — a misspelled needle would pass forever.
9275        assert_eq!(
9276            benign
9277                .matches(r#"href="https://href.example/post""#)
9278                .count(),
9279            2,
9280            "entry.html has two `href`s for the entry URL — the byline link and \
9281             the action-bar button — and this render produced a different \
9282             number: {benign}",
9283        );
9284        assert!(
9285            benign.contains("actionbar-open"),
9286            "a legitimate entry lost its open-original button: {benign}",
9287        );
9288        assert!(
9289            benign.contains("Original \u{2197}"),
9290            "a legitimate entry lost its byline link: {benign}",
9291        );
9292    }
9293
9294    /// **The outage fallback must not widen what the caller can READ — and the
9295    /// sibling test above can only see what it WRITES.**
9296    ///
9297    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
9298    /// on `entry_state`: the fallback's side effects. But the fail-open it names
9299    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
9300    /// leaks through the list it *hands back* — the sidebar and the reader render
9301    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
9302    /// perfectly honest and every existing assertion stays green.
9303    ///
9304    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
9305    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
9306    /// exact historical bug the fallback's comment describes — left **all 663
9307    /// tests passing**. Cross-tenant isolation is the one property this project
9308    /// cannot regress quietly, and nothing observed it.
9309    ///
9310    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
9311    /// user, and it deliberately does not look at `sub_ref` at all — that half is
9312    /// already covered above.
9313    #[tokio::test]
9314    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9315        let did_a = "did:plc:aaaa";
9316        let state = test_state(&[]).await;
9317        store::grant_access(&state.db, did_a, None, "test", None)
9318            .await
9319            .unwrap();
9320
9321        let feed_a = store::upsert_feed(
9322            &state.db,
9323            &store::NewFeed {
9324                url: "https://a.example/feed.xml".to_string(),
9325                title: Some("A".to_string()),
9326                ..Default::default()
9327            },
9328        )
9329        .await
9330        .unwrap();
9331        let _feed_b = store::upsert_feed(
9332            &state.db,
9333            &store::NewFeed {
9334                url: "https://b.example/feed.xml".to_string(),
9335                title: Some("B".to_string()),
9336                ..Default::default()
9337            },
9338        )
9339        .await
9340        .unwrap();
9341        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
9342        // to nobody — exactly the row a whole-cache fallback would hand to A.
9343        store::replace_sub_refs(&state.db, did_a, &[feed_a])
9344            .await
9345            .unwrap();
9346
9347        // No sidecar and no PDS are reachable from a test, so
9348        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
9349        // that, rather than assuming it: if the repo ever starts succeeding here,
9350        // this test would silently stop exercising the fallback at all.
9351        assert!(
9352            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9353            "this test is only meaningful on the outage path; the repo answered",
9354        );
9355
9356        let resolved = resolve_subscriptions(&state, did_a).await;
9357
9358        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9359        assert_eq!(
9360            urls,
9361            vec!["https://a.example/feed.xml"],
9362            "the outage fallback must return the caller's OWN subscriptions only; \
9363             any other feed here is cross-tenant read access granted by an outage",
9364        );
9365    }
9366
9367    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
9368    /// seeding `did` a beta seat + session-capable state.
9369    async fn test_state_with_caps(
9370        did: &str,
9371        max_subs_per_did: i64,
9372        max_feeds_global: i64,
9373    ) -> AppState {
9374        let db = store::init_url("sqlite::memory:").await.unwrap();
9375        let config = Config {
9376            cookie_secret: "test-cookie-secret-000".to_string(),
9377            beta_cap: 100,
9378            max_subs_per_did,
9379            max_feeds_global,
9380            ..Config::default()
9381        };
9382        store::grant_access(&db, did, None, "test", None)
9383            .await
9384            .unwrap();
9385        AppState::new(config, db).unwrap()
9386    }
9387
9388    /// An OPML document with `n` distinct public feeds.
9389    fn opml_with_feeds(n: usize) -> String {
9390        let mut outlines = String::new();
9391        for i in 0..n {
9392            outlines.push_str(&format!(
9393                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9394            ));
9395        }
9396        format!(
9397            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9398        )
9399    }
9400
9401    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
9402    /// distinct new feeds than the shared cache can hold caches only up to the
9403    /// ceiling — the rest are trimmed. (Regression: the import loop previously
9404    /// bypassed `max_feeds_global` entirely.)
9405    #[tokio::test]
9406    async fn opml_import_enforces_global_feeds_ceiling() {
9407        let did = "did:plc:importer";
9408        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
9409        let state = test_state_with_caps(did, 0, 3).await;
9410        let cookie = session_cookie(&state, did, None);
9411        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9412        let app = router(state.clone());
9413
9414        let resp = app
9415            .oneshot(
9416                Request::builder()
9417                    .method("POST")
9418                    .uri("/opml")
9419                    .header(header::COOKIE, cookie)
9420                    .header("content-type", ct)
9421                    .body(Body::from(body))
9422                    .unwrap(),
9423            )
9424            .await
9425            .unwrap();
9426        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9427
9428        let feeds = store::count_feeds(&state.db).await.unwrap();
9429        assert!(
9430            feeds <= 3,
9431            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9432        );
9433    }
9434
9435    /// **A malformed `at://` on the add path is "not a kind of feed we take",
9436    /// not "private/paid".** The first gate was the privacy classifier, whose
9437    /// at:// arm fails closed as `Private` for anything not a well-formed
9438    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
9439    /// the private-feed flash and a "refused private/paid feed" log line. On
9440    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
9441    /// feed". Storability is decided first for an at:// input, with its own
9442    /// message.
9443    #[tokio::test]
9444    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
9445        let did = "did:plc:typoist";
9446        let state = test_state_with_caps(did, 0, 0).await;
9447        let cookie = session_cookie(&state, did, None);
9448        for input in [
9449            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
9450            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9451        ] {
9452            let resp = router(state.clone())
9453                .oneshot(
9454                    Request::builder()
9455                        .method("POST")
9456                        .uri("/subscriptions")
9457                        .header(header::COOKIE, cookie.clone())
9458                        .header("content-type", "application/x-www-form-urlencoded")
9459                        .body(Body::from(format!("url={input}")))
9460                        .unwrap(),
9461                )
9462                .await
9463                .unwrap();
9464            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9465            let loc = resp
9466                .headers()
9467                .get(header::LOCATION)
9468                .unwrap()
9469                .to_str()
9470                .unwrap();
9471            assert!(
9472                loc.contains("kind%20of%20feed"),
9473                "expected the unsupported-feed flash for {input}, got {loc}"
9474            );
9475            assert!(
9476                !loc.contains("Private"),
9477                "a storability refusal was reported as a privacy one for {input}: {loc}"
9478            );
9479        }
9480        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9481    }
9482
9483    /// **An OPML entry this instance cannot store is counted and reported, not
9484    /// silently dropped.** The storability `continue` incremented nothing,
9485    /// while the privacy branch beside it produced a user-visible label — so
9486    /// an OPML exported from a standard.site-enabled instance imported
9487    /// "successfully" with entries missing and no reason given. The reader is
9488    /// told how many, and why.
9489    #[tokio::test]
9490    async fn opml_import_reports_entries_this_instance_cannot_store() {
9491        let did = "did:plc:renamer4";
9492        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9493        let state = test_state_with_sidecar(&[did], &sidecar).await;
9494        assert!(!state.config.standard_site);
9495        let opml = format!(
9496            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9497             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9498             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9499             </body></opml>"
9500        );
9501        let (ct, body) = opml_multipart(opml.as_bytes());
9502        let cookie = session_cookie(&state, did, None);
9503        let resp = router(state.clone())
9504            .oneshot(
9505                Request::builder()
9506                    .method("POST")
9507                    .uri("/opml")
9508                    .header(header::COOKIE, cookie)
9509                    .header("content-type", ct)
9510                    .body(Body::from(body))
9511                    .unwrap(),
9512            )
9513            .await
9514            .unwrap();
9515        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9516        let loc = resp
9517            .headers()
9518            .get(header::LOCATION)
9519            .unwrap()
9520            .to_str()
9521            .unwrap();
9522        assert!(
9523            loc.contains("Imported%201%20feed"),
9524            "unexpected flash: {loc}"
9525        );
9526        assert!(
9527            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
9528            "the dropped entry was not reported: {loc}"
9529        );
9530        // Reported by count only: the at-URI itself is not echoed back.
9531        assert!(
9532            !loc.contains("site.standard.publication"),
9533            "the URI was echoed: {loc}"
9534        );
9535    }
9536
9537    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
9538    /// cap imports zero new feeds.
9539    #[tokio::test]
9540    async fn opml_import_enforces_per_did_cap() {
9541        let did = "did:plc:capped";
9542        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
9543        let state = test_state_with_caps(did, 2, 0).await;
9544        let existing_a = store::upsert_feed(
9545            &state.db,
9546            &store::NewFeed {
9547                url: "https://have-a.example/feed.xml".to_string(),
9548                ..Default::default()
9549            },
9550        )
9551        .await
9552        .unwrap();
9553        let existing_b = store::upsert_feed(
9554            &state.db,
9555            &store::NewFeed {
9556                url: "https://have-b.example/feed.xml".to_string(),
9557                ..Default::default()
9558            },
9559        )
9560        .await
9561        .unwrap();
9562        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
9563            .await
9564            .unwrap();
9565        let before = store::count_feeds(&state.db).await.unwrap();
9566
9567        let cookie = session_cookie(&state, did, None);
9568        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9569        let app = router(state.clone());
9570        let resp = app
9571            .oneshot(
9572                Request::builder()
9573                    .method("POST")
9574                    .uri("/opml")
9575                    .header(header::COOKIE, cookie)
9576                    .header("content-type", ct)
9577                    .body(Body::from(body))
9578                    .unwrap(),
9579            )
9580            .await
9581            .unwrap();
9582        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9583        // Headroom was 0 → no new feeds imported into the shared cache.
9584        let after = store::count_feeds(&state.db).await.unwrap();
9585        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
9586    }
9587
9588    /// Single-add per-DID cap: a DID at its subscription cap is refused before
9589    /// any fetch, with the limit flash.
9590    #[tokio::test]
9591    async fn single_add_enforces_per_did_cap() {
9592        let did = "did:plc:subcapped";
9593        let state = test_state_with_caps(did, 1, 0).await;
9594        let f = store::upsert_feed(
9595            &state.db,
9596            &store::NewFeed {
9597                url: "https://have.example/feed.xml".to_string(),
9598                ..Default::default()
9599            },
9600        )
9601        .await
9602        .unwrap();
9603        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
9604        let cookie = session_cookie(&state, did, None);
9605        let app = router(state.clone());
9606        let resp = app
9607            .oneshot(
9608                Request::builder()
9609                    .method("POST")
9610                    .uri("/subscriptions")
9611                    .header(header::COOKIE, cookie)
9612                    .header("content-type", "application/x-www-form-urlencoded")
9613                    .body(Body::from("url=https://another.example/feed.xml"))
9614                    .unwrap(),
9615            )
9616            .await
9617            .unwrap();
9618        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9619        let loc = resp
9620            .headers()
9621            .get(header::LOCATION)
9622            .unwrap()
9623            .to_str()
9624            .unwrap();
9625        assert!(
9626            loc.contains("Subscription%20limit%20reached"),
9627            "expected sub-limit flash, got {loc}"
9628        );
9629    }
9630
9631    /// `GET /` renders at most one page of rows and offers a way to the rest.
9632    ///
9633    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
9634    /// `LIMIT`, article bodies included — and hand the lot to the template. With
9635    /// 250 entries that is the whole list in one response; with a real backlog on
9636    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
9637    /// is capped, the heading still reports the true total, and page 2 is
9638    /// reachable and disjoint.
9639    #[tokio::test]
9640    async fn the_reader_index_pages_instead_of_rendering_everything() {
9641        let did = "did:plc:pager";
9642        let state = test_state(&[]).await;
9643        store::grant_access(&state.db, did, None, "test", None)
9644            .await
9645            .unwrap();
9646        let feed = store::upsert_feed(
9647            &state.db,
9648            &store::NewFeed {
9649                url: "https://pager.example/feed.xml".to_string(),
9650                title: Some("Pager".to_string()),
9651                ..Default::default()
9652            },
9653        )
9654        .await
9655        .unwrap();
9656        let total = 250_usize;
9657        let entries: Vec<store::NewEntry> = (0..total)
9658            .map(|i| store::NewEntry {
9659                guid: format!("p-{i:04}"),
9660                url: Some(format!("https://pager.example/{i}")),
9661                title: Some(format!("Article {i:04}")),
9662                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
9663                content_html: Some("x".repeat(4_000)),
9664                ..Default::default()
9665            })
9666            .collect();
9667        store::insert_entries(&state.db, feed, &entries, 0)
9668            .await
9669            .unwrap();
9670        store::replace_sub_refs(&state.db, did, &[feed])
9671            .await
9672            .unwrap();
9673
9674        let cookie = session_cookie(&state, did, None);
9675        let app = router(state.clone());
9676        let get = |uri: &str| {
9677            let app = app.clone();
9678            let cookie = cookie.clone();
9679            let uri = uri.to_string();
9680            async move {
9681                let resp = app
9682                    .oneshot(
9683                        Request::builder()
9684                            .uri(uri)
9685                            .header(header::COOKIE, cookie)
9686                            .body(Body::empty())
9687                            .unwrap(),
9688                    )
9689                    .await
9690                    .unwrap();
9691                assert_eq!(resp.status(), StatusCode::OK);
9692                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
9693                    .await
9694                    .unwrap();
9695                String::from_utf8(bytes.to_vec()).unwrap()
9696            }
9697        };
9698
9699        let page1 = get("/").await;
9700        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
9701        // over-count: each row carries several (the link plus the read/star
9702        // forms).
9703        let rows1 = page1.matches("<li class=\"entry").count();
9704        assert!(
9705            rows1 <= ENTRIES_PER_PAGE as usize,
9706            "page 1 rendered {rows1} entry links; the list is unbounded"
9707        );
9708        assert!(
9709            rows1 > 0,
9710            "page 1 rendered nothing at all: the page bound swallowed the list"
9711        );
9712        // The count is the TRUE total, not the page size — otherwise paging
9713        // would quietly relabel a 250-entry backlog as a 100-entry one.
9714        assert!(
9715            page1.contains("250 entries"),
9716            "heading must report the full total, not the page"
9717        );
9718        assert!(
9719            page1.contains("page=2"),
9720            "no way to reach the rest of the list: {}",
9721            &page1[..page1.len().min(400)]
9722        );
9723        // The body never belongs in a list response.
9724        assert!(
9725            !page1.contains(&"x".repeat(4_000)),
9726            "the list response carried an article body"
9727        );
9728
9729        let page2 = get("/?page=2").await;
9730        assert!(
9731            page2.matches("<li class=\"entry").count() > 0,
9732            "page 2 rendered no rows at all"
9733        );
9734        assert!(
9735            page2.contains("page=1") || page2.contains("Newer"),
9736            "page 2 offers no way back"
9737        );
9738        // Disjoint: an article on page 1 must not reappear on page 2.
9739        let first_title = (0..total)
9740            .map(|i| format!("Article {i:04}"))
9741            .find(|t| page1.contains(t))
9742            .expect("page 1 shows at least one titled article");
9743        assert!(
9744            !page2.contains(&first_title),
9745            "{first_title} appears on both pages"
9746        );
9747
9748        // A page past the end must not be a dead end. The empty state renders
9749        // instead of the pager, so an out-of-range page would leave a reader
9750        // with no link back — reachable by typing a number, and reachable
9751        // WITHOUT typing anything by paging to the end and then marking entries
9752        // read, which shrinks the list under the URL already in the address bar.
9753        let past_end = get("/?page=999").await;
9754        assert!(
9755            past_end.matches("<li class=\"entry").count() > 0,
9756            "an out-of-range page rendered nothing and offered no way back"
9757        );
9758        assert!(
9759            past_end.contains("page=2"),
9760            "the clamped page offers no pager"
9761        );
9762    }
9763
9764    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
9765    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
9766    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
9767    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
9768    /// view (no reader header) instead swaps the row. This guards the reader OOB
9769    /// toggle wiring, which had no test.
9770    #[tokio::test]
9771    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9772        let did = "did:plc:reader";
9773        let state = test_state(&[]).await;
9774        store::grant_access(&state.db, did, None, "test", None)
9775            .await
9776            .unwrap();
9777        let feed = store::upsert_feed(
9778            &state.db,
9779            &store::NewFeed {
9780                url: "https://reader.example/feed.xml".to_string(),
9781                title: Some("Reader".to_string()),
9782                ..Default::default()
9783            },
9784        )
9785        .await
9786        .unwrap();
9787        store::insert_entries(
9788            &state.db,
9789            feed,
9790            &[store::NewEntry {
9791                guid: "r-1".to_string(),
9792                url: Some("https://reader.example/1".to_string()),
9793                title: Some("Article".to_string()),
9794                published: Some("2026-07-11T00:00:00Z".to_string()),
9795                content_html: Some("<p>body</p>".to_string()),
9796                ..Default::default()
9797            }],
9798            0,
9799        )
9800        .await
9801        .unwrap();
9802        store::replace_sub_refs(&state.db, did, &[feed])
9803            .await
9804            .unwrap();
9805        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9806
9807        let cookie = session_cookie(&state, did, None);
9808        let app = router(state.clone());
9809
9810        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
9811        let resp = app
9812            .clone()
9813            .oneshot(
9814                Request::builder()
9815                    .method("POST")
9816                    .uri(format!("/entries/{entry_id}/read"))
9817                    .header(header::COOKIE, cookie.clone())
9818                    .header("HX-Request", "true")
9819                    .header("X-FR-Reader", "1")
9820                    .header("content-type", "application/x-www-form-urlencoded")
9821                    .body(Body::from("read=true"))
9822                    .unwrap(),
9823            )
9824            .await
9825            .unwrap();
9826        assert_eq!(resp.status(), StatusCode::OK);
9827        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9828            .await
9829            .unwrap();
9830        let html = String::from_utf8(bytes.to_vec()).unwrap();
9831        assert!(
9832            html.contains("hx-swap-oob=\"outerHTML\""),
9833            "reader response must be an OOB swap: {html}"
9834        );
9835        assert!(
9836            html.contains(r#"id="entry-actionbar""#),
9837            "reader response must be the action-bar fragment: {html}"
9838        );
9839        // Now READ: the read button reflects it (aria-pressed=true) and the
9840        // hidden value flips to `false` so the next tap marks it UNREAD.
9841        assert!(
9842            html.contains(r#"aria-pressed="true""#),
9843            "read button must show pressed after marking read: {html}"
9844        );
9845        assert!(
9846            html.contains(r#"name="read" value="false""#),
9847            "hidden read value must flip to false so a second tap reverses: {html}"
9848        );
9849
9850        // A second reader mark-read (submitting the flipped `read=false`) marks
9851        // it UNREAD again — the toggle reverses.
9852        let resp2 = app
9853            .oneshot(
9854                Request::builder()
9855                    .method("POST")
9856                    .uri(format!("/entries/{entry_id}/read"))
9857                    .header(header::COOKIE, cookie)
9858                    .header("HX-Request", "true")
9859                    .header("X-FR-Reader", "1")
9860                    .header("content-type", "application/x-www-form-urlencoded")
9861                    .body(Body::from("read=false"))
9862                    .unwrap(),
9863            )
9864            .await
9865            .unwrap();
9866        assert_eq!(resp2.status(), StatusCode::OK);
9867        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
9868            .await
9869            .unwrap();
9870        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
9871        assert!(
9872            html2.contains(r#"aria-pressed="false""#),
9873            "read button must show un-pressed after reversing: {html2}"
9874        );
9875        assert!(
9876            html2.contains(r#"name="read" value="true""#),
9877            "hidden read value must flip back to true: {html2}"
9878        );
9879    }
9880
9881    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
9882    /// action-bar — the counterpart to the reader-OOB test above.
9883    #[tokio::test]
9884    async fn list_mark_read_returns_row_not_oob_actionbar() {
9885        let did = "did:plc:listv";
9886        let state = test_state(&[]).await;
9887        store::grant_access(&state.db, did, None, "test", None)
9888            .await
9889            .unwrap();
9890        let feed = store::upsert_feed(
9891            &state.db,
9892            &store::NewFeed {
9893                url: "https://list.example/feed.xml".to_string(),
9894                title: Some("List".to_string()),
9895                ..Default::default()
9896            },
9897        )
9898        .await
9899        .unwrap();
9900        store::insert_entries(
9901            &state.db,
9902            feed,
9903            &[store::NewEntry {
9904                guid: "l-1".to_string(),
9905                url: Some("https://list.example/1".to_string()),
9906                title: Some("Article".to_string()),
9907                published: Some("2026-07-11T00:00:00Z".to_string()),
9908                ..Default::default()
9909            }],
9910            0,
9911        )
9912        .await
9913        .unwrap();
9914        store::replace_sub_refs(&state.db, did, &[feed])
9915            .await
9916            .unwrap();
9917        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9918
9919        let cookie = session_cookie(&state, did, None);
9920        let app = router(state.clone());
9921
9922        let resp = app
9923            .oneshot(
9924                Request::builder()
9925                    .method("POST")
9926                    .uri(format!("/entries/{entry_id}/read"))
9927                    .header(header::COOKIE, cookie)
9928                    .header("HX-Request", "true")
9929                    .header("content-type", "application/x-www-form-urlencoded")
9930                    .body(Body::from("read=true"))
9931                    .unwrap(),
9932            )
9933            .await
9934            .unwrap();
9935        assert_eq!(resp.status(), StatusCode::OK);
9936        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9937            .await
9938            .unwrap();
9939        let html = String::from_utf8(bytes.to_vec()).unwrap();
9940        assert!(
9941            !html.contains("hx-swap-oob"),
9942            "list-view response must NOT be an OOB swap: {html}"
9943        );
9944        // **And it must actually BE the row.** The assertion above is satisfied
9945        // by an empty body, or by any response that simply omits the attribute —
9946        // so on its own it pins half a property and the name promises the other
9947        // half.
9948        assert!(
9949            html.contains(&format!("/entries/{entry_id}")),
9950            "the response is not the row for this entry: {html}",
9951        );
9952        assert!(
9953            html.contains("Article"),
9954            "the row rendered without its title: {html}",
9955        );
9956        // **The row comes back carrying read state. That is all this proves.**
9957        //
9958        // It does NOT prove the state was persisted: the handler renders
9959        // `Some(read)` from the form value, so making `mark_read` roll back
9960        // instead of commit fails 11 store tests and leaves this one green.
9961        //
9962        // It does not prove the OVERRIDE either, which an earlier version of
9963        // this comment claimed. Verified: changing the call site to
9964        // `build_entry_row(pool, &did, id, None)` — deleting the override
9965        // wholesale — keeps the whole suite green, because `mark_read` has
9966        // already persisted the same value two lines earlier, so reading it back
9967        // from the database produces an identical row.
9968        //
9969        // Distinguishing the two needs a case where the override and the stored
9970        // state DISAGREE, which this handler never produces: it writes the value
9971        // it then renders. Left as a known gap rather than described as covered.
9972        assert!(
9973            html.contains("is-read"),
9974            "the row came back without the read state it was just given: {html}",
9975        );
9976    }
9977
9978    // -----------------------------------------------------------------------
9979    // Rename parity (POST /subscriptions/{rkey}/rename)
9980    // -----------------------------------------------------------------------
9981
9982    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
9983    ///
9984    /// The add path gates the URL the user *typed*; the URL it *stores* is
9985    /// whatever `resolve_feed_url` returns, which for an HTML page is a
9986    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
9987    /// that: `discover_feed` yields only http(s), and the add path re-checks
9988    /// storability on the resolved URL. This test pins the DISJUNCTION —
9989    /// each layer alone holds it, both removed fails it — driven through the
9990    /// real route against a real local server.
9991    ///
9992    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
9993    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
9994    /// form: once storage became DID-only the privacy classifier refused it
9995    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
9996    /// — the colons in the DID), so `discover_feed` drops it before either
9997    /// layer exists. An at:// link cannot come out of autodiscovery under
9998    /// ANY mutation of the layers, so no test through this route can pin
9999    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
10000    /// structure and pinned where it lives: `discover_skips_a_non_http_
10001    /// alternate` and the storability tests in `feed.rs`.
10002    #[tokio::test]
10003    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10004        let did = "did:plc:autodiscovered";
10005        // Access granted, both caps disabled — the only gates left are the
10006        // two under test.
10007        let state = test_state_with_caps(did, 0, 0).await;
10008
10009        let page = r#"<!doctype html><html><head><title>Blog</title>
10010            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10011            </head><body>hi</body></html>"#;
10012        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10013        let port: u16 = base
10014            .trim_end_matches('/')
10015            .rsplit(':')
10016            .next()
10017            .unwrap()
10018            .parse()
10019            .unwrap();
10020        crate::net::test_host_override(
10021            "autodiscover-ftp.test",
10022            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10023        );
10024
10025        let cookie = session_cookie(&state, did, None);
10026        let resp = router(state.clone())
10027            .oneshot(
10028                Request::builder()
10029                    .method("POST")
10030                    .uri("/subscriptions")
10031                    .header(header::COOKIE, cookie)
10032                    .header("content-type", "application/x-www-form-urlencoded")
10033                    .body(Body::from(format!(
10034                        "url=http://autodiscover-ftp.test:{port}/"
10035                    )))
10036                    .unwrap(),
10037            )
10038            .await
10039            .unwrap();
10040        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10041        let loc = resp
10042            .headers()
10043            .get(header::LOCATION)
10044            .unwrap()
10045            .to_str()
10046            .unwrap();
10047        assert_ne!(loc, "/login", "the test never reached the add path");
10048        assert_ne!(loc, "/", "the subscribe succeeded");
10049
10050        assert_eq!(
10051            store::count_feeds(&state.db).await.unwrap(),
10052            0,
10053            "a non-http(s) URL from autodiscovery was stored"
10054        );
10055        assert_eq!(
10056            store::count_subscriptions_for_did(&state.db, did)
10057                .await
10058                .unwrap(),
10059            0
10060        );
10061    }
10062
10063    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
10064    /// its global ceiling must be refused (capacity flash) and must NOT insert a
10065    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
10066    /// rename loop can't inflate the shared cache past the cap.
10067    #[tokio::test]
10068    async fn rename_to_new_url_refused_at_global_feeds_cap() {
10069        let did = "did:plc:renamer4";
10070        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10071        // Global cap 1; pre-fill it with one feed so headroom is 0.
10072        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10073        store::upsert_feed(
10074            &state.db,
10075            &store::NewFeed {
10076                url: "https://existing.example/feed.xml".to_string(),
10077                ..Default::default()
10078            },
10079        )
10080        .await
10081        .unwrap();
10082        let before = store::count_feeds(&state.db).await.unwrap();
10083        assert_eq!(before, 1);
10084
10085        let cookie = session_cookie(&state, did, None);
10086        let resp = router(state.clone())
10087            .oneshot(
10088                Request::builder()
10089                    .method("POST")
10090                    .uri("/subscriptions/rk-keep/rename")
10091                    .header(header::COOKIE, cookie)
10092                    .header("content-type", "application/x-www-form-urlencoded")
10093                    // A URL not in the cache → would be a NEW feeds row.
10094                    .body(Body::from(
10095                        "url=https://brand-new.example/feed.xml&title=Renamed",
10096                    ))
10097                    .unwrap(),
10098            )
10099            .await
10100            .unwrap();
10101        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10102        let loc = resp
10103            .headers()
10104            .get(header::LOCATION)
10105            .unwrap()
10106            .to_str()
10107            .unwrap();
10108        assert!(
10109            loc.contains("feed%20capacity"),
10110            "expected the feed-capacity flash, got {loc}"
10111        );
10112        // No new feeds row was inserted, and nothing reached the PDS.
10113        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10114        assert!(
10115            puts.lock().unwrap().is_empty(),
10116            "a refused repoint reached the PDS"
10117        );
10118    }
10119
10120    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
10121    /// global cap (only new URLs are gated) — the other half of the guard.
10122    ///
10123    /// On the sidecar fake, so "allowed" means the put actually happened: the
10124    /// earlier harness had no sidecar, and this passed on a "could not reach
10125    /// your PDS" flash that merely was not the capacity one.
10126    #[tokio::test]
10127    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10128        let did = "did:plc:renamer4";
10129        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10130        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10131        store::upsert_feed(
10132            &state.db,
10133            &store::NewFeed {
10134                url: "https://existing.example/feed.xml".to_string(),
10135                ..Default::default()
10136            },
10137        )
10138        .await
10139        .unwrap();
10140        let before = store::count_feeds(&state.db).await.unwrap();
10141
10142        let cookie = session_cookie(&state, did, None);
10143        let resp = router(state.clone())
10144            .oneshot(
10145                Request::builder()
10146                    .method("POST")
10147                    .uri("/subscriptions/rk-keep/rename")
10148                    .header(header::COOKIE, cookie)
10149                    .header("content-type", "application/x-www-form-urlencoded")
10150                    .body(Body::from(
10151                        "url=https://existing.example/feed.xml&title=Retitled",
10152                    ))
10153                    .unwrap(),
10154            )
10155            .await
10156            .unwrap();
10157        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10158        let loc = resp
10159            .headers()
10160            .get(header::LOCATION)
10161            .unwrap()
10162            .to_str()
10163            .unwrap();
10164        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10165        assert_eq!(
10166            puts.lock().unwrap().len(),
10167            1,
10168            "the repoint did not reach the PDS"
10169        );
10170        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10171    }
10172
10173    /// A rename with a blank URL writes nothing anywhere.
10174    #[tokio::test]
10175    async fn rename_with_blank_url_writes_nothing() {
10176        let did = "did:plc:renamer3";
10177        let state = test_state_with_caps(did, 0, 0).await;
10178        let before = store::count_feeds(&state.db).await.unwrap();
10179        assert_eq!(before, 0);
10180
10181        let cookie = session_cookie(&state, did, None);
10182        let app = router(state.clone());
10183        let resp = app
10184            .oneshot(
10185                Request::builder()
10186                    .method("POST")
10187                    .uri("/subscriptions/rkey123/rename")
10188                    .header(header::COOKIE, cookie)
10189                    .header("content-type", "application/x-www-form-urlencoded")
10190                    // Whitespace-only URL trims to empty.
10191                    .body(Body::from("url=%20%20&title=Nope"))
10192                    .unwrap(),
10193            )
10194            .await
10195            .unwrap();
10196        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10197        assert_eq!(
10198            resp.headers()
10199                .get(header::LOCATION)
10200                .unwrap()
10201                .to_str()
10202                .unwrap(),
10203            "/",
10204        );
10205        // Nothing was cached.
10206        assert_eq!(
10207            store::count_feeds(&state.db).await.unwrap(),
10208            0,
10209            "blank-URL rename wrote a junk feeds row"
10210        );
10211    }
10212
10213    /// A sidecar mock that serves ONE existing subscription record and captures
10214    /// every `put` body a rename produces.
10215    ///
10216    /// **Reads to `content-length` rather than taking one `read`.** A single
10217    /// read gets whatever one segment carried; if the head and body land
10218    /// separately the capture holds no record and every field assertion below
10219    /// passes for the wrong reason. Each captured body must also mention the
10220    /// collection, so an empty capture fails loudly instead of quietly.
10221    async fn spawn_rename_sidecar(
10222        existing: serde_json::Value,
10223    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10224        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10225        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10226        let addr = listener.local_addr().unwrap();
10227        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10228        let sink = puts.clone();
10229        tokio::spawn(async move {
10230            loop {
10231                let Ok((mut sock, _)) = listener.accept().await else {
10232                    break;
10233                };
10234                let mut raw: Vec<u8> = Vec::new();
10235                let mut chunk = [0u8; 4096];
10236                let body_text = loop {
10237                    let Ok(n) = sock.read(&mut chunk).await else {
10238                        break String::new();
10239                    };
10240                    if n == 0 {
10241                        break String::from_utf8_lossy(&raw).to_string();
10242                    }
10243                    raw.extend_from_slice(&chunk[..n]);
10244                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10245                        continue;
10246                    };
10247                    let (head, body) = raw.split_at(split + 4);
10248                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10249                        let (k, v) = l.split_once(':')?;
10250                        k.eq_ignore_ascii_case("content-length")
10251                            .then(|| v.trim().parse::<usize>().ok())?
10252                    });
10253                    if want.is_none_or(|want| body.len() >= want) {
10254                        break String::from_utf8_lossy(body).to_string();
10255                    }
10256                };
10257
10258                // `"action":"put"` is the rename write; anything else is the read.
10259                let is_put = body_text.contains("\"action\":\"put\"");
10260                let data = if is_put {
10261                    sink.lock().unwrap().push(body_text.clone());
10262                    serde_json::json!({
10263                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10264                        "cid": "bafyreiafter"
10265                    })
10266                } else {
10267                    serde_json::json!({ "records": [existing.clone()] })
10268                };
10269                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10270                let resp = format!(
10271                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10272                    body.len(),
10273                    body
10274                );
10275                let _ = sock.write_all(resp.as_bytes()).await;
10276                let _ = sock.flush().await;
10277            }
10278        });
10279        (format!("http://{addr}"), puts)
10280    }
10281
10282    /// The existing record a rename must not destroy.
10283    fn seeded_subscription() -> serde_json::Value {
10284        serde_json::json!({
10285            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10286            "cid": "bafyreibefore",
10287            "value": {
10288                "$type": "community.lexicon.rss.subscription",
10289                "url": "https://example.com/feed.xml",
10290                "title": "Old title",
10291                "siteUrl": "https://example.com/blog",
10292                "fetchHint": "hourly",
10293                "private": false,
10294                "createdAt": "2024-03-01T00:00:00.000Z"
10295            }
10296        })
10297    }
10298
10299    /// An existing standard.site subscription, as the 19 in production are:
10300    /// written before this reader refused the scheme, still in the repo.
10301    fn seeded_at_uri_subscription() -> serde_json::Value {
10302        seeded_subscription_with_url(AT_URI_SUB)
10303    }
10304    /// An existing subscription record at `rk-keep` with the given URL.
10305    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10306        serde_json::json!({
10307            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10308            "cid": "bafyreibefore",
10309            "value": {
10310                "$type": "community.lexicon.rss.subscription",
10311                "url": url,
10312                "title": "Old title",
10313                "private": false,
10314                "createdAt": "2024-03-01T00:00:00.000Z"
10315            }
10316        })
10317    }
10318    const AT_URI_SUB: &str =
10319        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10320    const AT_URI_SUB_ENC: &str =
10321        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10322
10323    /// **Retitling an existing `at://` subscription must work with the flag off.**
10324    ///
10325    /// The storability guard was placed before the repo lookup, so it refused
10326    /// any rename whose URL is an at-URI — including a pure title or folder
10327    /// change on a record that already exists. On main that rename succeeded;
10328    /// the 19 production records would have become un-editable. The flag gates
10329    /// what may be STORED in the cache, not whether a reader may edit their own
10330    /// record: the PDS write goes through, the cache row is simply not created.
10331    #[tokio::test]
10332    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10333        let did = "did:plc:renamer5";
10334        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10335        let state = test_state_with_sidecar(&[did], &sidecar).await;
10336        assert!(
10337            !state.config.standard_site,
10338            "the flag must be off for this test"
10339        );
10340        let cookie = session_cookie(&state, did, None);
10341        let resp = router(state.clone())
10342            .oneshot(
10343                Request::builder()
10344                    .method("POST")
10345                    .uri("/subscriptions/rk-keep/rename")
10346                    .header(header::COOKIE, cookie)
10347                    .header("content-type", "application/x-www-form-urlencoded")
10348                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10349                    .unwrap(),
10350            )
10351            .await
10352            .unwrap();
10353        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10354        let loc = resp
10355            .headers()
10356            .get(header::LOCATION)
10357            .unwrap()
10358            .to_str()
10359            .unwrap();
10360        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10361
10362        let bodies = puts.lock().unwrap().clone();
10363        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10364        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10365        assert_eq!(
10366            sent["record"]["title"], "New title",
10367            "the rename did not apply"
10368        );
10369        assert_eq!(
10370            sent["record"]["url"], AT_URI_SUB,
10371            "the rename changed the URL"
10372        );
10373
10374        // The flag still means what it says for the CACHE: no at:// row.
10375        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10376        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10377    }
10378
10379    /// **Repointing a subscription AT an `at://` URI is still refused with the
10380    /// flag off** — the half of the guard that has to survive the fix above.
10381    /// Nothing reaches the PDS and nothing reaches the cache.
10382    #[tokio::test]
10383    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
10384        let did = "did:plc:renamer4";
10385        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10386        let state = test_state_with_sidecar(&[did], &sidecar).await;
10387        let cookie = session_cookie(&state, did, None);
10388        let resp = router(state.clone())
10389            .oneshot(
10390                Request::builder()
10391                    .method("POST")
10392                    .uri("/subscriptions/rk-keep/rename")
10393                    .header(header::COOKIE, cookie)
10394                    .header("content-type", "application/x-www-form-urlencoded")
10395                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10396                    .unwrap(),
10397            )
10398            .await
10399            .unwrap();
10400        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10401        let loc = resp
10402            .headers()
10403            .get(header::LOCATION)
10404            .unwrap()
10405            .to_str()
10406            .unwrap();
10407        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
10408        assert!(
10409            !loc.contains("Private"),
10410            "a storability refusal was reported as a privacy one: {loc}"
10411        );
10412        assert!(
10413            puts.lock().unwrap().is_empty(),
10414            "the repoint reached the PDS"
10415        );
10416        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10417        assert_eq!(cached, 0);
10418    }
10419
10420    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
10421    /// redirect location.
10422    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
10423        let cookie = session_cookie(state, did, None);
10424        let resp = router(state.clone())
10425            .oneshot(
10426                Request::builder()
10427                    .method("POST")
10428                    .uri("/subscriptions/rk-keep/rename")
10429                    .header(header::COOKIE, cookie)
10430                    .header("content-type", "application/x-www-form-urlencoded")
10431                    .body(Body::from(format!("url={url_enc}&title=New+title")))
10432                    .unwrap(),
10433            )
10434            .await
10435            .unwrap();
10436        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10437        resp.headers()
10438            .get(header::LOCATION)
10439            .unwrap()
10440            .to_str()
10441            .unwrap()
10442            .to_string()
10443    }
10444
10445    /// **The privacy gate has the same ordering bug the storable gate had.**
10446    ///
10447    /// Another client can write a subscription whose URL is an at-URI that is
10448    /// not a well-formed publication URI at all — a feed generator, say. On
10449    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
10450    /// the classifier reads as `Public`). The narrowed at:// arm now fails
10451    /// closed as `Private` for it, and the gate ran before `url_changed` was
10452    /// known — so the record became un-editable, with a flash claiming it "was
10453    /// not saved or sent anywhere". Both gates now apply to a repoint only.
10454    #[tokio::test]
10455    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
10456        let did = "did:plc:renamer5";
10457        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
10458        let other_enc =
10459            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
10460        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
10461        let state = test_state_with_sidecar(&[did], &sidecar).await;
10462        let loc = retitle_unchanged(&state, did, other_enc).await;
10463        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10464        let bodies = puts.lock().unwrap().clone();
10465        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10466        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10467        assert_eq!(sent["record"]["title"], "New title");
10468        assert_eq!(sent["record"]["url"], other);
10469    }
10470
10471    /// **A repoint to a secret-bearing URL is still refused** — the half of
10472    /// the privacy gate that has to survive moving it behind `url_changed`.
10473    /// Found by mutation: with the gate deleted outright, nothing failed.
10474    #[tokio::test]
10475    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
10476        let did = "did:plc:renamer4";
10477        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10478        let state = test_state_with_sidecar(&[did], &sidecar).await;
10479        let cookie = session_cookie(&state, did, None);
10480        let resp = router(state.clone())
10481            .oneshot(
10482                Request::builder()
10483                    .method("POST")
10484                    .uri("/subscriptions/rk-keep/rename")
10485                    .header(header::COOKIE, cookie)
10486                    .header("content-type", "application/x-www-form-urlencoded")
10487                    .body(Body::from(
10488                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
10489                    ))
10490                    .unwrap(),
10491            )
10492            .await
10493            .unwrap();
10494        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10495        let loc = resp
10496            .headers()
10497            .get(header::LOCATION)
10498            .unwrap()
10499            .to_str()
10500            .unwrap();
10501        assert!(
10502            loc.contains("Private"),
10503            "the private repoint was not refused: {loc}"
10504        );
10505        assert!(
10506            puts.lock().unwrap().is_empty(),
10507            "a secret-bearing URL reached the PDS"
10508        );
10509        // The repo's fixture token: opaque enough for the classifier, not a real
10510        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
10511        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
10512        assert!(store::get_feed_by_url(&state.db, leaked)
10513            .await
10514            .unwrap()
10515            .is_none());
10516    }
10517
10518    /// **A retitle of a never-cached at:// subscription is not "at feed
10519    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
10520    /// and an at:// record is never cached with the flag off — so at capacity,
10521    /// a pure retitle was refused for a row the handler would not insert. The
10522    /// check now runs once `url_changed` is known and only for a repoint.
10523    #[tokio::test]
10524    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
10525        let did = "did:plc:renamer5";
10526        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10527        // Ceiling 1, and one real feed already fills it.
10528        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10529        store::upsert_feed(
10530            &state.db,
10531            &store::NewFeed {
10532                url: "https://filler.example/feed.xml".to_string(),
10533                ..Default::default()
10534            },
10535        )
10536        .await
10537        .unwrap();
10538        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10539        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10540        assert_eq!(
10541            puts.lock().unwrap().len(),
10542            1,
10543            "the retitle did not reach the PDS"
10544        );
10545        assert_eq!(
10546            store::count_feeds(&state.db).await.unwrap(),
10547            1,
10548            "a row was inserted"
10549        );
10550    }
10551
10552    /// POST `/subscriptions` with `url`, returning the redirect target.
10553    async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
10554        let cookie = session_cookie(state, did, None);
10555        let resp = router(state.clone())
10556            .oneshot(
10557                Request::builder()
10558                    .method("POST")
10559                    .uri("/subscriptions")
10560                    .header(header::COOKIE, cookie)
10561                    .header("content-type", "application/x-www-form-urlencoded")
10562                    .body(Body::from(format!("url={url_enc}")))
10563                    .unwrap(),
10564            )
10565            .await
10566            .unwrap();
10567        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10568        resp.headers()
10569            .get(header::LOCATION)
10570            .unwrap()
10571            .to_str()
10572            .unwrap()
10573            .to_string()
10574    }
10575
10576    /// A `com.atproto.identity.resolveHandle` that answers `did` for anything.
10577    async fn serve_resolver(did: &str) -> String {
10578        let base = crate::net::tests::serve_body(
10579            serde_json::json!({ "did": did }).to_string().into_bytes(),
10580        )
10581        .await;
10582        let port: u16 = base
10583            .trim_end_matches('/')
10584            .rsplit(':')
10585            .next()
10586            .unwrap()
10587            .parse()
10588            .unwrap();
10589        let host = format!("resolver-{port}.test");
10590        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10591        format!("http://{host}:{port}")
10592    }
10593
10594    fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
10595        let mut config = (*state.config).clone();
10596        f(&mut config);
10597        state.config = std::sync::Arc::new(config);
10598        state
10599    }
10600
10601    /// **0.4.0 step 3: with the flag ON, a well-formed at:// paste is
10602    /// subscribed.** It was refused as unsupported while nothing could read a
10603    /// publication; the poller reads them now. Stored in DID form, as a
10604    /// `publication`, and written to the reader's PDS like any subscription.
10605    #[tokio::test]
10606    async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
10607        let did = "did:plc:renamer5";
10608        let (sidecar, log) = spawn_logging_sidecar().await;
10609        let state = with_config(
10610            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10611            |c| {
10612                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10613            },
10614        );
10615        let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
10616        assert_eq!(loc, "/", "the paste was refused: {loc}");
10617        let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
10618            .await
10619            .unwrap()
10620            .expect("no feed row");
10621        assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
10622        let sent = log.lock().unwrap().join("\n");
10623        assert!(
10624            sent.contains(AT_URI_SUB),
10625            "the subscription was not written to the PDS: {sent}"
10626        );
10627    }
10628
10629    /// **A0 through the form** — the 0.4.0 exit test, end to end: a reader
10630    /// pastes a publication, it is stored and written to their PDS, and the
10631    /// first poll — the one subscribing runs at once — stores its documents.
10632    #[tokio::test]
10633    async fn a0_subscribing_from_the_form_delivers_entries() {
10634        let did = "did:plc:renamer5";
10635        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10636        let site = AT_URI_SUB;
10637        let (plc, _) = crate::standard_site::tests::serve_repo(
10638            author,
10639            vec![
10640                (
10641                    lexicon::nsid::STANDARD_PUBLICATION,
10642                    "3lab2c4d5e6f7g8h",
10643                    serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
10644                ),
10645                (
10646                    lexicon::nsid::STANDARD_DOCUMENT,
10647                    "3l2a0frmaaa2a",
10648                    serde_json::json!({ "title": "From the form", "path": "/f",
10649                        "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
10650                ),
10651            ],
10652        )
10653        .await;
10654        let (sidecar, _log) = spawn_logging_sidecar().await;
10655        let state = with_config(
10656            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10657            |c| {
10658                c.oauth.plc_directory = plc;
10659            },
10660        );
10661        assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
10662        let row = store::get_feed_by_url(&state.db, site)
10663            .await
10664            .unwrap()
10665            .unwrap();
10666        let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
10667            .bind(row.id)
10668            .fetch_all(&state.db)
10669            .await
10670            .unwrap();
10671        assert_eq!(
10672            titles,
10673            vec!["From the form".to_string()],
10674            "the first poll stored nothing"
10675        );
10676        assert_eq!(row.title.as_deref(), Some("A0 Journal"));
10677    }
10678
10679    /// A handle-form paste is resolved to the DID before it is stored: a
10680    /// handle is a mutable name, and `feeds.url` is keyed on identity.
10681    #[tokio::test]
10682    async fn a_handle_form_paste_is_stored_by_its_did() {
10683        let did = "did:plc:renamer5";
10684        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10685        let (sidecar, _log) = spawn_logging_sidecar().await;
10686        let resolver = serve_resolver(author).await;
10687        let state = with_config(
10688            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10689            |c| {
10690                c.resolver_base = resolver;
10691                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10692            },
10693        );
10694        let loc = subscribe(
10695            &state,
10696            did,
10697            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10698        )
10699        .await;
10700        assert_eq!(loc, "/", "the paste was refused: {loc}");
10701        assert!(
10702            store::get_feed_by_url(&state.db, AT_URI_SUB)
10703                .await
10704                .unwrap()
10705                .is_some(),
10706            "not stored by its DID"
10707        );
10708        assert_eq!(
10709            store::count_feeds(&state.db).await.unwrap(),
10710            1,
10711            "the handle form was stored too"
10712        );
10713    }
10714
10715    /// A resolver answering `did` that counts how often it was asked.
10716    async fn serve_counting_resolver(
10717        did: &str,
10718    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10719        let (base, hits) = crate::net::tests::serve_body_counted(
10720            serde_json::json!({ "did": did }).to_string().into_bytes(),
10721        )
10722        .await;
10723        let port: u16 = base
10724            .trim_end_matches('/')
10725            .rsplit(':')
10726            .next()
10727            .unwrap()
10728            .parse()
10729            .unwrap();
10730        let host = format!("counting-resolver-{port}.test");
10731        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10732        (format!("http://{host}:{port}"), hits)
10733    }
10734
10735    /// Review of #230: the per-DID cap is documented as checked "BEFORE any
10736    /// fetch/resolve so an over-cap account can't even trigger an outbound
10737    /// request" — a handle paste resolved the handle first.
10738    #[tokio::test]
10739    async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10740        let did = "did:plc:renamer5";
10741        let (sidecar, _log) = spawn_logging_sidecar().await;
10742        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10743        let state = with_config(
10744            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10745            |c| {
10746                c.resolver_base = resolver;
10747                c.max_subs_per_did = 1;
10748            },
10749        );
10750        let feed_id = store::upsert_feed(
10751            &state.db,
10752            &store::NewFeed {
10753                url: "https://already.example/feed.xml".into(),
10754                ..Default::default()
10755            },
10756        )
10757        .await
10758        .unwrap();
10759        store::replace_sub_refs(&state.db, did, &[feed_id])
10760            .await
10761            .unwrap();
10762        let loc = subscribe(
10763            &state,
10764            did,
10765            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10766        )
10767        .await;
10768        assert!(
10769            loc.contains("Subscription%20limit"),
10770            "expected the cap flash: {loc}"
10771        );
10772        assert_eq!(
10773            hits.load(std::sync::atomic::Ordering::SeqCst),
10774            0,
10775            "an over-cap paste resolved a handle"
10776        );
10777    }
10778
10779    /// Review of #230: an authority that is neither a valid DID nor a valid
10780    /// handle — `did:plc:TOOSHORT`, an uppercase DID — went to the resolver as
10781    /// a "handle". It is unsupported, and asks nobody anything.
10782    #[tokio::test]
10783    async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10784        let did = "did:plc:renamer5";
10785        let (sidecar, _log) = spawn_logging_sidecar().await;
10786        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10787        let state = with_config(
10788            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10789            |c| {
10790                c.resolver_base = resolver;
10791            },
10792        );
10793        for authority in [
10794            "did%3Aplc%3ATOOSHORT",
10795            "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10796            "bad%0Ahandle.example",
10797        ] {
10798            let loc = subscribe(
10799                &state,
10800                did,
10801                &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10802            )
10803            .await;
10804            assert!(
10805                loc.contains("kind%20of%20feed"),
10806                "{authority}: expected the unsupported flash: {loc}"
10807            );
10808        }
10809        assert_eq!(
10810            hits.load(std::sync::atomic::Ordering::SeqCst),
10811            0,
10812            "a malformed authority reached the resolver"
10813        );
10814        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10815    }
10816
10817    /// A handle that does not resolve is refused, and nothing is stored.
10818    #[tokio::test]
10819    async fn an_unresolvable_handle_paste_is_refused() {
10820        let did = "did:plc:renamer5";
10821        let (sidecar, _log) = spawn_logging_sidecar().await;
10822        let state = with_config(
10823            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10824            |c| {
10825                c.resolver_base = "http://resolver.nowhere.invalid".into();
10826            },
10827        );
10828        let loc = subscribe(
10829            &state,
10830            did,
10831            "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10832        )
10833        .await;
10834        assert!(
10835            loc.contains("resolve%20the%20handle"),
10836            "expected the unresolvable-handle flash: {loc}"
10837        );
10838        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10839    }
10840
10841    /// An at:// URI that is not a publication is refused, flag on or off.
10842    #[tokio::test]
10843    async fn a_non_publication_at_uri_paste_is_refused() {
10844        let did = "did:plc:renamer5";
10845        let (sidecar, _log) = spawn_logging_sidecar().await;
10846        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10847        let loc = subscribe(
10848            &state,
10849            did,
10850            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
10851        )
10852        .await;
10853        assert!(
10854            loc.contains("kind%20of%20feed"),
10855            "expected the unsupported flash: {loc}"
10856        );
10857        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10858    }
10859
10860    /// A mixed-case scheme is canonicalised at input, not refused and not
10861    /// stored as a second spelling of the same publication.
10862    #[tokio::test]
10863    async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
10864        let did = "did:plc:renamer5";
10865        let (sidecar, _log) = spawn_logging_sidecar().await;
10866        let state = with_config(
10867            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10868            |c| {
10869                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10870            },
10871        );
10872        let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
10873        assert_eq!(loc, "/", "the paste was refused: {loc}");
10874        assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
10875            .await
10876            .unwrap()
10877            .is_some());
10878    }
10879
10880    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
10881    /// path that is meant to work today, asserted with the flag actually on.
10882    #[tokio::test]
10883    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
10884        let did = "did:plc:renamer5";
10885        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10886        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10887        let opml = format!(
10888            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10889             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10890             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10891             </body></opml>"
10892        );
10893        let (ct, body) = opml_multipart(opml.as_bytes());
10894        let cookie = session_cookie(&state, did, None);
10895        let resp = router(state.clone())
10896            .oneshot(
10897                Request::builder()
10898                    .method("POST")
10899                    .uri("/opml")
10900                    .header(header::COOKIE, cookie)
10901                    .header("content-type", ct)
10902                    .body(Body::from(body))
10903                    .unwrap(),
10904            )
10905            .await
10906            .unwrap();
10907        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10908        let loc = resp
10909            .headers()
10910            .get(header::LOCATION)
10911            .unwrap()
10912            .to_str()
10913            .unwrap();
10914        assert!(
10915            loc.contains("Imported%202%20feeds"),
10916            "unexpected flash: {loc}"
10917        );
10918        assert!(
10919            !loc.contains("skipped"),
10920            "the at:// entry was skipped with the flag on: {loc}"
10921        );
10922        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
10923        assert!(
10924            stored.is_some(),
10925            "the at:// entry was not stored with the flag on"
10926        );
10927    }
10928
10929    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
10930    /// gate behind `url_changed` was right for the PDS write — the record is
10931    /// the reader's — but the cache write was gated only on `storable`, which
10932    /// any http(s) URL is. So a retitle of a record another client wrote with
10933    /// a tokened feed URL inserted that URL into the shared `feeds` table,
10934    /// where the poller would fail it every cycle and print it on the admin
10935    /// page. main refused the whole rename; this keeps the record editable and
10936    /// the cache clean, as `resolve_subscriptions` already does for the same
10937    /// record.
10938    #[tokio::test]
10939    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
10940        let did = "did:plc:renamer5";
10941        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
10942        let tokened_enc =
10943            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
10944        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
10945        let state = test_state_with_sidecar(&[did], &sidecar).await;
10946        let loc = retitle_unchanged(&state, did, tokened_enc).await;
10947        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10948        assert_eq!(
10949            puts.lock().unwrap().len(),
10950            1,
10951            "the retitle did not reach the PDS"
10952        );
10953        assert!(
10954            store::get_feed_by_url(&state.db, tokened)
10955                .await
10956                .unwrap()
10957                .is_none(),
10958            "a secret-bearing URL was written to the shared cache by a retitle"
10959        );
10960    }
10961
10962    /// **On a repoint, storability is decided before privacy and capacity** —
10963    /// the same ordering the add path got. A malformed at:// target drew the
10964    /// private/paid flash, and at capacity a well-formed one drew "try again
10965    /// later" for a URL that can never be accepted with the flag off.
10966    #[tokio::test]
10967    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
10968        let did = "did:plc:renamer4";
10969        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10970        let state = test_state_with_sidecar(&[did], &sidecar).await;
10971        let cookie = session_cookie(&state, did, None);
10972        let resp = router(state.clone())
10973            .oneshot(
10974                Request::builder()
10975                    .method("POST")
10976                    .uri("/subscriptions/rk-keep/rename")
10977                    .header(header::COOKIE, cookie)
10978                    .header("content-type", "application/x-www-form-urlencoded")
10979                    .body(Body::from(
10980                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
10981                    ))
10982                    .unwrap(),
10983            )
10984            .await
10985            .unwrap();
10986        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10987        let loc = resp
10988            .headers()
10989            .get(header::LOCATION)
10990            .unwrap()
10991            .to_str()
10992            .unwrap();
10993        assert!(
10994            loc.contains("kind%20of%20feed"),
10995            "expected the unsupported flash: {loc}"
10996        );
10997        assert!(
10998            !loc.contains("Private"),
10999            "a typo was reported as a paid feed: {loc}"
11000        );
11001        assert!(puts.lock().unwrap().is_empty());
11002    }
11003
11004    #[tokio::test]
11005    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11006        let did = "did:plc:renamer4";
11007        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11008        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11009        store::upsert_feed(
11010            &state.db,
11011            &store::NewFeed {
11012                url: "https://filler.example/feed.xml".to_string(),
11013                ..Default::default()
11014            },
11015        )
11016        .await
11017        .unwrap();
11018        let cookie = session_cookie(&state, did, None);
11019        let resp = router(state.clone())
11020            .oneshot(
11021                Request::builder()
11022                    .method("POST")
11023                    .uri("/subscriptions/rk-keep/rename")
11024                    .header(header::COOKIE, cookie)
11025                    .header("content-type", "application/x-www-form-urlencoded")
11026                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11027                    .unwrap(),
11028            )
11029            .await
11030            .unwrap();
11031        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11032        let loc = resp
11033            .headers()
11034            .get(header::LOCATION)
11035            .unwrap()
11036            .to_str()
11037            .unwrap();
11038        assert!(
11039            loc.contains("kind%20of%20feed"),
11040            "expected the unsupported flash: {loc}"
11041        );
11042        assert!(
11043            !loc.contains("capacity"),
11044            "an unacceptable URL was reported as a capacity problem: {loc}"
11045        );
11046        assert!(puts.lock().unwrap().is_empty());
11047    }
11048
11049    /// **`url_changed` compares like for like.** The form value is trimmed;
11050    /// the record's URL was compared raw, so a record another client wrote
11051    /// with a trailing space read as a repoint on every retitle and re-armed
11052    /// every gate — including the one that made an at:// record un-editable.
11053    #[tokio::test]
11054    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11055        let did = "did:plc:renamer5";
11056        let padded = format!("{AT_URI_SUB} ");
11057        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11058        let state = test_state_with_sidecar(&[did], &sidecar).await;
11059        // The manage row posts the record's URL verbatim, padding included.
11060        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11061        assert_eq!(
11062            loc, "/",
11063            "the retitle was treated as a repoint and refused: {loc}"
11064        );
11065        let bodies = puts.lock().unwrap().clone();
11066        assert_eq!(bodies.len(), 1);
11067        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11068        assert_eq!(
11069            sent["record"]["url"], AT_URI_SUB,
11070            "the padding was not normalised away"
11071        );
11072    }
11073
11074    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
11075    /// only, so the trailing upsert must not create a row for an unchanged URL
11076    /// that has none — with the flag on and the cache full, each retitle of a
11077    /// never-cached at:// record was a row past the cap. An existing row still
11078    /// gets its title kept in step.
11079    #[tokio::test]
11080    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11081        let did = "did:plc:renamer5";
11082        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11083        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11084        store::upsert_feed(
11085            &state.db,
11086            &store::NewFeed {
11087                url: "https://filler.example/feed.xml".to_string(),
11088                ..Default::default()
11089            },
11090        )
11091        .await
11092        .unwrap();
11093        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11094        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11095        assert_eq!(puts.lock().unwrap().len(), 1);
11096        assert_eq!(
11097            store::count_feeds(&state.db).await.unwrap(),
11098            1,
11099            "a retitle inserted a cache row past the ceiling"
11100        );
11101    }
11102
11103    /// **The add path's at:// pre-check is about the MESSAGE, so it is
11104    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
11105    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
11106    /// tripped the secret heuristic on the rkey — the private/paid flash the
11107    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
11108    /// touch it.
11109    #[tokio::test]
11110    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11111        let did = "did:plc:typoist";
11112        let state = test_state_with_caps(did, 0, 0).await;
11113        let cookie = session_cookie(&state, did, None);
11114        let resp = router(state.clone())
11115            .oneshot(
11116                Request::builder()
11117                    .method("POST")
11118                    .uri("/subscriptions")
11119                    .header(header::COOKIE, cookie)
11120                    .header("content-type", "application/x-www-form-urlencoded")
11121                    .body(Body::from(
11122                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11123                    ))
11124                    .unwrap(),
11125            )
11126            .await
11127            .unwrap();
11128        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11129        let loc = resp
11130            .headers()
11131            .get(header::LOCATION)
11132            .unwrap()
11133            .to_str()
11134            .unwrap();
11135        assert!(
11136            loc.contains("kind%20of%20feed"),
11137            "expected the unsupported flash: {loc}"
11138        );
11139        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11140    }
11141
11142    /// **A rename must not destroy the fields the form never carries.**
11143    ///
11144    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
11145    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
11146    /// every field absent from `templates/manage_row.html` (which posts only
11147    /// `url`, `title`, `folder`) was written back as its default:
11148    ///
11149    /// | field | before | after |
11150    /// |---|---|---|
11151    /// | `siteUrl` | whatever the feed advertised | gone |
11152    /// | `fetchHint` | as set | gone |
11153    /// | `private` | as set | gone |
11154    /// | `createdAt` | original subscribe time | reset to now |
11155    ///
11156    /// `createdAt` is the worst of the four: it is the sort key for "when did I
11157    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
11158    /// tells the reader it moved.
11159    ///
11160    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
11161    /// in the test — the record only becomes wrong on the way out, so checking
11162    /// the value we passed in would pass just as happily with the fix removed.
11163    #[tokio::test]
11164    async fn renaming_preserves_the_fields_the_form_never_carries() {
11165        let did = "did:plc:renamer4";
11166        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11167        let state = test_state_with_sidecar(&[did], &sidecar).await;
11168        let cookie = session_cookie(&state, did, None);
11169
11170        let resp = router(state.clone())
11171            .oneshot(
11172                Request::builder()
11173                    .method("POST")
11174                    .uri("/subscriptions/rk-keep/rename")
11175                    .header(header::COOKIE, cookie)
11176                    .header("content-type", "application/x-www-form-urlencoded")
11177                    // Exactly what the manage row posts: url, title, folder.
11178                    .body(Body::from(
11179                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11180                    ))
11181                    .unwrap(),
11182            )
11183            .await
11184            .unwrap();
11185        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11186
11187        let bodies = puts.lock().unwrap().clone();
11188        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11189        let body = &bodies[0];
11190        // Anchors the negative assertions: an empty capture would satisfy them.
11191        assert!(
11192            body.contains("community.lexicon.rss.subscription"),
11193            "captured no usable put body: {body:?}"
11194        );
11195
11196        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
11197        let record = &sent["record"];
11198
11199        // What the form DID carry must be applied.
11200        assert_eq!(record["title"], "New title", "the rename did not apply");
11201        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
11202
11203        // What the form did NOT carry must survive.
11204        assert_eq!(
11205            record["createdAt"], "2024-03-01T00:00:00.000Z",
11206            "the rename reset createdAt — the reader's subscribe time is gone \
11207             from their own repo, and nothing told them"
11208        );
11209        assert_eq!(
11210            record["siteUrl"], "https://example.com/blog",
11211            "the rename erased siteUrl"
11212        );
11213        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
11214        assert_eq!(record["private"], false, "the rename erased private");
11215    }
11216
11217    /// **Repointing at a different feed drops that feed's properties, but not
11218    /// the subscription's.**
11219    ///
11220    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
11221    /// so carrying them onto a different URL would leave a site link for the old
11222    /// feed hanging off the new one. `createdAt` and `private` are properties of
11223    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
11224    /// subscribed, whatever the URL was later corrected to.
11225    #[tokio::test]
11226    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
11227        let did = "did:plc:renamer4";
11228        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11229        let state = test_state_with_sidecar(&[did], &sidecar).await;
11230        let cookie = session_cookie(&state, did, None);
11231
11232        let resp = router(state.clone())
11233            .oneshot(
11234                Request::builder()
11235                    .method("POST")
11236                    .uri("/subscriptions/rk-keep/rename")
11237                    .header(header::COOKIE, cookie)
11238                    .header("content-type", "application/x-www-form-urlencoded")
11239                    // A DIFFERENT feed URL from the seeded record.
11240                    .body(Body::from(
11241                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
11242                    ))
11243                    .unwrap(),
11244            )
11245            .await
11246            .unwrap();
11247        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11248
11249        let bodies = puts.lock().unwrap().clone();
11250        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11251        assert!(
11252            bodies[0].contains("community.lexicon.rss.subscription"),
11253            "captured no usable put body: {:?}",
11254            bodies[0]
11255        );
11256        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11257        let record = &sent["record"];
11258
11259        assert_eq!(record["url"], "https://other.example/feed.xml");
11260        // The old feed's properties are gone rather than misattributed.
11261        assert!(
11262            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
11263            "the old feed's site link followed the subscription to a new feed: {record}"
11264        );
11265        assert!(
11266            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
11267            "the old feed's fetch hint followed the subscription to a new feed: {record}"
11268        );
11269        // The subscription's own properties survive.
11270        assert_eq!(
11271            record["createdAt"], "2024-03-01T00:00:00.000Z",
11272            "a repoint is still not a new subscription; createdAt must not move"
11273        );
11274        assert_eq!(record["private"], false, "the repoint erased private");
11275    }
11276
11277    /// **A rename against an rkey that is not in the repo writes NOTHING.**
11278    ///
11279    /// `update_subscription` is a `putRecord`, which CREATES the record when the
11280    /// rkey does not exist — with whatever `createdAt` we hand it. So without
11281    /// this refusal a rename against a stale or wrong rkey manufactures a
11282    /// subscription dated today, which is the bug this whole change exists to
11283    /// fix, arriving by a different door.
11284    ///
11285    /// The guard was untested when first written: removing it left all 733 tests
11286    /// green. An untested guard against the exact defect being fixed is how the
11287    /// two previous rounds of this problem got through.
11288    #[tokio::test]
11289    async fn renaming_an_unknown_rkey_writes_nothing() {
11290        let did = "did:plc:renamer4";
11291        // The sidecar serves exactly one record, at rkey `rk-keep`.
11292        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11293        let state = test_state_with_sidecar(&[did], &sidecar).await;
11294        let cookie = session_cookie(&state, did, None);
11295
11296        let resp = router(state.clone())
11297            .oneshot(
11298                Request::builder()
11299                    .method("POST")
11300                    // ...and this is not it.
11301                    .uri("/subscriptions/rk-does-not-exist/rename")
11302                    .header(header::COOKIE, cookie)
11303                    .header("content-type", "application/x-www-form-urlencoded")
11304                    .body(Body::from(
11305                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
11306                    ))
11307                    .unwrap(),
11308            )
11309            .await
11310            .unwrap();
11311
11312        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11313        let loc = resp
11314            .headers()
11315            .get(header::LOCATION)
11316            .unwrap()
11317            .to_str()
11318            .unwrap();
11319        assert!(
11320            loc.contains("flash="),
11321            "an unknown rkey redirected as though the rename had worked: {loc}"
11322        );
11323        assert!(
11324            puts.lock().unwrap().is_empty(),
11325            "a rename against an unknown rkey wrote a record — putRecord would \
11326             CREATE it, dated today: {:?}",
11327            puts.lock().unwrap()
11328        );
11329    }
11330
11331    /// **A `site_url` the client actually sends is applied, not dropped.**
11332    ///
11333    /// `templates/manage_row.html` does not post this field, so it is tempting
11334    /// to read the arm that handles it as dead code. It is not:
11335    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
11336    /// today. Discarding the value instead of applying it left all 733 tests
11337    /// green.
11338    ///
11339    /// The value is scheme-checked on the way out by the repo-boundary vet, so
11340    /// this is a coverage gap rather than an exposure — but an untested path
11341    /// that writes a URL into the reader's PDS should not stay untested.
11342    #[tokio::test]
11343    async fn a_client_supplied_site_url_reaches_the_record() {
11344        let did = "did:plc:renamer4";
11345        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11346        let state = test_state_with_sidecar(&[did], &sidecar).await;
11347        let cookie = session_cookie(&state, did, None);
11348
11349        let resp = router(state.clone())
11350            .oneshot(
11351                Request::builder()
11352                    .method("POST")
11353                    .uri("/subscriptions/rk-keep/rename")
11354                    .header(header::COOKIE, cookie)
11355                    .header("content-type", "application/x-www-form-urlencoded")
11356                    // Same feed URL, but carrying a site_url the manage row
11357                    // never sends.
11358                    .body(Body::from(
11359                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
11360                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
11361                    ))
11362                    .unwrap(),
11363            )
11364            .await
11365            .unwrap();
11366        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11367
11368        let bodies = puts.lock().unwrap().clone();
11369        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11370        assert!(
11371            bodies[0].contains("community.lexicon.rss.subscription"),
11372            "captured no usable put body: {:?}",
11373            bodies[0]
11374        );
11375        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11376        assert_eq!(
11377            sent["record"]["siteUrl"], "https://typed.example/site",
11378            "the client's siteUrl was dropped; the seeded record's survived instead"
11379        );
11380    }
11381
11382    /// **A rename whose read fails writes NOTHING.**
11383    ///
11384    /// This is the property most easily lost when someone later touches this
11385    /// handler: falling back to `Subscription::new` on a read error looks like
11386    /// graceful degradation and is in fact the original bug, reinstated on
11387    /// exactly the path where it is hardest to notice. The reader must be told
11388    /// instead.
11389    #[tokio::test]
11390    async fn a_rename_whose_read_fails_writes_nothing() {
11391        let did = "did:plc:renamer5";
11392        // A port that accepts nothing: the read cannot succeed.
11393        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11394        let dead = format!("http://{}", listener.local_addr().unwrap());
11395        drop(listener);
11396
11397        let state = test_state_with_sidecar(&[did], &dead).await;
11398        let cookie = session_cookie(&state, did, None);
11399        let before = store::count_feeds(&state.db).await.unwrap();
11400
11401        let resp = router(state.clone())
11402            .oneshot(
11403                Request::builder()
11404                    .method("POST")
11405                    .uri("/subscriptions/rk-keep/rename")
11406                    .header(header::COOKIE, cookie)
11407                    .header("content-type", "application/x-www-form-urlencoded")
11408                    .body(Body::from(
11409                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
11410                    ))
11411                    .unwrap(),
11412            )
11413            .await
11414            .unwrap();
11415
11416        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11417        let loc = resp
11418            .headers()
11419            .get(header::LOCATION)
11420            .unwrap()
11421            .to_str()
11422            .unwrap();
11423        assert!(
11424            loc.contains("flash="),
11425            "a failed read redirected as though the rename had worked: {loc}"
11426        );
11427        assert_eq!(
11428            store::count_feeds(&state.db).await.unwrap(),
11429            before,
11430            "a rename that could not read the record still wrote to the cache"
11431        );
11432    }
11433
11434    /// Folder pre-selection regression: the manage rename row must mark the
11435    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
11436    /// re-submits the current folder instead of silently un-foldering the feed.
11437    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
11438    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
11439    #[test]
11440    fn manage_rename_row_preselects_current_folder() {
11441        let nav = Nav {
11442            handle: "@reader.example".to_string(),
11443            avatar: "RE".to_string(),
11444            view: "unread".to_string(),
11445            scope_qs: String::new(),
11446            folders: Vec::new(),
11447            loose_feeds: Vec::new(),
11448            manage_active: true,
11449        };
11450        let folder_options = vec![
11451            FolderOption {
11452                uri: "at://did:plc:x/app.folder/work".to_string(),
11453                name: "Work".to_string(),
11454            },
11455            FolderOption {
11456                uri: "at://did:plc:x/app.folder/fun".to_string(),
11457                name: "Fun".to_string(),
11458            },
11459        ];
11460        // A foldered feed (in "Work") and a loose feed (no folder), each with a
11461        // non-empty rkey so the rename form renders.
11462        let foldered = FeedView {
11463            rkey: "sub-foldered".to_string(),
11464            url: "https://work.example/feed.xml".to_string(),
11465            title: "Work Feed".to_string(),
11466            unread: 0,
11467            selected: false,
11468            folder: Some("at://did:plc:x/app.folder/work".to_string()),
11469        };
11470        let loose = FeedView {
11471            rkey: "sub-loose".to_string(),
11472            url: "https://loose.example/feed.xml".to_string(),
11473            title: "Loose Feed".to_string(),
11474            unread: 0,
11475            selected: false,
11476            folder: None,
11477        };
11478        let tmpl = ManageTemplate {
11479            card: Card::private(&Config::default()),
11480            version: VERSION,
11481            repo_url: REPO_URL,
11482            kofi_url: KOFI_URL,
11483            flash: String::new(),
11484            alert: String::new(),
11485            nav,
11486            folder_options,
11487            folders: vec![FolderView {
11488                rkey: "folder-work".to_string(),
11489                uri: "at://did:plc:x/app.folder/work".to_string(),
11490                name: "Work".to_string(),
11491                feeds: vec![foldered],
11492                selected: false,
11493            }],
11494            loose_feeds: vec![loose],
11495            standard_site: false,
11496        };
11497        let html = tmpl.render().unwrap();
11498
11499        // The foldered feed's "Work" option is pre-selected.
11500        assert!(
11501            html.contains(
11502                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
11503            ),
11504            "foldered feed must pre-select its current folder: {html}"
11505        );
11506        // The loose feed's "No folder" option is pre-selected (appears for the
11507        // loose row, which has folder=None).
11508        assert!(
11509            html.contains(r#"<option value="" selected>No folder</option>"#),
11510            "loose feed must pre-select 'No folder': {html}"
11511        );
11512    }
11513
11514    /// **The public stats page carries no user data.**
11515    ///
11516    /// It is reachable by anyone, so the thing worth pinning is what it does
11517    /// NOT say: nothing about how many people use the instance, nothing about
11518    /// which feeds fail, nothing about who reads what.
11519    #[tokio::test]
11520    async fn the_public_stats_page_exposes_no_user_data() {
11521        let state = test_state(&[]).await;
11522        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
11523            .await
11524            .unwrap();
11525
11526        let resp = router(state)
11527            .oneshot(
11528                Request::builder()
11529                    .uri("/stats")
11530                    .body(Body::empty())
11531                    .unwrap(),
11532            )
11533            .await
11534            .unwrap();
11535        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
11536
11537        let body = String::from_utf8(
11538            axum::body::to_bytes(resp.into_body(), usize::MAX)
11539                .await
11540                .unwrap()
11541                .to_vec(),
11542        )
11543        .unwrap();
11544
11545        // Structural checks, not word checks. The page's own prose says it
11546        // publishes no error rates, so searching for that PHRASE finds the
11547        // disclaimer rather than a leak — the first version of this test failed
11548        // on exactly that. What matters is whether identifiers or the
11549        // admin-only figures are present.
11550        assert!(
11551            !body.contains("did:"),
11552            "the public stats page leaked an identifier"
11553        );
11554        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
11555            assert!(
11556                !body.contains(admin_only),
11557                "the public page is showing the admin metrics column {admin_only:?}"
11558            );
11559        }
11560        // And it does render the aggregate it exists for.
11561        assert!(body.contains("Feeds tracked"));
11562        assert!(body.contains("Waiting to be polled"));
11563    }
11564
11565    /// **The two states that stop feeds updating must be visible.**
11566    ///
11567    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
11568    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
11569    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
11570    /// the backlog and makes the page read healthier. That inversion is what this
11571    /// test pins: a broken feed must raise a number, not lower one.
11572    #[tokio::test]
11573    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
11574        let state = test_state(&[]).await;
11575        // Three feeds: one healthy, one flaky, one long dead.
11576        for (url, errors) in [
11577            ("https://ok.example/f.xml", 0),
11578            ("https://flaky.example/f.xml", 2),
11579            ("https://dead.example/f.xml", 9),
11580        ] {
11581            store::upsert_feed(
11582                &state.db,
11583                &store::NewFeed {
11584                    url: url.to_string(),
11585                    // Pushed forward, exactly as backoff does — so none of these
11586                    // are counted as `overdue`.
11587                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11588                    ..Default::default()
11589                },
11590            )
11591            .await
11592            .unwrap();
11593            for _ in 0..errors {
11594                store::bump_feed_errors(
11595                    &state.db,
11596                    url,
11597                    feed::FailureKind::Fetch,
11598                    "connection refused",
11599                )
11600                .await
11601                .unwrap();
11602            }
11603        }
11604
11605        let render_stats = |state: AppState| async move {
11606            let resp = router(state)
11607                .oneshot(
11608                    Request::builder()
11609                        .uri("/stats")
11610                        .body(Body::empty())
11611                        .unwrap(),
11612                )
11613                .await
11614                .unwrap();
11615            assert_eq!(resp.status(), StatusCode::OK);
11616            String::from_utf8(
11617                axum::body::to_bytes(resp.into_body(), usize::MAX)
11618                    .await
11619                    .unwrap()
11620                    .to_vec(),
11621            )
11622            .unwrap()
11623        };
11624
11625        // **The fixture must actually be RUNNING, or this test measures nothing.**
11626        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
11627        // checks that BEFORE the watermark — so without these two lines every
11628        // render below reports "off" and the watermark can never surface. The
11629        // assertions still passed, for reasons unrelated to what they name: see
11630        // the two comments below.
11631        state.runtime_health.set_schedulers_enabled(true);
11632        state
11633            .runtime_health
11634            .poll_tick_completed(crate::store::now_unix());
11635
11636        let body = render_stats(state.clone()).await;
11637        assert!(
11638            body.contains("Failing"),
11639            "backoff is still invisible on the public page"
11640        );
11641        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
11642        // value rather than on surrounding whitespace, so re-indenting the
11643        // template cannot break this.
11644        assert!(
11645            body.contains("2, 1 badly"),
11646            "expected '2, 1 badly' in the failing row; got:\n{}",
11647            body.split("Failing")
11648                .nth(1)
11649                .unwrap_or("")
11650                .chars()
11651                .take(300)
11652                .collect::<String>()
11653        );
11654        // Not paused, and the backlog is genuinely empty — which is exactly the
11655        // reading that used to be indistinguishable from healthy.
11656        //
11657        // **Asserted by EXCLUDING the other states, not by matching "running".**
11658        // The `off` row reads "the poller is not running on this instance", which
11659        // contains "running" — so the bare substring passed while the page was
11660        // reporting the exact opposite of what this line claims to check.
11661        assert!(
11662            !body.contains("the poller is not running")
11663                && !body.contains("the cache is at its size limit")
11664                && !body.contains("has not completed a round"),
11665            "expected the running state; the page reported a stopped one",
11666        );
11667
11668        // Now trip the watermark. Nothing in the database changes; only the
11669        // recorded runtime state does — which is the whole reason it needed a
11670        // home outside the log stream.
11671        state.runtime_health.set_watermark(true);
11672        let paused = render_stats(state.clone()).await;
11673        // Matched on the paused row's OWN sentence. The bare word "paused" also
11674        // appeared in the page's explanatory prose, so this assertion passed
11675        // whether or not the row rendered — and trimming that prose is what
11676        // exposed it. This phrase exists only inside the `paused` branch.
11677        assert!(
11678            paused.contains("the cache is at its size limit"),
11679            "a watermark pause is still invisible on the public page"
11680        );
11681
11682        // Still no identifiers: these are counts, not feeds.
11683        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
11684            assert!(
11685                !paused.contains(leak),
11686                "the public page leaked {leak:?} while reporting failures"
11687            );
11688        }
11689    }
11690
11691    /// **`/admin/metrics` is gated, and nothing checked that it was.**
11692    ///
11693    /// Deleting the `admin_seed_dids` check left the entire suite green. That
11694    /// was survivable while the page held only aggregate timings; it is not now,
11695    /// because this branch puts **per-feed URLs and remote error text** behind
11696    /// that gate. A guarantee nothing checks is a comment, and this one is now
11697    /// the only thing standing between a signed-in stranger and the operational
11698    /// picture the handler's own doc says is not public.
11699    ///
11700    /// All three doors: no session, a session that is not an admin, and the
11701    /// admin itself.
11702    #[tokio::test]
11703    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11704        let admin = "did:plc:adminseed";
11705        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
11706        // IS that list — deliberately, per its doc: "the same people I trust on
11707        // this instance". Production sets it to the bootstrap DID alone.
11708        //
11709        // A genuine non-admin is therefore someone holding a beta seat granted
11710        // by an invite, not by the allow-list. Seeding both would have made
11711        // both admins and quietly turned the 403 assertion below into a test of
11712        // nothing — which is exactly what the first draft of this did.
11713        let state = test_state(&[admin]).await;
11714        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11715            .await
11716            .unwrap();
11717        let url = "https://broken.example/f.xml";
11718        store::upsert_feed(
11719            &state.db,
11720            &store::NewFeed {
11721                url: url.to_string(),
11722                ..Default::default()
11723            },
11724        )
11725        .await
11726        .unwrap();
11727        store::bump_feed_errors(
11728            &state.db,
11729            url,
11730            feed::FailureKind::Fetch,
11731            "SENTINEL_ADMIN_ONLY",
11732        )
11733        .await
11734        .unwrap();
11735
11736        let get = |state: AppState, cookie: Option<String>| async move {
11737            let mut req = Request::builder().uri("/admin/metrics");
11738            if let Some(c) = cookie {
11739                req = req.header(header::COOKIE, c);
11740            }
11741            let resp = router(state)
11742                .oneshot(req.body(Body::empty()).unwrap())
11743                .await
11744                .unwrap();
11745            let status = resp.status();
11746            let body = String::from_utf8(
11747                axum::body::to_bytes(resp.into_body(), usize::MAX)
11748                    .await
11749                    .unwrap()
11750                    .to_vec(),
11751            )
11752            .unwrap();
11753            (status, body)
11754        };
11755
11756        // No session at all.
11757        let (status, body) = get(state.clone(), None).await;
11758        assert_eq!(status, StatusCode::UNAUTHORIZED);
11759        assert!(
11760            !body.contains("SENTINEL_ADMIN_ONLY"),
11761            "leaked to anonymous: {body}"
11762        );
11763
11764        // A real, signed-in user who is not an admin.
11765        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11766        let (status, body) = get(state.clone(), Some(ordinary)).await;
11767        assert_eq!(
11768            status,
11769            StatusCode::FORBIDDEN,
11770            "a non-admin session was let in"
11771        );
11772        assert!(
11773            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11774            "leaked to a non-admin: {body}",
11775        );
11776
11777        // The admin does get it — otherwise the two refusals above are
11778        // satisfied by the endpoint being broken for everyone.
11779        let admin_cookie = session_cookie(&state, admin, None);
11780        let (status, body) = get(state, Some(admin_cookie)).await;
11781        assert_eq!(status, StatusCode::OK);
11782        assert!(
11783            body.contains("SENTINEL_ADMIN_ONLY"),
11784            "admin cannot see it: {body}"
11785        );
11786    }
11787
11788    /// **The cause a public count cannot carry belongs on the admin page.**
11789    ///
11790    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
11791    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
11792    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
11793    /// have separated "sixty dead publishers" from "one bug here", which is the
11794    /// case it was justified by.
11795    ///
11796    /// The answer is not a finer public vocabulary — `/stats` promises never
11797    /// which feed and never whose, and a bucket per error string would break
11798    /// that. It is to put the detail where per-feed data is already allowed.
11799    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
11800    /// operational picture.
11801    ///
11802    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
11803    /// public one.
11804    #[tokio::test]
11805    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11806        let admin = "did:plc:adminseed";
11807        let state = test_state(&[admin]).await;
11808        let url = "https://broken.example/f.xml";
11809        store::upsert_feed(
11810            &state.db,
11811            &store::NewFeed {
11812                url: url.to_string(),
11813                ..Default::default()
11814            },
11815        )
11816        .await
11817        .unwrap();
11818        store::bump_feed_errors(
11819            &state.db,
11820            url,
11821            feed::FailureKind::Fetch,
11822            "SENTINEL_REDIRECT_NO_LOCATION",
11823        )
11824        .await
11825        .unwrap();
11826
11827        let cookie = session_cookie(&state, admin, None);
11828        let resp = router(state.clone())
11829            .oneshot(
11830                Request::builder()
11831                    .uri("/admin/metrics")
11832                    .header(header::COOKIE, cookie)
11833                    .body(Body::empty())
11834                    .unwrap(),
11835            )
11836            .await
11837            .unwrap();
11838        assert_eq!(resp.status(), StatusCode::OK);
11839        let admin_body = String::from_utf8(
11840            axum::body::to_bytes(resp.into_body(), usize::MAX)
11841                .await
11842                .unwrap()
11843                .to_vec(),
11844        )
11845        .unwrap();
11846        assert!(
11847            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
11848            "the admin page does not carry the failure detail: {admin_body}",
11849        );
11850        assert!(
11851            admin_body.contains("broken.example"),
11852            "the admin page does not name the failing feed: {admin_body}",
11853        );
11854
11855        // The public page still carries neither.
11856        let resp = router(state)
11857            .oneshot(
11858                Request::builder()
11859                    .uri("/stats")
11860                    .body(Body::empty())
11861                    .unwrap(),
11862            )
11863            .await
11864            .unwrap();
11865        let public = String::from_utf8(
11866            axum::body::to_bytes(resp.into_body(), usize::MAX)
11867                .await
11868                .unwrap()
11869                .to_vec(),
11870        )
11871        .unwrap();
11872        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
11873            assert!(
11874                !public.contains(secret),
11875                "{secret:?} reached the PUBLIC stats page: {public}",
11876            );
11877        }
11878    }
11879
11880    /// **A direct poll must settle the error columns, like the scheduler does.**
11881    ///
11882    /// `add_subscription` polls through `feed::poll_feed` rather than the
11883    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
11884    /// touches `consecutive_errors` — that is the scheduler's job, and this path
11885    /// is not the scheduler.
11886    ///
11887    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
11888    /// its old count and its old cause: the public page went on reporting it
11889    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
11890    /// the stale backoff horizon lasted — up to 24h — while the reader was
11891    /// demonstrably fetching it.
11892    #[tokio::test]
11893    async fn a_successful_direct_poll_clears_a_stale_failure() {
11894        let state = test_state(&[]).await;
11895        let url = "https://recovered.example/f.xml";
11896        store::upsert_feed(
11897            &state.db,
11898            &store::NewFeed {
11899                url: url.to_string(),
11900                ..Default::default()
11901            },
11902        )
11903        .await
11904        .unwrap();
11905        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
11906            .await
11907            .unwrap();
11908        // Park it on a stale backoff horizon, as a real failing feed would be.
11909        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
11910            .bind(url)
11911            .execute(&state.db)
11912            .await
11913            .unwrap();
11914
11915        // The publisher is fixed: a successful poll happens on this path.
11916        feed::settle_poll(
11917            &state.db,
11918            url,
11919            &feed::PollOutcome::NotModified,
11920            state.config.poll_interval,
11921        )
11922        .await;
11923
11924        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11925            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11926        )
11927        .bind(url)
11928        .fetch_one(&state.db)
11929        .await
11930        .unwrap();
11931        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
11932        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
11933        // **The half the first fix missed.** Clearing the count fixed the
11934        // REPORTING; the feed stayed parked until 2099. A working feed must be
11935        // rescheduled on its normal cadence, not left on the failure horizon.
11936        let next = row.2.expect("next_poll was cleared to NULL");
11937        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
11938        // backoff. A mutation that reschedules successes with backoff_for(1)
11939        // (5 min) also moves it off 2099, so the interval is asserted.
11940        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11941        let delta = parsed
11942            .signed_duration_since(chrono::Utc::now())
11943            .num_seconds();
11944        let cadence = state.config.poll_interval.as_secs() as i64;
11945        assert!(
11946            (cadence - 60..=cadence + 60).contains(&delta),
11947            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
11948        );
11949    }
11950
11951    /// The mirror case: a first poll that FAILS must be visible at all.
11952    ///
11953    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
11954    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
11955    /// with a NULL cause — invisible to the page built to count exactly that.
11956    #[tokio::test]
11957    async fn a_failing_direct_poll_is_recorded() {
11958        let state = test_state(&[]).await;
11959        let url = "https://born-broken.example/f.xml";
11960        store::upsert_feed(
11961            &state.db,
11962            &store::NewFeed {
11963                url: url.to_string(),
11964                ..Default::default()
11965            },
11966        )
11967        .await
11968        .unwrap();
11969
11970        feed::settle_poll(
11971            &state.db,
11972            url,
11973            &feed::PollOutcome::Failed {
11974                backoff: std::time::Duration::from_secs(300),
11975                kind: feed::FailureKind::Parse,
11976                detail: "SENTINEL_BORN_BROKEN".to_string(),
11977            },
11978            state.config.poll_interval,
11979        )
11980        .await;
11981
11982        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11983            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11984        )
11985        .bind(url)
11986        .fetch_one(&state.db)
11987        .await
11988        .unwrap();
11989        assert_eq!(row.0, 1, "a failed first poll was not counted");
11990        assert_eq!(
11991            row.1.as_deref(),
11992            Some("parse"),
11993            "its cause was not recorded"
11994        );
11995        // And it is BACKED OFF on the schedule the scheduler would use — not
11996        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
11997        // on the very next tick.
11998        let next = row.2.expect("a failed direct poll left next_poll NULL");
11999        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12000        let delta = parsed
12001            .signed_duration_since(chrono::Utc::now())
12002            .num_seconds();
12003        assert!(
12004            (240..=360).contains(&delta),
12005            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
12006        );
12007    }
12008
12009    /// **The breakdown must sum to the Failing figure above it.**
12010    ///
12011    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
12012    /// `consecutive_errors > 0`. On a migrated database every row that was
12013    /// already failing has a NULL kind — correctly, it was never recorded — so
12014    /// the two do not reconcile and the page shows "70 failing" beside "3
12015    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
12016    /// entirely while the prose still promises a breakdown.
12017    ///
12018    /// An explicit `unknown` bucket is the honest shape: the page says how many
12019    /// it cannot explain rather than omitting them.
12020    #[tokio::test]
12021    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
12022        let state = test_state(&[]).await;
12023        // Two legacy rows: failing, with no recorded cause.
12024        for url in [
12025            "https://legacy1.example/f.xml",
12026            "https://legacy2.example/f.xml",
12027        ] {
12028            store::upsert_feed(
12029                &state.db,
12030                &store::NewFeed {
12031                    url: url.to_string(),
12032                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12033                    ..Default::default()
12034                },
12035            )
12036            .await
12037            .unwrap();
12038            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
12039                .bind(url)
12040                .execute(&state.db)
12041                .await
12042                .unwrap();
12043        }
12044        // One row with a recorded cause.
12045        store::upsert_feed(
12046            &state.db,
12047            &store::NewFeed {
12048                url: "https://known.example/f.xml".to_string(),
12049                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12050                ..Default::default()
12051            },
12052        )
12053        .await
12054        .unwrap();
12055        store::bump_feed_errors(
12056            &state.db,
12057            "https://known.example/f.xml",
12058            feed::FailureKind::Status,
12059            "SENTINEL",
12060        )
12061        .await
12062        .unwrap();
12063
12064        let now = chrono::Utc::now();
12065        let health = store::poll_health(
12066            &state.db,
12067            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12068            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12069        )
12070        .await
12071        .unwrap();
12072        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
12073        assert_eq!(
12074            counted, health.in_backoff,
12075            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
12076            health.in_backoff, health.failure_kinds,
12077        );
12078        assert!(
12079            health
12080                .failure_kinds
12081                .iter()
12082                .any(|(k, n)| k == "unknown" && *n == 2),
12083            "no unknown bucket for the legacy rows: {:?}",
12084            health.failure_kinds,
12085        );
12086    }
12087
12088    /// **The breakdown is ordered by count, and the assertion can see it.**
12089    ///
12090    /// The first version of this asserted with three `contains` calls, which
12091    /// cannot observe order — deleting `ORDER BY` from the query passed.
12092    #[tokio::test]
12093    async fn the_failure_breakdown_is_ordered_by_count() {
12094        let state = test_state(&[]).await;
12095        for (url, kind, n) in [
12096            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
12097            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
12098            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
12099            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
12100            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
12101            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
12102        ] {
12103            store::upsert_feed(
12104                &state.db,
12105                &store::NewFeed {
12106                    url: url.to_string(),
12107                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12108                    ..Default::default()
12109                },
12110            )
12111            .await
12112            .unwrap();
12113            for _ in 0..n {
12114                store::bump_feed_errors(&state.db, url, kind, "d")
12115                    .await
12116                    .unwrap();
12117            }
12118        }
12119        let now = chrono::Utc::now();
12120        let health = store::poll_health(
12121            &state.db,
12122            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12123            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12124        )
12125        .await
12126        .unwrap();
12127        let labels: Vec<&str> = health
12128            .failure_kinds
12129            .iter()
12130            .map(|(k, _)| k.as_str())
12131            .collect();
12132        assert_eq!(
12133            labels,
12134            ["fetch", "status", "parse"],
12135            "not ordered by count, descending: {:?}",
12136            health.failure_kinds,
12137        );
12138    }
12139
12140    /// **Failing feeds are grouped by CAUSE, and still never named.**
12141    ///
12142    /// `badly_broken` could say that sixty feeds were failing and not whether
12143    /// that was sixty dead publishers or one bug here. It was the latter — #159,
12144    /// a `304 Not Modified` read as a malformed redirect — and the page could
12145    /// not say so, which is most of why it went unexamined.
12146    ///
12147    /// The second half of this test is the constraint that shapes the first:
12148    /// `/stats` is public and promises machines-not-people, *never which feed
12149    /// and never whose*. A histogram of causes keeps that promise; a list of
12150    /// failing URLs would break it, and is the obvious way to build this.
12151    #[tokio::test]
12152    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
12153        let state = test_state(&[]).await;
12154        for (url, kind, detail, errors) in [
12155            // Detail strings are distinctive SENTINELS, not plausible English.
12156            // A first pass used "not a feed", which the page's own explanation
12157            // of the `parse` kind contains verbatim — the privacy assertion
12158            // fired on static copy rather than on a leak. A sentinel cannot
12159            // collide with prose.
12160            (
12161                "https://a.example/f.xml",
12162                feed::FailureKind::Fetch,
12163                "SENTINEL_CONNREFUSED",
12164                3,
12165            ),
12166            (
12167                "https://b.example/f.xml",
12168                feed::FailureKind::Fetch,
12169                "SENTINEL_DNSFAIL",
12170                2,
12171            ),
12172            (
12173                "https://c.example/f.xml",
12174                feed::FailureKind::Status,
12175                "SENTINEL_404",
12176                1,
12177            ),
12178            (
12179                "https://d.example/f.xml",
12180                feed::FailureKind::Parse,
12181                "SENTINEL_UNPARSEABLE",
12182                1,
12183            ),
12184        ] {
12185            store::upsert_feed(
12186                &state.db,
12187                &store::NewFeed {
12188                    url: url.to_string(),
12189                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12190                    ..Default::default()
12191                },
12192            )
12193            .await
12194            .unwrap();
12195            for _ in 0..errors {
12196                store::bump_feed_errors(&state.db, url, kind, detail)
12197                    .await
12198                    .unwrap();
12199            }
12200        }
12201
12202        let resp = router(state.clone())
12203            .oneshot(
12204                Request::builder()
12205                    .uri("/stats")
12206                    .body(Body::empty())
12207                    .unwrap(),
12208            )
12209            .await
12210            .unwrap();
12211        assert_eq!(resp.status(), StatusCode::OK);
12212        let body = String::from_utf8(
12213            axum::body::to_bytes(resp.into_body(), usize::MAX)
12214                .await
12215                .unwrap()
12216                .to_vec(),
12217        )
12218        .unwrap();
12219
12220        // Descending by count: two fetch, then one each, tie-broken by name.
12221        assert!(
12222            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
12223            "the cause histogram did not render: {body}",
12224        );
12225
12226        // **The privacy half.** No feed URL, host, or error detail reaches the
12227        // public page — only counts by kind.
12228        for secret in [
12229            "a.example",
12230            "b.example",
12231            "c.example",
12232            "d.example",
12233            "SENTINEL_CONNREFUSED",
12234            "SENTINEL_DNSFAIL",
12235            "SENTINEL_404",
12236            "SENTINEL_UNPARSEABLE",
12237        ] {
12238            assert!(
12239                !body.contains(secret),
12240                "{secret:?} reached the PUBLIC stats page: {body}",
12241            );
12242        }
12243    }
12244
12245    /// `/health` must prove the process can reach its database, and must report
12246    /// the loop state without letting it change the status code.
12247    #[tokio::test]
12248    async fn health_checks_the_database_and_reports_the_loops() {
12249        let state = test_state(&[]).await;
12250        let body_of = |state: AppState| async move {
12251            let resp = router(state)
12252                .oneshot(
12253                    Request::builder()
12254                        .uri("/health")
12255                        .body(Body::empty())
12256                        .unwrap(),
12257                )
12258                .await
12259                .unwrap();
12260            let status = resp.status();
12261            let body = String::from_utf8(
12262                axum::body::to_bytes(resp.into_body(), usize::MAX)
12263                    .await
12264                    .unwrap()
12265                    .to_vec(),
12266            )
12267            .unwrap();
12268            (status, body)
12269        };
12270
12271        // The boot stamp is what `main` sets; the router alone does not, so this
12272        // starts "unknown" and the uptime branch below drives it explicitly.
12273        state
12274            .runtime_health
12275            .set_started_at(chrono::Utc::now().timestamp());
12276
12277        let (status, body) = body_of(state.clone()).await;
12278        assert_eq!(status, StatusCode::OK);
12279        assert!(
12280            body.contains("db: ok"),
12281            "health did not probe the DB: {body}"
12282        );
12283        assert!(
12284            body.contains("uptime:"),
12285            "no uptime — the first thing anyone asks about a container that may \
12286             be restarting: {body}"
12287        );
12288        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
12289        assert!(body.contains("polling-paused: no"), "{body}");
12290        assert!(body.contains("backend:"), "{body}");
12291        assert!(body.contains("oauth-runtime:"), "{body}");
12292
12293        // A watermark pause is REPORTED but must not fail the check. A failed
12294        // check DEREGISTERS this machine from the proxy — and it is the only
12295        // machine — so it would turn "feeds are behind" into "the site is down"
12296        // for as long as the disk stays full.
12297        state.runtime_health.set_watermark(true);
12298        state.runtime_health.set_schedulers_enabled(true);
12299        let (status, body) = body_of(state.clone()).await;
12300        assert_eq!(
12301            status,
12302            StatusCode::OK,
12303            "a watermark pause must not fail the liveness check: {body}"
12304        );
12305        assert!(body.contains("polling-paused: yes"), "{body}");
12306        // Schedulers on but no tick yet — and that must not read as "0s ago",
12307        // which is the healthiest possible answer to an unanswered question.
12308        assert!(
12309            body.contains("poller: not-yet-ticked"),
12310            "a never-ticked poller must say so: {body}"
12311        );
12312
12313        // A stale heartbeat is likewise reported, not fatal.
12314        let stale_after = health_tick_stale_secs(configured_poll_tick());
12315        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
12316        state.runtime_health.poll_tick_completed(long_ago);
12317        let (status, body) = body_of(state.clone()).await;
12318        assert_eq!(
12319            status,
12320            StatusCode::OK,
12321            "a stale poller must not 503: {body}"
12322        );
12323        assert!(body.contains("poller: stale"), "{body}");
12324
12325        // **A poller that has never ticked stops being benign.**
12326        //
12327        // In a crash loop with 30 s+ boot cycles the poller never reaches its
12328        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
12329        // could not detect the one failure mode the startup delays were added
12330        // for. It is read against uptime now.
12331        state.runtime_health.poll_tick_completed(0); // reset to "never"
12332        state
12333            .runtime_health
12334            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
12335        let (status, body) = body_of(state.clone()).await;
12336        assert_eq!(status, StatusCode::OK);
12337        assert!(
12338            body.contains("poller: stale never-ticked"),
12339            "a poller that never ticked long after boot still reads as benign: {body}"
12340        );
12341
12342        // A closed pool is a real outage: nothing can be served, and a restart is
12343        // the correct response. THIS is what the status code is for.
12344        state.db.close().await;
12345        let (status, body) = body_of(state.clone()).await;
12346        assert_eq!(
12347            status,
12348            StatusCode::SERVICE_UNAVAILABLE,
12349            "an unreachable database must fail the check: {body}"
12350        );
12351        assert!(body.starts_with("FAIL"), "{body}");
12352        // Coarse, not the raw sqlx error: an unauthenticated caller learning
12353        // exactly which failure it hit is an attack-progress oracle, and this
12354        // endpoint is exempt from the origin lock.
12355        assert!(
12356            !body.contains("PoolClosed") && !body.contains("sqlx"),
12357            "health leaked the raw database error to an unauthenticated caller: {body}"
12358        );
12359    }
12360
12361    /// The staleness threshold must track the configured tick.
12362    ///
12363    /// Hardcoded at 15 minutes, an operator who raised
12364    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
12365    /// in the body the deployment docs tell them to alert on.
12366    #[test]
12367    fn the_stale_threshold_follows_the_poll_tick() {
12368        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
12369        // alerting that early would fire on any brief hiccup.
12370        assert_eq!(
12371            health_tick_stale_secs(Duration::from_secs(60)),
12372            HEALTH_TICK_STALE_FLOOR_SECS
12373        );
12374        // A slow tick raises it, so a legitimately-configured loop is never
12375        // permanently "stale".
12376        let slow = Duration::from_secs(30 * 60);
12377        assert!(
12378            health_tick_stale_secs(slow) > slow.as_secs() as i64,
12379            "a 30-minute tick must not be stale after one interval"
12380        );
12381        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
12382        // And it cannot overflow into nonsense on an absurd value.
12383        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
12384    }
12385
12386    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
12387    ///
12388    /// `polling_paused` alone rendered "running" for three different states,
12389    /// including the two where nothing polls at all — on the page added to
12390    /// answer exactly that question.
12391    #[tokio::test]
12392    async fn stats_does_not_call_a_stopped_poller_running() {
12393        let state = test_state(&[]).await;
12394        let render = |state: AppState| async move {
12395            let resp = router(state)
12396                .oneshot(
12397                    Request::builder()
12398                        .uri("/stats")
12399                        .body(Body::empty())
12400                        .unwrap(),
12401                )
12402                .await
12403                .unwrap();
12404            assert_eq!(resp.status(), StatusCode::OK);
12405            String::from_utf8(
12406                axum::body::to_bytes(resp.into_body(), usize::MAX)
12407                    .await
12408                    .unwrap()
12409                    .to_vec(),
12410            )
12411            .unwrap()
12412        };
12413
12414        // Schedulers never started: not "running".
12415        let body = render(state.clone()).await;
12416        assert!(
12417            body.contains("the poller is not running on this instance"),
12418            "a disabled poller renders as healthy"
12419        );
12420
12421        // Started, but no tick has finished yet.
12422        state.runtime_health.set_schedulers_enabled(true);
12423        let body = render(state.clone()).await;
12424        assert!(
12425            body.contains("no poll has finished since this instance booted"),
12426            "a poller that has not ticked renders as healthy"
12427        );
12428
12429        // Ticking: running.
12430        state
12431            .runtime_health
12432            .poll_tick_completed(chrono::Utc::now().timestamp());
12433        let body = render(state.clone()).await;
12434        assert!(
12435            body.contains("running"),
12436            "a healthy poller must read as running"
12437        );
12438
12439        // Paused at the watermark still wins over "running".
12440        state.runtime_health.set_watermark(true);
12441        let body = render(state.clone()).await;
12442        assert!(
12443            body.contains("the cache is at its size limit"),
12444            "a watermark pause is hidden once the poller is ticking"
12445        );
12446    }
12447
12448    /// **An UNMEASURED database must not fail the check.**
12449    ///
12450    /// `/health` is the one path exempt from the Cloudflare origin lock and
12451    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
12452    /// drop WITHOUT recording a verdict — so a cancelled request (a client
12453    /// disconnect is enough) leaves the verdict at "none", and a concurrent
12454    /// caller reads it. Treating that as a failure turned an unauthenticated
12455    /// request into a lever on the only signal the platform acts on. The
12456    /// previous version of this code had the opposite bug and reported `ok` for
12457    /// a database nothing had read; "unknown" is neither.
12458    #[tokio::test]
12459    async fn health_reports_an_unmeasured_database_without_failing() {
12460        use crate::runtime_health::DbProbe;
12461        let state = test_state(&[]).await;
12462
12463        // Hold the probe claim, exactly as an in-flight request would, and never
12464        // record a verdict — the cancelled-request state.
12465        let held = state
12466            .runtime_health
12467            .begin_db_probe()
12468            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
12469
12470        let resp = router(state.clone())
12471            .oneshot(
12472                Request::builder()
12473                    .uri("/health")
12474                    .body(Body::empty())
12475                    .unwrap(),
12476            )
12477            .await
12478            .unwrap();
12479        let status = resp.status();
12480        let body = String::from_utf8(
12481            axum::body::to_bytes(resp.into_body(), usize::MAX)
12482                .await
12483                .unwrap()
12484                .to_vec(),
12485        )
12486        .unwrap();
12487        drop(held);
12488
12489        assert_eq!(
12490            status,
12491            StatusCode::OK,
12492            "an unmeasured database failed the check, which an unauthenticated \
12493             caller can cause on demand: {body}"
12494        );
12495        assert!(
12496            body.contains("db: unknown"),
12497            "the unmeasured state must still be REPORTED: {body}"
12498        );
12499        assert!(!body.starts_with("FAIL"), "{body}");
12500        // **And it must not read as `ok` either.** `fly.toml` tells operators to
12501        // alert on the BODY for everything the status code ignores, so a first
12502        // line identical to the healthy one makes a monitor keying on `^ok` read
12503        // green in exactly the state this enum exists to surface.
12504        assert!(
12505            !body.starts_with("ok"),
12506            "the unmeasured state is indistinguishable from healthy to a \
12507             body-matching monitor: {body}"
12508        );
12509        assert!(body.starts_with("unknown"), "{body}");
12510
12511        // **A BORROWED failure must 503 too.**
12512        //
12513        // This previously recorded `Failed` and then closed the pool — but
12514        // `record` consumes the guard and releases the claim, so the request won
12515        // it, ran a live probe against the closed pool, and failed on its own.
12516        // The 503 passed for the wrong reason and the borrow path — the whole
12517        // point of the three-state enum on the read side — had no coverage.
12518        //
12519        // Holding the claim forces the borrow, so the recorded verdict is what
12520        // gets reported.
12521        let held = state
12522            .runtime_health
12523            .begin_db_probe()
12524            .unwrap_or_else(|_| panic!("claim"));
12525        state
12526            .runtime_health
12527            .record_for_test(DbProbe::Failed("unavailable".to_string()));
12528        let resp = router(state.clone())
12529            .oneshot(
12530                Request::builder()
12531                    .uri("/health")
12532                    .body(Body::empty())
12533                    .unwrap(),
12534            )
12535            .await
12536            .unwrap();
12537        let status = resp.status();
12538        let body = String::from_utf8(
12539            axum::body::to_bytes(resp.into_body(), usize::MAX)
12540                .await
12541                .unwrap()
12542                .to_vec(),
12543        )
12544        .unwrap();
12545        drop(held);
12546        assert_eq!(
12547            status,
12548            StatusCode::SERVICE_UNAVAILABLE,
12549            "a BORROWED failure verdict must fail the check, not just a freshly \
12550             measured one: {body}"
12551        );
12552        assert!(body.starts_with("FAIL"), "{body}");
12553
12554        state.db.close().await;
12555        let resp = router(state.clone())
12556            .oneshot(
12557                Request::builder()
12558                    .uri("/health")
12559                    .body(Body::empty())
12560                    .unwrap(),
12561            )
12562            .await
12563            .unwrap();
12564        assert_eq!(
12565            resp.status(),
12566            StatusCode::SERVICE_UNAVAILABLE,
12567            "a measured database failure must still fail the check"
12568        );
12569    }
12570
12571    /// **A disconnected client must not be able to cancel the probe.**
12572    ///
12573    /// Axum drops the handler future when a caller goes away. With the probe
12574    /// inline that dropped it mid-flight and released the claim WITHOUT
12575    /// recording a verdict — which let an unauthenticated caller manufacture the
12576    /// no-verdict state on demand and freeze what every other caller, including
12577    /// Fly's own check, reads. The probe runs detached now, so the verdict is
12578    /// recorded whatever happens to the request that started it.
12579    #[tokio::test]
12580    async fn an_abandoned_request_still_records_its_probe() {
12581        use crate::runtime_health::DbProbe;
12582        let state = test_state(&[]).await;
12583        let rh = state.runtime_health.clone();
12584
12585        // Drive /health and abandon it immediately — the disconnect case.
12586        let app = router(state.clone());
12587        let fut = app.oneshot(
12588            Request::builder()
12589                .uri("/health")
12590                .body(Body::empty())
12591                .unwrap(),
12592        );
12593        let handle = tokio::spawn(fut);
12594        handle.abort();
12595        let _ = handle.await;
12596
12597        // The detached probe still completes and publishes a verdict, so the
12598        // claim is free and the next caller gets a MEASURED answer.
12599        for _ in 0..50 {
12600            if rh.begin_db_probe().is_ok() {
12601                break;
12602            }
12603            tokio::time::sleep(Duration::from_millis(20)).await;
12604        }
12605        let resp = router(state.clone())
12606            .oneshot(
12607                Request::builder()
12608                    .uri("/health")
12609                    .body(Body::empty())
12610                    .unwrap(),
12611            )
12612            .await
12613            .unwrap();
12614        let body = String::from_utf8(
12615            axum::body::to_bytes(resp.into_body(), usize::MAX)
12616                .await
12617                .unwrap()
12618                .to_vec(),
12619        )
12620        .unwrap();
12621        assert!(
12622            body.contains("db: ok"),
12623            "after an abandoned request the next caller still reads an \
12624             unmeasured database — the probe was cancelled with it: {body}"
12625        );
12626        // Sanity: the type still distinguishes the three states.
12627        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
12628    }
12629
12630    /// **The probe must read a real page.**
12631    ///
12632    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
12633    /// it never touches a b-tree and returns success against a corrupted
12634    /// database. Asserted by asking SQLite what the statement actually compiles
12635    /// to, so it survives someone "simplifying" the query later.
12636    #[tokio::test]
12637    async fn the_health_probe_opens_a_real_table() {
12638        use sqlx::Row;
12639        let state = test_state(&[]).await;
12640        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
12641        let opcodes = |sql: &'static str| {
12642            let db = state.db.clone();
12643            async move {
12644                sqlx::query(sql)
12645                    .fetch_all(&db)
12646                    .await
12647                    .unwrap()
12648                    .into_iter()
12649                    .map(|r| r.get::<String, _>("opcode"))
12650                    .collect::<Vec<String>>()
12651            }
12652        };
12653
12654        // The statement `health_db_probe` really runs — it is the sole path, so
12655        // there is no second string for the handler to use instead.
12656        let explain: &'static str =
12657            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
12658        let probe = opcodes(explain).await;
12659        // And the probe itself works against a real schema.
12660        assert!(
12661            health_db_probe(&state.db).await.is_ok(),
12662            "the probe does not run against the real schema",
12663        );
12664        assert!(
12665            probe.iter().any(|op| op == "OpenRead"),
12666            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
12667        );
12668        // And the bare form genuinely does not, which is the whole point.
12669        let bare = opcodes("EXPLAIN SELECT 1").await;
12670        assert!(
12671            !bare.iter().any(|op| op == "OpenRead"),
12672            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
12673        );
12674    }
12675
12676    /// A fresh instance says "never", not "0" — which would read as "polled
12677    /// just now", the opposite of the truth.
12678    #[test]
12679    fn an_instance_that_has_never_polled_says_so() {
12680        assert_eq!(humanise_ago(None), "never");
12681        assert_eq!(humanise_ago(Some(0)), "0s ago");
12682        assert_eq!(humanise_ago(Some(59)), "59s ago");
12683        assert_eq!(humanise_ago(Some(60)), "1m ago");
12684        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
12685        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
12686    }
12687
12688    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
12689    /// record, and anything else with an empty list. Serves repeatedly.
12690    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
12691        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12692        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12693        let addr = listener.local_addr().unwrap();
12694        let (url, title) = (saved_url.to_string(), saved_title.to_string());
12695        tokio::spawn(async move {
12696            loop {
12697                let Ok((mut sock, _)) = listener.accept().await else {
12698                    break;
12699                };
12700                let mut buf = vec![0u8; 8192];
12701                let Ok(n) = sock.read(&mut buf).await else {
12702                    continue;
12703                };
12704                let req = String::from_utf8_lossy(&buf[..n]).to_string();
12705                let wants_saved = req.contains("community.lexicon.rss.saved");
12706                let records = if wants_saved {
12707                    serde_json::json!([{
12708                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12709                        "cid": "bafy",
12710                        "value": {
12711                            "$type": "community.lexicon.rss.saved",
12712                            "url": url,
12713                            "title": title,
12714                            "createdAt": "2026-01-01T00:00:00Z"
12715                        }
12716                    }])
12717                } else {
12718                    serde_json::json!([])
12719                };
12720                let body = serde_json::json!({
12721                    "ok": true, "data": { "records": records }
12722                })
12723                .to_string();
12724                let resp = format!(
12725                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12726                    body.len(), body
12727                );
12728                let _ = sock.write_all(resp.as_bytes()).await;
12729                let _ = sock.flush().await;
12730            }
12731        });
12732        format!("http://{addr}")
12733    }
12734
12735    /// A sidecar mock serving `n` distinct saved records, none of them cached
12736    /// locally — the shape that exercises the uncached-row append.
12737    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12738        let feed = subscribed_feed.to_string();
12739        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12740        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12741        let addr = listener.local_addr().unwrap();
12742        tokio::spawn(async move {
12743            loop {
12744                let Ok((mut sock, _)) = listener.accept().await else {
12745                    break;
12746                };
12747                let mut buf = vec![0u8; 8192];
12748                let Ok(read) = sock.read(&mut buf).await else {
12749                    continue;
12750                };
12751                let req = String::from_utf8_lossy(&buf[..read]).to_string();
12752                let records = if req.contains("community.lexicon.rss.saved") {
12753                    serde_json::Value::Array(
12754                        (0..n)
12755                            .map(|i| {
12756                                serde_json::json!({
12757                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12758                                    "cid": "bafy",
12759                                    "value": {
12760                                        "$type": "community.lexicon.rss.saved",
12761                                        "url": format!("https://elsewhere.example/{i}"),
12762                                        "title": format!("Elsewhere {i}"),
12763                                        "createdAt": "2026-01-01T00:00:00Z"
12764                                    }
12765                                })
12766                            })
12767                            .collect(),
12768                    )
12769                } else if req.contains("community.lexicon.rss.subscription") {
12770                    // Without this the handler's `sync_sub_refs` would REPLACE
12771                    // sub_ref with an empty set on every render, and every
12772                    // sub_ref-scoped read — including the cached starred list
12773                    // this test is about — would come back empty.
12774                    serde_json::json!([{
12775                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12776                        "cid": "bafy",
12777                        "value": {
12778                            "$type": "community.lexicon.rss.subscription",
12779                            "url": feed,
12780                            "createdAt": "2026-01-01T00:00:00Z"
12781                        }
12782                    }])
12783                } else {
12784                    serde_json::json!([])
12785                };
12786                let body =
12787                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12788                let resp = format!(
12789                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12790                    body.len(), body
12791                );
12792                let _ = sock.write_all(resp.as_bytes()).await;
12793                let _ = sock.flush().await;
12794            }
12795        });
12796        format!("http://{addr}")
12797    }
12798
12799    /// **The pager must not advertise a page the clamp cannot reach.**
12800    ///
12801    /// The page clamp is computed from the CACHED total; the uncached PDS rows
12802    /// are appended to the last page rather than paged. Inflating `total` with
12803    /// them made `page_count` and the "Older →" link point one page past the end:
12804    /// requesting it clamped straight back, re-rendered the same last page, and
12805    /// still offered the link. An infinite "next" that never advances.
12806    #[tokio::test]
12807    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12808        let did = "did:plc:pagerloop";
12809        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12810        let state = test_state_with_sidecar(&[], &sidecar).await;
12811        store::grant_access(&state.db, did, None, "test", None)
12812            .await
12813            .unwrap();
12814        let feed = store::upsert_feed(
12815            &state.db,
12816            &store::NewFeed {
12817                url: "https://loop.example/feed.xml".to_string(),
12818                title: Some("Loop".to_string()),
12819                ..Default::default()
12820            },
12821        )
12822        .await
12823        .unwrap();
12824        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
12825        // and the old arithmetic reported a fourth page.
12826        let entries: Vec<store::NewEntry> = (0..250)
12827            .map(|i| store::NewEntry {
12828                guid: format!("s-{i:04}"),
12829                url: Some(format!("https://loop.example/{i}")),
12830                title: Some(format!("Starred {i:04}")),
12831                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12832                ..Default::default()
12833            })
12834            .collect();
12835        store::insert_entries(&state.db, feed, &entries, 0)
12836            .await
12837            .unwrap();
12838        store::replace_sub_refs(&state.db, did, &[feed])
12839            .await
12840            .unwrap();
12841        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
12842            .await
12843            .unwrap()
12844        {
12845            store::mark_starred(&state.db, did, row.id, true)
12846                .await
12847                .unwrap();
12848        }
12849
12850        let cookie = session_cookie(&state, did, None);
12851        let app = router(state.clone());
12852        let get = |uri: &str| {
12853            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
12854            async move {
12855                let resp = app
12856                    .oneshot(
12857                        Request::builder()
12858                            .uri(uri)
12859                            .header(header::COOKIE, cookie)
12860                            .body(Body::empty())
12861                            .unwrap(),
12862                    )
12863                    .await
12864                    .unwrap();
12865                assert_eq!(resp.status(), StatusCode::OK);
12866                String::from_utf8(
12867                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
12868                        .await
12869                        .unwrap()
12870                        .to_vec(),
12871                )
12872                .unwrap()
12873            }
12874        };
12875
12876        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
12877        // clamp must agree on that, and EVERY page it offers must have content —
12878        // the original bug advertised a fourth page that clamped back to the
12879        // third and re-rendered it, still offering the link.
12880        let p3 = get("/?view=starred&page=3").await;
12881        assert!(
12882            p3.contains("Page 3 of 4"),
12883            "the pager and the clamp disagree on the total: {}",
12884            p3.split("pager-pos")
12885                .nth(1)
12886                .unwrap_or("")
12887                .chars()
12888                .take(120)
12889                .collect::<String>()
12890        );
12891        // Page 3 is the boundary: the last 50 cached rows, then the first 50
12892        // uncached ones.
12893        assert!(
12894            p3.contains("Elsewhere 0"),
12895            "page 3 should start the uncached run"
12896        );
12897        assert_eq!(
12898            p3.matches("<li class=\"entry").count(),
12899            ENTRIES_PER_PAGE as usize,
12900            "the boundary page is not full"
12901        );
12902
12903        // **The heading, which the previous round broke by deleting this.**
12904        //
12905        // `total` includes the uncached records, so the parenthetical is a
12906        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
12907        // The version that said "plus N" double counted once `total` started
12908        // including them, and N had become page-local in the same commit while
12909        // the template stayed put. It shipped because this assertion was deleted
12910        // rather than updated.
12911        {
12912            let body = &p3;
12913            assert!(
12914                body.contains("330 entries"),
12915                "the heading must count the whole sequence: {}",
12916                body.split("content-count")
12917                    .nth(1)
12918                    .unwrap_or("")
12919                    .chars()
12920                    .take(120)
12921                    .collect::<String>()
12922            );
12923            assert!(
12924                body.contains("(80 saved elsewhere)"),
12925                "the heading must say how many of the total the cache cannot show, \
12926                 as a whole-list figure and not a per-page one: {}",
12927                body.split("content-count")
12928                    .nth(1)
12929                    .unwrap_or("")
12930                    .chars()
12931                    .take(120)
12932                    .collect::<String>()
12933            );
12934            assert!(
12935                !body.contains("plus 50") && !body.contains("plus 80"),
12936                "the heading is adding the uncached rows to a total that already \
12937                 includes them"
12938            );
12939        }
12940
12941        let p4 = get("/?view=starred&page=4").await;
12942        assert!(
12943            p4.contains("Page 4 of 4"),
12944            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
12945        );
12946        assert_eq!(
12947            p4.matches("<li class=\"entry").count(),
12948            30,
12949            "page 4 should hold the remaining 30 uncached records"
12950        );
12951        assert!(
12952            p4.contains("Elsewhere 79"),
12953            "the LAST saved record is unreachable — it can only be removed from here"
12954        );
12955
12956        // No uncached record appears on two pages.
12957        assert!(
12958            !p4.contains("Elsewhere 0"),
12959            "an uncached record was rendered on more than one page"
12960        );
12961        // Page 1 is all cached — and still reports the same whole-list heading,
12962        // because the parenthetical describes the LIST, not the page.
12963        let first = get("/?view=starred").await;
12964        assert!(
12965            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
12966            "the heading changed between pages; it describes the list, not the page"
12967        );
12968        assert!(
12969            !first.contains("Elsewhere "),
12970            "uncached saved records leaked onto the first page"
12971        );
12972    }
12973
12974    /// **A saved record whose article is not cached here is still shown.**
12975    ///
12976    /// The starred view is built from local `entries`, so before this a record
12977    /// starred in ANOTHER atproto reader — the portability the shared lexicon
12978    /// exists for — was simply invisible. It now renders from the PDS record,
12979    /// visually distinct, linking straight out.
12980    #[tokio::test]
12981    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
12982        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
12983        let sidecar =
12984            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
12985        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
12986        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
12987
12988        let resp = router(state)
12989            .oneshot(
12990                Request::builder()
12991                    .uri("/?view=starred")
12992                    .body(Body::empty())
12993                    .unwrap(),
12994            )
12995            .await
12996            .unwrap();
12997        assert_eq!(resp.status(), StatusCode::OK);
12998        let body = String::from_utf8(
12999            axum::body::to_bytes(resp.into_body(), usize::MAX)
13000                .await
13001                .unwrap()
13002                .to_vec(),
13003        )
13004        .unwrap();
13005
13006        assert!(
13007            body.contains("Starred elsewhere"),
13008            "the saved record was not rendered at all"
13009        );
13010        assert!(
13011            body.contains("entry-uncached"),
13012            "it was not marked as uncached, so it looks like a normal entry"
13013        );
13014        assert!(
13015            body.contains("https://elsewhere.example/article"),
13016            "the row must link straight to the article"
13017        );
13018        assert!(
13019            !body.contains("/entries/0/"),
13020            "an uncached row must not offer entry actions against a nonexistent id"
13021        );
13022    }
13023
13024    /// **A PDS `createdAt` must not be able to panic the starred view.**
13025    ///
13026    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
13027    /// timestamp the feed parser produced; the saved-record path passes a bare
13028    /// string off a PDS record, written by whatever client the reader used. A
13029    /// multi-byte value panicked the handler, and with no catch-panic layer the
13030    /// view stayed down until the record was removed — from that same view.
13031    #[test]
13032    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
13033        for hostile in [
13034            "日本語日本語日本",
13035            "é",
13036            "",
13037            "2026",
13038            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
13039        ] {
13040            let out = display_date(Some(hostile));
13041            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
13042        }
13043        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
13044        assert_eq!(display_date(None), "");
13045    }
13046
13047    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
13048    /// its neighbours are limited. It was added as a route and not added here.
13049    #[test]
13050    fn the_unsave_route_is_rate_limited() {
13051        use axum::http::Method;
13052        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
13053        // And the neighbours still are.
13054        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
13055    }
13056
13057    /// **The probe detects a broken database — asserted through `/health`
13058    /// itself, not through a string.**
13059    ///
13060    /// A named constant did not bind the handler: it stayed free to call
13061    /// `query_scalar` with a different literal, so degrading the real probe to
13062    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
13063    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
13064    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
13065    #[tokio::test]
13066    async fn health_reports_a_broken_database() {
13067        let state = test_state(&[]).await;
13068        // Sanity: healthy first, so the assertion below is about the damage.
13069        assert!(
13070            health_db_probe(&state.db).await.is_ok(),
13071            "the fixture was not healthy to begin with",
13072        );
13073
13074        sqlx::query("DROP TABLE feeds")
13075            .execute(&state.db)
13076            .await
13077            .unwrap();
13078
13079        assert!(
13080            health_db_probe(&state.db).await.is_err(),
13081            "the probe reported success against a database missing the table it \
13082             claims to read; `SELECT 1` would do exactly this",
13083        );
13084
13085        let resp = router(state)
13086            .oneshot(
13087                Request::builder()
13088                    .uri("/health")
13089                    .body(Body::empty())
13090                    .unwrap(),
13091            )
13092            .await
13093            .unwrap();
13094        let body = String::from_utf8(
13095            axum::body::to_bytes(resp.into_body(), usize::MAX)
13096                .await
13097                .unwrap()
13098                .to_vec(),
13099        )
13100        .unwrap();
13101        // The documented contract: the FIRST token is the state.
13102        assert!(
13103            body.starts_with("FAIL"),
13104            "/health did not report FAIL for a broken database: {body}",
13105        );
13106        assert!(
13107            !body.contains("db: ok"),
13108            "/health still called the database ok: {body}",
13109        );
13110    }
13111
13112    /// A sidecar mock for the OPML export: serves one subscription and one
13113    /// folder, except for the collection named in `fail_on`, which answers
13114    /// `500` — the shape a refused (short or unreadable) walk takes at this
13115    /// boundary.
13116    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
13117        use tokio::io::{AsyncReadExt, AsyncWriteExt};
13118        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13119        let addr = listener.local_addr().unwrap();
13120        tokio::spawn(async move {
13121            loop {
13122                let Ok((mut sock, _)) = listener.accept().await else {
13123                    break;
13124                };
13125                let mut buf = vec![0u8; 8192];
13126                let Ok(n) = sock.read(&mut buf).await else {
13127                    continue;
13128                };
13129                let req = String::from_utf8_lossy(&buf[..n]).to_string();
13130                let wants = |c: &str| req.contains(c);
13131                if fail_on.is_some_and(wants) {
13132                    let body = r#"{"ok":false,"error":"ShortList"}"#;
13133                    let resp = format!(
13134                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13135                        body.len(),
13136                        body
13137                    );
13138                    let _ = sock.write_all(resp.as_bytes()).await;
13139                    let _ = sock.flush().await;
13140                    continue;
13141                }
13142                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
13143                    serde_json::json!([{
13144                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
13145                        "cid": "bafy",
13146                        "value": {
13147                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
13148                            "url": "https://kept.example/feed.xml",
13149                            "title": "Kept",
13150                            // Inside the folder, so the healthy export has to
13151                            // carry BOTH walks' results: an exporter that lost
13152                            // the folder list would flatten this outline out of
13153                            // its group with nothing else changing.
13154                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13155                            "createdAt": "2026-01-01T00:00:00Z"
13156                        }
13157                    }])
13158                } else if wants(crate::lexicon::nsid::FOLDER) {
13159                    serde_json::json!([{
13160                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13161                        "cid": "bafy",
13162                        "value": {
13163                            "$type": crate::lexicon::nsid::FOLDER,
13164                            "name": "Kept folder",
13165                            "createdAt": "2026-01-01T00:00:00Z"
13166                        }
13167                    }])
13168                } else {
13169                    serde_json::json!([])
13170                };
13171                let body =
13172                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
13173                let resp = format!(
13174                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13175                    body.len(),
13176                    body
13177                );
13178                let _ = sock.write_all(resp.as_bytes()).await;
13179                let _ = sock.flush().await;
13180            }
13181        });
13182        format!("http://{addr}")
13183    }
13184
13185    /// A sidecar whose every `listRecords` page carries one good record and
13186    /// one with no `uri` — the #177 shape — for any collection.
13187    async fn spawn_malformed_sidecar() -> String {
13188        use tokio::io::{AsyncReadExt, AsyncWriteExt};
13189        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13190        let addr = listener.local_addr().unwrap();
13191        tokio::spawn(async move {
13192            loop {
13193                let Ok((mut sock, _)) = listener.accept().await else {
13194                    break;
13195                };
13196                let mut buf = vec![0u8; 8192];
13197                let _ = sock.read(&mut buf).await;
13198                let body = serde_json::json!({ "ok": true, "data": { "records": [
13199                    { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
13200                    { "cid": "bafy", "value": {} },
13201                ]}})
13202                .to_string();
13203                let resp = format!(
13204                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13205                    body.len(),
13206                    body
13207                );
13208                let _ = sock.write_all(resp.as_bytes()).await;
13209                let _ = sock.flush().await;
13210            }
13211        });
13212        format!("http://{addr}")
13213    }
13214
13215    async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
13216        let cookie = session_cookie(&state, did, None);
13217        let resp = router(state)
13218            .oneshot(
13219                Request::builder()
13220                    .uri(uri)
13221                    .header(header::COOKIE, cookie)
13222                    .body(Body::empty())
13223                    .unwrap(),
13224            )
13225            .await
13226            .unwrap();
13227        let status = resp.status();
13228        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
13229            .await
13230            .unwrap();
13231        (status, String::from_utf8_lossy(&body).to_string())
13232    }
13233
13234    /// **0.4.0 step 4: a publication document with neither summary field
13235    /// renders as a title, a date and a link** — 8% of measured documents
13236    /// (37 of 449) carry neither `description` nor `textContent`. That is what
13237    /// an RSS reader shows for a title-only feed, not an error, in the list and
13238    /// on the article page alike.
13239    #[tokio::test]
13240    async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
13241        let did = "did:plc:displayer";
13242        let state = test_state(&[did]).await;
13243        let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
13244        let feed_id = store::upsert_feed(
13245            &state.db,
13246            &store::NewFeed {
13247                url: url.into(),
13248                title: Some("Quiet Journal".into()),
13249                ..Default::default()
13250            },
13251        )
13252        .await
13253        .unwrap();
13254        store::replace_sub_refs(&state.db, did, &[feed_id])
13255            .await
13256            .unwrap();
13257        store::insert_entries(
13258            &state.db,
13259            feed_id,
13260            &[store::NewEntry {
13261                guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
13262                    .into(),
13263                url: Some("https://quiet.example/no-summary".into()),
13264                title: Some("A title-only article".into()),
13265                published: Some("2026-07-11T00:00:00Z".into()),
13266                content_html: None,
13267                ..Default::default()
13268            }],
13269            0,
13270        )
13271        .await
13272        .unwrap();
13273        let (status, list) = page_body(state.clone(), did, "/?view=all").await;
13274        assert_eq!(status, StatusCode::OK);
13275        assert!(
13276            list.contains("A title-only article"),
13277            "the entry is missing from the list"
13278        );
13279
13280        let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
13281            .bind(feed_id)
13282            .fetch_one(&state.db)
13283            .await
13284            .unwrap();
13285        let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
13286        assert_eq!(
13287            status,
13288            StatusCode::OK,
13289            "the article page failed for an entry with no body"
13290        );
13291        assert!(page.contains("A title-only article"));
13292        assert!(
13293            page.contains("https://quiet.example/no-summary"),
13294            "no link to the original"
13295        );
13296        assert!(
13297            page.contains(r#"<time datetime=""#),
13298            "no date on the article page"
13299        );
13300    }
13301
13302    /// **#177: a malformed record in the reader's own repo is refused, and the
13303    /// reader is told.** Refusing keeps `replace_sub_refs` from dropping the
13304    /// subscription that record was; telling them keeps the stale list from
13305    /// looking like the real one. Both the reading page and the manage page.
13306    #[tokio::test]
13307    async fn a_malformed_subscription_record_raises_an_alert() {
13308        let did = "did:plc:alerted";
13309        for page in ["/", "/manage"] {
13310            let sidecar = spawn_malformed_sidecar().await;
13311            let state = test_state_with_sidecar(&[did], &sidecar).await;
13312            let (status, body) = page_body(state, did, page).await;
13313            assert_eq!(status, StatusCode::OK, "{page} did not render");
13314            assert!(
13315                body.contains(r#"role="alert""#) && body.contains("could not be read"),
13316                "{page} rendered no alert for a refused subscription list"
13317            );
13318            assert!(
13319                body.contains("1 record(s) in your subscription list"),
13320                "{page} gave the generic alert, not the malformed-record one"
13321            );
13322        }
13323    }
13324
13325    /// The control: a healthy listing raises no alert.
13326    #[tokio::test]
13327    async fn a_healthy_subscription_listing_raises_no_alert() {
13328        let did = "did:plc:exporter";
13329        let sidecar = spawn_export_sidecar(None).await;
13330        let state = test_state_with_sidecar(&[did], &sidecar).await;
13331        let (status, body) = page_body(state, did, "/").await;
13332        assert_eq!(status, StatusCode::OK);
13333        assert!(
13334            !body.contains("could not be read"),
13335            "a healthy listing raised an alert"
13336        );
13337    }
13338
13339    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
13340    async fn export_opml_response(
13341        fail_on: Option<&'static str>,
13342    ) -> (StatusCode, HeaderMap, String) {
13343        let did = "did:plc:exporter";
13344        let sidecar = spawn_export_sidecar(fail_on).await;
13345        let state = test_state_with_sidecar(&[did], &sidecar).await;
13346        let cookie = session_cookie(&state, did, None);
13347        let resp = router(state)
13348            .oneshot(
13349                Request::builder()
13350                    .uri("/opml/export")
13351                    .header(header::COOKIE, cookie)
13352                    .body(Body::empty())
13353                    .unwrap(),
13354            )
13355            .await
13356            .unwrap();
13357        let status = resp.status();
13358        let headers = resp.headers().clone();
13359        let body = String::from_utf8_lossy(
13360            &axum::body::to_bytes(resp.into_body(), usize::MAX)
13361                .await
13362                .unwrap(),
13363        )
13364        .to_string();
13365        (status, headers, body)
13366    }
13367
13368    /// **An empty export is worse than no export, and this is the caller that
13369    /// used to produce one.**
13370    ///
13371    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
13372    /// truncated walk refuses instead of returning a short list, that turned the
13373    /// refusal into `200 OK` carrying a zero-feed
13374    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
13375    /// the moment a locked-out reader reached for one, and the changelog points
13376    /// them at this route as the recovery path.
13377    ///
13378    /// Asserts the three things a reader can actually observe: no success status,
13379    /// no download offered, and no OPML document in the body.
13380    #[tokio::test]
13381    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
13382        let (status, headers, body) =
13383            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
13384
13385        assert_ne!(
13386            status,
13387            StatusCode::OK,
13388            "a failed subscription walk answered 200: {body}",
13389        );
13390        assert!(
13391            !headers.contains_key(header::CONTENT_DISPOSITION),
13392            "a failed subscription walk still offered a download: {headers:?}",
13393        );
13394        assert!(
13395            !body.contains("<opml"),
13396            "a failed subscription walk still served an OPML document: {body}",
13397        );
13398    }
13399
13400    /// The folders half of the same hole. The two walks are separate calls, and
13401    /// fixing only the first leaves an export that silently loses every folder —
13402    /// a flat list that reimports as one, with no sign anything was lost.
13403    #[tokio::test]
13404    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
13405        let (status, headers, body) =
13406            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
13407
13408        assert_ne!(
13409            status,
13410            StatusCode::OK,
13411            "a failed folder walk answered 200: {body}",
13412        );
13413        assert!(
13414            !headers.contains_key(header::CONTENT_DISPOSITION),
13415            "a failed folder walk still offered a download: {headers:?}",
13416        );
13417        assert!(
13418            !body.contains("<opml"),
13419            "a failed folder walk still served an OPML document: {body}",
13420        );
13421    }
13422
13423    /// The other direction, without which "refuse everything" would pass both
13424    /// tests above: a healthy read still serves the file, with the feed in it.
13425    #[tokio::test]
13426    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
13427        let (status, headers, body) = export_opml_response(None).await;
13428
13429        assert_eq!(
13430            status,
13431            StatusCode::OK,
13432            "a healthy export did not answer 200"
13433        );
13434        assert_eq!(
13435            headers
13436                .get(header::CONTENT_DISPOSITION)
13437                .and_then(|v| v.to_str().ok()),
13438            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
13439            "a healthy export did not offer the download",
13440        );
13441        assert!(
13442            body.contains("https://kept.example/feed.xml"),
13443            "the exported OPML lost the subscription: {body}",
13444        );
13445        assert!(
13446            body.contains("Kept folder"),
13447            "the exported OPML lost the folder: {body}",
13448        );
13449    }
13450}