Skip to main content

feather_reader/
web.rs

1//! The axum web layer — server-rendered HTML + a dash of htmx, **no SPA**.
2//!
3//! This module owns the HTTP surface: [`router`] builds an `axum::Router` over
4//! the shared [`AppState`], wiring the store, feed, atproto, and config seams into
5//! a small set of typography-first, dark-mode-ready views rendered with
6//! `askama` templates (under `templates/`). Progressive enhancement is a single
7//! vendored `htmx` script plus a tiny keyboard handler (`static/keyboard.js`);
8//! every interaction also works as a plain HTML form POST, so the reader is fully
9//! usable with JavaScript disabled.
10//!
11//! ## HTTP surface
12//!
13//! * `GET  /health` — liveness + version, as `text/plain`.
14//! * `GET  /about`, `/standard-site`, `/stats`, `/privacy`, `/terms` — static
15//!   and status pages.
16//! * `GET  /manage` — feed and folder management.
17//! * `GET  /` — the reader: a folders/feeds sidebar (from the PDS records layer)
18//!   plus the main article list. Query params pick the scope (`?feed=…` /
19//!   `?folder=…` / all) and the view (`?view=unread|all|starred`).
20//! * `GET  /entries/{id}` — the clean, distraction-free reader for one entry,
21//!   with prev/next within the current list.
22//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
23//! * `POST /entries/{id}/star` — star/unstar; writes a
24//!   `community.lexicon.rss.saved` record to the user's PDS.
25//! * `POST /saved/{rkey}/delete` — unsave a `saved` record.
26//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
27//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
28//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
29//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
30//! * `POST /folders` — create a folder record.
31//! * `POST /folders/{rkey}/rename` — rename a folder record.
32//! * `POST /folders/{rkey}/delete` — delete a folder record.
33//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
34//!   subscription records in the PDS.
35//! * `GET  /opml/export` — OPML export (records → a downloadable document).
36//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
37//!   OAuth sign-in flow, on whichever backend `FEATHERREADER_REPO_BACKEND`
38//!   selects. `GET /oauth/client-metadata.json` and `/oauth/jwks.json` publish
39//!   the Rust client's identity.
40//! * `POST /account/delete` — revoke the session and drop this DID's local state.
41//! * `GET|POST /beta/redeem` — closed-beta invite redemption.
42//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
43//!   reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
44//!   (sets the reserving cookie → `/login`).
45//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
46//!   code + token/url for the bot to post. Cap-aware (409 when full).
47//! * `POST /admin/invites`, `GET /admin/metrics` — admin-only invite minting and
48//!   the backend metrics view.
49//!
50//! ## Identity — a cookie-resolved atproto session
51//!
52//! Per-request identity comes from a **signed session cookie** (`fr_session`)
53//! keyed by the logged-in DID, set by `oauth_callback` and read by
54//! `current_session` / `current_did`. For local runs without the sidecar,
55//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
56//! All PDS reads and writes route through [`AppState::repo`], which dispatches to
57//! the sidecar or the Rust OAuth client by `FEATHERREADER_REPO_BACKEND`.
58
59use std::collections::HashMap;
60use std::net::IpAddr;
61use std::sync::Mutex;
62use std::time::{Duration, Instant};
63
64use askama::Template;
65use axum::{
66    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
67    http::{header, HeaderMap, StatusCode},
68    middleware::{self, Next},
69    response::{Html, IntoResponse, Redirect, Response},
70    routing::{get, post},
71    Form, Router,
72};
73use serde::Deserialize;
74use std::net::SocketAddr;
75use tower_http::services::{ServeDir, ServeFile};
76use tower_http::set_header::SetResponseHeaderLayer;
77use tower_http::trace::TraceLayer;
78use tracing::{info, warn};
79
80use crate::config::Config;
81use crate::lexicon::{self, Folder, Saved, Subscription};
82use crate::safe_link::SafeLink;
83use crate::sanitized_html::{BodyRender, BodyRenderer};
84use crate::{feed, store, AppState, Session, VERSION};
85
86// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
87// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
88// here via an explicit path so the reader's OPML routes can use the canonical
89// `parse_opml` / `to_opml` without duplicating that logic.
90#[path = "opml.rs"]
91mod opml;
92
93/// The name of the signed session cookie.
94const SESSION_COOKIE: &str = "fr_session";
95
96/// The name of the short-lived signed **invite** cookie.
97///
98/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
99/// OAuth callback. It reserves *intent* to redeem a specific code before the
100/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
101/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
102/// signed with the same key as the session cookie.
103const INVITE_COOKIE: &str = "fr_invite";
104
105/// Browser-binding cookie for an in-flight OAuth login (Rust backend only).
106///
107/// `state` alone cannot stop a login CSRF: it lives in a server-global table, so
108/// a stolen `state` replayed from ANOTHER browser matches just as well as from
109/// the one that started the flow. This cookie is what makes the callback
110/// browser-specific — the pending row stores only its hash, and a callback that
111/// cannot present it is refused.
112const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
113
114/// How long an in-flight login may sit, matching the pending row's own TTL.
115const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
116
117/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
118/// Short enough that a reserved-but-unclaimed seat frees quickly.
119const INVITE_TTL_SECS: i64 = 1800;
120
121/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
122/// sign-in pitch, and `/about`.
123const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
124
125/// The tip / support link (cloud plan public-experiment UI).
126const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
127
128/// The published crate on crates.io — surfaced on the signed-out landing page.
129const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
130
131/// The Content-Security-Policy applied to every response.
132///
133/// Tuned to keep the app fully working while neutralising injected script:
134/// * `default-src 'self'` — same-origin baseline.
135/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
136///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
137///   `javascript:` href (F4) cannot execute. (The design's templates carry no
138///   inline event handlers — every control is wired in `keyboard.js`.)
139/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
140///   inline styles htmx toggles for its request indicators.
141/// * `img-src 'self' https: data:` — feed content routinely embeds remote
142///   images; allow https + data URIs but not other schemes.
143/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
144///   down form posts, `<base>` hijacking, and clickjacking.
145/// * `object-src 'none'` — no plugins.
146const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
147     script-src 'self'; \
148     style-src 'self' 'unsafe-inline'; \
149     img-src 'self' https: data:; \
150     font-src 'self'; \
151     connect-src 'self'; \
152     form-action 'self'; \
153     base-uri 'self'; \
154     frame-ancestors 'none'; \
155     object-src 'none'";
156
157/// The resolved identity for the current request.
158///
159/// `did` is the primary key for all per-user local state; `handle` is display
160/// only; `sid` is the opaque server-side session id the cookie carried (needed
161/// so logout can revoke exactly this session). Sourced from the signed cookie
162/// (real login) or, if none, the configured dev DID fallback.
163#[derive(Clone, Debug)]
164struct CurrentUser {
165    did: String,
166    handle: Option<String>,
167    /// The opaque session id, if this identity came from a real cookie session
168    /// (absent for the dev-DID fallback, which has no server-side session row).
169    sid: Option<String>,
170}
171
172/// Resolve the current request's session from the signed cookie, falling back to
173/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
174///
175/// The cookie carries an opaque server-minted session id (not the DID). We
176/// verify its HMAC, look the id up in the registry, and — crucially —
177/// **re-check the DID against the closed-beta gate on every request**
178/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
179/// DID's beta seat takes effect immediately for already-issued cookies. (The
180/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
181/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
182async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
183    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
184        if let Some(session) = state.sessions.get(&sid) {
185            if store::has_beta_access(&state.db, &session.did)
186                .await
187                .unwrap_or(false)
188            {
189                return Some(CurrentUser {
190                    did: session.did,
191                    handle: session.handle,
192                    sid: Some(sid),
193                });
194            }
195            // DID no longer holds a beta seat: treat as logged out (and drop the
196            // stale server-side session so the dead cookie can't linger).
197            state.sessions.remove(&sid);
198        }
199    }
200    // No valid cookie: dev fallback only if explicitly configured *and* still
201    // inside the beta gate (seeded via ensure_seed / a redeemed code).
202    if let Some(did) = state.config.dev_did.clone() {
203        if store::has_beta_access(&state.db, &did)
204            .await
205            .unwrap_or(false)
206        {
207            return Some(CurrentUser {
208                did,
209                handle: None,
210                sid: None,
211            });
212        }
213    }
214    None
215}
216
217/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
218async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
219    current_session(state, headers).await.map(|u| u.did)
220}
221
222/// Build the application router over shared [`AppState`].
223///
224/// Wires the reader routes, the health check, and the `/static` asset mount
225/// (the stylesheet, vendored htmx, and the keyboard handler, served from
226/// `static/` via `ServeDir`). A `TraceLayer` gives per-request tracing.
227pub fn router(state: AppState) -> Router {
228    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
229    // and the write endpoints). One instance is cloned into the state closure of
230    // the `rate_limit` middleware.
231    let limiter = RateLimiter::shared();
232    // The trusted client-IP source for the limiter (a proxy header the operator
233    // controls, or the socket peer when unset). Bundled with the limiter so the
234    // middleware derives a spoof-resistant IP.
235    let rl_state = RateLimitState {
236        limiter,
237        trusted_header: state.config.trusted_ip_header.clone(),
238    };
239
240    Router::new()
241        .route("/health", get(health))
242        .route("/about", get(about))
243        .route("/standard-site", get(standard_site))
244        .route("/stats", get(stats))
245        .route("/privacy", get(privacy))
246        .route("/terms", get(terms))
247        .route("/manage", get(manage))
248        .route("/", get(index))
249        .route("/entries/{id}", get(entry_view))
250        .route("/entries/{id}/read", post(mark_read))
251        .route("/entries/{id}/star", post(toggle_star))
252        .route("/saved/{rkey}/delete", post(unsave_record))
253        .route("/read-all", post(mark_all_read))
254        .route("/subscriptions", post(add_subscription))
255        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
256        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
257        .route("/folders", post(create_folder))
258        .route("/folders/{rkey}/rename", post(rename_folder))
259        .route("/folders/{rkey}/delete", post(delete_folder))
260        // OPML import takes untrusted uploads: cap the body so a huge upload
261        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
262        .route(
263            "/opml",
264            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
265        )
266        .route("/opml/export", get(export_opml))
267        .route("/login", get(login_form).post(login_submit))
268        .route(
269            "/beta/redeem",
270            get(beta_redeem_form).post(beta_redeem_submit),
271        )
272        // The follow→invite bot's claim link: a public skeet points a new
273        // follower here with an opaque token that reserves a pre-minted code.
274        .route("/claim", get(claim))
275        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
276        // code + returns its token/url for the bot to post.
277        .route("/bot/claims", post(bot_mint_claim))
278        .route("/admin/invites", post(admin_mint_invites))
279        .route("/admin/metrics", get(admin_metrics))
280        .route("/oauth/client-metadata.json", get(oauth_client_metadata))
281        .route("/oauth/jwks.json", get(oauth_jwks))
282        .route("/account/delete", post(account_delete))
283        .route("/oauth/callback", get(oauth_callback))
284        .route("/logout", post(logout))
285        .nest_service("/static", ServeDir::new("static"))
286        // Browsers (and some feed clients) request /favicon.ico at the root
287        // regardless of the <link rel="icon"> tags; serve the same icon that
288        // lives under /static so the bare path stops 404-ing.
289        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
290        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
291        // logged-out landing + static assets, `no-store` on anything that
292        // rendered a session's private view. Runs *inside* the security layers so
293        // the CSP/nosniff/frame headers are untouched.
294        .layer(middleware::from_fn(cache_control))
295        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
296        // as a middleware so it sees the matched path + the peer IP.
297        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
298        .layer(TraceLayer::new_for_http())
299        // Baseline security headers on *every* response (F4). The CSP is the
300        // backstop that neutralises any XSS that slips past sanitization; the
301        // others harden sniffing, framing, and referrer leakage.
302        .layer(static_header_layer(
303            "content-security-policy",
304            CONTENT_SECURITY_POLICY,
305        ))
306        .layer(static_header_layer("x-content-type-options", "nosniff"))
307        .layer(static_header_layer(
308            "referrer-policy",
309            "strict-origin-when-cross-origin",
310        ))
311        .layer(static_header_layer("x-frame-options", "DENY"))
312        .with_state(state)
313}
314
315/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
316/// axum's 2 MiB default.**
317///
318/// The value used to BE the framework default, which made the route's own
319/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
320/// nothing could test it, and the ceiling this route wanted was whatever the
321/// framework happened to pick. Sized to this route instead — one outline is
322/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
323/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
324/// larger is not a subscription list.
325///
326/// Being strictly tighter than the default is what makes the layer both real
327/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
328/// uploads a payload that only this limit refuses.
329const OPML_BODY_LIMIT: usize = 1024 * 1024;
330
331/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
332/// uploads a payload between the two ceilings.
333///
334/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
335/// number is exact — axum does not export it, so it cannot be imported. The
336/// exceeding is what the test's mutation demonstrates: with the route's layer
337/// removed, a payload of this size is accepted. If axum ever lowers its
338/// default below ours, that mutation stops failing and the compile-time
339/// assertion below is the thing to revisit.
340#[cfg(test)]
341const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
342
343/// The route's cap must stay strictly tighter than the framework's, or its
344/// layer is a no-op again. A compile error, not a test failure: this is a
345/// property of the two constants, and nothing should be able to build a binary
346/// where it is false.
347#[cfg(test)]
348const _: () = assert!(
349    OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
350    "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
351);
352
353/// A response-header layer that sets `name: value` on every response, overriding
354/// any existing header of that name. `name`/`value` must be valid static header
355/// tokens (they are, for our fixed security headers).
356fn static_header_layer(
357    name: &'static str,
358    value: &'static str,
359) -> SetResponseHeaderLayer<header::HeaderValue> {
360    SetResponseHeaderLayer::overriding(
361        header::HeaderName::from_static(name),
362        header::HeaderValue::from_static(value),
363    )
364}
365
366// ---------------------------------------------------------------------------
367// Per-IP rate limiting (token bucket, self-contained — no extra crate)
368// ---------------------------------------------------------------------------
369
370/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
371/// kick-off and callback, the invite redeem, logout, the mutating write
372/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
373/// intentionally *not* limited.
374///
375/// The criterion is **does this path make an outbound request**, not "does it
376/// mutate" — the two diverge, and every miss so far has been on the outbound
377/// side. This is an allowlist a new route has to be added to by hand, which is
378/// exactly why it has now been missed three times: `/saved/` (fixed), then
379/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
380/// only path here reachable with no session at all.
381///
382/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
383/// make PDS calls but are ordinary authenticated navigation, and throttling them
384/// would degrade normal reading. They are bounded by needing a valid session.
385fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
386    use axum::http::Method;
387    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
388    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
389    // like the other abuse-prone entry points — not just `/login`.
390    // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
391    // real outbound round-trip — a sidecar `resolve_session` or a full token
392    // exchange against a PDS. Anyone could spend one outbound request per hit.
393    // It is the only entry point here that needs no session at all.
394    if method != Method::POST
395        && !(method == Method::GET
396            && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
397    {
398        return false;
399    }
400    match path {
401        // `/logout` and `/oauth/callback` are here because they make outbound
402        // calls, not because they mutate: logout revokes at the PDS (up to two
403        // round-trips) and the callback exchanges a code. The list is by
404        // *network cost*, which is what the limiter is actually for.
405        "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
406        | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
407        | "/folders" => true,
408        // Every per-record subscription/folder mutation (delete/rename) and the
409        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
410        p => {
411            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
412                // Unsaving makes a DPoP-signed deleteRecord round-trip to the
413                // PDS, which is exactly the reason the neighbours above are
414                // limited. It was added as a new route and not added here.
415                || p.starts_with("/saved/")
416                || p.starts_with("/subscriptions/")
417                || p.starts_with("/folders/")
418        }
419    }
420}
421
422/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
423/// client-IP header (if any). Cloned into every request; both fields are cheap.
424#[derive(Clone)]
425struct RateLimitState {
426    limiter: RateLimiter,
427    /// The lowercased proxy header the operator trusts for the client IP, or
428    /// `None` to trust only the socket peer. See [`client_ip`].
429    trusted_header: Option<String>,
430}
431
432/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
433/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
434/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
435/// dependency → no network fetch at build, deterministic offline CI).
436#[derive(Clone)]
437struct RateLimiter {
438    inner: std::sync::Arc<Mutex<RateLimiterState>>,
439}
440
441/// The limiter's shared state: the buckets plus when they were last swept.
442struct RateLimiterState {
443    buckets: HashMap<IpAddr, Bucket>,
444    last_sweep: Instant,
445}
446
447/// One IP's token bucket: a fractional token count + the last-refill instant.
448struct Bucket {
449    tokens: f64,
450    last: Instant,
451}
452
453/// Burst capacity per IP — how many requests can arrive back-to-back.
454const RATE_BURST: f64 = 20.0;
455/// Steady-state refill rate (tokens/sec) once the burst is spent.
456const RATE_REFILL_PER_SEC: f64 = 1.0;
457/// Evict idle buckets older than this so the map can't grow unbounded.
458const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
459
460/// How often the idle sweep may actually run.
461///
462/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
463/// map to find entries that, by construction, can only age out on an hour
464/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
465/// under any volume of distinct source IPs the server spent its single shared
466/// core re-walking a map whose contents had not changed. Once a minute is
467/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
468const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
469
470/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
471/// target, sized so ordinary traffic never reaches it.
472///
473/// The idle eviction above was the only bound, and it is a TIME bound, which
474/// says nothing about how many distinct IPs can arrive inside one hour.
475/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
476/// this one did not.
477const MAX_RATE_BUCKETS: usize = 10_000;
478
479/// When the cap is hit, evict down to this fraction of it rather than removing
480/// a single entry — so the O(n) eviction happens once per `cap/8` requests
481/// instead of once per request while the map sits full.
482const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
483
484impl RateLimiter {
485    /// A fresh, shared limiter (cloned into the middleware state).
486    fn shared() -> Self {
487        Self {
488            inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
489                buckets: HashMap::new(),
490                last_sweep: Instant::now(),
491            })),
492        }
493    }
494
495    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
496    /// bucket is empty (→ 429).
497    fn check(&self, ip: IpAddr) -> bool {
498        self.check_at(ip, Instant::now())
499    }
500
501    /// [`check`](Self::check) with the clock injected, so the sweep and eviction
502    /// paths below are reachable in a test without sleeping through an hour.
503    fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
504        let mut state = match self.inner.lock() {
505            Ok(m) => m,
506            // A poisoned lock shouldn't take the site down — fail open.
507            Err(p) => p.into_inner(),
508        };
509
510        // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
511        if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
512            state
513                .buckets
514                .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
515            state.last_sweep = now;
516        }
517
518        // Hard size bound, independent of the time bound above.
519        //
520        // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
521        // attacker cannot use eviction to clear their OWN throttled bucket: that
522        // bucket is by definition the most recently touched, so it is the last
523        // thing this removes. Going quiet long enough to become the oldest entry
524        // is exactly what the refill already grants for free.
525        if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
526            let mut by_age: Vec<(IpAddr, Instant)> =
527                state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
528            by_age.sort_unstable_by_key(|(_, last)| *last);
529            for (victim, _) in by_age
530                .into_iter()
531                .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
532            {
533                state.buckets.remove(&victim);
534            }
535            warn!(
536                buckets = state.buckets.len(),
537                "rate-limit bucket cap reached; evicted the least recently seen clients"
538            );
539        }
540
541        let bucket = state.buckets.entry(ip).or_insert(Bucket {
542            tokens: RATE_BURST,
543            last: now,
544        });
545        let elapsed = now.duration_since(bucket.last).as_secs_f64();
546        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
547        bucket.last = now;
548        if bucket.tokens >= 1.0 {
549            bucket.tokens -= 1.0;
550            true
551        } else {
552            false
553        }
554    }
555}
556
557/// The **trusted** client IP for a request.
558///
559/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
560/// is fully bypassable — the left-most value is attacker-supplied (any client
561/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
562/// bucket and the per-IP limit never bites. We therefore derive the IP only from
563/// a source the operator controls:
564///
565/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
566///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
567///   set by the proxy we run in front and overwrites any client-supplied copy.
568///   We take the LAST value if the header happens to be a comma list (the hop
569///   the trusted proxy appended), which is also the correct read for a
570///   right-most-`X-Forwarded-For` deployment where the operator points
571///   `trusted_header` at `x-forwarded-for`.
572/// * Otherwise we ignore all forwarding headers and use the socket peer
573///   (`ConnectInfo`) — correct for a direct bind with no proxy.
574///
575/// Returns `None` only when neither source yields a parseable IP (the limiter
576/// then fails open for that one request).
577fn client_ip(
578    headers: &HeaderMap,
579    conn: Option<&SocketAddr>,
580    trusted_header: Option<&str>,
581) -> Option<IpAddr> {
582    if let Some(name) = trusted_header {
583        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
584            // Right-most hop is the one the trusted proxy appended; earlier
585            // entries may be client-forged, so never trust the left-most.
586            if let Some(last) = raw.split(',').next_back() {
587                if let Ok(ip) = last.trim().parse::<IpAddr>() {
588                    return Some(ip);
589                }
590            }
591        }
592        // Trusted header absent/unparseable → fall through to the socket peer.
593    }
594    conn.map(|s| s.ip())
595}
596
597/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
598/// empty; every other request (and every non-guarded path) passes through. The
599/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
600/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
601async fn rate_limit(
602    State(rl): State<RateLimitState>,
603    req: axum::extract::Request,
604    next: Next,
605) -> Response {
606    let path = req.uri().path().to_string();
607    let method = req.method().clone();
608    if is_rate_limited_path(&path, &method) {
609        let conn = req
610            .extensions()
611            .get::<ConnectInfo<SocketAddr>>()
612            .map(|c| c.0);
613        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
614        // Deliberately fail OPEN when no client IP is derivable (no trusted
615        // header / no socket peer): there is no per-IP key to enforce, and a
616        // blanket 429 would self-DoS every guarded path (incl. /login). This is
617        // safe precisely because we never key on an attacker-forged XFF — see
618        // `rate_limit_ignores_spoofed_xff_rotation`.
619        if let Some(ip) = ip {
620            if !rl.limiter.check(ip) {
621                warn!(%ip, %path, "rate limit exceeded");
622                return (
623                    StatusCode::TOO_MANY_REQUESTS,
624                    [(header::RETRY_AFTER, "1")],
625                    "rate limit exceeded\n",
626                )
627                    .into_response();
628            }
629        }
630    }
631    next.run(req).await
632}
633
634// ---------------------------------------------------------------------------
635// Cache-Control (viral / CDN vs. private authenticated views)
636// ---------------------------------------------------------------------------
637
638/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
639/// logged-out surfaces (the `/login` landing without a handle, `/about`,
640/// `/standard-site`, `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
641/// authenticated app pages, so a CDN /
642/// browser can hold the viral landing while never caching a signed-in user's
643/// private view. Never overrides a handler that already set Cache-Control.
644async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
645    let path = req.uri().path().to_string();
646    // The logged-out landing is only cacheable when it's the bare form — a
647    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
648    let is_login_landing = path == "/login"
649        && req.method() == axum::http::Method::GET
650        && !req.uri().query().unwrap_or("").contains("handle=");
651    let public = is_login_landing
652        || path == "/about"
653        || path == "/standard-site"
654        || path == "/privacy"
655        || path == "/terms"
656        || path.starts_with("/static/");
657
658    let mut resp = next.run(req).await;
659    if resp.headers().contains_key(header::CACHE_CONTROL) {
660        return resp;
661    }
662    let value = if public {
663        "public, max-age=300"
664    } else {
665        "no-store"
666    };
667    if let Ok(hv) = header::HeaderValue::from_str(value) {
668        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
669    }
670    resp
671}
672
673// ---------------------------------------------------------------------------
674// Health
675// ---------------------------------------------------------------------------
676
677/// Run `/health`'s database probe. **The single path, so a test cannot assert
678/// on a string the handler is free to ignore** — a named constant alone was not
679/// enough: the test read the constant while the handler passed `query_scalar`
680/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
681async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
682    sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
683        .fetch_optional(pool)
684        .await
685}
686
687/// The statement `/health` uses to prove the database is readable.
688///
689/// **A named constant so the test can assert on the query that actually runs.**
690/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
691/// this string, so degrading the real probe to `SELECT 1` — which opens no page
692/// and therefore cannot detect a broken database — left the suite green.
693const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
694
695/// How long `/health` will wait for its database ping before calling it broken.
696///
697/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
698/// handler chose rather than a timeout Fly inferred — the difference between a
699/// log line that says why and one that says nothing.
700const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
701
702/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
703/// see the handler for why.
704///
705/// The threshold itself is derived from the configured tick
706/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
707/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
708/// stale` in the body the deployment docs now tell them to alert on.
709const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
710
711/// How long without a completed tick before the poller reads as stale: several
712/// tick intervals, floored, so a normally-paced loop never trips it and a
713/// genuinely wedged one always does.
714fn health_tick_stale_secs(tick: Duration) -> i64 {
715    let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
716    tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
717}
718
719/// The poll tick this instance is configured for. Read from the same env var
720/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
721/// handler cannot see its constants.
722fn configured_poll_tick() -> Duration {
723    std::env::var("FEATHERREADER_POLL_TICK_SECS")
724        .ok()
725        .and_then(|v| v.trim().parse::<u64>().ok())
726        .filter(|s| *s > 0)
727        .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
728}
729
730/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
731/// so cannot be imported here. Duplicated deliberately and named, rather than
732/// left as a bare `60` inside the parse chain, so the drift is at least visible
733/// if the scheduler's value ever moves.
734const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
735
736/// Grace period after boot before a poller that has never ticked is called
737/// `stale` rather than `not-yet-ticked`.
738///
739/// Without this the two are indistinguishable forever, which matters precisely
740/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
741/// cycles the poller never reaches its first tick, so `/health` reported the
742/// benign `not-yet-ticked` on every single probe and the heartbeat could not
743/// detect the failure mode it exists for. `run_poller` returning early — a failed
744/// HTTP client build — has the same shape and was equally invisible.
745///
746/// Sized off the poller's own startup delay plus its tick, with slack.
747const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
748
749/// `GET /health` — does this process still work, and what are its loops doing?
750///
751/// This used to return a constant string, touching no database, no pool and no
752/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
753/// sole other failure detector is a child process exiting. It proved the HTTP
754/// listener was up and nothing else.
755///
756/// **What can fail the check: the database, and only the database.** A process
757/// that cannot reach its store serves nothing, so a restart is the right
758/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
759/// mode is not blocked by any writer — so the retention sweep, the poller and a
760/// login burst cannot make this flap. That property is the reason it is a read
761/// and not, say, a write canary.
762///
763/// **What is reported but never fails the check: everything else.** A stale poll
764/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
765/// and none of them a reason to stop serving.
766///
767/// That last clause is the whole justification, and it is NOT the one this
768/// comment used to give. It said "Fly restarts on a failed check", which is
769/// false — verified against Fly's own docs, which state it three times: *"your
770/// Machines won't automatically restart or stop due to failing their health
771/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
772/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
773/// V1 (`restart_limit`) and has no successor on Machines.
774///
775/// The corrected model makes the conclusion stronger, not weaker. With one
776/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
777/// failover — it is a total outage that lasts exactly as long as the condition,
778/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
779/// question the status code answers is not "would a restart fix this" but **"can
780/// this process still serve a useful request at all"**. A stale poller can. A
781/// database it cannot read cannot.
782///
783/// Re-registration is automatic: the proxy keeps probing and routes again the
784/// moment the check passes. That is what makes a 503 recoverable without
785/// intervention — not a restart, which never comes.
786///
787/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
788/// it is publishable on the same terms as `/stats`. It is also the non-session
789/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
790/// (which needs a live admin session) is exactly as unreachable as the thing it
791/// would diagnose, while this is reachable with `curl`.
792async fn health(State(state): State<AppState>) -> Response {
793    let now = chrono::Utc::now().timestamp();
794    let rh = &state.runtime_health;
795
796    use crate::runtime_health::DbProbe;
797    let db = match rh.begin_db_probe() {
798        // A probe is already in flight; report its predecessor rather than
799        // starting a second one. See `RuntimeHealth::begin_db_probe`.
800        Err(borrowed) => borrowed,
801        Ok(probe) => {
802            // **Spawned, so the probe cannot be cancelled by the caller.**
803            //
804            // Axum drops the handler future when a client disconnects. With the
805            // probe inline, that dropped it mid-flight and released the claim
806            // WITHOUT recording a verdict — which let an unauthenticated caller
807            // manufacture the no-verdict state on demand and freeze what every
808            // other caller, Fly's check included, reads. Running it detached
809            // means the verdict is always recorded and the claim is always
810            // released after it.
811            let pool = state.db.clone();
812            let task = tokio::spawn(async move {
813                // **`SELECT 1` was not a database probe.** It compiles to
814                // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
815                // never touches a b-tree, never reads a page, and never consults
816                // the file. Against a corrupted database it returns success
817                // while every real query returns SQLITE_CORRUPT. Reading one row
818                // from a real table costs the same and actually proves what the
819                // check claims. `LIMIT 1` keeps it to a single page; an empty
820                // table still opens the b-tree root, which is the part that
821                // matters.
822                let verdict =
823                    match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
824                        Ok(Ok(_)) => DbProbe::Ok,
825                        // Coarse, not the raw error. An unauthenticated caller
826                        // learning exactly which failure it hit is an
827                        // attack-progress oracle; the detail belongs in the log,
828                        // which gets it here.
829                        Ok(Err(err)) => {
830                            warn!(%err, "health: database probe failed");
831                            DbProbe::Failed("unavailable".to_string())
832                        }
833                        Err(_) => {
834                            warn!(
835                                timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
836                                "health: database probe timed out (pool exhausted?)"
837                            );
838                            DbProbe::Failed("timeout".to_string())
839                        }
840                    };
841                probe.record(verdict.clone());
842                verdict
843            });
844            // A panicking task drops the guard, which releases the claim without
845            // a verdict — the only remaining path to that state, and not one a
846            // caller can drive.
847            task.await.unwrap_or(DbProbe::Unknown)
848        }
849    };
850
851    let uptime = rh.uptime_secs(now);
852    let poller = if !rh.schedulers_enabled() {
853        // Not a fault. Dev runs and the seam tests disable the loops on purpose,
854        // and reporting that as "stale" would be a false alarm on every one.
855        "disabled".to_string()
856    } else {
857        match rh.secs_since_poll_tick(now) {
858            // "Never ticked" is benign right after boot and alarming well after
859            // it — so it is read against UPTIME, not left permanently benign.
860            None => match uptime {
861                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
862                    format!("stale never-ticked {up}s")
863                }
864                _ => "not-yet-ticked".to_string(),
865            },
866            Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
867                format!("stale {secs}s")
868            }
869            Some(secs) => format!("ok {secs}s"),
870        }
871    };
872
873    // **Only a MEASURED failure fails the check.**
874    //
875    // `Unknown` means no probe has completed — a concurrent request arrived
876    // before the first one finished, or a previous owner was cancelled before
877    // recording. It is reported and returns 200, because an unmeasured database
878    // is not evidence of a broken one, and this endpoint is reachable by
879    // unauthenticated callers who can manufacture that state. Treating it as a
880    // failure handed them a lever on the only signal the platform acts on.
881    let mut body = String::new();
882    let status = match &db {
883        DbProbe::Ok => {
884            body.push_str(&format!("ok featherreader/{VERSION}\n"));
885            body.push_str("db: ok\n");
886            StatusCode::OK
887        }
888        // **Not `ok`.** The first token is the state, and this one is neither
889        // healthy nor failed. It used to print a line byte-identical to the
890        // healthy branch, which mattered because `fly.toml` tells operators to
891        // alert on the BODY for everything the status code deliberately ignores
892        // — so a monitor keying on `^ok` read green in exactly the state this
893        // enum exists to make visible.
894        DbProbe::Unknown => {
895            body.push_str(&format!("unknown featherreader/{VERSION}\n"));
896            body.push_str("db: unknown (no probe has completed yet)\n");
897            StatusCode::OK
898        }
899        DbProbe::Failed(why) => {
900            body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
901            body.push_str(&format!("db: {why}\n"));
902            StatusCode::SERVICE_UNAVAILABLE
903        }
904    };
905    // Uptime answers the first question anyone asks about a container under a
906    // supervisor that tears the machine down whenever a child exits: is this
907    // thing restarting? Nothing else on any surface could tell you.
908    body.push_str(&format!(
909        "uptime: {}\n",
910        match uptime {
911            Some(secs) => format!("{secs}s"),
912            None => "unknown".to_string(),
913        }
914    ));
915    body.push_str(&format!("poller: {poller}\n"));
916    body.push_str(&format!(
917        "polling-paused: {}\n",
918        if rh.watermark_paused() { "yes" } else { "no" }
919    ));
920    // Deliberately NOT the measured database size. `/health` is the one path
921    // exempted from the Caddy origin lock, so it answers direct hits to the Fly
922    // IP that never passed Cloudflare — which caps what belongs here at the
923    // class of facts `/stats` already publishes to anyone. "Polling is paused"
924    // is that; the exact byte count is a precise internal number that adds
925    // nothing an operator cannot get from `/stats` or the logs.
926    body.push_str(&format!(
927        "backend: {}\n",
928        state.config.repo_backend.as_str()
929    ));
930    body.push_str(&format!(
931        "oauth-runtime: {}\n",
932        if state.oauth.is_some() {
933            "built"
934        } else {
935            "absent"
936        }
937    ));
938
939    // Never cached: a stale health response is worse than none, and Cloudflare
940    // sits in front of this.
941    let mut resp = (status, body).into_response();
942    if let Ok(hv) = header::HeaderValue::from_str("no-store") {
943        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
944    }
945    resp
946}
947
948/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
949/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
950/// Readable whether or not a session exists.
951///
952/// Optionally carries one quiet line about network adoption
953/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
954/// default — the handler issues **zero** queries and the page is byte-identical
955/// to what it was before the probe existed.
956async fn about(State(state): State<AppState>) -> Response {
957    let adoption = if state.config.show_adoption {
958        adoption_line(&state).await
959    } else {
960        None
961    };
962    render(&AboutTemplate {
963        card: Card::public(
964            &state.config,
965            "/about",
966            "About — FeatherReader",
967            "What FeatherReader is and isn't: an open-source, atproto-native reader for \
968             RSS feeds and standard.site publications, run as an experiment, free to \
969             self-host under the AGPL.",
970        ),
971        version: VERSION,
972        repo_url: REPO_URL,
973        kofi_url: KOFI_URL,
974        adoption,
975        standard_site: state.config.standard_site,
976    })
977}
978
979/// `GET /standard-site` — the public feature page for standard.site
980/// publications: what a publication is, what FeatherReader shows from one, how
981/// to subscribe, the limits, and the latest releases. Readable whether or not
982/// a session exists, like `/about`. Every how-to-subscribe line is conditional
983/// on `Config::standard_site`, as on the other public pages.
984async fn standard_site(State(state): State<AppState>) -> Response {
985    render(&StandardSiteTemplate {
986        card: Card::public(
987            &state.config,
988            "/standard-site",
989            "standard.site — FeatherReader",
990            "Read standard.site publications beside your RSS feeds: articles \
991             published as atproto records, followed with the same portable \
992             subscription record.",
993        ),
994        version: VERSION,
995        repo_url: REPO_URL,
996        kofi_url: KOFI_URL,
997        standard_site: state.config.standard_site,
998        releases: RELEASES,
999    })
1000}
1001
1002/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
1003///
1004/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
1005/// does not have. This deletes the record straight from the repo by its rkey,
1006/// and then clears any LOCAL star for the same article.
1007///
1008/// That second step is not belt-and-braces. "Has no local entry" is how the
1009/// starred view classifies a record, and it decides that through `sub_ref` — so
1010/// an article that really is cached, and really is starred, lands here whenever
1011/// the reader has unsubscribed from its feed. Deleting only the record left
1012/// `entry_state.starred = 1` behind: invisible, because the starred list is
1013/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
1014/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
1015/// places it lives.
1016async fn unsave_record(
1017    State(state): State<AppState>,
1018    headers: HeaderMap,
1019    Path(rkey): Path<String>,
1020) -> Response {
1021    let Some(did) = current_did(&state, &headers).await else {
1022        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1023    };
1024
1025    // Read the record's identity BEFORE deleting it — afterwards there is
1026    // nothing left to learn it from. Best-effort: a failure here must not block
1027    // the deletion the reader actually asked for, so it degrades to the old
1028    // behaviour (record gone, local star possibly stale) and says so.
1029    let identity = match state.repo().list_saved(&did).await {
1030        Ok(records) => records
1031            .into_iter()
1032            .find(|(k, _)| *k == rkey)
1033            .map(|(_, rec)| (rec.url, rec.entry_id)),
1034        Err(err) => {
1035            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1036                                      a local star for the same article may survive");
1037            None
1038        }
1039    };
1040
1041    match state.repo().remove_saved(&did, &rkey).await {
1042        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1043        Err(err) => {
1044            warn!(%err, %did, %rkey, "could not remove the saved record");
1045            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1046        }
1047    }
1048
1049    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1050    // saved, so clearing the local star before knowing the record is gone would
1051    // be the desync in the other direction.
1052    if let Some((url, guid)) = identity {
1053        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1054            Ok(0) => {}
1055            Ok(n) => {
1056                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1057            }
1058            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1059        }
1060    }
1061    // htmx swaps the row out; a plain form post goes back to the starred list.
1062    if is_htmx(&headers) {
1063        return (StatusCode::OK, "").into_response();
1064    }
1065    Redirect::to("/?view=starred").into_response()
1066}
1067
1068/// What the poller is doing, as one word for `/stats`.
1069///
1070/// **Parity with `/health` is the point.** `polling_paused` alone reported
1071/// "running" for three different states including the two where nothing polls,
1072/// on the page added to answer exactly that. The first attempt at fixing it
1073/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1074/// poll loop that ticked once at boot and then WEDGED still read as running.
1075/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1076/// original finding's exact shape surviving its own fix.
1077///
1078/// Shares the staleness threshold with `/health` rather than picking its own, so
1079/// the two pages cannot disagree about what "stale" means.
1080fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1081    if !rh.schedulers_enabled() {
1082        return "off";
1083    }
1084    // Checked before the pause: a wedged poller cannot clear a pause either, so
1085    // reporting "paused" would name the symptom and hide the cause.
1086    match rh.secs_since_poll_tick(now_unix) {
1087        None => {
1088            // Never ticked. Benign at boot, a dead loop long after — read
1089            // against uptime, exactly as `/health` does.
1090            match rh.uptime_secs(now_unix) {
1091                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1092                _ => "starting",
1093            }
1094        }
1095        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1096        _ if rh.watermark_paused() => "paused",
1097        _ => "running",
1098    }
1099}
1100
1101/// `GET /stats` — public poll health.
1102async fn stats(State(state): State<AppState>) -> Response {
1103    let now = chrono::Utc::now();
1104    let health = match store::poll_health(
1105        &state.db,
1106        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1107        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1108    )
1109    .await
1110    {
1111        Ok(health) => health,
1112        Err(err) => {
1113            warn!(%err, "could not compute poll health");
1114            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1115        }
1116    };
1117
1118    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1119    // instance is not behind on anything.
1120    let (deferred, starved_since) = state.sanitize_starvation.snapshot();
1121
1122    let polled_pct = if health.feeds_tracked == 0 {
1123        100
1124    } else {
1125        health.polled_last_hour * 100 / health.feeds_tracked
1126    };
1127
1128    render(&StatsTemplate {
1129        card: Card::public(
1130            &state.config,
1131            "/stats",
1132            "Stats — FeatherReader",
1133            "Is this instance's poller keeping up? Aggregate feed-polling health — \
1134             counts only; no feed and no reader is named.",
1135        ),
1136        version: VERSION,
1137        repo_url: REPO_URL,
1138        kofi_url: KOFI_URL,
1139        feeds_tracked: health.feeds_tracked,
1140        polled_last_hour: health.polled_last_hour,
1141        polled_pct,
1142        overdue: health.overdue,
1143        last_poll: humanise_ago(health.last_poll_secs_ago),
1144        oldest_poll: if health.never_polled > 0 {
1145            "never".to_string()
1146        } else {
1147            humanise_ago(health.oldest_poll_secs_ago)
1148        },
1149        never_polled: health.never_polled,
1150        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1151        // **The two states that actually stop feeds updating.**
1152        //
1153        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1154        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1155        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1156        // feed failing every fetch drops out of the backlog and makes the page
1157        // read healthier. Both of these are machine facts with no per-feed
1158        // detail, so they sit inside the page's stated contract.
1159        in_backoff: health.in_backoff,
1160        badly_broken: health.badly_broken,
1161        failure_kinds: health.failure_kinds,
1162        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1163        deferred_no_permit: deferred,
1164        starved_since: starved_since.map(|since| humanise_ago(Some(now.timestamp() - since))),
1165    })
1166}
1167
1168/// "3h 11m ago", or "never" when there has been no poll at all.
1169///
1170/// `None` must not render as `0` — on a fresh instance that would read as
1171/// "polled just now", which is the opposite of the truth.
1172fn humanise_ago(secs: Option<i64>) -> String {
1173    let Some(secs) = secs else {
1174        return "never".to_string();
1175    };
1176    match secs {
1177        s if s < 60 => format!("{s}s ago"),
1178        s if s < 3600 => format!("{}m ago", s / 60),
1179        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1180    }
1181}
1182
1183/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1184/// observation of zero, or a store failure).
1185///
1186/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1187/// probe is never allowed to affect the reader, and that rule applies at the
1188/// display end too — a locked or corrupt DB costs the About page one log line,
1189/// not a 500.
1190async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1191    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1192        // A legitimate zero renders nothing rather than a sad "0 accounts".
1193        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1194            repos: stat.value,
1195            truncated: stat.truncated,
1196            observed_on: stat
1197                .observed_at
1198                .split('T')
1199                .next()
1200                .unwrap_or_default()
1201                .to_string(),
1202        }),
1203        Ok(_) => None,
1204        Err(err) => {
1205            warn!(%err, "about: adoption stat read failed; omitting the line");
1206            None
1207        }
1208    }
1209}
1210
1211/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1212/// lives in the user's PDS, what the server caches, and the session-token
1213/// handling. A static render; readable whether or not a session exists.
1214async fn privacy(State(state): State<AppState>) -> Response {
1215    render(&PrivacyTemplate {
1216        card: Card::public(
1217            &state.config,
1218            "/privacy",
1219            "Privacy — FeatherReader",
1220            "No account and no tracking: your subscriptions and reading state live in \
1221             your own PDS. What this server caches, for how long, and how the session \
1222             token is handled.",
1223        ),
1224        version: VERSION,
1225        repo_url: REPO_URL,
1226        kofi_url: KOFI_URL,
1227    })
1228}
1229
1230/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1231/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1232/// static render; readable whether or not a session exists.
1233async fn terms(State(state): State<AppState>) -> Response {
1234    render(&TermsTemplate {
1235        card: Card::public(
1236            &state.config,
1237            "/terms",
1238            "Terms — FeatherReader",
1239            "The terms of use: an experimental service offered as-is with no warranty, \
1240             what acceptable use means here, and the AGPL self-host note.",
1241        ),
1242        version: VERSION,
1243        repo_url: REPO_URL,
1244        kofi_url: KOFI_URL,
1245    })
1246}
1247
1248// ---------------------------------------------------------------------------
1249// View models
1250// ---------------------------------------------------------------------------
1251
1252/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1253/// and the PDS subscription rkey for management actions).
1254struct FeedView {
1255    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1256    rkey: String,
1257    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1258    url: String,
1259    title: String,
1260    unread: i64,
1261    /// Whether this feed is the currently-selected scope.
1262    selected: bool,
1263    /// The feed's current folder `at://` URI (from its subscription record), or
1264    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1265    /// rename row so an untouched folder dropdown does not silently un-folder the
1266    /// feed on save.
1267    folder: Option<String>,
1268}
1269
1270/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1271struct FolderView {
1272    /// PDS folder rkey — addresses the record for rename/delete.
1273    rkey: String,
1274    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1275    uri: String,
1276    name: String,
1277    feeds: Vec<FeedView>,
1278    /// Whether this folder is the currently-selected scope.
1279    selected: bool,
1280}
1281
1282/// One entry as shown in the article list / after an htmx swap.
1283struct EntryRow {
1284    id: i64,
1285    title: String,
1286    feed_title: String,
1287    published: String,
1288    read: bool,
1289    starred: bool,
1290    /// The reader link href, already carrying the scope/view query so opening an
1291    /// entry and paging back stays within the list it came from.
1292    link: SafeLink,
1293    /// Whether the article itself is in this instance's cache.
1294    ///
1295    /// `false` for a saved record that exists in the reader's PDS but whose
1296    /// entry was never cached here — starred in another atproto reader, or
1297    /// starred here and since evicted. There is no local row, so the row has no
1298    /// usable `id`: it links straight out to the article and carries no
1299    /// mark-read control, because there is nothing local to mark.
1300    cached: bool,
1301    /// The PDS record key, for un-saving a row that has no local entry.
1302    rkey: String,
1303}
1304
1305/// A folder as an option in the "move feed to folder" select.
1306struct FolderOption {
1307    uri: String,
1308    name: String,
1309}
1310
1311/// The shared navigation "rail" model: the same DOM element is the
1312/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1313/// manage) renders it from this one struct. Feed management lives on `/manage`,
1314/// not here — the rail is navigation only.
1315struct Nav {
1316    /// `@handle` for the identity chip (falls back to the DID's tail).
1317    handle: String,
1318    /// Two-letter avatar initials for the identity chip.
1319    avatar: String,
1320    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1321    view: String,
1322    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1323    /// empty for the unscoped "everything" views.
1324    scope_qs: String,
1325    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1326    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1327    folders: Vec<FolderView>,
1328    loose_feeds: Vec<FeedView>,
1329    /// Whether the "Manage feeds" rail tool is the current page.
1330    manage_active: bool,
1331}
1332
1333/// The subscribe input's `pattern` when standard.site is on, and the input is
1334/// `type="text"` (see `templates/manage.html`). It keeps the browser asking for
1335/// a scheme, as `type="url"` did, while admitting `at://`. Matched in any case,
1336/// because the handler canonicalises the scheme. Browsers compile `pattern`
1337/// with the `v` flag and anchor it at both ends. Unlike `type="url"`, a text
1338/// input does not strip surrounding whitespace before checking, so the pattern
1339/// allows it: a URL pasted with a leading space is common, and the handler
1340/// trims it.
1341pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1342
1343// ---------------------------------------------------------------------------
1344// Link cards (Open Graph / Twitter / Bluesky)
1345// ---------------------------------------------------------------------------
1346
1347/// The site's own title: the landing page's, and the one every private view
1348/// shows instead of its own.
1349const SITE_TITLE: &str = "FeatherReader — read, quietly";
1350
1351/// The site's one-paragraph description: the landing page's, and the one every
1352/// private view shows instead of its own.
1353const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1354standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1355password, no tracking.";
1356
1357/// Where the share image is served, relative to the public origin. The file is
1358/// `static/social-card.png`, rendered from `static/social-card.svg` by
1359/// `scripts/social-card.sh`; `base.html` advertises its dimensions, and a test
1360/// checks the PNG's own header agrees.
1361const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1362
1363/// What a link to a page unfurls as when it is posted — on Bluesky, in a chat,
1364/// anywhere that reads Open Graph tags. `base.html` renders it into `<head>`.
1365///
1366/// Measured before this existed: Bluesky's card service
1367/// (`cardyb.bsky.app/v1/extract?url=https://feather-reader.com/`) returned
1368/// `{"title":"FeatherReader — read, quietly","description":"","image":""}`,
1369/// because `<title>` was the only tag it could find. Card fetchers read the
1370/// initial HTML server-side, run no JS, and resolve nothing relative, so every
1371/// URL here is absolute on [`Config::public_url`] — `https://feather-reader.com`
1372/// in production, whatever `FEATHERREADER_PUBLIC_URL` says elsewhere.
1373#[derive(Debug, Clone)]
1374pub(crate) struct Card {
1375    /// `og:title`. On a public page, the same text as its `<title>`.
1376    pub title: String,
1377    /// `og:description` and `<meta name="description">`: one or two plain
1378    /// sentences about THIS page, not the site.
1379    pub description: String,
1380    /// `og:url` and `<link rel="canonical">`: absolute, on the public origin.
1381    pub url: String,
1382    /// `og:image`: absolute, on the public origin.
1383    pub image: String,
1384    /// Set on a page that renders a session's private view. The card is then
1385    /// the site's generic one — nothing from the view reaches `<head>` — and
1386    /// the page is `noindex`.
1387    pub private: bool,
1388}
1389
1390impl Card {
1391    /// The card of the public page at `path` (leading slash) on this instance.
1392    fn public(
1393        config: &Config,
1394        path: &str,
1395        title: impl Into<String>,
1396        description: impl Into<String>,
1397    ) -> Self {
1398        let origin = config.public_url.trim_end_matches('/');
1399        Card {
1400            title: title.into(),
1401            description: description.into(),
1402            url: format!("{origin}{path}"),
1403            image: format!("{origin}{SHARE_IMAGE_PATH}"),
1404            private: false,
1405        }
1406    }
1407
1408    /// The landing page's card: the site's own title and description.
1409    fn site(config: &Config) -> Self {
1410        Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1411    }
1412
1413    /// The card of a page that renders a session's private view: the site's
1414    /// generic card pointing at the front door, plus `noindex`. The view's
1415    /// heading, feed names and handle stay out of `<head>`.
1416    fn private(config: &Config) -> Self {
1417        Card {
1418            private: true,
1419            ..Card::site(config)
1420        }
1421    }
1422}
1423
1424/// The reader index (`GET /`).
1425#[derive(Template)]
1426#[template(path = "index.html")]
1427struct IndexTemplate {
1428    /// The link card. A private view: the site's generic card, `noindex`.
1429    card: Card,
1430    version: &'static str,
1431    repo_url: &'static str,
1432    kofi_url: &'static str,
1433    flash: String,
1434    /// Shown as `role="alert"` when the subscription list is the cached one
1435    /// because the PDS listing failed; empty otherwise.
1436    alert: String,
1437    /// The shared rail (drawer + desktop sidebar) navigation model.
1438    nav: Nav,
1439    /// The article list for the selected scope + view.
1440    entries: Vec<EntryRow>,
1441    /// The list heading (the selected view/feed/folder name).
1442    heading: String,
1443    /// Whether a feed scope is active (enables per-feed mark-all-read).
1444    feed_scope: Option<String>,
1445    /// Total CACHED entries in this scope + view across ALL pages. The count used
1446    /// to be `entries.len()`, which was the same number only because the list was
1447    /// unpaged — the thing this change exists to stop.
1448    ///
1449    /// The pager is derived from this, so it must not include the uncached PDS
1450    /// rows below: they are appended to the last page rather than paged, and
1451    /// counting them here advertised a page the clamp could never reach.
1452    total: i64,
1453    /// How many of `total` are PDS saved records the cache cannot show.
1454    ///
1455    /// A subset of `total`, not an addition to it — the heading says "N entries
1456    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1457    /// which double counted once `total` started including them, against an M
1458    /// that had become page-local in the same commit while the template stayed
1459    /// put.
1460    uncached_total: i64,
1461    /// 1-based current page.
1462    page: i64,
1463    /// Total pages, at least 1 (an empty list is page 1 of 1).
1464    page_count: i64,
1465    /// Link to the previous (newer) page, or `None` on the first.
1466    prev_href: Option<String>,
1467    /// Link to the next (older) page, or `None` on the last.
1468    next_href: Option<String>,
1469}
1470
1471/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1472#[derive(Template)]
1473#[template(path = "manage.html")]
1474struct ManageTemplate {
1475    /// The link card. A private view: the site's generic card, `noindex`.
1476    card: Card,
1477    version: &'static str,
1478    repo_url: &'static str,
1479    kofi_url: &'static str,
1480    flash: String,
1481    /// See [`IndexTemplate::alert`].
1482    alert: String,
1483    nav: Nav,
1484    /// All folders as move-targets for the subscribe folder select.
1485    folder_options: Vec<FolderOption>,
1486    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1487    folders: Vec<FolderView>,
1488    loose_feeds: Vec<FeedView>,
1489    /// `Config::standard_site`. With it on, the subscribe form says a
1490    /// `site.standard.publication` URI is accepted and its input drops
1491    /// `type="url"`, whose browser validation rejects the DID form. With it off
1492    /// `add_subscription` refuses every `at://` paste, so the form must not
1493    /// advertise one.
1494    standard_site: bool,
1495}
1496
1497/// The optional one-line adoption fact at the bottom of `/about`
1498/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1499/// probe has succeeded yet, or the read failed — the line then simply does not
1500/// render.
1501struct AdoptionLine {
1502    /// Repos a relay has indexed as holding the subscription collection.
1503    repos: i64,
1504    /// The probe hit its page cap, so the copy must say "at least".
1505    truncated: bool,
1506    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1507    observed_on: String,
1508}
1509
1510/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1511/// the optional adoption line.
1512#[derive(Template)]
1513#[template(path = "about.html")]
1514struct AboutTemplate {
1515    /// The link card: this page's own title and description.
1516    card: Card,
1517    version: &'static str,
1518    repo_url: &'static str,
1519    kofi_url: &'static str,
1520    adoption: Option<AdoptionLine>,
1521    /// `Config::standard_site`: whether the publications section may tell the
1522    /// reader how to subscribe to one here. See [`ManageTemplate::standard_site`].
1523    standard_site: bool,
1524}
1525
1526/// The public `/standard-site` feature page. Carries the same footer fields
1527/// as the other public pages, the standard.site flag, and the release list for
1528/// the "latest releases" call-out.
1529#[derive(Template)]
1530#[template(path = "standard_site.html")]
1531struct StandardSiteTemplate {
1532    /// The link card: this page's own title and description.
1533    card: Card,
1534    version: &'static str,
1535    repo_url: &'static str,
1536    kofi_url: &'static str,
1537    /// `Config::standard_site`: whether the page may tell a visitor how to
1538    /// subscribe to a publication here. See [`ManageTemplate::standard_site`].
1539    standard_site: bool,
1540    /// [`RELEASES`], newest first, for `templates/releases.html`.
1541    releases: &'static [Release],
1542}
1543
1544/// One tagged release, as the "latest releases" call-out
1545/// (`templates/releases.html`) shows it on `/standard-site` and the landing
1546/// page. The links are derived from `version` and `date`, so a release is
1547/// described in exactly one place: an entry in [`RELEASES`].
1548pub(crate) struct Release {
1549    /// The crate version, without the `v` (`"0.4.1"`). The tag is `v{version}`.
1550    pub(crate) version: &'static str,
1551    /// The release date, `YYYY-MM-DD`, as the CHANGELOG heading has it.
1552    pub(crate) date: &'static str,
1553    /// One or two plain sentences for a visitor. No markup: the template escapes it.
1554    pub(crate) summary: &'static str,
1555}
1556
1557impl Release {
1558    /// The GitHub release page: `{REPO_URL}/releases/tag/v{version}`.
1559    pub(crate) fn url(&self) -> String {
1560        format!("{REPO_URL}/releases/tag/v{}", self.version)
1561    }
1562
1563    /// The release's section of `CHANGELOG.md` on `main`. GitHub derives the
1564    /// anchor for a heading `## 0.4.1 — 2026-10-04` as `041--2026-10-04`: the
1565    /// dots dropped, the em dash dropped, each space a hyphen.
1566    pub(crate) fn changelog_url(&self) -> String {
1567        format!(
1568            "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1569            self.version.replace('.', ""),
1570            self.date
1571        )
1572    }
1573}
1574
1575/// **The one place a release is described for the website.** Newest first.
1576/// To announce the next release, add one entry at the top; the call-out on
1577/// `/standard-site` and the landing page, and both links, follow from it.
1578/// `releases_are_newest_first_and_link_the_tag_and_changelog` pins the shape.
1579pub(crate) const RELEASES: &[Release] = &[
1580    Release {
1581        version: "0.4.8",
1582        date: "2026-10-08",
1583        summary: "A misbehaving server can no longer drop your subscriptions by \
1584                  cutting a list short, and a small publication that shares a \
1585                  repo with big ones is read in full.",
1586    },
1587    Release {
1588        version: "0.4.7",
1589        date: "2026-10-07",
1590        summary: "A feed can no longer stall the reader with an article that is \
1591                  slow to clean up, and every stored article is cleaned again \
1592                  as it is shown.",
1593    },
1594    Release {
1595        version: "0.4.6",
1596        date: "2026-10-06",
1597        summary: "Renaming a subscription or a folder no longer overwrites \
1598                  what another app changed at the same moment, and a folder \
1599                  rename keeps everything but the name.",
1600    },
1601    Release {
1602        version: "0.4.5",
1603        date: "2026-10-06",
1604        summary: "An operator teardown now signs every user out at their own \
1605                  server before deleting anything, and the session-writing \
1606                  code is hardened against the races that work exposed.",
1607    },
1608    Release {
1609        version: "0.4.4",
1610        date: "2026-10-05",
1611        summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1612                  links unchanged and real RSS bylines, and the address guard \
1613                  refuses the reserved ranges it missed.",
1614    },
1615    Release {
1616        version: "0.4.3",
1617        date: "2026-10-05",
1618        summary: "Two write-path fixes for any PDS: large OPML imports and \
1619                  read-state syncs are sent in calls the PDS accepts, and a \
1620                  read-state sync that disagreed with the PDS recovers instead \
1621                  of failing every round.",
1622    },
1623    Release {
1624        version: "0.4.2",
1625        date: "2026-10-04",
1626        summary: "A public standard.site feature page with this list of recent \
1627                  releases, and link cards: a posted feather-reader.com link \
1628                  now unfurls with a description and an image.",
1629    },
1630    Release {
1631        version: "0.4.1",
1632        date: "2026-10-04",
1633        summary: "The public pages explain standard.site publications, and the \
1634                  subscribe form can submit the DID form of a publication URI, \
1635                  which browsers refused in 0.4.0.",
1636    },
1637    Release {
1638        version: "0.4.0",
1639        date: "2026-10-03",
1640        summary: "standard.site support: publications are read from their \
1641                  authors' atproto repos as subscriptions, beside RSS, on their \
1642                  own polling loop. Every stored field from a feed or a \
1643                  publication now has a size bound.",
1644    },
1645];
1646
1647/// The public `/stats` page — is the poller keeping up?
1648///
1649/// Aggregate only, deliberately. It is published to anyone, so it carries no
1650/// user counts and no per-feed detail: a reader does not need to know how many
1651/// people use an instance or which feeds are failing. What it does answer is the
1652/// question that decides whether an instance can take more readers — whether the
1653/// poller is servicing the feeds it already has.
1654///
1655/// The counts below are aggregate machine facts, which is why they fit that
1656/// contract: "12 feeds are in backoff" names no feed and no reader, while
1657/// answering the question the page was previously unable to answer at all.
1658#[derive(Template)]
1659#[template(path = "stats.html")]
1660struct StatsTemplate {
1661    /// The link card: this page's own title and description.
1662    card: Card,
1663    version: &'static str,
1664    repo_url: &'static str,
1665    kofi_url: &'static str,
1666    feeds_tracked: i64,
1667    polled_last_hour: i64,
1668    polled_pct: i64,
1669    overdue: i64,
1670    last_poll: String,
1671    oldest_poll: String,
1672    never_polled: i64,
1673    poll_interval_mins: i64,
1674    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1675    in_backoff: i64,
1676    /// Of those, the ones retried hours apart rather than minutes. **Not
1677    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1678    /// their next successful poll, and most of this instance's did.
1679    badly_broken: i64,
1680    /// Failing feeds by cause, descending — counts only, never which feed.
1681    failure_kinds: Vec<(String, i64)>,
1682    /// What the poller is actually doing: `running`, `paused` (at the size
1683    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1684    /// disabled). Three of those four used to render as "running".
1685    fetching: &'static str,
1686    /// Polls deferred since boot because no sanitize permit came free (#226),
1687    /// and — while that is still so — since when, humanised. Deferred polls
1688    /// store nothing and file nothing against their feeds, so this row is the
1689    /// only sign that hostile bodies' sanitizes hold every permit.
1690    deferred_no_permit: u64,
1691    starved_since: Option<String>,
1692}
1693
1694/// The public `/privacy` page — what the server holds vs. what lives in the
1695/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1696/// footer include needs.
1697#[derive(Template)]
1698#[template(path = "privacy.html")]
1699struct PrivacyTemplate {
1700    /// The link card: this page's own title and description.
1701    card: Card,
1702    version: &'static str,
1703    repo_url: &'static str,
1704    kofi_url: &'static str,
1705}
1706
1707/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1708/// same fields the shared footer include needs.
1709#[derive(Template)]
1710#[template(path = "terms.html")]
1711struct TermsTemplate {
1712    /// The link card: this page's own title and description.
1713    card: Card,
1714    version: &'static str,
1715    repo_url: &'static str,
1716    kofi_url: &'static str,
1717}
1718
1719/// The signed-out landing page (`GET /` with no session) — the public front
1720/// door at feather-reader.com. A static render, no session required.
1721#[derive(Template)]
1722#[template(path = "landing.html")]
1723struct LandingTemplate {
1724    /// The link card: the site's own title and description.
1725    card: Card,
1726    version: &'static str,
1727    repo_url: &'static str,
1728    crates_url: &'static str,
1729    kofi_url: &'static str,
1730    /// `Config::standard_site`: whether the publications point may tell a
1731    /// visitor how to subscribe to one here. See [`ManageTemplate::standard_site`].
1732    standard_site: bool,
1733    /// [`RELEASES`], newest first, for the quiet "latest releases" strip.
1734    releases: &'static [Release],
1735}
1736
1737/// The single-entry reader view (`GET /entries/:id`).
1738#[derive(Template)]
1739#[template(path = "entry.html")]
1740struct EntryTemplate {
1741    /// The link card. A private view: the site's generic card, `noindex`.
1742    card: Card,
1743    version: &'static str,
1744    repo_url: &'static str,
1745    kofi_url: &'static str,
1746    nav: Nav,
1747    id: i64,
1748    title: String,
1749    feed_title: String,
1750    author: Option<String>,
1751    published: String,
1752    /// The entry's own link, for `entry.html`'s two `href`s.
1753    ///
1754    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1755    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1756    /// long way from the `href` and holds only while every future writer to
1757    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1758    /// defence that, on the saved-record row, turned out to be deletable with
1759    /// all 679 tests still green. `None` is the refusal: the template's
1760    /// no-URL branch already renders a disabled open-original button.
1761    url: Option<SafeLink>,
1762    /// The article body, **re-sanitized for this render** (#151), or the
1763    /// reason it is not shown.
1764    ///
1765    /// Not the stored `String`: that reached the page through `|safe` and was
1766    /// safe only because `feed.rs` had sanitized it at ingest — the same
1767    /// every-writer-remembers guard `url` above used to rest on, and the more
1768    /// dangerous of the two. A `SanitizedHtml` can only be built by running
1769    /// the ingest sanitizer, so the template renders it unescaped without a
1770    /// `|safe` on a raw string anywhere. The other two variants are the
1771    /// renderer's bounds: a body over the stored size cap, or no sanitizer
1772    /// permit in time; the template shows a note and the original's link.
1773    content_html: Option<BodyRender>,
1774    read: bool,
1775    starred: bool,
1776    /// The query string to carry the reading context back to the list.
1777    back_qs: String,
1778    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1779    prev_id: Option<i64>,
1780    next_id: Option<i64>,
1781    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1782    oob: bool,
1783}
1784
1785/// The htmx swap fragment for a single entry row (`entry_row.html`).
1786#[derive(Template)]
1787#[template(path = "entry_row.html")]
1788struct EntryRowTemplate {
1789    e: EntryRow,
1790}
1791
1792/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1793/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1794/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1795/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1796#[derive(Template)]
1797#[template(path = "entry_actionbar.html")]
1798struct EntryActionBarTemplate {
1799    id: i64,
1800    read: bool,
1801    starred: bool,
1802    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1803    oob: bool,
1804}
1805
1806/// The login stub (`GET /login`).
1807#[derive(Template)]
1808#[template(path = "login.html")]
1809struct LoginTemplate {
1810    /// The link card: this page's own title and description.
1811    card: Card,
1812    repo_url: &'static str,
1813    error: String,
1814    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1815    /// distinct from `error`. Empty renders nothing.
1816    flash: String,
1817}
1818
1819/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1820#[derive(Template)]
1821#[template(path = "beta_redeem.html")]
1822struct BetaRedeemTemplate {
1823    /// The link card: this page's own title and description.
1824    card: Card,
1825    repo_url: &'static str,
1826    error: String,
1827    /// When true the seat cap is full: hide the form and show the "capacity
1828    /// full — try self-hosting" message instead.
1829    capacity_full: bool,
1830}
1831
1832// ---------------------------------------------------------------------------
1833// Rendering + error helpers
1834// ---------------------------------------------------------------------------
1835
1836/// Render an askama template into an HTML response, mapping a render failure to
1837/// a `500` rather than panicking (no `unwrap` in the request path).
1838fn render<T: Template>(tmpl: &T) -> Response {
1839    match tmpl.render() {
1840        Ok(body) => Html(body).into_response(),
1841        Err(err) => {
1842            warn!(%err, "template render failed");
1843            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1844        }
1845    }
1846}
1847
1848/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1849/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1850/// by default; a handler may override the status (e.g. `413` for an over-cap
1851/// upload) via [`WebError::with_status`].
1852struct WebError {
1853    err: anyhow::Error,
1854    status: StatusCode,
1855}
1856
1857impl<E: Into<anyhow::Error>> From<E> for WebError {
1858    fn from(err: E) -> Self {
1859        WebError {
1860            err: err.into(),
1861            status: StatusCode::INTERNAL_SERVER_ERROR,
1862        }
1863    }
1864}
1865
1866impl WebError {
1867    /// Attach an explicit HTTP status to render instead of the default `500`.
1868    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1869        WebError {
1870            err: err.into(),
1871            status,
1872        }
1873    }
1874}
1875
1876impl IntoResponse for WebError {
1877    fn into_response(self) -> Response {
1878        warn!(error = %self.err, status = %self.status, "request failed");
1879        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1880            "internal error"
1881        } else {
1882            self.status.canonical_reason().unwrap_or("error")
1883        };
1884        (self.status, body).into_response()
1885    }
1886}
1887
1888/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1889/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1890/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1891/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1892fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1893    let status = err.status();
1894    WebError::with_status(err, status)
1895}
1896
1897/// A short, human display of a feed/site title for the sidebar/list, falling
1898/// back to the host of a URL and finally to the raw string.
1899fn display_title(title: Option<&str>, url: &str) -> String {
1900    if let Some(t) = title {
1901        let t = t.trim();
1902        if !t.is_empty() {
1903            return t.to_string();
1904        }
1905    }
1906    url::Url::parse(url)
1907        .ok()
1908        .and_then(|u| u.host_str().map(str::to_string))
1909        .unwrap_or_else(|| url.to_string())
1910}
1911
1912/// A display `@handle` for the identity chip: the stored handle if present,
1913/// else the tail of the DID so the chip is never empty.
1914fn display_handle(handle: Option<&str>, did: &str) -> String {
1915    match handle {
1916        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1917        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1918    }
1919}
1920
1921/// Two-letter, lowercase avatar initials from a handle/DID.
1922fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1923    let source = handle
1924        .map(|h| h.trim().trim_start_matches('@'))
1925        .filter(|h| !h.is_empty())
1926        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1927    let letters: String = source
1928        .chars()
1929        .filter(|c| c.is_alphanumeric())
1930        .take(2)
1931        .collect::<String>()
1932        .to_lowercase();
1933    if letters.is_empty() {
1934        "fr".to_string()
1935    } else {
1936        letters
1937    }
1938}
1939
1940/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1941/// low-noise display. Falls back to the raw string if it doesn't look like one.
1942fn display_date(published: Option<&str>) -> String {
1943    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1944    // multi-byte character, and every caller used to pass a timestamp the feed
1945    // parser had produced. The saved-record path passes `createdAt` straight off
1946    // a PDS record, which the lexicon types as a bare string with no validation
1947    // — written by whatever atproto client the reader used. A `createdAt` of
1948    // "日本語日本語日本" took down the whole starred view, and there is no
1949    // catch-panic layer in the stack, so the page stayed down until the record
1950    // was removed from the very view that would not render.
1951    match published {
1952        Some(p) => p.chars().take(10).collect(),
1953        None => String::new(),
1954    }
1955}
1956
1957/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1958/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1959/// a bare value, and this keeps the scope-preserving links honest.
1960fn qenc(s: &str) -> String {
1961    let mut out = String::with_capacity(s.len() * 3);
1962    for b in s.bytes() {
1963        match b {
1964            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1965                out.push(b as char)
1966            }
1967            _ => out.push_str(&format!("%{b:02X}")),
1968        }
1969    }
1970    out
1971}
1972
1973// ---------------------------------------------------------------------------
1974// Reader: index
1975// ---------------------------------------------------------------------------
1976
1977/// Query for `GET /` — the scope + view selector.
1978#[derive(Debug, Deserialize, Default)]
1979struct IndexQuery {
1980    /// Filter to a single feed by its canonical URL.
1981    #[serde(default)]
1982    feed: Option<String>,
1983    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1984    #[serde(default)]
1985    folder: Option<String>,
1986    /// `unread` (default) | `all` | `starred`.
1987    #[serde(default)]
1988    view: Option<String>,
1989    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1990    #[serde(default)]
1991    page: Option<u32>,
1992    /// Optional flash message (e.g. after an action redirect).
1993    #[serde(default)]
1994    flash: Option<String>,
1995}
1996
1997/// Rows per page in the reader's list views.
1998///
1999/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
2000/// so a page is on the order of tens of kilobytes rather than the tens or
2001/// hundreds of megabytes an unbounded list of full entries could reach. The page
2002/// bound is the second half of that fix: without it, a reader with a long
2003/// backlog still decides how much memory a single request allocates.
2004const ENTRIES_PER_PAGE: i64 = 100;
2005
2006/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
2007/// pager reads "1 / 1" rather than "1 / 0".
2008fn page_count_for(total: i64) -> i64 {
2009    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
2010}
2011
2012/// Ceiling on the reader's prev/next id list.
2013///
2014/// Unlike the page above, this genuinely spans the whole list — prev/next is the
2015/// reader's position within it — so it is bounded by count rather than paged. At
2016/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
2017/// resolving; the article itself still opens, and the list view still pages.
2018const PREV_NEXT_MAX: i64 = 5_000;
2019
2020/// Ceiling on the cached-starred identity set matched against PDS saved records.
2021///
2022/// Deliberately generous: under-reading this set makes a cached article look
2023/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
2024/// than un-starring the entry. Truncating here would change what a click
2025/// destroys, so the cap exists only as a backstop against an absurd starred
2026/// count, not as a routine bound.
2027const STARRED_IDENTITY_MAX: i64 = 20_000;
2028
2029/// Most uncached PDS saved records this handler will hold in memory for one
2030/// request.
2031///
2032/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
2033/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
2034/// this only caps how many are collected before slicing. An earlier version used
2035/// it to cap what was SHOWN, which left everything past it invisible and —
2036/// because the un-save control lives on the row, and nothing else in the app
2037/// lists these — unremovable.
2038///
2039/// Well above the PDS list ceiling's practical reach for one reader, so a reader
2040/// meeting it has thousands of saved records and gets a logged, ordered prefix
2041/// rather than a failure.
2042const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
2043
2044/// A subscription resolved against the local cache: the PDS record + its
2045/// (possibly-missing) cached feed row.
2046struct ResolvedSub {
2047    rkey: String,
2048    sub: Subscription,
2049    feed: Option<store::Feed>,
2050}
2051
2052/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
2053/// local cache row so unread counts work, and return them resolved. Best-effort
2054/// on the sidecar: a failure falls back to the local cache alone.
2055async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
2056    resolve_subscriptions_noting(state, did).await.0
2057}
2058
2059/// What to tell a reader whose subscription list could not be read from their
2060/// PDS, so the last-known list being shown does not pass for a fresh one.
2061///
2062/// **A malformed record is named as such** (#177): the walk refuses rather than
2063/// drop that subscription, and "unreachable" would send the reader looking at
2064/// their network when the cause is a record some client wrote into their repo.
2065fn subscriptions_alert(err: &anyhow::Error) -> String {
2066    match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2067        Some(m) => format!(
2068            "{} record(s) in your subscription list could not be read, so it was not \
2069             refreshed. Showing your last-known subscriptions; nothing was removed.",
2070            m.count
2071        ),
2072        None => "Your subscription list could not be read from your PDS just now. \
2073                 Showing your last-known subscriptions."
2074            .to_string(),
2075    }
2076}
2077
2078/// [`resolve_subscriptions`], plus the alert to show when the list shown is the
2079/// cached one because the PDS listing failed.
2080async fn resolve_subscriptions_noting(
2081    state: &AppState,
2082    did: &str,
2083) -> (Vec<ResolvedSub>, Option<String>) {
2084    let pool = &state.db;
2085    let subs = match state.repo().list_subscriptions_sorted(did).await {
2086        Ok(s) => s,
2087        Err(err) => {
2088            let alert = subscriptions_alert(&err);
2089            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2090            return (cached_subscriptions(pool, did).await, Some(alert));
2091        }
2092    };
2093
2094    // **Deliberately NOT truncated to `max_subs_per_did`.**
2095    //
2096    // The PDS list is unbounded in practice — any client can write subscription
2097    // records, and only the 20,000-record list ceiling stops it — and the first
2098    // attempt at bounding it truncated the list right here. That was the wrong
2099    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
2100    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
2101    // removed the reader's ability to read OR mutate those feeds. A query-shape
2102    // problem would have become an access problem.
2103    //
2104    // The shape problem was the scope filter emitting one SQL placeholder per
2105    // feed; `store::list_query_sql` now passes the whole set as a single
2106    // `json_each` bind, so there is no size to defend against here and nothing
2107    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
2108    // feeds — rather than becoming a silent read-time filter.
2109    let mut out = Vec::with_capacity(subs.len());
2110    for (rkey, sub) in subs {
2111        let feed = match store::get_feed_by_url(pool, &sub.url).await {
2112            Ok(Some(f)) => Some(f),
2113            Ok(None) => {
2114                // `sub.url` came out of an atproto record. The lexicon is open —
2115                // ANY client can write a subscription into a user's repo — so
2116                // this is untrusted input on the hot path of `GET /`, and it was
2117                // being stored with none of the three checks the add and import
2118                // paths apply. Two of those are capacity ceilings; this one is
2119                // the invariant in `FeedPrivacy`'s doc comment, which promises a
2120                // private feed URL is "never stored". Writing a
2121                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
2122                // that promise even though `net::guarded_get` still refuses to
2123                // fetch it.
2124                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2125                    || feed::classify_feed_privacy(&sub.url).is_private()
2126                {
2127                    warn!(
2128                        %did,
2129                        "skipping cache row for a subscription URL that is private or not http(s)"
2130                    );
2131                    out.push(ResolvedSub {
2132                        rkey,
2133                        sub,
2134                        feed: None,
2135                    });
2136                    continue;
2137                }
2138                // Upsert a cache row so the sidebar reflects the real follow-list.
2139                //
2140                // A silent failure here is a support ticket with no evidence: no
2141                // `feeds` row means the poller never selects this subscription,
2142                // so the reader sees "I added a feed and it never updates" while
2143                // the PDS record looks perfect. Logged with the URL so the
2144                // failing subscription is identifiable.
2145                if let Err(err) = store::upsert_feed(
2146                    pool,
2147                    &store::NewFeed {
2148                        url: sub.url.clone(),
2149                        title: sub.title.clone(),
2150                        site_url: sub.site_url.clone(),
2151                        ..Default::default()
2152                    },
2153                )
2154                .await
2155                {
2156                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2157                                                       it will not be polled");
2158                }
2159                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2160            }
2161            Err(err) => {
2162                warn!(%err, url = %sub.url, "get_feed_by_url failed");
2163                None
2164            }
2165        };
2166        out.push(ResolvedSub { rkey, sub, feed });
2167    }
2168    // **A mass drop is read twice before it is believed (#203).** A walk
2169    // that ended early is indistinguishable from a complete one at the walk:
2170    // a page carrying a cursor and no records is how a real PDS ends a list,
2171    // and also how a broken or hostile one cuts it short. Here, where the
2172    // result would DELETE `sub_ref` rows, a shrink of the ordinary size is
2173    // applied at once and a big one must read the same a second time.
2174    let refused = match shrink_is_corroborated(state, did, &out).await {
2175        Shrink::Apply => None,
2176        Shrink::Disagreed => Some(
2177            "Your subscription list came back much shorter than before and did not \
2178             read the same twice, so it was not applied. Showing your last-known \
2179             subscriptions; nothing was removed."
2180                .to_string(),
2181        ),
2182        // Not a disagreement: the second read failed outright, and the reader
2183        // is told why, exactly as for a failed first read (#177).
2184        Shrink::Unreadable(err) => Some(subscriptions_alert(&err)),
2185    };
2186    if let Some(alert) = refused {
2187        return (cached_subscriptions(pool, did).await, Some(alert));
2188    }
2189    // Mirror the caller's resolved subscription set into `sub_ref`, so every
2190    // scoped entry/feed read + read/star mutation authorizes against exactly
2191    // the feeds this DID follows right now. This is THE per-DID isolation hook.
2192    sync_sub_refs(pool, did, &out).await;
2193    (out, None)
2194}
2195
2196/// How many feeds one resolve may drop from a reader's `sub_ref` before the
2197/// listing has to read the same twice to be applied (#203).
2198///
2199/// **Why 3.** Unsubscribing in this app is one feed at a time, so an ordinary
2200/// resolve drops one, occasionally two when another client removed one in the
2201/// meantime. Dropping three or more at once is the shape a walk that ended
2202/// early produces — every record past the cut gone in one step — and is rare
2203/// enough from a real reader that paying one extra listing for it is cheap.
2204/// A folder delete or a cleanup in another client can legitimately drop many;
2205/// those read the same twice and are applied.
2206const SUB_REF_SHRINK_CORROBORATE: usize = 3;
2207
2208/// What [`shrink_is_corroborated`] found.
2209enum Shrink {
2210    /// Apply the listing to `sub_ref`.
2211    Apply,
2212    /// A big shrink whose second listing returned different subscriptions.
2213    Disagreed,
2214    /// A big shrink whose second listing could not be read at all; the
2215    /// error, so the reader is told the real cause.
2216    Unreadable(anyhow::Error),
2217}
2218
2219/// Whether `resolved` may replace `did`'s `sub_ref` projection: yes outright
2220/// unless it drops [`SUB_REF_SHRINK_CORROBORATE`] or more current feeds, in
2221/// which case only if a second listing returns the same subscription URLs.
2222///
2223/// **The limit, stated:** a server that truncates identically twice is
2224/// indistinguishable from a reader who really unsubscribed from those feeds,
2225/// and is applied. This closes the walk that ends early once, not a server
2226/// determined to lie consistently.
2227async fn shrink_is_corroborated(state: &AppState, did: &str, resolved: &[ResolvedSub]) -> Shrink {
2228    let current = match store::subscribed_feed_ids(&state.db, did).await {
2229        Ok(ids) => ids,
2230        Err(err) => {
2231            // Nothing to compare against; the write below will meet the same
2232            // database and log its own failure.
2233            warn!(%err, %did, "could not read sub_ref to check for a mass drop");
2234            return Shrink::Apply;
2235        }
2236    };
2237    let kept: std::collections::HashSet<i64> = resolved
2238        .iter()
2239        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2240        .collect();
2241    let dropped = current.iter().filter(|id| !kept.contains(id)).count();
2242    if dropped < SUB_REF_SHRINK_CORROBORATE {
2243        return Shrink::Apply;
2244    }
2245    let first: std::collections::HashSet<&str> =
2246        resolved.iter().map(|s| s.sub.url.as_str()).collect();
2247    match state.repo().list_subscriptions_sorted(did).await {
2248        Ok(again) => {
2249            let second: std::collections::HashSet<&str> =
2250                again.iter().map(|(_, s)| s.url.as_str()).collect();
2251            if first == second {
2252                return Shrink::Apply;
2253            }
2254            warn!(
2255                %did,
2256                current = current.len(),
2257                dropped,
2258                first = first.len(),
2259                second = second.len(),
2260                "subscription list shrank sharply and did not read the same twice; \
2261                 leaving sub_ref untouched"
2262            );
2263            Shrink::Disagreed
2264        }
2265        Err(err) => {
2266            warn!(
2267                %err,
2268                %did,
2269                current = current.len(),
2270                dropped,
2271                "subscription list shrank sharply and could not be read a second time; \
2272                 leaving sub_ref untouched"
2273            );
2274            Shrink::Unreadable(err)
2275        }
2276    }
2277}
2278
2279/// The fail-closed answer when the PDS listing cannot be applied: `did`'s OWN
2280/// last-known subscriptions, from its `sub_ref` projection, which is left
2281/// untouched.
2282async fn cached_subscriptions(pool: &store::Pool, did: &str) -> Vec<ResolvedSub> {
2283    // Fail CLOSED: the PDS is the source of truth for what this DID
2284    // follows. When it is unreachable, or its answer is not trusted, we
2285    // must NOT widen the caller's authorization surface. Serve from the
2286    // DID's OWN last-known `sub_ref` projection (its own feeds, possibly
2287    // stale) and leave `sub_ref` untouched — never synthesize from every
2288    // cached feed, which would grant cross-tenant read+mutate during any
2289    // outage.
2290    // A DB failure here is NOT the same as "this DID follows nothing",
2291    // but `unwrap_or_default` rendered it as exactly that: an empty
2292    // sidebar and an empty reader, which arrives as "all my feeds
2293    // vanished". It still degrades to empty — there is nothing better to
2294    // show — but it says so, so the support ticket and the log line can
2295    // be matched up.
2296    let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2297        warn!(%err, %did, "the PDS listing could not be applied AND the local \
2298                           subscription projection could not be read; rendering an \
2299                           EMPTY feed list, which is not the same as having none");
2300        Vec::new()
2301    });
2302    feeds
2303        .into_iter()
2304        .map(|f| ResolvedSub {
2305            rkey: String::new(),
2306            sub: Subscription::new(f.url.clone(), now_rfc3339()),
2307            feed: Some(f),
2308        })
2309        .collect()
2310}
2311
2312/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
2313/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
2314/// fail closed / show fewer rows), never leaks another user's entries.
2315async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2316    let feed_ids: Vec<i64> = subs
2317        .iter()
2318        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2319        .collect();
2320    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2321        warn!(%err, %did, "failed to sync sub_ref projection");
2322    }
2323}
2324
2325/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
2326/// records layer) and the article list for the selected scope + view.
2327async fn index(
2328    State(state): State<AppState>,
2329    headers: HeaderMap,
2330    Query(q): Query<IndexQuery>,
2331) -> Result<Response, WebError> {
2332    let user = match current_session(&state, &headers).await {
2333        Some(u) => u,
2334        // Signed out: serve the public landing page rather than bouncing to
2335        // /login. /login remains the entry point for the actual OAuth sign-in.
2336        None => {
2337            return Ok(render(&LandingTemplate {
2338                card: Card::site(&state.config),
2339                version: VERSION,
2340                repo_url: REPO_URL,
2341                crates_url: CRATES_URL,
2342                kofi_url: KOFI_URL,
2343                standard_site: state.config.standard_site,
2344                releases: RELEASES,
2345            }))
2346        }
2347    };
2348    let did = user.did.clone();
2349    let pool = &state.db;
2350
2351    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2352
2353    // View: unread (default) | all | starred.
2354    let view = match q.view.as_deref() {
2355        Some("all") => "all",
2356        Some("starred") => "starred",
2357        _ => "unread",
2358    }
2359    .to_string();
2360    let list_view = list_view_of(q.view.as_deref());
2361
2362    // Which feed URLs are in scope?
2363    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2364    // …and the feed ids they resolve to. Scope is applied inside the query now,
2365    // so a page is a page of rows the reader will actually see. Filtering after
2366    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
2367    // any scope narrower than the whole subscription list.
2368    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2369
2370    let feed_title_by_id = |id: i64| -> String {
2371        subs.iter()
2372            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2373            .map(|s| {
2374                display_title(
2375                    s.sub
2376                        .title
2377                        .as_deref()
2378                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2379                    &s.sub.url,
2380                )
2381            })
2382            .unwrap_or_default()
2383    };
2384
2385    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
2386    //
2387    // All three views used to materialize every matching entry — `SELECT e.*`,
2388    // no `LIMIT`, article bodies included — and the "all" view additionally ran
2389    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
2390    // of the row fields below read the body. See `store::EntryListRow`.
2391    // **Saved records the cache cannot show.**
2392    //
2393    // The starred view is built from local `entries`, so a saved record whose
2394    // article was never cached here is invisible — the case that matters is
2395    // starring in ANOTHER atproto reader, which is the portability the shared
2396    // lexicon exists for. Those rows are rendered from the PDS record alone.
2397    let mut uncached: Vec<EntryRow> = Vec::new();
2398    if view == "starred" {
2399        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
2400        //
2401        // `source` has already been filtered by feed/folder. Matching against it
2402        // meant an entry that IS cached but sits outside the current filter
2403        // looked uncached — so it rendered as a "not cached" row whose star
2404        // button deletes the PDS RECORD instead of un-starring the entry. A
2405        // scope filter must not change what is destroyed. Paging is the same
2406        // hazard in a new form: matching against the visible PAGE would make
2407        // every cached article outside it look uncached. Hence a dedicated
2408        // identity query over the whole starred set — urls and guids only, no
2409        // bodies — rather than reusing `source`.
2410        //
2411        // One gap remains BY DESIGN, and is handled at the other end. This query
2412        // still carries the `sub_ref` predicate, so a starred, cached entry in a
2413        // feed the reader has UNSUBSCRIBED from is absent here and its record
2414        // renders as uncached. That is the right rendering — the article is no
2415        // longer part of any feed the reader follows, and the PDS record is what
2416        // still holds it — but it means the un-save button is the record-deleting
2417        // one. `unsave_record` therefore clears the local star too, so the two
2418        // stores agree however the row got classified. Dropping the predicate
2419        // here instead would have made the row link to `/entries/{id}`, which is
2420        // `sub_ref`-scoped and would 404.
2421        //
2422        // **Three ways this can be unusable, and all three fail CLOSED.** With an
2423        // incomplete identity set, a cached article looks uncached and renders an
2424        // un-save button that deletes the PDS RECORD. Showing no uncached rows
2425        // loses rows for one render; getting this wrong loses data permanently,
2426        // so every uncertain case suppresses them.
2427        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2428            Ok(store::StarredIdentities::All(rows)) => Some(rows),
2429            // The cap is a memory backstop, and reaching it means the set is an
2430            // arbitrary subset. It used to return that subset with no way to
2431            // tell, so every starred article outside it got the destructive
2432            // button.
2433            Ok(store::StarredIdentities::Truncated) => {
2434                warn!(
2435                    %did,
2436                    cap = STARRED_IDENTITY_MAX,
2437                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
2438                     rather than rendering record-deleting buttons for cached articles"
2439                );
2440                None
2441            }
2442            Err(err) => {
2443                warn!(%err, %did, "cached-starred identity lookup failed; \
2444                                    suppressing uncached saved rows this render");
2445                None
2446            }
2447        };
2448        // The escape hatch asks whether this DID has ANY cached starred entry —
2449        // not whether the current SCOPE does. `total` is narrowed by
2450        // `?feed=`/`?folder=` while the identity set spans every feed, so
2451        // comparing them waved the fail-closed condition through for any narrow
2452        // scope: a record whose `feedUrl` matched the filter while its cached
2453        // entry lived under another feed rendered as uncached.
2454        let identities_ok = identities.is_some();
2455        let identities = identities.unwrap_or_default();
2456        let cached_urls: std::collections::HashSet<&str> = identities
2457            .iter()
2458            .filter_map(|(url, _)| url.as_deref())
2459            .collect();
2460        let cached_guids: std::collections::HashSet<&str> =
2461            identities.iter().map(|(_, guid)| guid.as_str()).collect();
2462
2463        // Collected in full here, sliced per page later. They sort after every
2464        // cached row, so the two lists form one sequence that the pager walks —
2465        // see the slice below. Collected BEFORE the page is chosen because the
2466        // page count depends on how many there are.
2467        // Bounded like everything else on this page. These come from the PDS
2468        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
2469        // `backend=rust`, whose caps are a quarter of the other's) and are
2470        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
2471        // constrain them at all. The
2472        // cap is generous — a reader with more saved-elsewhere records than this
2473        // is not the case being designed for — but a response has to have a size
2474        // an operator can reason about.
2475        let mut uncached_dropped = 0usize;
2476        match state.repo().list_saved_sorted(&did).await {
2477            Ok(saved) if identities_ok => {
2478                for (rkey, item) in saved {
2479                    let known = cached_urls.contains(item.url.as_str())
2480                        || item
2481                            .entry_id
2482                            .as_deref()
2483                            .is_some_and(|g| cached_guids.contains(g));
2484                    if known {
2485                        continue;
2486                    }
2487                    // And the scope filter applies to these rows too. Without
2488                    // it, `?feed=X` still listed saved records from every other
2489                    // feed — the filter silently did nothing for them.
2490                    if let Some(urls) = &scope_urls {
2491                        match item.feed_url.as_deref() {
2492                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2493                            // A saved record with no `feedUrl` cannot be placed
2494                            // in any feed's scope, so it belongs only to the
2495                            // unfiltered view.
2496                            _ => continue,
2497                        }
2498                    }
2499                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2500                    //
2501                    // `item.url` is attacker-controlled — a saved record written
2502                    // by any client — and it lands in an `href`. Askama escapes
2503                    // HTML metacharacters but not SCHEMES, so `javascript:`
2504                    // survives escaping intact. This project already built the
2505                    // helper for exactly that, and `feed.rs` uses it on the
2506                    // equivalent link; this path was simply not routed through it.
2507                    //
2508                    // The real defect was what a failure DID: it `continue`d, so
2509                    // the row vanished entirely — no badge, no count, nothing —
2510                    // and the only trace was a `debug!` below any realistic
2511                    // filter. That makes the record unremovable FROM HERE, because
2512                    // the un-save button lives on the row; the reader has to open
2513                    // a different atproto client to get rid of it. A bad URL is a
2514                    // reason to withhold the LINK, not the row.
2515                    //
2516                    // The check also moved ABOVE the poll nudge. That is ordering
2517                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2518                    // on the URL being rejected here, and is already gated on the
2519                    // reader actually subscribing to that feed — so it was never
2520                    // reachable by an unusable `item.url`. Deciding whether a
2521                    // record is renderable before doing anything outbound on its
2522                    // behalf is simply the order that stays correct if either of
2523                    // those two facts later stops being true.
2524                    let link = SafeLink::external(&item.url);
2525                    if link.is_empty() {
2526                        warn!(
2527                            %did, %rkey,
2528                            "a saved record has an unusable URL; rendering it without a link \
2529                             so it can still be removed"
2530                        );
2531                    }
2532
2533                    // Opportunistic re-fetch: if the reader still subscribes to
2534                    // the feed, make it due now. If the article is still inside
2535                    // the feed's window the poller caches it normally and this
2536                    // row becomes a real entry on its own — no synthetic rows in
2537                    // the shared cache, which every subscriber would otherwise
2538                    // see as a content-less entry.
2539                    // **Bound the WORK, not just the response.** This check sat
2540                    // after the nudge and the `subs` scan below, so every render
2541                    // still walked all ≤20,000 PDS records, ran a subs-length
2542                    // string scan per record, and issued up to that many
2543                    // `mark_feed_due` round-trips on a 5-connection pool — then
2544                    // discarded everything past the cap. A cap that runs after
2545                    // the expensive part is a cap on the output only.
2546                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2547                        uncached_dropped += 1;
2548                        continue;
2549                    }
2550                    if let Some(feed_url) = item.feed_url.as_deref() {
2551                        if subs.iter().any(|s| s.sub.url == feed_url) {
2552                            // Bounded to one nudge per feed per poll interval —
2553                            // see `mark_feed_due`. Unbounded, a reload loop here
2554                            // becomes outbound amplification.
2555                            let stale_before = (chrono::Utc::now()
2556                                - chrono::Duration::from_std(state.config.poll_interval)
2557                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2558                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2559                            if let Err(err) =
2560                                store::mark_feed_due(pool, feed_url, &stale_before).await
2561                            {
2562                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2563                            }
2564                        }
2565                    }
2566                    uncached.push(EntryRow {
2567                        id: 0,
2568                        title: item
2569                            .title
2570                            .clone()
2571                            .filter(|t| !t.trim().is_empty())
2572                            // Falling back to the URL is fine for a link we are
2573                            // willing to render, and wrong for one we are not:
2574                            // it would put the exact string `safe_link` just
2575                            // rejected into the page as the record's name. The
2576                            // rkey is what the un-save button acts on, so it is
2577                            // the honest identifier for a row that has nothing
2578                            // else trustworthy to show.
2579                            .unwrap_or_else(|| {
2580                                if link.is_empty() {
2581                                    format!("Saved item {rkey}")
2582                                } else {
2583                                    item.url.clone()
2584                                }
2585                            }),
2586                        feed_title: item.feed_url.clone().unwrap_or_default(),
2587                        published: display_date(Some(&item.created_at)),
2588                        read: false,
2589                        starred: true,
2590                        // Empty = "render this row without an anchor". The
2591                        // template branches on it, so the rejected URL never
2592                        // reaches an `href` even as an escaped string.
2593                        link,
2594                        cached: false,
2595                        rkey,
2596                    });
2597                }
2598            }
2599            // Identity lookup was unusable — see the fail-closed note above.
2600            Ok(_) => {}
2601            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2602        }
2603        if uncached_dropped > 0 {
2604            warn!(
2605                %did,
2606                dropped = uncached_dropped,
2607                cap = MAX_UNCACHED_SAVED_ROWS,
2608                "more saved records than this instance will hold in one response; the \
2609                 rest are not reachable from here"
2610            );
2611        }
2612    }
2613
2614    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2615    // PDS records follow them, and the pager walks the concatenation.
2616    //
2617    // The first version appended the uncached rows to the last page only and
2618    // kept them out of `total`, which left everything past a cap invisible AND
2619    // unremovable — the un-save button lives on the row, and there is no other
2620    // surface in the app that lists these. That is the same "unremovable FROM
2621    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2622    // forty lines later by a bound meant to protect memory.
2623    //
2624    // Paging the concatenation makes every record reachable and needs no cap on
2625    // what is RENDERED — one page is one page either way. The version before
2626    // that inflated `total` while clamping on the cached count, which advertised
2627    // a page the clamp could never reach; both numbers come from the same total
2628    // now, which is what makes that impossible rather than merely fixed.
2629    let total_cached =
2630        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2631    let uncached_len = uncached.len();
2632    let total = total_cached + uncached_len as i64;
2633    // Clamped to the range that exists. Past the end the list is empty, and the
2634    // empty state renders instead of the pager — which would strand a reader who
2635    // typed a page number, or who paged to the end and then marked entries read
2636    // out from under their own URL. Showing the last page is the answer to both.
2637    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2638    let offset = (page - 1) * ENTRIES_PER_PAGE;
2639    // Past the cached rows this returns nothing, which is exactly right: the
2640    // page is then made up entirely of uncached ones.
2641    let source = store::list_entries(
2642        pool,
2643        &did,
2644        list_view,
2645        scope_ids.as_deref(),
2646        ENTRIES_PER_PAGE,
2647        offset,
2648    )
2649    .await?;
2650    // **Both halves of the page are computed from the COUNT alone.**
2651    //
2652    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2653    // queries, so they can disagree about how many cached rows exist. Any part of
2654    // the page composition that reads `source.len()` inherits that disagreement.
2655    //
2656    // `cached_allotment` is this page's cached share according to the snapshot,
2657    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2658    // pages tile the uncached list exactly, whichever way the count drifted.
2659    // `source` is then truncated to it only to avoid rendering rows the next page
2660    // will also claim.
2661    //
2662    // The previous version took `skip` from the count but `take` from
2663    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2664    // an un-star or a retention delete landing between the two queries — made
2665    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2666    // putting twenty rows, each carrying the record-DELETING un-save button, on
2667    // two pages at once. The comment claimed that shape was impossible; it was
2668    // merely rarer.
2669    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2670    let cached_here = cached_allotment.min(source.len());
2671    // Only compose when there is something to compose WITH. `uncached` is empty
2672    // on every view but `starred`, and truncating there just drops trailing rows
2673    // that no page then shows — the poller inserting between the COUNT and the
2674    // SELECT was enough to trigger it.
2675    let source = if uncached_len == 0 {
2676        &source[..]
2677    } else {
2678        &source[..cached_here]
2679    };
2680    let uncached_page: Vec<EntryRow> = {
2681        let skip = (offset - total_cached).max(0) as usize;
2682        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2683        uncached.into_iter().skip(skip).take(take).collect()
2684    };
2685    // This page's slice, used only to append below. The heading needs the
2686    // WHOLE-list figure, which is the set's size before slicing.
2687    let uncached_total = uncached_len as i64;
2688
2689    // The scope/view suffix carried onto every entry link (built once).
2690    let entry_scope_qs = {
2691        let mut parts = Vec::new();
2692        if let Some(f) = q.feed.as_deref() {
2693            parts.push(format!("feed={}", qenc(f)));
2694        }
2695        if let Some(f) = q.folder.as_deref() {
2696            parts.push(format!("folder={}", qenc(f)));
2697        }
2698        if view != "unread" {
2699            parts.push(format!("view={}", qenc(&view)));
2700        }
2701        parts.join("&")
2702    };
2703    let entries: Vec<EntryRow> = source
2704        .iter()
2705        .map(|e| EntryRow {
2706            id: e.id,
2707            title: e
2708                .title
2709                .clone()
2710                .filter(|t| !t.trim().is_empty())
2711                .unwrap_or_else(|| "(untitled)".to_string()),
2712            feed_title: feed_title_by_id(e.feed_id),
2713            published: display_date(e.published.as_deref()),
2714            // Both bits ride along on the row's own `entry_state` join now. They
2715            // used to be membership tests against the full unread and starred
2716            // sets, which is why those two lists were fetched in their entirety
2717            // on every render even when the page showed a hundred rows.
2718            read: e.read,
2719            starred: e.starred,
2720            link: SafeLink::entry(e.id, &entry_scope_qs),
2721            cached: true,
2722            rkey: String::new(),
2723        })
2724        .collect();
2725
2726    // The uncached slice for this page follows the cached rows.
2727    let mut entries = entries;
2728    entries.extend(uncached_page);
2729    let entries = entries;
2730
2731    let selected_feed = q.feed.as_deref();
2732    let selected_folder = q.folder.as_deref();
2733
2734    // Build the shared sidebar (folders + loose feeds, with unread counts).
2735    let (folder_views, loose_feeds, _folder_options) =
2736        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2737
2738    // Heading + scope query-string suffix.
2739    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2740        let name = subs
2741            .iter()
2742            .find(|s| s.sub.url == feed_url)
2743            .map(|s| {
2744                display_title(
2745                    s.sub
2746                        .title
2747                        .as_deref()
2748                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2749                    &s.sub.url,
2750                )
2751            })
2752            .unwrap_or_else(|| display_title(None, feed_url));
2753        (name, format!("feed={}", qenc(feed_url)))
2754    } else if let Some(folder_uri) = selected_folder {
2755        let name = folder_views
2756            .iter()
2757            .find(|f| f.uri == folder_uri)
2758            .map(|f| f.name.clone())
2759            .unwrap_or_else(|| "Folder".to_string());
2760        (name, format!("folder={}", qenc(folder_uri)))
2761    } else {
2762        let h = match view.as_str() {
2763            "all" => "All",
2764            "starred" => "Starred",
2765            _ => "Unread",
2766        };
2767        (h.to_string(), String::new())
2768    };
2769
2770    let feed_scope = selected_feed.map(str::to_string);
2771    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2772
2773    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2774    // page number is the only thing appended — which keeps a paged link
2775    // identical to an unpaged one in every other respect.
2776    let page_href = |n: i64| -> String {
2777        let mut parts = Vec::new();
2778        if !entry_scope_qs.is_empty() {
2779            parts.push(entry_scope_qs.clone());
2780        }
2781        if n > 1 {
2782            parts.push(format!("page={n}"));
2783        }
2784        if parts.is_empty() {
2785            "/".to_string()
2786        } else {
2787            format!("/?{}", parts.join("&"))
2788        }
2789    };
2790    let prev_href = (page > 1).then(|| page_href(page - 1));
2791    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2792
2793    let tmpl = IndexTemplate {
2794        card: Card::private(&state.config),
2795        version: VERSION,
2796        repo_url: REPO_URL,
2797        kofi_url: KOFI_URL,
2798        flash: q.flash.unwrap_or_default(),
2799        alert: alert.unwrap_or_default(),
2800        nav,
2801        entries,
2802        heading,
2803        feed_scope,
2804        total,
2805        // Whole-list figure, so it sits beside `total` without double counting.
2806        // The per-page slice is composed above and is not a heading number.
2807        uncached_total,
2808        page,
2809        page_count: page_count_for(total),
2810        prev_href,
2811        next_href,
2812    };
2813    Ok(render(&tmpl))
2814}
2815
2816/// Query for `GET /manage` — carries an optional flash after an action redirect.
2817#[derive(Debug, Deserialize, Default)]
2818struct ManageQuery {
2819    #[serde(default)]
2820    flash: Option<String>,
2821}
2822
2823/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2824/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2825/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2826/// mutation logic of its own.
2827async fn manage(
2828    State(state): State<AppState>,
2829    headers: HeaderMap,
2830    Query(q): Query<ManageQuery>,
2831) -> Result<Response, WebError> {
2832    let user = match current_session(&state, &headers).await {
2833        Some(u) => u,
2834        None => return Ok(Redirect::to("/login").into_response()),
2835    };
2836    let did = user.did.clone();
2837
2838    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2839    let (folder_views, loose_feeds, folder_options) =
2840        build_sidebar(&state, &did, &subs, None, None).await;
2841
2842    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2843    let nav = build_nav(
2844        &user,
2845        "unread",
2846        String::new(),
2847        folder_views.iter().map(clone_folder_view).collect(),
2848        loose_feeds.iter().map(clone_feed_view).collect(),
2849        true,
2850    );
2851
2852    let tmpl = ManageTemplate {
2853        card: Card::private(&state.config),
2854        version: VERSION,
2855        repo_url: REPO_URL,
2856        kofi_url: KOFI_URL,
2857        flash: q.flash.unwrap_or_default(),
2858        alert: alert.unwrap_or_default(),
2859        nav,
2860        folder_options,
2861        folders: folder_views,
2862        loose_feeds,
2863        standard_site: state.config.standard_site,
2864    };
2865    Ok(render(&tmpl))
2866}
2867
2868/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2869/// (`Nav`) and the page body without an extra DB round-trip.
2870fn clone_feed_view(f: &FeedView) -> FeedView {
2871    FeedView {
2872        rkey: f.rkey.clone(),
2873        url: f.url.clone(),
2874        title: f.title.clone(),
2875        unread: f.unread,
2876        selected: f.selected,
2877        folder: f.folder.clone(),
2878    }
2879}
2880
2881fn clone_folder_view(f: &FolderView) -> FolderView {
2882    FolderView {
2883        rkey: f.rkey.clone(),
2884        uri: f.uri.clone(),
2885        name: f.name.clone(),
2886        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2887        selected: f.selected,
2888    }
2889}
2890
2891/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2892/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2893/// unscoped "everything" view. A folder scope takes the feed scope when both are
2894/// somehow present (feed wins, matching the query precedence elsewhere).
2895fn scope_urls_for(
2896    subs: &[ResolvedSub],
2897    feed: Option<&str>,
2898    folder: Option<&str>,
2899) -> Option<Vec<String>> {
2900    if let Some(feed_url) = feed {
2901        Some(vec![feed_url.to_string()])
2902    } else {
2903        folder.map(|folder_uri| {
2904            subs.iter()
2905                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2906                .map(|s| s.sub.url.clone())
2907                .collect()
2908        })
2909    }
2910}
2911
2912/// The `at://` URI for a folder record given the owner DID + rkey.
2913fn folder_uri(did: &str, rkey: &str) -> String {
2914    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2915}
2916
2917/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2918/// DID — the shared source for both the reader index and the rail on every
2919/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2920async fn build_sidebar(
2921    state: &AppState,
2922    did: &str,
2923    subs: &[ResolvedSub],
2924    selected_feed: Option<&str>,
2925    selected_folder: Option<&str>,
2926) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2927    let pool = &state.db;
2928    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2929    // all — purely to `.filter().count()` them in Rust, on every page that
2930    // renders chrome, which made the sidebar the most frequently executed
2931    // instance of the unbounded-projection problem.
2932    let unread_counts = store::unread_counts_by_feed(pool, did)
2933        .await
2934        .unwrap_or_else(|err| {
2935            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2936            Default::default()
2937        });
2938    let folders = state
2939        .repo()
2940        .list_folders_sorted(did)
2941        .await
2942        .unwrap_or_default();
2943
2944    let unread_count = |feed_id: Option<i64>| -> i64 {
2945        feed_id
2946            .and_then(|id| unread_counts.get(&id).copied())
2947            .unwrap_or(0)
2948    };
2949    let mk_feed_view = |s: &ResolvedSub| FeedView {
2950        rkey: s.rkey.clone(),
2951        url: s.sub.url.clone(),
2952        title: display_title(
2953            s.sub
2954                .title
2955                .as_deref()
2956                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2957            &s.sub.url,
2958        ),
2959        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2960        selected: selected_feed == Some(s.sub.url.as_str()),
2961        folder: s.sub.folder.clone(),
2962    };
2963
2964    let mut folder_views = Vec::with_capacity(folders.len());
2965    for (rkey, folder) in &folders {
2966        let uri = folder_uri(did, rkey);
2967        let feeds: Vec<FeedView> = subs
2968            .iter()
2969            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2970            .map(mk_feed_view)
2971            .collect();
2972        folder_views.push(FolderView {
2973            rkey: rkey.clone(),
2974            uri: uri.clone(),
2975            name: folder.name.clone(),
2976            feeds,
2977            selected: selected_folder == Some(uri.as_str()),
2978        });
2979    }
2980
2981    let known_uris: std::collections::HashSet<String> =
2982        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2983    let loose_feeds: Vec<FeedView> = subs
2984        .iter()
2985        .filter(|s| {
2986            s.sub
2987                .folder
2988                .as_deref()
2989                .map(|f| !known_uris.contains(f))
2990                .unwrap_or(true)
2991        })
2992        .map(mk_feed_view)
2993        .collect();
2994
2995    let folder_options: Vec<FolderOption> = folders
2996        .iter()
2997        .map(|(rkey, folder)| FolderOption {
2998            name: folder.name.clone(),
2999            uri: folder_uri(did, rkey),
3000        })
3001        .collect();
3002
3003    (folder_views, loose_feeds, folder_options)
3004}
3005
3006/// Assemble the shared rail [`Nav`] for a chrome page.
3007fn build_nav(
3008    user: &CurrentUser,
3009    view: &str,
3010    scope_qs: String,
3011    folders: Vec<FolderView>,
3012    loose_feeds: Vec<FeedView>,
3013    manage_active: bool,
3014) -> Nav {
3015    Nav {
3016        handle: display_handle(user.handle.as_deref(), &user.did),
3017        avatar: avatar_initials(user.handle.as_deref(), &user.did),
3018        view: view.to_string(),
3019        scope_qs,
3020        folders,
3021        loose_feeds,
3022        manage_active,
3023    }
3024}
3025
3026// ---------------------------------------------------------------------------
3027// Reader: single entry
3028// ---------------------------------------------------------------------------
3029
3030/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
3031/// prev/next and "back" stay within the list the reader came from.
3032#[derive(Debug, Deserialize, Default)]
3033struct EntryQuery {
3034    #[serde(default)]
3035    feed: Option<String>,
3036    #[serde(default)]
3037    folder: Option<String>,
3038    #[serde(default)]
3039    view: Option<String>,
3040}
3041
3042/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
3043/// within the current reading list.
3044async fn entry_view(
3045    State(state): State<AppState>,
3046    headers: HeaderMap,
3047    Path(id): Path<i64>,
3048    Query(q): Query<EntryQuery>,
3049) -> Result<Response, WebError> {
3050    let user = match current_session(&state, &headers).await {
3051        Some(u) => u,
3052        None => return Ok(Redirect::to("/login").into_response()),
3053    };
3054    let did = user.did.clone();
3055    let pool = &state.db;
3056
3057    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
3058    // the per-DID entry gate below authorizes against the caller's current PDS
3059    // subscription set (not another user's cached feeds).
3060    let subs = resolve_subscriptions(&state, &did).await;
3061
3062    let entry = match get_entry_by_id(pool, &did, id).await? {
3063        Some(e) => e,
3064        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3065    };
3066
3067    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
3068
3069    let read = entry_is_read(pool, &did, id).await?;
3070    let starred = entry_is_starred(pool, &did, id).await?;
3071
3072    // Reconstruct the current list to compute prev/next, so paging in the reader
3073    // matches what the list showed.
3074    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
3075
3076    let back_qs = scope_query(&q);
3077
3078    // Re-sanitize the stored body for this render, through the process-wide
3079    // renderer: size-capped, at most two cleans at once, cached by content
3080    // hash, all off the async runtime (#151).
3081    let content_html = match entry.content_html.clone() {
3082        Some(raw) => Some(BodyRenderer::shared().render(raw).await?),
3083        None => None,
3084    };
3085
3086    let (folder_views, loose_feeds, _) =
3087        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
3088    let nav_view = match q.view.as_deref() {
3089        Some("all") => "all",
3090        Some("starred") => "starred",
3091        _ => "unread",
3092    };
3093    let nav = build_nav(
3094        &user,
3095        nav_view,
3096        back_qs.clone(),
3097        folder_views,
3098        loose_feeds,
3099        false,
3100    );
3101
3102    let tmpl = EntryTemplate {
3103        card: Card::private(&state.config),
3104        version: VERSION,
3105        repo_url: REPO_URL,
3106        kofi_url: KOFI_URL,
3107        nav,
3108        id: entry.id,
3109        title: entry
3110            .title
3111            .clone()
3112            .filter(|t| !t.trim().is_empty())
3113            .unwrap_or_else(|| "(untitled)".to_string()),
3114        feed_title,
3115        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
3116        published: display_date(entry.published.as_deref()),
3117        url: entry.url.as_deref().and_then(SafeLink::external_opt),
3118        content_html,
3119        read,
3120        starred,
3121        back_qs,
3122        prev_id,
3123        next_id,
3124        oob: false,
3125    };
3126    Ok(render(&tmpl))
3127}
3128
3129/// Compute the prev/next entry ids around `current` within the reader's current
3130/// scope + view, so the reader view can offer keyboard/paging navigation.
3131async fn neighbors_in_scope(
3132    state: &AppState,
3133    did: &str,
3134    q: &EntryQuery,
3135    current: i64,
3136) -> (Option<i64>, Option<i64>) {
3137    let idx_q = IndexQuery {
3138        feed: q.feed.clone(),
3139        folder: q.folder.clone(),
3140        view: q.view.clone(),
3141        // Neighbours span the whole list, not the page the reader arrived from.
3142        page: None,
3143        flash: None,
3144    };
3145    let ids = list_entry_ids(state, did, &idx_q).await;
3146    let pos = ids.iter().position(|&x| x == current);
3147    match pos {
3148        Some(p) => {
3149            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
3150            let next = ids.get(p + 1).copied();
3151            (prev, next)
3152        }
3153        None => (None, None),
3154    }
3155}
3156
3157/// The ordered entry ids for a scope + view — the same ordering `index` renders,
3158/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
3159async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
3160    let pool = &state.db;
3161    let subs = resolve_subscriptions(state, did).await;
3162
3163    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
3164
3165    // Ids only, and bounded. This used to fetch whole entries — bodies included
3166    // — for all three views and then throw everything but `id` away; the "all"
3167    // branch additionally ran one unbounded query PER FEED and sorted the union
3168    // in memory. Scope is now a feed-id restriction inside the query, so the
3169    // database does the filtering and the ordering exactly once.
3170    store::list_entry_ids(
3171        pool,
3172        did,
3173        list_view_of(q.view.as_deref()),
3174        scoped_feed_ids(&subs, &scope_urls).as_deref(),
3175        PREV_NEXT_MAX,
3176    )
3177    .await
3178    .unwrap_or_else(|err| {
3179        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3180        Vec::new()
3181    })
3182}
3183
3184/// Map the `?view=` query value onto the store's list view. Anything
3185/// unrecognised is the unread default, matching `index`.
3186fn list_view_of(view: Option<&str>) -> store::ListView {
3187    match view {
3188        Some("all") => store::ListView::All,
3189        Some("starred") => store::ListView::Starred,
3190        _ => store::ListView::Unread,
3191    }
3192}
3193
3194/// Translate a feed/folder scope into the feed ids to restrict a list query to.
3195///
3196/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
3197/// matched no local feed, which must return nothing rather than everything — so
3198/// the empty vec is deliberately preserved, not collapsed back into `None`.
3199fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3200    let urls = scope_urls.as_ref()?;
3201    Some(
3202        subs.iter()
3203            .filter(|s| urls.contains(&s.sub.url))
3204            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3205            .collect(),
3206    )
3207}
3208
3209/// Build a `?…` query string that preserves the reading scope + view for links.
3210fn scope_query(q: &EntryQuery) -> String {
3211    let mut parts = Vec::new();
3212    if let Some(f) = q.feed.as_deref() {
3213        parts.push(format!("feed={}", qenc(f)));
3214    }
3215    if let Some(f) = q.folder.as_deref() {
3216        parts.push(format!("folder={}", qenc(f)));
3217    }
3218    if let Some(v) = q.view.as_deref() {
3219        if v != "unread" {
3220            parts.push(format!("view={}", qenc(v)));
3221        }
3222    }
3223    parts.join("&")
3224}
3225
3226// ---------------------------------------------------------------------------
3227// Mark read / unread
3228// ---------------------------------------------------------------------------
3229
3230/// Form body for `POST /entries/:id/read`.
3231#[derive(Debug, Deserialize)]
3232struct ReadForm {
3233    #[serde(default)]
3234    read: Option<String>,
3235}
3236
3237/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
3238async fn mark_read(
3239    State(state): State<AppState>,
3240    Path(id): Path<i64>,
3241    headers: HeaderMap,
3242    Form(form): Form<ReadForm>,
3243) -> Result<Response, WebError> {
3244    let did = match current_did(&state, &headers).await {
3245        Some(d) => d,
3246        None => return Ok(Redirect::to("/login").into_response()),
3247    };
3248    let pool = &state.db;
3249
3250    let read = matches!(
3251        form.read.as_deref(),
3252        Some("true") | Some("1") | Some("on") | None
3253    );
3254
3255    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3256    // mutation: `mark_read` only writes when `did` subscribes to the entry's
3257    // feed. A non-subscriber gets a 404, never a mutation of someone else's
3258    // (or the shared cache's) state.
3259    resolve_subscriptions(&state, &did).await;
3260    if !store::mark_read(pool, &did, id, read).await? {
3261        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3262    }
3263
3264    if !is_htmx(&headers) {
3265        return Ok(Redirect::to("/").into_response());
3266    }
3267
3268    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
3269    // in the DOM), so its button's hidden value + aria-pressed update in place
3270    // and a second keypress can reverse the toggle. The list view swaps the row.
3271    if is_reader_request(&headers) {
3272        let starred = entry_is_starred(pool, &did, id).await?;
3273        return Ok(render(&EntryActionBarTemplate {
3274            id,
3275            read,
3276            starred,
3277            oob: true,
3278        }));
3279    }
3280
3281    let row = build_entry_row(pool, &did, id, Some(read)).await?;
3282    match row {
3283        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3284        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3285    }
3286}
3287
3288// ---------------------------------------------------------------------------
3289// Star / save
3290// ---------------------------------------------------------------------------
3291
3292/// Form body for `POST /entries/:id/star`.
3293#[derive(Debug, Deserialize)]
3294struct StarForm {
3295    #[serde(default)]
3296    starred: Option<String>,
3297}
3298
3299/// `POST /entries/:id/star` — star/unstar an entry.
3300///
3301/// Sets the local `starred` bit (fast working copy) and writes/removes a
3302/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
3303/// owning). The PDS write is best-effort — the local star still lands.
3304async fn toggle_star(
3305    State(state): State<AppState>,
3306    Path(id): Path<i64>,
3307    headers: HeaderMap,
3308    Form(form): Form<StarForm>,
3309) -> Result<Response, WebError> {
3310    let did = match current_did(&state, &headers).await {
3311        Some(d) => d,
3312        None => return Ok(Redirect::to("/login").into_response()),
3313    };
3314    let pool = &state.db;
3315
3316    let starred = matches!(
3317        form.starred.as_deref(),
3318        Some("true") | Some("1") | Some("on") | None
3319    );
3320
3321    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3322    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
3323    // feed. A non-subscriber gets a 404, never a mutation.
3324    resolve_subscriptions(&state, &did).await;
3325    if !store::mark_starred(pool, &did, id, starred).await? {
3326        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3327    }
3328
3329    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
3330    // to the caller's subscriptions, so this only ever acts on the caller's feed.
3331    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3332        let entry_url = entry.url.clone().unwrap_or_default();
3333        if !entry_url.is_empty() {
3334            if starred {
3335                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3336                saved.title = entry.title.clone();
3337                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3338                saved.entry_id = Some(entry.guid.clone());
3339                match state.repo().add_saved(&did, &saved).await {
3340                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3341                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3342                }
3343            } else {
3344                // Un-star: find and delete the matching saved record by URL.
3345                match state.repo().list_saved(&did).await {
3346                    Ok(records) => {
3347                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3348                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3349                                warn!(%err, %did, %rkey, "PDS saved delete failed");
3350                            }
3351                        }
3352                    }
3353                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3354                }
3355            }
3356        }
3357    }
3358
3359    if !is_htmx(&headers) {
3360        return Ok(Redirect::to("/").into_response());
3361    }
3362
3363    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
3364    if is_reader_request(&headers) {
3365        let read = entry_is_read(pool, &did, id).await?;
3366        return Ok(render(&EntryActionBarTemplate {
3367            id,
3368            read,
3369            starred,
3370            oob: true,
3371        }));
3372    }
3373
3374    let row = build_entry_row(pool, &did, id, None).await?;
3375    match row {
3376        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3377        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3378    }
3379}
3380
3381/// The feed URL for a cached feed id, if the row exists.
3382async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3383    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3384        .bind(feed_id)
3385        .fetch_optional(pool)
3386        .await
3387        .ok()
3388        .flatten()
3389}
3390
3391// ---------------------------------------------------------------------------
3392// Mark-all-read
3393// ---------------------------------------------------------------------------
3394
3395/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
3396/// absent means mark everything read.
3397#[derive(Debug, Deserialize, Default)]
3398struct ReadAllQuery {
3399    #[serde(default)]
3400    feed: Option<String>,
3401}
3402
3403/// `POST /read-all` — mark every entry read for the current DID, optionally
3404/// scoped to one feed (mark-all-read per feed or globally).
3405async fn mark_all_read(
3406    State(state): State<AppState>,
3407    headers: HeaderMap,
3408    Query(q): Query<ReadAllQuery>,
3409) -> Result<Response, WebError> {
3410    let did = match current_did(&state, &headers).await {
3411        Some(d) => d,
3412        None => return Ok(Redirect::to("/login").into_response()),
3413    };
3414    let pool = &state.db;
3415
3416    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
3417    // only ever touch feeds this DID actually subscribes to.
3418    resolve_subscriptions(&state, &did).await;
3419
3420    if let Some(feed_url) = q.feed.as_deref() {
3421        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3422            store::mark_feed_read(pool, &did, feed.id, true).await?;
3423        }
3424        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3425    }
3426
3427    // Global: mark every subscribed feed read. Fan out over the DID's feeds
3428    // (bounded by the per-DID subscription cap) using the batched per-feed path,
3429    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
3430    // state, but O(feeds) statements instead of O(unread entries).
3431    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3432        store::mark_feed_read(pool, &did, feed_id, true).await?;
3433    }
3434    Ok(Redirect::to("/").into_response())
3435}
3436
3437// ---------------------------------------------------------------------------
3438// Subscribe by URL
3439// ---------------------------------------------------------------------------
3440
3441/// Flash for a URL this instance cannot store as a feed — not private, just
3442/// not a kind of feed it supports (an `at://` publication with
3443/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
3444/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
3445/// false promise for a record that may already exist in the user's PDS.
3446const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3447    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3448
3449/// Shown when an OPML export is refused because the subscription list could not
3450/// be read in full.
3451///
3452/// **An empty export is worse than no export.** This path used to
3453/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
3454/// file — a blank backup, handed over at the moment the reader reached for one.
3455const EXPORT_INCOMPLETE_REFUSAL: &str =
3456    "Could not read your subscriptions in full, so nothing was exported. Your \
3457     feeds are unchanged — try again, and if it keeps failing the list may be \
3458     larger than this reader can page through.";
3459
3460/// Refusal message shown when a private/paid feed is submitted. FeatherReader
3461/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
3462/// only for now — a private feed's secret URL is never saved, fetched, or sent
3463/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
3464/// and the boot-smoke can assert on it.
3465const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3466    FeatherReader stores your subscriptions in your public PDS, so it supports public \
3467    feeds for now — private-feed support arrives when atproto's private data \
3468    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3469
3470/// Form body for `POST /subscriptions`.
3471#[derive(Debug, Deserialize)]
3472struct SubscribeForm {
3473    url: String,
3474    /// Optional folder `at://` URI to file the new feed under.
3475    #[serde(default)]
3476    folder: Option<String>,
3477}
3478
3479/// The DID-form URL to store for a pasted `at://` publication, or the flash
3480/// to refuse it with.
3481///
3482/// - The scheme is canonicalised: `At://` is the same publication, and
3483///   storing a second spelling makes a second row for it (#183).
3484/// - It must name a `site.standard.publication`; anything else is not a feed
3485///   this instance can read.
3486/// - A handle is resolved to its DID: a handle is a mutable name, and
3487///   `feeds.url` is keyed on identity, so only the DID form is stored.
3488async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3489    let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3490    let canonical = format!(
3491        "{}{}",
3492        crate::atproto::AT_URI_PREFIX,
3493        &input[crate::atproto::AT_URI_PREFIX.len()..]
3494    );
3495    let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3496    if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3497        return Err(unsupported());
3498    }
3499    let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3500        uri.authority.clone()
3501    } else {
3502        let handle =
3503            // Validated as a handle before it is sent anywhere: an authority
3504            // that is neither a DID nor a handle (`did:plc:TOOSHORT`, an
3505            // uppercase DID, a newline) is unsupported, not a lookup (found in
3506            // review).
3507            crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3508        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3509            .await
3510            .map_err(|err| {
3511                warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3512                format!("Couldn't resolve the handle {} to an account.", uri.authority)
3513            })?
3514    };
3515    let url = format!(
3516        "{}{did}/{}/{}",
3517        crate::atproto::AT_URI_PREFIX,
3518        uri.collection,
3519        uri.rkey
3520    );
3521    if !feed::is_storable_feed_url(&url, true) {
3522        return Err(unsupported());
3523    }
3524    Ok(url)
3525}
3526
3527/// `POST /subscriptions` — subscribe by URL.
3528async fn add_subscription(
3529    State(state): State<AppState>,
3530    headers: HeaderMap,
3531    Form(form): Form<SubscribeForm>,
3532) -> Result<Response, WebError> {
3533    let did = match current_did(&state, &headers).await {
3534        Some(d) => d,
3535        None => return Ok(Redirect::to("/login").into_response()),
3536    };
3537    let pool = &state.db;
3538    let input = form.url.trim().to_string();
3539    if input.is_empty() {
3540        return Ok(Redirect::to("/").into_response());
3541    }
3542
3543    // Per-DID subscription cap: bound one account's storage/poller footprint on
3544    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3545    // can't even trigger an outbound request. `<= 0` disables the cap.
3546    let cap = state.config.max_subs_per_did;
3547    if cap > 0 {
3548        match store::count_subscriptions_for_did(pool, &did).await {
3549            Ok(n) if n >= cap => {
3550                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3551                return Ok(Redirect::to(&format!(
3552                    "/?flash={}",
3553                    qenc(&format!(
3554                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3555                    ))
3556                ))
3557                .into_response());
3558            }
3559            Ok(_) => {}
3560            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3561        }
3562    }
3563
3564    // **An at:// paste is a standard.site publication, read by the poller
3565    // since 0.4.0**: canonicalised and resolved to its DID form here, then it
3566    // joins the ordinary path below. With the flag off it is refused as it
3567    // always was — the flag gates what may be stored.
3568    let is_at_uri = input
3569        .get(..crate::atproto::AT_URI_PREFIX.len())
3570        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3571    let publication_url = if is_at_uri {
3572        if !state.config.standard_site {
3573            info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3574            return Ok(
3575                Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3576                    .into_response(),
3577            );
3578        }
3579        match publication_url_from_paste(&state, &input).await {
3580            Ok(url) => Some(url),
3581            Err(flash) => {
3582                info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3583                return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3584            }
3585        }
3586    } else {
3587        None
3588    };
3589
3590    if let feed::FeedPrivacy::Private(reason) =
3591        feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3592    {
3593        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3594        return Ok(
3595            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3596        );
3597    }
3598
3599    let resolved = match publication_url {
3600        Some(url) => Ok(url),
3601        None => resolve_feed_url(&state.config, &input).await,
3602    };
3603    let feed_url = match resolved {
3604        Ok(u) => u,
3605        Err(err) => {
3606            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3607            return Ok(Redirect::to(&format!(
3608                "/?flash={}",
3609                qenc("Couldn't find a feed at that URL")
3610            ))
3611            .into_response());
3612        }
3613    };
3614
3615    // Defensive: resolution may have discovered a feed URL that itself carries a
3616    // secret (e.g. a public site page linking a tokened feed). Re-check the
3617    // resolved URL and refuse before storing/writing anything.
3618    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3619        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3620        return Ok(
3621            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3622        );
3623    }
3624
3625    // The URL about to be STORED is what must be storable — not the one the
3626    // user typed. Autodiscovery already yields only http(s), but this is the
3627    // path that writes the row and the PDS record, so the check lives here too:
3628    // the same gate the OPML and rename paths apply, on the same terms.
3629    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3630        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3631        return Ok(
3632            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3633                .into_response(),
3634        );
3635    }
3636
3637    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3638    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3639    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3640    let feeds_cap = state.config.max_feeds_global;
3641    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3642        match store::count_feeds(pool).await {
3643            Ok(n) if n >= feeds_cap => {
3644                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3645                return Ok(Redirect::to(&format!(
3646                    "/?flash={}",
3647                    qenc(
3648                        "This instance is at its feed capacity right now. Please try again later."
3649                    )
3650                ))
3651                .into_response());
3652            }
3653            Ok(_) => {}
3654            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3655        }
3656    }
3657
3658    store::upsert_feed(
3659        pool,
3660        &store::NewFeed {
3661            url: feed_url.clone(),
3662            ..Default::default()
3663        },
3664    )
3665    .await?;
3666
3667    if let Ok(client) = feed::build_client() {
3668        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3669            match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3670                Ok(outcome) => {
3671                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3672                    // **This path is not the scheduler, so it must settle the
3673                    // error columns itself.** `poll_feed` writes validators and
3674                    // `last_polled` and nothing else.
3675                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3676                }
3677                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3678            }
3679        }
3680    }
3681
3682    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3683    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3684        sub.title = feed_row.title.clone();
3685        sub.site_url = feed_row.site_url.clone();
3686    }
3687    sub.folder = form
3688        .folder
3689        .map(|f| f.trim().to_string())
3690        .filter(|f| !f.is_empty());
3691
3692    match state.repo().add_subscription(&did, &sub).await {
3693        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3694        Err(err) => {
3695            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3696        }
3697    }
3698
3699    Ok(Redirect::to("/").into_response())
3700}
3701
3702/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3703async fn delete_subscription(
3704    State(state): State<AppState>,
3705    headers: HeaderMap,
3706    Path(rkey): Path<String>,
3707) -> Result<Response, WebError> {
3708    let did = match current_did(&state, &headers).await {
3709        Some(d) => d,
3710        None => return Ok(Redirect::to("/login").into_response()),
3711    };
3712    match state.repo().remove_subscription(&did, &rkey).await {
3713        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3714        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3715    }
3716    Ok(Redirect::to("/").into_response())
3717}
3718
3719/// Form body for `POST /subscriptions/:rkey/rename`.
3720#[derive(Debug, Deserialize)]
3721struct RenameSubForm {
3722    url: String,
3723    #[serde(default)]
3724    title: Option<String>,
3725    #[serde(default)]
3726    site_url: Option<String>,
3727    #[serde(default)]
3728    folder: Option<String>,
3729    /// What the `url` input held when the page was rendered. With the `seen_*`
3730    /// fields the handler tells what the reader CHANGED from what they merely
3731    /// saw: every input is always posted, so its value alone cannot (#149).
3732    /// Absent (a hand-made POST, or a page from an older build), the handler's
3733    /// first read stands in for it.
3734    #[serde(default)]
3735    seen_url: Option<String>,
3736    /// What the title input was pre-filled with — the DISPLAY title, which
3737    /// falls back to the cached feed title or the URL for an untitled record.
3738    #[serde(default)]
3739    seen_title: Option<String>,
3740    /// The folder the select was pre-selected with (`""` for none). Posted
3741    /// only when the select is, so the two are present or absent together —
3742    /// and both absent means the reader never saw a folder to change.
3743    #[serde(default)]
3744    seen_folder: Option<String>,
3745}
3746
3747/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3748/// folder, rewriting the whole subscription record via `putRecord`.
3749async fn rename_subscription(
3750    State(state): State<AppState>,
3751    headers: HeaderMap,
3752    Path(rkey): Path<String>,
3753    Form(form): Form<RenameSubForm>,
3754) -> Result<Response, WebError> {
3755    let did = match current_did(&state, &headers).await {
3756        Some(d) => d,
3757        None => return Ok(Redirect::to("/login").into_response()),
3758    };
3759    let feed_url = form.url.trim().to_string();
3760
3761    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3762    // write a junk row to the cache or a malformed subscription record to the
3763    // PDS (add_subscription refuses an empty input the same way).
3764    if feed_url.is_empty() {
3765        return Ok(Redirect::to("/").into_response());
3766    }
3767
3768    // **A compare-and-swap, retried once (#149).** Each attempt reads the
3769    // record with its CID and writes with `swapRecord` set to it, so another
3770    // atproto client's write between the two is refused by the PDS rather than
3771    // erased by our whole-record put. A refused attempt reads again and
3772    // re-applies the form's fields — and only those — to the FRESH record,
3773    // re-running every gate against it. A second refusal is reported as a
3774    // conflict, never as success: a record that keeps moving is being edited
3775    // somewhere, and the reader is the one to decide which edit wins.
3776    //
3777    // **A retry MERGES; it does not replay the form.** Every input is always
3778    // posted, so replaying the form on the fresh record would put back each
3779    // field the reader never touched — a URL another client repointed, a title
3780    // another client changed. `base` is the record as first read, and each
3781    // attempt applies only what the reader changed relative to it; see
3782    // [`merge_rename`].
3783    let mut base: Option<Subscription> = None;
3784    for attempt in 1..=RENAME_ATTEMPTS {
3785        match rename_subscription_once(&state, &did, &rkey, &form, &mut base).await? {
3786            RenameAttempt::Done(resp) => return Ok(resp),
3787            RenameAttempt::Raced => {
3788                info!(%did, %rkey, attempt, "subscription changed between read and write; re-reading");
3789            }
3790        }
3791    }
3792    warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused rename: the subscription kept changing elsewhere");
3793    Ok(rename_conflict_response())
3794}
3795
3796/// The answer to a rename that conflicts with another client's write: nothing
3797/// was written, and the reader decides which edit wins.
3798fn rename_conflict_response() -> Response {
3799    Redirect::to(&format!(
3800        "/?flash={}",
3801        qenc(
3802            "This subscription was changed elsewhere while you were editing it — \
3803             nothing was renamed or moved. Reload and try again."
3804        )
3805    ))
3806    .into_response()
3807}
3808
3809/// Trim, and read an empty value as absent — how the form's optional fields
3810/// have always been taken.
3811fn form_value(v: Option<&str>) -> Option<String> {
3812    v.map(str::trim)
3813        .filter(|t| !t.is_empty())
3814        .map(str::to_string)
3815}
3816
3817/// The record a rename should write, from [`merge_rename`].
3818#[derive(Debug, PartialEq, Eq)]
3819struct MergedRename {
3820    /// The record to write.
3821    sub: Subscription,
3822    /// Whether THIS write repoints the subscription to a different feed URL.
3823    repoint: bool,
3824    /// Every field the reader changed already holds the reader's value — a
3825    /// double-submitted Save whose first request landed. Nothing to write.
3826    already_saved: bool,
3827}
3828
3829/// Which field the reader and another client both changed, differently.
3830#[derive(Debug, PartialEq, Eq)]
3831struct RenameConflict(&'static str);
3832
3833/// Apply the reader's edits to `fresh`: a three-way merge of the form against
3834/// `base`, the record as the handler FIRST read it (#149). Returns the record
3835/// to write and whether the reader repointed it to a different URL.
3836///
3837/// For each field the form carries (`url`, `title`, `folder`, `site_url`):
3838///
3839/// - **the reader did not change it** — the posted value equals the value the
3840///   input was pre-filled with (`seen_*`, or `base` when the form lacks it) —
3841///   so `fresh`'s value stands, whoever wrote it;
3842/// - **the reader changed it, and `fresh` still has `base`'s value** — the
3843///   reader's value is applied;
3844/// - **the reader changed it, and `fresh` already holds the reader's value** —
3845///   both made the same edit (or a double-submitted Save landed first): no
3846///   conflict, nothing to write for that field;
3847/// - **the reader changed it, and so did someone else, differently** — a
3848///   conflict; nothing is written.
3849///
3850/// A field whose input the page did not render (the folder select, without
3851/// folders to list) is untouched by the reader.
3852///
3853/// On the first attempt `fresh` IS `base`, so the only question is what the
3854/// reader changed. The repoint semantics — dropping `siteUrl` and `fetchHint`
3855/// as properties of the old feed — follow the READER's change, never the
3856/// difference between the form and a record another client moved.
3857///
3858/// **What `seen_*` closes, and what it leaves.** Without it, a field another
3859/// client changed between page load and the first read (so no swap fails)
3860/// read as the reader's change — the stale hidden URL repointed the record
3861/// back. With it, an untouched field is never written. A field BOTH changed
3862/// in that window is still last-writer-wins: the conflict check compares
3863/// against the first read, not the page-load record, because `seen_title` is
3864/// the display title (a fallback for an untitled record), not the record's.
3865fn merge_rename(
3866    form: &RenameSubForm,
3867    base: &Subscription,
3868    fresh: Subscription,
3869) -> Result<MergedRename, RenameConflict> {
3870    let mut sub = fresh;
3871    // Fields the reader changed, and how many of those still need writing:
3872    // a change `fresh` already holds — both sides made the same edit, or this
3873    // is a double-submitted Save whose first request landed — is agreement,
3874    // not a conflict, and there is nothing to write for it.
3875    let mut edited = 0;
3876    let mut to_write = 0;
3877
3878    let posted_url = form.url.trim();
3879    let seen_url = form.seen_url.as_deref().unwrap_or(&base.url).trim();
3880    let mut repoint = false;
3881    if posted_url != seen_url {
3882        edited += 1;
3883        if sub.url.trim() == posted_url {
3884            // Already there. Not a repoint by THIS write, so the fresh
3885            // record's siteUrl and fetchHint — perhaps the new feed's — stay.
3886        } else if sub.url.trim() != base.url.trim() {
3887            return Err(RenameConflict("url"));
3888        } else {
3889            to_write += 1;
3890            repoint = true;
3891        }
3892    }
3893    // Like for like: a record another client wrote may carry padding.
3894    sub.url = if repoint { posted_url } else { sub.url.trim() }.to_string();
3895
3896    let posted_title = form_value(form.title.as_deref());
3897    let seen_title = match form.seen_title.as_deref() {
3898        Some(seen) => form_value(Some(seen)),
3899        None => base.title.clone(),
3900    };
3901    if posted_title != seen_title {
3902        edited += 1;
3903        if sub.title == posted_title {
3904            // Already there.
3905        } else if sub.title != base.title {
3906            return Err(RenameConflict("title"));
3907        } else {
3908            to_write += 1;
3909            sub.title = posted_title;
3910        }
3911    }
3912
3913    // **The folder select is conditional; absent, the reader never saw a
3914    // folder.** The manage row renders it — and `seen_folder` with it — only
3915    // when it has folders to list, which a reader without folders, or a page
3916    // whose folder listing failed, does not. Posted with neither, the folder
3917    // is untouched; reading the absence as "no folder" un-foldered every
3918    // subscription retitled from such a page. (The title input is always
3919    // rendered, so an absent title keeps its old meaning.)
3920    if form.folder.is_some() || form.seen_folder.is_some() {
3921        let posted_folder = form_value(form.folder.as_deref());
3922        let seen_folder = match form.seen_folder.as_deref() {
3923            Some(seen) => form_value(Some(seen)),
3924            None => base.folder.clone(),
3925        };
3926        if posted_folder != seen_folder {
3927            edited += 1;
3928            if sub.folder == posted_folder {
3929                // Already there.
3930            } else if sub.folder != base.folder {
3931                return Err(RenameConflict("folder"));
3932            } else {
3933                to_write += 1;
3934                sub.folder = posted_folder;
3935            }
3936        }
3937    }
3938
3939    // `createdAt` and `private` carry over untouched — neither is a property
3940    // of which feed URL the subscription points at.
3941    //
3942    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3943    // repoint drops them rather than leaving a site link for the old feed
3944    // hanging off the new one. The manage row does not post `site_url`; a
3945    // value that is posted and differs from the base is an edit like any
3946    // other.
3947    match form_value(form.site_url.as_deref()) {
3948        Some(site) if Some(&site) != base.site_url.as_ref() => {
3949            edited += 1;
3950            if sub.site_url.as_ref() == Some(&site) {
3951                // Already there.
3952            } else if sub.site_url != base.site_url {
3953                return Err(RenameConflict("siteUrl"));
3954            } else {
3955                to_write += 1;
3956                sub.site_url = Some(site);
3957            }
3958        }
3959        Some(_) => {}
3960        None if repoint => sub.site_url = None,
3961        None => {}
3962    }
3963    if repoint {
3964        sub.fetch_hint = None;
3965    }
3966    Ok(MergedRename {
3967        sub,
3968        repoint,
3969        // A form with no edits is not "already saved": it writes, as it always
3970        // has — it is the reader asking for exactly this record.
3971        already_saved: edited > 0 && to_write == 0,
3972    })
3973}
3974
3975/// How many times [`rename_subscription`] and [`rename_folder`] read and
3976/// write before giving up on a record that keeps changing: the first try and
3977/// one retry.
3978const RENAME_ATTEMPTS: u32 = 2;
3979
3980/// The outcome of one read-then-write of a rename.
3981enum RenameAttempt {
3982    /// Answered: renamed, refused by a gate, or failed for a reason a re-read
3983    /// cannot fix.
3984    Done(Response),
3985    /// The PDS refused the write with `InvalidSwap`: the record moved after
3986    /// this attempt read it. Nothing was written.
3987    Raced,
3988}
3989
3990/// One attempt at [`rename_subscription`]: read the record and its CID, apply
3991/// the form to it, and write it back on the condition that it is still at
3992/// that CID.
3993///
3994/// `base` is the record as the FIRST attempt read it; this sets it on that
3995/// attempt, and every attempt merges against it — see [`merge_rename`].
3996async fn rename_subscription_once(
3997    state: &AppState,
3998    did: &str,
3999    rkey: &str,
4000    form: &RenameSubForm,
4001    base: &mut Option<Subscription>,
4002) -> Result<RenameAttempt, WebError> {
4003    use RenameAttempt::Done;
4004
4005    // **Read before write — `update_subscription` is a `putRecord`, and a
4006    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
4007    //
4008    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
4009    // and hand that over, so every field the form does not carry was written
4010    // back as its default. `templates/manage_row.html` posts `url`, `title` and
4011    // `folder` — and nothing else — so a rename silently destroyed four fields:
4012    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
4013    //
4014    // `createdAt` is the one that matters most: it is the reader's subscribe
4015    // time, it is the sort key for "when did I subscribe", it lives in THEIR
4016    // repo rather than our cache, and once overwritten it is gone with nothing
4017    // in the UI to say so.
4018    //
4019    // There is no single-record read on `Repo` (no `getRecord`), so this lists
4020    // and filters. That is one extra round trip on an action that is already
4021    // doing a PDS write, and it is bounded. A `get_subscription` was weighed
4022    // for #149 and not added: the sidecar has no `get` action, so it would be
4023    // new surface on the backend being retired, and the listing already
4024    // carries each record's CID.
4025    //
4026    // **A failed read refuses the rename.** Falling back to the old
4027    // rebuild-from-scratch here would reinstate the data loss on exactly the
4028    // flaky path, which is the worst place to have it. The write below already
4029    // takes this stance — "a failure here means nothing was renamed or moved" —
4030    // and the read gets the same one.
4031    //
4032    // **The CID comes with the record**, and the write below names it: that is
4033    // the whole compare-and-swap (#149).
4034    let found = match state.repo().list_subscriptions_with_cids(did).await {
4035        Ok(subs) => subs
4036            .into_iter()
4037            .find(|(k, _, _)| k == rkey)
4038            .map(|(_, cid, s)| (cid, s)),
4039        Err(err) => {
4040            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
4041            return Ok(Done(
4042                Redirect::to(&format!(
4043                    "/?flash={}",
4044                    qenc("Could not reach your PDS — nothing was renamed or moved.")
4045                ))
4046                .into_response(),
4047            ));
4048        }
4049    };
4050    let Some((read_cid, fresh)) = found else {
4051        // The rkey is not in the reader's repo. Renaming a record that is not
4052        // there would CREATE one, which is not what "rename" means and would
4053        // give it a fresh `createdAt` — the bug this read exists to prevent.
4054        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
4055        return Ok(Done(
4056            Redirect::to(&format!(
4057                "/?flash={}",
4058                qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
4059            ))
4060            .into_response(),
4061        ));
4062    };
4063
4064    // The subscription can be repointed at a different feed URL. **Every gate
4065    // on the URL applies to a repoint and only a repoint** — the three below
4066    // were each, at one time, run before this line on the URL as posted, and
4067    // each refused a pure retitle of a record that already existed:
4068    //
4069    // - privacy: the narrowed at:// arm fails closed as `Private` for an
4070    //   at-URI that is not a publication (a feed generator another client
4071    //   subscribed to), so the record became un-editable with a flash saying
4072    //   it "was not saved or sent anywhere";
4073    // - the global feeds ceiling keyed on "URL not in the cache", and an
4074    //   at:// record is never cached with the flag off, so at capacity a
4075    //   retitle was refused for a row the handler would not insert;
4076    // - storability, the same way.
4077    //
4078    // An unchanged URL is already in the reader's repo; refusing to retitle
4079    // it protects nothing and takes their own record away from them.
4080    // Like for like: the form value is trimmed, and a record another client
4081    // wrote may carry padding — compared raw, every retitle of it was a repoint.
4082    //
4083    // Whether this IS a repoint is the reader's change, from the merge — not
4084    // the form against a record another client may have moved (#149).
4085    let base = base.get_or_insert_with(|| fresh.clone());
4086    let MergedRename {
4087        sub,
4088        repoint: url_changed,
4089        already_saved,
4090    } = match merge_rename(form, base, fresh) {
4091        Ok(merged) => merged,
4092        Err(RenameConflict(field)) => {
4093            warn!(%did, %rkey, field, "refused rename: the reader and another client both changed the same field");
4094            return Ok(Done(rename_conflict_response()));
4095        }
4096    };
4097    // Every change the reader made is already in the record: a Save submitted
4098    // twice, whose first request landed. Success, with nothing to write — and
4099    // no cache write either, since the request that wrote it made that too.
4100    if already_saved {
4101        info!(%did, %rkey, "rename already in the record; nothing to write");
4102        return Ok(Done(Redirect::to("/").into_response()));
4103    }
4104    let feed_url = sub.url.clone();
4105
4106    // **Storability, on the same terms as the add and OPML paths — for a
4107    // REPOINT, and FIRST.** A target this instance cannot store gets that
4108    // answer, not "private" (the at:// arm fails closed) or "at capacity"
4109    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
4110    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
4111    // here; a review found it by enumerating every writer of the table. The
4112    // first fix ran this check before the repo lookup, on the URL as posted —
4113    // which refused a pure retitle of a subscription that already IS an
4114    // at-URI, on every instance with the flag off. The flag gates what the
4115    // cache may store, not whether a reader may edit their own record: an
4116    // unchanged non-storable URL keeps its PDS write and simply gets no cache
4117    // row below.
4118    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
4119    if url_changed && !storable {
4120        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
4121        return Ok(Done(
4122            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
4123                .into_response(),
4124        ));
4125    }
4126
4127    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
4128    // and rename both upserts it to the local cache AND rewrites the PDS
4129    // subscription record (a public `putRecord`), so without this guard a
4130    // crafted rename could land a secret-bearing URL in the public PDS — the
4131    // exact leak the add and OPML paths already prevent.
4132    if url_changed {
4133        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
4134            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
4135            return Ok(Done(
4136                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
4137            ));
4138        }
4139    }
4140
4141    // Global feeds ceiling parity with add_subscription: a repoint to a
4142    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
4143    // shared cache is at capacity (an existing/duplicate URL adds no row and
4144    // is always fine). `<= 0` disables.
4145    let feeds_cap = state.config.max_feeds_global;
4146    if url_changed
4147        && feeds_cap > 0
4148        && store::get_feed_by_url(&state.db, &feed_url)
4149            .await?
4150            .is_none()
4151    {
4152        match store::count_feeds(&state.db).await {
4153            Ok(n) if n >= feeds_cap => {
4154                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
4155                return Ok(Done(
4156                    Redirect::to(&format!(
4157                        "/?flash={}",
4158                        qenc(
4159                            "This instance is at its feed capacity right now. Please try again later."
4160                        )
4161                    ))
4162                    .into_response(),
4163                ));
4164            }
4165            Ok(_) => {}
4166            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
4167        }
4168    }
4169
4170    // **The PDS write decides what the reader is told.**
4171    //
4172    // This used to `warn!` on failure and then redirect exactly as it does on
4173    // success, so a rename that did not happen was indistinguishable from one
4174    // that did — the reader saw their old title come back and had no reason to
4175    // think anything had gone wrong. The PDS record IS the subscription; a
4176    // failure here means nothing was renamed or moved.
4177    //
4178    // **Conditional on the CID read above (#149).** A listing with no CID is
4179    // a PDS outside the lexicon (`listRecords` requires one); the write then
4180    // goes unconditionally, as every write did before this, and says so.
4181    if read_cid.is_none() {
4182        warn!(%did, %rkey, "the PDS listed this subscription without a CID; renaming without a compare-and-swap");
4183    }
4184    let res = match state
4185        .repo()
4186        .update_subscription(did, rkey, &sub, read_cid.as_deref())
4187        .await
4188    {
4189        Ok(res) => res,
4190        // Another client wrote the record after the read above: nothing was
4191        // written, and the caller decides whether to read again.
4192        Err(err) if crate::atproto::is_invalid_swap(&err) => return Ok(RenameAttempt::Raced),
4193        Err(err) => {
4194            warn!(%err, %did, %rkey, "PDS subscription update failed");
4195            return Ok(Done(
4196                Redirect::to(&format!(
4197                    "/?flash={}",
4198                    qenc("Could not save that change to your PDS — nothing was renamed or moved.")
4199                ))
4200                .into_response(),
4201            ));
4202        }
4203    };
4204    info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
4205
4206    // **The cache follows the PDS, so it is written only now** — after the
4207    // write landed. It used to be written before the put, so a rename the PDS
4208    // refused (a failed save, or both attempts of a lost race, #149) still
4209    // left the new title on the cached row, or a fresh `feeds` row for a
4210    // repoint's URL that the poller then fetched for nobody. Every gate on
4211    // that URL (storability, privacy, the ceiling) ran above, before the put;
4212    // only the write itself moved.
4213    //
4214    // Keep the local cache title in step for the loose-feed fallback path —
4215    // for a row this instance would have. Two cases write nothing:
4216    //
4217    // - not storable (an existing at-URI with the flag off): the record is the
4218    //   reader's to edit, the cache row is not this instance's to create;
4219    // - an unchanged URL with no cache row: a retitle is never the write that
4220    //   CREATES a row. That covers two findings at once — the ceiling is
4221    //   checked on a repoint only, so a retitle must not insert past it; and
4222    //   a secret-bearing URL another client subscribed to has no row (the
4223    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
4224    //   refuses to cache it), so it cannot enter the shared table here, be
4225    //   polled, fail, and be printed on the admin page. A privacy re-check on
4226    //   this write was the first draft; mutation showed it dead — the row
4227    //   rule already refused every case it would have.
4228    //
4229    // A failed lookup skips the cache rather than failing the request: the
4230    // rename has already landed, and the reader must be told so.
4231    let cache_write = storable
4232        && (url_changed
4233            || match store::get_feed_by_url(&state.db, &sub.url).await {
4234                Ok(row) => row.is_some(),
4235                Err(err) => {
4236                    warn!(%err, %did, url = %sub.url, "could not look up the cached feed row after a rename");
4237                    false
4238                }
4239            });
4240    if !cache_write {
4241        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
4242    } else if let Err(err) = store::upsert_feed(
4243        &state.db,
4244        &store::NewFeed {
4245            url: sub.url.clone(),
4246            title: sub.title.clone(),
4247            site_url: sub.site_url.clone(),
4248            ..Default::default()
4249        },
4250    )
4251    .await
4252    {
4253        // Not fatal to the rename — the PDS record is the source of truth —
4254        // but a missing `feeds` row means this subscription is never polled.
4255        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
4256    }
4257
4258    Ok(Done(Redirect::to("/").into_response()))
4259}
4260
4261// ---------------------------------------------------------------------------
4262// Folders
4263// ---------------------------------------------------------------------------
4264
4265/// Form body for `POST /folders`.
4266#[derive(Debug, Deserialize)]
4267struct FolderForm {
4268    name: String,
4269}
4270
4271/// `POST /folders` — create a folder record.
4272async fn create_folder(
4273    State(state): State<AppState>,
4274    headers: HeaderMap,
4275    Form(form): Form<FolderForm>,
4276) -> Result<Response, WebError> {
4277    let did = match current_did(&state, &headers).await {
4278        Some(d) => d,
4279        None => return Ok(Redirect::to("/login").into_response()),
4280    };
4281    let name = form.name.trim();
4282    if name.is_empty() {
4283        return Ok(Redirect::to("/").into_response());
4284    }
4285    let folder = Folder::new(name.to_string(), now_rfc3339());
4286    match state.repo().add_folder(&did, &folder).await {
4287        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
4288        Err(err) => warn!(%err, %did, "PDS folder create failed"),
4289    }
4290    Ok(Redirect::to("/").into_response())
4291}
4292
4293/// Form body for `POST /folders/:rkey/rename`.
4294#[derive(Debug, Deserialize)]
4295struct RenameFolderForm {
4296    name: String,
4297    /// The name the input was pre-filled with — the record's own name, so it
4298    /// is the common ancestor of the reader's edit and any other client's
4299    /// (#268). Absent (a hand-made POST, or a page from an older build), the
4300    /// handler's first read stands in for it.
4301    #[serde(default)]
4302    seen_name: Option<String>,
4303}
4304
4305/// `POST /folders/:rkey/rename` — rename a folder record, changing its `name`
4306/// and nothing else.
4307///
4308/// **An edit of the record, not a replacement (#268).** This used to put
4309/// `Folder::new(name, now)` over the record, which reset `position`, replaced
4310/// `createdAt` with the rename time and dropped every field another
4311/// `community.lexicon.rss` client had added — and reported success whether or
4312/// not the write landed. It now reads the record with its CID, changes only
4313/// the name ([`Folder::extra`] carries the fields this build does not know),
4314/// and writes it back with `swapRecord` set to that CID, retried once on
4315/// `InvalidSwap` with a three-way merge — the same compare-and-swap as
4316/// [`rename_subscription`] (#149).
4317async fn rename_folder(
4318    State(state): State<AppState>,
4319    headers: HeaderMap,
4320    Path(rkey): Path<String>,
4321    Form(form): Form<RenameFolderForm>,
4322) -> Result<Response, WebError> {
4323    let did = match current_did(&state, &headers).await {
4324        Some(d) => d,
4325        None => return Ok(Redirect::to("/login").into_response()),
4326    };
4327    if form.name.trim().is_empty() {
4328        return Ok(Redirect::to("/").into_response());
4329    }
4330    let mut base: Option<Folder> = None;
4331    for attempt in 1..=RENAME_ATTEMPTS {
4332        match rename_folder_once(&state, &did, &rkey, &form, &mut base).await {
4333            RenameAttempt::Done(resp) => return Ok(resp),
4334            RenameAttempt::Raced => {
4335                info!(%did, %rkey, attempt, "folder changed between read and write; re-reading");
4336            }
4337        }
4338    }
4339    warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused folder rename: the folder kept changing elsewhere");
4340    Ok(folder_flash(FOLDER_RENAME_CONFLICT))
4341}
4342
4343/// The answer to a folder rename that conflicts with another client's write.
4344const FOLDER_RENAME_CONFLICT: &str = "This folder was changed elsewhere while you were renaming \
4345     it — it was not renamed. Reload and try again.";
4346
4347/// Redirect home with `message` as the flash.
4348fn folder_flash(message: &str) -> Response {
4349    Redirect::to(&format!("/?flash={}", qenc(message))).into_response()
4350}
4351
4352/// What a folder rename should do, from [`merge_folder_rename`].
4353#[derive(Debug, PartialEq, Eq)]
4354enum FolderMerge {
4355    /// Write this record: the fresh one, renamed.
4356    Write(Folder),
4357    /// The record already has the reader's name — a double-submitted Save
4358    /// whose first request landed, or the same rename made elsewhere.
4359    AlreadySaved,
4360    /// The reader did not change the name. Nothing to write.
4361    Unchanged,
4362}
4363
4364/// A three-way merge of the reader's rename against `fresh`, the record as
4365/// just read (#268).
4366///
4367/// The ancestor is `seen` — the name the input showed — or, without it,
4368/// `base`, the record as the handler first read it. The name is the folder's
4369/// only field the form edits; everything else comes from `fresh` untouched.
4370///
4371/// - the reader left the name as it was shown → [`FolderMerge::Unchanged`],
4372///   whatever `fresh` holds;
4373/// - `fresh` already has the reader's name → [`FolderMerge::AlreadySaved`];
4374/// - `fresh` still has the ancestor's name → write `fresh` renamed;
4375/// - otherwise someone else renamed it differently → a conflict.
4376///
4377/// Compared trimmed: the posted name is trimmed, and a record another client
4378/// wrote may carry padding.
4379fn merge_folder_rename(
4380    posted: &str,
4381    seen: Option<&str>,
4382    base: &Folder,
4383    fresh: Folder,
4384) -> Result<FolderMerge, RenameConflict> {
4385    let posted = posted.trim();
4386    let ancestor = seen.unwrap_or(&base.name).trim();
4387    if posted == ancestor {
4388        return Ok(FolderMerge::Unchanged);
4389    }
4390    let current = fresh.name.trim();
4391    if current == posted {
4392        return Ok(FolderMerge::AlreadySaved);
4393    }
4394    if current != ancestor {
4395        return Err(RenameConflict("name"));
4396    }
4397    let mut folder = fresh;
4398    folder.name = posted.to_string();
4399    Ok(FolderMerge::Write(folder))
4400}
4401
4402/// One attempt at [`rename_folder`]: read the folder and its CID, merge the
4403/// reader's rename into it, and write it back on the condition that it is
4404/// still at that CID. `base` is set by the first attempt's read.
4405async fn rename_folder_once(
4406    state: &AppState,
4407    did: &str,
4408    rkey: &str,
4409    form: &RenameFolderForm,
4410    base: &mut Option<Folder>,
4411) -> RenameAttempt {
4412    use RenameAttempt::Done;
4413
4414    // No single-record read on `Repo`, as for subscriptions: the sidecar has
4415    // no `get` action, and the listing already carries each record's CID.
4416    // A failed read refuses the rename — rebuilding the record from the form
4417    // is the loss this read exists to prevent.
4418    let found = match state.repo().list_folders_with_cids(did).await {
4419        Ok(folders) => folders
4420            .into_iter()
4421            .find(|(k, _, _)| k == rkey)
4422            .map(|(_, cid, f)| (cid, f)),
4423        Err(err) => {
4424            warn!(%err, %did, %rkey, "could not read the folder before renaming it");
4425            return Done(folder_flash(
4426                "Could not reach your PDS — the folder was not renamed.",
4427            ));
4428        }
4429    };
4430    let Some((read_cid, fresh)) = found else {
4431        // Deleted elsewhere (or never there). A put at a missing rkey would
4432        // CREATE the folder, which is not what "rename" means.
4433        warn!(%did, %rkey, "refused folder rename: no such folder in the repo");
4434        return Done(folder_flash(
4435            "That folder no longer exists — it may have been deleted elsewhere. \
4436             Nothing was renamed.",
4437        ));
4438    };
4439
4440    let base = base.get_or_insert_with(|| fresh.clone());
4441    let folder = match merge_folder_rename(&form.name, form.seen_name.as_deref(), base, fresh) {
4442        Ok(FolderMerge::Write(folder)) => folder,
4443        Ok(FolderMerge::AlreadySaved) => {
4444            info!(%did, %rkey, "folder already has this name; nothing to write");
4445            return Done(Redirect::to("/").into_response());
4446        }
4447        Ok(FolderMerge::Unchanged) => return Done(Redirect::to("/").into_response()),
4448        Err(RenameConflict(field)) => {
4449            warn!(%did, %rkey, field, "refused folder rename: the reader and another client both renamed it");
4450            return Done(folder_flash(FOLDER_RENAME_CONFLICT));
4451        }
4452    };
4453
4454    if read_cid.is_none() {
4455        warn!(%did, %rkey, "the PDS listed this folder without a CID; renaming without a compare-and-swap");
4456    }
4457    match state
4458        .repo()
4459        .rename_folder(did, rkey, &folder, read_cid.as_deref())
4460        .await
4461    {
4462        Ok(res) => {
4463            info!(%did, %rkey, uri = %res.uri, "renamed folder");
4464            Done(Redirect::to("/").into_response())
4465        }
4466        Err(err) if crate::atproto::is_invalid_swap(&err) => RenameAttempt::Raced,
4467        Err(err) => {
4468            warn!(%err, %did, %rkey, "PDS folder rename failed");
4469            Done(folder_flash(
4470                "Could not save that change to your PDS — the folder was not renamed.",
4471            ))
4472        }
4473    }
4474}
4475
4476/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
4477/// simply become un-foldered).
4478async fn delete_folder(
4479    State(state): State<AppState>,
4480    headers: HeaderMap,
4481    Path(rkey): Path<String>,
4482) -> Result<Response, WebError> {
4483    let did = match current_did(&state, &headers).await {
4484        Some(d) => d,
4485        None => return Ok(Redirect::to("/login").into_response()),
4486    };
4487    match state.repo().remove_folder(&did, &rkey).await {
4488        Ok(()) => info!(%did, %rkey, "deleted folder record"),
4489        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
4490    }
4491    Ok(Redirect::to("/").into_response())
4492}
4493
4494/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
4495/// feed document we take it as-is; if it yields an HTML page we run
4496/// autodiscovery over its `<link rel="alternate">` tags.
4497async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
4498    let parsed =
4499        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
4500
4501    let client = feed::build_client()?;
4502    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
4503    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
4504    // loopback / private hosts.
4505    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
4506    let final_url = resp.url().clone();
4507    let content_type = resp
4508        .headers()
4509        .get(axum::http::header::CONTENT_TYPE)
4510        .and_then(|v| v.to_str().ok())
4511        .unwrap_or("")
4512        .to_ascii_lowercase();
4513    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
4514    // gzip strips it, and this response is reflected into the UI.
4515    let raw = crate::net::read_capped(resp).await?;
4516    let body = String::from_utf8_lossy(&raw).into_owned();
4517
4518    let looks_like_feed = content_type.contains("xml")
4519        || content_type.contains("rss")
4520        || content_type.contains("atom")
4521        || content_type.contains("application/feed+json")
4522        || {
4523            let head = body.trim_start();
4524            head.starts_with("<?xml")
4525                || head.starts_with("<rss")
4526                || head.starts_with("<feed")
4527                || head.contains("<rss")
4528                || head.contains("<feed")
4529        };
4530    if looks_like_feed {
4531        return Ok(final_url.to_string());
4532    }
4533
4534    match feed::discover_feed(&body, Some(&final_url)) {
4535        Some(u) => Ok(u.to_string()),
4536        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
4537    }
4538}
4539
4540// ---------------------------------------------------------------------------
4541// Login (atproto OAuth via the sidecar)
4542// ---------------------------------------------------------------------------
4543
4544/// Query for `GET /login`.
4545#[derive(Debug, Deserialize, Default)]
4546struct LoginQuery {
4547    #[serde(default)]
4548    handle: Option<String>,
4549    #[serde(default)]
4550    error: Option<String>,
4551    #[serde(default)]
4552    flash: Option<String>,
4553}
4554
4555/// `GET /login` — start the atproto OAuth flow, or render the handle form.
4556///
4557/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
4558/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
4559/// session cookie *or* the submitted handle resolving to a seated DID) or a
4560/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
4561/// form (no handle) always renders.
4562async fn login_form(
4563    State(state): State<AppState>,
4564    headers: HeaderMap,
4565    Query(q): Query<LoginQuery>,
4566) -> Response {
4567    if let Some(handle) = q
4568        .handle
4569        .map(|h| h.trim().to_string())
4570        .filter(|h| !h.is_empty())
4571    {
4572        if !may_start_oauth(&state, &headers, &handle).await {
4573            return Redirect::to("/beta/redeem").into_response();
4574        }
4575        return start_oauth(&state, &handle).await;
4576    }
4577    render(&LoginTemplate {
4578        card: login_card(&state.config),
4579        repo_url: REPO_URL,
4580        error: q.error.unwrap_or_default(),
4581        flash: q.flash.unwrap_or_default(),
4582    })
4583}
4584
4585/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
4586/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
4587async fn login_submit(
4588    State(state): State<AppState>,
4589    headers: HeaderMap,
4590    Form(form): Form<LoginForm>,
4591) -> Response {
4592    let handle = form.handle.trim();
4593    if handle.is_empty() {
4594        return login_error(&state, "Enter your atproto handle.");
4595    }
4596    if !may_start_oauth(&state, &headers, handle).await {
4597        return Redirect::to("/beta/redeem").into_response();
4598    }
4599    start_oauth(&state, handle).await
4600}
4601
4602/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
4603/// admits, in order of cost:
4604///
4605/// 1. an existing beta member's cookie session whose DID already holds a seat;
4606/// 2. a fresh visitor carrying a valid reserving invite cookie;
4607/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
4608///    already holds a seat — this honors the **seeded admin's first login** on a
4609///    fresh deploy (and any returning member who cleared cookies) without a
4610///    session cookie or an invite code.
4611///
4612/// The cookie/invite fast paths run FIRST and short-circuit, so the network
4613/// handle→DID resolution is only attempted when neither applies. It fails
4614/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
4615/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
4616/// This keeps the anti-abuse intent — a rando now pays a cheap handle
4617/// resolution instead of a burned sidecar handshake (and `/login` is already in
4618/// the rate-limited path set).
4619async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
4620    // The production resolver is the app's existing atproto handle→DID path,
4621    // routed through the SSRF guard. Resolution is injected so tests can exercise
4622    // the gate without a live network call (the guard forbids loopback mocks).
4623    may_start_oauth_with(state, headers, handle, |h| async move {
4624        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
4625            .await
4626            .ok()
4627    })
4628    .await
4629}
4630
4631/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
4632/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
4633/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
4634/// only called when neither admits — keeping the network round-trip off the hot
4635/// path and preserving the fail-closed contract on resolution failure.
4636async fn may_start_oauth_with<F, Fut>(
4637    state: &AppState,
4638    headers: &HeaderMap,
4639    handle: &str,
4640    resolve: F,
4641) -> bool
4642where
4643    F: FnOnce(String) -> Fut,
4644    Fut: std::future::Future<Output = Option<String>>,
4645{
4646    // 1. An already-beta'd session may re-auth freely.
4647    if let Some(did) = current_did(state, headers).await {
4648        if store::has_beta_access(&state.db, &did)
4649            .await
4650            .unwrap_or(false)
4651        {
4652            return true;
4653        }
4654    }
4655    // 2. A valid reserving invite cookie.
4656    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4657        return true;
4658    }
4659    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
4660    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
4661    //    on any resolution error or unresolvable/malformed handle.
4662    match resolve(handle.to_string()).await {
4663        Some(did) => store::has_beta_access(&state.db, &did)
4664            .await
4665            .unwrap_or(false),
4666        None => {
4667            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4668            false
4669        }
4670    }
4671}
4672
4673/// Begin the OAuth handshake for `handle`, on whichever backend is live.
4674///
4675/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
4676/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
4677/// carries `form-action 'self'`. Browsers have historically disagreed about
4678/// whether that directive applies to redirects following a form submission, and
4679/// if it did here, login would break in a browser while every test passed.
4680///
4681/// It does not, and the evidence is the SIDECAR path, which is live in
4682/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
4683/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
4684/// whole redirect chain would already be blocking that. One checking only the
4685/// form's action URL sees `/login` in both cases. The two arms differ only in
4686/// how many same-origin hops precede the cross-origin one, so any policy that
4687/// permits the sidecar flow permits this one.
4688///
4689/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
4690/// its own `/login` and its own callback, so starting a login is one redirect
4691/// and nothing is stored here. The Rust backend pushes the authorization
4692/// request itself, which means this app now holds the pending login — and must
4693/// set the browser-binding cookie that the callback will be checked against.
4694async fn start_oauth(state: &AppState, handle: &str) -> Response {
4695    match state.config.repo_backend {
4696        crate::metrics::Backend::Sidecar => {
4697            let url = state.sidecar.login_url(handle, None);
4698            info!(%handle, "redirecting to OAuth sidecar login");
4699            Redirect::to(&url).into_response()
4700        }
4701        crate::metrics::Backend::Rust => {
4702            let Some(runtime) = state.oauth.as_deref() else {
4703                warn!("the rust backend is live but its OAuth runtime is absent");
4704                return login_error(state, "Login is not available right now.");
4705            };
4706            match crate::oauth::login::start(
4707                runtime,
4708                &state.http,
4709                &state.db,
4710                handle,
4711                crate::store::now_unix(),
4712            )
4713            .await
4714            {
4715                Ok(started) => {
4716                    info!(%handle, "pushed authorization request; redirecting to the PDS");
4717                    let mut resp = Redirect::to(&started.authorize_url).into_response();
4718                    set_cookie(
4719                        &mut resp,
4720                        &cookie::sign_value(
4721                            OAUTH_BINDING_COOKIE,
4722                            &started.binding_token,
4723                            &state.config.cookie_secret,
4724                            OAUTH_BINDING_MAX_AGE_SECS,
4725                        ),
4726                    );
4727                    resp
4728                }
4729                Err(err) => {
4730                    // The handle the user typed is logged; the error is not shown
4731                    // to them verbatim, since it can name internal hosts.
4732                    warn!(%err, %handle, "could not start the OAuth login");
4733                    login_error(state, "Could not start login for that handle.")
4734                }
4735            }
4736        }
4737    }
4738}
4739
4740/// Clear the browser-binding cookie. Called on every terminal outcome of a
4741/// callback, successful or not: the pending row is consumed either way, so a
4742/// lingering cookie can only ever match a login that no longer exists.
4743fn clear_binding_cookie(resp: &mut Response) {
4744    set_cookie(
4745        resp,
4746        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4747    );
4748}
4749
4750/// Form body for `POST /login`.
4751#[derive(Debug, Deserialize)]
4752struct LoginForm {
4753    handle: String,
4754}
4755
4756/// Query for `GET /oauth/callback`.
4757///
4758/// Carries BOTH shapes, because the two backends deliver different things to
4759/// the same URL: the sidecar hands back a one-shot `session_id` it has already
4760/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
4761/// for this app to exchange itself. Which fields are populated is decided by
4762/// which backend started the login, not by which is live now — so a flip with a
4763/// login already in flight still lands in the right arm.
4764#[derive(Debug, Deserialize, Default)]
4765struct CallbackQuery {
4766    /// Sidecar backend: the handoff id.
4767    #[serde(default)]
4768    session_id: Option<String>,
4769    /// Rust backend: the authorization code and its envelope.
4770    #[serde(default)]
4771    code: Option<String>,
4772    #[serde(default)]
4773    state: Option<String>,
4774    #[serde(default)]
4775    iss: Option<String>,
4776    /// JARM, which is not supported — carried only so it can be refused
4777    /// explicitly rather than read as "no code".
4778    #[serde(default)]
4779    response: Option<String>,
4780    #[serde(default)]
4781    error: Option<String>,
4782    #[serde(default)]
4783    error_description: Option<String>,
4784}
4785
4786/// `GET /oauth/callback` — establish the cookie session.
4787///
4788/// **Invite gate:** the verified DID must hold beta access. If it already does
4789/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
4790/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
4791/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
4792async fn oauth_callback(
4793    State(state): State<AppState>,
4794    headers: HeaderMap,
4795    Query(q): Query<CallbackQuery>,
4796) -> Response {
4797    // An error response is handled by the SAME arm that would have handled a
4798    // success, not short-circuited here.
4799    //
4800    // Returning early looks obviously right and is wrong on the Rust path: it
4801    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
4802    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
4803    // error originates from the intended AS". It also leaves the pending row
4804    // unconsumed, so a `state` that has already produced a callback stays usable
4805    // until it expires.
4806    //
4807    // The sidecar arm has no such check to reach, so it is short-circuited
4808    // below, preserving exactly what it did before.
4809    // **The arm is chosen by what the SERVER knows, not by what the caller
4810    // sent.** A `session_id` in the query used to select the sidecar arm on its
4811    // own — so a caller could pick which code path ran, and the sidecar arm has
4812    // no browser-binding check at all. It also short-circuited the error path
4813    // below, skipping the `iss` validation.
4814    //
4815    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
4816    // configured one, means the selection follows this deployment's own
4817    // configuration. A login started before a flip still completes, because the
4818    // Rust arm is reached whenever the Rust runtime exists and can match the
4819    // `state` against a pending row it actually wrote.
4820    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
4821    // and `?error=…&error_description=…` on its own failure. Keying only on
4822    // `session_id` sent the failure shape down the Rust arm, which then failed
4823    // with "no `state`" and replaced the specific reason with a generic one —
4824    // and `error_description` is exactly what the sidecar Caddy routing matches
4825    // to send that request here in the first place.
4826    let sidecar_shape =
4827        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4828    let sidecar_handoff = sidecar_shape
4829        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4830    if let Some(err) = q.error.clone() {
4831        // **Neither the code nor the description is echoed as sent.**
4832        //
4833        // Both are server-controlled free text arriving on a public GET, so
4834        // anyone who can make a browser fetch this URL chooses them. The raw
4835        // `error` used to go into a `warn!` AND into the rendered login page,
4836        // and `error_description` — arbitrary text, newlines included — went
4837        // into the log verbatim: a log-injection surface on one side and
4838        // attacker-chosen copy in the product's own voice on the other.
4839        //
4840        // `oauth::flow` already decided this exact question for the Rust arm:
4841        // reduce the code to a known slug, drop the description entirely. That
4842        // reasoning is not specific to which arm handles the callback, and this
4843        // one simply never got the same treatment. The description's LENGTH is
4844        // kept, because "the server sent a 4 KB explanation" is occasionally
4845        // worth knowing and cannot be used to inject anything.
4846        let slug = crate::oauth::flow::known_error_slug(&err);
4847        warn!(
4848            error = slug,
4849            desc_len = q.error_description.as_deref().map_or(0, str::len),
4850            "OAuth callback returned an error"
4851        );
4852        if sidecar_handoff || state.oauth.is_none() {
4853            return login_error(&state, &format!("Login failed: {slug}"));
4854        }
4855        // Fall through: the Rust arm consumes the pending row and validates
4856        // `iss` against it, and reports the failure afterwards.
4857    }
4858
4859    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
4860    // currently selected: a login started before a flip must still complete.
4861    let session = if sidecar_handoff {
4862        let session_id = q.session_id.clone().unwrap_or_default();
4863        match state.sidecar.resolve_session(&session_id).await {
4864            Ok(Some(s)) => s,
4865            Ok(None) => {
4866                warn!("OAuth callback session_id did not resolve (expired/unknown)");
4867                return login_error(&state, "Login session expired — please try again.");
4868            }
4869            Err(err) => {
4870                warn!(%err, "failed to resolve OAuth session via the sidecar");
4871                return login_error(&state, "Login failed talking to the auth service.");
4872            }
4873        }
4874    } else {
4875        let Some(runtime) = state.oauth.as_deref() else {
4876            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4877            return login_error(&state, "Login failed: this login could not be completed.");
4878        };
4879        let params = crate::oauth::flow::CallbackParams {
4880            code: q.code.clone(),
4881            state: q.state.clone(),
4882            iss: q.iss.clone(),
4883            // Passed through, NOT dropped: `verify_callback` checks `iss`
4884            // against the pending row's issuer before it reports the error, and
4885            // it cannot do that for an error it never sees.
4886            error: q.error.clone(),
4887            error_description: q.error_description.clone(),
4888            response: q.response.clone(),
4889        };
4890        let binding =
4891            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4892        match crate::oauth::login::complete(
4893            runtime,
4894            &state.http,
4895            &state.db,
4896            &params,
4897            binding.as_deref(),
4898            crate::store::now_unix(),
4899        )
4900        .await
4901        {
4902            Ok(done) => crate::atproto::SidecarSession {
4903                did: done.did,
4904                handle: done.handle,
4905            },
4906            Err(err) => {
4907                // Never echoed to the browser: the message can name the issuer,
4908                // the PDS, and why a binding check failed.
4909                warn!(%err, "could not complete the OAuth callback");
4910                let mut resp = login_error(&state, "Login failed — please try again.");
4911                clear_binding_cookie(&mut resp);
4912                return resp;
4913            }
4914        }
4915    };
4916
4917    // Bind the verified DID to the invite gate. Returns a response only on the
4918    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
4919    let mut clear_invite = false;
4920    if !store::has_beta_access(&state.db, &session.did)
4921        .await
4922        .unwrap_or(false)
4923    {
4924        // Not yet a member: consume the reserved invite code, if any.
4925        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4926            Some(c) => c,
4927            None => {
4928                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4929                return Redirect::to("/beta/redeem").into_response();
4930            }
4931        };
4932        match store::redeem_code(
4933            &state.db,
4934            &code,
4935            &session.did,
4936            session.handle.as_deref(),
4937            state.config.beta_cap,
4938        )
4939        .await
4940        {
4941            Ok(Ok(())) => {
4942                clear_invite = true;
4943                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4944            }
4945            Ok(Err(policy)) => {
4946                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4947                let mut resp = redeem_bounce(&state, &policy).into_response();
4948                // The reservation is spent/invalid — drop the stale invite cookie.
4949                clear_invite_cookie(&mut resp);
4950                return resp;
4951            }
4952            Err(err) => {
4953                warn!(%err, did = %session.did, "invite redeem infra error at callback");
4954                return login_error(&state, "Login failed while confirming your invite.");
4955            }
4956        }
4957    }
4958
4959    // Mint an opaque, random server-side session id and store the identity under
4960    // it; the cookie carries the (HMAC-signed) sid, never the DID.
4961    let sid = state.sessions.create(Session {
4962        did: session.did.clone(),
4963        handle: session.handle.clone(),
4964    });
4965    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4966    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4967
4968    let mut resp = Redirect::to("/").into_response();
4969    set_cookie(&mut resp, &cookie);
4970    clear_binding_cookie(&mut resp);
4971    if clear_invite {
4972        clear_invite_cookie(&mut resp);
4973    }
4974    resp
4975}
4976
4977/// Revoke a DID's OAuth session on BOTH backends, best-effort.
4978///
4979/// Not "whichever backend is live": during a cutover a user's tokens can be in
4980/// either store — they logged in under one backend and are logging out under
4981/// the other. Revoking only the live one would leave a live refresh token
4982/// behind in the other, which is the exact failure sign-out exists to prevent,
4983/// and it would be invisible because the sign-out itself looks successful.
4984///
4985/// Both arms are best-effort. The caller has already decided to sign the user
4986/// out, and a network failure must not trap them in a half-logged-out state.
4987/// How long sign-out will wait for a final read-state flush before revoking
4988/// anyway.
4989///
4990/// Bounded because the flush talks to the user's PDS, and a user trying to leave
4991/// must never be held by a server that is not answering. Three seconds is long
4992/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
4993/// and short enough that a dead PDS is an inconvenience rather than a trap.
4994const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4995
4996/// Flush whatever read-state is still dirty for `did`, then give up quietly.
4997///
4998/// **Called before revoking, because revoking first strands it (#117).**
4999/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
5000/// session cannot be sent by anyone — it parks until the user signs in again,
5001/// which may be never. Flushing first is what stops the common case from
5002/// becoming that.
5003///
5004/// Best-effort by construction: every failure path here falls through to the
5005/// revoke. A flush that times out or errors leaves the cursors dirty, which is
5006/// the parked state the flusher now handles deliberately rather than retrying
5007/// forever.
5008async fn flush_before_revoke(state: &AppState, did: &str) {
5009    match tokio::time::timeout(
5010        SIGN_OUT_FLUSH_BUDGET,
5011        crate::readstate::flush_did(state, did),
5012    )
5013    .await
5014    {
5015        Ok(Ok(())) => {}
5016        Ok(Err(err)) => {
5017            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
5018        }
5019        Err(_) => warn!(
5020            %did,
5021            budget = ?SIGN_OUT_FLUSH_BUDGET,
5022            "sign-out: final read-state flush timed out; it will park until next sign-in"
5023        ),
5024    }
5025}
5026
5027async fn revoke_everywhere(state: &AppState, did: &str) {
5028    // **Counted under Backend::Sidecar, not left uncounted.** A review found
5029    // that recording only the rust arm let `oauth_revoke` report a clean success
5030    // while every sidecar revocation failed — and for anyone who logged in before
5031    // the cutover, the sidecar store is the ONLY one that held tokens, so the
5032    // rust arm correctly returns NoSession and the metric reads all-clear while
5033    // live refresh tokens sit at the PDS.
5034    //
5035    // Same op name, different backend: the backend column is what distinguishes
5036    // them, so "no revocation failures" means checking both rows, not one.
5037    let sidecar_started = std::time::Instant::now();
5038    let sidecar_ok = match state.sidecar.revoke_session(did).await {
5039        Ok(res) => {
5040            info!(%did, revoked = res.revoked, "sidecar session revoked");
5041            true
5042        }
5043        Err(err) => {
5044            warn!(%did, %err, "sidecar revoke failed; continuing");
5045            false
5046        }
5047    };
5048    state.metrics.record(
5049        crate::metrics::Backend::Sidecar,
5050        "oauth_revoke",
5051        sidecar_started.elapsed().as_micros() as u64,
5052        sidecar_ok,
5053    );
5054
5055    if let Some(runtime) = state.oauth.as_deref() {
5056        let revoke_started = std::time::Instant::now();
5057        let outcome = crate::oauth::revoke::sign_out_discovering(
5058            runtime,
5059            &state.http,
5060            &state.db,
5061            did,
5062            crate::store::now_unix(),
5063        )
5064        .await;
5065        // **Counted, because a warn! nobody reads is not observability.** Until
5066        // this existed, a revocation failure left exactly one trace: a log line.
5067        // "No revocation failures this week" was therefore a statement about
5068        // nobody having looked, which is not the same claim.
5069        //
5070        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
5071        // there being nothing to revoke is the correct outcome, not a failure,
5072        // and counting it as an error would make the metric noisy in exactly
5073        // the case that is fine. Only `Failed` means the PDS still holds live
5074        // tokens we asked it to drop.
5075        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
5076        state.metrics.record(
5077            crate::metrics::Backend::Rust,
5078            "oauth_revoke",
5079            revoke_started.elapsed().as_micros() as u64,
5080            revoke_ok,
5081        );
5082        match outcome {
5083            crate::oauth::revoke::Revocation::Revoked => {
5084                info!(%did, "rust OAuth session revoked at the PDS")
5085            }
5086            crate::oauth::revoke::Revocation::NoSession => {}
5087            crate::oauth::revoke::Revocation::Failed(reason) => {
5088                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
5089            }
5090        }
5091    }
5092}
5093
5094/// `POST /logout` — end the session everywhere, not just in this browser.
5095///
5096/// Clearing the cookie only stops *this* device from presenting the session;
5097/// the sidecar still holds live OAuth tokens for the DID. So logout now also
5098/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
5099/// access tokens at the PDS and drops the sidecar's session rows. The local
5100/// registry entry is dropped and the cookie cleared regardless of whether the
5101/// revoke round-trip succeeds (best-effort — a network blip must not trap the
5102/// user in a half-logged-out state).
5103async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
5104    if let Some(user) = current_session(&state, &headers).await {
5105        // Only a real cookie session (`sid` present) has sidecar-held tokens to
5106        // revoke; the dev-DID fallback never handshook the sidecar.
5107        if let Some(sid) = user.sid {
5108            state.sessions.remove(&sid);
5109            // BEFORE the revoke: afterwards there is no session to send it with.
5110            flush_before_revoke(&state, &user.did).await;
5111            revoke_everywhere(&state, &user.did).await;
5112        }
5113    }
5114    let mut resp = Redirect::to("/login").into_response();
5115    set_cookie(
5116        &mut resp,
5117        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5118    );
5119    resp
5120}
5121
5122/// Form body for `POST /account/delete` — the confirm-gate. The user must type
5123/// `DELETE` into this field for the purge to run.
5124#[derive(Debug, Deserialize)]
5125struct DeleteAccountForm {
5126    #[serde(default)]
5127    confirm: String,
5128}
5129
5130/// The literal a user must type to confirm the destructive delete.
5131const DELETE_CONFIRM_PHRASE: &str = "DELETE";
5132
5133/// `POST /account/delete` (authed) — the "delete my data" endpoint.
5134///
5135/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
5136/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
5137///   1. purges **every** local row owned by the caller DID (`entry_state`,
5138///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
5139///      DID created) via [`store::purge_did_data`], then
5140///   2. revokes the OAuth session at the PDS via `revoke_everywhere` — the
5141///      sidecar's `POST /internal/revoke {did}` and, when the Rust OAuth runtime
5142///      is configured, its RFC 7009 revocation too — then
5143///   3. drops the in-memory session and clears the cookie, signing the user out.
5144///
5145/// The subscription/folder/saved *records* in the user's own PDS are
5146/// intentionally left alone — they are the user's data on their own server; the
5147/// `/about` copy and this page's UI both say so, and export stays available.
5148async fn account_delete(
5149    State(state): State<AppState>,
5150    headers: HeaderMap,
5151    Form(form): Form<DeleteAccountForm>,
5152) -> Result<Response, WebError> {
5153    let user = match current_session(&state, &headers).await {
5154        Some(u) => u,
5155        None => return Ok(Redirect::to("/login").into_response()),
5156    };
5157    let did = user.did.clone();
5158
5159    // Confirm-gate: require the exact typed phrase before doing anything.
5160    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
5161        return Ok(Redirect::to(&format!(
5162            "/manage?flash={}",
5163            qenc("Type DELETE to confirm — nothing was deleted.")
5164        ))
5165        .into_response());
5166    }
5167
5168    // 1. Purge every local row this DID owns (single transaction).
5169    let counts = store::purge_did_data(&state.db, &did).await?;
5170    info!(
5171        %did,
5172        total = counts.total(),
5173        entry_state = counts.entry_state,
5174        read_cursor = counts.read_cursor,
5175        sub_ref = counts.sub_ref,
5176        beta_access = counts.beta_access,
5177        invite_codes = counts.invite_codes,
5178        "account/delete: local rows purged"
5179    );
5180
5181    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
5182    //    rows are already gone; a network blip must not block the sign-out).
5183    revoke_everywhere(&state, &did).await;
5184
5185    // 3. Drop the in-memory session and clear the cookie: sign the user out.
5186    if let Some(sid) = user.sid {
5187        state.sessions.remove(&sid);
5188    }
5189    let mut resp = Redirect::to(&format!(
5190        "/login?flash={}",
5191        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
5192    ))
5193    .into_response();
5194    set_cookie(
5195        &mut resp,
5196        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5197    );
5198    Ok(resp)
5199}
5200
5201/// The `/login` card, shared by the form and its error re-render.
5202fn login_card(config: &Config) -> Card {
5203    Card::public(
5204        config,
5205        "/login",
5206        "Sign in — FeatherReader",
5207        "Sign in to FeatherReader with your atproto handle. You approve access on \
5208         your own server — no signup, no password.",
5209    )
5210}
5211
5212/// Re-render the login form with an error banner.
5213fn login_error(state: &AppState, msg: &str) -> Response {
5214    render(&LoginTemplate {
5215        card: login_card(&state.config),
5216        repo_url: REPO_URL,
5217        error: msg.to_string(),
5218        flash: String::new(),
5219    })
5220}
5221
5222// ---------------------------------------------------------------------------
5223// Closed-beta invite gate (self-serve redeem + admin mint)
5224// ---------------------------------------------------------------------------
5225
5226/// Form body for `POST /beta/redeem`.
5227#[derive(Debug, Deserialize)]
5228struct RedeemForm {
5229    code: String,
5230}
5231
5232/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
5233/// already full we render the "capacity full" variant (no form).
5234async fn beta_redeem_form(State(state): State<AppState>) -> Response {
5235    let full = store::count_beta_access(&state.db)
5236        .await
5237        .map(|n| n >= state.config.beta_cap)
5238        .unwrap_or(false);
5239    render(&BetaRedeemTemplate {
5240        card: redeem_card(&state.config),
5241        repo_url: REPO_URL,
5242        error: String::new(),
5243        capacity_full: full,
5244    })
5245}
5246
5247/// `POST /beta/redeem` — the **pre-handshake** reservation.
5248///
5249/// Validates the pasted code is *redeemable right now* (exists, active,
5250/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
5251/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
5252/// reserving intent to redeem this code, then sends the visitor to `/login`. The
5253/// OAuth callback later binds the verified DID and atomically consumes the code
5254/// (`store::redeem_code`). This ordering means a non-invited visitor can never
5255/// start OAuth (and burn a sidecar handshake).
5256async fn beta_redeem_submit(
5257    State(state): State<AppState>,
5258    Form(form): Form<RedeemForm>,
5259) -> Response {
5260    let code = form.code.trim().to_uppercase();
5261    if code.is_empty() {
5262        return render(&BetaRedeemTemplate {
5263            card: redeem_card(&state.config),
5264            repo_url: REPO_URL,
5265            error: "Enter your invite code.".to_string(),
5266            capacity_full: false,
5267        });
5268    }
5269
5270    match preflight_code(&state, &code).await {
5271        Ok(()) => {
5272            let cookie = sign_invite(&code, &state.config.cookie_secret);
5273            let mut resp = Redirect::to("/login").into_response();
5274            set_cookie(&mut resp, &cookie);
5275            info!("invite code preflight OK; reserving intent + redirecting to /login");
5276            resp
5277        }
5278        Err(policy) => {
5279            warn!(?policy, "invite code preflight rejected");
5280            redeem_bounce(&state, &policy)
5281        }
5282    }
5283}
5284
5285/// Read-only preflight of an invite code for the pre-handshake reservation:
5286/// verify it exists, is active, is not past `expires_at`, and that a seat is
5287/// free — mirroring the checks `store::redeem_code` will re-run atomically at
5288/// callback time. Does NOT consume the code or grant a seat. Returns the same
5289/// typed [`store::RedeemError`] variants so the two paths share one message map.
5290async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
5291    // Cap check first: a clear "capacity full" beats "code invalid" when both.
5292    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
5293    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
5294    // still backstops the real cap inside its tx, so this is a consistency /
5295    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
5296    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
5297    // that might overrun the cap.
5298    let count = match store::count_beta_access(&state.db).await {
5299        Ok(n) => n,
5300        Err(err) => {
5301            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
5302            return Err(store::RedeemError::CapacityFull);
5303        }
5304    };
5305    if count >= state.config.beta_cap {
5306        return Err(store::RedeemError::CapacityFull);
5307    }
5308    // Look up the code's current status + expiry (read-only).
5309    let row = sqlx::query_as::<_, (String, i64)>(
5310        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
5311    )
5312    .bind(code)
5313    .fetch_optional(&state.db)
5314    .await
5315    .ok()
5316    .flatten();
5317    let (status, expires_at) = match row {
5318        Some(r) => r,
5319        None => return Err(store::RedeemError::NotFound),
5320    };
5321    let now = chrono::Utc::now().timestamp();
5322    match status.as_str() {
5323        "active" if expires_at >= now => Ok(()),
5324        "active" => Err(store::RedeemError::Expired),
5325        "expired" => Err(store::RedeemError::Expired),
5326        // "redeemed" or anything else non-active.
5327        _ => Err(store::RedeemError::AlreadyRedeemed),
5328    }
5329}
5330
5331/// Map a [`store::RedeemError`] to the invite page with the right message. Used
5332/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
5333fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
5334    use store::RedeemError::*;
5335    let (msg, capacity_full) = match policy {
5336        NotFound => ("That invite code isn't valid.", false),
5337        Expired => ("That invite code has expired.", false),
5338        AlreadyRedeemed => ("That invite code has already been used.", false),
5339        CapacityFull => ("", true),
5340    };
5341    render(&BetaRedeemTemplate {
5342        card: redeem_card(&state.config),
5343        repo_url: REPO_URL,
5344        error: msg.to_string(),
5345        capacity_full,
5346    })
5347}
5348
5349/// The `/beta/redeem` card, shared by the form, its re-renders and the claim
5350/// link's bounce.
5351fn redeem_card(config: &Config) -> Card {
5352    Card::public(
5353        config,
5354        "/beta/redeem",
5355        "Redeem an invite — FeatherReader",
5356        "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
5357         in with your atproto handle.",
5358    )
5359}
5360
5361/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
5362#[derive(Debug, Deserialize, Default)]
5363struct MintQuery {
5364    #[serde(default)]
5365    n: Option<u32>,
5366}
5367
5368/// `POST /admin/invites?n=N` — mint N invite codes.
5369///
5370/// `GET /oauth/client-metadata.json` — the client's published identity.
5371///
5372/// **This URL IS the `client_id`.** The PDS fetches it during every login and
5373/// caches it against every existing grant, so it must keep answering at exactly
5374/// this path across the cutover — the sidecar serves the same document at the
5375/// same URL today, proxied by the edge.
5376///
5377/// Served whatever backend is live: a request that arrives here is from a PDS
5378/// resolving our identity, and it has no idea which of our two implementations
5379/// is currently answering repo calls.
5380async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
5381    let Some(runtime) = state.oauth.as_deref() else {
5382        // The sidecar is serving this path in front of us, or nothing is.
5383        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
5384    };
5385    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
5386}
5387
5388/// `GET /oauth/jwks.json` — the client's public signing key.
5389///
5390/// Production only. The localhost dev client is a PUBLIC client: it registers no
5391/// key and signs no assertions, so publishing a JWKS there would advertise a
5392/// credential that is never used — and would make a dev deployment look like a
5393/// confidential client to anyone reading it.
5394async fn oauth_jwks(State(state): State<AppState>) -> Response {
5395    let Some(runtime) = state.oauth.as_deref() else {
5396        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
5397    };
5398    match runtime.client_key.as_ref() {
5399        Some(key) => match key.jwks_document() {
5400            Ok(doc) => axum::Json(doc).into_response(),
5401            Err(err) => {
5402                warn!(%err, "could not render the client JWKS");
5403                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
5404            }
5405        },
5406        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
5407    }
5408}
5409
5410/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
5411const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
5412
5413/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
5414///
5415/// Admin-gated on the same rule as the invite minter: the table names every
5416/// operation the reader performs and how often each fails, which is an
5417/// operational picture rather than public information.
5418///
5419/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
5420/// is safe, and the comparison is two rows side by side.
5421async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
5422    let did = match current_did(&state, &headers).await {
5423        Some(d) => d,
5424        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5425    };
5426    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5427        warn!(%did, "admin metrics denied: not an admin-seed DID");
5428        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5429    }
5430
5431    // Flush first, so the table includes this process's traffic up to now.
5432    // Then read the PERSISTED rows, which is the only place both backends can
5433    // appear at once -- a flip is a restart, and in-process memory only ever
5434    // holds the backend currently running.
5435    if let Err(err) =
5436        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
5437    {
5438        warn!(%err, "could not flush repo timings before rendering");
5439    }
5440    let rows = match crate::metrics::persisted_rows(&state.db).await {
5441        Ok(rows) => rows,
5442        Err(err) => {
5443            warn!(%err, "could not read persisted repo timings");
5444            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
5445        }
5446    };
5447
5448    // The live backend is named at the top: a table of two populated rows is
5449    // ambiguous about which one is currently serving users.
5450    // Parked read-state, alongside the timings. The flusher no longer logs
5451    // these every round (#117), so without a number here the state would be
5452    // silent — which is the failure the noisy loop at least did not have.
5453    let parked = match crate::store::parked_readstate_dids(&state.db).await {
5454        Ok(n) => n.to_string(),
5455        Err(err) => {
5456            warn!(%err, "could not count parked read-state DIDs");
5457            "unknown".to_string()
5458        }
5459    };
5460    // **The half the public histogram cannot carry.** `/stats` reports counts by
5461    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
5462    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
5463    // cannot separate "the publishers are gone" from "we are broken". #159 was
5464    // the latter and took a production investigation to establish. Named feeds
5465    // and their error text belong here, behind ALLOWED_DIDS.
5466    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
5467        Ok(f) => f,
5468        Err(err) => {
5469            warn!(%err, "could not list failing feeds");
5470            Vec::new()
5471        }
5472    };
5473    let mut failing_block = String::new();
5474    if !failing.is_empty() {
5475        failing_block.push_str("\nfailing feeds (worst first)\n");
5476        for f in &failing {
5477            failing_block.push_str(&format!(
5478                "  {:>4}x  {:<8}  {}\n          {}\n",
5479                f.consecutive_errors,
5480                f.kind.as_deref().unwrap_or("unknown"),
5481                f.url,
5482                f.detail.as_deref().unwrap_or("(no detail recorded)"),
5483            ));
5484        }
5485    }
5486
5487    // **Capacity that no other page can show.** The global ceiling counts every
5488    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
5489    // unpollable ones — so an instance can be at its cap with every public
5490    // number saying otherwise. A review found exactly that gap.
5491    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
5492        Ok(n) => n,
5493        Err(err) => {
5494            warn!(%err, "could not count unpollable feeds");
5495            -1
5496        }
5497    };
5498    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
5499
5500    let body = format!(
5501        "live backend: {}\nparked read-state DIDs: {}\n\
5502         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
5503        state.config.repo_backend.as_str(),
5504        parked,
5505        cached,
5506        state.config.max_feeds_global,
5507        unpollable,
5508        crate::metrics::render(&rows),
5509        failing_block,
5510    );
5511    (StatusCode::OK, body).into_response()
5512}
5513
5514/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
5515/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
5516/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
5517async fn admin_mint_invites(
5518    State(state): State<AppState>,
5519    headers: HeaderMap,
5520    Query(q): Query<MintQuery>,
5521) -> Response {
5522    // Require a real, current session (not just a DID string) whose DID is an
5523    // admin-seed DID. `current_did` already re-checks the beta gate.
5524    let did = match current_did(&state, &headers).await {
5525        Some(d) => d,
5526        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5527    };
5528    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5529        warn!(%did, "admin mint denied: not an admin-seed DID");
5530        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5531    }
5532
5533    let n = q.n.unwrap_or(1).clamp(1, 100);
5534    let mut codes = Vec::with_capacity(n as usize);
5535    for _ in 0..n {
5536        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
5537            Ok(code) => codes.push(code),
5538            Err(err) => {
5539                warn!(%err, %did, "admin mint_code failed");
5540                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5541            }
5542        }
5543    }
5544    info!(%did, count = codes.len(), "admin minted invite codes");
5545    let mut body = codes.join("\n");
5546    body.push('\n');
5547    (StatusCode::OK, body).into_response()
5548}
5549
5550// ---------------------------------------------------------------------------
5551// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
5552// ---------------------------------------------------------------------------
5553
5554/// Query for `GET /claim`.
5555#[derive(Debug, Deserialize)]
5556struct ClaimQuery {
5557    /// The opaque claim token from the bot's public follow-back skeet.
5558    t: Option<String>,
5559}
5560
5561/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
5562///
5563/// The follow→invite bot posts a public skeet mentioning a new follower with a
5564/// link here. The token wraps a pre-minted invite code (never the raw code — see
5565/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
5566/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
5567/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
5568/// callback atomically consumes the code (`store::redeem_code`) — the same
5569/// machinery as a pasted code. On any failure it bounces to the invite page with
5570/// the matching message.
5571///
5572/// Single-use / grabbability: a token in a public URL is grabbable. The code it
5573/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
5574/// here rejects an already-used / expired / capacity-full code before reserving,
5575/// so a replayed link past the first successful claim is refused. The residual
5576/// window is the same as any pasted invite code: whoever completes OAuth *first*
5577/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
5578/// blunts brute-force enumeration.
5579async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
5580    let token = match q.t {
5581        Some(t) if !t.is_empty() => t,
5582        _ => {
5583            warn!("claim link with no token");
5584            return redeem_bounce(&state, &store::RedeemError::NotFound);
5585        }
5586    };
5587
5588    // Unwrap the token → the invite code it reserves. A tampered/forged token
5589    // yields nothing → treat as an invalid code (don't leak whether it parsed).
5590    let code = match claim_token_code(&token, &state.config.cookie_secret) {
5591        Some(c) => c,
5592        None => {
5593            warn!("claim token invalid (bad signature / malformed)");
5594            return redeem_bounce(&state, &store::RedeemError::NotFound);
5595        }
5596    };
5597
5598    // Re-run the same preflight as the pasted-code path: exists, active,
5599    // unexpired, seat free. This is what makes a replayed link past first-claim
5600    // (or past cap) fail cleanly.
5601    match preflight_code(&state, &code).await {
5602        Ok(()) => {
5603            let cookie = sign_invite(&code, &state.config.cookie_secret);
5604            let mut resp = Redirect::to("/login").into_response();
5605            set_cookie(&mut resp, &cookie);
5606            info!("claim token preflight OK; reserving intent + redirecting to /login");
5607            resp
5608        }
5609        Err(policy) => {
5610            warn!(?policy, "claim token preflight rejected");
5611            redeem_bounce(&state, &policy)
5612        }
5613    }
5614}
5615
5616/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
5617///
5618/// Passing the follower DID makes the APP the authoritative deduper: the app can
5619/// short-circuit a DID that already holds a seat, and return the SAME code for a
5620/// DID that already has an outstanding claim — so a bot-host state loss cannot
5621/// re-mint or re-post per follower. Handle is advisory (logs only).
5622#[derive(Debug, Default, Deserialize)]
5623struct BotClaimRequest {
5624    /// The follower's DID (the idempotency key). Optional for backward-compat: an
5625    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
5626    #[serde(default)]
5627    did: Option<String>,
5628    /// The follower's handle (advisory; recorded for operator logs only).
5629    #[serde(default)]
5630    #[allow(dead_code)]
5631    handle: Option<String>,
5632}
5633
5634/// The JSON body `POST /bot/claims` returns on success.
5635#[derive(Debug, serde::Serialize)]
5636struct BotClaimResponse {
5637    /// Server-side dedupe outcome, so the bot knows whether to post:
5638    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
5639    /// already had an outstanding claim; the SAME code/token/url is returned, so an
5640    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
5641    /// beta access; code/token/url are empty and the bot should post NOTHING).
5642    status: &'static str,
5643    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
5644    /// store. NEVER post this publicly; post the `url` instead. Empty when
5645    /// `already_seated`.
5646    code: String,
5647    /// The opaque claim token (the code wrapped + signed). Empty when
5648    /// `already_seated`.
5649    token: String,
5650    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
5651    /// Empty when `already_seated`.
5652    url: String,
5653}
5654
5655/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
5656///
5657/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
5658/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
5659/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
5660/// (503), so a bare/dev instance never exposes an unauthenticated mint.
5661///
5662/// Server-side DID idempotency (the authoritative dedupe backstop): the request
5663/// body carries the follower `did`. The app — not the bot's local SQLite — is the
5664/// source of truth, so a bot-host state loss cannot re-mint or re-post per
5665/// follower:
5666///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
5667///     code/url; the bot marks it handled and posts NOTHING);
5668///   * DID already has an outstanding active claim → `200 {status:"existing"}`
5669///     returning the SAME code/token/url (idempotent — never a second mint);
5670///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
5671///
5672/// Cap accounting: the bot must not promise more claims than seats remain, so
5673/// this refuses with `409 Conflict {"error":"full"}` when
5674/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
5675/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
5676/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
5677/// minting past the cap.
5678///
5679/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
5680/// default 14d — the admin browser flow's 30-min TTL would expire before the
5681/// follower taps an async-delivered link).
5682async fn bot_mint_claim(
5683    State(state): State<AppState>,
5684    headers: HeaderMap,
5685    body: axum::body::Bytes,
5686) -> Response {
5687    // 1. The endpoint is OFF unless a bot secret is configured.
5688    let bot_secret = match state.config.bot_secret.as_deref() {
5689        Some(s) => s,
5690        None => {
5691            warn!(
5692                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5693            );
5694            return (
5695                StatusCode::SERVICE_UNAVAILABLE,
5696                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5697            )
5698                .into_response();
5699        }
5700    };
5701
5702    // 2. Constant-time bearer check on the X-Bot-Secret header.
5703    let presented = headers
5704        .get("x-bot-secret")
5705        .and_then(|v| v.to_str().ok())
5706        .unwrap_or("");
5707    if !bot_secret_matches(presented, bot_secret) {
5708        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5709        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5710    }
5711
5712    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
5713    // (legacy caller) parses to an all-None request; a malformed body is a 400.
5714    let req: BotClaimRequest = if body.is_empty() {
5715        BotClaimRequest::default()
5716    } else {
5717        match serde_json::from_slice(&body) {
5718            Ok(r) => r,
5719            Err(err) => {
5720                warn!(%err, "POST /bot/claims: bad JSON body");
5721                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5722            }
5723        }
5724    };
5725    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5726
5727    // 3. Server-side DID idempotency (only when a DID was supplied):
5728    if let Some(did) = follower_did {
5729        // 3a. Already seated → tell the bot to post nothing.
5730        match store::has_beta_access(&state.db, did).await {
5731            Ok(true) => {
5732                info!("bot mint: DID already holds beta access; already_seated");
5733                return bot_claim_json(BotClaimResponse {
5734                    status: "already_seated",
5735                    code: String::new(),
5736                    token: String::new(),
5737                    url: String::new(),
5738                });
5739            }
5740            Ok(false) => {}
5741            Err(err) => {
5742                // Fail closed: a DB error must not fall through to a fresh mint.
5743                warn!(%err, "bot mint: has_beta_access failed");
5744                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5745            }
5746        }
5747        // 3b. Outstanding active claim for this DID → return the SAME code (no
5748        // second mint). This is what survives a bot-host state loss.
5749        match store::find_active_code_for_did(&state.db, did).await {
5750            Ok(Some(code)) => {
5751                info!("bot mint: existing outstanding claim for DID; returning same code");
5752                let token = sign_claim_token(&code, &state.config.cookie_secret);
5753                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5754                return bot_claim_json(BotClaimResponse {
5755                    status: "existing",
5756                    code,
5757                    token,
5758                    url,
5759                });
5760            }
5761            Ok(None) => {}
5762            Err(err) => {
5763                warn!(%err, "bot mint: find_active_code_for_did failed");
5764                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5765            }
5766        }
5767    }
5768
5769    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
5770    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
5771    let granted = match store::count_beta_access(&state.db).await {
5772        Ok(n) => n,
5773        Err(err) => {
5774            warn!(%err, "bot mint: count_beta_access failed; failing closed");
5775            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5776        }
5777    };
5778    let outstanding = match store::count_active_codes(&state.db).await {
5779        Ok(n) => n,
5780        Err(err) => {
5781            warn!(%err, "bot mint: count_active_codes failed; failing closed");
5782            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5783        }
5784    };
5785    if granted + outstanding >= state.config.beta_cap {
5786        info!(
5787            granted,
5788            outstanding,
5789            cap = state.config.beta_cap,
5790            "bot mint refused: at capacity"
5791        );
5792        return (
5793            StatusCode::CONFLICT,
5794            [(header::CONTENT_TYPE, "application/json")],
5795            "{\"error\":\"full\"}\n",
5796        )
5797            .into_response();
5798    }
5799
5800    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
5801    //    so a re-request for the same DID returns THIS code idempotently.
5802    let bot_did = state
5803        .config
5804        .admin_seed_dids()
5805        .first()
5806        .cloned()
5807        .unwrap_or_else(|| "did:bot:featherreader".to_string());
5808    let minted = match follower_did {
5809        Some(did) => {
5810            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5811        }
5812        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5813    };
5814    let code = match minted {
5815        Ok(c) => c,
5816        // S4: the dedupe check (3b) and this mint are separate statements, so two
5817        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
5818        // The partial unique index `idx_invite_codes_intended_active` makes the
5819        // loser's INSERT fail (only one active row per intended DID), which
5820        // surfaces here as a conflict. Recover by returning the winner's existing
5821        // code (same shape as the 3b idempotent path) instead of a 500.
5822        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5823            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5824                Ok(Some(code)) => {
5825                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
5826                    let token = sign_claim_token(&code, &state.config.cookie_secret);
5827                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5828                    return bot_claim_json(BotClaimResponse {
5829                        status: "existing",
5830                        code,
5831                        token,
5832                        url,
5833                    });
5834                }
5835                // The winner's row vanished between the conflict and this lookup
5836                // (redeemed/expired/purged in the gap) — nothing to hand back.
5837                // Fail closed rather than silently mint past the just-hit guard.
5838                Ok(None) => {
5839                    warn!("bot mint: conflict but no active code found on recovery");
5840                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5841                }
5842                Err(err) => {
5843                    warn!(%err, "bot mint: recovery lookup after conflict failed");
5844                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5845                }
5846            }
5847        }
5848        Err(err) => {
5849            warn!(%err, "bot mint_code failed");
5850            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5851        }
5852    };
5853    let token = sign_claim_token(&code, &state.config.cookie_secret);
5854    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5855    info!("bot minted a claim code + token");
5856
5857    bot_claim_json(BotClaimResponse {
5858        status: "minted",
5859        code,
5860        token,
5861        url,
5862    })
5863}
5864
5865/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
5866/// `500` if serialization somehow fails).
5867fn bot_claim_json(resp: BotClaimResponse) -> Response {
5868    match serde_json::to_string(&resp) {
5869        Ok(body) => (
5870            StatusCode::OK,
5871            [(header::CONTENT_TYPE, "application/json")],
5872            body,
5873        )
5874            .into_response(),
5875        Err(err) => {
5876            warn!(%err, "serializing bot claim response failed");
5877            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5878        }
5879    }
5880}
5881
5882/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
5883/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
5884/// by the HMAC checks so there is one comparator to audit; a length mismatch
5885/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
5886fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5887    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5888}
5889
5890// ---------------------------------------------------------------------------
5891// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
5892// ---------------------------------------------------------------------------
5893
5894/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
5895/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
5896/// itself (base64url) rather than an opaque sid, since the code IS the reserved
5897/// intent the callback consumes.
5898fn sign_invite(code: &str, secret: &str) -> String {
5899    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5900}
5901
5902/// Verify + read the reserved invite code out of the request's invite cookie
5903/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
5904/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
5905/// authority on the code's live status.
5906fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5907    cookie::verify_value(headers, INVITE_COOKIE, secret)
5908}
5909
5910/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
5911/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
5912/// cookie value and vice-versa.
5913const CLAIM_TOKEN_LABEL: &str = "claim-token";
5914
5915/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
5916/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
5917///
5918/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
5919/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
5920/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
5921/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
5922/// code won't verify), the wrapped code is single-use (redeem flips
5923/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
5924/// token one self-contained string needing no server-side token table; it does
5925/// NOT hide the code.
5926fn sign_claim_token(code: &str, secret: &str) -> String {
5927    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5928}
5929
5930/// Verify a claim token and return the invite code it wraps (`None` on a tampered
5931/// / forged / malformed token). The code's live status (active/unexpired/seat
5932/// free) is re-checked by `preflight_code`; this only proves the token was minted
5933/// by this instance.
5934fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5935    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5936}
5937
5938/// Clear the invite cookie on a response (after a successful bind, or when the
5939/// reservation turned out to be stale).
5940fn clear_invite_cookie(resp: &mut Response) {
5941    set_cookie(
5942        resp,
5943        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5944    );
5945}
5946
5947// ---------------------------------------------------------------------------
5948// OPML import + export
5949// ---------------------------------------------------------------------------
5950
5951/// `POST /opml` — import subscriptions from an OPML document.
5952///
5953/// Accepts either a multipart file upload (field `file`) or a pasted textarea
5954/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
5955/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
5956/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
5957/// `applyWrites` round-trip per 200 feeds). Feeds are also upserted into the local cache so
5958/// they show immediately; polling is left to the background poller.
5959async fn import_opml(
5960    State(state): State<AppState>,
5961    headers: HeaderMap,
5962    mut multipart: Multipart,
5963) -> Result<Response, WebError> {
5964    let did = match current_did(&state, &headers).await {
5965        Some(d) => d,
5966        None => return Ok(Redirect::to("/login").into_response()),
5967    };
5968    let pool = &state.db;
5969
5970    // Collect the OPML text from whichever field carried it. Multipart errors
5971    // are mapped to their axum-native response so that an over-cap upload (the
5972    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
5973    // `413 Payload Too Large` rather than being swallowed by the blanket
5974    // `WebError` → `500` conversion.
5975    let mut opml_text = String::new();
5976    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5977        let name = field.name().unwrap_or("").to_string();
5978        if name == "opml" || name == "file" {
5979            let bytes = field.bytes().await.map_err(multipart_response)?;
5980            if !bytes.is_empty() {
5981                opml_text = String::from_utf8_lossy(&bytes).into_owned();
5982                if name == "file" {
5983                    break;
5984                }
5985            }
5986        }
5987    }
5988
5989    // A parse FAILURE and an empty-but-valid file are different things, and
5990    // `unwrap_or_default` collapsed them: a malformed export was reported to the
5991    // reader as "No feeds found in that OPML", which sends them looking at their
5992    // old reader for feeds that are right there in the file.
5993    let feeds =
5994        match opml::parse_opml(&opml_text) {
5995            Ok(feeds) => feeds,
5996            Err(err) => {
5997                warn!(%err, %did, "OPML import could not parse the uploaded file");
5998                return Ok(Redirect::to(&format!(
5999                "/?flash={}",
6000                qenc("That file could not be read as OPML. Export it again from your other reader?")
6001            ))
6002                .into_response());
6003            }
6004        };
6005    if feeds.is_empty() {
6006        info!(%did, "OPML import found no feeds");
6007        return Ok(
6008            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
6009                .into_response(),
6010        );
6011    }
6012
6013    // Create any named folders first, mapping folder name → at:// URI so
6014    // subscriptions can reference them.
6015    let now = now_rfc3339();
6016    let mut folder_uris: std::collections::HashMap<String, String> =
6017        std::collections::HashMap::new();
6018    // Reuse existing folders where the name already exists.
6019    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
6020        for (rkey, folder) in existing {
6021            folder_uris
6022                .entry(folder.name.clone())
6023                .or_insert_with(|| folder_uri(&did, &rkey));
6024        }
6025    }
6026    let mut wanted_folders: Vec<String> = feeds
6027        .iter()
6028        .filter_map(|f| f.folder.clone())
6029        .filter(|n| !n.is_empty())
6030        .collect();
6031    wanted_folders.sort();
6032    wanted_folders.dedup();
6033    for name in wanted_folders {
6034        if folder_uris.contains_key(&name) {
6035            continue;
6036        }
6037        let folder = Folder::new(name.clone(), now.clone());
6038        match state.repo().add_folder(&did, &folder).await {
6039            Ok(rkey) => {
6040                folder_uris.insert(name, folder_uri(&did, &rkey));
6041            }
6042            Err(err) => warn!(%err, %did, "OPML folder create failed"),
6043        }
6044    }
6045
6046    // Build one subscription record per PUBLIC feed + upsert the local cache row.
6047    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
6048    // and reported back to the user — the same public-feeds-only stance as the
6049    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
6050    // token onto the public network either.
6051    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
6052    // the remaining headroom (cap − existing) once; public feeds beyond it are
6053    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
6054    let sub_cap = state.config.max_subs_per_did;
6055    let mut headroom: Option<i64> = if sub_cap > 0 {
6056        let existing = store::count_subscriptions_for_did(pool, &did)
6057            .await
6058            .unwrap_or(0);
6059        Some((sub_cap - existing).max(0))
6060    } else {
6061        None
6062    };
6063    let mut trimmed_over_cap: usize = 0;
6064
6065    // Global feeds ceiling: an OPML import must not blow past the shared cache
6066    // ceiling any more than the single-add path may. Seed the remaining global
6067    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
6068    // not already cached) consumes it. Existing/duplicate URLs add no row and
6069    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
6070    // `<= 0` disables the ceiling.
6071    let feeds_cap = state.config.max_feeds_global;
6072    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
6073        let existing = store::count_feeds(pool).await.unwrap_or(0);
6074        Some((feeds_cap - existing).max(0))
6075    } else {
6076        None
6077    };
6078    let mut trimmed_over_global: usize = 0;
6079
6080    let mut subs = Vec::with_capacity(feeds.len());
6081    let mut skipped_private: Vec<String> = Vec::new();
6082    // Imported into the PDS but not cached locally, so not pollable until the
6083    // next import touches them. Counted rather than only logged — see below.
6084    let mut uncached: usize = 0;
6085    // Entries this instance cannot store at all (an `at://` publication with
6086    // the flag off, an unsupported scheme). Counted, because the `continue`
6087    // below used to increment nothing while the privacy branch beside it
6088    // produced a label — so an OPML from a standard.site-enabled instance
6089    // imported "successfully" with entries missing and no reason given.
6090    let mut skipped_unsupported: usize = 0;
6091    for f in &feeds {
6092        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
6093        // ever parsed it — the single-add path can't reach here because
6094        // `resolve_feed_url` must parse AND successfully fetch first. So
6095        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
6096        // cached, and published as records to the user's PUBLIC repo. Note that
6097        // `classify_feed_privacy` does not catch these: both parse cleanly, and
6098        // it returns `Public` for anything unparseable by design.
6099        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
6100            info!(
6101                %did,
6102                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
6103            );
6104            skipped_unsupported += 1;
6105            continue;
6106        }
6107        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
6108            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
6109            // Report by title where we have one, else the (public-safe) host.
6110            let label = f
6111                .title
6112                .clone()
6113                .filter(|t| !t.trim().is_empty())
6114                .unwrap_or_else(|| private_feed_label(&f.feed_url));
6115            skipped_private.push(label);
6116            continue;
6117        }
6118
6119        // Over-cap: stop importing once headroom is exhausted (count the rest so
6120        // we can tell the user how many were dropped).
6121        if let Some(h) = headroom.as_mut() {
6122            if *h <= 0 {
6123                trimmed_over_cap += 1;
6124                continue;
6125            }
6126        }
6127
6128        // Global ceiling: a brand-new feed URL consumes global headroom. Once
6129        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
6130        // free — they add no row). Checked before decrementing the per-DID
6131        // headroom so a dropped feed doesn't burn the caller's own quota.
6132        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
6133            Ok(existing) => existing.is_none(),
6134            // On a lookup error, treat as existing (don't consume global
6135            // headroom) but still allow the upsert to proceed.
6136            Err(err) => {
6137                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
6138                false
6139            }
6140        };
6141        if is_new {
6142            if let Some(g) = global_headroom.as_mut() {
6143                if *g <= 0 {
6144                    trimmed_over_global += 1;
6145                    continue;
6146                }
6147                *g -= 1;
6148            }
6149        }
6150
6151        // Passed both caps: consume the per-DID headroom now that the feed is
6152        // actually being imported.
6153        if let Some(h) = headroom.as_mut() {
6154            *h -= 1;
6155        }
6156
6157        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
6158        sub.title = f.title.clone();
6159        sub.site_url = f.site_url.clone();
6160        sub.folder = f
6161            .folder
6162            .as_ref()
6163            .and_then(|name| folder_uris.get(name).cloned());
6164        subs.push(sub);
6165        // Same support ticket as the single-add path: no `feeds` row means the
6166        // poller never selects this subscription, so the import looks like it
6167        // worked and the feed silently never updates. Counted as well as logged,
6168        // because one line per feed in a 200-feed import is not something anyone
6169        // reads — the count goes to the reader.
6170        if let Err(err) = store::upsert_feed(
6171            pool,
6172            &store::NewFeed {
6173                url: f.feed_url.clone(),
6174                title: f.title.clone(),
6175                site_url: f.site_url.clone(),
6176                ..Default::default()
6177            },
6178        )
6179        .await
6180        {
6181            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
6182                                                  it will not be polled");
6183            uncached += 1;
6184        }
6185    }
6186
6187    // **A failed PDS write is not an import.**
6188    //
6189    // The subscriptions live in the reader's repo; a local `feeds` row is just a
6190    // poller hint. This used to `warn!` and then report "Imported N feeds"
6191    // regardless, so a total failure read as a total success — and the reader
6192    // would only discover otherwise on their next visit, with an empty sidebar.
6193    //
6194    // **And a part-landed write is not a failed one.** The batch goes out in
6195    // calls of at most 200 (#240: the reference PDS refuses more), sent in
6196    // order and stopped at the first failure, so what landed is a prefix of
6197    // `subs` and the error says how long. Saying "nothing was imported" after
6198    // the first 200 of 450 landed would send the reader to import the file
6199    // again, which adds those 200 a second time. Nothing local needs undoing
6200    // either way: the reader's `sub_ref` projection is rebuilt from the repo on
6201    // the next read, and a cached `feeds` row with no subscriber is the same
6202    // poller hint the total-failure path has always left behind.
6203    let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
6204        Ok(rkeys) => {
6205            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
6206            rkeys.len()
6207        }
6208        Err(err) => {
6209            let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
6210            warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
6211            landed
6212        }
6213    };
6214    if landed == 0 && !subs.is_empty() {
6215        return Ok(Redirect::to(&format!(
6216            "/?flash={}",
6217            qenc(
6218                "Could not save those subscriptions to your PDS, so nothing was imported. \
6219                 Try again in a moment."
6220            )
6221        ))
6222        .into_response());
6223    }
6224
6225    // Report the import count, plus any private/paid feeds skipped as unsupported.
6226    let mut flash = if landed < subs.len() {
6227        format!(
6228            "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
6229             other {} may not have been saved. Importing the same file again would add the first \
6230             {landed} a second time",
6231            subs.len(),
6232            subs.len() - landed
6233        )
6234    } else {
6235        format!("Imported {} feeds", subs.len())
6236    };
6237    if uncached > 0 {
6238        flash.push_str(&format!(
6239            ". {uncached} of them could not be cached locally and may not update until the next import."
6240        ));
6241    }
6242    if trimmed_over_cap > 0 {
6243        flash.push_str(&format!(
6244            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
6245        ));
6246    }
6247    if trimmed_over_global > 0 {
6248        flash.push_str(&format!(
6249            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
6250        ));
6251    }
6252    if !skipped_private.is_empty() {
6253        flash.push_str(&format!(
6254            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
6255            skipped_private.len(),
6256            skipped_private.join(", ")
6257        ));
6258    }
6259    if skipped_unsupported > 0 {
6260        // By count only — the URL is whatever the file said, and unlike the
6261        // private branch there is no public-safe label to give.
6262        flash.push_str(&format!(
6263            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
6264        ));
6265    }
6266    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
6267}
6268
6269/// A public-safe label for a skipped private feed when it has no title: just the
6270/// host, so we never echo the secret-bearing path/query back to the user.
6271fn private_feed_label(url: &str) -> String {
6272    url::Url::parse(url)
6273        .ok()
6274        .and_then(|u| u.host_str().map(str::to_string))
6275        .unwrap_or_else(|| "a private feed".to_string())
6276}
6277
6278/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
6279async fn export_opml(
6280    State(state): State<AppState>,
6281    headers: HeaderMap,
6282) -> Result<Response, WebError> {
6283    let did = match current_did(&state, &headers).await {
6284        Some(d) => d,
6285        None => return Ok(Redirect::to("/login").into_response()),
6286    };
6287
6288    // **An export must never be silently empty.** `unwrap_or_default` here turned
6289    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
6290    // backup, blank, at exactly the moment they reached for it. That was survivable
6291    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
6292    // this is the one caller that converts a refusal into data loss, and it is also
6293    // the recovery route the changelog points a locked-out reader at.
6294    let subs = match state.repo().list_subscriptions_sorted(&did).await {
6295        Ok(subs) => subs,
6296        Err(err) => {
6297            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
6298            return Ok(Redirect::to(&format!(
6299                "/manage?flash={}",
6300                qenc(EXPORT_INCOMPLETE_REFUSAL)
6301            ))
6302            .into_response());
6303        }
6304    };
6305    let folders = match state.repo().list_folders_sorted(&did).await {
6306        Ok(folders) => folders,
6307        Err(err) => {
6308            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
6309            return Ok(Redirect::to(&format!(
6310                "/manage?flash={}",
6311                qenc(EXPORT_INCOMPLETE_REFUSAL)
6312            ))
6313            .into_response());
6314        }
6315    };
6316    // The exporter matches a subscription's `folder` at-uri against the folder's
6317    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
6318    let folder_pairs: Vec<(String, Folder)> = folders
6319        .into_iter()
6320        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
6321        .collect();
6322
6323    let body = opml::to_opml(&subs, &folder_pairs);
6324    let mut resp = (StatusCode::OK, body).into_response();
6325    resp.headers_mut().insert(
6326        header::CONTENT_TYPE,
6327        "text/x-opml; charset=utf-8".parse().unwrap(),
6328    );
6329    resp.headers_mut().insert(
6330        header::CONTENT_DISPOSITION,
6331        "attachment; filename=\"featherreader-subscriptions.opml\""
6332            .parse()
6333            .unwrap(),
6334    );
6335    Ok(resp)
6336}
6337
6338// ---------------------------------------------------------------------------
6339// Signed session cookie (HMAC-SHA256, dependency-free)
6340// ---------------------------------------------------------------------------
6341
6342/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
6343fn set_cookie(resp: &mut Response, cookie: &str) {
6344    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
6345        resp.headers_mut()
6346            .append(axum::http::header::SET_COOKIE, value);
6347    }
6348}
6349
6350/// Whether the request came from htmx (the `HX-Request` header).
6351fn is_htmx(headers: &HeaderMap) -> bool {
6352    headers
6353        .get("HX-Request")
6354        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
6355}
6356
6357/// Whether a mark-read / star request originated from the single-entry READER
6358/// (as opposed to the list view). The reader's forms tag themselves with
6359/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
6360/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
6361/// isn't in the DOM), the list gets the row (`entry_row.html`).
6362fn is_reader_request(headers: &HeaderMap) -> bool {
6363    headers
6364        .get("X-FR-Reader")
6365        .is_some_and(|v| v.as_bytes() == b"1")
6366}
6367
6368/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
6369/// server-minted **session id** (never the DID — so the cookie can't be forged
6370/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
6371/// server-side session id).
6372mod cookie {
6373    use super::{HeaderMap, SESSION_COOKIE};
6374
6375    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
6376    pub fn sign_session(sid: &str, secret: &str) -> String {
6377        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
6378    }
6379
6380    /// Verify the request's session cookie and return the session id it carries.
6381    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
6382        verify_value(headers, SESSION_COOKIE, secret)
6383    }
6384
6385    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
6386    /// value`), so a signature minted for one cookie can't verify under another —
6387    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
6388    /// The NUL separator can't appear in a cookie name, so the encoding is
6389    /// unambiguous.
6390    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
6391        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
6392        msg.extend_from_slice(name.as_bytes());
6393        msg.push(0);
6394        msg.extend_from_slice(value.as_bytes());
6395        msg
6396    }
6397
6398    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
6399    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
6400    /// generic form behind both the session cookie and the short-lived invite
6401    /// cookie; domain-separating by name keeps a signature valid only for the
6402    /// cookie it was minted for.
6403    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
6404        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
6405        let b64 = b64url_encode(value.as_bytes());
6406        format!(
6407            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
6408        )
6409    }
6410
6411    /// Verify + read a value out of the named signed cookie (`None` on absent /
6412    /// tampered / forged / cross-cookie). The generic form behind both readers.
6413    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
6414        let raw = cookie_value(headers, name)?;
6415        let (b64, sig) = raw.split_once('.')?;
6416        let bytes = b64url_decode(b64)?;
6417        let value = String::from_utf8(bytes).ok()?;
6418        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
6419        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6420            Some(value)
6421        } else {
6422            None
6423        }
6424    }
6425
6426    /// Sign an arbitrary `value` into an opaque, URL-safe token string
6427    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
6428    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
6429    /// URL query param (the bot's claim link). `label` domain-separates it from
6430    /// the cookies so a token can't be replayed as a cookie value.
6431    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
6432        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
6433        let b64 = b64url_encode(value.as_bytes());
6434        format!("{b64}.{sig}")
6435    }
6436
6437    /// Verify a token minted by [`sign_token`] and return the wrapped value
6438    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
6439    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
6440        let (b64, sig) = token.split_once('.')?;
6441        let bytes = b64url_decode(b64)?;
6442        let value = String::from_utf8(bytes).ok()?;
6443        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
6444        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6445            Some(value)
6446        } else {
6447            None
6448        }
6449    }
6450
6451    /// Pull one cookie value out of the `Cookie` request header.
6452    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
6453        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
6454        for part in header.split(';') {
6455            let part = part.trim();
6456            if let Some((k, v)) = part.split_once('=') {
6457                if k == name {
6458                    return Some(v.to_string());
6459                }
6460            }
6461        }
6462        None
6463    }
6464
6465    /// Constant-time byte comparison (avoid signature-timing leaks). Public
6466    /// within the module so the bot-secret bearer check reuses the exact same
6467    /// comparator as the cookie/token HMAC checks (one implementation to audit).
6468    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
6469        if a.len() != b.len() {
6470            return false;
6471        }
6472        let mut diff = 0u8;
6473        for (x, y) in a.iter().zip(b.iter()) {
6474            diff |= x ^ y;
6475        }
6476        diff == 0
6477    }
6478
6479    // -- URL-safe base64 (no padding), std-only --------------------------------
6480
6481    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6482
6483    fn b64url_encode(input: &[u8]) -> String {
6484        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
6485        for chunk in input.chunks(3) {
6486            let b = [
6487                chunk[0],
6488                *chunk.get(1).unwrap_or(&0),
6489                *chunk.get(2).unwrap_or(&0),
6490            ];
6491            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
6492            out.push(B64[((n >> 18) & 63) as usize] as char);
6493            out.push(B64[((n >> 12) & 63) as usize] as char);
6494            if chunk.len() > 1 {
6495                out.push(B64[((n >> 6) & 63) as usize] as char);
6496            }
6497            if chunk.len() > 2 {
6498                out.push(B64[(n & 63) as usize] as char);
6499            }
6500        }
6501        out
6502    }
6503
6504    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
6505        fn val(c: u8) -> Option<u32> {
6506            match c {
6507                b'A'..=b'Z' => Some((c - b'A') as u32),
6508                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6509                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6510                b'-' => Some(62),
6511                b'_' => Some(63),
6512                _ => None,
6513            }
6514        }
6515        let bytes = input.as_bytes();
6516        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
6517        for chunk in bytes.chunks(4) {
6518            let mut n = 0u32;
6519            let mut valid = 0;
6520            for (i, &c) in chunk.iter().enumerate() {
6521                n |= val(c)? << (18 - 6 * i);
6522                valid += 1;
6523            }
6524            out.push((n >> 16) as u8);
6525            if valid > 2 {
6526                out.push((n >> 8) as u8);
6527            }
6528            if valid > 3 {
6529                out.push(n as u8);
6530            }
6531        }
6532        Some(out)
6533    }
6534
6535    // -- HMAC-SHA256, std-only -------------------------------------------------
6536
6537    /// HMAC-SHA256(key, msg) as lowercase hex.
6538    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
6539        const BLOCK: usize = 64;
6540        let mut k = [0u8; BLOCK];
6541        if key.len() > BLOCK {
6542            let d = sha256(key);
6543            k[..32].copy_from_slice(&d);
6544        } else {
6545            k[..key.len()].copy_from_slice(key);
6546        }
6547        let mut ipad = [0x36u8; BLOCK];
6548        let mut opad = [0x5cu8; BLOCK];
6549        for i in 0..BLOCK {
6550            ipad[i] ^= k[i];
6551            opad[i] ^= k[i];
6552        }
6553        let mut inner = Vec::with_capacity(BLOCK + msg.len());
6554        inner.extend_from_slice(&ipad);
6555        inner.extend_from_slice(msg);
6556        let inner_hash = sha256(&inner);
6557        let mut outer = Vec::with_capacity(BLOCK + 32);
6558        outer.extend_from_slice(&opad);
6559        outer.extend_from_slice(&inner_hash);
6560        let mac = sha256(&outer);
6561        let mut hex = String::with_capacity(64);
6562        for b in mac {
6563            hex.push_str(&format!("{b:02x}"));
6564        }
6565        hex
6566    }
6567
6568    /// SHA-256 (FIPS 180-4), std-only.
6569    fn sha256(data: &[u8]) -> [u8; 32] {
6570        const K: [u32; 64] = [
6571            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
6572            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
6573            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
6574            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
6575            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
6576            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
6577            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
6578            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
6579            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
6580            0xc67178f2,
6581        ];
6582        let mut h: [u32; 8] = [
6583            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
6584            0x5be0cd19,
6585        ];
6586
6587        let bit_len = (data.len() as u64) * 8;
6588        let mut msg = data.to_vec();
6589        msg.push(0x80);
6590        while msg.len() % 64 != 56 {
6591            msg.push(0);
6592        }
6593        msg.extend_from_slice(&bit_len.to_be_bytes());
6594
6595        for block in msg.chunks(64) {
6596            let mut w = [0u32; 64];
6597            for i in 0..16 {
6598                w[i] = u32::from_be_bytes([
6599                    block[i * 4],
6600                    block[i * 4 + 1],
6601                    block[i * 4 + 2],
6602                    block[i * 4 + 3],
6603                ]);
6604            }
6605            for i in 16..64 {
6606                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
6607                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
6608                w[i] = w[i - 16]
6609                    .wrapping_add(s0)
6610                    .wrapping_add(w[i - 7])
6611                    .wrapping_add(s1);
6612            }
6613            let mut a = h;
6614            for i in 0..64 {
6615                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
6616                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
6617                let t1 = a[7]
6618                    .wrapping_add(s1)
6619                    .wrapping_add(ch)
6620                    .wrapping_add(K[i])
6621                    .wrapping_add(w[i]);
6622                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
6623                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
6624                let t2 = s0.wrapping_add(maj);
6625                a[7] = a[6];
6626                a[6] = a[5];
6627                a[5] = a[4];
6628                a[4] = a[3].wrapping_add(t1);
6629                a[3] = a[2];
6630                a[2] = a[1];
6631                a[1] = a[0];
6632                a[0] = t1.wrapping_add(t2);
6633            }
6634            for i in 0..8 {
6635                h[i] = h[i].wrapping_add(a[i]);
6636            }
6637        }
6638
6639        let mut out = [0u8; 32];
6640        for (i, word) in h.iter().enumerate() {
6641            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6642        }
6643        out
6644    }
6645
6646    #[cfg(test)]
6647    mod tests {
6648        use super::*;
6649
6650        #[test]
6651        fn sha256_known_vector() {
6652            let d = sha256(b"abc");
6653            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6654            assert_eq!(
6655                hex,
6656                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6657            );
6658        }
6659
6660        #[test]
6661        fn hmac_known_vector() {
6662            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6663            assert_eq!(
6664                mac,
6665                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6666            );
6667        }
6668
6669        #[test]
6670        fn sign_verify_round_trips() {
6671            let secret = "test-secret";
6672            let sid = "9f2c-opaque-session-id";
6673            let cookie = sign_session(sid, secret);
6674            let pair = cookie.split(';').next().unwrap().to_string();
6675            let mut headers = HeaderMap::new();
6676            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6677            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6678            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
6679            assert!(verify_session(&headers, "other-secret").is_none());
6680        }
6681
6682        #[test]
6683        fn forged_and_tampered_cookies_are_rejected() {
6684            let secret = "test-secret";
6685
6686            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
6687            //    the secret, so an arbitrary signature must not verify.
6688            let forged = format!(
6689                "{SESSION_COOKIE}={}.{}",
6690                b64url_encode(b"attacker-chosen-sid"),
6691                "deadbeef".repeat(8) // 64 hex chars, wrong sig
6692            );
6693            let mut headers = HeaderMap::new();
6694            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6695            assert!(verify_session(&headers, secret).is_none());
6696
6697            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
6698            //    keeping the original signature — must not verify.
6699            let cookie = sign_session("real-sid", secret);
6700            let pair = cookie.split(';').next().unwrap();
6701            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6702            let tampered = format!(
6703                "{SESSION_COOKIE}={}.{}",
6704                b64url_encode(b"different-sid"),
6705                sig
6706            );
6707            let mut headers2 = HeaderMap::new();
6708            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6709            assert!(verify_session(&headers2, secret).is_none());
6710        }
6711
6712        #[test]
6713        fn b64url_round_trips() {
6714            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6715                let enc = b64url_encode(s.as_bytes());
6716                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6717            }
6718        }
6719    }
6720}
6721
6722// ---------------------------------------------------------------------------
6723// Small store helpers local to the web layer
6724// ---------------------------------------------------------------------------
6725
6726/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
6727///
6728/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
6729/// `did` does not subscribe to its feed. This is the per-DID read gate for the
6730/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
6731/// deduped by URL, but no DID can read another DID's cached article.
6732///
6733/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
6734/// that renders `content_html`, and it fetches exactly one row. The list views
6735/// go through [`store::list_entries`], which is both paged and body-free — see
6736/// [`store::EntryListRow`] for why they had to stop sharing this projection.
6737async fn get_entry_by_id(
6738    pool: &store::Pool,
6739    did: &str,
6740    id: i64,
6741) -> anyhow::Result<Option<store::Entry>> {
6742    let entry = sqlx::query_as::<_, store::Entry>(
6743        r#"
6744        SELECT e.* FROM entries e
6745        WHERE e.id = ?2
6746          AND EXISTS (
6747              SELECT 1 FROM sub_ref sr
6748              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6749          )
6750        "#,
6751    )
6752    .bind(did)
6753    .bind(id)
6754    .fetch_optional(pool)
6755    .await?;
6756    Ok(entry)
6757}
6758
6759/// Whether `entry_id` is marked read for `did` (absent state row = unread).
6760async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6761    let read: Option<bool> =
6762        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6763            .bind(did)
6764            .bind(entry_id)
6765            .fetch_optional(pool)
6766            .await?
6767            .flatten();
6768    Ok(read.unwrap_or(false))
6769}
6770
6771/// Whether `entry_id` is starred for `did` (absent state row = not starred).
6772async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6773    let starred: Option<bool> =
6774        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6775            .bind(did)
6776            .bind(entry_id)
6777            .fetch_optional(pool)
6778            .await?
6779            .flatten();
6780    Ok(starred.unwrap_or(false))
6781}
6782
6783/// Feed display title for one entry's feed id (via a single lookup).
6784async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6785    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6786        .bind(feed_id)
6787        .fetch_optional(pool)
6788        .await
6789    {
6790        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6791        _ => String::new(),
6792    }
6793}
6794
6795/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
6796/// be forced (mark-read path) or looked up (`None` — star path).
6797async fn build_entry_row(
6798    pool: &store::Pool,
6799    did: &str,
6800    id: i64,
6801    read: Option<bool>,
6802) -> anyhow::Result<Option<EntryRow>> {
6803    let entry = match get_entry_by_id(pool, did, id).await? {
6804        Some(e) => e,
6805        None => return Ok(None),
6806    };
6807    let read = match read {
6808        Some(r) => r,
6809        None => entry_is_read(pool, did, id).await?,
6810    };
6811    let starred = entry_is_starred(pool, did, id).await?;
6812    Ok(Some(EntryRow {
6813        id: entry.id,
6814        title: entry
6815            .title
6816            .clone()
6817            .filter(|t| !t.trim().is_empty())
6818            .unwrap_or_else(|| "(untitled)".to_string()),
6819        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6820        published: display_date(entry.published.as_deref()),
6821        read,
6822        starred,
6823        link: SafeLink::entry(id, ""),
6824        cached: true,
6825        rkey: String::new(),
6826    }))
6827}
6828
6829/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
6830fn now_rfc3339() -> String {
6831    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6832}
6833
6834#[cfg(test)]
6835mod tests {
6836    use super::*;
6837
6838    #[test]
6839    fn qenc_encodes_reserved() {
6840        assert_eq!(qenc("a b"), "a%20b");
6841        assert_eq!(
6842            qenc("https://example.com/feed.xml"),
6843            "https%3A%2F%2Fexample.com%2Ffeed.xml"
6844        );
6845        assert_eq!(
6846            qenc("at://did:plc:x/c/r"),
6847            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6848        );
6849        // Unreserved chars pass through untouched.
6850        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6851    }
6852
6853    #[test]
6854    fn folder_uri_shape() {
6855        assert_eq!(
6856            folder_uri("did:plc:abc", "3kfolder"),
6857            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6858        );
6859    }
6860
6861    // -- public-feeds-only: private/paid feeds are refused --------------------
6862
6863    #[test]
6864    fn private_feeds_are_classified_private_across_providers() {
6865        // The add + OPML paths both gate on this classifier; assert it flags a
6866        // spread of paid providers (newsletters + private podcasts) and the
6867        // generic credential-in-URL shapes.
6868        for url in [
6869            "https://author.substack.com/feed/private/deadbeefcafe1234",
6870            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6871            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6872            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6873            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6874            "https://user:pass@example.com/feed",
6875        ] {
6876            assert!(
6877                feed::classify_feed_privacy(url).is_private(),
6878                "expected private: {url}"
6879            );
6880        }
6881    }
6882
6883    #[test]
6884    fn public_feeds_stay_public() {
6885        for url in [
6886            "https://author.substack.com/feed",
6887            "https://wordpress.example.com/feed/",
6888            "https://example.com/rss.xml",
6889            "https://example.org/atom.xml",
6890            // YouTube channel/playlist RSS is fully public — must not false-block.
6891            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6892            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6893        ] {
6894            assert!(
6895                !feed::classify_feed_privacy(url).is_private(),
6896                "expected public: {url}"
6897            );
6898        }
6899    }
6900
6901    #[test]
6902    fn private_feed_label_is_public_safe_host_only() {
6903        // The OPML skip report must never echo the secret path/query, only the host.
6904        let label =
6905            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6906        assert_eq!(label, "author.substack.com");
6907        assert!(!label.contains("deadbeefcafe1234token"));
6908        assert!(!label.contains("/private/"));
6909        // An unparseable URL degrades to a generic label.
6910        assert_eq!(private_feed_label("not a url"), "a private feed");
6911    }
6912
6913    #[test]
6914    fn refusal_message_promises_nothing_stored() {
6915        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6916        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6917    }
6918
6919    #[test]
6920    fn scope_query_preserves_context() {
6921        let q = EntryQuery {
6922            feed: Some("https://example.com/feed.xml".to_string()),
6923            folder: None,
6924            view: Some("all".to_string()),
6925        };
6926        let s = scope_query(&q);
6927        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6928        assert!(s.contains("view=all"));
6929
6930        // Default view is omitted.
6931        let q2 = EntryQuery {
6932            feed: None,
6933            folder: None,
6934            view: Some("unread".to_string()),
6935        };
6936        assert_eq!(scope_query(&q2), "");
6937    }
6938
6939    // -- closed-beta invite gate + rate-limit + cache-control ------------------
6940
6941    use axum::body::Body;
6942    use axum::http::Request;
6943    use tower::ServiceExt; // for `oneshot`
6944
6945    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
6946    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
6947    /// can forge matching cookies.
6948    async fn test_state(allowed: &[&str]) -> AppState {
6949        let db = store::init_url("sqlite::memory:").await.unwrap();
6950        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6951        store::ensure_seed(&db, &dids).await.unwrap();
6952        let config = Config {
6953            allowed_dids: dids,
6954            cookie_secret: "test-cookie-secret-000".to_string(),
6955            beta_cap: 3,
6956            ..Config::default()
6957        };
6958        AppState::new(config, db).unwrap()
6959    }
6960
6961    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
6962    /// looked up in the registry, so create the session first).
6963    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6964        let sid = state.sessions.create(Session {
6965            did: did.to_string(),
6966            handle: handle.map(str::to_string),
6967        });
6968        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6969        sc.split(';').next().unwrap().to_string()
6970    }
6971
6972    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
6973    /// long time to accept distinct source IPs on two unauthenticated guarded
6974    /// routes.
6975    #[test]
6976    fn the_rate_limit_map_is_bounded() {
6977        let rl = RateLimiter::shared();
6978        let now = Instant::now();
6979        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6980            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
6981            // ordering below is well-defined.
6982            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6983            rl.check_at(ip, now + Duration::from_millis(i as u64));
6984        }
6985        let len = rl.inner.lock().unwrap().buckets.len();
6986        assert!(
6987            len <= MAX_RATE_BUCKETS,
6988            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6989        );
6990    }
6991
6992    /// Eviction must not hand a throttled attacker a fresh burst.
6993    ///
6994    /// The bound is LRU, so the one bucket an attacker can never evict is their
6995    /// own — it is the most recently touched thing in the map. If this inverted,
6996    /// the size cap would become a rate-limit bypass: spray addresses until the
6997    /// map overflows, then resume.
6998    #[test]
6999    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
7000        let rl = RateLimiter::shared();
7001        let base = Instant::now();
7002        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
7003        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
7004        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
7005        // millisecond step made the whole flood take a second, and the refill —
7006        // working correctly — then looked exactly like an eviction bypass.
7007        let at = |n: u64| base + Duration::from_nanos(n);
7008
7009        // Spend the burst. `RATE_BURST` allowed, then refused.
7010        for i in 0..(RATE_BURST as u64) {
7011            assert!(rl.check_at(attacker, at(i)));
7012        }
7013        assert!(
7014            !rl.check_at(attacker, at(RATE_BURST as u64)),
7015            "burst was not exhausted; the rest of this test proves nothing"
7016        );
7017
7018        // Now overflow the map from other addresses, interleaving the attacker
7019        // so their bucket stays hot — the realistic shape of the attack.
7020        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
7021            let t = at(100 + i as u64 * 2);
7022            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
7023            rl.check_at(ip, t);
7024            assert!(
7025                !rl.check_at(attacker, t),
7026                "the attacker got a token back after evictions at i={i}"
7027            );
7028        }
7029    }
7030
7031    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
7032    /// of the whole map on every guarded request, on one shared core.
7033    #[test]
7034    fn the_idle_sweep_does_not_run_on_every_request() {
7035        let rl = RateLimiter::shared();
7036        let start = Instant::now();
7037        let a: IpAddr = "198.51.100.1".parse().unwrap();
7038        let b: IpAddr = "198.51.100.2".parse().unwrap();
7039
7040        rl.check_at(a, start);
7041        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
7042        // the sweep interval has elapsed too, so this request does sweep it.
7043        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
7044        assert!(
7045            !rl.inner.lock().unwrap().buckets.contains_key(&a),
7046            "an idle bucket survived a sweep that was due"
7047        );
7048
7049        // A second request moments later must NOT re-sweep — `b` is still there,
7050        // and the recorded sweep time must not have moved.
7051        let before = rl.inner.lock().unwrap().last_sweep;
7052        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
7053        assert_eq!(
7054            rl.inner.lock().unwrap().last_sweep,
7055            before,
7056            "the sweep ran again within the interval"
7057        );
7058    }
7059
7060    #[test]
7061    fn rate_limited_paths_match_expected() {
7062        use axum::http::Method;
7063        assert!(is_rate_limited_path("/login", &Method::GET));
7064        assert!(is_rate_limited_path("/login", &Method::POST));
7065        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
7066        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
7067        assert!(is_rate_limited_path("/opml", &Method::POST));
7068        assert!(is_rate_limited_path("/read-all", &Method::POST));
7069        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
7070        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
7071        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
7072        // Read-only navigation is NOT limited.
7073        assert!(!is_rate_limited_path("/", &Method::GET));
7074        assert!(!is_rate_limited_path("/about", &Method::GET));
7075        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
7076        assert!(!is_rate_limited_path("/login", &Method::HEAD));
7077    }
7078
7079    #[test]
7080    fn rate_limiter_allows_burst_then_429s() {
7081        let rl = RateLimiter::shared();
7082        let ip: IpAddr = "203.0.113.7".parse().unwrap();
7083        // The full burst passes.
7084        for _ in 0..(RATE_BURST as usize) {
7085            assert!(rl.check(ip));
7086        }
7087        // The next one (no time elapsed → no refill) is rejected.
7088        assert!(!rl.check(ip));
7089        // A different IP has its own bucket.
7090        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
7091        assert!(rl.check(ip2));
7092    }
7093
7094    #[test]
7095    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
7096        // With NO trusted header configured, a client-supplied X-Forwarded-For
7097        // must be ignored entirely — the limiter keys on the real socket peer,
7098        // so an attacker can't mint a fresh bucket per forged XFF value.
7099        let mut h = HeaderMap::new();
7100        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
7101        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
7102        assert_eq!(
7103            client_ip(&h, Some(&sock), None),
7104            Some("203.0.113.55".parse().unwrap()),
7105            "spoofed XFF must not override the socket peer"
7106        );
7107    }
7108
7109    #[test]
7110    fn client_ip_uses_trusted_header_last_hop() {
7111        // With a trusted proxy header configured, the client IP comes from THAT
7112        // header (the proxy overwrites any client copy). On a comma list we take
7113        // the RIGHT-most hop — the one the trusted proxy appended — so a
7114        // client-forged left-most value is ignored.
7115        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
7116
7117        let mut h = HeaderMap::new();
7118        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
7119        assert_eq!(
7120            client_ip(&h, Some(&sock), Some("fly-client-ip")),
7121            Some("198.51.100.9".parse().unwrap())
7122        );
7123
7124        // Attacker prepends a forged hop; the trusted proxy appends the real one.
7125        let mut h2 = HeaderMap::new();
7126        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
7127        assert_eq!(
7128            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
7129            Some("198.51.100.9".parse().unwrap()),
7130            "must take the right-most (trusted) hop, not the forged left-most"
7131        );
7132
7133        // Trusted header absent → fall back to the socket peer.
7134        let h3 = HeaderMap::new();
7135        assert_eq!(
7136            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
7137            Some("10.0.0.1".parse().unwrap())
7138        );
7139    }
7140
7141    #[test]
7142    fn invite_cookie_round_trips_and_rejects_tamper() {
7143        let secret = "test-cookie-secret-000";
7144        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
7145        let pair = sc.split(';').next().unwrap();
7146        let mut h = HeaderMap::new();
7147        h.insert(header::COOKIE, pair.parse().unwrap());
7148        assert_eq!(
7149            invite_cookie_code(&h, secret).as_deref(),
7150            Some("FEATHER-ABCDWXYZ")
7151        );
7152        // Wrong secret → rejected.
7153        assert!(invite_cookie_code(&h, "other").is_none());
7154    }
7155
7156    #[tokio::test]
7157    async fn preflight_valid_expired_and_full() {
7158        let state = test_state(&["did:plc:admin"]).await;
7159        // A minted, active code preflights OK.
7160        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7161            .await
7162            .unwrap();
7163        assert!(preflight_code(&state, &code).await.is_ok());
7164
7165        // A code whose expiry is in the past preflights as Expired. (mint_code
7166        // clamps negative ttl to 0, so back-date the row directly for a
7167        // deterministic past expiry.)
7168        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
7169            .await
7170            .unwrap();
7171        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
7172            .bind(chrono::Utc::now().timestamp() - 3600)
7173            .bind(&expired)
7174            .execute(&state.db)
7175            .await
7176            .unwrap();
7177        assert_eq!(
7178            preflight_code(&state, &expired).await,
7179            Err(store::RedeemError::Expired)
7180        );
7181
7182        // Unknown code → NotFound.
7183        assert_eq!(
7184            preflight_code(&state, "FEATHER-NOPENOPE").await,
7185            Err(store::RedeemError::NotFound)
7186        );
7187
7188        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
7189        // must report CapacityFull.
7190        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7191            .await
7192            .unwrap();
7193        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7194            .await
7195            .unwrap();
7196        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7197        assert_eq!(
7198            preflight_code(&state, &code).await,
7199            Err(store::RedeemError::CapacityFull)
7200        );
7201    }
7202
7203    // -- Bot claim link + shared-secret mint ---------------------------------
7204
7205    /// A test state with a configured bot secret (so `/bot/claims` is live).
7206    async fn bot_state(bot_secret: &str) -> AppState {
7207        let db = store::init_url("sqlite::memory:").await.unwrap();
7208        store::ensure_seed(&db, &["did:plc:admin".to_string()])
7209            .await
7210            .unwrap();
7211        let config = Config {
7212            allowed_dids: vec!["did:plc:admin".to_string()],
7213            cookie_secret: "test-cookie-secret-000".to_string(),
7214            beta_cap: 3,
7215            bot_secret: Some(bot_secret.to_string()),
7216            public_url: "https://feather-reader.com".to_string(),
7217            ..Config::default()
7218        };
7219        AppState::new(config, db).unwrap()
7220    }
7221
7222    #[test]
7223    fn claim_token_round_trips_and_rejects_tamper() {
7224        let secret = "test-cookie-secret-000";
7225        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
7226        // No cookie framing — a bare URL-safe token.
7227        assert!(!token.contains(';'));
7228        assert_eq!(
7229            claim_token_code(&token, secret).as_deref(),
7230            Some("FEATHER-ABCDWXYZ")
7231        );
7232        // Wrong secret → rejected.
7233        assert!(claim_token_code(&token, "other").is_none());
7234        // Tampered token → rejected.
7235        let mut bad = token.clone();
7236        bad.push('x');
7237        assert!(claim_token_code(&bad, secret).is_none());
7238        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
7239        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
7240        // recover it WITHOUT the secret). The security is single-use + HMAC
7241        // integrity + rate-limit, not secrecy of the code. Assert the code half is
7242        // publicly decodable (a plain base64url decode, no secret involved).
7243        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
7244        assert_eq!(
7245            test_b64url_decode(b64).as_deref(),
7246            Some("FEATHER-ABCDWXYZ".as_bytes()),
7247            "the code half of the token is plain base64url, decodable by anyone"
7248        );
7249    }
7250
7251    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
7252    /// claim token's code half needs NO secret to recover (it is not confidential).
7253    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
7254        fn val(c: u8) -> Option<u32> {
7255            match c {
7256                b'A'..=b'Z' => Some((c - b'A') as u32),
7257                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
7258                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
7259                b'-' => Some(62),
7260                b'_' => Some(63),
7261                _ => None,
7262            }
7263        }
7264        let mut out = Vec::with_capacity(input.len() / 4 * 3);
7265        for chunk in input.as_bytes().chunks(4) {
7266            let mut n = 0u32;
7267            let mut bits = 0;
7268            for &c in chunk {
7269                n = (n << 6) | val(c)?;
7270                bits += 6;
7271            }
7272            let bytes = bits / 8;
7273            n <<= 24 - bits;
7274            for i in 0..bytes {
7275                out.push((n >> (16 - i * 8)) as u8);
7276            }
7277        }
7278        Some(out)
7279    }
7280
7281    #[tokio::test]
7282    async fn bot_mint_then_claim_grants_a_seat() {
7283        let state = bot_state("bot-secret-abcdef").await;
7284        let app = router(state.clone());
7285
7286        // 1. Mint a claim via the shared-secret endpoint.
7287        let resp = app
7288            .clone()
7289            .oneshot(
7290                Request::builder()
7291                    .method("POST")
7292                    .uri("/bot/claims")
7293                    .header("x-bot-secret", "bot-secret-abcdef")
7294                    .body(Body::empty())
7295                    .unwrap(),
7296            )
7297            .await
7298            .unwrap();
7299        assert_eq!(resp.status(), StatusCode::OK);
7300        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7301            .await
7302            .unwrap();
7303        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7304        let token = json["token"].as_str().unwrap().to_string();
7305        let url = json["url"].as_str().unwrap();
7306        assert!(url.starts_with("https://feather-reader.com/claim?t="));
7307        // The raw code is returned for the bot's records but not embedded in url.
7308        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
7309        assert!(!url.contains("FEATHER-"));
7310
7311        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
7312        let resp = app
7313            .clone()
7314            .oneshot(
7315                Request::builder()
7316                    .method("GET")
7317                    .uri(format!("/claim?t={}", qenc(&token)))
7318                    .body(Body::empty())
7319                    .unwrap(),
7320            )
7321            .await
7322            .unwrap();
7323        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7324        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7325        let set_cookie = resp
7326            .headers()
7327            .get(header::SET_COOKIE)
7328            .unwrap()
7329            .to_str()
7330            .unwrap();
7331        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
7332
7333        // 3. The reserved cookie carries the same code the token wrapped, and
7334        //    redeeming it (the callback's machinery) grants a seat.
7335        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
7336        let out = store::redeem_code(
7337            &state.db,
7338            &code,
7339            "did:plc:follower",
7340            None,
7341            state.config.beta_cap,
7342        )
7343        .await
7344        .unwrap();
7345        assert_eq!(out, Ok(()));
7346        assert!(store::has_beta_access(&state.db, "did:plc:follower")
7347            .await
7348            .unwrap());
7349    }
7350
7351    #[tokio::test]
7352    async fn claim_with_invalid_token_bounces() {
7353        let state = bot_state("bot-secret-abcdef").await;
7354        let app = router(state);
7355        let resp = app
7356            .oneshot(
7357                Request::builder()
7358                    .method("GET")
7359                    .uri("/claim?t=not-a-real-token")
7360                    .body(Body::empty())
7361                    .unwrap(),
7362            )
7363            .await
7364            .unwrap();
7365        // Renders the invite page (200), NOT a redirect to /login.
7366        assert_eq!(resp.status(), StatusCode::OK);
7367    }
7368
7369    #[tokio::test]
7370    async fn claim_with_used_token_is_refused() {
7371        let state = bot_state("bot-secret-abcdef").await;
7372        // Mint a code + wrap it, then redeem it out from under the token.
7373        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7374            .await
7375            .unwrap();
7376        let token = sign_claim_token(&code, &state.config.cookie_secret);
7377        store::redeem_code(
7378            &state.db,
7379            &code,
7380            "did:plc:someone",
7381            None,
7382            state.config.beta_cap,
7383        )
7384        .await
7385        .unwrap()
7386        .unwrap();
7387        let app = router(state);
7388        let resp = app
7389            .oneshot(
7390                Request::builder()
7391                    .method("GET")
7392                    .uri(format!("/claim?t={}", qenc(&token)))
7393                    .body(Body::empty())
7394                    .unwrap(),
7395            )
7396            .await
7397            .unwrap();
7398        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
7399        assert_eq!(resp.status(), StatusCode::OK);
7400        assert!(resp.headers().get(header::SET_COOKIE).is_none());
7401    }
7402
7403    #[tokio::test]
7404    async fn bot_claims_rejects_bad_and_missing_secret() {
7405        let state = bot_state("bot-secret-abcdef").await;
7406        let app = router(state);
7407        // Wrong secret.
7408        let resp = app
7409            .clone()
7410            .oneshot(
7411                Request::builder()
7412                    .method("POST")
7413                    .uri("/bot/claims")
7414                    .header("x-bot-secret", "wrong")
7415                    .body(Body::empty())
7416                    .unwrap(),
7417            )
7418            .await
7419            .unwrap();
7420        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7421        // Missing secret.
7422        let resp = app
7423            .oneshot(
7424                Request::builder()
7425                    .method("POST")
7426                    .uri("/bot/claims")
7427                    .body(Body::empty())
7428                    .unwrap(),
7429            )
7430            .await
7431            .unwrap();
7432        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7433    }
7434
7435    #[tokio::test]
7436    async fn bot_claims_disabled_when_secret_unset() {
7437        // test_state configures NO bot secret → the endpoint is off (503).
7438        let state = test_state(&["did:plc:admin"]).await;
7439        let app = router(state);
7440        let resp = app
7441            .oneshot(
7442                Request::builder()
7443                    .method("POST")
7444                    .uri("/bot/claims")
7445                    .header("x-bot-secret", "anything")
7446                    .body(Body::empty())
7447                    .unwrap(),
7448            )
7449            .await
7450            .unwrap();
7451        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7452    }
7453
7454    #[tokio::test]
7455    async fn bot_claims_refuses_at_capacity() {
7456        let state = bot_state("bot-secret-abcdef").await;
7457        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
7458        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7459            .await
7460            .unwrap();
7461        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7462            .await
7463            .unwrap();
7464        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7465        let app = router(state);
7466        let resp = app
7467            .oneshot(
7468                Request::builder()
7469                    .method("POST")
7470                    .uri("/bot/claims")
7471                    .header("x-bot-secret", "bot-secret-abcdef")
7472                    .body(Body::empty())
7473                    .unwrap(),
7474            )
7475            .await
7476            .unwrap();
7477        assert_eq!(resp.status(), StatusCode::CONFLICT);
7478        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7479            .await
7480            .unwrap();
7481        assert!(String::from_utf8_lossy(&bytes).contains("full"));
7482    }
7483
7484    #[tokio::test]
7485    async fn bot_claims_counts_outstanding_codes_against_cap() {
7486        let state = bot_state("bot-secret-abcdef").await;
7487        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
7488        store::mint_code(&state.db, "did:plc:admin", 3600)
7489            .await
7490            .unwrap();
7491        store::mint_code(&state.db, "did:plc:admin", 3600)
7492            .await
7493            .unwrap();
7494        let app = router(state);
7495        let resp = app
7496            .oneshot(
7497                Request::builder()
7498                    .method("POST")
7499                    .uri("/bot/claims")
7500                    .header("x-bot-secret", "bot-secret-abcdef")
7501                    .body(Body::empty())
7502                    .unwrap(),
7503            )
7504            .await
7505            .unwrap();
7506        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
7507        assert_eq!(resp.status(), StatusCode::CONFLICT);
7508    }
7509
7510    /// POST /bot/claims with a JSON body carrying the follower DID.
7511    async fn post_bot_claim_for(
7512        app: &axum::Router,
7513        secret: &str,
7514        did: &str,
7515    ) -> (StatusCode, serde_json::Value) {
7516        let resp = app
7517            .clone()
7518            .oneshot(
7519                Request::builder()
7520                    .method("POST")
7521                    .uri("/bot/claims")
7522                    .header("x-bot-secret", secret)
7523                    .header("content-type", "application/json")
7524                    .body(Body::from(format!(
7525                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
7526                    )))
7527                    .unwrap(),
7528            )
7529            .await
7530            .unwrap();
7531        let status = resp.status();
7532        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7533            .await
7534            .unwrap();
7535        let json = if bytes.is_empty() {
7536            serde_json::Value::Null
7537        } else {
7538            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
7539        };
7540        (status, json)
7541    }
7542
7543    #[tokio::test]
7544    async fn bot_claims_returns_already_seated_for_a_member() {
7545        // A DID that already holds beta access must get `already_seated` with NO
7546        // code/url — the bot posts nothing. This is the server-side backstop that
7547        // survives a bot-host state loss (it would otherwise re-mint + re-post).
7548        let state = bot_state("bot-secret-abcdef").await;
7549        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
7550            .await
7551            .unwrap();
7552        let app = router(state.clone());
7553        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
7554        assert_eq!(status, StatusCode::OK);
7555        assert_eq!(json["status"], "already_seated");
7556        assert_eq!(json["code"], "");
7557        assert_eq!(json["url"], "");
7558        // No new invite code was minted for the seated DID.
7559        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
7560            .await
7561            .unwrap()
7562            .is_none());
7563    }
7564
7565    #[tokio::test]
7566    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
7567        // Two mint requests for the SAME follower DID must return the SAME code
7568        // (the app is authoritative), never a second one — so a bot-host state loss
7569        // re-requesting cannot double-mint or double-post.
7570        let state = bot_state("bot-secret-abcdef").await;
7571        let app = router(state.clone());
7572
7573        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7574        assert_eq!(s1, StatusCode::OK);
7575        assert_eq!(j1["status"], "minted");
7576        let code1 = j1["code"].as_str().unwrap().to_string();
7577
7578        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7579        assert_eq!(s2, StatusCode::OK);
7580        assert_eq!(j2["status"], "existing");
7581        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
7582        assert_eq!(j2["url"], j1["url"], "same url returned");
7583
7584        // Exactly ONE active code exists for that DID.
7585        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
7586    }
7587
7588    #[tokio::test]
7589    async fn bot_claims_records_intended_did_at_mint() {
7590        // A fresh mint records the follower DID so the lookup finds it.
7591        let state = bot_state("bot-secret-abcdef").await;
7592        let app = router(state.clone());
7593        let (status, json) =
7594            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
7595        assert_eq!(status, StatusCode::OK);
7596        let code = json["code"].as_str().unwrap();
7597        assert_eq!(
7598            store::find_active_code_for_did(&state.db, "did:plc:follower2")
7599                .await
7600                .unwrap()
7601                .as_deref(),
7602            Some(code)
7603        );
7604    }
7605
7606    #[tokio::test]
7607    async fn bot_claims_concurrent_same_did_never_double_mints() {
7608        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
7609        // active code. The dedupe check (3b) and the mint are separate statements,
7610        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
7611        // then makes the loser's INSERT conflict, and the handler recovers by
7612        // returning the winner's code (status `existing`) rather than 500-ing.
7613        // Result: exactly ONE active code, and BOTH callers get a usable code.
7614        let state = bot_state("bot-secret-abcdef").await;
7615        let app = router(state.clone());
7616
7617        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7618        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7619        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
7620
7621        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
7622        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
7623
7624        // Exactly one active code for the DID — the whole point of the fix.
7625        assert_eq!(
7626            store::count_active_codes(&state.db).await.unwrap(),
7627            1,
7628            "concurrent mints must not create two active codes"
7629        );
7630
7631        // Both callers received the SAME (single) code, and neither got a 500.
7632        let ca = ja["code"].as_str().unwrap_or("");
7633        let cb = jb["code"].as_str().unwrap_or("");
7634        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7635        assert_eq!(ca, cb, "both callers must get the one minted code");
7636        // One is `minted` (the winner), the other `minted` or `existing` depending
7637        // on interleaving — but never an error status.
7638        for st in [&ja["status"], &jb["status"]] {
7639            let s = st.as_str().unwrap_or("");
7640            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7641        }
7642    }
7643
7644    #[tokio::test]
7645    async fn bot_claims_rejects_malformed_json_body() {
7646        let state = bot_state("bot-secret-abcdef").await;
7647        let app = router(state);
7648        let resp = app
7649            .oneshot(
7650                Request::builder()
7651                    .method("POST")
7652                    .uri("/bot/claims")
7653                    .header("x-bot-secret", "bot-secret-abcdef")
7654                    .header("content-type", "application/json")
7655                    .body(Body::from("{not json"))
7656                    .unwrap(),
7657            )
7658            .await
7659            .unwrap();
7660        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7661    }
7662
7663    #[tokio::test]
7664    async fn favicon_ico_served_at_root() {
7665        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
7666        // tags in <head>; the root route must serve the icon, not 404.
7667        let state = test_state(&[]).await;
7668        let app = router(state);
7669        let resp = app
7670            .oneshot(
7671                Request::builder()
7672                    .uri("/favicon.ico")
7673                    .body(Body::empty())
7674                    .unwrap(),
7675            )
7676            .await
7677            .unwrap();
7678        assert_eq!(resp.status(), StatusCode::OK);
7679        let ct = resp
7680            .headers()
7681            .get(header::CONTENT_TYPE)
7682            .unwrap()
7683            .to_str()
7684            .unwrap();
7685        assert!(
7686            ct.contains("icon") || ct.starts_with("image/"),
7687            "content-type = {ct}"
7688        );
7689    }
7690
7691    #[tokio::test]
7692    async fn login_without_invite_redirects_to_beta_redeem() {
7693        // No allow-list seed, no invite cookie: starting OAuth must be refused.
7694        let state = test_state(&[]).await;
7695        let app = router(state);
7696        let resp = app
7697            .oneshot(
7698                Request::builder()
7699                    .method("POST")
7700                    .uri("/login")
7701                    .header("content-type", "application/x-www-form-urlencoded")
7702                    .body(Body::from("handle=alice.bsky.social"))
7703                    .unwrap(),
7704            )
7705            .await
7706            .unwrap();
7707        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7708        assert_eq!(
7709            resp.headers().get(header::LOCATION).unwrap(),
7710            "/beta/redeem"
7711        );
7712    }
7713
7714    #[tokio::test]
7715    async fn login_with_valid_invite_cookie_starts_oauth() {
7716        let state = test_state(&[]).await;
7717        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7718        let cookie = cookie.split(';').next().unwrap().to_string();
7719        let app = router(state);
7720        let resp = app
7721            .oneshot(
7722                Request::builder()
7723                    .method("POST")
7724                    .uri("/login")
7725                    .header("content-type", "application/x-www-form-urlencoded")
7726                    .header(header::COOKIE, cookie)
7727                    .body(Body::from("handle=alice.bsky.social"))
7728                    .unwrap(),
7729            )
7730            .await
7731            .unwrap();
7732        // Redirects into the sidecar login (not to /beta/redeem).
7733        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7734        let loc = resp
7735            .headers()
7736            .get(header::LOCATION)
7737            .unwrap()
7738            .to_str()
7739            .unwrap();
7740        assert!(loc.contains("/login"), "loc = {loc}");
7741        assert_ne!(loc, "/beta/redeem");
7742    }
7743
7744    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
7745    /// any network resolution and that a resolution failure fails closed.
7746    async fn resolver_never(_handle: String) -> Option<String> {
7747        None
7748    }
7749
7750    /// A resolver that maps every handle to `did`.
7751    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7752        move |_handle| std::future::ready(Some(did.to_string()))
7753    }
7754
7755    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
7756    /// that already holds a seat (the seeded-admin first-login case) passes the
7757    /// gate — no session cookie, no invite code.
7758    #[tokio::test]
7759    async fn may_start_oauth_honors_seat_via_resolved_handle() {
7760        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
7761        // no cookie on a fresh deploy.
7762        let state = test_state(&["did:plc:admin"]).await;
7763        let headers = HeaderMap::new();
7764        assert!(
7765            may_start_oauth_with(
7766                &state,
7767                &headers,
7768                "admin.example",
7769                resolver_to("did:plc:admin")
7770            )
7771            .await,
7772            "a handle resolving to a seated DID must pass the gate"
7773        );
7774    }
7775
7776    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
7777    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
7778    /// fails).
7779    #[tokio::test]
7780    async fn may_start_oauth_bounces_non_member_handle() {
7781        let state = test_state(&["did:plc:admin"]).await;
7782        let headers = HeaderMap::new();
7783        assert!(
7784            !may_start_oauth_with(
7785                &state,
7786                &headers,
7787                "rando.example",
7788                resolver_to("did:plc:rando")
7789            )
7790            .await,
7791            "a resolved DID with no seat must be bounced"
7792        );
7793    }
7794
7795    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
7796    /// bounces gracefully — no panic, no handshake.
7797    #[tokio::test]
7798    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7799        let state = test_state(&["did:plc:admin"]).await;
7800        let headers = HeaderMap::new();
7801        assert!(
7802            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7803            "an unresolvable handle must fail closed"
7804        );
7805    }
7806
7807    /// The session-cookie fast path admits a seated member WITHOUT calling the
7808    /// resolver (proven by injecting `resolver_never`, which would otherwise
7809    /// bounce).
7810    #[tokio::test]
7811    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7812        let state = test_state(&[]).await;
7813        let did = "did:plc:member";
7814        store::grant_access(&state.db, did, Some("member.example"), "test", None)
7815            .await
7816            .unwrap();
7817        let cookie = session_cookie(&state, did, Some("member.example"));
7818        let mut headers = HeaderMap::new();
7819        headers.insert(header::COOKIE, cookie.parse().unwrap());
7820        assert!(
7821            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7822            "a seated session cookie must pass without resolution"
7823        );
7824    }
7825
7826    /// The invite-cookie fast path admits WITHOUT calling the resolver.
7827    #[tokio::test]
7828    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7829        let state = test_state(&[]).await;
7830        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7831        let cookie = cookie.split(';').next().unwrap().to_string();
7832        let mut headers = HeaderMap::new();
7833        headers.insert(header::COOKIE, cookie.parse().unwrap());
7834        assert!(
7835            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7836            "a valid invite cookie must pass without resolution"
7837        );
7838    }
7839
7840    #[tokio::test]
7841    async fn admin_mint_requires_admin_seed_did() {
7842        let state = test_state(&["did:plc:admin"]).await;
7843        // A non-admin (but beta'd) session is forbidden.
7844        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7845            .await
7846            .unwrap();
7847        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7848        // An admin session is allowed.
7849        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7850        let app = router(state);
7851
7852        let forbidden = app
7853            .clone()
7854            .oneshot(
7855                Request::builder()
7856                    .method("POST")
7857                    .uri("/admin/invites?n=2")
7858                    .header(header::COOKIE, rando_cookie)
7859                    .body(Body::empty())
7860                    .unwrap(),
7861            )
7862            .await
7863            .unwrap();
7864        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7865
7866        let ok = app
7867            .oneshot(
7868                Request::builder()
7869                    .method("POST")
7870                    .uri("/admin/invites?n=2")
7871                    .header(header::COOKIE, admin_cookie)
7872                    .body(Body::empty())
7873                    .unwrap(),
7874            )
7875            .await
7876            .unwrap();
7877        assert_eq!(ok.status(), StatusCode::OK);
7878        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7879            .await
7880            .unwrap();
7881        let body = String::from_utf8(bytes.to_vec()).unwrap();
7882        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7883        assert_eq!(minted.len(), 2);
7884        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7885    }
7886
7887    #[tokio::test]
7888    async fn admin_mint_unauthenticated_is_401() {
7889        let state = test_state(&["did:plc:admin"]).await;
7890        let app = router(state);
7891        let resp = app
7892            .oneshot(
7893                Request::builder()
7894                    .method("POST")
7895                    .uri("/admin/invites")
7896                    .body(Body::empty())
7897                    .unwrap(),
7898            )
7899            .await
7900            .unwrap();
7901        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7902    }
7903
7904    /// A state whose `/about` renders the adoption line, seeded with one
7905    /// observation.
7906    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7907        let db = store::init_url("sqlite::memory:").await.unwrap();
7908        store::record_network_stat(
7909            &db,
7910            &store::NetworkStat {
7911                key: store::ADOPTION_STAT_KEY.to_string(),
7912                source: "https://relay1.us-west.bsky.network".to_string(),
7913                value: repos,
7914                truncated,
7915                observed_at: "2026-08-13T04:05:06Z".to_string(),
7916            },
7917        )
7918        .await
7919        .unwrap();
7920        let config = Config {
7921            cookie_secret: "test-cookie-secret-000".to_string(),
7922            show_adoption: true,
7923            ..Config::default()
7924        };
7925        AppState::new(config, db).unwrap()
7926    }
7927
7928    async fn about_body(state: AppState) -> String {
7929        let resp = router(state)
7930            .oneshot(
7931                Request::builder()
7932                    .uri("/about")
7933                    .body(Body::empty())
7934                    .unwrap(),
7935            )
7936            .await
7937            .unwrap();
7938        assert_eq!(resp.status(), StatusCode::OK);
7939        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7940            .await
7941            .unwrap();
7942        String::from_utf8(bytes.to_vec()).unwrap()
7943    }
7944
7945    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
7946    #[tokio::test]
7947    async fn about_omits_adoption_line_by_default() {
7948        let state = test_state(&[]).await;
7949        assert!(!state.config.show_adoption);
7950        let body = about_body(state).await;
7951        assert!(
7952            !body.contains("atproto network"),
7953            "the adoption line must not render by default"
7954        );
7955    }
7956
7957    #[tokio::test]
7958    async fn about_renders_adoption_line_when_enabled() {
7959        // **A distinctive count, and asserted IN ITS SENTENCE.**
7960        //
7961        // This used to seed 4 and assert `body.contains("4")`, which the
7962        // colophon's `width="44"` satisfies whatever the count is — so
7963        // hardcoding the rendered number passed. Both halves are needed: a
7964        // digit that does not occur incidentally, and the assertion tied to the
7965        // phrase it belongs to.
7966        let body = about_body(adoption_state(7_318, false).await).await;
7967        // The count and its phrase are on separate template lines, so compare
7968        // against a whitespace-collapsed copy rather than the raw HTML.
7969        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7970        assert!(
7971            flat.contains("7318 accounts on the atproto network hold"),
7972            "the count did not render in its own sentence: {flat}",
7973        );
7974        assert!(
7975            body.contains("accounts on the atproto network hold"),
7976            "{body}"
7977        );
7978        assert!(
7979            body.contains("2026-08-13"),
7980            "the observation date must render"
7981        );
7982        assert!(
7983            body.contains("lower bound"),
7984            "the non-archival caveat must ride along with the number"
7985        );
7986        assert!(
7987            !body.contains("At least"),
7988            "an untruncated count is exact-ish"
7989        );
7990    }
7991
7992    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
7993    #[tokio::test]
7994    async fn about_adoption_line_is_singular_at_one() {
7995        let body = about_body(adoption_state(1, false).await).await;
7996        assert!(
7997            body.contains("account on the atproto network holds"),
7998            "{body}"
7999        );
8000    }
8001
8002    /// A truncated observation is a floor, and must say so.
8003    #[tokio::test]
8004    async fn about_adoption_line_says_at_least_when_truncated() {
8005        let body = about_body(adoption_state(25_000, true).await).await;
8006        assert!(body.contains("At least"), "{body}");
8007    }
8008
8009    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
8010    #[tokio::test]
8011    async fn about_omits_line_when_enabled_with_no_observation() {
8012        let db = store::init_url("sqlite::memory:").await.unwrap();
8013        let config = Config {
8014            cookie_secret: "test-cookie-secret-000".to_string(),
8015            show_adoption: true,
8016            ..Config::default()
8017        };
8018        let body = about_body(AppState::new(config, db).unwrap()).await;
8019        assert!(!body.contains("atproto network"));
8020    }
8021
8022    // ---- standard.site on the public pages and the subscribe form ----------
8023    //
8024    // `FEATHERREADER_STANDARD_SITE` gates what may be STORED (`add_subscription`
8025    // refuses every `at://` paste with it off), so a page that tells the reader
8026    // to paste a publication URI is advertising a form that will be refused
8027    // unless the flag is on. These pin both halves: with the flag on the pages
8028    // say how; with it off they do not.
8029
8030    /// A state with the standard.site flag chosen, and `did` holding a seat so
8031    /// `/manage` renders for it.
8032    async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
8033        let db = store::init_url("sqlite::memory:").await.unwrap();
8034        store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
8035        let config = Config {
8036            allowed_dids: vec![did.to_string()],
8037            cookie_secret: "test-cookie-secret-000".to_string(),
8038            beta_cap: 3,
8039            standard_site,
8040            ..Config::default()
8041        };
8042        AppState::new(config, db).unwrap()
8043    }
8044
8045    /// `GET path` as `did`, asserted 200, body as a string.
8046    async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
8047        let cookie = session_cookie(&state, did, Some("reader.example"));
8048        let resp = router(state)
8049            .oneshot(
8050                Request::builder()
8051                    .uri(path)
8052                    .header(header::COOKIE, cookie)
8053                    .body(Body::empty())
8054                    .unwrap(),
8055            )
8056            .await
8057            .unwrap();
8058        assert_eq!(resp.status(), StatusCode::OK, "{path}");
8059        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
8060            .await
8061            .unwrap();
8062        String::from_utf8(bytes.to_vec()).unwrap()
8063    }
8064
8065    /// `GET path` signed out, asserted 200, body as a string.
8066    async fn public_body(state: AppState, path: &str) -> String {
8067        let resp = router(state)
8068            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8069            .await
8070            .unwrap();
8071        assert_eq!(resp.status(), StatusCode::OK, "{path}");
8072        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
8073            .await
8074            .unwrap();
8075        String::from_utf8(bytes.to_vec()).unwrap()
8076    }
8077
8078    /// The `<input … id="feed-url" …>` tag of the subscribe form, whole.
8079    fn feed_url_input(body: &str) -> &str {
8080        let start = body
8081            .find("id=\"feed-url\"")
8082            .and_then(|i| body[..i].rfind("<input"))
8083            .expect("the subscribe form's URL input renders");
8084        let end = body[start..].find('>').expect("the input tag closes") + start + 1;
8085        &body[start..end]
8086    }
8087
8088    /// Flag on: the subscribe form says a publication URI is accepted, and shows
8089    /// both spellings the handler takes (DID and handle).
8090    #[tokio::test]
8091    async fn manage_hints_at_publications_when_the_flag_is_on() {
8092        let did = "did:plc:reader";
8093        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
8094        assert!(
8095            body.contains("at://did:plc:…/site.standard.publication/…"),
8096            "the DID form must be shown: {body}"
8097        );
8098        assert!(
8099            body.contains("at://alice.example.com/site.standard.publication/…"),
8100            "the handle form must be shown: {body}"
8101        );
8102    }
8103
8104    /// Flag on: the URL input must not be `type="url"`. A browser validates
8105    /// that type with the WHATWG URL parser, which REJECTS the DID form —
8106    /// `at://did:plc:…/…` fails as an invalid port, the same failure
8107    /// `url::Url::parse` has (see `feed::is_storable_feed_url`) — so the form
8108    /// would refuse to submit the very string the hint asks for.
8109    #[tokio::test]
8110    async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
8111        let did = "did:plc:reader";
8112        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
8113        let input = feed_url_input(&body);
8114        assert!(
8115            input.contains("type=\"text\""),
8116            "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
8117        );
8118        assert!(
8119            input.contains("inputmode=\"url\""),
8120            "the URL keyboard is still wanted: {input}"
8121        );
8122    }
8123
8124    /// Flag on, `type="text"` drops the browser's scheme check, so a pasted
8125    /// `example.com/blog` would reach the handler and come back as "Couldn't
8126    /// find a feed" — wrong, the site likely has one. A `pattern` keeps the
8127    /// browser asking for a scheme while still admitting `at://` (both cases:
8128    /// the handler canonicalises the scheme).
8129    #[tokio::test]
8130    async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
8131        let did = "did:plc:reader";
8132        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
8133        let input = feed_url_input(&body);
8134        assert!(
8135            input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
8136            "the text input must keep a scheme check: {input}"
8137        );
8138    }
8139
8140    /// Flag off: every `at://` paste is refused, so the form must not say
8141    /// publications are accepted — and the input keeps browser URL validation.
8142    #[tokio::test]
8143    async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
8144        let did = "did:plc:reader";
8145        let state = standard_site_state(false, did).await;
8146        assert!(!state.config.standard_site);
8147        let page = signed_in_body(state, "/manage", did).await;
8148        // The `<head>` carries the site's link card, whose one-line description
8149        // names standard.site whatever the flag says — as the landing page does
8150        // with the flag off (a stored publication is polled regardless). What
8151        // must not advertise is the page: everything after `</head>`.
8152        let body = &page[page.find("</head>").expect("a <head>")..];
8153        assert!(
8154            !body.contains("site.standard.publication"),
8155            "a refused form must not be advertised: {body}"
8156        );
8157        // The shared footer links the `/standard-site` feature page on every
8158        // page, flag on or off — that page itself says the instance isn't
8159        // accepting new publication subscriptions — so the check is on the
8160        // page above the footer, where the form and its hints are.
8161        let above_footer = body
8162            .split("<footer")
8163            .next()
8164            .expect("split yields at least one piece");
8165        assert!(
8166            above_footer.contains("id=\"feed-url\""),
8167            "the form must be above the footer: {body}"
8168        );
8169        assert!(
8170            !above_footer.contains("standard.site"),
8171            "a refused form must not be advertised: {body}"
8172        );
8173        assert!(
8174            feed_url_input(body).contains("type=\"url\""),
8175            "with the flag off the input is unchanged"
8176        );
8177    }
8178
8179    /// Flag on: the landing page says publications sit beside feeds AND how to
8180    /// subscribe to one.
8181    #[tokio::test]
8182    async fn landing_describes_publications_and_how_to_subscribe_when_on() {
8183        let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
8184        assert!(body.contains("standard.site"), "{body}");
8185        assert!(
8186            body.contains("at://did:plc:…/site.standard.publication/…"),
8187            "the landing page must show the DID form: {body}"
8188        );
8189        assert!(
8190            body.contains("at://alice.example.com/site.standard.publication/…"),
8191            "the landing page must show the handle form: {body}"
8192        );
8193    }
8194
8195    /// Flag off: the landing page still says what a publication is (a stored
8196    /// one is polled whatever the flag says), but shows no paste instructions
8197    /// and says new ones are not accepted here.
8198    #[tokio::test]
8199    async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
8200        let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
8201        assert!(body.contains("standard.site"), "{body}");
8202        assert!(
8203            !body.contains("at://did:plc:…/site.standard.publication/…"),
8204            "no paste instructions with the flag off: {body}"
8205        );
8206        assert!(
8207            !body.contains("at://alice.example.com/site.standard.publication/…"),
8208            "no paste instructions with the flag off: {body}"
8209        );
8210        assert!(
8211            body.contains("isn't accepting new publication subscriptions"),
8212            "the page must say the form is closed here: {body}"
8213        );
8214    }
8215
8216    /// Flag on: /about has a publications section with both spellings.
8217    #[tokio::test]
8218    async fn about_describes_publications_and_how_to_subscribe_when_on() {
8219        let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
8220        assert!(body.contains("site.standard.publication"), "{body}");
8221        assert!(body.contains("site.standard.document"), "{body}");
8222        assert!(
8223            body.contains("at://did:plc:…/site.standard.publication/…"),
8224            "{body}"
8225        );
8226        assert!(
8227            body.contains("at://alice.example.com/site.standard.publication/…"),
8228            "{body}"
8229        );
8230    }
8231
8232    /// Flag off: /about keeps the description, drops the paste instructions.
8233    #[tokio::test]
8234    async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
8235        let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
8236        assert!(body.contains("site.standard.publication"), "{body}");
8237        assert!(
8238            !body.contains("at://did:plc:…/site.standard.publication/…"),
8239            "no paste instructions with the flag off: {body}"
8240        );
8241        assert!(
8242            !body.contains("at://alice.example.com/site.standard.publication/…"),
8243            "no paste instructions with the flag off: {body}"
8244        );
8245        assert!(
8246            body.contains("isn't accepting new publication subscriptions"),
8247            "{body}"
8248        );
8249    }
8250
8251    // ---- the standard.site feature page (`/standard-site`) -----------------
8252    //
8253    // A public page, like `/about`: what a publication is, what is shown from
8254    // it, how to subscribe (flag-conditional, as on the other public pages),
8255    // and the honest limits. It also carries the "latest releases" call-out.
8256
8257    /// Signed out, with the default config, the page renders.
8258    #[tokio::test]
8259    async fn standard_site_page_renders_signed_out() {
8260        let body = public_body(test_state(&[]).await, "/standard-site").await;
8261        assert!(body.contains("site.standard.publication"), "{body}");
8262        assert!(body.contains("site.standard.document"), "{body}");
8263        assert!(
8264            body.contains("<title>standard.site — FeatherReader</title>"),
8265            "{body}"
8266        );
8267    }
8268
8269    /// Flag on: the page says how to subscribe, in both spellings, and that a
8270    /// handle is resolved to its DID.
8271    #[tokio::test]
8272    async fn standard_site_page_tells_how_to_subscribe_when_on() {
8273        let body = public_body(
8274            standard_site_state(true, "did:plc:x").await,
8275            "/standard-site",
8276        )
8277        .await;
8278        assert!(
8279            body.contains("at://did:plc:…/site.standard.publication/…"),
8280            "the DID form must be shown: {body}"
8281        );
8282        assert!(
8283            body.contains("at://alice.example.com/site.standard.publication/…"),
8284            "the handle form must be shown: {body}"
8285        );
8286        assert!(
8287            body.contains("resolved to its DID"),
8288            "the handle resolution must be stated: {body}"
8289        );
8290        assert!(
8291            !body.contains("isn't accepting new publication subscriptions"),
8292            "{body}"
8293        );
8294    }
8295
8296    /// Flag off: `add_subscription` refuses every `at://` paste, so the page
8297    /// must not tell visitors to paste one — it says new publication
8298    /// subscriptions are not accepted here, and that stored ones are still read.
8299    #[tokio::test]
8300    async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
8301        let state = standard_site_state(false, "did:plc:x").await;
8302        assert!(!state.config.standard_site);
8303        let body = public_body(state, "/standard-site").await;
8304        assert!(body.contains("site.standard.publication"), "{body}");
8305        assert!(
8306            !body.contains("at://did:plc:…/site.standard.publication/…"),
8307            "no paste instructions with the flag off: {body}"
8308        );
8309        assert!(
8310            !body.contains("at://alice.example.com/site.standard.publication/…"),
8311            "no paste instructions with the flag off: {body}"
8312        );
8313        assert!(
8314            body.contains("isn't accepting new publication subscriptions"),
8315            "the page must say the form is closed here: {body}"
8316        );
8317        assert!(
8318            body.contains("already follows are still read"),
8319            "stored publications are polled whatever the flag says: {body}"
8320        );
8321    }
8322
8323    /// The releases call-out links each release's GitHub page and the
8324    /// changelog, on the feature page and on the landing page.
8325    #[tokio::test]
8326    async fn releases_callout_links_the_release_pages() {
8327        for path in ["/standard-site", "/"] {
8328            let body = public_body(test_state(&[]).await, path).await;
8329            for tag in ["v0.4.1", "v0.4.0"] {
8330                let href = format!(
8331                    "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
8332                );
8333                assert!(body.contains(&href), "{path} must link {tag}: {body}");
8334            }
8335            assert!(
8336                body.contains(
8337                    "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
8338                ),
8339                "{path} must link the changelog: {body}"
8340            );
8341        }
8342    }
8343
8344    /// The feature page is reachable from the landing page, from `/about`, and
8345    /// from the shared footer (`/privacy` renders nothing but prose and that
8346    /// footer, so it stands in for every page that includes it).
8347    #[tokio::test]
8348    async fn landing_about_and_footer_link_the_standard_site_page() {
8349        for path in ["/", "/about", "/privacy"] {
8350            let body = public_body(test_state(&[]).await, path).await;
8351            assert!(
8352                body.contains("href=\"/standard-site\""),
8353                "{path} must link the feature page: {body}"
8354            );
8355        }
8356    }
8357
8358    /// `RELEASES` is the one place a release is described, so its shape is
8359    /// pinned: newest first, dates as the CHANGELOG headings spell them, and
8360    /// both derived links pointing where the template promises.
8361    #[test]
8362    fn releases_are_newest_first_and_link_the_tag_and_changelog() {
8363        assert!(!RELEASES.is_empty());
8364        let parse = |v: &str| -> Vec<u32> {
8365            v.split('.')
8366                .map(|p| p.parse::<u32>().expect("a numeric version part"))
8367                .collect()
8368        };
8369        for pair in RELEASES.windows(2) {
8370            assert!(
8371                parse(pair[0].version) > parse(pair[1].version),
8372                "{} must come before {}",
8373                pair[0].version,
8374                pair[1].version
8375            );
8376        }
8377        for r in RELEASES {
8378            assert_eq!(parse(r.version).len(), 3, "{}", r.version);
8379            assert!(
8380                chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
8381                "{} is not YYYY-MM-DD",
8382                r.date
8383            );
8384            assert!(!r.summary.trim().is_empty());
8385            assert!(!r.summary.contains('<'), "the summary is plain text");
8386            assert_eq!(
8387                r.url(),
8388                format!(
8389                    "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
8390                    r.version
8391                )
8392            );
8393        }
8394        // The newest entry is this build's own version, so a release cannot
8395        // ship without adding itself to the call-out.
8396        let latest = &RELEASES[0];
8397        assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
8398        assert_eq!(
8399            latest.changelog_url(),
8400            "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#048--2026-10-08"
8401        );
8402    }
8403
8404    /// Public and static like `/about`, so it is cacheable on the same terms.
8405    #[tokio::test]
8406    async fn standard_site_page_is_publicly_cacheable() {
8407        let resp = router(test_state(&[]).await)
8408            .oneshot(
8409                Request::builder()
8410                    .uri("/standard-site")
8411                    .body(Body::empty())
8412                    .unwrap(),
8413            )
8414            .await
8415            .unwrap();
8416        assert_eq!(resp.status(), StatusCode::OK);
8417        assert_eq!(
8418            resp.headers().get(header::CACHE_CONTROL).unwrap(),
8419            "public, max-age=300"
8420        );
8421    }
8422
8423    #[tokio::test]
8424    async fn cache_control_public_on_about_no_store_on_authed() {
8425        let state = test_state(&["did:plc:admin"]).await;
8426        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
8427        let app = router(state);
8428
8429        // /about → public, cacheable.
8430        let about = app
8431            .clone()
8432            .oneshot(
8433                Request::builder()
8434                    .uri("/about")
8435                    .body(Body::empty())
8436                    .unwrap(),
8437            )
8438            .await
8439            .unwrap();
8440        assert_eq!(
8441            about.headers().get(header::CACHE_CONTROL).unwrap(),
8442            "public, max-age=300"
8443        );
8444        // The security headers are still intact.
8445        // The VALUE, spelled out here rather than compared to the constant —
8446        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
8447        // used to assert only that the header existed, which a policy of
8448        // `default-src *` satisfies.
8449        assert_eq!(
8450            about.headers()["content-security-policy"],
8451            EXPECTED_CSP,
8452            "the CSP is not the policy the router promises"
8453        );
8454        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
8455
8456        // /privacy and /terms are static public pages → public, cacheable.
8457        for path in ["/privacy", "/terms"] {
8458            let resp = app
8459                .clone()
8460                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8461                .await
8462                .unwrap();
8463            assert_eq!(resp.status(), StatusCode::OK);
8464            assert_eq!(
8465                resp.headers().get(header::CACHE_CONTROL).unwrap(),
8466                "public, max-age=300",
8467                "{path} should be publicly cacheable"
8468            );
8469            // Security headers apply to these pages too.
8470            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
8471            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
8472        }
8473
8474        // The bare /login landing → public, cacheable.
8475        let login = app
8476            .clone()
8477            .oneshot(
8478                Request::builder()
8479                    .uri("/login")
8480                    .body(Body::empty())
8481                    .unwrap(),
8482            )
8483            .await
8484            .unwrap();
8485        assert_eq!(
8486            login.headers().get(header::CACHE_CONTROL).unwrap(),
8487            "public, max-age=300"
8488        );
8489
8490        // An authenticated page → no-store.
8491        let home = app
8492            .oneshot(
8493                Request::builder()
8494                    .uri("/")
8495                    .header(header::COOKIE, admin_cookie)
8496                    .body(Body::empty())
8497                    .unwrap(),
8498            )
8499            .await
8500            .unwrap();
8501        assert_eq!(
8502            home.headers().get(header::CACHE_CONTROL).unwrap(),
8503            "no-store"
8504        );
8505    }
8506
8507    // -- link cards (Open Graph) -----------------------------------------------
8508    //
8509    // Bluesky's card service fetches the HTML server-side, runs no JS, and
8510    // resolves nothing relative. Measured before these tags existed:
8511    // `cardyb.bsky.app/v1/extract?url=https://feather-reader.com/` returned
8512    // `{"title":"FeatherReader — read, quietly","description":"","image":""}`.
8513
8514    /// Everything up to `</head>` — the only part a card fetcher reads.
8515    fn head(body: &str) -> &str {
8516        let end = body.find("</head>").expect("a <head>");
8517        &body[..end]
8518    }
8519
8520    /// The `content` of the first `<meta …>` tag carrying `attr` (e.g.
8521    /// `property="og:title"`), or `None` when no tag carries it.
8522    fn meta(head: &str, attr: &str) -> Option<String> {
8523        let tag_start = head.find(attr)?;
8524        let rest = &head[tag_start..];
8525        let tag_end = rest.find('>')?;
8526        let tag = &rest[..tag_end];
8527        let content = tag.find("content=\"")? + "content=\"".len();
8528        let close = tag[content..].find('"')?;
8529        Some(tag[content..content + close].to_string())
8530    }
8531
8532    /// A state whose public origin is production's. The card URLs must be
8533    /// absolute on THAT origin: a relative `/static/…` is what the card
8534    /// fetcher cannot use.
8535    async fn production_origin_state() -> AppState {
8536        let db = store::init_url("sqlite::memory:").await.unwrap();
8537        store::ensure_seed(&db, &["did:plc:admin".to_string()])
8538            .await
8539            .unwrap();
8540        let config = Config {
8541            allowed_dids: vec!["did:plc:admin".to_string()],
8542            cookie_secret: "test-cookie-secret-000".to_string(),
8543            beta_cap: 3,
8544            public_url: "https://feather-reader.com".to_string(),
8545            ..Config::default()
8546        };
8547        AppState::new(config, db).unwrap()
8548    }
8549
8550    /// The landing page and /about each carry a complete card with absolute
8551    /// https URLs, and the two describe different things.
8552    #[tokio::test]
8553    async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
8554        let landing = public_body(production_origin_state().await, "/").await;
8555        let about = public_body(production_origin_state().await, "/about").await;
8556        let (lh, ah) = (head(&landing), head(&about));
8557
8558        assert_eq!(
8559            meta(lh, "property=\"og:title\"").as_deref(),
8560            Some("FeatherReader — read, quietly"),
8561            "{lh}"
8562        );
8563        assert_eq!(
8564            meta(ah, "property=\"og:title\"").as_deref(),
8565            Some("About — FeatherReader"),
8566            "{ah}"
8567        );
8568        for (h, path) in [(lh, "/"), (ah, "/about")] {
8569            let url = format!("https://feather-reader.com{path}");
8570            assert_eq!(
8571                meta(h, "property=\"og:url\"").as_deref(),
8572                Some(url.as_str())
8573            );
8574            assert!(
8575                h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
8576                "{path} must carry a canonical link: {h}"
8577            );
8578            let image = meta(h, "property=\"og:image\"").unwrap_or_default();
8579            assert!(
8580                image.starts_with("https://feather-reader.com/static/"),
8581                "{path}: og:image must be absolute on the public origin, got {image:?}"
8582            );
8583            assert_eq!(
8584                meta(h, "name=\"twitter:card\"").as_deref(),
8585                Some("summary_large_image")
8586            );
8587            assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
8588            assert_eq!(
8589                meta(h, "property=\"og:site_name\"").as_deref(),
8590                Some("FeatherReader")
8591            );
8592            let description = meta(h, "property=\"og:description\"").unwrap_or_default();
8593            assert!(!description.is_empty(), "{path}: og:description is empty");
8594            assert_eq!(
8595                meta(h, "name=\"description\"").as_deref(),
8596                Some(description.as_str()),
8597                "{path}: the meta description and og:description must agree"
8598            );
8599        }
8600        assert_ne!(
8601            meta(lh, "property=\"og:description\""),
8602            meta(ah, "property=\"og:description\""),
8603            "the landing page and /about must not share a description"
8604        );
8605    }
8606
8607    /// The origin comes from `FEATHERREADER_PUBLIC_URL`, not a constant.
8608    #[tokio::test]
8609    async fn card_urls_follow_the_configured_public_url() {
8610        let db = store::init_url("sqlite::memory:").await.unwrap();
8611        store::ensure_seed(&db, &[]).await.unwrap();
8612        let config = Config {
8613            cookie_secret: "test-cookie-secret-000".to_string(),
8614            public_url: "https://reader.example.org".to_string(),
8615            ..Config::default()
8616        };
8617        let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
8618        let h = head(&body);
8619        assert_eq!(
8620            meta(h, "property=\"og:url\"").as_deref(),
8621            Some("https://reader.example.org/privacy")
8622        );
8623        assert_eq!(
8624            meta(h, "property=\"og:image\"").as_deref(),
8625            Some("https://reader.example.org/static/social-card.png")
8626        );
8627    }
8628
8629    /// Every signed-out page describes itself: no two share a description,
8630    /// and each `og:url` is its own path.
8631    #[tokio::test]
8632    async fn public_pages_each_carry_their_own_description() {
8633        let paths = [
8634            "/",
8635            "/about",
8636            "/privacy",
8637            "/terms",
8638            "/stats",
8639            "/standard-site",
8640            "/login",
8641            "/beta/redeem",
8642        ];
8643        let mut seen = std::collections::HashSet::new();
8644        for path in paths {
8645            let body = public_body(production_origin_state().await, path).await;
8646            let h = head(&body);
8647            let description = meta(h, "name=\"description\"").unwrap_or_default();
8648            assert!(!description.is_empty(), "{path} has no description: {h}");
8649            assert!(
8650                seen.insert(description.clone()),
8651                "{path} repeats another page's description: {description:?}"
8652            );
8653            assert_eq!(
8654                meta(h, "property=\"og:url\"").as_deref(),
8655                Some(format!("https://feather-reader.com{path}").as_str()),
8656                "{path}"
8657            );
8658            assert!(
8659                !h.contains("name=\"robots\""),
8660                "{path} is public and must not be noindex: {h}"
8661            );
8662        }
8663    }
8664
8665    /// The share image is served from `/static` as a PNG of the dimensions the
8666    /// tags promise, well under the 1 MB card fetchers tolerate, and cacheable.
8667    #[tokio::test]
8668    async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8669        let landing = public_body(production_origin_state().await, "/").await;
8670        let h = head(&landing);
8671        let image = meta(h, "property=\"og:image\"").unwrap();
8672        let path = image.strip_prefix("https://feather-reader.com").unwrap();
8673        let width: u32 = meta(h, "property=\"og:image:width\"")
8674            .unwrap()
8675            .parse()
8676            .unwrap();
8677        let height: u32 = meta(h, "property=\"og:image:height\"")
8678            .unwrap()
8679            .parse()
8680            .unwrap();
8681        assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8682        assert_eq!(
8683            meta(h, "property=\"og:image:type\"").as_deref(),
8684            Some("image/png")
8685        );
8686        assert!(
8687            !meta(h, "property=\"og:image:alt\"")
8688                .unwrap_or_default()
8689                .is_empty(),
8690            "the image needs alt text"
8691        );
8692
8693        let resp = router(production_origin_state().await)
8694            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8695            .await
8696            .unwrap();
8697        assert_eq!(resp.status(), StatusCode::OK, "{path}");
8698        assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8699        assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8700        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8701            .await
8702            .expect("the image is under 1 MB");
8703        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8704        // IHDR: width and height, big-endian, at offsets 16 and 20.
8705        let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8706        assert_eq!(
8707            (be(16), be(20)),
8708            (width, height),
8709            "the PNG's own dimensions must match the tags"
8710        );
8711    }
8712
8713    /// A page that renders a session's private view carries the site's generic
8714    /// card — nothing from the view reaches `<head>` — and is `noindex`.
8715    #[tokio::test]
8716    async fn private_pages_keep_user_data_out_of_the_card() {
8717        for path in ["/", "/manage"] {
8718            let state = production_origin_state().await;
8719            let body = signed_in_body(state, path, "did:plc:admin").await;
8720            let h = head(&body);
8721            assert!(
8722                h.contains("<meta name=\"robots\" content=\"noindex\""),
8723                "{path}: a private view must be noindex: {h}"
8724            );
8725            assert_eq!(
8726                meta(h, "property=\"og:title\"").as_deref(),
8727                Some("FeatherReader — read, quietly"),
8728                "{path}: the card of a private view is the site's generic one"
8729            );
8730            assert_eq!(
8731                meta(h, "property=\"og:url\"").as_deref(),
8732                Some("https://feather-reader.com/"),
8733                "{path}: og:url of a private view is the front door, not the private path"
8734            );
8735            for private in ["reader.example", "did:plc:admin"] {
8736                assert!(
8737                    !h.contains(private),
8738                    "{path}: {private:?} must not reach <head>: {h}"
8739                );
8740            }
8741        }
8742    }
8743
8744    #[tokio::test]
8745    async fn beta_redeem_page_renders() {
8746        let state = test_state(&[]).await;
8747        let app = router(state);
8748        let resp = app
8749            .oneshot(
8750                Request::builder()
8751                    .uri("/beta/redeem")
8752                    .body(Body::empty())
8753                    .unwrap(),
8754            )
8755            .await
8756            .unwrap();
8757        assert_eq!(resp.status(), StatusCode::OK);
8758        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8759            .await
8760            .unwrap();
8761        let html = String::from_utf8(bytes.to_vec()).unwrap();
8762        assert!(html.contains("Invite code"));
8763        assert!(html.contains("/beta/redeem"));
8764    }
8765
8766    #[tokio::test]
8767    async fn rate_limit_returns_429_after_burst() {
8768        // Configure a trusted proxy header so the limiter keys on the forwarded
8769        // IP (the oneshot harness sets no ConnectInfo socket peer).
8770        let db = store::init_url("sqlite::memory:").await.unwrap();
8771        store::ensure_seed(&db, &[]).await.unwrap();
8772        let config = Config {
8773            cookie_secret: "test-cookie-secret-000".to_string(),
8774            beta_cap: 3,
8775            trusted_ip_header: Some("cf-connecting-ip".to_string()),
8776            ..Config::default()
8777        };
8778        let state = AppState::new(config, db).unwrap();
8779        let app = router(state);
8780        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
8781        // handler itself returns 200 (re-render) on a bad code; the limiter is
8782        // what eventually yields 429.
8783        let mut saw_429 = false;
8784        for _ in 0..(RATE_BURST as usize + 5) {
8785            let resp = app
8786                .clone()
8787                .oneshot(
8788                    Request::builder()
8789                        .method("POST")
8790                        .uri("/beta/redeem")
8791                        .header("content-type", "application/x-www-form-urlencoded")
8792                        .header("cf-connecting-ip", "203.0.113.200")
8793                        .body(Body::from("code=FEATHER-NOPENOPE"))
8794                        .unwrap(),
8795                )
8796                .await
8797                .unwrap();
8798            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8799                saw_429 = true;
8800                break;
8801            }
8802        }
8803        assert!(saw_429, "expected a 429 after exhausting the burst");
8804    }
8805
8806    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
8807    /// the middleware's comment cites this test as proof of.
8808    ///
8809    /// The previous version rotated the forged header and asserted that no
8810    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
8811    /// burst, so that assertion held whether the header was trusted or
8812    /// ignored — it passed in the vulnerable configuration too. And with no
8813    /// socket peer the limiter fails open, so nothing could have been keyed on
8814    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
8815    /// a DIFFERENT forged header, and the last must be 429: they all landed in
8816    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
8817    /// per request and never trips — which is exactly what the mutation does.
8818    #[tokio::test]
8819    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8820        let state = test_state(&[]).await;
8821        assert!(
8822            state.config.trusted_ip_header.is_none(),
8823            "no proxy header is trusted here"
8824        );
8825        let app = router(state);
8826        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8827        let mut saw_429 = false;
8828        for i in 0..(RATE_BURST as usize + 5) {
8829            let forged = format!("10.9.8.{}", i % 250);
8830            let resp = app
8831                .clone()
8832                .oneshot(
8833                    Request::builder()
8834                        .method("POST")
8835                        .uri("/beta/redeem")
8836                        .header("content-type", "application/x-www-form-urlencoded")
8837                        .header("x-forwarded-for", forged)
8838                        .extension(axum::extract::ConnectInfo(peer))
8839                        .body(Body::from("code=FEATHER-NOPENOPE"))
8840                        .unwrap(),
8841                )
8842                .await
8843                .unwrap();
8844            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8845                saw_429 = true;
8846                break;
8847            }
8848        }
8849        assert!(
8850            saw_429,
8851            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8852        );
8853    }
8854
8855    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
8856
8857    /// **A private feed is refused BEFORE it is fetched.** The add path's
8858    /// privacy gate had no test at all — `private_feeds_are_classified_private_
8859    /// across_providers` says "the add + OPML paths both gate on this
8860    /// classifier" and nothing checked either. The gate exists so a
8861    /// token-bearing URL never reaches the network; the assertion that
8862    /// matters is the server's hit count: zero.
8863    #[tokio::test]
8864    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8865        let did = "did:plc:privateadder";
8866        let state = test_state_with_caps(did, 0, 0).await;
8867        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8868        let port: u16 = base
8869            .trim_end_matches('/')
8870            .rsplit(':')
8871            .next()
8872            .unwrap()
8873            .parse()
8874            .unwrap();
8875        crate::net::test_host_override(
8876            "private-add.test",
8877            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8878        );
8879        let cookie = session_cookie(&state, did, None);
8880        let resp = router(state.clone())
8881            .oneshot(
8882                Request::builder()
8883                    .method("POST")
8884                    .uri("/subscriptions")
8885                    .header(header::COOKIE, cookie)
8886                    .header("content-type", "application/x-www-form-urlencoded")
8887                    .body(Body::from(format!(
8888                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8889                    )))
8890                    .unwrap(),
8891            )
8892            .await
8893            .unwrap();
8894        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8895        let loc = resp
8896            .headers()
8897            .get(header::LOCATION)
8898            .unwrap()
8899            .to_str()
8900            .unwrap();
8901        assert!(loc.contains("Private"), "not refused as private: {loc}");
8902        assert_eq!(
8903            hits.load(std::sync::atomic::Ordering::SeqCst),
8904            0,
8905            "the private feed was FETCHED before being refused"
8906        );
8907        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8908    }
8909
8910    /// **OPML import skips a private feed without storing or publishing it.**
8911    /// The import path does not fetch, so "never fetched" is not the signal
8912    /// here; "never stored, never written to the PDS" is. The batch write's
8913    /// bytes are captured and must not carry the URL.
8914    #[tokio::test]
8915    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8916        let did = "did:plc:renamer4";
8917        let (sidecar, bodies) = spawn_logging_sidecar().await;
8918        let state = test_state_with_sidecar(&[did], &sidecar).await;
8919        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8920        let opml = format!(
8921            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8922             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8923             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8924             </body></opml>"
8925        );
8926        let (ct, body) = opml_multipart(opml.as_bytes());
8927        let cookie = session_cookie(&state, did, None);
8928        let resp = router(state.clone())
8929            .oneshot(
8930                Request::builder()
8931                    .method("POST")
8932                    .uri("/opml")
8933                    .header(header::COOKIE, cookie)
8934                    .header("content-type", ct)
8935                    .body(Body::from(body))
8936                    .unwrap(),
8937            )
8938            .await
8939            .unwrap();
8940        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8941        let loc = resp
8942            .headers()
8943            .get(header::LOCATION)
8944            .unwrap()
8945            .to_str()
8946            .unwrap();
8947        assert!(
8948            loc.contains("skipped%20as%20private"),
8949            "not reported as skipped: {loc}"
8950        );
8951        assert!(store::get_feed_by_url(&state.db, tokened)
8952            .await
8953            .unwrap()
8954            .is_none());
8955        let sent = bodies.lock().unwrap().join("\n");
8956        assert!(
8957            sent.contains("public.example"),
8958            "the public feed was not written: {sent}"
8959        );
8960        assert!(
8961            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8962            "the secret was PUBLISHED to the PDS: {sent}"
8963        );
8964    }
8965
8966    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
8967    /// tested; the GET form starts the same handshake and had no test, so
8968    /// deleting its gate left the suite green.
8969    #[tokio::test]
8970    async fn get_login_without_a_seat_is_refused() {
8971        let state = test_state(&[]).await;
8972        let resp = router(state)
8973            .oneshot(
8974                Request::builder()
8975                    .method("GET")
8976                    .uri("/login?handle=alice.bsky.social")
8977                    .body(Body::empty())
8978                    .unwrap(),
8979            )
8980            .await
8981            .unwrap();
8982        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8983        assert_eq!(
8984            resp.headers().get(header::LOCATION).unwrap(),
8985            "/beta/redeem"
8986        );
8987    }
8988
8989    /// A sidecar fake that answers every request `ok` and records the PATH of
8990    /// each in arrival order, plus every body — for asserting what was sent,
8991    /// and in what order.
8992    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8993        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8994        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8995        let addr = listener.local_addr().unwrap();
8996        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8997        let sink = log.clone();
8998        tokio::spawn(async move {
8999            loop {
9000                let Ok((mut sock, _)) = listener.accept().await else {
9001                    break;
9002                };
9003                let mut raw: Vec<u8> = Vec::new();
9004                let mut chunk = [0u8; 4096];
9005                let text = loop {
9006                    let Ok(n) = sock.read(&mut chunk).await else {
9007                        break String::new();
9008                    };
9009                    if n == 0 {
9010                        break String::from_utf8_lossy(&raw).to_string();
9011                    }
9012                    raw.extend_from_slice(&chunk[..n]);
9013                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9014                        continue;
9015                    };
9016                    let (head, body) = raw.split_at(split + 4);
9017                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9018                        let (k, v) = l.split_once(':')?;
9019                        k.eq_ignore_ascii_case("content-length")
9020                            .then(|| v.trim().parse::<usize>().ok())?
9021                    });
9022                    if want.is_none_or(|w| body.len() >= w) {
9023                        break String::from_utf8_lossy(&raw).to_string();
9024                    }
9025                };
9026                let path = text
9027                    .lines()
9028                    .next()
9029                    .and_then(|l| l.split_whitespace().nth(1))
9030                    .unwrap_or("")
9031                    .to_string();
9032                let body_text = text
9033                    .split_once("\r\n\r\n")
9034                    .map(|(_, b)| b)
9035                    .unwrap_or("")
9036                    .to_string();
9037                sink.lock().unwrap().push(format!("{path} {body_text}"));
9038                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();
9039                let resp = format!(
9040                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9041                    body.len(),
9042                    body
9043                );
9044                let _ = sock.write_all(resp.as_bytes()).await;
9045                let _ = sock.flush().await;
9046            }
9047        });
9048        (format!("http://{addr}"), log)
9049    }
9050
9051    /// **The sign-out flush settles a split flush's landed prefix too.** It is
9052    /// `readstate::flush_did` under a timeout, so it shares the fix — pinned
9053    /// here so a sign-out path that grew its own flush would not silently lose
9054    /// it. Call 1 of 2 lands, call 2's connection drops: the landed cursors are
9055    /// created and clean, the rest stay dirty to park until the next sign-in.
9056    #[tokio::test]
9057    async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
9058        use crate::readstate::tests as rs;
9059        for backend in [
9060            crate::metrics::Backend::Sidecar,
9061            crate::metrics::Backend::Rust,
9062        ] {
9063            let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
9064            let state = rs::state_on(backend, &fake).await;
9065            for i in 0..250 {
9066                rs::mark_read(&state, i, "1").await;
9067            }
9068            fake.lock().unwrap().drop_call = Some(2);
9069
9070            flush_before_revoke(&state, rs::DID).await;
9071
9072            let order = rs::send_order(250);
9073            let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
9074            for &i in landed {
9075                let c = rs::cursor(&state, i).await;
9076                assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
9077            }
9078            for &i in rest {
9079                let c = rs::cursor(&state, i).await;
9080                assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
9081            }
9082            assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
9083        }
9084    }
9085
9086    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
9087    /// route.** The previous version of this test called
9088    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
9089    /// flush attempt; its doc claimed deleting the call from the handler
9090    /// "drops that to zero", which was false — the handler was never run.
9091    /// Deleting the call left the suite green: #117 regressing in full, with
9092    /// the test named after it still passing. Now `POST /logout` is driven and
9093    /// the sidecar's log must show a repo write BEFORE the revoke.
9094    #[tokio::test]
9095    async fn signing_out_flushes_before_it_revokes_through_the_route() {
9096        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9097        let (sidecar, log) = spawn_logging_sidecar().await;
9098        let state = test_state_with_sidecar(&[did], &sidecar).await;
9099        crate::store::upsert_cursor(
9100            &state.db,
9101            &crate::store::ReadCursor {
9102                did: did.to_string(),
9103                feed_url: "https://example.com/feed.xml".into(),
9104                read_through: None,
9105                read_ids: "[\"1\"]".into(),
9106                unread_ids: "[]".into(),
9107                dirty: true,
9108                pds_created: false,
9109                updated_at: "2026-09-13T21:22:40Z".into(),
9110            },
9111        )
9112        .await
9113        .unwrap();
9114        let cookie = session_cookie(&state, did, None);
9115        let resp = router(state.clone())
9116            .oneshot(
9117                Request::builder()
9118                    .method("POST")
9119                    .uri("/logout")
9120                    .header(header::COOKIE, cookie)
9121                    .body(Body::empty())
9122                    .unwrap(),
9123            )
9124            .await
9125            .unwrap();
9126        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9127
9128        let entries = log.lock().unwrap().clone();
9129        let flush = entries
9130            .iter()
9131            .position(|e| e.starts_with("/internal/repo "));
9132        let revoke = entries
9133            .iter()
9134            .position(|e| e.starts_with("/internal/revoke "));
9135        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
9136        assert!(
9137            flush.is_some(),
9138            "sign-out did not attempt a flush before revoking: {entries:?}"
9139        );
9140        assert!(
9141            flush < revoke,
9142            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
9143        );
9144    }
9145
9146    /// The policy, as a literal: the backstop the router calls "neutralises any
9147    /// XSS that slips past sanitization". `script-src 'self'` and no
9148    /// `'unsafe-inline'` on it are the two clauses that make it one.
9149    const EXPECTED_CSP: &str = "default-src 'self'; \
9150     script-src 'self'; \
9151     style-src 'self' 'unsafe-inline'; \
9152     img-src 'self' https: data:; \
9153     font-src 'self'; \
9154     connect-src 'self'; \
9155     form-action 'self'; \
9156     base-uri 'self'; \
9157     frame-ancestors 'none'; \
9158     object-src 'none'";
9159
9160    /// Build a `multipart/form-data` body carrying a single `file` field whose
9161    /// contents are `payload`, returning `(content_type, body_bytes)`.
9162    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
9163        let boundary = "----featherreadertestboundary";
9164        let mut body = Vec::new();
9165        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
9166        body.extend_from_slice(
9167            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
9168        );
9169        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
9170        body.extend_from_slice(payload);
9171        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
9172        (format!("multipart/form-data; boundary={boundary}"), body)
9173    }
9174
9175    #[tokio::test]
9176    async fn opml_import_oversize_upload_returns_413() {
9177        let state = test_state(&["did:plc:admin"]).await;
9178        let cookie = session_cookie(&state, "did:plc:admin", None);
9179        let app = router(state);
9180
9181        // A payload comfortably above the route cap.
9182        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
9183        let (content_type, body) = opml_multipart(&payload);
9184
9185        let resp = app
9186            .oneshot(
9187                Request::builder()
9188                    .method("POST")
9189                    .uri("/opml")
9190                    .header("content-type", content_type)
9191                    .header(header::COOKIE, cookie)
9192                    .body(Body::from(body))
9193                    .unwrap(),
9194            )
9195            .await
9196            .unwrap();
9197        assert_eq!(
9198            resp.status(),
9199            StatusCode::PAYLOAD_TOO_LARGE,
9200            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
9201        );
9202    }
9203
9204    /// **The route's own cap is what refuses this, not the framework's.**
9205    ///
9206    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
9207    /// the route's layer was a no-op — deleting it left every test green, and
9208    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
9209    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
9210    /// sits BETWEEN the two: over ours, under the framework's. Only the
9211    /// route's layer can refuse it — remove the layer and this payload is
9212    /// accepted, which is also what demonstrates the framework's default is
9213    /// the larger of the two.
9214    #[tokio::test]
9215    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
9216        let state = test_state(&["did:plc:admin"]).await;
9217        let cookie = session_cookie(&state, "did:plc:admin", None);
9218        let app = router(state);
9219
9220        // Between the two ceilings: the framework would accept this.
9221        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
9222        let (content_type, body) = opml_multipart(&payload);
9223
9224        let resp = app
9225            .oneshot(
9226                Request::builder()
9227                    .method("POST")
9228                    .uri("/opml")
9229                    .header("content-type", content_type)
9230                    .header(header::COOKIE, cookie)
9231                    .body(Body::from(body))
9232                    .unwrap(),
9233            )
9234            .await
9235            .unwrap();
9236        assert_eq!(
9237            resp.status(),
9238            StatusCode::PAYLOAD_TOO_LARGE,
9239            "a payload over the route's cap but under the framework's was accepted — \
9240             the route's own DefaultBodyLimit layer is not doing anything"
9241        );
9242    }
9243
9244    #[tokio::test]
9245    async fn opml_import_under_limit_upload_is_accepted() {
9246        let state = test_state(&["did:plc:admin"]).await;
9247        let cookie = session_cookie(&state, "did:plc:admin", None);
9248        let db = state.db.clone();
9249        let app = router(state);
9250
9251        // A small, valid OPML well under the cap: must be accepted (the handler
9252        // redirects to `/` or a flash), i.e. never 413.
9253        let opml = br#"<?xml version="1.0"?>
9254<opml version="2.0"><body>
9255  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
9256</body></opml>"#;
9257        let (content_type, body) = opml_multipart(opml);
9258
9259        let resp = app
9260            .oneshot(
9261                Request::builder()
9262                    .method("POST")
9263                    .uri("/opml")
9264                    .header("content-type", content_type)
9265                    .header(header::COOKIE, cookie)
9266                    .body(Body::from(body))
9267                    .unwrap(),
9268            )
9269            .await
9270            .unwrap();
9271        // **Assert it was ACCEPTED, not merely that it was not a 413.**
9272        //
9273        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
9274        // 500 satisfies — so making `import_opml` fail unconditionally left this
9275        // green. Three other OPML tests caught that mutation; the one whose name
9276        // promises to cover the under-cap case did not.
9277        assert_eq!(
9278            resp.status(),
9279            StatusCode::SEE_OTHER,
9280            "an under-cap OPML upload was not accepted (status {})",
9281            resp.status(),
9282        );
9283        // **303 alone is not acceptance.** `import_opml` redirects on several
9284        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
9285        // by a cap — so an import that stored nothing satisfied the status check.
9286        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
9287            .bind("https://example.com/feed.xml")
9288            .fetch_one(&db)
9289            .await
9290            .unwrap();
9291        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
9292        let location = resp
9293            .headers()
9294            .get(header::LOCATION)
9295            .and_then(|v| v.to_str().ok())
9296            .unwrap_or_default()
9297            .to_string();
9298        assert!(
9299            !location.starts_with("/login"),
9300            "the import bounced to login instead of being accepted: {location}",
9301        );
9302    }
9303
9304    #[tokio::test]
9305    async fn opml_import_logged_out_redirects_to_login() {
9306        // Logged-out callers are redirected before the body is consumed; assert
9307        // the auth short-circuit rather than a body-cap rejection.
9308        let state = test_state(&["did:plc:admin"]).await;
9309        let app = router(state);
9310
9311        let opml = b"<opml version=\"2.0\"><body></body></opml>";
9312        let (content_type, body) = opml_multipart(opml);
9313
9314        let resp = app
9315            .oneshot(
9316                Request::builder()
9317                    .method("POST")
9318                    .uri("/opml")
9319                    .header("content-type", content_type)
9320                    .body(Body::from(body))
9321                    .unwrap(),
9322            )
9323            .await
9324            .unwrap();
9325        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9326        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
9327    }
9328
9329    // -- delete-my-data (POST /account/delete) --------------------------------
9330
9331    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
9332    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
9333    /// channel) the DID it was asked to revoke. Enough to prove the delete
9334    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
9335    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
9336        use tokio::io::{AsyncReadExt, AsyncWriteExt};
9337        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9338        let addr = listener.local_addr().unwrap();
9339        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
9340        tokio::spawn(async move {
9341            let (mut sock, _) = listener.accept().await.unwrap();
9342            let mut buf = vec![0u8; 4096];
9343            let n = sock.read(&mut buf).await.unwrap();
9344            let req = String::from_utf8_lossy(&buf[..n]).to_string();
9345            // Pull the DID out of the JSON body (last line of the request).
9346            let did = req
9347                .split("\r\n\r\n")
9348                .nth(1)
9349                .and_then(|body| {
9350                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
9351                    v.get("did")?.as_str().map(str::to_string)
9352                })
9353                .unwrap_or_default();
9354            let is_revoke = req.starts_with("POST /internal/revoke");
9355            let body = serde_json::json!({
9356                "ok": true, "did": did, "revoked": true, "hadSession": true
9357            })
9358            .to_string();
9359            let resp = format!(
9360                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9361                body.len(),
9362                body
9363            );
9364            sock.write_all(resp.as_bytes()).await.unwrap();
9365            sock.flush().await.unwrap();
9366            let _ = tx.send(if is_revoke { did } else { String::new() });
9367        });
9368        (format!("http://{addr}"), rx)
9369    }
9370
9371    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
9372    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
9373        let defaults = Config::default();
9374        test_state_with_sidecar_and(
9375            allowed,
9376            sidecar_url,
9377            defaults.standard_site,
9378            defaults.max_feeds_global,
9379        )
9380        .await
9381    }
9382
9383    /// [`test_state_with_sidecar`] with the standard.site flag and the global
9384    /// feeds ceiling chosen — the two settings the at:// paths branch on.
9385    async fn test_state_with_sidecar_and(
9386        allowed: &[&str],
9387        sidecar_url: &str,
9388        standard_site: bool,
9389        max_feeds_global: i64,
9390    ) -> AppState {
9391        let db = store::init_url("sqlite::memory:").await.unwrap();
9392        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
9393        store::ensure_seed(&db, &dids).await.unwrap();
9394        let mut config = Config {
9395            allowed_dids: dids,
9396            cookie_secret: "test-cookie-secret-000".to_string(),
9397            beta_cap: 3,
9398            standard_site,
9399            max_feeds_global,
9400            ..Config::default()
9401        };
9402        config.sidecar.public_url = sidecar_url.to_string();
9403        config.sidecar.internal_url = sidecar_url.to_string();
9404        AppState::new(config, db).unwrap()
9405    }
9406
9407    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
9408    /// the sidecar revoke for that DID, and clears the session cookie.
9409    #[tokio::test]
9410    async fn account_delete_purges_rows_and_triggers_revoke() {
9411        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
9412        let did = "did:plc:leaver";
9413        let state = test_state_with_sidecar(&[], &sidecar_url).await;
9414
9415        // Seed the DID with local rows across the per-DID tables.
9416        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
9417            .await
9418            .unwrap();
9419        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
9420        store::mint_code(&state.db, did, 3600).await.unwrap();
9421        assert!(store::has_beta_access(&state.db, did).await.unwrap());
9422
9423        let cookie = session_cookie(&state, did, Some("leaver.example"));
9424        let app = router(state.clone());
9425
9426        let resp = app
9427            .oneshot(
9428                Request::builder()
9429                    .method("POST")
9430                    .uri("/account/delete")
9431                    .header(header::COOKIE, cookie)
9432                    .header("content-type", "application/x-www-form-urlencoded")
9433                    .body(Body::from("confirm=DELETE"))
9434                    .unwrap(),
9435            )
9436            .await
9437            .unwrap();
9438
9439        // Signed out: redirect to /login with the cookie cleared.
9440        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9441        assert!(resp
9442            .headers()
9443            .get(header::LOCATION)
9444            .unwrap()
9445            .to_str()
9446            .unwrap()
9447            .starts_with("/login"));
9448        let set_cookie = resp
9449            .headers()
9450            .get(header::SET_COOKIE)
9451            .unwrap()
9452            .to_str()
9453            .unwrap();
9454        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
9455
9456        // The sidecar revoke was called for exactly this DID.
9457        //
9458        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
9459        // that simply never called the sidecar — hung this test forever instead
9460        // of failing it: a wedged CI job rather than a red one, which is the
9461        // worse of the two signals because nobody reads it as a defect.
9462        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
9463            .await
9464            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
9465            .unwrap();
9466        assert_eq!(
9467            revoked_did, did,
9468            "sidecar revoke must fire for the caller DID"
9469        );
9470
9471        // Local rows are gone.
9472        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
9473        let codes: i64 =
9474            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
9475                .bind(did)
9476                .fetch_one(&state.db)
9477                .await
9478                .unwrap();
9479        assert_eq!(codes, 0);
9480    }
9481
9482    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
9483    /// nothing and bounces back to /manage.
9484    #[tokio::test]
9485    async fn account_delete_without_confirm_is_a_noop() {
9486        let did = "did:plc:staying";
9487        let state = test_state(&[]).await;
9488        store::grant_access(&state.db, did, None, "test", None)
9489            .await
9490            .unwrap();
9491        let cookie = session_cookie(&state, did, None);
9492        let app = router(state.clone());
9493
9494        let resp = app
9495            .oneshot(
9496                Request::builder()
9497                    .method("POST")
9498                    .uri("/account/delete")
9499                    .header(header::COOKIE, cookie)
9500                    .header("content-type", "application/x-www-form-urlencoded")
9501                    .body(Body::from("confirm=nope"))
9502                    .unwrap(),
9503            )
9504            .await
9505            .unwrap();
9506
9507        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9508        assert!(resp
9509            .headers()
9510            .get(header::LOCATION)
9511            .unwrap()
9512            .to_str()
9513            .unwrap()
9514            .starts_with("/manage"));
9515        // Nothing deleted.
9516        assert!(store::has_beta_access(&state.db, did).await.unwrap());
9517    }
9518
9519    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
9520    /// this harness — the default sidecar URL is not served), a DID must STILL
9521    /// be unable to read or mutate an entry in a feed it does not subscribe to.
9522    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
9523    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
9524    /// every cached feed.
9525    #[tokio::test]
9526    async fn pds_outage_does_not_widen_cross_did_access() {
9527        let did_a = "did:plc:aaaa";
9528        let state = test_state(&[]).await;
9529        store::grant_access(&state.db, did_a, None, "test", None)
9530            .await
9531            .unwrap();
9532
9533        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
9534        // lives in feed_b — the one A must never touch during the outage.
9535        let feed_a = store::upsert_feed(
9536            &state.db,
9537            &store::NewFeed {
9538                url: "https://a.example/feed.xml".to_string(),
9539                title: Some("A".to_string()),
9540                ..Default::default()
9541            },
9542        )
9543        .await
9544        .unwrap();
9545        let feed_b = store::upsert_feed(
9546            &state.db,
9547            &store::NewFeed {
9548                url: "https://b.example/feed.xml".to_string(),
9549                title: Some("B".to_string()),
9550                ..Default::default()
9551            },
9552        )
9553        .await
9554        .unwrap();
9555        store::insert_entries(
9556            &state.db,
9557            feed_b,
9558            &[store::NewEntry {
9559                guid: "b-1".to_string(),
9560                url: Some("https://b.example/1".to_string()),
9561                title: Some("B one".to_string()),
9562                published: Some("2026-07-11T00:00:00Z".to_string()),
9563                content_html: Some("<p>secret B body</p>".to_string()),
9564                ..Default::default()
9565            }],
9566            0,
9567        )
9568        .await
9569        .unwrap();
9570        // A subscribes ONLY to feed_a.
9571        store::replace_sub_refs(&state.db, did_a, &[feed_a])
9572            .await
9573            .unwrap();
9574        // Read B's entry id via a transient sub_ref, then drop it so only the
9575        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
9576        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
9577            .await
9578            .unwrap();
9579        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
9580            .await
9581            .unwrap()[0]
9582            .id;
9583        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
9584            .await
9585            .unwrap();
9586
9587        let cookie = session_cookie(&state, did_a, None);
9588        let app = router(state.clone());
9589
9590        // GET /entries/{b} as A → 404 even during the outage.
9591        let get_b = app
9592            .clone()
9593            .oneshot(
9594                Request::builder()
9595                    .method("GET")
9596                    .uri(format!("/entries/{b_entry_id}"))
9597                    .header(header::COOKIE, cookie.clone())
9598                    .body(Body::empty())
9599                    .unwrap(),
9600            )
9601            .await
9602            .unwrap();
9603        assert_eq!(
9604            get_b.status(),
9605            StatusCode::NOT_FOUND,
9606            "A must not read B's entry during a PDS outage"
9607        );
9608
9609        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
9610        let read_b = app
9611            .oneshot(
9612                Request::builder()
9613                    .method("POST")
9614                    .uri(format!("/entries/{b_entry_id}/read"))
9615                    .header(header::COOKIE, cookie)
9616                    .header("content-type", "application/x-www-form-urlencoded")
9617                    .body(Body::from("read=true"))
9618                    .unwrap(),
9619            )
9620            .await
9621            .unwrap();
9622        assert_eq!(
9623            read_b.status(),
9624            StatusCode::NOT_FOUND,
9625            "A must not mark B's entry read during a PDS outage"
9626        );
9627
9628        // The fallback must NOT have widened A's sub_ref to feed_b.
9629        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
9630            .bind(did_a)
9631            .fetch_all(&state.db)
9632            .await
9633            .unwrap();
9634        assert_eq!(
9635            a_feed_ids,
9636            vec![feed_a],
9637            "outage fallback must not add feeds A never subscribed to"
9638        );
9639        // And B's entry has zero read-state (A's attempt did not mutate).
9640        let es_count: i64 =
9641            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9642                .bind(did_a)
9643                .bind(b_entry_id)
9644                .fetch_one(&state.db)
9645                .await
9646                .unwrap();
9647        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9648    }
9649
9650    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
9651    /// nothing. The other arm is counted separately.**
9652    ///
9653    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
9654    /// error would make the metric noisy in exactly the case that is fine.
9655    ///
9656    /// But `revoke_everywhere` has TWO arms, and a review found that counting
9657    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
9658    /// revocation failed. For anyone who logged in before the cutover the sidecar
9659    /// store is the only one holding tokens, so the rust arm correctly says
9660    /// NoSession and the metric said nothing was wrong. Both arms are now
9661    /// recorded, distinguished by the backend column — so this test pins the
9662    /// BACKEND as well as the outcome.
9663    #[tokio::test]
9664    async fn a_logout_with_no_session_counts_as_success() {
9665        let did = "did:plc:aaaa";
9666        let state = test_state(&[]).await;
9667        assert!(
9668            state.oauth.is_some(),
9669            "meaningless without an oauth runtime; the revoke arm would be skipped",
9670        );
9671
9672        revoke_everywhere(&state, did).await;
9673        let rows = state.metrics.snapshot();
9674        let find = |b: crate::metrics::Backend| {
9675            rows.iter()
9676                .find(|r| r.op == "oauth_revoke" && r.backend == b)
9677                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9678        };
9679
9680        // Rust arm: nothing stored for this DID, so NoSession -> ok.
9681        let rust = find(crate::metrics::Backend::Rust);
9682        assert_eq!(
9683            rust.stats.err_count, 0,
9684            "NoSession was counted as a failure; logout is idempotent",
9685        );
9686        assert_eq!(rust.stats.ok_count, 1);
9687
9688        // Sidecar arm: unreachable in a test, so it must be recorded as an
9689        // ERROR under its own backend — not silently dropped, and not folded
9690        // into the rust row.
9691        let sidecar = find(crate::metrics::Backend::Sidecar);
9692        assert_eq!(
9693            sidecar.stats.err_count, 1,
9694            "a failed sidecar revoke was not counted",
9695        );
9696    }
9697
9698    /// **`Failed` must count as an error — the half the metric exists for.**
9699    ///
9700    /// A review found this unpinned: replacing the mapping with
9701    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
9702    /// asserted the `NoSession -> ok` half, so the branch that actually means
9703    /// "the PDS still holds tokens we asked it to drop" was untested.
9704    ///
9705    /// Driven through the same handler, with a session present but the PDS
9706    /// unreachable, so `sign_out_discovering` returns `Failed`.
9707    #[tokio::test]
9708    async fn a_failed_rust_revoke_counts_as_an_error() {
9709        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9710        let state = test_state(&[]).await;
9711        let runtime = state.oauth.as_deref().expect("oauth runtime");
9712        crate::oauth::store::put_session(
9713            &state.db,
9714            &runtime.codec,
9715            &crate::oauth::store::OAuthSession {
9716                sub: did.into(),
9717                issuer: "https://auth.invalid".into(),
9718                aud: "https://pds.invalid".into(),
9719                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9720                    .to_jwk_json()
9721                    .unwrap(),
9722                access_token: "at".into(),
9723                refresh_token: "rt".into(),
9724                token_type: "DPoP".into(),
9725                granted_scope: "atproto".into(),
9726                expires_at: Some(crate::store::now_unix() + 3600),
9727            },
9728        )
9729        .await
9730        .unwrap();
9731
9732        revoke_everywhere(&state, did).await;
9733
9734        let rows = state.metrics.snapshot();
9735        let rust = rows
9736            .iter()
9737            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9738            .expect("no rust oauth_revoke row");
9739        assert_eq!(
9740            rust.stats.err_count, 1,
9741            "an unreachable PDS must count as a revocation failure",
9742        );
9743        assert_eq!(rust.stats.ok_count, 0);
9744    }
9745
9746    /// **The `href` defence is now carried by the TYPE, not by remembering.**
9747    ///
9748    /// `EntryRow.link` used to be a `String`, and the guard was "call
9749    /// `net::safe_link` before assigning it". Deleting that call left all 679
9750    /// tests passing — a live XSS defence with nothing protecting it.
9751    ///
9752    /// `SafeLink` has no `From<String>` and no public member, so the only way to
9753    /// get foreign input into an `href` is `external`, which does the check
9754    /// itself. This test pins that constructor; the *wiring* is now pinned by
9755    /// the compiler, which is the part a test could never hold down.
9756    ///
9757    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
9758    /// so the template renders the row WITHOUT an anchor. Dropping the row
9759    /// instead would make the record unremovable, because the un-save button
9760    /// lives on it.
9761    #[test]
9762    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9763        for hostile in [
9764            "javascript:alert(1)",
9765            "JavaScript:alert(1)",
9766            "  javascript:alert(1)",
9767            "data:text/html;base64,PHNjcmlwdD4=",
9768            "vbscript:msgbox(1)",
9769            "file:///etc/passwd",
9770            // Protocol-relative: inherits the page's scheme, so it is an
9771            // off-site link wearing a same-site costume. Carried over from the
9772            // test this one replaces, which was its only unique input.
9773            "//evil.example/path",
9774        ] {
9775            let link = SafeLink::external(hostile);
9776            assert!(
9777                link.is_empty(),
9778                "{hostile:?} produced a non-empty href: {link}",
9779            );
9780            assert!(
9781                !link.to_string().to_ascii_lowercase().contains("script"),
9782                "{hostile:?} leaked into the rendered link",
9783            );
9784        }
9785
9786        // And the other direction: a check that rejects everything would satisfy
9787        // the loop above while breaking every real saved record.
9788        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9789            let link = SafeLink::external(good);
9790            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9791            assert_eq!(link.to_string(), good);
9792        }
9793    }
9794
9795    /// **The WIRING, not the helper — this is the one that catches the real
9796    /// mistake.**
9797    ///
9798    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
9799    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
9800    /// *calls* it, and a review proved that gap was live twice over: swapping
9801    /// `external` for the app-path constructor, and constructing the tuple
9802    /// directly, both restored the whole `javascript:` hole with every test
9803    /// green. The type now blocks both — `entry` takes an `i64`, and the field
9804    /// lives in another module — but the wiring deserves a test of its own
9805    /// rather than resting on the shape of a signature.
9806    ///
9807    /// Renders the actual row through the actual handler, from a record whose
9808    /// URL is hostile.
9809    #[tokio::test]
9810    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9811        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9812        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9813        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9814        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9815
9816        let resp = router(state)
9817            .oneshot(
9818                Request::builder()
9819                    .uri("/?view=starred")
9820                    .body(Body::empty())
9821                    .unwrap(),
9822            )
9823            .await
9824            .unwrap();
9825        assert_eq!(resp.status(), StatusCode::OK);
9826        let body = String::from_utf8(
9827            axum::body::to_bytes(resp.into_body(), usize::MAX)
9828                .await
9829                .unwrap()
9830                .to_vec(),
9831        )
9832        .unwrap();
9833
9834        // Not in an href, and not as the title either — the title falls back to
9835        // the URL for links we DO render, so both paths must withhold it.
9836        assert!(
9837            !body.to_ascii_lowercase().contains("javascript:"),
9838            "the hostile scheme reached the rendered page",
9839        );
9840        // But the row must survive: the un-save button lives on it, so dropping
9841        // the row would make the record unremovable from here.
9842        assert!(
9843            body.contains("unusable link"),
9844            "the row was dropped instead of rendering without an anchor",
9845        );
9846    }
9847
9848    /// **The reader view's two `href`s, through the actual handler.**
9849    ///
9850    /// The sibling above covers the LIST row. `entry.html` has its own pair of
9851    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
9852    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
9853    ///
9854    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
9855    /// this was never a live hole. But that guard is procedural and sits a long
9856    /// way from the `href`: it holds only as long as every future writer to
9857    /// `entries.url` remembers to go through `feed.rs`. This test does not
9858    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
9859    /// is precisely the state the ingest check cannot speak for.
9860    ///
9861    /// **Both directions, deliberately.** A fix that renders no link at all
9862    /// satisfies every negative assertion here, and would break every real
9863    /// entry. The second half is what makes the first half mean something.
9864    #[tokio::test]
9865    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9866        let did = "did:plc:readerhref";
9867        let state = test_state(&[]).await;
9868        store::grant_access(&state.db, did, None, "test", None)
9869            .await
9870            .unwrap();
9871        let feed = store::upsert_feed(
9872            &state.db,
9873            &store::NewFeed {
9874                url: "https://href.example/feed.xml".to_string(),
9875                title: Some("Href".to_string()),
9876                ..Default::default()
9877            },
9878        )
9879        .await
9880        .unwrap();
9881        // Straight into the column, bypassing `feed.rs` — the whole point.
9882        store::insert_entries(
9883            &state.db,
9884            feed,
9885            &[
9886                store::NewEntry {
9887                    guid: "hostile-1".to_string(),
9888                    url: Some("javascript:alert(1)".to_string()),
9889                    title: Some("Hostile entry".to_string()),
9890                    published: Some("2026-07-11T00:00:00Z".to_string()),
9891                    ..Default::default()
9892                },
9893                store::NewEntry {
9894                    guid: "benign-1".to_string(),
9895                    url: Some("https://href.example/post".to_string()),
9896                    title: Some("Benign entry".to_string()),
9897                    published: Some("2026-07-10T00:00:00Z".to_string()),
9898                    ..Default::default()
9899                },
9900            ],
9901            0,
9902        )
9903        .await
9904        .unwrap();
9905        store::replace_sub_refs(&state.db, did, &[feed])
9906            .await
9907            .unwrap();
9908        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9909        let id_of = |guid: &str| {
9910            rows.iter()
9911                .find(|r| r.guid == guid)
9912                .unwrap_or_else(|| panic!("{guid} was not inserted"))
9913                .id
9914        };
9915
9916        let cookie = session_cookie(&state, did, None);
9917        let app = router(state.clone());
9918
9919        let render = |id: i64| {
9920            let app = app.clone();
9921            let cookie = cookie.clone();
9922            async move {
9923                let resp = app
9924                    .oneshot(
9925                        Request::builder()
9926                            .method("GET")
9927                            .uri(format!("/entries/{id}"))
9928                            .header(header::COOKIE, cookie)
9929                            .body(Body::empty())
9930                            .unwrap(),
9931                    )
9932                    .await
9933                    .unwrap();
9934                assert_eq!(resp.status(), StatusCode::OK);
9935                String::from_utf8(
9936                    axum::body::to_bytes(resp.into_body(), usize::MAX)
9937                        .await
9938                        .unwrap()
9939                        .to_vec(),
9940                )
9941                .unwrap()
9942            }
9943        };
9944
9945        let hostile = render(id_of("hostile-1")).await;
9946        // The reader page for THIS entry actually rendered. Without this the
9947        // three negatives below are satisfied by an empty body.
9948        assert!(
9949            hostile.contains("Hostile entry"),
9950            "the reader did not render the entry: {hostile}",
9951        );
9952        assert!(
9953            !hostile.to_ascii_lowercase().contains("javascript:"),
9954            "the hostile scheme reached the reader page: {hostile}",
9955        );
9956        // Not merely escaped — the template took its no-link branch. Both
9957        // `href`s are gated on the same `Option`, so this covers the byline
9958        // link and the action-bar button together.
9959        assert!(
9960            !hostile.contains("actionbar-open"),
9961            "the action bar rendered an open-original link for a refused URL: {hostile}",
9962        );
9963        assert!(
9964            !hostile.contains("Original \u{2197}"),
9965            "the byline rendered an original link for a refused URL: {hostile}",
9966        );
9967
9968        // The other direction: a legitimate entry still links out, so "render
9969        // nothing" cannot pass as a fix.
9970        let benign = render(id_of("benign-1")).await;
9971        assert!(
9972            benign.contains("Benign entry"),
9973            "the reader did not render the benign entry: {benign}",
9974        );
9975        // BOTH `href`s, counted. The negatives above fire on the action bar
9976        // first, so without this the byline needle `Original \u{2197}` is never
9977        // once observed failing — a misspelled needle would pass forever.
9978        assert_eq!(
9979            benign
9980                .matches(r#"href="https://href.example/post""#)
9981                .count(),
9982            2,
9983            "entry.html has two `href`s for the entry URL — the byline link and \
9984             the action-bar button — and this render produced a different \
9985             number: {benign}",
9986        );
9987        assert!(
9988            benign.contains("actionbar-open"),
9989            "a legitimate entry lost its open-original button: {benign}",
9990        );
9991        assert!(
9992            benign.contains("Original \u{2197}"),
9993            "a legitimate entry lost its byline link: {benign}",
9994        );
9995    }
9996
9997    /// **The reader view's body, through the actual handler (#151).**
9998    ///
9999    /// `entry.html` used to emit `content_html` with `|safe`, trusting that
10000    /// `feed.rs` had run `ammonia` at ingest. This writes hostile markup into
10001    /// the column DIRECTLY — `store::insert_entries` does not sanitize — which
10002    /// is the state the ingest guard cannot speak for, and asserts that none of
10003    /// it is live on the page.
10004    ///
10005    /// **Both directions.** A fix that escapes the whole body (or drops it)
10006    /// passes every negative below and breaks every real article, so the same
10007    /// render must also carry the benign markup through as markup, and an
10008    /// already-clean body must come out byte-identical.
10009    #[tokio::test]
10010    async fn a_hostile_stored_body_renders_inert_on_the_reader_page() {
10011        let did = "did:plc:readerbody";
10012        let state = test_state(&[]).await;
10013        store::grant_access(&state.db, did, None, "test", None)
10014            .await
10015            .unwrap();
10016        let feed = store::upsert_feed(
10017            &state.db,
10018            &store::NewFeed {
10019                url: "https://body.example/feed.xml".to_string(),
10020                title: Some("Body".to_string()),
10021                ..Default::default()
10022            },
10023        )
10024        .await
10025        .unwrap();
10026        // Hostile attributes on tags the sanitizer keeps, and tags it removes.
10027        let hostile_body = concat!(
10028            "<p>kept <b>bold</b></p>",
10029            r#"<img src="x" onerror="alert(2)">"#,
10030            r#"<a href="javascript:alert(3)">click</a>"#,
10031            r#"<p onclick="alert(4)" style="color:red">tail</p>"#,
10032            "<script>alert(1)</script>",
10033            r#"<iframe src="https://evil.example/"></iframe>"#,
10034        );
10035        // Already-clean: the shape ingest stores. It must render unchanged.
10036        let clean_body = concat!(
10037            "<h2>Heading</h2>",
10038            r#"<p>Text with <a href="https://body.example/x" rel="noopener noreferrer">a link</a>, "#,
10039            "<em>emphasis</em> &amp; an entity, &lt;angle&gt; brackets.</p>",
10040            "<pre><code>if a &lt; b &amp;&amp; c { }</code></pre>",
10041            r#"<ul><li>one</li><li>two</li></ul><img src="https://body.example/i.png" alt="i">"#,
10042        );
10043        store::insert_entries(
10044            &state.db,
10045            feed,
10046            &[
10047                store::NewEntry {
10048                    guid: "hostile-body".to_string(),
10049                    title: Some("Hostile body".to_string()),
10050                    published: Some("2026-07-11T00:00:00Z".to_string()),
10051                    content_html: Some(hostile_body.to_string()),
10052                    ..Default::default()
10053                },
10054                store::NewEntry {
10055                    guid: "clean-body".to_string(),
10056                    title: Some("Clean body".to_string()),
10057                    published: Some("2026-07-10T00:00:00Z".to_string()),
10058                    content_html: Some(clean_body.to_string()),
10059                    ..Default::default()
10060                },
10061                // One byte over the bound ingest enforces on what it stores:
10062                // only a writer that skipped `feed.rs` can produce this row.
10063                store::NewEntry {
10064                    guid: "oversize-body".to_string(),
10065                    title: Some("Oversize body".to_string()),
10066                    published: Some("2026-07-09T00:00:00Z".to_string()),
10067                    content_html: Some(format!(
10068                        "<p>{}OVERSIZE</p>",
10069                        "a".repeat(crate::sanitized_html::MAX_RENDER_HTML_BYTES)
10070                    )),
10071                    ..Default::default()
10072                },
10073            ],
10074            0,
10075        )
10076        .await
10077        .unwrap();
10078        store::replace_sub_refs(&state.db, did, &[feed])
10079            .await
10080            .unwrap();
10081        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
10082        let id_of = |guid: &str| {
10083            rows.iter()
10084                .find(|r| r.guid == guid)
10085                .unwrap_or_else(|| panic!("{guid} was not inserted"))
10086                .id
10087        };
10088
10089        let cookie = session_cookie(&state, did, None);
10090        let app = router(state.clone());
10091        // The article body only: `base.html` carries the app's own `<script>`
10092        // tags, which are not what this is about.
10093        let prose = |id: i64| {
10094            let app = app.clone();
10095            let cookie = cookie.clone();
10096            async move {
10097                let resp = app
10098                    .oneshot(
10099                        Request::builder()
10100                            .method("GET")
10101                            .uri(format!("/entries/{id}"))
10102                            .header(header::COOKIE, cookie)
10103                            .body(Body::empty())
10104                            .unwrap(),
10105                    )
10106                    .await
10107                    .unwrap();
10108                assert_eq!(resp.status(), StatusCode::OK);
10109                let page = String::from_utf8(
10110                    axum::body::to_bytes(resp.into_body(), usize::MAX)
10111                        .await
10112                        .unwrap()
10113                        .to_vec(),
10114                )
10115                .unwrap();
10116                let start = page
10117                    .find(r#"<div class="prose">"#)
10118                    .unwrap_or_else(|| panic!("no prose block: {page}"));
10119                let end = page[start..]
10120                    .find("</article>")
10121                    .map(|e| start + e)
10122                    .unwrap_or_else(|| panic!("no </article>: {page}"));
10123                page[start..end].to_string()
10124            }
10125        };
10126
10127        let hostile = prose(id_of("hostile-body")).await;
10128        let lower = hostile.to_ascii_lowercase();
10129        for needle in [
10130            "<script",
10131            "alert(1)",
10132            "onerror",
10133            "javascript:",
10134            "<iframe",
10135            "onclick",
10136        ] {
10137            assert!(
10138                !lower.contains(needle),
10139                "`{needle}` from a stored body reached the reader page: {hostile}",
10140            );
10141        }
10142        // Rendered as markup, not escaped: the benign parts survive as tags.
10143        assert!(
10144            hostile.contains("<p>kept <b>bold</b></p>"),
10145            "the benign markup did not render as markup: {hostile}",
10146        );
10147        assert!(
10148            hostile.contains(r#"<img src="x">"#)
10149                && hostile.contains(r#"<a rel="noopener noreferrer">click</a>"#),
10150            "the sanitizer's output did not reach the page: {hostile}",
10151        );
10152
10153        let clean = prose(id_of("clean-body")).await;
10154        assert!(
10155            clean.contains(clean_body),
10156            "an already-clean stored body did not render byte-identically: {clean}",
10157        );
10158        assert!(
10159            !clean.contains("body-too-large")
10160                && !clean.contains("body-unavailable")
10161                && !clean.contains("body-too-slow"),
10162            "a whole, ordinary body was shown as refused: {clean}",
10163        );
10164
10165        // Over the stored size cap: not given to the sanitizer, and the reader
10166        // is told why rather than shown a silently empty body.
10167        let over = prose(id_of("oversize-body")).await;
10168        assert!(
10169            !over.contains("OVERSIZE") && !over.contains(&"a".repeat(64)),
10170            "an over-size stored body was rendered: {}…",
10171            &over[..over.len().min(300)],
10172        );
10173        assert!(
10174            over.contains("body-too-large"),
10175            "an over-size body did not say so: {over}",
10176        );
10177    }
10178
10179    /// **Each of the renderer's four outcomes has its own branch in
10180    /// `entry.html`.** The handler test above drives the first two through the
10181    /// real path; `Unavailable` cannot be forced there without starving the
10182    /// process-wide permits that every other test shares, and `TooSlow` needs
10183    /// a body that took over 500 ms to clean, so the template is rendered
10184    /// directly for all four. `TooSlow` was missing here, so deleting its note
10185    /// from the template left the page silently empty with every test green
10186    /// (vacuous-test hunt of #273).
10187    #[test]
10188    fn the_reader_template_renders_each_body_outcome() {
10189        let config = Config::default();
10190        let user = CurrentUser {
10191            did: "did:plc:bodyoutcomes".to_string(),
10192            handle: None,
10193            sid: None,
10194        };
10195        let page = |content_html: Option<BodyRender>| {
10196            EntryTemplate {
10197                card: Card::private(&config),
10198                version: VERSION,
10199                repo_url: REPO_URL,
10200                kofi_url: KOFI_URL,
10201                nav: build_nav(&user, "unread", String::new(), vec![], vec![], false),
10202                id: 1,
10203                title: "T".to_string(),
10204                feed_title: "F".to_string(),
10205                author: None,
10206                published: String::new(),
10207                url: SafeLink::external_opt("https://orig.example/a"),
10208                content_html,
10209                read: false,
10210                starred: false,
10211                back_qs: String::new(),
10212                prev_id: None,
10213                next_id: None,
10214                oob: false,
10215            }
10216            .render()
10217            .unwrap()
10218        };
10219
10220        let html = page(Some(BodyRender::Html(
10221            crate::sanitized_html::SanitizedHtml::clean("<p>body <b>here</b></p>"),
10222        )));
10223        assert!(html.contains("<p>body <b>here</b></p>"), "{html}");
10224        assert!(
10225            !html.contains("body-too-large")
10226                && !html.contains("body-unavailable")
10227                && !html.contains("body-too-slow")
10228        );
10229
10230        let too_large = page(Some(BodyRender::TooLarge));
10231        assert!(too_large.contains("body-too-large"), "{too_large}");
10232        assert!(too_large.contains("too large to display"), "{too_large}");
10233        assert!(!too_large.contains("body-unavailable"));
10234
10235        let unavailable = page(Some(BodyRender::Unavailable));
10236        assert!(unavailable.contains("body-unavailable"), "{unavailable}");
10237        assert!(
10238            unavailable.contains("temporarily unavailable"),
10239            "{unavailable}"
10240        );
10241        assert!(!unavailable.contains("body-too-large"));
10242
10243        let too_slow = page(Some(BodyRender::TooSlow));
10244        assert!(too_slow.contains("body-too-slow"), "{too_slow}");
10245        assert!(too_slow.contains("too complex to display"), "{too_slow}");
10246        assert!(
10247            !too_slow.contains("body-too-large") && !too_slow.contains("body-unavailable"),
10248            "{too_slow}"
10249        );
10250
10251        let none = page(None);
10252        assert!(none.contains("has no stored content"), "{none}");
10253    }
10254
10255    /// **The outage fallback must not widen what the caller can READ — and the
10256    /// sibling test above can only see what it WRITES.**
10257    ///
10258    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
10259    /// on `entry_state`: the fallback's side effects. But the fail-open it names
10260    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
10261    /// leaks through the list it *hands back* — the sidebar and the reader render
10262    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
10263    /// perfectly honest and every existing assertion stays green.
10264    ///
10265    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
10266    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
10267    /// exact historical bug the fallback's comment describes — left **all 663
10268    /// tests passing**. Cross-tenant isolation is the one property this project
10269    /// cannot regress quietly, and nothing observed it.
10270    ///
10271    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
10272    /// user, and it deliberately does not look at `sub_ref` at all — that half is
10273    /// already covered above.
10274    #[tokio::test]
10275    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
10276        let did_a = "did:plc:aaaa";
10277        let state = test_state(&[]).await;
10278        store::grant_access(&state.db, did_a, None, "test", None)
10279            .await
10280            .unwrap();
10281
10282        let feed_a = store::upsert_feed(
10283            &state.db,
10284            &store::NewFeed {
10285                url: "https://a.example/feed.xml".to_string(),
10286                title: Some("A".to_string()),
10287                ..Default::default()
10288            },
10289        )
10290        .await
10291        .unwrap();
10292        let _feed_b = store::upsert_feed(
10293            &state.db,
10294            &store::NewFeed {
10295                url: "https://b.example/feed.xml".to_string(),
10296                title: Some("B".to_string()),
10297                ..Default::default()
10298            },
10299        )
10300        .await
10301        .unwrap();
10302        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
10303        // to nobody — exactly the row a whole-cache fallback would hand to A.
10304        store::replace_sub_refs(&state.db, did_a, &[feed_a])
10305            .await
10306            .unwrap();
10307
10308        // No sidecar and no PDS are reachable from a test, so
10309        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
10310        // that, rather than assuming it: if the repo ever starts succeeding here,
10311        // this test would silently stop exercising the fallback at all.
10312        assert!(
10313            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
10314            "this test is only meaningful on the outage path; the repo answered",
10315        );
10316
10317        let resolved = resolve_subscriptions(&state, did_a).await;
10318
10319        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
10320        assert_eq!(
10321            urls,
10322            vec!["https://a.example/feed.xml"],
10323            "the outage fallback must return the caller's OWN subscriptions only; \
10324             any other feed here is cross-tenant read access granted by an outage",
10325        );
10326    }
10327
10328    // -- #203: a big shrink of sub_ref is corroborated before it is applied --
10329
10330    /// A sidecar that answers the `n`th subscription listing with
10331    /// `listings[n]` (the last one repeats), one page each, no cursor, and
10332    /// counts how many listings it served.
10333    /// In a `spawn_listing_sidecar` listing: serve a malformed record there.
10334    const MALFORMED_LISTING: &str = "<malformed>";
10335
10336    async fn spawn_listing_sidecar(
10337        listings: Vec<Vec<String>>,
10338    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10339        use tokio::io::{AsyncReadExt, AsyncWriteExt};
10340        let served = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10341        let counter = served.clone();
10342        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10343        let addr = listener.local_addr().unwrap();
10344        tokio::spawn(async move {
10345            loop {
10346                let Ok((mut sock, _)) = listener.accept().await else {
10347                    break;
10348                };
10349                // Drain head and declared body, so closing does not RST.
10350                let mut req = Vec::new();
10351                let mut buf = [0u8; 4096];
10352                while let Ok(n) = sock.read(&mut buf).await {
10353                    if n == 0 {
10354                        break;
10355                    }
10356                    req.extend_from_slice(&buf[..n]);
10357                    let text = String::from_utf8_lossy(&req).to_string();
10358                    let Some(head_end) = text.find("\r\n\r\n") else {
10359                        continue;
10360                    };
10361                    let len = text[..head_end]
10362                        .lines()
10363                        .find_map(|l| {
10364                            let (k, v) = l.split_once(':')?;
10365                            k.eq_ignore_ascii_case("content-length")
10366                                .then(|| v.trim().parse::<usize>().ok())
10367                                .flatten()
10368                        })
10369                        .unwrap_or(0);
10370                    if req.len() >= head_end + 4 + len {
10371                        break;
10372                    }
10373                }
10374                let req = String::from_utf8_lossy(&req).to_string();
10375                let records = if req.contains("subscription") {
10376                    let i = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10377                    let urls = &listings[i.min(listings.len() - 1)];
10378                    urls.iter()
10379                        .enumerate()
10380                        .map(|(j, url)| {
10381                            // A record no client could have meant: the walk
10382                            // refuses the whole listing (`MalformedRecords`).
10383                            if url == MALFORMED_LISTING {
10384                                return serde_json::json!({ "uri": 7 });
10385                            }
10386                            serde_json::json!({
10387                                "uri": format!("at://did:plc:x/{}/3lab{j}", lexicon::nsid::SUBSCRIPTION),
10388                                "cid": "bafy",
10389                                "value": {
10390                                    "$type": lexicon::nsid::SUBSCRIPTION,
10391                                    "url": url,
10392                                    "createdAt": "2026-01-01T00:00:00Z"
10393                                }
10394                            })
10395                        })
10396                        .collect::<Vec<_>>()
10397                } else {
10398                    Vec::new()
10399                };
10400                let body =
10401                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
10402                let resp = format!(
10403                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10404                    body.len(),
10405                    body
10406                );
10407                let _ = sock.write_all(resp.as_bytes()).await;
10408                let _ = sock.flush().await;
10409            }
10410        });
10411        (format!("http://{addr}"), served)
10412    }
10413
10414    fn shrink_feed_urls(n: usize) -> Vec<String> {
10415        (0..n)
10416            .map(|i| format!("https://shrink{i}.example/feed.xml"))
10417            .collect()
10418    }
10419
10420    /// A state whose sidecar serves `listings`, with `did`'s `sub_ref` seeded
10421    /// to the first `subscribed` of five cached feeds. Returns the state, the
10422    /// listing counter, and the five feed ids.
10423    async fn shrink_state(
10424        did: &str,
10425        subscribed: usize,
10426        listings: Vec<Vec<String>>,
10427    ) -> (
10428        AppState,
10429        std::sync::Arc<std::sync::atomic::AtomicUsize>,
10430        Vec<i64>,
10431    ) {
10432        let (url, served) = spawn_listing_sidecar(listings).await;
10433        let state = test_state_with_sidecar(&[did], &url).await;
10434        let mut ids = Vec::new();
10435        for u in shrink_feed_urls(5) {
10436            ids.push(
10437                store::upsert_feed(
10438                    &state.db,
10439                    &store::NewFeed {
10440                        url: u,
10441                        ..Default::default()
10442                    },
10443                )
10444                .await
10445                .unwrap(),
10446            );
10447        }
10448        store::replace_sub_refs(&state.db, did, &ids[..subscribed])
10449            .await
10450            .unwrap();
10451        (state, served, ids)
10452    }
10453
10454    async fn sub_ref_sorted(state: &AppState, did: &str) -> Vec<i64> {
10455        let mut ids = store::subscribed_feed_ids(&state.db, did).await.unwrap();
10456        ids.sort();
10457        ids
10458    }
10459
10460    /// **A mass drop that does not read the same twice is not applied.** A
10461    /// walk that ended early (a page with a cursor and no records, which a real
10462    /// PDS also sends at the end of a list) is indistinguishable from a
10463    /// complete one at the walk, so the destination asks again before it
10464    /// DELETEs the reader's authorization set.
10465    #[tokio::test]
10466    async fn a_big_shrink_that_does_not_read_the_same_twice_is_not_applied() {
10467        let did = "did:plc:shrinker";
10468        let all = shrink_feed_urls(5);
10469        // Drops exactly SUB_REF_SHRINK_CORROBORATE (5 -> 2), pinning the
10470        // threshold from this side; `a_small_shrink_is_applied_on_one_read`
10471        // pins it from the other.
10472        let (state, served, ids) = shrink_state(did, 5, vec![all[..2].to_vec(), all.clone()]).await;
10473
10474        let (subs, alert) = resolve_subscriptions_noting(&state, did).await;
10475
10476        assert_eq!(
10477            sub_ref_sorted(&state, did).await,
10478            ids,
10479            "a mass drop that did not read the same twice was applied to sub_ref"
10480        );
10481        let alert = alert.expect("a refused shrink must say why the list is stale");
10482        assert!(
10483            alert.contains("did not read the same twice"),
10484            "wrong alert: {alert}"
10485        );
10486        assert_eq!(subs.len(), 5, "the last-known list was not the one shown");
10487        assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 2);
10488    }
10489
10490    /// **A second read that fails says why** (review of #278). A refused
10491    /// shrink whose second listing could not be read at all — the PDS gone,
10492    /// or a malformed record — is not a disagreement, and "did not read the
10493    /// same twice" sent the reader looking for a race. It gets the alert a
10494    /// failed first read gets (#177), and `sub_ref` is still left untouched.
10495    #[tokio::test]
10496    async fn a_big_shrink_whose_second_read_fails_names_the_failure() {
10497        let did = "did:plc:shrinker";
10498        let all = shrink_feed_urls(5);
10499        let (state, served, ids) = shrink_state(
10500            did,
10501            5,
10502            vec![all[..1].to_vec(), vec![MALFORMED_LISTING.to_string()]],
10503        )
10504        .await;
10505
10506        let (subs, alert) = resolve_subscriptions_noting(&state, did).await;
10507
10508        assert_eq!(sub_ref_sorted(&state, did).await, ids);
10509        let alert = alert.expect("a refused shrink must say why the list is stale");
10510        assert!(
10511            alert.contains("1 record(s) in your subscription list could not be read"),
10512            "the failed second read was reported as a disagreement: {alert}"
10513        );
10514        assert_eq!(subs.len(), 5);
10515        assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 2);
10516    }
10517
10518    /// The other side: a mass drop that reads the same twice IS applied —
10519    /// a reader can unsubscribe from many feeds, and a stale `sub_ref` would
10520    /// keep authorizing feeds they left.
10521    #[tokio::test]
10522    async fn a_big_shrink_that_reads_the_same_twice_is_applied() {
10523        let did = "did:plc:shrinker";
10524        let all = shrink_feed_urls(5);
10525        let (state, served, ids) =
10526            shrink_state(did, 5, vec![all[..1].to_vec(), all[..1].to_vec()]).await;
10527
10528        let (_, alert) = resolve_subscriptions_noting(&state, did).await;
10529
10530        assert_eq!(alert, None);
10531        assert_eq!(
10532            sub_ref_sorted(&state, did).await,
10533            vec![ids[0]],
10534            "a corroborated mass drop was not applied"
10535        );
10536        assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 2);
10537    }
10538
10539    /// A drop below the threshold is the ordinary one-at-a-time unsubscribe:
10540    /// applied on one read, without paying a second round trip.
10541    #[tokio::test]
10542    async fn a_small_shrink_is_applied_on_one_read() {
10543        let did = "did:plc:shrinker";
10544        let all = shrink_feed_urls(5);
10545        let (state, served, ids) = shrink_state(did, 5, vec![all[..3].to_vec()]).await;
10546
10547        let (_, alert) = resolve_subscriptions_noting(&state, did).await;
10548
10549        assert_eq!(alert, None);
10550        assert_eq!(sub_ref_sorted(&state, did).await, ids[..3].to_vec());
10551        assert_eq!(
10552            served.load(std::sync::atomic::Ordering::SeqCst),
10553            1,
10554            "a shrink below the threshold paid for a second listing"
10555        );
10556    }
10557
10558    /// The first resolve for a reader has nothing to shrink from.
10559    #[tokio::test]
10560    async fn the_first_resolve_is_never_corroborated() {
10561        let did = "did:plc:shrinker";
10562        let all = shrink_feed_urls(5);
10563        let (state, served, ids) = shrink_state(did, 0, vec![all.clone()]).await;
10564
10565        let (_, alert) = resolve_subscriptions_noting(&state, did).await;
10566
10567        assert_eq!(alert, None);
10568        assert_eq!(sub_ref_sorted(&state, did).await, ids);
10569        assert_eq!(
10570            served.load(std::sync::atomic::Ordering::SeqCst),
10571            1,
10572            "a first-ever resolve paid for a second listing"
10573        );
10574    }
10575
10576    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
10577    /// seeding `did` a beta seat + session-capable state.
10578    async fn test_state_with_caps(
10579        did: &str,
10580        max_subs_per_did: i64,
10581        max_feeds_global: i64,
10582    ) -> AppState {
10583        let db = store::init_url("sqlite::memory:").await.unwrap();
10584        let config = Config {
10585            cookie_secret: "test-cookie-secret-000".to_string(),
10586            beta_cap: 100,
10587            max_subs_per_did,
10588            max_feeds_global,
10589            ..Config::default()
10590        };
10591        store::grant_access(&db, did, None, "test", None)
10592            .await
10593            .unwrap();
10594        AppState::new(config, db).unwrap()
10595    }
10596
10597    /// An OPML document with `n` distinct public feeds.
10598    fn opml_with_feeds(n: usize) -> String {
10599        let mut outlines = String::new();
10600        for i in 0..n {
10601            outlines.push_str(&format!(
10602                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
10603            ));
10604        }
10605        format!(
10606            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
10607        )
10608    }
10609
10610    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
10611    /// distinct new feeds than the shared cache can hold caches only up to the
10612    /// ceiling — the rest are trimmed. (Regression: the import loop previously
10613    /// bypassed `max_feeds_global` entirely.)
10614    #[tokio::test]
10615    async fn opml_import_enforces_global_feeds_ceiling() {
10616        let did = "did:plc:importer";
10617        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
10618        let state = test_state_with_caps(did, 0, 3).await;
10619        let cookie = session_cookie(&state, did, None);
10620        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10621        let app = router(state.clone());
10622
10623        let resp = app
10624            .oneshot(
10625                Request::builder()
10626                    .method("POST")
10627                    .uri("/opml")
10628                    .header(header::COOKIE, cookie)
10629                    .header("content-type", ct)
10630                    .body(Body::from(body))
10631                    .unwrap(),
10632            )
10633            .await
10634            .unwrap();
10635        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10636
10637        let feeds = store::count_feeds(&state.db).await.unwrap();
10638        assert!(
10639            feeds <= 3,
10640            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
10641        );
10642    }
10643
10644    /// POST an OPML of `n` feeds as `did` against a strict fake PDS behind the
10645    /// sidecar, and return the flash it redirected with plus the fake's log.
10646    async fn import_against_strict_pds(
10647        did: &str,
10648        n: usize,
10649        fail_call: Option<usize>,
10650    ) -> (String, crate::atproto::tests::ApplyWritesLog) {
10651        let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
10652        let state = test_state_with_sidecar(&[did], &sidecar).await;
10653        let cookie = session_cookie(&state, did, None);
10654        let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
10655        let resp = router(state)
10656            .oneshot(
10657                Request::builder()
10658                    .method("POST")
10659                    .uri("/opml")
10660                    .header(header::COOKIE, cookie)
10661                    .header("content-type", ct)
10662                    .body(Body::from(body))
10663                    .unwrap(),
10664            )
10665            .await
10666            .unwrap();
10667        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10668        let loc = resp.headers()[header::LOCATION].to_str().unwrap();
10669        let flash = url::Url::parse(&format!("http://x{loc}"))
10670            .unwrap()
10671            .query_pairs()
10672            .find(|(k, _)| k == "flash")
10673            .map(|(_, v)| v.into_owned())
10674            .unwrap_or_default();
10675        (flash, log)
10676    }
10677
10678    /// **An OPML import of more than 200 feeds succeeds** against a PDS that
10679    /// refuses more than 200 writes a call, as the reference PDS does. It used
10680    /// to go out as one `applyWrites` and fail outright, importing nothing.
10681    #[tokio::test]
10682    async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
10683        let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
10684        assert_eq!(flash, "Imported 450 feeds", "{flash}");
10685        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
10686    }
10687
10688    /// **A part-landed import says so.** Chunk 2 of 3 fails: chunk 1's 200
10689    /// feeds are in the reader's repo, and "nothing was imported" — what the
10690    /// handler said for any failure — would be false.
10691    #[tokio::test]
10692    async fn opml_import_that_part_lands_reports_what_landed() {
10693        let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
10694        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
10695        assert!(
10696            flash.contains("200 of 450"),
10697            "the landed count is not reported: {flash}"
10698        );
10699        assert!(
10700            !flash.contains("nothing was imported"),
10701            "200 feeds landed and the reader was told none did: {flash}"
10702        );
10703    }
10704
10705    /// A batch that failed on its first call still reports that nothing was
10706    /// imported — true, since nothing after a failed call is sent.
10707    #[tokio::test]
10708    async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
10709        let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
10710        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
10711        assert!(flash.contains("nothing was imported"), "{flash}");
10712    }
10713
10714    /// **A malformed `at://` on the add path is "not a kind of feed we take",
10715    /// not "private/paid".** The first gate was the privacy classifier, whose
10716    /// at:// arm fails closed as `Private` for anything not a well-formed
10717    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
10718    /// the private-feed flash and a "refused private/paid feed" log line. On
10719    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
10720    /// feed". Storability is decided first for an at:// input, with its own
10721    /// message.
10722    #[tokio::test]
10723    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
10724        let did = "did:plc:typoist";
10725        let state = test_state_with_caps(did, 0, 0).await;
10726        let cookie = session_cookie(&state, did, None);
10727        for input in [
10728            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
10729            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10730        ] {
10731            let resp = router(state.clone())
10732                .oneshot(
10733                    Request::builder()
10734                        .method("POST")
10735                        .uri("/subscriptions")
10736                        .header(header::COOKIE, cookie.clone())
10737                        .header("content-type", "application/x-www-form-urlencoded")
10738                        .body(Body::from(format!("url={input}")))
10739                        .unwrap(),
10740                )
10741                .await
10742                .unwrap();
10743            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10744            let loc = resp
10745                .headers()
10746                .get(header::LOCATION)
10747                .unwrap()
10748                .to_str()
10749                .unwrap();
10750            assert!(
10751                loc.contains("kind%20of%20feed"),
10752                "expected the unsupported-feed flash for {input}, got {loc}"
10753            );
10754            assert!(
10755                !loc.contains("Private"),
10756                "a storability refusal was reported as a privacy one for {input}: {loc}"
10757            );
10758        }
10759        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10760    }
10761
10762    /// **An OPML entry this instance cannot store is counted and reported, not
10763    /// silently dropped.** The storability `continue` incremented nothing,
10764    /// while the privacy branch beside it produced a user-visible label — so
10765    /// an OPML exported from a standard.site-enabled instance imported
10766    /// "successfully" with entries missing and no reason given. The reader is
10767    /// told how many, and why.
10768    #[tokio::test]
10769    async fn opml_import_reports_entries_this_instance_cannot_store() {
10770        let did = "did:plc:renamer4";
10771        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10772        let state = test_state_with_sidecar(&[did], &sidecar).await;
10773        assert!(!state.config.standard_site);
10774        let opml = format!(
10775            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10776             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10777             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10778             </body></opml>"
10779        );
10780        let (ct, body) = opml_multipart(opml.as_bytes());
10781        let cookie = session_cookie(&state, did, None);
10782        let resp = router(state.clone())
10783            .oneshot(
10784                Request::builder()
10785                    .method("POST")
10786                    .uri("/opml")
10787                    .header(header::COOKIE, cookie)
10788                    .header("content-type", ct)
10789                    .body(Body::from(body))
10790                    .unwrap(),
10791            )
10792            .await
10793            .unwrap();
10794        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10795        let loc = resp
10796            .headers()
10797            .get(header::LOCATION)
10798            .unwrap()
10799            .to_str()
10800            .unwrap();
10801        assert!(
10802            loc.contains("Imported%201%20feed"),
10803            "unexpected flash: {loc}"
10804        );
10805        assert!(
10806            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
10807            "the dropped entry was not reported: {loc}"
10808        );
10809        // Reported by count only: the at-URI itself is not echoed back.
10810        assert!(
10811            !loc.contains("site.standard.publication"),
10812            "the URI was echoed: {loc}"
10813        );
10814    }
10815
10816    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
10817    /// cap imports zero new feeds.
10818    #[tokio::test]
10819    async fn opml_import_enforces_per_did_cap() {
10820        let did = "did:plc:capped";
10821        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
10822        let state = test_state_with_caps(did, 2, 0).await;
10823        let existing_a = store::upsert_feed(
10824            &state.db,
10825            &store::NewFeed {
10826                url: "https://have-a.example/feed.xml".to_string(),
10827                ..Default::default()
10828            },
10829        )
10830        .await
10831        .unwrap();
10832        let existing_b = store::upsert_feed(
10833            &state.db,
10834            &store::NewFeed {
10835                url: "https://have-b.example/feed.xml".to_string(),
10836                ..Default::default()
10837            },
10838        )
10839        .await
10840        .unwrap();
10841        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
10842            .await
10843            .unwrap();
10844        let before = store::count_feeds(&state.db).await.unwrap();
10845
10846        let cookie = session_cookie(&state, did, None);
10847        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10848        let app = router(state.clone());
10849        let resp = app
10850            .oneshot(
10851                Request::builder()
10852                    .method("POST")
10853                    .uri("/opml")
10854                    .header(header::COOKIE, cookie)
10855                    .header("content-type", ct)
10856                    .body(Body::from(body))
10857                    .unwrap(),
10858            )
10859            .await
10860            .unwrap();
10861        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10862        // Headroom was 0 → no new feeds imported into the shared cache.
10863        let after = store::count_feeds(&state.db).await.unwrap();
10864        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
10865    }
10866
10867    /// Single-add per-DID cap: a DID at its subscription cap is refused before
10868    /// any fetch, with the limit flash.
10869    #[tokio::test]
10870    async fn single_add_enforces_per_did_cap() {
10871        let did = "did:plc:subcapped";
10872        let state = test_state_with_caps(did, 1, 0).await;
10873        let f = store::upsert_feed(
10874            &state.db,
10875            &store::NewFeed {
10876                url: "https://have.example/feed.xml".to_string(),
10877                ..Default::default()
10878            },
10879        )
10880        .await
10881        .unwrap();
10882        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
10883        let cookie = session_cookie(&state, did, None);
10884        let app = router(state.clone());
10885        let resp = app
10886            .oneshot(
10887                Request::builder()
10888                    .method("POST")
10889                    .uri("/subscriptions")
10890                    .header(header::COOKIE, cookie)
10891                    .header("content-type", "application/x-www-form-urlencoded")
10892                    .body(Body::from("url=https://another.example/feed.xml"))
10893                    .unwrap(),
10894            )
10895            .await
10896            .unwrap();
10897        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10898        let loc = resp
10899            .headers()
10900            .get(header::LOCATION)
10901            .unwrap()
10902            .to_str()
10903            .unwrap();
10904        assert!(
10905            loc.contains("Subscription%20limit%20reached"),
10906            "expected sub-limit flash, got {loc}"
10907        );
10908    }
10909
10910    /// `GET /` renders at most one page of rows and offers a way to the rest.
10911    ///
10912    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
10913    /// `LIMIT`, article bodies included — and hand the lot to the template. With
10914    /// 250 entries that is the whole list in one response; with a real backlog on
10915    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
10916    /// is capped, the heading still reports the true total, and page 2 is
10917    /// reachable and disjoint.
10918    #[tokio::test]
10919    async fn the_reader_index_pages_instead_of_rendering_everything() {
10920        let did = "did:plc:pager";
10921        let state = test_state(&[]).await;
10922        store::grant_access(&state.db, did, None, "test", None)
10923            .await
10924            .unwrap();
10925        let feed = store::upsert_feed(
10926            &state.db,
10927            &store::NewFeed {
10928                url: "https://pager.example/feed.xml".to_string(),
10929                title: Some("Pager".to_string()),
10930                ..Default::default()
10931            },
10932        )
10933        .await
10934        .unwrap();
10935        let total = 250_usize;
10936        let entries: Vec<store::NewEntry> = (0..total)
10937            .map(|i| store::NewEntry {
10938                guid: format!("p-{i:04}"),
10939                url: Some(format!("https://pager.example/{i}")),
10940                title: Some(format!("Article {i:04}")),
10941                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
10942                content_html: Some("x".repeat(4_000)),
10943                ..Default::default()
10944            })
10945            .collect();
10946        store::insert_entries(&state.db, feed, &entries, 0)
10947            .await
10948            .unwrap();
10949        store::replace_sub_refs(&state.db, did, &[feed])
10950            .await
10951            .unwrap();
10952
10953        let cookie = session_cookie(&state, did, None);
10954        let app = router(state.clone());
10955        let get = |uri: &str| {
10956            let app = app.clone();
10957            let cookie = cookie.clone();
10958            let uri = uri.to_string();
10959            async move {
10960                let resp = app
10961                    .oneshot(
10962                        Request::builder()
10963                            .uri(uri)
10964                            .header(header::COOKIE, cookie)
10965                            .body(Body::empty())
10966                            .unwrap(),
10967                    )
10968                    .await
10969                    .unwrap();
10970                assert_eq!(resp.status(), StatusCode::OK);
10971                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
10972                    .await
10973                    .unwrap();
10974                String::from_utf8(bytes.to_vec()).unwrap()
10975            }
10976        };
10977
10978        let page1 = get("/").await;
10979        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
10980        // over-count: each row carries several (the link plus the read/star
10981        // forms).
10982        let rows1 = page1.matches("<li class=\"entry").count();
10983        assert!(
10984            rows1 <= ENTRIES_PER_PAGE as usize,
10985            "page 1 rendered {rows1} entry links; the list is unbounded"
10986        );
10987        assert!(
10988            rows1 > 0,
10989            "page 1 rendered nothing at all: the page bound swallowed the list"
10990        );
10991        // The count is the TRUE total, not the page size — otherwise paging
10992        // would quietly relabel a 250-entry backlog as a 100-entry one.
10993        assert!(
10994            page1.contains("250 entries"),
10995            "heading must report the full total, not the page"
10996        );
10997        assert!(
10998            page1.contains("page=2"),
10999            "no way to reach the rest of the list: {}",
11000            &page1[..page1.len().min(400)]
11001        );
11002        // The body never belongs in a list response.
11003        assert!(
11004            !page1.contains(&"x".repeat(4_000)),
11005            "the list response carried an article body"
11006        );
11007
11008        let page2 = get("/?page=2").await;
11009        assert!(
11010            page2.matches("<li class=\"entry").count() > 0,
11011            "page 2 rendered no rows at all"
11012        );
11013        assert!(
11014            page2.contains("page=1") || page2.contains("Newer"),
11015            "page 2 offers no way back"
11016        );
11017        // Disjoint: an article on page 1 must not reappear on page 2.
11018        let first_title = (0..total)
11019            .map(|i| format!("Article {i:04}"))
11020            .find(|t| page1.contains(t))
11021            .expect("page 1 shows at least one titled article");
11022        assert!(
11023            !page2.contains(&first_title),
11024            "{first_title} appears on both pages"
11025        );
11026
11027        // A page past the end must not be a dead end. The empty state renders
11028        // instead of the pager, so an out-of-range page would leave a reader
11029        // with no link back — reachable by typing a number, and reachable
11030        // WITHOUT typing anything by paging to the end and then marking entries
11031        // read, which shrinks the list under the URL already in the address bar.
11032        let past_end = get("/?page=999").await;
11033        assert!(
11034            past_end.matches("<li class=\"entry").count() > 0,
11035            "an out-of-range page rendered nothing and offered no way back"
11036        );
11037        assert!(
11038            past_end.contains("page=2"),
11039            "the clamped page offers no pager"
11040        );
11041    }
11042
11043    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
11044    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
11045    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
11046    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
11047    /// view (no reader header) instead swaps the row. This guards the reader OOB
11048    /// toggle wiring, which had no test.
11049    #[tokio::test]
11050    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
11051        let did = "did:plc:reader";
11052        let state = test_state(&[]).await;
11053        store::grant_access(&state.db, did, None, "test", None)
11054            .await
11055            .unwrap();
11056        let feed = store::upsert_feed(
11057            &state.db,
11058            &store::NewFeed {
11059                url: "https://reader.example/feed.xml".to_string(),
11060                title: Some("Reader".to_string()),
11061                ..Default::default()
11062            },
11063        )
11064        .await
11065        .unwrap();
11066        store::insert_entries(
11067            &state.db,
11068            feed,
11069            &[store::NewEntry {
11070                guid: "r-1".to_string(),
11071                url: Some("https://reader.example/1".to_string()),
11072                title: Some("Article".to_string()),
11073                published: Some("2026-07-11T00:00:00Z".to_string()),
11074                content_html: Some("<p>body</p>".to_string()),
11075                ..Default::default()
11076            }],
11077            0,
11078        )
11079        .await
11080        .unwrap();
11081        store::replace_sub_refs(&state.db, did, &[feed])
11082            .await
11083            .unwrap();
11084        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
11085
11086        let cookie = session_cookie(&state, did, None);
11087        let app = router(state.clone());
11088
11089        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
11090        let resp = app
11091            .clone()
11092            .oneshot(
11093                Request::builder()
11094                    .method("POST")
11095                    .uri(format!("/entries/{entry_id}/read"))
11096                    .header(header::COOKIE, cookie.clone())
11097                    .header("HX-Request", "true")
11098                    .header("X-FR-Reader", "1")
11099                    .header("content-type", "application/x-www-form-urlencoded")
11100                    .body(Body::from("read=true"))
11101                    .unwrap(),
11102            )
11103            .await
11104            .unwrap();
11105        assert_eq!(resp.status(), StatusCode::OK);
11106        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
11107            .await
11108            .unwrap();
11109        let html = String::from_utf8(bytes.to_vec()).unwrap();
11110        assert!(
11111            html.contains("hx-swap-oob=\"outerHTML\""),
11112            "reader response must be an OOB swap: {html}"
11113        );
11114        assert!(
11115            html.contains(r#"id="entry-actionbar""#),
11116            "reader response must be the action-bar fragment: {html}"
11117        );
11118        // Now READ: the read button reflects it (aria-pressed=true) and the
11119        // hidden value flips to `false` so the next tap marks it UNREAD.
11120        assert!(
11121            html.contains(r#"aria-pressed="true""#),
11122            "read button must show pressed after marking read: {html}"
11123        );
11124        assert!(
11125            html.contains(r#"name="read" value="false""#),
11126            "hidden read value must flip to false so a second tap reverses: {html}"
11127        );
11128
11129        // A second reader mark-read (submitting the flipped `read=false`) marks
11130        // it UNREAD again — the toggle reverses.
11131        let resp2 = app
11132            .oneshot(
11133                Request::builder()
11134                    .method("POST")
11135                    .uri(format!("/entries/{entry_id}/read"))
11136                    .header(header::COOKIE, cookie)
11137                    .header("HX-Request", "true")
11138                    .header("X-FR-Reader", "1")
11139                    .header("content-type", "application/x-www-form-urlencoded")
11140                    .body(Body::from("read=false"))
11141                    .unwrap(),
11142            )
11143            .await
11144            .unwrap();
11145        assert_eq!(resp2.status(), StatusCode::OK);
11146        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
11147            .await
11148            .unwrap();
11149        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
11150        assert!(
11151            html2.contains(r#"aria-pressed="false""#),
11152            "read button must show un-pressed after reversing: {html2}"
11153        );
11154        assert!(
11155            html2.contains(r#"name="read" value="true""#),
11156            "hidden read value must flip back to true: {html2}"
11157        );
11158    }
11159
11160    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
11161    /// action-bar — the counterpart to the reader-OOB test above.
11162    #[tokio::test]
11163    async fn list_mark_read_returns_row_not_oob_actionbar() {
11164        let did = "did:plc:listv";
11165        let state = test_state(&[]).await;
11166        store::grant_access(&state.db, did, None, "test", None)
11167            .await
11168            .unwrap();
11169        let feed = store::upsert_feed(
11170            &state.db,
11171            &store::NewFeed {
11172                url: "https://list.example/feed.xml".to_string(),
11173                title: Some("List".to_string()),
11174                ..Default::default()
11175            },
11176        )
11177        .await
11178        .unwrap();
11179        store::insert_entries(
11180            &state.db,
11181            feed,
11182            &[store::NewEntry {
11183                guid: "l-1".to_string(),
11184                url: Some("https://list.example/1".to_string()),
11185                title: Some("Article".to_string()),
11186                published: Some("2026-07-11T00:00:00Z".to_string()),
11187                ..Default::default()
11188            }],
11189            0,
11190        )
11191        .await
11192        .unwrap();
11193        store::replace_sub_refs(&state.db, did, &[feed])
11194            .await
11195            .unwrap();
11196        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
11197
11198        let cookie = session_cookie(&state, did, None);
11199        let app = router(state.clone());
11200
11201        let resp = app
11202            .oneshot(
11203                Request::builder()
11204                    .method("POST")
11205                    .uri(format!("/entries/{entry_id}/read"))
11206                    .header(header::COOKIE, cookie)
11207                    .header("HX-Request", "true")
11208                    .header("content-type", "application/x-www-form-urlencoded")
11209                    .body(Body::from("read=true"))
11210                    .unwrap(),
11211            )
11212            .await
11213            .unwrap();
11214        assert_eq!(resp.status(), StatusCode::OK);
11215        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
11216            .await
11217            .unwrap();
11218        let html = String::from_utf8(bytes.to_vec()).unwrap();
11219        assert!(
11220            !html.contains("hx-swap-oob"),
11221            "list-view response must NOT be an OOB swap: {html}"
11222        );
11223        // **And it must actually BE the row.** The assertion above is satisfied
11224        // by an empty body, or by any response that simply omits the attribute —
11225        // so on its own it pins half a property and the name promises the other
11226        // half.
11227        assert!(
11228            html.contains(&format!("/entries/{entry_id}")),
11229            "the response is not the row for this entry: {html}",
11230        );
11231        assert!(
11232            html.contains("Article"),
11233            "the row rendered without its title: {html}",
11234        );
11235        // **The row comes back carrying read state. That is all this proves.**
11236        //
11237        // It does NOT prove the state was persisted: the handler renders
11238        // `Some(read)` from the form value, so making `mark_read` roll back
11239        // instead of commit fails 11 store tests and leaves this one green.
11240        //
11241        // It does not prove the OVERRIDE either, which an earlier version of
11242        // this comment claimed. Verified: changing the call site to
11243        // `build_entry_row(pool, &did, id, None)` — deleting the override
11244        // wholesale — keeps the whole suite green, because `mark_read` has
11245        // already persisted the same value two lines earlier, so reading it back
11246        // from the database produces an identical row.
11247        //
11248        // Distinguishing the two needs a case where the override and the stored
11249        // state DISAGREE, which this handler never produces: it writes the value
11250        // it then renders. Left as a known gap rather than described as covered.
11251        assert!(
11252            html.contains("is-read"),
11253            "the row came back without the read state it was just given: {html}",
11254        );
11255    }
11256
11257    // -----------------------------------------------------------------------
11258    // Rename parity (POST /subscriptions/{rkey}/rename)
11259    // -----------------------------------------------------------------------
11260
11261    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
11262    ///
11263    /// The add path gates the URL the user *typed*; the URL it *stores* is
11264    /// whatever `resolve_feed_url` returns, which for an HTML page is a
11265    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
11266    /// that: `discover_feed` yields only http(s), and the add path re-checks
11267    /// storability on the resolved URL. This test pins the DISJUNCTION —
11268    /// each layer alone holds it, both removed fails it — driven through the
11269    /// real route against a real local server.
11270    ///
11271    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
11272    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
11273    /// form: once storage became DID-only the privacy classifier refused it
11274    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
11275    /// — the colons in the DID), so `discover_feed` drops it before either
11276    /// layer exists. An at:// link cannot come out of autodiscovery under
11277    /// ANY mutation of the layers, so no test through this route can pin
11278    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
11279    /// structure and pinned where it lives: `discover_skips_a_non_http_
11280    /// alternate` and the storability tests in `feed.rs`.
11281    #[tokio::test]
11282    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
11283        let did = "did:plc:autodiscovered";
11284        // Access granted, both caps disabled — the only gates left are the
11285        // two under test.
11286        let state = test_state_with_caps(did, 0, 0).await;
11287
11288        let page = r#"<!doctype html><html><head><title>Blog</title>
11289            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
11290            </head><body>hi</body></html>"#;
11291        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
11292        let port: u16 = base
11293            .trim_end_matches('/')
11294            .rsplit(':')
11295            .next()
11296            .unwrap()
11297            .parse()
11298            .unwrap();
11299        crate::net::test_host_override(
11300            "autodiscover-ftp.test",
11301            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
11302        );
11303
11304        let cookie = session_cookie(&state, did, None);
11305        let resp = router(state.clone())
11306            .oneshot(
11307                Request::builder()
11308                    .method("POST")
11309                    .uri("/subscriptions")
11310                    .header(header::COOKIE, cookie)
11311                    .header("content-type", "application/x-www-form-urlencoded")
11312                    .body(Body::from(format!(
11313                        "url=http://autodiscover-ftp.test:{port}/"
11314                    )))
11315                    .unwrap(),
11316            )
11317            .await
11318            .unwrap();
11319        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11320        let loc = resp
11321            .headers()
11322            .get(header::LOCATION)
11323            .unwrap()
11324            .to_str()
11325            .unwrap();
11326        assert_ne!(loc, "/login", "the test never reached the add path");
11327        assert_ne!(loc, "/", "the subscribe succeeded");
11328
11329        assert_eq!(
11330            store::count_feeds(&state.db).await.unwrap(),
11331            0,
11332            "a non-http(s) URL from autodiscovery was stored"
11333        );
11334        assert_eq!(
11335            store::count_subscriptions_for_did(&state.db, did)
11336                .await
11337                .unwrap(),
11338            0
11339        );
11340    }
11341
11342    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
11343    /// its global ceiling must be refused (capacity flash) and must NOT insert a
11344    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
11345    /// rename loop can't inflate the shared cache past the cap.
11346    #[tokio::test]
11347    async fn rename_to_new_url_refused_at_global_feeds_cap() {
11348        let did = "did:plc:renamer4";
11349        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11350        // Global cap 1; pre-fill it with one feed so headroom is 0.
11351        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11352        store::upsert_feed(
11353            &state.db,
11354            &store::NewFeed {
11355                url: "https://existing.example/feed.xml".to_string(),
11356                ..Default::default()
11357            },
11358        )
11359        .await
11360        .unwrap();
11361        let before = store::count_feeds(&state.db).await.unwrap();
11362        assert_eq!(before, 1);
11363
11364        let cookie = session_cookie(&state, did, None);
11365        let resp = router(state.clone())
11366            .oneshot(
11367                Request::builder()
11368                    .method("POST")
11369                    .uri("/subscriptions/rk-keep/rename")
11370                    .header(header::COOKIE, cookie)
11371                    .header("content-type", "application/x-www-form-urlencoded")
11372                    // A URL not in the cache → would be a NEW feeds row.
11373                    .body(Body::from(
11374                        "url=https://brand-new.example/feed.xml&title=Renamed",
11375                    ))
11376                    .unwrap(),
11377            )
11378            .await
11379            .unwrap();
11380        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11381        let loc = resp
11382            .headers()
11383            .get(header::LOCATION)
11384            .unwrap()
11385            .to_str()
11386            .unwrap();
11387        assert!(
11388            loc.contains("feed%20capacity"),
11389            "expected the feed-capacity flash, got {loc}"
11390        );
11391        // No new feeds row was inserted, and nothing reached the PDS.
11392        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
11393        assert!(
11394            puts.lock().unwrap().is_empty(),
11395            "a refused repoint reached the PDS"
11396        );
11397    }
11398
11399    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
11400    /// global cap (only new URLs are gated) — the other half of the guard.
11401    ///
11402    /// On the sidecar fake, so "allowed" means the put actually happened: the
11403    /// earlier harness had no sidecar, and this passed on a "could not reach
11404    /// your PDS" flash that merely was not the capacity one.
11405    #[tokio::test]
11406    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
11407        let did = "did:plc:renamer4";
11408        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11409        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11410        store::upsert_feed(
11411            &state.db,
11412            &store::NewFeed {
11413                url: "https://existing.example/feed.xml".to_string(),
11414                ..Default::default()
11415            },
11416        )
11417        .await
11418        .unwrap();
11419        let before = store::count_feeds(&state.db).await.unwrap();
11420
11421        let cookie = session_cookie(&state, did, None);
11422        let resp = router(state.clone())
11423            .oneshot(
11424                Request::builder()
11425                    .method("POST")
11426                    .uri("/subscriptions/rk-keep/rename")
11427                    .header(header::COOKIE, cookie)
11428                    .header("content-type", "application/x-www-form-urlencoded")
11429                    .body(Body::from(
11430                        "url=https://existing.example/feed.xml&title=Retitled",
11431                    ))
11432                    .unwrap(),
11433            )
11434            .await
11435            .unwrap();
11436        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11437        let loc = resp
11438            .headers()
11439            .get(header::LOCATION)
11440            .unwrap()
11441            .to_str()
11442            .unwrap();
11443        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
11444        assert_eq!(
11445            puts.lock().unwrap().len(),
11446            1,
11447            "the repoint did not reach the PDS"
11448        );
11449        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
11450    }
11451
11452    /// A rename with a blank URL writes nothing anywhere.
11453    #[tokio::test]
11454    async fn rename_with_blank_url_writes_nothing() {
11455        let did = "did:plc:renamer3";
11456        let state = test_state_with_caps(did, 0, 0).await;
11457        let before = store::count_feeds(&state.db).await.unwrap();
11458        assert_eq!(before, 0);
11459
11460        let cookie = session_cookie(&state, did, None);
11461        let app = router(state.clone());
11462        let resp = app
11463            .oneshot(
11464                Request::builder()
11465                    .method("POST")
11466                    .uri("/subscriptions/rkey123/rename")
11467                    .header(header::COOKIE, cookie)
11468                    .header("content-type", "application/x-www-form-urlencoded")
11469                    // Whitespace-only URL trims to empty.
11470                    .body(Body::from("url=%20%20&title=Nope"))
11471                    .unwrap(),
11472            )
11473            .await
11474            .unwrap();
11475        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11476        assert_eq!(
11477            resp.headers()
11478                .get(header::LOCATION)
11479                .unwrap()
11480                .to_str()
11481                .unwrap(),
11482            "/",
11483        );
11484        // Nothing was cached.
11485        assert_eq!(
11486            store::count_feeds(&state.db).await.unwrap(),
11487            0,
11488            "blank-URL rename wrote a junk feeds row"
11489        );
11490    }
11491
11492    /// A sidecar mock that serves ONE existing subscription record and captures
11493    /// every `put` body a rename produces.
11494    ///
11495    /// **Reads to `content-length` rather than taking one `read`.** A single
11496    /// read gets whatever one segment carried; if the head and body land
11497    /// separately the capture holds no record and every field assertion below
11498    /// passes for the wrong reason. Each captured body must also mention the
11499    /// collection, so an empty capture fails loudly instead of quietly.
11500    async fn spawn_rename_sidecar(
11501        existing: serde_json::Value,
11502    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
11503        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
11504        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11505        let addr = listener.local_addr().unwrap();
11506        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
11507        let sink = puts.clone();
11508        tokio::spawn(async move {
11509            loop {
11510                let Ok((mut sock, _)) = listener.accept().await else {
11511                    break;
11512                };
11513                let mut raw: Vec<u8> = Vec::new();
11514                let mut chunk = [0u8; 4096];
11515                let body_text = loop {
11516                    let Ok(n) = sock.read(&mut chunk).await else {
11517                        break String::new();
11518                    };
11519                    if n == 0 {
11520                        break String::from_utf8_lossy(&raw).to_string();
11521                    }
11522                    raw.extend_from_slice(&chunk[..n]);
11523                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
11524                        continue;
11525                    };
11526                    let (head, body) = raw.split_at(split + 4);
11527                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
11528                        let (k, v) = l.split_once(':')?;
11529                        k.eq_ignore_ascii_case("content-length")
11530                            .then(|| v.trim().parse::<usize>().ok())?
11531                    });
11532                    if want.is_none_or(|want| body.len() >= want) {
11533                        break String::from_utf8_lossy(body).to_string();
11534                    }
11535                };
11536
11537                // `"action":"put"` is the rename write; anything else is the read.
11538                let is_put = body_text.contains("\"action\":\"put\"");
11539                let data = if is_put {
11540                    sink.lock().unwrap().push(body_text.clone());
11541                    serde_json::json!({
11542                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
11543                        "cid": "bafyreiafter"
11544                    })
11545                } else {
11546                    serde_json::json!({ "records": [existing.clone()] })
11547                };
11548                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
11549                let resp = format!(
11550                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11551                    body.len(),
11552                    body
11553                );
11554                let _ = sock.write_all(resp.as_bytes()).await;
11555                let _ = sock.flush().await;
11556            }
11557        });
11558        (format!("http://{addr}"), puts)
11559    }
11560
11561    /// The existing record a rename must not destroy.
11562    fn seeded_subscription() -> serde_json::Value {
11563        serde_json::json!({
11564            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
11565            "cid": "bafyreibefore",
11566            "value": {
11567                "$type": "community.lexicon.rss.subscription",
11568                "url": "https://example.com/feed.xml",
11569                "title": "Old title",
11570                "siteUrl": "https://example.com/blog",
11571                "fetchHint": "hourly",
11572                "private": false,
11573                "createdAt": "2024-03-01T00:00:00.000Z"
11574            }
11575        })
11576    }
11577
11578    /// An existing standard.site subscription, as the 19 in production are:
11579    /// written before this reader refused the scheme, still in the repo.
11580    fn seeded_at_uri_subscription() -> serde_json::Value {
11581        seeded_subscription_with_url(AT_URI_SUB)
11582    }
11583    /// An existing subscription record at `rk-keep` with the given URL.
11584    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
11585        serde_json::json!({
11586            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
11587            "cid": "bafyreibefore",
11588            "value": {
11589                "$type": "community.lexicon.rss.subscription",
11590                "url": url,
11591                "title": "Old title",
11592                "private": false,
11593                "createdAt": "2024-03-01T00:00:00.000Z"
11594            }
11595        })
11596    }
11597    const AT_URI_SUB: &str =
11598        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
11599    const AT_URI_SUB_ENC: &str =
11600        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
11601
11602    /// **Retitling an existing `at://` subscription must work with the flag off.**
11603    ///
11604    /// The storability guard was placed before the repo lookup, so it refused
11605    /// any rename whose URL is an at-URI — including a pure title or folder
11606    /// change on a record that already exists. On main that rename succeeded;
11607    /// the 19 production records would have become un-editable. The flag gates
11608    /// what may be STORED in the cache, not whether a reader may edit their own
11609    /// record: the PDS write goes through, the cache row is simply not created.
11610    #[tokio::test]
11611    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
11612        let did = "did:plc:renamer5";
11613        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11614        let state = test_state_with_sidecar(&[did], &sidecar).await;
11615        assert!(
11616            !state.config.standard_site,
11617            "the flag must be off for this test"
11618        );
11619        let cookie = session_cookie(&state, did, None);
11620        let resp = router(state.clone())
11621            .oneshot(
11622                Request::builder()
11623                    .method("POST")
11624                    .uri("/subscriptions/rk-keep/rename")
11625                    .header(header::COOKIE, cookie)
11626                    .header("content-type", "application/x-www-form-urlencoded")
11627                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
11628                    .unwrap(),
11629            )
11630            .await
11631            .unwrap();
11632        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11633        let loc = resp
11634            .headers()
11635            .get(header::LOCATION)
11636            .unwrap()
11637            .to_str()
11638            .unwrap();
11639        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11640
11641        let bodies = puts.lock().unwrap().clone();
11642        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11643        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11644        assert_eq!(
11645            sent["record"]["title"], "New title",
11646            "the rename did not apply"
11647        );
11648        assert_eq!(
11649            sent["record"]["url"], AT_URI_SUB,
11650            "the rename changed the URL"
11651        );
11652
11653        // The flag still means what it says for the CACHE: no at:// row.
11654        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11655        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
11656    }
11657
11658    /// **Repointing a subscription AT an `at://` URI is still refused with the
11659    /// flag off** — the half of the guard that has to survive the fix above.
11660    /// Nothing reaches the PDS and nothing reaches the cache.
11661    #[tokio::test]
11662    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
11663        let did = "did:plc:renamer4";
11664        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11665        let state = test_state_with_sidecar(&[did], &sidecar).await;
11666        let cookie = session_cookie(&state, did, None);
11667        let resp = router(state.clone())
11668            .oneshot(
11669                Request::builder()
11670                    .method("POST")
11671                    .uri("/subscriptions/rk-keep/rename")
11672                    .header(header::COOKIE, cookie)
11673                    .header("content-type", "application/x-www-form-urlencoded")
11674                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11675                    .unwrap(),
11676            )
11677            .await
11678            .unwrap();
11679        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11680        let loc = resp
11681            .headers()
11682            .get(header::LOCATION)
11683            .unwrap()
11684            .to_str()
11685            .unwrap();
11686        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
11687        assert!(
11688            !loc.contains("Private"),
11689            "a storability refusal was reported as a privacy one: {loc}"
11690        );
11691        assert!(
11692            puts.lock().unwrap().is_empty(),
11693            "the repoint reached the PDS"
11694        );
11695        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11696        assert_eq!(cached, 0);
11697    }
11698
11699    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
11700    /// redirect location.
11701    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
11702        let cookie = session_cookie(state, did, None);
11703        let resp = router(state.clone())
11704            .oneshot(
11705                Request::builder()
11706                    .method("POST")
11707                    .uri("/subscriptions/rk-keep/rename")
11708                    .header(header::COOKIE, cookie)
11709                    .header("content-type", "application/x-www-form-urlencoded")
11710                    .body(Body::from(format!("url={url_enc}&title=New+title")))
11711                    .unwrap(),
11712            )
11713            .await
11714            .unwrap();
11715        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11716        resp.headers()
11717            .get(header::LOCATION)
11718            .unwrap()
11719            .to_str()
11720            .unwrap()
11721            .to_string()
11722    }
11723
11724    /// **The privacy gate has the same ordering bug the storable gate had.**
11725    ///
11726    /// Another client can write a subscription whose URL is an at-URI that is
11727    /// not a well-formed publication URI at all — a feed generator, say. On
11728    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
11729    /// the classifier reads as `Public`). The narrowed at:// arm now fails
11730    /// closed as `Private` for it, and the gate ran before `url_changed` was
11731    /// known — so the record became un-editable, with a flash claiming it "was
11732    /// not saved or sent anywhere". Both gates now apply to a repoint only.
11733    #[tokio::test]
11734    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
11735        let did = "did:plc:renamer5";
11736        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
11737        let other_enc =
11738            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
11739        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
11740        let state = test_state_with_sidecar(&[did], &sidecar).await;
11741        let loc = retitle_unchanged(&state, did, other_enc).await;
11742        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11743        let bodies = puts.lock().unwrap().clone();
11744        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11745        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11746        assert_eq!(sent["record"]["title"], "New title");
11747        assert_eq!(sent["record"]["url"], other);
11748    }
11749
11750    /// **A repoint to a secret-bearing URL is still refused** — the half of
11751    /// the privacy gate that has to survive moving it behind `url_changed`.
11752    /// Found by mutation: with the gate deleted outright, nothing failed.
11753    #[tokio::test]
11754    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
11755        let did = "did:plc:renamer4";
11756        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11757        let state = test_state_with_sidecar(&[did], &sidecar).await;
11758        let cookie = session_cookie(&state, did, None);
11759        let resp = router(state.clone())
11760            .oneshot(
11761                Request::builder()
11762                    .method("POST")
11763                    .uri("/subscriptions/rk-keep/rename")
11764                    .header(header::COOKIE, cookie)
11765                    .header("content-type", "application/x-www-form-urlencoded")
11766                    .body(Body::from(
11767                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
11768                    ))
11769                    .unwrap(),
11770            )
11771            .await
11772            .unwrap();
11773        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11774        let loc = resp
11775            .headers()
11776            .get(header::LOCATION)
11777            .unwrap()
11778            .to_str()
11779            .unwrap();
11780        assert!(
11781            loc.contains("Private"),
11782            "the private repoint was not refused: {loc}"
11783        );
11784        assert!(
11785            puts.lock().unwrap().is_empty(),
11786            "a secret-bearing URL reached the PDS"
11787        );
11788        // The repo's fixture token: opaque enough for the classifier, not a real
11789        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
11790        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
11791        assert!(store::get_feed_by_url(&state.db, leaked)
11792            .await
11793            .unwrap()
11794            .is_none());
11795    }
11796
11797    /// **A retitle of a never-cached at:// subscription is not "at feed
11798    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
11799    /// and an at:// record is never cached with the flag off — so at capacity,
11800    /// a pure retitle was refused for a row the handler would not insert. The
11801    /// check now runs once `url_changed` is known and only for a repoint.
11802    #[tokio::test]
11803    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
11804        let did = "did:plc:renamer5";
11805        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11806        // Ceiling 1, and one real feed already fills it.
11807        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11808        store::upsert_feed(
11809            &state.db,
11810            &store::NewFeed {
11811                url: "https://filler.example/feed.xml".to_string(),
11812                ..Default::default()
11813            },
11814        )
11815        .await
11816        .unwrap();
11817        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11818        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11819        assert_eq!(
11820            puts.lock().unwrap().len(),
11821            1,
11822            "the retitle did not reach the PDS"
11823        );
11824        assert_eq!(
11825            store::count_feeds(&state.db).await.unwrap(),
11826            1,
11827            "a row was inserted"
11828        );
11829    }
11830
11831    /// POST `/subscriptions` with `url`, returning the redirect target.
11832    async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
11833        let cookie = session_cookie(state, did, None);
11834        let resp = router(state.clone())
11835            .oneshot(
11836                Request::builder()
11837                    .method("POST")
11838                    .uri("/subscriptions")
11839                    .header(header::COOKIE, cookie)
11840                    .header("content-type", "application/x-www-form-urlencoded")
11841                    .body(Body::from(format!("url={url_enc}")))
11842                    .unwrap(),
11843            )
11844            .await
11845            .unwrap();
11846        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11847        resp.headers()
11848            .get(header::LOCATION)
11849            .unwrap()
11850            .to_str()
11851            .unwrap()
11852            .to_string()
11853    }
11854
11855    /// A `com.atproto.identity.resolveHandle` that answers `did` for anything.
11856    async fn serve_resolver(did: &str) -> String {
11857        let base = crate::net::tests::serve_body(
11858            serde_json::json!({ "did": did }).to_string().into_bytes(),
11859        )
11860        .await;
11861        let port: u16 = base
11862            .trim_end_matches('/')
11863            .rsplit(':')
11864            .next()
11865            .unwrap()
11866            .parse()
11867            .unwrap();
11868        let host = format!("resolver-{port}.test");
11869        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11870        format!("http://{host}:{port}")
11871    }
11872
11873    fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
11874        let mut config = (*state.config).clone();
11875        f(&mut config);
11876        state.config = std::sync::Arc::new(config);
11877        state
11878    }
11879
11880    /// **0.4.0 step 3: with the flag ON, a well-formed at:// paste is
11881    /// subscribed.** It was refused as unsupported while nothing could read a
11882    /// publication; the poller reads them now. Stored in DID form, as a
11883    /// `publication`, and written to the reader's PDS like any subscription.
11884    #[tokio::test]
11885    async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
11886        let did = "did:plc:renamer5";
11887        let (sidecar, log) = spawn_logging_sidecar().await;
11888        let state = with_config(
11889            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11890            |c| {
11891                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11892            },
11893        );
11894        let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
11895        assert_eq!(loc, "/", "the paste was refused: {loc}");
11896        let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
11897            .await
11898            .unwrap()
11899            .expect("no feed row");
11900        assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
11901        let sent = log.lock().unwrap().join("\n");
11902        assert!(
11903            sent.contains(AT_URI_SUB),
11904            "the subscription was not written to the PDS: {sent}"
11905        );
11906    }
11907
11908    /// **A0 through the form** — the 0.4.0 exit test, end to end: a reader
11909    /// pastes a publication, it is stored and written to their PDS, and the
11910    /// first poll — the one subscribing runs at once — stores its documents.
11911    #[tokio::test]
11912    async fn a0_subscribing_from_the_form_delivers_entries() {
11913        let did = "did:plc:renamer5";
11914        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11915        let site = AT_URI_SUB;
11916        let (plc, _) = crate::standard_site::tests::serve_repo(
11917            author,
11918            vec![
11919                (
11920                    lexicon::nsid::STANDARD_PUBLICATION,
11921                    "3lab2c4d5e6f7g8h",
11922                    serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
11923                ),
11924                (
11925                    lexicon::nsid::STANDARD_DOCUMENT,
11926                    "3l2a0frmaaa2a",
11927                    serde_json::json!({ "title": "From the form", "path": "/f",
11928                        "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
11929                ),
11930            ],
11931        )
11932        .await;
11933        let (sidecar, _log) = spawn_logging_sidecar().await;
11934        let state = with_config(
11935            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11936            |c| {
11937                c.oauth.plc_directory = plc;
11938            },
11939        );
11940        assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
11941        let row = store::get_feed_by_url(&state.db, site)
11942            .await
11943            .unwrap()
11944            .unwrap();
11945        let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
11946            .bind(row.id)
11947            .fetch_all(&state.db)
11948            .await
11949            .unwrap();
11950        assert_eq!(
11951            titles,
11952            vec!["From the form".to_string()],
11953            "the first poll stored nothing"
11954        );
11955        assert_eq!(row.title.as_deref(), Some("A0 Journal"));
11956    }
11957
11958    /// A handle-form paste is resolved to the DID before it is stored: a
11959    /// handle is a mutable name, and `feeds.url` is keyed on identity.
11960    #[tokio::test]
11961    async fn a_handle_form_paste_is_stored_by_its_did() {
11962        let did = "did:plc:renamer5";
11963        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11964        let (sidecar, _log) = spawn_logging_sidecar().await;
11965        let resolver = serve_resolver(author).await;
11966        let state = with_config(
11967            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11968            |c| {
11969                c.resolver_base = resolver;
11970                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11971            },
11972        );
11973        let loc = subscribe(
11974            &state,
11975            did,
11976            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11977        )
11978        .await;
11979        assert_eq!(loc, "/", "the paste was refused: {loc}");
11980        assert!(
11981            store::get_feed_by_url(&state.db, AT_URI_SUB)
11982                .await
11983                .unwrap()
11984                .is_some(),
11985            "not stored by its DID"
11986        );
11987        assert_eq!(
11988            store::count_feeds(&state.db).await.unwrap(),
11989            1,
11990            "the handle form was stored too"
11991        );
11992    }
11993
11994    /// A resolver answering `did` that counts how often it was asked.
11995    async fn serve_counting_resolver(
11996        did: &str,
11997    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
11998        let (base, hits) = crate::net::tests::serve_body_counted(
11999            serde_json::json!({ "did": did }).to_string().into_bytes(),
12000        )
12001        .await;
12002        let port: u16 = base
12003            .trim_end_matches('/')
12004            .rsplit(':')
12005            .next()
12006            .unwrap()
12007            .parse()
12008            .unwrap();
12009        let host = format!("counting-resolver-{port}.test");
12010        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
12011        (format!("http://{host}:{port}"), hits)
12012    }
12013
12014    /// Review of #230: the per-DID cap is documented as checked "BEFORE any
12015    /// fetch/resolve so an over-cap account can't even trigger an outbound
12016    /// request" — a handle paste resolved the handle first.
12017    #[tokio::test]
12018    async fn an_over_cap_handle_paste_makes_no_outbound_request() {
12019        let did = "did:plc:renamer5";
12020        let (sidecar, _log) = spawn_logging_sidecar().await;
12021        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
12022        let state = with_config(
12023            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
12024            |c| {
12025                c.resolver_base = resolver;
12026                c.max_subs_per_did = 1;
12027            },
12028        );
12029        let feed_id = store::upsert_feed(
12030            &state.db,
12031            &store::NewFeed {
12032                url: "https://already.example/feed.xml".into(),
12033                ..Default::default()
12034            },
12035        )
12036        .await
12037        .unwrap();
12038        store::replace_sub_refs(&state.db, did, &[feed_id])
12039            .await
12040            .unwrap();
12041        let loc = subscribe(
12042            &state,
12043            did,
12044            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
12045        )
12046        .await;
12047        assert!(
12048            loc.contains("Subscription%20limit"),
12049            "expected the cap flash: {loc}"
12050        );
12051        assert_eq!(
12052            hits.load(std::sync::atomic::Ordering::SeqCst),
12053            0,
12054            "an over-cap paste resolved a handle"
12055        );
12056    }
12057
12058    /// Review of #230: an authority that is neither a valid DID nor a valid
12059    /// handle — `did:plc:TOOSHORT`, an uppercase DID — went to the resolver as
12060    /// a "handle". It is unsupported, and asks nobody anything.
12061    #[tokio::test]
12062    async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
12063        let did = "did:plc:renamer5";
12064        let (sidecar, _log) = spawn_logging_sidecar().await;
12065        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
12066        let state = with_config(
12067            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
12068            |c| {
12069                c.resolver_base = resolver;
12070            },
12071        );
12072        for authority in [
12073            "did%3Aplc%3ATOOSHORT",
12074            "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
12075            "bad%0Ahandle.example",
12076        ] {
12077            let loc = subscribe(
12078                &state,
12079                did,
12080                &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
12081            )
12082            .await;
12083            assert!(
12084                loc.contains("kind%20of%20feed"),
12085                "{authority}: expected the unsupported flash: {loc}"
12086            );
12087        }
12088        assert_eq!(
12089            hits.load(std::sync::atomic::Ordering::SeqCst),
12090            0,
12091            "a malformed authority reached the resolver"
12092        );
12093        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
12094    }
12095
12096    /// A handle that does not resolve is refused, and nothing is stored.
12097    #[tokio::test]
12098    async fn an_unresolvable_handle_paste_is_refused() {
12099        let did = "did:plc:renamer5";
12100        let (sidecar, _log) = spawn_logging_sidecar().await;
12101        let state = with_config(
12102            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
12103            |c| {
12104                c.resolver_base = "http://resolver.nowhere.invalid".into();
12105            },
12106        );
12107        let loc = subscribe(
12108            &state,
12109            did,
12110            "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
12111        )
12112        .await;
12113        assert!(
12114            loc.contains("resolve%20the%20handle"),
12115            "expected the unresolvable-handle flash: {loc}"
12116        );
12117        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
12118    }
12119
12120    /// An at:// URI that is not a publication is refused, flag on or off.
12121    #[tokio::test]
12122    async fn a_non_publication_at_uri_paste_is_refused() {
12123        let did = "did:plc:renamer5";
12124        let (sidecar, _log) = spawn_logging_sidecar().await;
12125        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
12126        let loc = subscribe(
12127            &state,
12128            did,
12129            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
12130        )
12131        .await;
12132        assert!(
12133            loc.contains("kind%20of%20feed"),
12134            "expected the unsupported flash: {loc}"
12135        );
12136        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
12137    }
12138
12139    /// A mixed-case scheme is canonicalised at input, not refused and not
12140    /// stored as a second spelling of the same publication.
12141    #[tokio::test]
12142    async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
12143        let did = "did:plc:renamer5";
12144        let (sidecar, _log) = spawn_logging_sidecar().await;
12145        let state = with_config(
12146            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
12147            |c| {
12148                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
12149            },
12150        );
12151        let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
12152        assert_eq!(loc, "/", "the paste was refused: {loc}");
12153        assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
12154            .await
12155            .unwrap()
12156            .is_some());
12157    }
12158
12159    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
12160    /// path that is meant to work today, asserted with the flag actually on.
12161    #[tokio::test]
12162    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
12163        let did = "did:plc:renamer5";
12164        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
12165        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
12166        let opml = format!(
12167            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
12168             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
12169             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
12170             </body></opml>"
12171        );
12172        let (ct, body) = opml_multipart(opml.as_bytes());
12173        let cookie = session_cookie(&state, did, None);
12174        let resp = router(state.clone())
12175            .oneshot(
12176                Request::builder()
12177                    .method("POST")
12178                    .uri("/opml")
12179                    .header(header::COOKIE, cookie)
12180                    .header("content-type", ct)
12181                    .body(Body::from(body))
12182                    .unwrap(),
12183            )
12184            .await
12185            .unwrap();
12186        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12187        let loc = resp
12188            .headers()
12189            .get(header::LOCATION)
12190            .unwrap()
12191            .to_str()
12192            .unwrap();
12193        assert!(
12194            loc.contains("Imported%202%20feeds"),
12195            "unexpected flash: {loc}"
12196        );
12197        assert!(
12198            !loc.contains("skipped"),
12199            "the at:// entry was skipped with the flag on: {loc}"
12200        );
12201        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
12202        assert!(
12203            stored.is_some(),
12204            "the at:// entry was not stored with the flag on"
12205        );
12206    }
12207
12208    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
12209    /// gate behind `url_changed` was right for the PDS write — the record is
12210    /// the reader's — but the cache write was gated only on `storable`, which
12211    /// any http(s) URL is. So a retitle of a record another client wrote with
12212    /// a tokened feed URL inserted that URL into the shared `feeds` table,
12213    /// where the poller would fail it every cycle and print it on the admin
12214    /// page. main refused the whole rename; this keeps the record editable and
12215    /// the cache clean, as `resolve_subscriptions` already does for the same
12216    /// record.
12217    #[tokio::test]
12218    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
12219        let did = "did:plc:renamer5";
12220        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
12221        let tokened_enc =
12222            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
12223        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
12224        let state = test_state_with_sidecar(&[did], &sidecar).await;
12225        let loc = retitle_unchanged(&state, did, tokened_enc).await;
12226        assert_eq!(loc, "/", "the retitle was refused: {loc}");
12227        assert_eq!(
12228            puts.lock().unwrap().len(),
12229            1,
12230            "the retitle did not reach the PDS"
12231        );
12232        assert!(
12233            store::get_feed_by_url(&state.db, tokened)
12234                .await
12235                .unwrap()
12236                .is_none(),
12237            "a secret-bearing URL was written to the shared cache by a retitle"
12238        );
12239    }
12240
12241    /// **On a repoint, storability is decided before privacy and capacity** —
12242    /// the same ordering the add path got. A malformed at:// target drew the
12243    /// private/paid flash, and at capacity a well-formed one drew "try again
12244    /// later" for a URL that can never be accepted with the flag off.
12245    #[tokio::test]
12246    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
12247        let did = "did:plc:renamer4";
12248        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
12249        let state = test_state_with_sidecar(&[did], &sidecar).await;
12250        let cookie = session_cookie(&state, did, None);
12251        let resp = router(state.clone())
12252            .oneshot(
12253                Request::builder()
12254                    .method("POST")
12255                    .uri("/subscriptions/rk-keep/rename")
12256                    .header(header::COOKIE, cookie)
12257                    .header("content-type", "application/x-www-form-urlencoded")
12258                    .body(Body::from(
12259                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
12260                    ))
12261                    .unwrap(),
12262            )
12263            .await
12264            .unwrap();
12265        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12266        let loc = resp
12267            .headers()
12268            .get(header::LOCATION)
12269            .unwrap()
12270            .to_str()
12271            .unwrap();
12272        assert!(
12273            loc.contains("kind%20of%20feed"),
12274            "expected the unsupported flash: {loc}"
12275        );
12276        assert!(
12277            !loc.contains("Private"),
12278            "a typo was reported as a paid feed: {loc}"
12279        );
12280        assert!(puts.lock().unwrap().is_empty());
12281    }
12282
12283    #[tokio::test]
12284    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
12285        let did = "did:plc:renamer4";
12286        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
12287        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
12288        store::upsert_feed(
12289            &state.db,
12290            &store::NewFeed {
12291                url: "https://filler.example/feed.xml".to_string(),
12292                ..Default::default()
12293            },
12294        )
12295        .await
12296        .unwrap();
12297        let cookie = session_cookie(&state, did, None);
12298        let resp = router(state.clone())
12299            .oneshot(
12300                Request::builder()
12301                    .method("POST")
12302                    .uri("/subscriptions/rk-keep/rename")
12303                    .header(header::COOKIE, cookie)
12304                    .header("content-type", "application/x-www-form-urlencoded")
12305                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
12306                    .unwrap(),
12307            )
12308            .await
12309            .unwrap();
12310        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12311        let loc = resp
12312            .headers()
12313            .get(header::LOCATION)
12314            .unwrap()
12315            .to_str()
12316            .unwrap();
12317        assert!(
12318            loc.contains("kind%20of%20feed"),
12319            "expected the unsupported flash: {loc}"
12320        );
12321        assert!(
12322            !loc.contains("capacity"),
12323            "an unacceptable URL was reported as a capacity problem: {loc}"
12324        );
12325        assert!(puts.lock().unwrap().is_empty());
12326    }
12327
12328    /// **`url_changed` compares like for like.** The form value is trimmed;
12329    /// the record's URL was compared raw, so a record another client wrote
12330    /// with a trailing space read as a repoint on every retitle and re-armed
12331    /// every gate — including the one that made an at:// record un-editable.
12332    #[tokio::test]
12333    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
12334        let did = "did:plc:renamer5";
12335        let padded = format!("{AT_URI_SUB} ");
12336        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
12337        let state = test_state_with_sidecar(&[did], &sidecar).await;
12338        // The manage row posts the record's URL verbatim, padding included.
12339        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
12340        assert_eq!(
12341            loc, "/",
12342            "the retitle was treated as a repoint and refused: {loc}"
12343        );
12344        let bodies = puts.lock().unwrap().clone();
12345        assert_eq!(bodies.len(), 1);
12346        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
12347        assert_eq!(
12348            sent["record"]["url"], AT_URI_SUB,
12349            "the padding was not normalised away"
12350        );
12351    }
12352
12353    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
12354    /// only, so the trailing upsert must not create a row for an unchanged URL
12355    /// that has none — with the flag on and the cache full, each retitle of a
12356    /// never-cached at:// record was a row past the cap. An existing row still
12357    /// gets its title kept in step.
12358    #[tokio::test]
12359    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
12360        let did = "did:plc:renamer5";
12361        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
12362        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
12363        store::upsert_feed(
12364            &state.db,
12365            &store::NewFeed {
12366                url: "https://filler.example/feed.xml".to_string(),
12367                ..Default::default()
12368            },
12369        )
12370        .await
12371        .unwrap();
12372        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
12373        assert_eq!(loc, "/", "the retitle was refused: {loc}");
12374        assert_eq!(puts.lock().unwrap().len(), 1);
12375        assert_eq!(
12376            store::count_feeds(&state.db).await.unwrap(),
12377            1,
12378            "a retitle inserted a cache row past the ceiling"
12379        );
12380    }
12381
12382    /// **The add path's at:// pre-check is about the MESSAGE, so it is
12383    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
12384    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
12385    /// tripped the secret heuristic on the rkey — the private/paid flash the
12386    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
12387    /// touch it.
12388    #[tokio::test]
12389    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
12390        let did = "did:plc:typoist";
12391        let state = test_state_with_caps(did, 0, 0).await;
12392        let cookie = session_cookie(&state, did, None);
12393        let resp = router(state.clone())
12394            .oneshot(
12395                Request::builder()
12396                    .method("POST")
12397                    .uri("/subscriptions")
12398                    .header(header::COOKIE, cookie)
12399                    .header("content-type", "application/x-www-form-urlencoded")
12400                    .body(Body::from(
12401                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
12402                    ))
12403                    .unwrap(),
12404            )
12405            .await
12406            .unwrap();
12407        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12408        let loc = resp
12409            .headers()
12410            .get(header::LOCATION)
12411            .unwrap()
12412            .to_str()
12413            .unwrap();
12414        assert!(
12415            loc.contains("kind%20of%20feed"),
12416            "expected the unsupported flash: {loc}"
12417        );
12418        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
12419    }
12420
12421    // -- #149: a rename that races another client's write -------------------
12422
12423    /// One subscription record behind a fake repo that ENFORCES `swapRecord`
12424    /// the way the reference PDS does: a put naming a CID the record is no
12425    /// longer at is refused `400 InvalidSwap`; a put with no swap always lands.
12426    #[derive(Default)]
12427    struct SwapRepo {
12428        /// The record's current value.
12429        value: serde_json::Value,
12430        /// Bumped on every write, so each version has its own CID.
12431        version: u32,
12432        /// Every put request body received, in order, landed or not.
12433        puts: Vec<serde_json::Value>,
12434        /// Another client's write, landed the moment our FIRST put arrives —
12435        /// i.e. between our read and our write.
12436        concurrent: Option<serde_json::Value>,
12437        /// Refuse every put that carries a swap, whatever CID it names.
12438        refuse_every_swap: bool,
12439        /// Refuse every put with this (status, error) — a non-swap failure.
12440        fail_puts: Option<(u16, &'static str)>,
12441        /// The collection the record lives in; the subscription one when unset.
12442        collection: Option<&'static str>,
12443        /// The record is not in the repo: the listing comes back empty.
12444        missing: bool,
12445        /// Every listing fails `502`.
12446        fail_list: bool,
12447    }
12448
12449    impl SwapRepo {
12450        fn cid(&self) -> String {
12451            format!("bafyreiversion{}", self.version)
12452        }
12453
12454        fn nsid(&self) -> &'static str {
12455            self.collection
12456                .unwrap_or(crate::lexicon::nsid::SUBSCRIPTION)
12457        }
12458
12459        fn page(&self) -> serde_json::Value {
12460            if self.missing {
12461                return serde_json::json!({ "records": [] });
12462            }
12463            serde_json::json!({ "records": [{
12464                "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
12465                "cid": self.cid(),
12466                "value": self.value,
12467            }] })
12468        }
12469
12470        /// A put: `Ok(strong ref)` or `Err((status, error name))`.
12471        fn put(&mut self, body: &serde_json::Value) -> Result<serde_json::Value, (u16, String)> {
12472            self.puts.push(body.clone());
12473            if let Some(theirs) = self.concurrent.take() {
12474                self.value = theirs;
12475                self.version += 1;
12476            }
12477            if let Some((status, error)) = self.fail_puts {
12478                return Err((status, error.to_string()));
12479            }
12480            if let Some(swap) = body.get("swapRecord").and_then(|v| v.as_str()) {
12481                if self.refuse_every_swap || swap != self.cid() {
12482                    return Err((400, "InvalidSwap".to_string()));
12483                }
12484            }
12485            self.value = body["record"].clone();
12486            self.version += 1;
12487            Ok(serde_json::json!({
12488                "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
12489                "cid": self.cid(),
12490            }))
12491        }
12492    }
12493
12494    const RACE_DID: &str = "did:plc:racer149";
12495
12496    /// Serve `repo` as both a sidecar (`/internal/repo`) and a PDS (`/xrpc/*`),
12497    /// so one fixture drives either backend. Returns the sidecar base URL and
12498    /// the PDS audience a Rust-backend session should carry.
12499    async fn serve_swap_repo(repo: std::sync::Arc<std::sync::Mutex<SwapRepo>>) -> (String, String) {
12500        use axum::response::IntoResponse as _;
12501        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12502        let addr = listener.local_addr().unwrap();
12503        let host = format!("pds-{}.race.test", addr.port());
12504        crate::net::test_host_override(&host, addr);
12505        let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
12506            let repo = std::sync::Arc::clone(&repo);
12507            async move {
12508                let (parts, body) = req.into_parts();
12509                let raw = axum::body::to_bytes(body, usize::MAX).await.unwrap();
12510                let body: serde_json::Value =
12511                    serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null);
12512                let reply = |status: u16, body: serde_json::Value| {
12513                    (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
12514                };
12515                let mut repo = repo.lock().unwrap();
12516                match (parts.uri.path(), body["action"].as_str()) {
12517                    ("/internal/repo", Some("list")) if repo.fail_list => reply(
12518                        502,
12519                        serde_json::json!({
12520                            "ok": false, "error": "UpstreamFailure", "message": "down", "status": 502,
12521                        }),
12522                    ),
12523                    ("/internal/repo", Some("list")) => {
12524                        reply(200, serde_json::json!({ "ok": true, "data": repo.page() }))
12525                    }
12526                    ("/internal/repo", Some("put")) => match repo.put(&body) {
12527                        Ok(data) => reply(200, serde_json::json!({ "ok": true, "data": data })),
12528                        Err((status, error)) => reply(
12529                            status,
12530                            serde_json::json!({
12531                                "ok": false, "error": error, "message": "refused", "status": status,
12532                            }),
12533                        ),
12534                    },
12535                    ("/xrpc/com.atproto.repo.listRecords", _) if repo.fail_list => reply(
12536                        502,
12537                        serde_json::json!({ "error": "UpstreamFailure", "message": "down" }),
12538                    ),
12539                    ("/xrpc/com.atproto.repo.listRecords", _) => reply(200, repo.page()),
12540                    ("/xrpc/com.atproto.repo.putRecord", _) => match repo.put(&body) {
12541                        Ok(data) => reply(200, data),
12542                        Err((status, error)) => reply(
12543                            status,
12544                            serde_json::json!({ "error": error, "message": "refused" }),
12545                        ),
12546                    },
12547                    other => panic!("unexpected request {other:?}"),
12548                }
12549            }
12550        });
12551        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
12552        (
12553            format!("http://{addr}"),
12554            format!("http://{host}:{}", addr.port()),
12555        )
12556    }
12557
12558    /// An `AppState` on `backend`, pointed at `repo` — the sidecar through its
12559    /// internal URL, the Rust client through a live OAuth session whose `aud`
12560    /// is the fake.
12561    async fn race_state(
12562        backend: crate::metrics::Backend,
12563        repo: &std::sync::Arc<std::sync::Mutex<SwapRepo>>,
12564    ) -> AppState {
12565        let (sidecar, aud) = serve_swap_repo(std::sync::Arc::clone(repo)).await;
12566        let db = store::init_url("sqlite::memory:").await.unwrap();
12567        store::ensure_seed(&db, &[RACE_DID.to_string()])
12568            .await
12569            .unwrap();
12570        let mut config = Config {
12571            allowed_dids: vec![RACE_DID.to_string()],
12572            cookie_secret: "test-cookie-secret-000".to_string(),
12573            beta_cap: 3,
12574            repo_backend: backend,
12575            oauth: crate::config::OauthConfig {
12576                // Per test, never the relative default — see `repo::tests`.
12577                key_path: std::env::temp_dir().join(format!(
12578                    "fr-race-oauth-key-{}-{:p}.json",
12579                    std::process::id(),
12580                    &db as *const _
12581                )),
12582                encryption_key: Some("a".repeat(43)),
12583                ..crate::config::OauthConfig::default()
12584            },
12585            ..Config::default()
12586        };
12587        config.sidecar.public_url = sidecar.clone();
12588        config.sidecar.internal_url = sidecar;
12589        let state = AppState::new(config, db).unwrap();
12590        if backend == crate::metrics::Backend::Rust {
12591            let runtime = state.oauth.as_deref().expect("oauth runtime");
12592            crate::oauth::store::put_session(
12593                &state.db,
12594                &runtime.codec,
12595                &crate::oauth::store::OAuthSession {
12596                    sub: RACE_DID.into(),
12597                    issuer: "https://auth.invalid".into(),
12598                    aud,
12599                    dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
12600                        .to_jwk_json()
12601                        .unwrap(),
12602                    access_token: "at".into(),
12603                    refresh_token: "rt".into(),
12604                    token_type: "DPoP".into(),
12605                    granted_scope: "atproto".into(),
12606                    expires_at: Some(store::now_unix() + 3600),
12607                },
12608            )
12609            .await
12610            .unwrap();
12611        }
12612        state
12613    }
12614
12615    /// The record before anyone touches it — the seeded one, as a value.
12616    fn race_seed() -> serde_json::Value {
12617        seeded_subscription()["value"].clone()
12618    }
12619
12620    /// Post the manage row's rename (url unchanged, a new title and folder) and
12621    /// return the redirect location.
12622    async fn post_race_rename(state: &AppState) -> String {
12623        post_race_rename_body(
12624            state,
12625            "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
12626        )
12627        .await
12628    }
12629
12630    /// Post `body` as the rename of `rk-keep`; returns the redirect location.
12631    async fn post_race_rename_body(state: &AppState, body: &str) -> String {
12632        let cookie = session_cookie(state, RACE_DID, None);
12633        let resp = router(state.clone())
12634            .oneshot(
12635                Request::builder()
12636                    .method("POST")
12637                    .uri("/subscriptions/rk-keep/rename")
12638                    .header(header::COOKIE, cookie)
12639                    .header("content-type", "application/x-www-form-urlencoded")
12640                    .body(Body::from(body.to_string()))
12641                    .unwrap(),
12642            )
12643            .await
12644            .unwrap();
12645        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12646        resp.headers()
12647            .get(header::LOCATION)
12648            .unwrap()
12649            .to_str()
12650            .unwrap()
12651            .to_string()
12652    }
12653
12654    const RACE_BACKENDS: [crate::metrics::Backend; 2] = [
12655        crate::metrics::Backend::Sidecar,
12656        crate::metrics::Backend::Rust,
12657    ];
12658
12659    /// **The key test of #149: a rename that loses a race keeps the other
12660    /// client's change AND lands its own.**
12661    ///
12662    /// The fake lands another client's edit (a new `siteUrl` and `fetchHint`)
12663    /// between the handler's read and its write. The write names the CID it
12664    /// read, so the PDS refuses it; the handler re-reads, re-applies the form's
12665    /// fields to the FRESH record, and writes again under the new CID.
12666    ///
12667    /// With `swapRecord` dropped anywhere on the way out, the first put lands
12668    /// unconditionally and the other client's edit is gone — which is what
12669    /// the final-record assertions catch. Run on both backends: production is
12670    /// on `rust`, and a backend whose put ignores the swap is the exact gap.
12671    #[tokio::test]
12672    async fn a_rename_that_loses_a_race_keeps_the_concurrent_edit_and_lands() {
12673        for backend in RACE_BACKENDS {
12674            let mut theirs = race_seed();
12675            theirs["siteUrl"] = serde_json::json!("https://elsewhere.example/blog");
12676            theirs["fetchHint"] = serde_json::json!("daily");
12677            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12678                value: race_seed(),
12679                concurrent: Some(theirs),
12680                ..SwapRepo::default()
12681            }));
12682            let state = race_state(backend, &repo).await;
12683
12684            let loc = post_race_rename(&state).await;
12685
12686            let repo = repo.lock().unwrap();
12687            assert_eq!(
12688                loc, "/",
12689                "{backend:?}: a rename that converged was not reported as done"
12690            );
12691            assert_eq!(
12692                repo.puts.len(),
12693                2,
12694                "{backend:?}: expected the refused put and one retry: {:?}",
12695                repo.puts
12696            );
12697            assert_eq!(
12698                repo.puts[0]["swapRecord"], "bafyreiversion0",
12699                "{backend:?}: the first put did not name the CID it read: {}",
12700                repo.puts[0]
12701            );
12702            assert_eq!(
12703                repo.puts[1]["swapRecord"], "bafyreiversion1",
12704                "{backend:?}: the retry did not name the RE-READ CID: {}",
12705                repo.puts[1]
12706            );
12707            let landed = &repo.value;
12708            // The reader's change landed...
12709            assert_eq!(landed["title"], "New title", "{backend:?}: {landed}");
12710            assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12711            // ...on top of the other client's, not over it.
12712            assert_eq!(
12713                landed["siteUrl"], "https://elsewhere.example/blog",
12714                "{backend:?}: the concurrent edit was lost: {landed}"
12715            );
12716            assert_eq!(
12717                landed["fetchHint"], "daily",
12718                "{backend:?}: the concurrent edit was lost: {landed}"
12719            );
12720            // And #147's preservation still holds on the retried record.
12721            assert_eq!(
12722                landed["createdAt"], "2024-03-01T00:00:00.000Z",
12723                "{backend:?}: {landed}"
12724            );
12725        }
12726    }
12727
12728    /// **A rename the PDS refuses on every attempt is reported as a conflict,
12729    /// never as done — and is not retried forever.** One retry, so at most two
12730    /// puts; then the reader is told the subscription changed elsewhere.
12731    #[tokio::test]
12732    async fn a_rename_refused_on_every_swap_reports_the_conflict() {
12733        for backend in RACE_BACKENDS {
12734            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12735                value: race_seed(),
12736                refuse_every_swap: true,
12737                ..SwapRepo::default()
12738            }));
12739            let state = race_state(backend, &repo).await;
12740
12741            let loc = post_race_rename(&state).await;
12742
12743            let repo = repo.lock().unwrap();
12744            assert_ne!(loc, "/", "{backend:?}: a refused rename reported success");
12745            assert!(
12746                loc.contains("changed%20elsewhere"),
12747                "{backend:?}: expected the conflict flash, got {loc}"
12748            );
12749            assert!(
12750                (1..=2).contains(&repo.puts.len()),
12751                "{backend:?}: expected at most two put attempts, got {}",
12752                repo.puts.len()
12753            );
12754            assert_eq!(
12755                repo.value,
12756                race_seed(),
12757                "{backend:?}: the record changed though every put was refused"
12758            );
12759        }
12760    }
12761
12762    /// **A put refused for any OTHER reason is not retried**, and keeps the
12763    /// message it had: a re-read cannot fix a rejected record or an outage,
12764    /// and calling it a conflict would send the reader looking for an edit
12765    /// nobody made.
12766    #[tokio::test]
12767    async fn a_rename_refused_for_another_reason_is_not_retried() {
12768        for backend in RACE_BACKENDS {
12769            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12770                value: race_seed(),
12771                fail_puts: Some((400, "InvalidRequest")),
12772                ..SwapRepo::default()
12773            }));
12774            let state = race_state(backend, &repo).await;
12775
12776            let loc = post_race_rename(&state).await;
12777
12778            let repo = repo.lock().unwrap();
12779            assert_eq!(
12780                repo.puts.len(),
12781                1,
12782                "{backend:?}: a non-swap refusal was retried"
12783            );
12784            assert!(
12785                loc.contains("Could%20not%20save"),
12786                "{backend:?}: expected the save-failed flash, got {loc}"
12787            );
12788            assert!(
12789                !loc.contains("changed%20elsewhere"),
12790                "{backend:?}: a non-swap refusal was reported as a conflict: {loc}"
12791            );
12792        }
12793    }
12794
12795    /// What the manage row posts for a reader who changed only the title: the
12796    /// url and folder as they were, plus the `seen_*` values the inputs were
12797    /// pre-filled with.
12798    const SEEN_SEED: &str = "seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12799                             &seen_title=Old+title&seen_folder=";
12800
12801    /// **A retry merges; it does not replay the whole form.** Another client
12802    /// repoints the record (A -> B, with B's own siteUrl and fetchHint) while
12803    /// the reader only retitles it. The form still carries URL A — it is a
12804    /// hidden input — so replaying it on the fresh record "repointed" back to
12805    /// A, cleared the other client's siteUrl and fetchHint, and reported
12806    /// success. The reader changed the title and nothing else, so the title is
12807    /// all that may move. Run with and without the `seen_*` inputs: without
12808    /// them the base is the handler's first read.
12809    #[tokio::test]
12810    async fn a_retry_keeps_a_concurrent_repoint_the_reader_did_not_make() {
12811        for backend in RACE_BACKENDS {
12812            for with_seen in [true, false] {
12813                let mut theirs = race_seed();
12814                theirs["url"] = serde_json::json!("https://moved.example/feed.xml");
12815                theirs["siteUrl"] = serde_json::json!("https://moved.example/");
12816                theirs["fetchHint"] = serde_json::json!("daily");
12817                let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12818                    value: race_seed(),
12819                    concurrent: Some(theirs),
12820                    ..SwapRepo::default()
12821                }));
12822                let state = race_state(backend, &repo).await;
12823                let mut body =
12824                    "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title".to_string();
12825                if with_seen {
12826                    body.push_str(
12827                        "&seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml&seen_title=Old+title",
12828                    );
12829                }
12830
12831                let loc = post_race_rename_body(&state, &body).await;
12832
12833                let repo = repo.lock().unwrap();
12834                let ctx = format!("{backend:?} seen={with_seen}");
12835                assert_eq!(loc, "/", "{ctx}: the rename did not land: {loc}");
12836                assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12837                let landed = &repo.value;
12838                assert_eq!(landed["title"], "New title", "{ctx}: {landed}");
12839                assert_eq!(
12840                    landed["url"], "https://moved.example/feed.xml",
12841                    "{ctx}: the retry repointed the record back to the stale URL: {landed}"
12842                );
12843                assert_eq!(
12844                    landed["siteUrl"], "https://moved.example/",
12845                    "{ctx}: {landed}"
12846                );
12847                assert_eq!(landed["fetchHint"], "daily", "{ctx}: {landed}");
12848            }
12849        }
12850    }
12851
12852    /// The other client retitles; the reader only moves the folder. Their
12853    /// title is kept and the folder applied — the reader's stale copy of the
12854    /// title (posted because the input is always submitted) is not a change.
12855    #[tokio::test]
12856    async fn a_retry_keeps_a_concurrent_retitle_when_the_reader_only_moved_it() {
12857        for backend in RACE_BACKENDS {
12858            let mut theirs = race_seed();
12859            theirs["title"] = serde_json::json!("Their title");
12860            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12861                value: race_seed(),
12862                concurrent: Some(theirs),
12863                ..SwapRepo::default()
12864            }));
12865            let state = race_state(backend, &repo).await;
12866
12867            let loc = post_race_rename_body(
12868                &state,
12869                &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Old+title&folder=Tech&{SEEN_SEED}"),
12870            )
12871            .await;
12872
12873            let repo = repo.lock().unwrap();
12874            assert_eq!(loc, "/", "{backend:?}: {loc}");
12875            let landed = &repo.value;
12876            assert_eq!(
12877                landed["title"], "Their title",
12878                "{backend:?}: the reader's untouched title overwrote the other client's: {landed}"
12879            );
12880            assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12881        }
12882    }
12883
12884    /// **Both changed the same field: a conflict, and nothing is written.**
12885    /// Neither edit can be chosen for the reader, so they are told, and the
12886    /// other client's title stays.
12887    #[tokio::test]
12888    async fn both_retitling_is_a_conflict_that_writes_nothing() {
12889        for backend in RACE_BACKENDS {
12890            let mut theirs = race_seed();
12891            theirs["title"] = serde_json::json!("Their title");
12892            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12893                value: race_seed(),
12894                concurrent: Some(theirs.clone()),
12895                ..SwapRepo::default()
12896            }));
12897            let state = race_state(backend, &repo).await;
12898
12899            let loc = post_race_rename_body(
12900                &state,
12901                &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12902            )
12903            .await;
12904
12905            let repo = repo.lock().unwrap();
12906            assert!(
12907                loc.contains("changed%20elsewhere"),
12908                "{backend:?}: expected the conflict flash, got {loc}"
12909            );
12910            assert_eq!(
12911                repo.puts.len(),
12912                1,
12913                "{backend:?}: a conflicting retry was written: {:?}",
12914                repo.puts
12915            );
12916            assert_eq!(
12917                repo.value, theirs,
12918                "{backend:?}: their title was overwritten"
12919            );
12920        }
12921    }
12922
12923    /// **The page-load window: an edit that landed BEFORE the handler's first
12924    /// read.** The manage page showed URL A; another client repointed to B
12925    /// before the reader pressed Save, so the first read already sees B and
12926    /// no swap fails. The `seen_url` the page was rendered with is what says
12927    /// the reader never touched the URL — without it, the stale hidden `url`
12928    /// reads as a repoint back to A.
12929    #[tokio::test]
12930    async fn a_repoint_before_the_first_read_is_kept_when_the_reader_only_retitled() {
12931        for backend in RACE_BACKENDS {
12932            let mut moved = race_seed();
12933            moved["url"] = serde_json::json!("https://moved.example/feed.xml");
12934            moved["siteUrl"] = serde_json::json!("https://moved.example/");
12935            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12936                value: moved,
12937                ..SwapRepo::default()
12938            }));
12939            let state = race_state(backend, &repo).await;
12940
12941            let loc = post_race_rename_body(
12942                &state,
12943                &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12944            )
12945            .await;
12946
12947            let repo = repo.lock().unwrap();
12948            assert_eq!(loc, "/", "{backend:?}: {loc}");
12949            assert_eq!(repo.puts.len(), 1, "{backend:?}");
12950            let landed = &repo.value;
12951            assert_eq!(
12952                landed["url"], "https://moved.example/feed.xml",
12953                "{backend:?}: the stale hidden url repointed the record: {landed}"
12954            );
12955            assert_eq!(landed["siteUrl"], "https://moved.example/", "{backend:?}");
12956            assert_eq!(landed["title"], "New title", "{backend:?}");
12957        }
12958    }
12959
12960    /// **The cache follows the PDS, never leads it.** A rename that did not
12961    /// land — every swap refused, or the put failed for another reason — must
12962    /// leave the local `feeds` cache as it was: no row for a repoint's new URL
12963    /// (the poller would fetch a feed nobody subscribes to), and no new title
12964    /// on the existing row. One that landed updates it as before.
12965    #[tokio::test]
12966    async fn a_rename_that_did_not_land_leaves_the_cache_alone() {
12967        const NEW_URL: &str = "https://other.example/feed.xml";
12968        const OLD_URL: &str = "https://example.com/feed.xml";
12969        // (refuse every swap, fail every put, expect the write to land)
12970        for (refuse_every_swap, fail_puts, lands) in [
12971            (true, None, false),
12972            (false, Some((400, "InvalidRequest")), false),
12973            (false, Some((502, "UpstreamFailure")), false),
12974            (false, None, true),
12975        ] {
12976            for backend in RACE_BACKENDS {
12977                let ctx = format!("{backend:?} refuse={refuse_every_swap} fail={fail_puts:?}");
12978                for repoint in [false, true] {
12979                    let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12980                        value: race_seed(),
12981                        refuse_every_swap,
12982                        fail_puts,
12983                        ..SwapRepo::default()
12984                    }));
12985                    let state = race_state(backend, &repo).await;
12986                    store::upsert_feed(
12987                        &state.db,
12988                        &store::NewFeed {
12989                            url: OLD_URL.to_string(),
12990                            title: Some("Cached title".to_string()),
12991                            ..Default::default()
12992                        },
12993                    )
12994                    .await
12995                    .unwrap();
12996                    let url = if repoint { NEW_URL } else { OLD_URL };
12997                    let body = format!("url={}&title=New+title&{SEEN_SEED}", qenc(url));
12998
12999                    let loc = post_race_rename_body(&state, &body).await;
13000
13001                    let new_row = store::get_feed_by_url(&state.db, NEW_URL).await.unwrap();
13002                    let old_row = store::get_feed_by_url(&state.db, OLD_URL)
13003                        .await
13004                        .unwrap()
13005                        .expect("the old row");
13006                    let ctx = format!("{ctx} repoint={repoint} -> {loc}");
13007                    if lands {
13008                        assert_eq!(loc, "/", "{ctx}");
13009                        if repoint {
13010                            assert!(
13011                                new_row.is_some(),
13012                                "{ctx}: a landed repoint got no cache row"
13013                            );
13014                        } else {
13015                            assert_eq!(old_row.title.as_deref(), Some("New title"), "{ctx}");
13016                        }
13017                    } else {
13018                        assert_ne!(loc, "/", "{ctx}");
13019                        assert!(
13020                            new_row.is_none(),
13021                            "{ctx}: a repoint that did not land left a feeds row for its URL"
13022                        );
13023                        assert_eq!(
13024                            old_row.title.as_deref(),
13025                            Some("Cached title"),
13026                            "{ctx}: a rename that did not land changed the cached title"
13027                        );
13028                    }
13029                }
13030            }
13031        }
13032    }
13033
13034    /// **A page with no folder dropdown does not un-folder.** The select (and
13035    /// its `seen_folder`) render only when the reader has folders the page
13036    /// could list — none, or a failed folder listing, and neither is posted.
13037    /// That is "the reader never saw a folder", not "the reader chose none":
13038    /// a retitle from such a page used to un-folder the subscription, and on
13039    /// a retry could report a conflict on a field the reader never saw.
13040    #[tokio::test]
13041    async fn a_rename_from_a_page_without_a_folder_select_keeps_the_folder() {
13042        for backend in RACE_BACKENDS {
13043            for raced in [false, true] {
13044                let mut seed = race_seed();
13045                seed["folder"] = serde_json::json!("at://did:plc:racer149/folder/kept");
13046                let concurrent = raced.then(|| {
13047                    let mut theirs = seed.clone();
13048                    theirs["folder"] = serde_json::json!("at://did:plc:racer149/folder/theirs");
13049                    theirs
13050                });
13051                let want_folder = concurrent
13052                    .as_ref()
13053                    .map_or(seed["folder"].clone(), |t| t["folder"].clone());
13054                let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
13055                    value: seed,
13056                    concurrent,
13057                    ..SwapRepo::default()
13058                }));
13059                let state = race_state(backend, &repo).await;
13060
13061                // Exactly what the manage row posts with no folder select.
13062                let loc = post_race_rename_body(
13063                    &state,
13064                    "url=https%3A%2F%2Fexample.com%2Ffeed.xml\
13065                     &seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
13066                     &seen_title=Old+title&title=New+title",
13067                )
13068                .await;
13069
13070                let repo = repo.lock().unwrap();
13071                let ctx = format!("{backend:?} raced={raced}");
13072                assert_eq!(loc, "/", "{ctx}: {loc}");
13073                assert_eq!(repo.value["title"], "New title", "{ctx}");
13074                assert_eq!(
13075                    repo.value["folder"], want_folder,
13076                    "{ctx}: a page that never showed a folder changed it: {}",
13077                    repo.value
13078                );
13079            }
13080        }
13081    }
13082
13083    /// **A double-clicked Save is not a conflict.** Both POSTs read the same
13084    /// CID; the first lands; the second's swap fails, and its re-read finds
13085    /// the record already saying exactly what the reader asked for. That is
13086    /// success, with nothing left to write — not "nothing was renamed".
13087    #[tokio::test]
13088    async fn a_double_submitted_rename_reports_success_and_writes_once() {
13089        for backend in RACE_BACKENDS {
13090            // The first submission's write, landing between the second's read
13091            // and its put.
13092            let mut first = race_seed();
13093            first["title"] = serde_json::json!("New title");
13094            first["folder"] = serde_json::json!("Tech");
13095            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
13096                value: race_seed(),
13097                concurrent: Some(first.clone()),
13098                ..SwapRepo::default()
13099            }));
13100            let state = race_state(backend, &repo).await;
13101
13102            let loc = post_race_rename_body(
13103                &state,
13104                &format!(
13105                    "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech&{SEEN_SEED}"
13106                ),
13107            )
13108            .await;
13109
13110            let repo = repo.lock().unwrap();
13111            assert_eq!(
13112                loc, "/",
13113                "{backend:?}: a save that landed was reported as a conflict: {loc}"
13114            );
13115            assert_eq!(
13116                repo.puts.len(),
13117                1,
13118                "{backend:?}: only the refused put; the re-read has nothing left to write: {:?}",
13119                repo.puts
13120            );
13121            assert_eq!(repo.value, first, "{backend:?}");
13122        }
13123    }
13124
13125    // -- #268: renaming a folder edits the record, it does not replace it ----
13126
13127    /// A folder record as another `community.lexicon.rss` client might have
13128    /// left it: a sort position, an old `createdAt`, and a field this build
13129    /// does not know.
13130    fn folder_seed() -> serde_json::Value {
13131        serde_json::json!({
13132            "$type": crate::lexicon::nsid::FOLDER,
13133            "name": "Old name",
13134            "position": 3,
13135            "createdAt": "2024-01-01T00:00:00.000Z",
13136            "color": "#abc",
13137        })
13138    }
13139
13140    /// A [`SwapRepo`] holding `value` as the folder `rk-keep`.
13141    fn folder_repo(value: serde_json::Value) -> SwapRepo {
13142        SwapRepo {
13143            value,
13144            collection: Some(crate::lexicon::nsid::FOLDER),
13145            ..SwapRepo::default()
13146        }
13147    }
13148
13149    /// Post `body` as the rename of folder `rk-keep`; returns the redirect.
13150    async fn post_folder_rename(state: &AppState, body: &str) -> String {
13151        let cookie = session_cookie(state, RACE_DID, None);
13152        let resp = router(state.clone())
13153            .oneshot(
13154                Request::builder()
13155                    .method("POST")
13156                    .uri("/folders/rk-keep/rename")
13157                    .header(header::COOKIE, cookie)
13158                    .header("content-type", "application/x-www-form-urlencoded")
13159                    .body(Body::from(body.to_string()))
13160                    .unwrap(),
13161            )
13162            .await
13163            .unwrap();
13164        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13165        resp.headers()
13166            .get(header::LOCATION)
13167            .unwrap()
13168            .to_str()
13169            .unwrap()
13170            .to_string()
13171    }
13172
13173    /// `value` with its name set to `name`.
13174    fn renamed(mut value: serde_json::Value, name: &str) -> serde_json::Value {
13175        value["name"] = serde_json::json!(name);
13176        value
13177    }
13178
13179    /// **The key test of #268: a rename changes the name and nothing else.**
13180    /// The handler used to put `Folder::new(name, now)` over the record, which
13181    /// reset `position`, replaced `createdAt` with the rename time and dropped
13182    /// every field another client had added. The exact body put is asserted,
13183    /// so any field lost or invented on the way fails it — on both backends.
13184    #[tokio::test]
13185    async fn renaming_a_folder_changes_only_its_name() {
13186        for backend in RACE_BACKENDS {
13187            let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(folder_seed())));
13188            let state = race_state(backend, &repo).await;
13189
13190            let loc = post_folder_rename(&state, "name=New+name").await;
13191
13192            let repo = repo.lock().unwrap();
13193            assert_eq!(
13194                loc, "/",
13195                "{backend:?}: a landed rename was not reported as done"
13196            );
13197            assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
13198            assert_eq!(
13199                repo.puts[0]["record"],
13200                renamed(folder_seed(), "New name"),
13201                "{backend:?}: the put did not keep the record whole: {}",
13202                repo.puts[0]
13203            );
13204            assert_eq!(
13205                repo.puts[0]["collection"],
13206                crate::lexicon::nsid::FOLDER,
13207                "{backend:?}"
13208            );
13209            assert_eq!(
13210                repo.puts[0]["swapRecord"], "bafyreiversion0",
13211                "{backend:?}: the put did not name the CID it read: {}",
13212                repo.puts[0]
13213            );
13214        }
13215    }
13216
13217    /// **A rename that loses a race keeps the other client's change and
13218    /// lands.** Another client moves the folder (and adds a field) between the
13219    /// read and the write; the swap is refused, the handler re-reads and
13220    /// renames the FRESH record.
13221    #[tokio::test]
13222    async fn a_folder_rename_that_loses_a_race_keeps_the_concurrent_edit() {
13223        for backend in RACE_BACKENDS {
13224            for with_seen in [true, false] {
13225                let mut theirs = folder_seed();
13226                theirs["position"] = serde_json::json!(7);
13227                theirs["icon"] = serde_json::json!("star");
13228                let mut fake = folder_repo(folder_seed());
13229                fake.concurrent = Some(theirs.clone());
13230                let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13231                let state = race_state(backend, &repo).await;
13232
13233                let body = if with_seen {
13234                    "name=New+name&seen_name=Old+name"
13235                } else {
13236                    "name=New+name"
13237                };
13238                let loc = post_folder_rename(&state, body).await;
13239
13240                let repo = repo.lock().unwrap();
13241                let ctx = format!("{backend:?} with_seen={with_seen}");
13242                assert_eq!(
13243                    loc, "/",
13244                    "{ctx}: a converged rename was not reported as done"
13245                );
13246                assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
13247                assert_eq!(repo.puts[0]["swapRecord"], "bafyreiversion0", "{ctx}");
13248                assert_eq!(
13249                    repo.puts[1]["swapRecord"], "bafyreiversion1",
13250                    "{ctx}: the retry did not name the RE-READ CID"
13251                );
13252                assert_eq!(
13253                    repo.value,
13254                    renamed(theirs, "New name"),
13255                    "{ctx}: the concurrent edit was lost"
13256                );
13257            }
13258        }
13259    }
13260
13261    /// **Both renaming the folder, differently, is a conflict that writes
13262    /// nothing** — the reader is told, and the other client's name stands.
13263    /// With and without `seen_name`: without it, the first read is the
13264    /// ancestor.
13265    #[tokio::test]
13266    async fn both_renaming_a_folder_differently_is_a_conflict() {
13267        for backend in RACE_BACKENDS {
13268            for body in ["name=New+name&seen_name=Old+name", "name=New+name"] {
13269                let theirs = renamed(folder_seed(), "Their name");
13270                let mut fake = folder_repo(folder_seed());
13271                fake.concurrent = Some(theirs.clone());
13272                let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13273                let state = race_state(backend, &repo).await;
13274
13275                let loc = post_folder_rename(&state, body).await;
13276
13277                let repo = repo.lock().unwrap();
13278                assert!(
13279                    loc.contains("changed%20elsewhere"),
13280                    "{backend:?} {body}: expected the conflict flash, got {loc}"
13281                );
13282                assert_eq!(
13283                    repo.puts.len(),
13284                    1,
13285                    "{backend:?} {body}: only the refused put: {:?}",
13286                    repo.puts
13287                );
13288                assert_eq!(
13289                    repo.value, theirs,
13290                    "{backend:?} {body}: the other client's name was overwritten"
13291                );
13292            }
13293        }
13294    }
13295
13296    /// **Both renaming it to the SAME name is agreement** — a double-submitted
13297    /// Save whose first request landed. Success, and no second write.
13298    #[tokio::test]
13299    async fn both_renaming_a_folder_the_same_is_success_without_a_write() {
13300        for backend in RACE_BACKENDS {
13301            let theirs = renamed(folder_seed(), "New name");
13302            let mut fake = folder_repo(folder_seed());
13303            fake.concurrent = Some(theirs.clone());
13304            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13305            let state = race_state(backend, &repo).await;
13306
13307            let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
13308
13309            let repo = repo.lock().unwrap();
13310            assert_eq!(
13311                loc, "/",
13312                "{backend:?}: agreement reported as a failure: {loc}"
13313            );
13314            assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
13315            assert_eq!(repo.value, theirs, "{backend:?}");
13316        }
13317    }
13318
13319    /// **A folder that keeps moving is a conflict after one retry**, never
13320    /// reported as renamed and never retried forever.
13321    #[tokio::test]
13322    async fn a_folder_rename_refused_on_every_swap_reports_the_conflict() {
13323        for backend in RACE_BACKENDS {
13324            let mut fake = folder_repo(folder_seed());
13325            fake.refuse_every_swap = true;
13326            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13327            let state = race_state(backend, &repo).await;
13328
13329            let loc = post_folder_rename(&state, "name=New+name").await;
13330
13331            let repo = repo.lock().unwrap();
13332            assert!(
13333                loc.contains("changed%20elsewhere"),
13334                "{backend:?}: expected the conflict flash, got {loc}"
13335            );
13336            assert_eq!(repo.puts.len(), 2, "{backend:?}: one try and one retry");
13337            assert_eq!(repo.value, folder_seed(), "{backend:?}");
13338        }
13339    }
13340
13341    /// **A failed rename tells the reader**, instead of redirecting as if it
13342    /// had worked — and a failure a re-read cannot fix is not retried.
13343    #[tokio::test]
13344    async fn a_failed_folder_rename_shows_an_error() {
13345        for backend in RACE_BACKENDS {
13346            let mut fake = folder_repo(folder_seed());
13347            fake.fail_puts = Some((502, "UpstreamFailure"));
13348            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13349            let state = race_state(backend, &repo).await;
13350
13351            let loc = post_folder_rename(&state, "name=New+name").await;
13352
13353            let repo = repo.lock().unwrap();
13354            assert_ne!(loc, "/", "{backend:?}: a failed rename reported success");
13355            assert!(
13356                loc.contains("Could%20not%20save"),
13357                "{backend:?}: expected the save-failed flash, got {loc}"
13358            );
13359            assert!(!loc.contains("changed%20elsewhere"), "{backend:?}: {loc}");
13360            assert_eq!(
13361                repo.puts.len(),
13362                1,
13363                "{backend:?}: a non-swap failure was retried"
13364            );
13365        }
13366    }
13367
13368    /// **A folder deleted elsewhere is not recreated.** A put at a missing
13369    /// rkey creates the record, which a rename must not do.
13370    #[tokio::test]
13371    async fn renaming_a_folder_that_no_longer_exists_writes_nothing() {
13372        for backend in RACE_BACKENDS {
13373            let mut fake = folder_repo(folder_seed());
13374            fake.missing = true;
13375            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13376            let state = race_state(backend, &repo).await;
13377
13378            let loc = post_folder_rename(&state, "name=New+name").await;
13379
13380            let repo = repo.lock().unwrap();
13381            assert!(
13382                loc.contains("no%20longer%20exists"),
13383                "{backend:?}: expected the missing-folder flash, got {loc}"
13384            );
13385            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13386        }
13387    }
13388
13389    /// **A folder that cannot be read is not renamed** — rebuilding it from
13390    /// the form instead is the record loss this read exists to prevent.
13391    #[tokio::test]
13392    async fn a_folder_rename_whose_read_fails_writes_nothing() {
13393        for backend in RACE_BACKENDS {
13394            let mut fake = folder_repo(folder_seed());
13395            fake.fail_list = true;
13396            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13397            let state = race_state(backend, &repo).await;
13398
13399            let loc = post_folder_rename(&state, "name=New+name").await;
13400
13401            let repo = repo.lock().unwrap();
13402            assert!(
13403                loc.contains("Could%20not%20reach"),
13404                "{backend:?}: expected the read-failed flash, got {loc}"
13405            );
13406            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13407        }
13408    }
13409
13410    /// **`seen_name` closes the page-load window.** Another client renamed the
13411    /// folder after the page was rendered but before the handler read it, so
13412    /// no swap fails; the form still says what the reader saw, and their
13413    /// different rename is a conflict rather than a silent overwrite.
13414    #[tokio::test]
13415    async fn a_rename_elsewhere_after_page_load_is_a_conflict_with_seen_name() {
13416        for backend in RACE_BACKENDS {
13417            let theirs = renamed(folder_seed(), "Their name");
13418            let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
13419            let state = race_state(backend, &repo).await;
13420
13421            let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
13422
13423            let repo = repo.lock().unwrap();
13424            assert!(
13425                loc.contains("changed%20elsewhere"),
13426                "{backend:?}: expected the conflict flash, got {loc}"
13427            );
13428            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13429            assert_eq!(repo.value, theirs, "{backend:?}");
13430        }
13431    }
13432
13433    /// A form whose name is the one it showed changes nothing: no write, and
13434    /// a rename another client made since stands.
13435    #[tokio::test]
13436    async fn an_unchanged_folder_name_writes_nothing() {
13437        for backend in RACE_BACKENDS {
13438            let theirs = renamed(folder_seed(), "Their name");
13439            let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
13440            let state = race_state(backend, &repo).await;
13441
13442            let loc = post_folder_rename(&state, "name=Old+name&seen_name=Old+name").await;
13443
13444            let repo = repo.lock().unwrap();
13445            assert_eq!(loc, "/", "{backend:?}");
13446            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13447            assert_eq!(repo.value, theirs, "{backend:?}");
13448        }
13449    }
13450
13451    /// The folder merge, case by case (#268).
13452    #[test]
13453    fn merge_folder_rename_is_a_three_way_merge_on_the_name() {
13454        let base = Folder::new("Old", "2024-01-01T00:00:00.000Z");
13455        let with = |name: &str| {
13456            let mut f = base.clone();
13457            f.name = name.to_string();
13458            f.position = Some(3);
13459            f.extra
13460                .insert("color".to_string(), serde_json::json!("#abc"));
13461            f
13462        };
13463        // The reader left the name as shown: nothing to write.
13464        assert_eq!(
13465            merge_folder_rename("Old", Some("Old"), &base, with("Other")),
13466            Ok(FolderMerge::Unchanged)
13467        );
13468        // Only the reader changed it: the FRESH record, renamed.
13469        assert_eq!(
13470            merge_folder_rename("New", Some("Old"), &base, with("Old")),
13471            Ok(FolderMerge::Write(with("New")))
13472        );
13473        // Fresh already holds the reader's name: agreement.
13474        assert_eq!(
13475            merge_folder_rename("New", Some("Old"), &base, with("New")),
13476            Ok(FolderMerge::AlreadySaved)
13477        );
13478        // Both changed it, differently: a conflict.
13479        assert_eq!(
13480            merge_folder_rename("New", Some("Old"), &base, with("Other")),
13481            Err(RenameConflict("name"))
13482        );
13483        // Without seen_name the first read is the ancestor.
13484        assert_eq!(
13485            merge_folder_rename("New", None, &with("Other"), with("Other")),
13486            Ok(FolderMerge::Write(with("New")))
13487        );
13488        assert_eq!(
13489            merge_folder_rename("New", None, &base, with("Other")),
13490            Err(RenameConflict("name"))
13491        );
13492        // Padding is not a change.
13493        assert_eq!(
13494            merge_folder_rename(" Old ", Some("Old "), &base, with("Other")),
13495            Ok(FolderMerge::Unchanged)
13496        );
13497        assert_eq!(
13498            merge_folder_rename("New ", Some("Old"), &base, with(" Old ")),
13499            Ok(FolderMerge::Write(with("New")))
13500        );
13501    }
13502
13503    /// Both sides changing a field to the SAME value is agreement, not a
13504    /// conflict — for every field the merge handles. Alongside a field still
13505    /// to apply, the write goes ahead with it; alone, there is nothing to
13506    /// write and the save is already done.
13507    #[test]
13508    fn the_same_change_on_both_sides_is_not_a_conflict() {
13509        let base = merge_base();
13510        let url = "https://a.example/feed.xml";
13511        let new_url = "https://c.example/feed.xml";
13512
13513        // title: both "New".
13514        let mut fresh = base.clone();
13515        fresh.title = Some("New".to_string());
13516        let merged = merge_rename(&merge_form(url, "New", Some("at://f/old")), &base, fresh)
13517            .expect("same title is no conflict");
13518        assert!(merged.already_saved, "nothing left to write");
13519
13520        // folder: both moved to the same folder, while the reader also retitles.
13521        let mut fresh = base.clone();
13522        fresh.folder = Some("at://f/new".to_string());
13523        let merged = merge_rename(&merge_form(url, "Mine", Some("at://f/new")), &base, fresh)
13524            .expect("same folder is no conflict");
13525        assert!(!merged.already_saved, "the title is still to write");
13526        assert_eq!(merged.sub.title.as_deref(), Some("Mine"));
13527        assert_eq!(merged.sub.folder.as_deref(), Some("at://f/new"));
13528
13529        // url: both repointed to the same URL. Not a repoint by THIS write, so
13530        // the fresh record's siteUrl (which may be for the new feed) stays.
13531        let mut fresh = base.clone();
13532        fresh.url = new_url.to_string();
13533        fresh.site_url = Some("https://c.example/".to_string());
13534        let merged = merge_rename(
13535            &merge_form(new_url, "Old", Some("at://f/old")),
13536            &base,
13537            fresh,
13538        )
13539        .expect("same url is no conflict");
13540        assert!(merged.already_saved);
13541        assert!(!merged.repoint);
13542        assert_eq!(merged.sub.site_url.as_deref(), Some("https://c.example/"));
13543
13544        // siteUrl: both set it the same.
13545        let mut fresh = base.clone();
13546        fresh.site_url = Some("https://same.example/".to_string());
13547        let mut form = merge_form(url, "Old", Some("at://f/old"));
13548        form.site_url = Some("https://same.example/".to_string());
13549        let merged = merge_rename(&form, &base, fresh).expect("same siteUrl is no conflict");
13550        assert!(merged.already_saved);
13551
13552        // A form with no edits at all is NOT "already saved": it writes, as
13553        // it always has.
13554        let merged = merge_rename(
13555            &merge_form(url, "Old", Some("at://f/old")),
13556            &base,
13557            base.clone(),
13558        )
13559        .unwrap();
13560        assert!(!merged.already_saved);
13561    }
13562
13563    fn merge_form(url: &str, title: &str, folder: Option<&str>) -> RenameSubForm {
13564        RenameSubForm {
13565            url: url.to_string(),
13566            title: Some(title.to_string()),
13567            site_url: None,
13568            folder: folder.map(str::to_string),
13569            seen_url: None,
13570            seen_title: None,
13571            seen_folder: None,
13572        }
13573    }
13574
13575    fn merge_base() -> Subscription {
13576        let mut s = Subscription::new("https://a.example/feed.xml", "2024-03-01T00:00:00.000Z");
13577        s.title = Some("Old".to_string());
13578        s.folder = Some("at://f/old".to_string());
13579        s.site_url = Some("https://a.example/".to_string());
13580        s
13581    }
13582
13583    /// The merge, field by field, for the branches the handler tests do not
13584    /// each reach: every field the reader changed that someone else also
13585    /// changed is a conflict; every field only one side changed merges.
13586    #[test]
13587    fn merge_rename_is_a_three_way_merge_per_field() {
13588        let base = merge_base();
13589        let url = "https://a.example/feed.xml";
13590
13591        // Folder: both moved it -> conflict; only the reader -> applied.
13592        let mut theirs = base.clone();
13593        theirs.folder = Some("at://f/theirs".to_string());
13594        assert_eq!(
13595            merge_rename(
13596                &merge_form(url, "Old", Some("at://f/mine")),
13597                &base,
13598                theirs.clone()
13599            ),
13600            Err(RenameConflict("folder"))
13601        );
13602        let merged = merge_rename(
13603            &merge_form(url, "Old", Some("at://f/mine")),
13604            &base,
13605            base.clone(),
13606        )
13607        .unwrap();
13608        assert_eq!(merged.sub.folder.as_deref(), Some("at://f/mine"));
13609        assert!(!merged.repoint);
13610        // Only they moved it: theirs stands.
13611        let merged =
13612            merge_rename(&merge_form(url, "Old", Some("at://f/old")), &base, theirs).unwrap();
13613        assert_eq!(merged.sub.folder.as_deref(), Some("at://f/theirs"));
13614
13615        // URL: both repointed -> conflict.
13616        let mut moved = base.clone();
13617        moved.url = "https://b.example/feed.xml".to_string();
13618        assert_eq!(
13619            merge_rename(
13620                &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
13621                &base,
13622                moved
13623            ),
13624            Err(RenameConflict("url"))
13625        );
13626
13627        // siteUrl: a posted value both sides changed -> conflict.
13628        let mut resited = base.clone();
13629        resited.site_url = Some("https://theirs.example/".to_string());
13630        let mut form = merge_form(url, "Old", Some("at://f/old"));
13631        form.site_url = Some("https://mine.example/".to_string());
13632        assert_eq!(
13633            merge_rename(&form, &base, resited),
13634            Err(RenameConflict("siteUrl"))
13635        );
13636
13637        // A reader's repoint drops the old feed's properties.
13638        let merged = merge_rename(
13639            &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
13640            &base,
13641            base.clone(),
13642        )
13643        .unwrap();
13644        assert!(merged.repoint);
13645        assert_eq!(merged.sub.url, "https://c.example/feed.xml");
13646        assert_eq!(merged.sub.site_url, None);
13647
13648        // seen_* wins over base for "did the reader change it": the input was
13649        // pre-filled with a display title, and posting it back is no edit.
13650        let mut untitled = base.clone();
13651        untitled.title = None;
13652        let mut form = merge_form(url, "A display fallback", Some("at://f/old"));
13653        form.seen_title = Some("A display fallback".to_string());
13654        let merged = merge_rename(&form, &untitled, untitled.clone()).unwrap();
13655        assert_eq!(
13656            merged.sub.title, None,
13657            "an untouched display title was written"
13658        );
13659    }
13660
13661    /// **A rename must not destroy the fields the form never carries.**
13662    ///
13663    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
13664    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
13665    /// every field absent from `templates/manage_row.html` (which posts only
13666    /// `url`, `title`, `folder`) was written back as its default:
13667    ///
13668    /// | field | before | after |
13669    /// |---|---|---|
13670    /// | `siteUrl` | whatever the feed advertised | gone |
13671    /// | `fetchHint` | as set | gone |
13672    /// | `private` | as set | gone |
13673    /// | `createdAt` | original subscribe time | reset to now |
13674    ///
13675    /// `createdAt` is the worst of the four: it is the sort key for "when did I
13676    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
13677    /// tells the reader it moved.
13678    ///
13679    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
13680    /// in the test — the record only becomes wrong on the way out, so checking
13681    /// the value we passed in would pass just as happily with the fix removed.
13682    #[tokio::test]
13683    async fn renaming_preserves_the_fields_the_form_never_carries() {
13684        let did = "did:plc:renamer4";
13685        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13686        let state = test_state_with_sidecar(&[did], &sidecar).await;
13687        let cookie = session_cookie(&state, did, None);
13688
13689        let resp = router(state.clone())
13690            .oneshot(
13691                Request::builder()
13692                    .method("POST")
13693                    .uri("/subscriptions/rk-keep/rename")
13694                    .header(header::COOKIE, cookie)
13695                    .header("content-type", "application/x-www-form-urlencoded")
13696                    // Exactly what the manage row posts: url, title, folder.
13697                    .body(Body::from(
13698                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
13699                    ))
13700                    .unwrap(),
13701            )
13702            .await
13703            .unwrap();
13704        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13705
13706        let bodies = puts.lock().unwrap().clone();
13707        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13708        let body = &bodies[0];
13709        // Anchors the negative assertions: an empty capture would satisfy them.
13710        assert!(
13711            body.contains("community.lexicon.rss.subscription"),
13712            "captured no usable put body: {body:?}"
13713        );
13714
13715        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
13716        let record = &sent["record"];
13717
13718        // What the form DID carry must be applied.
13719        assert_eq!(record["title"], "New title", "the rename did not apply");
13720        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
13721
13722        // What the form did NOT carry must survive.
13723        assert_eq!(
13724            record["createdAt"], "2024-03-01T00:00:00.000Z",
13725            "the rename reset createdAt — the reader's subscribe time is gone \
13726             from their own repo, and nothing told them"
13727        );
13728        assert_eq!(
13729            record["siteUrl"], "https://example.com/blog",
13730            "the rename erased siteUrl"
13731        );
13732        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
13733        assert_eq!(record["private"], false, "the rename erased private");
13734    }
13735
13736    /// **Repointing at a different feed drops that feed's properties, but not
13737    /// the subscription's.**
13738    ///
13739    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
13740    /// so carrying them onto a different URL would leave a site link for the old
13741    /// feed hanging off the new one. `createdAt` and `private` are properties of
13742    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
13743    /// subscribed, whatever the URL was later corrected to.
13744    #[tokio::test]
13745    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
13746        let did = "did:plc:renamer4";
13747        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13748        let state = test_state_with_sidecar(&[did], &sidecar).await;
13749        let cookie = session_cookie(&state, did, None);
13750
13751        let resp = router(state.clone())
13752            .oneshot(
13753                Request::builder()
13754                    .method("POST")
13755                    .uri("/subscriptions/rk-keep/rename")
13756                    .header(header::COOKIE, cookie)
13757                    .header("content-type", "application/x-www-form-urlencoded")
13758                    // A DIFFERENT feed URL from the seeded record.
13759                    .body(Body::from(
13760                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
13761                    ))
13762                    .unwrap(),
13763            )
13764            .await
13765            .unwrap();
13766        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13767
13768        let bodies = puts.lock().unwrap().clone();
13769        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13770        assert!(
13771            bodies[0].contains("community.lexicon.rss.subscription"),
13772            "captured no usable put body: {:?}",
13773            bodies[0]
13774        );
13775        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13776        let record = &sent["record"];
13777
13778        assert_eq!(record["url"], "https://other.example/feed.xml");
13779        // The old feed's properties are gone rather than misattributed.
13780        assert!(
13781            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
13782            "the old feed's site link followed the subscription to a new feed: {record}"
13783        );
13784        assert!(
13785            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
13786            "the old feed's fetch hint followed the subscription to a new feed: {record}"
13787        );
13788        // The subscription's own properties survive.
13789        assert_eq!(
13790            record["createdAt"], "2024-03-01T00:00:00.000Z",
13791            "a repoint is still not a new subscription; createdAt must not move"
13792        );
13793        assert_eq!(record["private"], false, "the repoint erased private");
13794    }
13795
13796    /// **A rename against an rkey that is not in the repo writes NOTHING.**
13797    ///
13798    /// `update_subscription` is a `putRecord`, which CREATES the record when the
13799    /// rkey does not exist — with whatever `createdAt` we hand it. So without
13800    /// this refusal a rename against a stale or wrong rkey manufactures a
13801    /// subscription dated today, which is the bug this whole change exists to
13802    /// fix, arriving by a different door.
13803    ///
13804    /// The guard was untested when first written: removing it left all 733 tests
13805    /// green. An untested guard against the exact defect being fixed is how the
13806    /// two previous rounds of this problem got through.
13807    #[tokio::test]
13808    async fn renaming_an_unknown_rkey_writes_nothing() {
13809        let did = "did:plc:renamer4";
13810        // The sidecar serves exactly one record, at rkey `rk-keep`.
13811        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13812        let state = test_state_with_sidecar(&[did], &sidecar).await;
13813        let cookie = session_cookie(&state, did, None);
13814
13815        let resp = router(state.clone())
13816            .oneshot(
13817                Request::builder()
13818                    .method("POST")
13819                    // ...and this is not it.
13820                    .uri("/subscriptions/rk-does-not-exist/rename")
13821                    .header(header::COOKIE, cookie)
13822                    .header("content-type", "application/x-www-form-urlencoded")
13823                    .body(Body::from(
13824                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
13825                    ))
13826                    .unwrap(),
13827            )
13828            .await
13829            .unwrap();
13830
13831        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13832        let loc = resp
13833            .headers()
13834            .get(header::LOCATION)
13835            .unwrap()
13836            .to_str()
13837            .unwrap();
13838        assert!(
13839            loc.contains("flash="),
13840            "an unknown rkey redirected as though the rename had worked: {loc}"
13841        );
13842        assert!(
13843            puts.lock().unwrap().is_empty(),
13844            "a rename against an unknown rkey wrote a record — putRecord would \
13845             CREATE it, dated today: {:?}",
13846            puts.lock().unwrap()
13847        );
13848    }
13849
13850    /// **A `site_url` the client actually sends is applied, not dropped.**
13851    ///
13852    /// `templates/manage_row.html` does not post this field, so it is tempting
13853    /// to read the arm that handles it as dead code. It is not:
13854    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
13855    /// today. Discarding the value instead of applying it left all 733 tests
13856    /// green.
13857    ///
13858    /// The value is scheme-checked on the way out by the repo-boundary vet, so
13859    /// this is a coverage gap rather than an exposure — but an untested path
13860    /// that writes a URL into the reader's PDS should not stay untested.
13861    #[tokio::test]
13862    async fn a_client_supplied_site_url_reaches_the_record() {
13863        let did = "did:plc:renamer4";
13864        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13865        let state = test_state_with_sidecar(&[did], &sidecar).await;
13866        let cookie = session_cookie(&state, did, None);
13867
13868        let resp = router(state.clone())
13869            .oneshot(
13870                Request::builder()
13871                    .method("POST")
13872                    .uri("/subscriptions/rk-keep/rename")
13873                    .header(header::COOKIE, cookie)
13874                    .header("content-type", "application/x-www-form-urlencoded")
13875                    // Same feed URL, but carrying a site_url the manage row
13876                    // never sends.
13877                    .body(Body::from(
13878                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
13879                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
13880                    ))
13881                    .unwrap(),
13882            )
13883            .await
13884            .unwrap();
13885        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13886
13887        let bodies = puts.lock().unwrap().clone();
13888        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13889        assert!(
13890            bodies[0].contains("community.lexicon.rss.subscription"),
13891            "captured no usable put body: {:?}",
13892            bodies[0]
13893        );
13894        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13895        assert_eq!(
13896            sent["record"]["siteUrl"], "https://typed.example/site",
13897            "the client's siteUrl was dropped; the seeded record's survived instead"
13898        );
13899    }
13900
13901    /// **A rename whose read fails writes NOTHING.**
13902    ///
13903    /// This is the property most easily lost when someone later touches this
13904    /// handler: falling back to `Subscription::new` on a read error looks like
13905    /// graceful degradation and is in fact the original bug, reinstated on
13906    /// exactly the path where it is hardest to notice. The reader must be told
13907    /// instead.
13908    #[tokio::test]
13909    async fn a_rename_whose_read_fails_writes_nothing() {
13910        let did = "did:plc:renamer5";
13911        // A port that accepts nothing: the read cannot succeed.
13912        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13913        let dead = format!("http://{}", listener.local_addr().unwrap());
13914        drop(listener);
13915
13916        let state = test_state_with_sidecar(&[did], &dead).await;
13917        let cookie = session_cookie(&state, did, None);
13918        let before = store::count_feeds(&state.db).await.unwrap();
13919
13920        let resp = router(state.clone())
13921            .oneshot(
13922                Request::builder()
13923                    .method("POST")
13924                    .uri("/subscriptions/rk-keep/rename")
13925                    .header(header::COOKIE, cookie)
13926                    .header("content-type", "application/x-www-form-urlencoded")
13927                    .body(Body::from(
13928                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
13929                    ))
13930                    .unwrap(),
13931            )
13932            .await
13933            .unwrap();
13934
13935        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13936        let loc = resp
13937            .headers()
13938            .get(header::LOCATION)
13939            .unwrap()
13940            .to_str()
13941            .unwrap();
13942        assert!(
13943            loc.contains("flash="),
13944            "a failed read redirected as though the rename had worked: {loc}"
13945        );
13946        assert_eq!(
13947            store::count_feeds(&state.db).await.unwrap(),
13948            before,
13949            "a rename that could not read the record still wrote to the cache"
13950        );
13951    }
13952
13953    /// Folder pre-selection regression: the manage rename row must mark the
13954    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
13955    /// re-submits the current folder instead of silently un-foldering the feed.
13956    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
13957    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
13958    #[test]
13959    fn manage_rename_row_preselects_current_folder() {
13960        let nav = Nav {
13961            handle: "@reader.example".to_string(),
13962            avatar: "RE".to_string(),
13963            view: "unread".to_string(),
13964            scope_qs: String::new(),
13965            folders: Vec::new(),
13966            loose_feeds: Vec::new(),
13967            manage_active: true,
13968        };
13969        let folder_options = vec![
13970            FolderOption {
13971                uri: "at://did:plc:x/app.folder/work".to_string(),
13972                name: "Work".to_string(),
13973            },
13974            FolderOption {
13975                uri: "at://did:plc:x/app.folder/fun".to_string(),
13976                name: "Fun".to_string(),
13977            },
13978        ];
13979        // A foldered feed (in "Work") and a loose feed (no folder), each with a
13980        // non-empty rkey so the rename form renders.
13981        let foldered = FeedView {
13982            rkey: "sub-foldered".to_string(),
13983            url: "https://work.example/feed.xml".to_string(),
13984            title: "Work Feed".to_string(),
13985            unread: 0,
13986            selected: false,
13987            folder: Some("at://did:plc:x/app.folder/work".to_string()),
13988        };
13989        let loose = FeedView {
13990            rkey: "sub-loose".to_string(),
13991            url: "https://loose.example/feed.xml".to_string(),
13992            title: "Loose Feed".to_string(),
13993            unread: 0,
13994            selected: false,
13995            folder: None,
13996        };
13997        let tmpl = ManageTemplate {
13998            card: Card::private(&Config::default()),
13999            version: VERSION,
14000            repo_url: REPO_URL,
14001            kofi_url: KOFI_URL,
14002            flash: String::new(),
14003            alert: String::new(),
14004            nav,
14005            folder_options,
14006            folders: vec![FolderView {
14007                rkey: "folder-work".to_string(),
14008                uri: "at://did:plc:x/app.folder/work".to_string(),
14009                name: "Work".to_string(),
14010                feeds: vec![foldered],
14011                selected: false,
14012            }],
14013            loose_feeds: vec![loose],
14014            standard_site: false,
14015        };
14016        let html = tmpl.render().unwrap();
14017
14018        // The foldered feed's "Work" option is pre-selected.
14019        assert!(
14020            html.contains(
14021                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
14022            ),
14023            "foldered feed must pre-select its current folder: {html}"
14024        );
14025        // The loose feed's "No folder" option is pre-selected (appears for the
14026        // loose row, which has folder=None).
14027        assert!(
14028            html.contains(r#"<option value="" selected>No folder</option>"#),
14029            "loose feed must pre-select 'No folder': {html}"
14030        );
14031
14032        // #149: the values each input was pre-filled with ride along, so the
14033        // handler can tell what the reader changed from what they merely saw.
14034        for want in [
14035            r#"<input type="hidden" name="seen_url" value="https://work.example/feed.xml" />"#,
14036            r#"<input type="hidden" name="seen_title" value="Work Feed" />"#,
14037            r#"<input type="hidden" name="seen_folder" value="at://did:plc:x/app.folder/work" />"#,
14038            r#"<input type="hidden" name="seen_folder" value="" />"#,
14039            // #268: the folder rename form says which name it showed.
14040            r#"<input type="hidden" name="seen_name" value="Work" />"#,
14041        ] {
14042            assert!(html.contains(want), "missing {want}: {html}");
14043        }
14044    }
14045
14046    /// **The public stats page carries no user data.**
14047    ///
14048    /// It is reachable by anyone, so the thing worth pinning is what it does
14049    /// NOT say: nothing about how many people use the instance, nothing about
14050    /// which feeds fail, nothing about who reads what.
14051    #[tokio::test]
14052    async fn the_public_stats_page_exposes_no_user_data() {
14053        let state = test_state(&[]).await;
14054        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
14055            .await
14056            .unwrap();
14057
14058        let resp = router(state)
14059            .oneshot(
14060                Request::builder()
14061                    .uri("/stats")
14062                    .body(Body::empty())
14063                    .unwrap(),
14064            )
14065            .await
14066            .unwrap();
14067        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
14068
14069        let body = String::from_utf8(
14070            axum::body::to_bytes(resp.into_body(), usize::MAX)
14071                .await
14072                .unwrap()
14073                .to_vec(),
14074        )
14075        .unwrap();
14076
14077        // Structural checks, not word checks. The page's own prose says it
14078        // publishes no error rates, so searching for that PHRASE finds the
14079        // disclaimer rather than a leak — the first version of this test failed
14080        // on exactly that. What matters is whether identifiers or the
14081        // admin-only figures are present.
14082        assert!(
14083            !body.contains("did:"),
14084            "the public stats page leaked an identifier"
14085        );
14086        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
14087            assert!(
14088                !body.contains(admin_only),
14089                "the public page is showing the admin metrics column {admin_only:?}"
14090            );
14091        }
14092        // And it does render the aggregate it exists for.
14093        assert!(body.contains("Feeds tracked"));
14094        assert!(body.contains("Waiting to be polled"));
14095    }
14096
14097    /// **The two states that stop feeds updating must be visible.**
14098    ///
14099    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
14100    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
14101    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
14102    /// the backlog and makes the page read healthier. That inversion is what this
14103    /// test pins: a broken feed must raise a number, not lower one.
14104    #[tokio::test]
14105    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
14106        let state = test_state(&[]).await;
14107        // Three feeds: one healthy, one flaky, one long dead.
14108        for (url, errors) in [
14109            ("https://ok.example/f.xml", 0),
14110            ("https://flaky.example/f.xml", 2),
14111            ("https://dead.example/f.xml", 9),
14112        ] {
14113            store::upsert_feed(
14114                &state.db,
14115                &store::NewFeed {
14116                    url: url.to_string(),
14117                    // Pushed forward, exactly as backoff does — so none of these
14118                    // are counted as `overdue`.
14119                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14120                    ..Default::default()
14121                },
14122            )
14123            .await
14124            .unwrap();
14125            for _ in 0..errors {
14126                store::bump_feed_errors(
14127                    &state.db,
14128                    url,
14129                    feed::FailureKind::Fetch,
14130                    "connection refused",
14131                )
14132                .await
14133                .unwrap();
14134            }
14135        }
14136
14137        let render_stats = |state: AppState| async move {
14138            let resp = router(state)
14139                .oneshot(
14140                    Request::builder()
14141                        .uri("/stats")
14142                        .body(Body::empty())
14143                        .unwrap(),
14144                )
14145                .await
14146                .unwrap();
14147            assert_eq!(resp.status(), StatusCode::OK);
14148            String::from_utf8(
14149                axum::body::to_bytes(resp.into_body(), usize::MAX)
14150                    .await
14151                    .unwrap()
14152                    .to_vec(),
14153            )
14154            .unwrap()
14155        };
14156
14157        // **The fixture must actually be RUNNING, or this test measures nothing.**
14158        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
14159        // checks that BEFORE the watermark — so without these two lines every
14160        // render below reports "off" and the watermark can never surface. The
14161        // assertions still passed, for reasons unrelated to what they name: see
14162        // the two comments below.
14163        state.runtime_health.set_schedulers_enabled(true);
14164        state
14165            .runtime_health
14166            .poll_tick_completed(crate::store::now_unix());
14167
14168        let body = render_stats(state.clone()).await;
14169        assert!(
14170            body.contains("Failing"),
14171            "backoff is still invisible on the public page"
14172        );
14173        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
14174        // value rather than on surrounding whitespace, so re-indenting the
14175        // template cannot break this.
14176        assert!(
14177            body.contains("2, 1 badly"),
14178            "expected '2, 1 badly' in the failing row; got:\n{}",
14179            body.split("Failing")
14180                .nth(1)
14181                .unwrap_or("")
14182                .chars()
14183                .take(300)
14184                .collect::<String>()
14185        );
14186        // Not paused, and the backlog is genuinely empty — which is exactly the
14187        // reading that used to be indistinguishable from healthy.
14188        //
14189        // **Asserted by EXCLUDING the other states, not by matching "running".**
14190        // The `off` row reads "the poller is not running on this instance", which
14191        // contains "running" — so the bare substring passed while the page was
14192        // reporting the exact opposite of what this line claims to check.
14193        assert!(
14194            !body.contains("the poller is not running")
14195                && !body.contains("the cache is at its size limit")
14196                && !body.contains("has not completed a round"),
14197            "expected the running state; the page reported a stopped one",
14198        );
14199
14200        // Now trip the watermark. Nothing in the database changes; only the
14201        // recorded runtime state does — which is the whole reason it needed a
14202        // home outside the log stream.
14203        state.runtime_health.set_watermark(true);
14204        let paused = render_stats(state.clone()).await;
14205        // Matched on the paused row's OWN sentence. The bare word "paused" also
14206        // appeared in the page's explanatory prose, so this assertion passed
14207        // whether or not the row rendered — and trimming that prose is what
14208        // exposed it. This phrase exists only inside the `paused` branch.
14209        assert!(
14210            paused.contains("the cache is at its size limit"),
14211            "a watermark pause is still invisible on the public page"
14212        );
14213
14214        // Still no identifiers: these are counts, not feeds.
14215        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
14216            assert!(
14217                !paused.contains(leak),
14218                "the public page leaked {leak:?} while reporting failures"
14219            );
14220        }
14221    }
14222
14223    /// **`/admin/metrics` is gated, and nothing checked that it was.**
14224    ///
14225    /// Deleting the `admin_seed_dids` check left the entire suite green. That
14226    /// was survivable while the page held only aggregate timings; it is not now,
14227    /// because this branch puts **per-feed URLs and remote error text** behind
14228    /// that gate. A guarantee nothing checks is a comment, and this one is now
14229    /// the only thing standing between a signed-in stranger and the operational
14230    /// picture the handler's own doc says is not public.
14231    ///
14232    /// All three doors: no session, a session that is not an admin, and the
14233    /// admin itself.
14234    #[tokio::test]
14235    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
14236        let admin = "did:plc:adminseed";
14237        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
14238        // IS that list — deliberately, per its doc: "the same people I trust on
14239        // this instance". Production sets it to the bootstrap DID alone.
14240        //
14241        // A genuine non-admin is therefore someone holding a beta seat granted
14242        // by an invite, not by the allow-list. Seeding both would have made
14243        // both admins and quietly turned the 403 assertion below into a test of
14244        // nothing — which is exactly what the first draft of this did.
14245        let state = test_state(&[admin]).await;
14246        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
14247            .await
14248            .unwrap();
14249        let url = "https://broken.example/f.xml";
14250        store::upsert_feed(
14251            &state.db,
14252            &store::NewFeed {
14253                url: url.to_string(),
14254                ..Default::default()
14255            },
14256        )
14257        .await
14258        .unwrap();
14259        store::bump_feed_errors(
14260            &state.db,
14261            url,
14262            feed::FailureKind::Fetch,
14263            "SENTINEL_ADMIN_ONLY",
14264        )
14265        .await
14266        .unwrap();
14267
14268        let get = |state: AppState, cookie: Option<String>| async move {
14269            let mut req = Request::builder().uri("/admin/metrics");
14270            if let Some(c) = cookie {
14271                req = req.header(header::COOKIE, c);
14272            }
14273            let resp = router(state)
14274                .oneshot(req.body(Body::empty()).unwrap())
14275                .await
14276                .unwrap();
14277            let status = resp.status();
14278            let body = String::from_utf8(
14279                axum::body::to_bytes(resp.into_body(), usize::MAX)
14280                    .await
14281                    .unwrap()
14282                    .to_vec(),
14283            )
14284            .unwrap();
14285            (status, body)
14286        };
14287
14288        // No session at all.
14289        let (status, body) = get(state.clone(), None).await;
14290        assert_eq!(status, StatusCode::UNAUTHORIZED);
14291        assert!(
14292            !body.contains("SENTINEL_ADMIN_ONLY"),
14293            "leaked to anonymous: {body}"
14294        );
14295
14296        // A real, signed-in user who is not an admin.
14297        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
14298        let (status, body) = get(state.clone(), Some(ordinary)).await;
14299        assert_eq!(
14300            status,
14301            StatusCode::FORBIDDEN,
14302            "a non-admin session was let in"
14303        );
14304        assert!(
14305            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
14306            "leaked to a non-admin: {body}",
14307        );
14308
14309        // The admin does get it — otherwise the two refusals above are
14310        // satisfied by the endpoint being broken for everyone.
14311        let admin_cookie = session_cookie(&state, admin, None);
14312        let (status, body) = get(state, Some(admin_cookie)).await;
14313        assert_eq!(status, StatusCode::OK);
14314        assert!(
14315            body.contains("SENTINEL_ADMIN_ONLY"),
14316            "admin cannot see it: {body}"
14317        );
14318    }
14319
14320    /// **The cause a public count cannot carry belongs on the admin page.**
14321    ///
14322    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
14323    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
14324    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
14325    /// have separated "sixty dead publishers" from "one bug here", which is the
14326    /// case it was justified by.
14327    ///
14328    /// The answer is not a finer public vocabulary — `/stats` promises never
14329    /// which feed and never whose, and a bucket per error string would break
14330    /// that. It is to put the detail where per-feed data is already allowed.
14331    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
14332    /// operational picture.
14333    ///
14334    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
14335    /// public one.
14336    #[tokio::test]
14337    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
14338        let admin = "did:plc:adminseed";
14339        let state = test_state(&[admin]).await;
14340        let url = "https://broken.example/f.xml";
14341        store::upsert_feed(
14342            &state.db,
14343            &store::NewFeed {
14344                url: url.to_string(),
14345                ..Default::default()
14346            },
14347        )
14348        .await
14349        .unwrap();
14350        store::bump_feed_errors(
14351            &state.db,
14352            url,
14353            feed::FailureKind::Fetch,
14354            "SENTINEL_REDIRECT_NO_LOCATION",
14355        )
14356        .await
14357        .unwrap();
14358
14359        let cookie = session_cookie(&state, admin, None);
14360        let resp = router(state.clone())
14361            .oneshot(
14362                Request::builder()
14363                    .uri("/admin/metrics")
14364                    .header(header::COOKIE, cookie)
14365                    .body(Body::empty())
14366                    .unwrap(),
14367            )
14368            .await
14369            .unwrap();
14370        assert_eq!(resp.status(), StatusCode::OK);
14371        let admin_body = String::from_utf8(
14372            axum::body::to_bytes(resp.into_body(), usize::MAX)
14373                .await
14374                .unwrap()
14375                .to_vec(),
14376        )
14377        .unwrap();
14378        assert!(
14379            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
14380            "the admin page does not carry the failure detail: {admin_body}",
14381        );
14382        assert!(
14383            admin_body.contains("broken.example"),
14384            "the admin page does not name the failing feed: {admin_body}",
14385        );
14386
14387        // The public page still carries neither.
14388        let resp = router(state)
14389            .oneshot(
14390                Request::builder()
14391                    .uri("/stats")
14392                    .body(Body::empty())
14393                    .unwrap(),
14394            )
14395            .await
14396            .unwrap();
14397        let public = String::from_utf8(
14398            axum::body::to_bytes(resp.into_body(), usize::MAX)
14399                .await
14400                .unwrap()
14401                .to_vec(),
14402        )
14403        .unwrap();
14404        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
14405            assert!(
14406                !public.contains(secret),
14407                "{secret:?} reached the PUBLIC stats page: {public}",
14408            );
14409        }
14410    }
14411
14412    /// **A direct poll must settle the error columns, like the scheduler does.**
14413    ///
14414    /// `add_subscription` polls through `feed::poll_feed` rather than the
14415    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
14416    /// touches `consecutive_errors` — that is the scheduler's job, and this path
14417    /// is not the scheduler.
14418    ///
14419    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
14420    /// its old count and its old cause: the public page went on reporting it
14421    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
14422    /// the stale backoff horizon lasted — up to 24h — while the reader was
14423    /// demonstrably fetching it.
14424    #[tokio::test]
14425    async fn a_successful_direct_poll_clears_a_stale_failure() {
14426        let state = test_state(&[]).await;
14427        let url = "https://recovered.example/f.xml";
14428        store::upsert_feed(
14429            &state.db,
14430            &store::NewFeed {
14431                url: url.to_string(),
14432                ..Default::default()
14433            },
14434        )
14435        .await
14436        .unwrap();
14437        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
14438            .await
14439            .unwrap();
14440        // Park it on a stale backoff horizon, as a real failing feed would be.
14441        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
14442            .bind(url)
14443            .execute(&state.db)
14444            .await
14445            .unwrap();
14446
14447        // The publisher is fixed: a successful poll happens on this path.
14448        feed::settle_poll(
14449            &state.db,
14450            url,
14451            &feed::PollOutcome::NotModified,
14452            state.config.poll_interval,
14453        )
14454        .await;
14455
14456        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
14457            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
14458        )
14459        .bind(url)
14460        .fetch_one(&state.db)
14461        .await
14462        .unwrap();
14463        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
14464        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
14465        // **The half the first fix missed.** Clearing the count fixed the
14466        // REPORTING; the feed stayed parked until 2099. A working feed must be
14467        // rescheduled on its normal cadence, not left on the failure horizon.
14468        let next = row.2.expect("next_poll was cleared to NULL");
14469        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
14470        // backoff. A mutation that reschedules successes with backoff_for(1)
14471        // (5 min) also moves it off 2099, so the interval is asserted.
14472        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
14473        let delta = parsed
14474            .signed_duration_since(chrono::Utc::now())
14475            .num_seconds();
14476        let cadence = state.config.poll_interval.as_secs() as i64;
14477        assert!(
14478            (cadence - 60..=cadence + 60).contains(&delta),
14479            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
14480        );
14481    }
14482
14483    /// The mirror case: a first poll that FAILS must be visible at all.
14484    ///
14485    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
14486    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
14487    /// with a NULL cause — invisible to the page built to count exactly that.
14488    #[tokio::test]
14489    async fn a_failing_direct_poll_is_recorded() {
14490        let state = test_state(&[]).await;
14491        let url = "https://born-broken.example/f.xml";
14492        store::upsert_feed(
14493            &state.db,
14494            &store::NewFeed {
14495                url: url.to_string(),
14496                ..Default::default()
14497            },
14498        )
14499        .await
14500        .unwrap();
14501
14502        feed::settle_poll(
14503            &state.db,
14504            url,
14505            &feed::PollOutcome::Failed {
14506                backoff: std::time::Duration::from_secs(300),
14507                kind: feed::FailureKind::Parse,
14508                detail: "SENTINEL_BORN_BROKEN".to_string(),
14509            },
14510            state.config.poll_interval,
14511        )
14512        .await;
14513
14514        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
14515            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
14516        )
14517        .bind(url)
14518        .fetch_one(&state.db)
14519        .await
14520        .unwrap();
14521        assert_eq!(row.0, 1, "a failed first poll was not counted");
14522        assert_eq!(
14523            row.1.as_deref(),
14524            Some("parse"),
14525            "its cause was not recorded"
14526        );
14527        // And it is BACKED OFF on the schedule the scheduler would use — not
14528        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
14529        // on the very next tick.
14530        let next = row.2.expect("a failed direct poll left next_poll NULL");
14531        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
14532        let delta = parsed
14533            .signed_duration_since(chrono::Utc::now())
14534            .num_seconds();
14535        assert!(
14536            (240..=360).contains(&delta),
14537            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
14538        );
14539    }
14540
14541    /// **The breakdown must sum to the Failing figure above it.**
14542    ///
14543    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
14544    /// `consecutive_errors > 0`. On a migrated database every row that was
14545    /// already failing has a NULL kind — correctly, it was never recorded — so
14546    /// the two do not reconcile and the page shows "70 failing" beside "3
14547    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
14548    /// entirely while the prose still promises a breakdown.
14549    ///
14550    /// An explicit `unknown` bucket is the honest shape: the page says how many
14551    /// it cannot explain rather than omitting them.
14552    #[tokio::test]
14553    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
14554        let state = test_state(&[]).await;
14555        // Two legacy rows: failing, with no recorded cause.
14556        for url in [
14557            "https://legacy1.example/f.xml",
14558            "https://legacy2.example/f.xml",
14559        ] {
14560            store::upsert_feed(
14561                &state.db,
14562                &store::NewFeed {
14563                    url: url.to_string(),
14564                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14565                    ..Default::default()
14566                },
14567            )
14568            .await
14569            .unwrap();
14570            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
14571                .bind(url)
14572                .execute(&state.db)
14573                .await
14574                .unwrap();
14575        }
14576        // One row with a recorded cause.
14577        store::upsert_feed(
14578            &state.db,
14579            &store::NewFeed {
14580                url: "https://known.example/f.xml".to_string(),
14581                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14582                ..Default::default()
14583            },
14584        )
14585        .await
14586        .unwrap();
14587        store::bump_feed_errors(
14588            &state.db,
14589            "https://known.example/f.xml",
14590            feed::FailureKind::Status,
14591            "SENTINEL",
14592        )
14593        .await
14594        .unwrap();
14595
14596        let now = chrono::Utc::now();
14597        let health = store::poll_health(
14598            &state.db,
14599            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14600            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14601        )
14602        .await
14603        .unwrap();
14604        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
14605        assert_eq!(
14606            counted, health.in_backoff,
14607            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
14608            health.in_backoff, health.failure_kinds,
14609        );
14610        assert!(
14611            health
14612                .failure_kinds
14613                .iter()
14614                .any(|(k, n)| k == "unknown" && *n == 2),
14615            "no unknown bucket for the legacy rows: {:?}",
14616            health.failure_kinds,
14617        );
14618    }
14619
14620    /// **The breakdown is ordered by count, and the assertion can see it.**
14621    ///
14622    /// The first version of this asserted with three `contains` calls, which
14623    /// cannot observe order — deleting `ORDER BY` from the query passed.
14624    #[tokio::test]
14625    async fn the_failure_breakdown_is_ordered_by_count() {
14626        let state = test_state(&[]).await;
14627        for (url, kind, n) in [
14628            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
14629            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
14630            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
14631            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
14632            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
14633            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
14634        ] {
14635            store::upsert_feed(
14636                &state.db,
14637                &store::NewFeed {
14638                    url: url.to_string(),
14639                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14640                    ..Default::default()
14641                },
14642            )
14643            .await
14644            .unwrap();
14645            for _ in 0..n {
14646                store::bump_feed_errors(&state.db, url, kind, "d")
14647                    .await
14648                    .unwrap();
14649            }
14650        }
14651        let now = chrono::Utc::now();
14652        let health = store::poll_health(
14653            &state.db,
14654            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14655            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14656        )
14657        .await
14658        .unwrap();
14659        let labels: Vec<&str> = health
14660            .failure_kinds
14661            .iter()
14662            .map(|(k, _)| k.as_str())
14663            .collect();
14664        assert_eq!(
14665            labels,
14666            ["fetch", "status", "parse"],
14667            "not ordered by count, descending: {:?}",
14668            health.failure_kinds,
14669        );
14670    }
14671
14672    /// **Failing feeds are grouped by CAUSE, and still never named.**
14673    ///
14674    /// `badly_broken` could say that sixty feeds were failing and not whether
14675    /// that was sixty dead publishers or one bug here. It was the latter — #159,
14676    /// a `304 Not Modified` read as a malformed redirect — and the page could
14677    /// not say so, which is most of why it went unexamined.
14678    ///
14679    /// The second half of this test is the constraint that shapes the first:
14680    /// `/stats` is public and promises machines-not-people, *never which feed
14681    /// and never whose*. A histogram of causes keeps that promise; a list of
14682    /// failing URLs would break it, and is the obvious way to build this.
14683    #[tokio::test]
14684    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
14685        let state = test_state(&[]).await;
14686        for (url, kind, detail, errors) in [
14687            // Detail strings are distinctive SENTINELS, not plausible English.
14688            // A first pass used "not a feed", which the page's own explanation
14689            // of the `parse` kind contains verbatim — the privacy assertion
14690            // fired on static copy rather than on a leak. A sentinel cannot
14691            // collide with prose.
14692            (
14693                "https://a.example/f.xml",
14694                feed::FailureKind::Fetch,
14695                "SENTINEL_CONNREFUSED",
14696                3,
14697            ),
14698            (
14699                "https://b.example/f.xml",
14700                feed::FailureKind::Fetch,
14701                "SENTINEL_DNSFAIL",
14702                2,
14703            ),
14704            (
14705                "https://c.example/f.xml",
14706                feed::FailureKind::Status,
14707                "SENTINEL_404",
14708                1,
14709            ),
14710            (
14711                "https://d.example/f.xml",
14712                feed::FailureKind::Parse,
14713                "SENTINEL_UNPARSEABLE",
14714                1,
14715            ),
14716        ] {
14717            store::upsert_feed(
14718                &state.db,
14719                &store::NewFeed {
14720                    url: url.to_string(),
14721                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14722                    ..Default::default()
14723                },
14724            )
14725            .await
14726            .unwrap();
14727            for _ in 0..errors {
14728                store::bump_feed_errors(&state.db, url, kind, detail)
14729                    .await
14730                    .unwrap();
14731            }
14732        }
14733
14734        let resp = router(state.clone())
14735            .oneshot(
14736                Request::builder()
14737                    .uri("/stats")
14738                    .body(Body::empty())
14739                    .unwrap(),
14740            )
14741            .await
14742            .unwrap();
14743        assert_eq!(resp.status(), StatusCode::OK);
14744        let body = String::from_utf8(
14745            axum::body::to_bytes(resp.into_body(), usize::MAX)
14746                .await
14747                .unwrap()
14748                .to_vec(),
14749        )
14750        .unwrap();
14751
14752        // Descending by count: two fetch, then one each, tie-broken by name.
14753        assert!(
14754            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
14755            "the cause histogram did not render: {body}",
14756        );
14757
14758        // **The privacy half.** No feed URL, host, or error detail reaches the
14759        // public page — only counts by kind.
14760        for secret in [
14761            "a.example",
14762            "b.example",
14763            "c.example",
14764            "d.example",
14765            "SENTINEL_CONNREFUSED",
14766            "SENTINEL_DNSFAIL",
14767            "SENTINEL_404",
14768            "SENTINEL_UNPARSEABLE",
14769        ] {
14770            assert!(
14771                !body.contains(secret),
14772                "{secret:?} reached the PUBLIC stats page: {body}",
14773            );
14774        }
14775    }
14776
14777    /// `/health` must prove the process can reach its database, and must report
14778    /// the loop state without letting it change the status code.
14779    #[tokio::test]
14780    async fn health_checks_the_database_and_reports_the_loops() {
14781        let state = test_state(&[]).await;
14782        let body_of = |state: AppState| async move {
14783            let resp = router(state)
14784                .oneshot(
14785                    Request::builder()
14786                        .uri("/health")
14787                        .body(Body::empty())
14788                        .unwrap(),
14789                )
14790                .await
14791                .unwrap();
14792            let status = resp.status();
14793            let body = String::from_utf8(
14794                axum::body::to_bytes(resp.into_body(), usize::MAX)
14795                    .await
14796                    .unwrap()
14797                    .to_vec(),
14798            )
14799            .unwrap();
14800            (status, body)
14801        };
14802
14803        // The boot stamp is what `main` sets; the router alone does not, so this
14804        // starts "unknown" and the uptime branch below drives it explicitly.
14805        state
14806            .runtime_health
14807            .set_started_at(chrono::Utc::now().timestamp());
14808
14809        let (status, body) = body_of(state.clone()).await;
14810        assert_eq!(status, StatusCode::OK);
14811        assert!(
14812            body.contains("db: ok"),
14813            "health did not probe the DB: {body}"
14814        );
14815        assert!(
14816            body.contains("uptime:"),
14817            "no uptime — the first thing anyone asks about a container that may \
14818             be restarting: {body}"
14819        );
14820        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
14821        assert!(body.contains("polling-paused: no"), "{body}");
14822        assert!(body.contains("backend:"), "{body}");
14823        assert!(body.contains("oauth-runtime:"), "{body}");
14824
14825        // A watermark pause is REPORTED but must not fail the check. A failed
14826        // check DEREGISTERS this machine from the proxy — and it is the only
14827        // machine — so it would turn "feeds are behind" into "the site is down"
14828        // for as long as the disk stays full.
14829        state.runtime_health.set_watermark(true);
14830        state.runtime_health.set_schedulers_enabled(true);
14831        let (status, body) = body_of(state.clone()).await;
14832        assert_eq!(
14833            status,
14834            StatusCode::OK,
14835            "a watermark pause must not fail the liveness check: {body}"
14836        );
14837        assert!(body.contains("polling-paused: yes"), "{body}");
14838        // Schedulers on but no tick yet — and that must not read as "0s ago",
14839        // which is the healthiest possible answer to an unanswered question.
14840        assert!(
14841            body.contains("poller: not-yet-ticked"),
14842            "a never-ticked poller must say so: {body}"
14843        );
14844
14845        // A stale heartbeat is likewise reported, not fatal.
14846        let stale_after = health_tick_stale_secs(configured_poll_tick());
14847        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
14848        state.runtime_health.poll_tick_completed(long_ago);
14849        let (status, body) = body_of(state.clone()).await;
14850        assert_eq!(
14851            status,
14852            StatusCode::OK,
14853            "a stale poller must not 503: {body}"
14854        );
14855        assert!(body.contains("poller: stale"), "{body}");
14856
14857        // **A poller that has never ticked stops being benign.**
14858        //
14859        // In a crash loop with 30 s+ boot cycles the poller never reaches its
14860        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
14861        // could not detect the one failure mode the startup delays were added
14862        // for. It is read against uptime now.
14863        state.runtime_health.poll_tick_completed(0); // reset to "never"
14864        state
14865            .runtime_health
14866            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
14867        let (status, body) = body_of(state.clone()).await;
14868        assert_eq!(status, StatusCode::OK);
14869        assert!(
14870            body.contains("poller: stale never-ticked"),
14871            "a poller that never ticked long after boot still reads as benign: {body}"
14872        );
14873
14874        // A closed pool is a real outage: nothing can be served, and a restart is
14875        // the correct response. THIS is what the status code is for.
14876        state.db.close().await;
14877        let (status, body) = body_of(state.clone()).await;
14878        assert_eq!(
14879            status,
14880            StatusCode::SERVICE_UNAVAILABLE,
14881            "an unreachable database must fail the check: {body}"
14882        );
14883        assert!(body.starts_with("FAIL"), "{body}");
14884        // Coarse, not the raw sqlx error: an unauthenticated caller learning
14885        // exactly which failure it hit is an attack-progress oracle, and this
14886        // endpoint is exempt from the origin lock.
14887        assert!(
14888            !body.contains("PoolClosed") && !body.contains("sqlx"),
14889            "health leaked the raw database error to an unauthenticated caller: {body}"
14890        );
14891    }
14892
14893    /// The staleness threshold must track the configured tick.
14894    ///
14895    /// Hardcoded at 15 minutes, an operator who raised
14896    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
14897    /// in the body the deployment docs tell them to alert on.
14898    #[test]
14899    fn the_stale_threshold_follows_the_poll_tick() {
14900        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
14901        // alerting that early would fire on any brief hiccup.
14902        assert_eq!(
14903            health_tick_stale_secs(Duration::from_secs(60)),
14904            HEALTH_TICK_STALE_FLOOR_SECS
14905        );
14906        // A slow tick raises it, so a legitimately-configured loop is never
14907        // permanently "stale".
14908        let slow = Duration::from_secs(30 * 60);
14909        assert!(
14910            health_tick_stale_secs(slow) > slow.as_secs() as i64,
14911            "a 30-minute tick must not be stale after one interval"
14912        );
14913        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
14914        // And it cannot overflow into nonsense on an absurd value.
14915        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
14916    }
14917
14918    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
14919    ///
14920    /// `polling_paused` alone rendered "running" for three different states,
14921    /// including the two where nothing polls at all — on the page added to
14922    /// answer exactly that question.
14923    #[tokio::test]
14924    async fn stats_does_not_call_a_stopped_poller_running() {
14925        let state = test_state(&[]).await;
14926        let render = |state: AppState| async move {
14927            let resp = router(state)
14928                .oneshot(
14929                    Request::builder()
14930                        .uri("/stats")
14931                        .body(Body::empty())
14932                        .unwrap(),
14933                )
14934                .await
14935                .unwrap();
14936            assert_eq!(resp.status(), StatusCode::OK);
14937            String::from_utf8(
14938                axum::body::to_bytes(resp.into_body(), usize::MAX)
14939                    .await
14940                    .unwrap()
14941                    .to_vec(),
14942            )
14943            .unwrap()
14944        };
14945
14946        // Schedulers never started: not "running".
14947        let body = render(state.clone()).await;
14948        assert!(
14949            body.contains("the poller is not running on this instance"),
14950            "a disabled poller renders as healthy"
14951        );
14952
14953        // Started, but no tick has finished yet.
14954        state.runtime_health.set_schedulers_enabled(true);
14955        let body = render(state.clone()).await;
14956        assert!(
14957            body.contains("no poll has finished since this instance booted"),
14958            "a poller that has not ticked renders as healthy"
14959        );
14960
14961        // Ticking: running.
14962        state
14963            .runtime_health
14964            .poll_tick_completed(chrono::Utc::now().timestamp());
14965        let body = render(state.clone()).await;
14966        assert!(
14967            body.contains("running"),
14968            "a healthy poller must read as running"
14969        );
14970
14971        // Paused at the watermark still wins over "running".
14972        state.runtime_health.set_watermark(true);
14973        let body = render(state.clone()).await;
14974        assert!(
14975            body.contains("the cache is at its size limit"),
14976            "a watermark pause is hidden once the poller is ticking"
14977        );
14978    }
14979
14980    /// **Ingest starved of sanitize permits shows on `/stats`** (review of
14981    /// #274). Four hostile feeds can hold every permit; every other poll then
14982    /// defers, storing nothing and filing nothing against its feed, so the
14983    /// failing-feeds rows stay clean while nothing updates. This row is how
14984    /// anyone sees it: absent until a deferral, then the count, and while no
14985    /// permit has come free, "stalled" and since when.
14986    #[tokio::test]
14987    async fn stats_shows_ingest_starved_of_sanitize_permits() {
14988        let mut state = test_state(&[]).await;
14989        let starvation: &'static crate::feed::Starvation =
14990            Box::leak(Box::new(crate::feed::Starvation::new()));
14991        state.sanitize_starvation = starvation;
14992        let render = |state: AppState| async move {
14993            let resp = router(state)
14994                .oneshot(
14995                    Request::builder()
14996                        .uri("/stats")
14997                        .body(Body::empty())
14998                        .unwrap(),
14999                )
15000                .await
15001                .unwrap();
15002            assert_eq!(resp.status(), StatusCode::OK);
15003            String::from_utf8(
15004                axum::body::to_bytes(resp.into_body(), usize::MAX)
15005                    .await
15006                    .unwrap()
15007                    .to_vec(),
15008            )
15009            .unwrap()
15010        };
15011
15012        let body = render(state.clone()).await;
15013        assert!(
15014            !body.contains("no sanitize capacity"),
15015            "the row shows on an instance that never deferred"
15016        );
15017
15018        let ten_min_ago = chrono::Utc::now().timestamp() - 600;
15019        starvation.record_no_permit(ten_min_ago);
15020        starvation.record_no_permit(ten_min_ago + 1);
15021        starvation.record_no_permit(ten_min_ago + 2);
15022        let body = render(state.clone()).await;
15023        assert!(body.contains("3 polls deferred since boot"), "{body}");
15024        assert!(
15025            body.contains(
15026                "<strong>stalled</strong> — no feed body has been sanitized since 10m ago"
15027            ),
15028            "a starved instance does not read as stalled"
15029        );
15030
15031        starvation.record_permit();
15032        let body = render(state).await;
15033        assert!(body.contains("3 polls deferred since boot"));
15034        assert!(
15035            !body.contains("<strong>stalled</strong> — no feed body"),
15036            "a permit came free but /stats still reads stalled"
15037        );
15038    }
15039
15040    /// **An UNMEASURED database must not fail the check.**
15041    ///
15042    /// `/health` is the one path exempt from the Cloudflare origin lock and
15043    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
15044    /// drop WITHOUT recording a verdict — so a cancelled request (a client
15045    /// disconnect is enough) leaves the verdict at "none", and a concurrent
15046    /// caller reads it. Treating that as a failure turned an unauthenticated
15047    /// request into a lever on the only signal the platform acts on. The
15048    /// previous version of this code had the opposite bug and reported `ok` for
15049    /// a database nothing had read; "unknown" is neither.
15050    #[tokio::test]
15051    async fn health_reports_an_unmeasured_database_without_failing() {
15052        use crate::runtime_health::DbProbe;
15053        let state = test_state(&[]).await;
15054
15055        // Hold the probe claim, exactly as an in-flight request would, and never
15056        // record a verdict — the cancelled-request state.
15057        let held = state
15058            .runtime_health
15059            .begin_db_probe()
15060            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
15061
15062        let resp = router(state.clone())
15063            .oneshot(
15064                Request::builder()
15065                    .uri("/health")
15066                    .body(Body::empty())
15067                    .unwrap(),
15068            )
15069            .await
15070            .unwrap();
15071        let status = resp.status();
15072        let body = String::from_utf8(
15073            axum::body::to_bytes(resp.into_body(), usize::MAX)
15074                .await
15075                .unwrap()
15076                .to_vec(),
15077        )
15078        .unwrap();
15079        drop(held);
15080
15081        assert_eq!(
15082            status,
15083            StatusCode::OK,
15084            "an unmeasured database failed the check, which an unauthenticated \
15085             caller can cause on demand: {body}"
15086        );
15087        assert!(
15088            body.contains("db: unknown"),
15089            "the unmeasured state must still be REPORTED: {body}"
15090        );
15091        assert!(!body.starts_with("FAIL"), "{body}");
15092        // **And it must not read as `ok` either.** `fly.toml` tells operators to
15093        // alert on the BODY for everything the status code ignores, so a first
15094        // line identical to the healthy one makes a monitor keying on `^ok` read
15095        // green in exactly the state this enum exists to surface.
15096        assert!(
15097            !body.starts_with("ok"),
15098            "the unmeasured state is indistinguishable from healthy to a \
15099             body-matching monitor: {body}"
15100        );
15101        assert!(body.starts_with("unknown"), "{body}");
15102
15103        // **A BORROWED failure must 503 too.**
15104        //
15105        // This previously recorded `Failed` and then closed the pool — but
15106        // `record` consumes the guard and releases the claim, so the request won
15107        // it, ran a live probe against the closed pool, and failed on its own.
15108        // The 503 passed for the wrong reason and the borrow path — the whole
15109        // point of the three-state enum on the read side — had no coverage.
15110        //
15111        // Holding the claim forces the borrow, so the recorded verdict is what
15112        // gets reported.
15113        let held = state
15114            .runtime_health
15115            .begin_db_probe()
15116            .unwrap_or_else(|_| panic!("claim"));
15117        state
15118            .runtime_health
15119            .record_for_test(DbProbe::Failed("unavailable".to_string()));
15120        let resp = router(state.clone())
15121            .oneshot(
15122                Request::builder()
15123                    .uri("/health")
15124                    .body(Body::empty())
15125                    .unwrap(),
15126            )
15127            .await
15128            .unwrap();
15129        let status = resp.status();
15130        let body = String::from_utf8(
15131            axum::body::to_bytes(resp.into_body(), usize::MAX)
15132                .await
15133                .unwrap()
15134                .to_vec(),
15135        )
15136        .unwrap();
15137        drop(held);
15138        assert_eq!(
15139            status,
15140            StatusCode::SERVICE_UNAVAILABLE,
15141            "a BORROWED failure verdict must fail the check, not just a freshly \
15142             measured one: {body}"
15143        );
15144        assert!(body.starts_with("FAIL"), "{body}");
15145
15146        state.db.close().await;
15147        let resp = router(state.clone())
15148            .oneshot(
15149                Request::builder()
15150                    .uri("/health")
15151                    .body(Body::empty())
15152                    .unwrap(),
15153            )
15154            .await
15155            .unwrap();
15156        assert_eq!(
15157            resp.status(),
15158            StatusCode::SERVICE_UNAVAILABLE,
15159            "a measured database failure must still fail the check"
15160        );
15161    }
15162
15163    /// **A disconnected client must not be able to cancel the probe.**
15164    ///
15165    /// Axum drops the handler future when a caller goes away. With the probe
15166    /// inline that dropped it mid-flight and released the claim WITHOUT
15167    /// recording a verdict — which let an unauthenticated caller manufacture the
15168    /// no-verdict state on demand and freeze what every other caller, including
15169    /// Fly's own check, reads. The probe runs detached now, so the verdict is
15170    /// recorded whatever happens to the request that started it.
15171    #[tokio::test]
15172    async fn an_abandoned_request_still_records_its_probe() {
15173        use crate::runtime_health::DbProbe;
15174        let state = test_state(&[]).await;
15175        let rh = state.runtime_health.clone();
15176
15177        // Drive /health and abandon it immediately — the disconnect case.
15178        let app = router(state.clone());
15179        let fut = app.oneshot(
15180            Request::builder()
15181                .uri("/health")
15182                .body(Body::empty())
15183                .unwrap(),
15184        );
15185        let handle = tokio::spawn(fut);
15186        handle.abort();
15187        let _ = handle.await;
15188
15189        // The detached probe still completes and publishes a verdict, so the
15190        // claim is free and the next caller gets a MEASURED answer.
15191        for _ in 0..50 {
15192            if rh.begin_db_probe().is_ok() {
15193                break;
15194            }
15195            tokio::time::sleep(Duration::from_millis(20)).await;
15196        }
15197        let resp = router(state.clone())
15198            .oneshot(
15199                Request::builder()
15200                    .uri("/health")
15201                    .body(Body::empty())
15202                    .unwrap(),
15203            )
15204            .await
15205            .unwrap();
15206        let body = String::from_utf8(
15207            axum::body::to_bytes(resp.into_body(), usize::MAX)
15208                .await
15209                .unwrap()
15210                .to_vec(),
15211        )
15212        .unwrap();
15213        assert!(
15214            body.contains("db: ok"),
15215            "after an abandoned request the next caller still reads an \
15216             unmeasured database — the probe was cancelled with it: {body}"
15217        );
15218        // Sanity: the type still distinguishes the three states.
15219        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
15220    }
15221
15222    /// **The probe must read a real page.**
15223    ///
15224    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
15225    /// it never touches a b-tree and returns success against a corrupted
15226    /// database. Asserted by asking SQLite what the statement actually compiles
15227    /// to, so it survives someone "simplifying" the query later.
15228    #[tokio::test]
15229    async fn the_health_probe_opens_a_real_table() {
15230        use sqlx::Row;
15231        let state = test_state(&[]).await;
15232        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
15233        let opcodes = |sql: &'static str| {
15234            let db = state.db.clone();
15235            async move {
15236                sqlx::query(sql)
15237                    .fetch_all(&db)
15238                    .await
15239                    .unwrap()
15240                    .into_iter()
15241                    .map(|r| r.get::<String, _>("opcode"))
15242                    .collect::<Vec<String>>()
15243            }
15244        };
15245
15246        // The statement `health_db_probe` really runs — it is the sole path, so
15247        // there is no second string for the handler to use instead.
15248        let explain: &'static str =
15249            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
15250        let probe = opcodes(explain).await;
15251        // And the probe itself works against a real schema.
15252        assert!(
15253            health_db_probe(&state.db).await.is_ok(),
15254            "the probe does not run against the real schema",
15255        );
15256        assert!(
15257            probe.iter().any(|op| op == "OpenRead"),
15258            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
15259        );
15260        // And the bare form genuinely does not, which is the whole point.
15261        let bare = opcodes("EXPLAIN SELECT 1").await;
15262        assert!(
15263            !bare.iter().any(|op| op == "OpenRead"),
15264            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
15265        );
15266    }
15267
15268    /// A fresh instance says "never", not "0" — which would read as "polled
15269    /// just now", the opposite of the truth.
15270    #[test]
15271    fn an_instance_that_has_never_polled_says_so() {
15272        assert_eq!(humanise_ago(None), "never");
15273        assert_eq!(humanise_ago(Some(0)), "0s ago");
15274        assert_eq!(humanise_ago(Some(59)), "59s ago");
15275        assert_eq!(humanise_ago(Some(60)), "1m ago");
15276        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
15277        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
15278    }
15279
15280    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
15281    /// record, and anything else with an empty list. Serves repeatedly.
15282    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
15283        use tokio::io::{AsyncReadExt, AsyncWriteExt};
15284        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15285        let addr = listener.local_addr().unwrap();
15286        let (url, title) = (saved_url.to_string(), saved_title.to_string());
15287        tokio::spawn(async move {
15288            loop {
15289                let Ok((mut sock, _)) = listener.accept().await else {
15290                    break;
15291                };
15292                let mut buf = vec![0u8; 8192];
15293                let Ok(n) = sock.read(&mut buf).await else {
15294                    continue;
15295                };
15296                let req = String::from_utf8_lossy(&buf[..n]).to_string();
15297                let wants_saved = req.contains("community.lexicon.rss.saved");
15298                let records = if wants_saved {
15299                    serde_json::json!([{
15300                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
15301                        "cid": "bafy",
15302                        "value": {
15303                            "$type": "community.lexicon.rss.saved",
15304                            "url": url,
15305                            "title": title,
15306                            "createdAt": "2026-01-01T00:00:00Z"
15307                        }
15308                    }])
15309                } else {
15310                    serde_json::json!([])
15311                };
15312                let body = serde_json::json!({
15313                    "ok": true, "data": { "records": records }
15314                })
15315                .to_string();
15316                let resp = format!(
15317                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15318                    body.len(), body
15319                );
15320                let _ = sock.write_all(resp.as_bytes()).await;
15321                let _ = sock.flush().await;
15322            }
15323        });
15324        format!("http://{addr}")
15325    }
15326
15327    /// A sidecar mock serving `n` distinct saved records, none of them cached
15328    /// locally — the shape that exercises the uncached-row append.
15329    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
15330        let feed = subscribed_feed.to_string();
15331        use tokio::io::{AsyncReadExt, AsyncWriteExt};
15332        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15333        let addr = listener.local_addr().unwrap();
15334        tokio::spawn(async move {
15335            loop {
15336                let Ok((mut sock, _)) = listener.accept().await else {
15337                    break;
15338                };
15339                let mut buf = vec![0u8; 8192];
15340                let Ok(read) = sock.read(&mut buf).await else {
15341                    continue;
15342                };
15343                let req = String::from_utf8_lossy(&buf[..read]).to_string();
15344                let records = if req.contains("community.lexicon.rss.saved") {
15345                    serde_json::Value::Array(
15346                        (0..n)
15347                            .map(|i| {
15348                                serde_json::json!({
15349                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
15350                                    "cid": "bafy",
15351                                    "value": {
15352                                        "$type": "community.lexicon.rss.saved",
15353                                        "url": format!("https://elsewhere.example/{i}"),
15354                                        "title": format!("Elsewhere {i}"),
15355                                        "createdAt": "2026-01-01T00:00:00Z"
15356                                    }
15357                                })
15358                            })
15359                            .collect(),
15360                    )
15361                } else if req.contains("community.lexicon.rss.subscription") {
15362                    // Without this the handler's `sync_sub_refs` would REPLACE
15363                    // sub_ref with an empty set on every render, and every
15364                    // sub_ref-scoped read — including the cached starred list
15365                    // this test is about — would come back empty.
15366                    serde_json::json!([{
15367                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
15368                        "cid": "bafy",
15369                        "value": {
15370                            "$type": "community.lexicon.rss.subscription",
15371                            "url": feed,
15372                            "createdAt": "2026-01-01T00:00:00Z"
15373                        }
15374                    }])
15375                } else {
15376                    serde_json::json!([])
15377                };
15378                let body =
15379                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15380                let resp = format!(
15381                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15382                    body.len(), body
15383                );
15384                let _ = sock.write_all(resp.as_bytes()).await;
15385                let _ = sock.flush().await;
15386            }
15387        });
15388        format!("http://{addr}")
15389    }
15390
15391    /// **The pager must not advertise a page the clamp cannot reach.**
15392    ///
15393    /// The page clamp is computed from the CACHED total; the uncached PDS rows
15394    /// are appended to the last page rather than paged. Inflating `total` with
15395    /// them made `page_count` and the "Older →" link point one page past the end:
15396    /// requesting it clamped straight back, re-rendered the same last page, and
15397    /// still offered the link. An infinite "next" that never advances.
15398    #[tokio::test]
15399    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
15400        let did = "did:plc:pagerloop";
15401        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
15402        let state = test_state_with_sidecar(&[], &sidecar).await;
15403        store::grant_access(&state.db, did, None, "test", None)
15404            .await
15405            .unwrap();
15406        let feed = store::upsert_feed(
15407            &state.db,
15408            &store::NewFeed {
15409                url: "https://loop.example/feed.xml".to_string(),
15410                title: Some("Loop".to_string()),
15411                ..Default::default()
15412            },
15413        )
15414        .await
15415        .unwrap();
15416        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
15417        // and the old arithmetic reported a fourth page.
15418        let entries: Vec<store::NewEntry> = (0..250)
15419            .map(|i| store::NewEntry {
15420                guid: format!("s-{i:04}"),
15421                url: Some(format!("https://loop.example/{i}")),
15422                title: Some(format!("Starred {i:04}")),
15423                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
15424                ..Default::default()
15425            })
15426            .collect();
15427        store::insert_entries(&state.db, feed, &entries, 0)
15428            .await
15429            .unwrap();
15430        store::replace_sub_refs(&state.db, did, &[feed])
15431            .await
15432            .unwrap();
15433        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
15434            .await
15435            .unwrap()
15436        {
15437            store::mark_starred(&state.db, did, row.id, true)
15438                .await
15439                .unwrap();
15440        }
15441
15442        let cookie = session_cookie(&state, did, None);
15443        let app = router(state.clone());
15444        let get = |uri: &str| {
15445            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
15446            async move {
15447                let resp = app
15448                    .oneshot(
15449                        Request::builder()
15450                            .uri(uri)
15451                            .header(header::COOKIE, cookie)
15452                            .body(Body::empty())
15453                            .unwrap(),
15454                    )
15455                    .await
15456                    .unwrap();
15457                assert_eq!(resp.status(), StatusCode::OK);
15458                String::from_utf8(
15459                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
15460                        .await
15461                        .unwrap()
15462                        .to_vec(),
15463                )
15464                .unwrap()
15465            }
15466        };
15467
15468        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
15469        // clamp must agree on that, and EVERY page it offers must have content —
15470        // the original bug advertised a fourth page that clamped back to the
15471        // third and re-rendered it, still offering the link.
15472        let p3 = get("/?view=starred&page=3").await;
15473        assert!(
15474            p3.contains("Page 3 of 4"),
15475            "the pager and the clamp disagree on the total: {}",
15476            p3.split("pager-pos")
15477                .nth(1)
15478                .unwrap_or("")
15479                .chars()
15480                .take(120)
15481                .collect::<String>()
15482        );
15483        // Page 3 is the boundary: the last 50 cached rows, then the first 50
15484        // uncached ones.
15485        assert!(
15486            p3.contains("Elsewhere 0"),
15487            "page 3 should start the uncached run"
15488        );
15489        assert_eq!(
15490            p3.matches("<li class=\"entry").count(),
15491            ENTRIES_PER_PAGE as usize,
15492            "the boundary page is not full"
15493        );
15494
15495        // **The heading, which the previous round broke by deleting this.**
15496        //
15497        // `total` includes the uncached records, so the parenthetical is a
15498        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
15499        // The version that said "plus N" double counted once `total` started
15500        // including them, and N had become page-local in the same commit while
15501        // the template stayed put. It shipped because this assertion was deleted
15502        // rather than updated.
15503        {
15504            let body = &p3;
15505            assert!(
15506                body.contains("330 entries"),
15507                "the heading must count the whole sequence: {}",
15508                body.split("content-count")
15509                    .nth(1)
15510                    .unwrap_or("")
15511                    .chars()
15512                    .take(120)
15513                    .collect::<String>()
15514            );
15515            assert!(
15516                body.contains("(80 saved elsewhere)"),
15517                "the heading must say how many of the total the cache cannot show, \
15518                 as a whole-list figure and not a per-page one: {}",
15519                body.split("content-count")
15520                    .nth(1)
15521                    .unwrap_or("")
15522                    .chars()
15523                    .take(120)
15524                    .collect::<String>()
15525            );
15526            assert!(
15527                !body.contains("plus 50") && !body.contains("plus 80"),
15528                "the heading is adding the uncached rows to a total that already \
15529                 includes them"
15530            );
15531        }
15532
15533        let p4 = get("/?view=starred&page=4").await;
15534        assert!(
15535            p4.contains("Page 4 of 4"),
15536            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
15537        );
15538        assert_eq!(
15539            p4.matches("<li class=\"entry").count(),
15540            30,
15541            "page 4 should hold the remaining 30 uncached records"
15542        );
15543        assert!(
15544            p4.contains("Elsewhere 79"),
15545            "the LAST saved record is unreachable — it can only be removed from here"
15546        );
15547
15548        // No uncached record appears on two pages.
15549        assert!(
15550            !p4.contains("Elsewhere 0"),
15551            "an uncached record was rendered on more than one page"
15552        );
15553        // Page 1 is all cached — and still reports the same whole-list heading,
15554        // because the parenthetical describes the LIST, not the page.
15555        let first = get("/?view=starred").await;
15556        assert!(
15557            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
15558            "the heading changed between pages; it describes the list, not the page"
15559        );
15560        assert!(
15561            !first.contains("Elsewhere "),
15562            "uncached saved records leaked onto the first page"
15563        );
15564    }
15565
15566    /// **A saved record whose article is not cached here is still shown.**
15567    ///
15568    /// The starred view is built from local `entries`, so before this a record
15569    /// starred in ANOTHER atproto reader — the portability the shared lexicon
15570    /// exists for — was simply invisible. It now renders from the PDS record,
15571    /// visually distinct, linking straight out.
15572    #[tokio::test]
15573    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
15574        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
15575        let sidecar =
15576            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
15577        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
15578        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
15579
15580        let resp = router(state)
15581            .oneshot(
15582                Request::builder()
15583                    .uri("/?view=starred")
15584                    .body(Body::empty())
15585                    .unwrap(),
15586            )
15587            .await
15588            .unwrap();
15589        assert_eq!(resp.status(), StatusCode::OK);
15590        let body = String::from_utf8(
15591            axum::body::to_bytes(resp.into_body(), usize::MAX)
15592                .await
15593                .unwrap()
15594                .to_vec(),
15595        )
15596        .unwrap();
15597
15598        assert!(
15599            body.contains("Starred elsewhere"),
15600            "the saved record was not rendered at all"
15601        );
15602        assert!(
15603            body.contains("entry-uncached"),
15604            "it was not marked as uncached, so it looks like a normal entry"
15605        );
15606        assert!(
15607            body.contains("https://elsewhere.example/article"),
15608            "the row must link straight to the article"
15609        );
15610        assert!(
15611            !body.contains("/entries/0/"),
15612            "an uncached row must not offer entry actions against a nonexistent id"
15613        );
15614    }
15615
15616    /// **A PDS `createdAt` must not be able to panic the starred view.**
15617    ///
15618    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
15619    /// timestamp the feed parser produced; the saved-record path passes a bare
15620    /// string off a PDS record, written by whatever client the reader used. A
15621    /// multi-byte value panicked the handler, and with no catch-panic layer the
15622    /// view stayed down until the record was removed — from that same view.
15623    #[test]
15624    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
15625        for hostile in [
15626            "日本語日本語日本",
15627            "é",
15628            "",
15629            "2026",
15630            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
15631        ] {
15632            let out = display_date(Some(hostile));
15633            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
15634        }
15635        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
15636        assert_eq!(display_date(None), "");
15637    }
15638
15639    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
15640    /// its neighbours are limited. It was added as a route and not added here.
15641    #[test]
15642    fn the_unsave_route_is_rate_limited() {
15643        use axum::http::Method;
15644        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
15645        // And the neighbours still are.
15646        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
15647    }
15648
15649    /// **The probe detects a broken database — asserted through `/health`
15650    /// itself, not through a string.**
15651    ///
15652    /// A named constant did not bind the handler: it stayed free to call
15653    /// `query_scalar` with a different literal, so degrading the real probe to
15654    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
15655    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
15656    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
15657    #[tokio::test]
15658    async fn health_reports_a_broken_database() {
15659        let state = test_state(&[]).await;
15660        // Sanity: healthy first, so the assertion below is about the damage.
15661        assert!(
15662            health_db_probe(&state.db).await.is_ok(),
15663            "the fixture was not healthy to begin with",
15664        );
15665
15666        sqlx::query("DROP TABLE feeds")
15667            .execute(&state.db)
15668            .await
15669            .unwrap();
15670
15671        assert!(
15672            health_db_probe(&state.db).await.is_err(),
15673            "the probe reported success against a database missing the table it \
15674             claims to read; `SELECT 1` would do exactly this",
15675        );
15676
15677        let resp = router(state)
15678            .oneshot(
15679                Request::builder()
15680                    .uri("/health")
15681                    .body(Body::empty())
15682                    .unwrap(),
15683            )
15684            .await
15685            .unwrap();
15686        let body = String::from_utf8(
15687            axum::body::to_bytes(resp.into_body(), usize::MAX)
15688                .await
15689                .unwrap()
15690                .to_vec(),
15691        )
15692        .unwrap();
15693        // The documented contract: the FIRST token is the state.
15694        assert!(
15695            body.starts_with("FAIL"),
15696            "/health did not report FAIL for a broken database: {body}",
15697        );
15698        assert!(
15699            !body.contains("db: ok"),
15700            "/health still called the database ok: {body}",
15701        );
15702    }
15703
15704    /// A sidecar mock for the OPML export: serves one subscription and one
15705    /// folder, except for the collection named in `fail_on`, which answers
15706    /// `500` — the shape a refused (short or unreadable) walk takes at this
15707    /// boundary.
15708    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
15709        use tokio::io::{AsyncReadExt, AsyncWriteExt};
15710        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15711        let addr = listener.local_addr().unwrap();
15712        tokio::spawn(async move {
15713            loop {
15714                let Ok((mut sock, _)) = listener.accept().await else {
15715                    break;
15716                };
15717                let mut buf = vec![0u8; 8192];
15718                let Ok(n) = sock.read(&mut buf).await else {
15719                    continue;
15720                };
15721                let req = String::from_utf8_lossy(&buf[..n]).to_string();
15722                let wants = |c: &str| req.contains(c);
15723                if fail_on.is_some_and(wants) {
15724                    let body = r#"{"ok":false,"error":"ShortList"}"#;
15725                    let resp = format!(
15726                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15727                        body.len(),
15728                        body
15729                    );
15730                    let _ = sock.write_all(resp.as_bytes()).await;
15731                    let _ = sock.flush().await;
15732                    continue;
15733                }
15734                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
15735                    serde_json::json!([{
15736                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
15737                        "cid": "bafy",
15738                        "value": {
15739                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
15740                            "url": "https://kept.example/feed.xml",
15741                            "title": "Kept",
15742                            // Inside the folder, so the healthy export has to
15743                            // carry BOTH walks' results: an exporter that lost
15744                            // the folder list would flatten this outline out of
15745                            // its group with nothing else changing.
15746                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15747                            "createdAt": "2026-01-01T00:00:00Z"
15748                        }
15749                    }])
15750                } else if wants(crate::lexicon::nsid::FOLDER) {
15751                    serde_json::json!([{
15752                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15753                        "cid": "bafy",
15754                        "value": {
15755                            "$type": crate::lexicon::nsid::FOLDER,
15756                            "name": "Kept folder",
15757                            "createdAt": "2026-01-01T00:00:00Z"
15758                        }
15759                    }])
15760                } else {
15761                    serde_json::json!([])
15762                };
15763                let body =
15764                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15765                let resp = format!(
15766                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15767                    body.len(),
15768                    body
15769                );
15770                let _ = sock.write_all(resp.as_bytes()).await;
15771                let _ = sock.flush().await;
15772            }
15773        });
15774        format!("http://{addr}")
15775    }
15776
15777    /// A sidecar whose every `listRecords` page carries one good record and
15778    /// one with no `uri` — the #177 shape — for any collection.
15779    async fn spawn_malformed_sidecar() -> String {
15780        use tokio::io::{AsyncReadExt, AsyncWriteExt};
15781        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15782        let addr = listener.local_addr().unwrap();
15783        tokio::spawn(async move {
15784            loop {
15785                let Ok((mut sock, _)) = listener.accept().await else {
15786                    break;
15787                };
15788                let mut buf = vec![0u8; 8192];
15789                let _ = sock.read(&mut buf).await;
15790                let body = serde_json::json!({ "ok": true, "data": { "records": [
15791                    { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
15792                    { "cid": "bafy", "value": {} },
15793                ]}})
15794                .to_string();
15795                let resp = format!(
15796                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15797                    body.len(),
15798                    body
15799                );
15800                let _ = sock.write_all(resp.as_bytes()).await;
15801                let _ = sock.flush().await;
15802            }
15803        });
15804        format!("http://{addr}")
15805    }
15806
15807    async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
15808        let cookie = session_cookie(&state, did, None);
15809        let resp = router(state)
15810            .oneshot(
15811                Request::builder()
15812                    .uri(uri)
15813                    .header(header::COOKIE, cookie)
15814                    .body(Body::empty())
15815                    .unwrap(),
15816            )
15817            .await
15818            .unwrap();
15819        let status = resp.status();
15820        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
15821            .await
15822            .unwrap();
15823        (status, String::from_utf8_lossy(&body).to_string())
15824    }
15825
15826    /// **0.4.0 step 4: a publication document with neither summary field
15827    /// renders as a title, a date and a link** — 8% of measured documents
15828    /// (37 of 449) carry neither `description` nor `textContent`. That is what
15829    /// an RSS reader shows for a title-only feed, not an error, in the list and
15830    /// on the article page alike.
15831    #[tokio::test]
15832    async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
15833        let did = "did:plc:displayer";
15834        let state = test_state(&[did]).await;
15835        let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
15836        let feed_id = store::upsert_feed(
15837            &state.db,
15838            &store::NewFeed {
15839                url: url.into(),
15840                title: Some("Quiet Journal".into()),
15841                ..Default::default()
15842            },
15843        )
15844        .await
15845        .unwrap();
15846        store::replace_sub_refs(&state.db, did, &[feed_id])
15847            .await
15848            .unwrap();
15849        store::insert_entries(
15850            &state.db,
15851            feed_id,
15852            &[store::NewEntry {
15853                guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
15854                    .into(),
15855                url: Some("https://quiet.example/no-summary".into()),
15856                title: Some("A title-only article".into()),
15857                published: Some("2026-07-11T00:00:00Z".into()),
15858                content_html: None,
15859                ..Default::default()
15860            }],
15861            0,
15862        )
15863        .await
15864        .unwrap();
15865        let (status, list) = page_body(state.clone(), did, "/?view=all").await;
15866        assert_eq!(status, StatusCode::OK);
15867        assert!(
15868            list.contains("A title-only article"),
15869            "the entry is missing from the list"
15870        );
15871
15872        let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
15873            .bind(feed_id)
15874            .fetch_one(&state.db)
15875            .await
15876            .unwrap();
15877        let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
15878        assert_eq!(
15879            status,
15880            StatusCode::OK,
15881            "the article page failed for an entry with no body"
15882        );
15883        assert!(page.contains("A title-only article"));
15884        assert!(
15885            page.contains("https://quiet.example/no-summary"),
15886            "no link to the original"
15887        );
15888        assert!(
15889            page.contains(r#"<time datetime=""#),
15890            "no date on the article page"
15891        );
15892    }
15893
15894    /// **#177: a malformed record in the reader's own repo is refused, and the
15895    /// reader is told.** Refusing keeps `replace_sub_refs` from dropping the
15896    /// subscription that record was; telling them keeps the stale list from
15897    /// looking like the real one. Both the reading page and the manage page.
15898    #[tokio::test]
15899    async fn a_malformed_subscription_record_raises_an_alert() {
15900        let did = "did:plc:alerted";
15901        for page in ["/", "/manage"] {
15902            let sidecar = spawn_malformed_sidecar().await;
15903            let state = test_state_with_sidecar(&[did], &sidecar).await;
15904            let (status, body) = page_body(state, did, page).await;
15905            assert_eq!(status, StatusCode::OK, "{page} did not render");
15906            assert!(
15907                body.contains(r#"role="alert""#) && body.contains("could not be read"),
15908                "{page} rendered no alert for a refused subscription list"
15909            );
15910            assert!(
15911                body.contains("1 record(s) in your subscription list"),
15912                "{page} gave the generic alert, not the malformed-record one"
15913            );
15914        }
15915    }
15916
15917    /// The control: a healthy listing raises no alert.
15918    #[tokio::test]
15919    async fn a_healthy_subscription_listing_raises_no_alert() {
15920        let did = "did:plc:exporter";
15921        let sidecar = spawn_export_sidecar(None).await;
15922        let state = test_state_with_sidecar(&[did], &sidecar).await;
15923        let (status, body) = page_body(state, did, "/").await;
15924        assert_eq!(status, StatusCode::OK);
15925        assert!(
15926            !body.contains("could not be read"),
15927            "a healthy listing raised an alert"
15928        );
15929    }
15930
15931    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
15932    async fn export_opml_response(
15933        fail_on: Option<&'static str>,
15934    ) -> (StatusCode, HeaderMap, String) {
15935        let did = "did:plc:exporter";
15936        let sidecar = spawn_export_sidecar(fail_on).await;
15937        let state = test_state_with_sidecar(&[did], &sidecar).await;
15938        let cookie = session_cookie(&state, did, None);
15939        let resp = router(state)
15940            .oneshot(
15941                Request::builder()
15942                    .uri("/opml/export")
15943                    .header(header::COOKIE, cookie)
15944                    .body(Body::empty())
15945                    .unwrap(),
15946            )
15947            .await
15948            .unwrap();
15949        let status = resp.status();
15950        let headers = resp.headers().clone();
15951        let body = String::from_utf8_lossy(
15952            &axum::body::to_bytes(resp.into_body(), usize::MAX)
15953                .await
15954                .unwrap(),
15955        )
15956        .to_string();
15957        (status, headers, body)
15958    }
15959
15960    /// **An empty export is worse than no export, and this is the caller that
15961    /// used to produce one.**
15962    ///
15963    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
15964    /// truncated walk refuses instead of returning a short list, that turned the
15965    /// refusal into `200 OK` carrying a zero-feed
15966    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
15967    /// the moment a locked-out reader reached for one, and the changelog points
15968    /// them at this route as the recovery path.
15969    ///
15970    /// Asserts the three things a reader can actually observe: no success status,
15971    /// no download offered, and no OPML document in the body.
15972    #[tokio::test]
15973    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
15974        let (status, headers, body) =
15975            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
15976
15977        assert_ne!(
15978            status,
15979            StatusCode::OK,
15980            "a failed subscription walk answered 200: {body}",
15981        );
15982        assert!(
15983            !headers.contains_key(header::CONTENT_DISPOSITION),
15984            "a failed subscription walk still offered a download: {headers:?}",
15985        );
15986        assert!(
15987            !body.contains("<opml"),
15988            "a failed subscription walk still served an OPML document: {body}",
15989        );
15990    }
15991
15992    /// The folders half of the same hole. The two walks are separate calls, and
15993    /// fixing only the first leaves an export that silently loses every folder —
15994    /// a flat list that reimports as one, with no sign anything was lost.
15995    #[tokio::test]
15996    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
15997        let (status, headers, body) =
15998            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
15999
16000        assert_ne!(
16001            status,
16002            StatusCode::OK,
16003            "a failed folder walk answered 200: {body}",
16004        );
16005        assert!(
16006            !headers.contains_key(header::CONTENT_DISPOSITION),
16007            "a failed folder walk still offered a download: {headers:?}",
16008        );
16009        assert!(
16010            !body.contains("<opml"),
16011            "a failed folder walk still served an OPML document: {body}",
16012        );
16013    }
16014
16015    /// The other direction, without which "refuse everything" would pass both
16016    /// tests above: a healthy read still serves the file, with the feed in it.
16017    #[tokio::test]
16018    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
16019        let (status, headers, body) = export_opml_response(None).await;
16020
16021        assert_eq!(
16022            status,
16023            StatusCode::OK,
16024            "a healthy export did not answer 200"
16025        );
16026        assert_eq!(
16027            headers
16028                .get(header::CONTENT_DISPOSITION)
16029                .and_then(|v| v.to_str().ok()),
16030            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
16031            "a healthy export did not offer the download",
16032        );
16033        assert!(
16034            body.contains("https://kept.example/feed.xml"),
16035            "the exported OPML lost the subscription: {body}",
16036        );
16037        assert!(
16038            body.contains("Kept folder"),
16039            "the exported OPML lost the folder: {body}",
16040        );
16041    }
16042}