Skip to main content

feather_reader/
web.rs

1//! The axum web layer — server-rendered HTML + a dash of htmx, **no SPA**.
2//!
3//! This module owns the HTTP surface: [`router`] builds an `axum::Router` over
4//! the shared [`AppState`], wiring the store, feed, atproto, and config seams into
5//! a small set of typography-first, dark-mode-ready views rendered with
6//! `askama` templates (under `templates/`). Progressive enhancement is a single
7//! vendored `htmx` script plus a tiny keyboard handler (`static/keyboard.js`);
8//! every interaction also works as a plain HTML form POST, so the reader is fully
9//! usable with JavaScript disabled.
10//!
11//! ## HTTP surface
12//!
13//! * `GET  /health` — liveness + version, as `text/plain`.
14//! * `GET  /about`, `/standard-site`, `/stats`, `/privacy`, `/terms` — static
15//!   and status pages.
16//! * `GET  /manage` — feed and folder management.
17//! * `GET  /` — the reader: a folders/feeds sidebar (from the PDS records layer)
18//!   plus the main article list. Query params pick the scope (`?feed=…` /
19//!   `?folder=…` / all) and the view (`?view=unread|all|starred`).
20//! * `GET  /entries/{id}` — the clean, distraction-free reader for one entry,
21//!   with prev/next within the current list.
22//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
23//! * `POST /entries/{id}/star` — star/unstar; writes a
24//!   `community.lexicon.rss.saved` record to the user's PDS.
25//! * `POST /saved/{rkey}/delete` — unsave a `saved` record.
26//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
27//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
28//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
29//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
30//! * `POST /folders` — create a folder record.
31//! * `POST /folders/{rkey}/rename` — rename a folder record.
32//! * `POST /folders/{rkey}/delete` — delete a folder record.
33//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
34//!   subscription records in the PDS.
35//! * `GET  /opml/export` — OPML export (records → a downloadable document).
36//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
37//!   OAuth sign-in flow, on whichever backend `FEATHERREADER_REPO_BACKEND`
38//!   selects. `GET /oauth/client-metadata.json` and `/oauth/jwks.json` publish
39//!   the Rust client's identity.
40//! * `POST /account/delete` — revoke the session and drop this DID's local state.
41//! * `GET|POST /beta/redeem` — closed-beta invite redemption.
42//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
43//!   reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
44//!   (sets the reserving cookie → `/login`).
45//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
46//!   code + token/url for the bot to post. Cap-aware (409 when full).
47//! * `POST /admin/invites`, `GET /admin/metrics` — admin-only invite minting and
48//!   the backend metrics view.
49//!
50//! ## Identity — a cookie-resolved atproto session
51//!
52//! Per-request identity comes from a **signed session cookie** (`fr_session`)
53//! keyed by the logged-in DID, set by `oauth_callback` and read by
54//! `current_session` / `current_did`. For local runs without the sidecar,
55//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
56//! All PDS reads and writes route through [`AppState::repo`], which dispatches to
57//! the sidecar or the Rust OAuth client by `FEATHERREADER_REPO_BACKEND`.
58
59use std::collections::HashMap;
60use std::net::IpAddr;
61use std::sync::Mutex;
62use std::time::{Duration, Instant};
63
64use askama::Template;
65use axum::{
66    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
67    http::{header, HeaderMap, StatusCode},
68    middleware::{self, Next},
69    response::{Html, IntoResponse, Redirect, Response},
70    routing::{get, post},
71    Form, Router,
72};
73use serde::Deserialize;
74use std::net::SocketAddr;
75use tower_http::services::{ServeDir, ServeFile};
76use tower_http::set_header::SetResponseHeaderLayer;
77use tower_http::trace::TraceLayer;
78use tracing::{info, warn};
79
80use crate::config::Config;
81use crate::lexicon::{self, Folder, Saved, Subscription};
82use crate::safe_link::SafeLink;
83use crate::{feed, store, AppState, Session, VERSION};
84
85// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
86// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
87// here via an explicit path so the reader's OPML routes can use the canonical
88// `parse_opml` / `to_opml` without duplicating that logic.
89#[path = "opml.rs"]
90mod opml;
91
92/// The name of the signed session cookie.
93const SESSION_COOKIE: &str = "fr_session";
94
95/// The name of the short-lived signed **invite** cookie.
96///
97/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
98/// OAuth callback. It reserves *intent* to redeem a specific code before the
99/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
100/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
101/// signed with the same key as the session cookie.
102const INVITE_COOKIE: &str = "fr_invite";
103
104/// Browser-binding cookie for an in-flight OAuth login (Rust backend only).
105///
106/// `state` alone cannot stop a login CSRF: it lives in a server-global table, so
107/// a stolen `state` replayed from ANOTHER browser matches just as well as from
108/// the one that started the flow. This cookie is what makes the callback
109/// browser-specific — the pending row stores only its hash, and a callback that
110/// cannot present it is refused.
111const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
112
113/// How long an in-flight login may sit, matching the pending row's own TTL.
114const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
115
116/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
117/// Short enough that a reserved-but-unclaimed seat frees quickly.
118const INVITE_TTL_SECS: i64 = 1800;
119
120/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
121/// sign-in pitch, and `/about`.
122const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
123
124/// The tip / support link (cloud plan public-experiment UI).
125const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
126
127/// The published crate on crates.io — surfaced on the signed-out landing page.
128const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
129
130/// The Content-Security-Policy applied to every response.
131///
132/// Tuned to keep the app fully working while neutralising injected script:
133/// * `default-src 'self'` — same-origin baseline.
134/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
135///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
136///   `javascript:` href (F4) cannot execute. (The design's templates carry no
137///   inline event handlers — every control is wired in `keyboard.js`.)
138/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
139///   inline styles htmx toggles for its request indicators.
140/// * `img-src 'self' https: data:` — feed content routinely embeds remote
141///   images; allow https + data URIs but not other schemes.
142/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
143///   down form posts, `<base>` hijacking, and clickjacking.
144/// * `object-src 'none'` — no plugins.
145const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
146     script-src 'self'; \
147     style-src 'self' 'unsafe-inline'; \
148     img-src 'self' https: data:; \
149     font-src 'self'; \
150     connect-src 'self'; \
151     form-action 'self'; \
152     base-uri 'self'; \
153     frame-ancestors 'none'; \
154     object-src 'none'";
155
156/// The resolved identity for the current request.
157///
158/// `did` is the primary key for all per-user local state; `handle` is display
159/// only; `sid` is the opaque server-side session id the cookie carried (needed
160/// so logout can revoke exactly this session). Sourced from the signed cookie
161/// (real login) or, if none, the configured dev DID fallback.
162#[derive(Clone, Debug)]
163struct CurrentUser {
164    did: String,
165    handle: Option<String>,
166    /// The opaque session id, if this identity came from a real cookie session
167    /// (absent for the dev-DID fallback, which has no server-side session row).
168    sid: Option<String>,
169}
170
171/// Resolve the current request's session from the signed cookie, falling back to
172/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
173///
174/// The cookie carries an opaque server-minted session id (not the DID). We
175/// verify its HMAC, look the id up in the registry, and — crucially —
176/// **re-check the DID against the closed-beta gate on every request**
177/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
178/// DID's beta seat takes effect immediately for already-issued cookies. (The
179/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
180/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
181async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
182    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
183        if let Some(session) = state.sessions.get(&sid) {
184            if store::has_beta_access(&state.db, &session.did)
185                .await
186                .unwrap_or(false)
187            {
188                return Some(CurrentUser {
189                    did: session.did,
190                    handle: session.handle,
191                    sid: Some(sid),
192                });
193            }
194            // DID no longer holds a beta seat: treat as logged out (and drop the
195            // stale server-side session so the dead cookie can't linger).
196            state.sessions.remove(&sid);
197        }
198    }
199    // No valid cookie: dev fallback only if explicitly configured *and* still
200    // inside the beta gate (seeded via ensure_seed / a redeemed code).
201    if let Some(did) = state.config.dev_did.clone() {
202        if store::has_beta_access(&state.db, &did)
203            .await
204            .unwrap_or(false)
205        {
206            return Some(CurrentUser {
207                did,
208                handle: None,
209                sid: None,
210            });
211        }
212    }
213    None
214}
215
216/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
217async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
218    current_session(state, headers).await.map(|u| u.did)
219}
220
221/// Build the application router over shared [`AppState`].
222///
223/// Wires the reader routes, the health check, and the `/static` asset mount
224/// (the stylesheet, vendored htmx, and the keyboard handler, served from
225/// `static/` via `ServeDir`). A `TraceLayer` gives per-request tracing.
226pub fn router(state: AppState) -> Router {
227    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
228    // and the write endpoints). One instance is cloned into the state closure of
229    // the `rate_limit` middleware.
230    let limiter = RateLimiter::shared();
231    // The trusted client-IP source for the limiter (a proxy header the operator
232    // controls, or the socket peer when unset). Bundled with the limiter so the
233    // middleware derives a spoof-resistant IP.
234    let rl_state = RateLimitState {
235        limiter,
236        trusted_header: state.config.trusted_ip_header.clone(),
237    };
238
239    Router::new()
240        .route("/health", get(health))
241        .route("/about", get(about))
242        .route("/standard-site", get(standard_site))
243        .route("/stats", get(stats))
244        .route("/privacy", get(privacy))
245        .route("/terms", get(terms))
246        .route("/manage", get(manage))
247        .route("/", get(index))
248        .route("/entries/{id}", get(entry_view))
249        .route("/entries/{id}/read", post(mark_read))
250        .route("/entries/{id}/star", post(toggle_star))
251        .route("/saved/{rkey}/delete", post(unsave_record))
252        .route("/read-all", post(mark_all_read))
253        .route("/subscriptions", post(add_subscription))
254        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
255        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
256        .route("/folders", post(create_folder))
257        .route("/folders/{rkey}/rename", post(rename_folder))
258        .route("/folders/{rkey}/delete", post(delete_folder))
259        // OPML import takes untrusted uploads: cap the body so a huge upload
260        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
261        .route(
262            "/opml",
263            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
264        )
265        .route("/opml/export", get(export_opml))
266        .route("/login", get(login_form).post(login_submit))
267        .route(
268            "/beta/redeem",
269            get(beta_redeem_form).post(beta_redeem_submit),
270        )
271        // The follow→invite bot's claim link: a public skeet points a new
272        // follower here with an opaque token that reserves a pre-minted code.
273        .route("/claim", get(claim))
274        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
275        // code + returns its token/url for the bot to post.
276        .route("/bot/claims", post(bot_mint_claim))
277        .route("/admin/invites", post(admin_mint_invites))
278        .route("/admin/metrics", get(admin_metrics))
279        .route("/oauth/client-metadata.json", get(oauth_client_metadata))
280        .route("/oauth/jwks.json", get(oauth_jwks))
281        .route("/account/delete", post(account_delete))
282        .route("/oauth/callback", get(oauth_callback))
283        .route("/logout", post(logout))
284        .nest_service("/static", ServeDir::new("static"))
285        // Browsers (and some feed clients) request /favicon.ico at the root
286        // regardless of the <link rel="icon"> tags; serve the same icon that
287        // lives under /static so the bare path stops 404-ing.
288        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
289        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
290        // logged-out landing + static assets, `no-store` on anything that
291        // rendered a session's private view. Runs *inside* the security layers so
292        // the CSP/nosniff/frame headers are untouched.
293        .layer(middleware::from_fn(cache_control))
294        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
295        // as a middleware so it sees the matched path + the peer IP.
296        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
297        .layer(TraceLayer::new_for_http())
298        // Baseline security headers on *every* response (F4). The CSP is the
299        // backstop that neutralises any XSS that slips past sanitization; the
300        // others harden sniffing, framing, and referrer leakage.
301        .layer(static_header_layer(
302            "content-security-policy",
303            CONTENT_SECURITY_POLICY,
304        ))
305        .layer(static_header_layer("x-content-type-options", "nosniff"))
306        .layer(static_header_layer(
307            "referrer-policy",
308            "strict-origin-when-cross-origin",
309        ))
310        .layer(static_header_layer("x-frame-options", "DENY"))
311        .with_state(state)
312}
313
314/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
315/// axum's 2 MiB default.**
316///
317/// The value used to BE the framework default, which made the route's own
318/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
319/// nothing could test it, and the ceiling this route wanted was whatever the
320/// framework happened to pick. Sized to this route instead — one outline is
321/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
322/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
323/// larger is not a subscription list.
324///
325/// Being strictly tighter than the default is what makes the layer both real
326/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
327/// uploads a payload that only this limit refuses.
328const OPML_BODY_LIMIT: usize = 1024 * 1024;
329
330/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
331/// uploads a payload between the two ceilings.
332///
333/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
334/// number is exact — axum does not export it, so it cannot be imported. The
335/// exceeding is what the test's mutation demonstrates: with the route's layer
336/// removed, a payload of this size is accepted. If axum ever lowers its
337/// default below ours, that mutation stops failing and the compile-time
338/// assertion below is the thing to revisit.
339#[cfg(test)]
340const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
341
342/// The route's cap must stay strictly tighter than the framework's, or its
343/// layer is a no-op again. A compile error, not a test failure: this is a
344/// property of the two constants, and nothing should be able to build a binary
345/// where it is false.
346#[cfg(test)]
347const _: () = assert!(
348    OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
349    "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
350);
351
352/// A response-header layer that sets `name: value` on every response, overriding
353/// any existing header of that name. `name`/`value` must be valid static header
354/// tokens (they are, for our fixed security headers).
355fn static_header_layer(
356    name: &'static str,
357    value: &'static str,
358) -> SetResponseHeaderLayer<header::HeaderValue> {
359    SetResponseHeaderLayer::overriding(
360        header::HeaderName::from_static(name),
361        header::HeaderValue::from_static(value),
362    )
363}
364
365// ---------------------------------------------------------------------------
366// Per-IP rate limiting (token bucket, self-contained — no extra crate)
367// ---------------------------------------------------------------------------
368
369/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
370/// kick-off and callback, the invite redeem, logout, the mutating write
371/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
372/// intentionally *not* limited.
373///
374/// The criterion is **does this path make an outbound request**, not "does it
375/// mutate" — the two diverge, and every miss so far has been on the outbound
376/// side. This is an allowlist a new route has to be added to by hand, which is
377/// exactly why it has now been missed three times: `/saved/` (fixed), then
378/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
379/// only path here reachable with no session at all.
380///
381/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
382/// make PDS calls but are ordinary authenticated navigation, and throttling them
383/// would degrade normal reading. They are bounded by needing a valid session.
384fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
385    use axum::http::Method;
386    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
387    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
388    // like the other abuse-prone entry points — not just `/login`.
389    // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
390    // real outbound round-trip — a sidecar `resolve_session` or a full token
391    // exchange against a PDS. Anyone could spend one outbound request per hit.
392    // It is the only entry point here that needs no session at all.
393    if method != Method::POST
394        && !(method == Method::GET
395            && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
396    {
397        return false;
398    }
399    match path {
400        // `/logout` and `/oauth/callback` are here because they make outbound
401        // calls, not because they mutate: logout revokes at the PDS (up to two
402        // round-trips) and the callback exchanges a code. The list is by
403        // *network cost*, which is what the limiter is actually for.
404        "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
405        | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
406        | "/folders" => true,
407        // Every per-record subscription/folder mutation (delete/rename) and the
408        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
409        p => {
410            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
411                // Unsaving makes a DPoP-signed deleteRecord round-trip to the
412                // PDS, which is exactly the reason the neighbours above are
413                // limited. It was added as a new route and not added here.
414                || p.starts_with("/saved/")
415                || p.starts_with("/subscriptions/")
416                || p.starts_with("/folders/")
417        }
418    }
419}
420
421/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
422/// client-IP header (if any). Cloned into every request; both fields are cheap.
423#[derive(Clone)]
424struct RateLimitState {
425    limiter: RateLimiter,
426    /// The lowercased proxy header the operator trusts for the client IP, or
427    /// `None` to trust only the socket peer. See [`client_ip`].
428    trusted_header: Option<String>,
429}
430
431/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
432/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
433/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
434/// dependency → no network fetch at build, deterministic offline CI).
435#[derive(Clone)]
436struct RateLimiter {
437    inner: std::sync::Arc<Mutex<RateLimiterState>>,
438}
439
440/// The limiter's shared state: the buckets plus when they were last swept.
441struct RateLimiterState {
442    buckets: HashMap<IpAddr, Bucket>,
443    last_sweep: Instant,
444}
445
446/// One IP's token bucket: a fractional token count + the last-refill instant.
447struct Bucket {
448    tokens: f64,
449    last: Instant,
450}
451
452/// Burst capacity per IP — how many requests can arrive back-to-back.
453const RATE_BURST: f64 = 20.0;
454/// Steady-state refill rate (tokens/sec) once the burst is spent.
455const RATE_REFILL_PER_SEC: f64 = 1.0;
456/// Evict idle buckets older than this so the map can't grow unbounded.
457const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
458
459/// How often the idle sweep may actually run.
460///
461/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
462/// map to find entries that, by construction, can only age out on an hour
463/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
464/// under any volume of distinct source IPs the server spent its single shared
465/// core re-walking a map whose contents had not changed. Once a minute is
466/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
467const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
468
469/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
470/// target, sized so ordinary traffic never reaches it.
471///
472/// The idle eviction above was the only bound, and it is a TIME bound, which
473/// says nothing about how many distinct IPs can arrive inside one hour.
474/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
475/// this one did not.
476const MAX_RATE_BUCKETS: usize = 10_000;
477
478/// When the cap is hit, evict down to this fraction of it rather than removing
479/// a single entry — so the O(n) eviction happens once per `cap/8` requests
480/// instead of once per request while the map sits full.
481const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
482
483impl RateLimiter {
484    /// A fresh, shared limiter (cloned into the middleware state).
485    fn shared() -> Self {
486        Self {
487            inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
488                buckets: HashMap::new(),
489                last_sweep: Instant::now(),
490            })),
491        }
492    }
493
494    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
495    /// bucket is empty (→ 429).
496    fn check(&self, ip: IpAddr) -> bool {
497        self.check_at(ip, Instant::now())
498    }
499
500    /// [`check`](Self::check) with the clock injected, so the sweep and eviction
501    /// paths below are reachable in a test without sleeping through an hour.
502    fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
503        let mut state = match self.inner.lock() {
504            Ok(m) => m,
505            // A poisoned lock shouldn't take the site down — fail open.
506            Err(p) => p.into_inner(),
507        };
508
509        // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
510        if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
511            state
512                .buckets
513                .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
514            state.last_sweep = now;
515        }
516
517        // Hard size bound, independent of the time bound above.
518        //
519        // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
520        // attacker cannot use eviction to clear their OWN throttled bucket: that
521        // bucket is by definition the most recently touched, so it is the last
522        // thing this removes. Going quiet long enough to become the oldest entry
523        // is exactly what the refill already grants for free.
524        if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
525            let mut by_age: Vec<(IpAddr, Instant)> =
526                state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
527            by_age.sort_unstable_by_key(|(_, last)| *last);
528            for (victim, _) in by_age
529                .into_iter()
530                .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
531            {
532                state.buckets.remove(&victim);
533            }
534            warn!(
535                buckets = state.buckets.len(),
536                "rate-limit bucket cap reached; evicted the least recently seen clients"
537            );
538        }
539
540        let bucket = state.buckets.entry(ip).or_insert(Bucket {
541            tokens: RATE_BURST,
542            last: now,
543        });
544        let elapsed = now.duration_since(bucket.last).as_secs_f64();
545        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
546        bucket.last = now;
547        if bucket.tokens >= 1.0 {
548            bucket.tokens -= 1.0;
549            true
550        } else {
551            false
552        }
553    }
554}
555
556/// The **trusted** client IP for a request.
557///
558/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
559/// is fully bypassable — the left-most value is attacker-supplied (any client
560/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
561/// bucket and the per-IP limit never bites. We therefore derive the IP only from
562/// a source the operator controls:
563///
564/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
565///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
566///   set by the proxy we run in front and overwrites any client-supplied copy.
567///   We take the LAST value if the header happens to be a comma list (the hop
568///   the trusted proxy appended), which is also the correct read for a
569///   right-most-`X-Forwarded-For` deployment where the operator points
570///   `trusted_header` at `x-forwarded-for`.
571/// * Otherwise we ignore all forwarding headers and use the socket peer
572///   (`ConnectInfo`) — correct for a direct bind with no proxy.
573///
574/// Returns `None` only when neither source yields a parseable IP (the limiter
575/// then fails open for that one request).
576fn client_ip(
577    headers: &HeaderMap,
578    conn: Option<&SocketAddr>,
579    trusted_header: Option<&str>,
580) -> Option<IpAddr> {
581    if let Some(name) = trusted_header {
582        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
583            // Right-most hop is the one the trusted proxy appended; earlier
584            // entries may be client-forged, so never trust the left-most.
585            if let Some(last) = raw.split(',').next_back() {
586                if let Ok(ip) = last.trim().parse::<IpAddr>() {
587                    return Some(ip);
588                }
589            }
590        }
591        // Trusted header absent/unparseable → fall through to the socket peer.
592    }
593    conn.map(|s| s.ip())
594}
595
596/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
597/// empty; every other request (and every non-guarded path) passes through. The
598/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
599/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
600async fn rate_limit(
601    State(rl): State<RateLimitState>,
602    req: axum::extract::Request,
603    next: Next,
604) -> Response {
605    let path = req.uri().path().to_string();
606    let method = req.method().clone();
607    if is_rate_limited_path(&path, &method) {
608        let conn = req
609            .extensions()
610            .get::<ConnectInfo<SocketAddr>>()
611            .map(|c| c.0);
612        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
613        // Deliberately fail OPEN when no client IP is derivable (no trusted
614        // header / no socket peer): there is no per-IP key to enforce, and a
615        // blanket 429 would self-DoS every guarded path (incl. /login). This is
616        // safe precisely because we never key on an attacker-forged XFF — see
617        // `rate_limit_ignores_spoofed_xff_rotation`.
618        if let Some(ip) = ip {
619            if !rl.limiter.check(ip) {
620                warn!(%ip, %path, "rate limit exceeded");
621                return (
622                    StatusCode::TOO_MANY_REQUESTS,
623                    [(header::RETRY_AFTER, "1")],
624                    "rate limit exceeded\n",
625                )
626                    .into_response();
627            }
628        }
629    }
630    next.run(req).await
631}
632
633// ---------------------------------------------------------------------------
634// Cache-Control (viral / CDN vs. private authenticated views)
635// ---------------------------------------------------------------------------
636
637/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
638/// logged-out surfaces (the `/login` landing without a handle, `/about`,
639/// `/standard-site`, `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
640/// authenticated app pages, so a CDN /
641/// browser can hold the viral landing while never caching a signed-in user's
642/// private view. Never overrides a handler that already set Cache-Control.
643async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
644    let path = req.uri().path().to_string();
645    // The logged-out landing is only cacheable when it's the bare form — a
646    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
647    let is_login_landing = path == "/login"
648        && req.method() == axum::http::Method::GET
649        && !req.uri().query().unwrap_or("").contains("handle=");
650    let public = is_login_landing
651        || path == "/about"
652        || path == "/standard-site"
653        || path == "/privacy"
654        || path == "/terms"
655        || path.starts_with("/static/");
656
657    let mut resp = next.run(req).await;
658    if resp.headers().contains_key(header::CACHE_CONTROL) {
659        return resp;
660    }
661    let value = if public {
662        "public, max-age=300"
663    } else {
664        "no-store"
665    };
666    if let Ok(hv) = header::HeaderValue::from_str(value) {
667        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
668    }
669    resp
670}
671
672// ---------------------------------------------------------------------------
673// Health
674// ---------------------------------------------------------------------------
675
676/// Run `/health`'s database probe. **The single path, so a test cannot assert
677/// on a string the handler is free to ignore** — a named constant alone was not
678/// enough: the test read the constant while the handler passed `query_scalar`
679/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
680async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
681    sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
682        .fetch_optional(pool)
683        .await
684}
685
686/// The statement `/health` uses to prove the database is readable.
687///
688/// **A named constant so the test can assert on the query that actually runs.**
689/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
690/// this string, so degrading the real probe to `SELECT 1` — which opens no page
691/// and therefore cannot detect a broken database — left the suite green.
692const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
693
694/// How long `/health` will wait for its database ping before calling it broken.
695///
696/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
697/// handler chose rather than a timeout Fly inferred — the difference between a
698/// log line that says why and one that says nothing.
699const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
700
701/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
702/// see the handler for why.
703///
704/// The threshold itself is derived from the configured tick
705/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
706/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
707/// stale` in the body the deployment docs now tell them to alert on.
708const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
709
710/// How long without a completed tick before the poller reads as stale: several
711/// tick intervals, floored, so a normally-paced loop never trips it and a
712/// genuinely wedged one always does.
713fn health_tick_stale_secs(tick: Duration) -> i64 {
714    let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
715    tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
716}
717
718/// The poll tick this instance is configured for. Read from the same env var
719/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
720/// handler cannot see its constants.
721fn configured_poll_tick() -> Duration {
722    std::env::var("FEATHERREADER_POLL_TICK_SECS")
723        .ok()
724        .and_then(|v| v.trim().parse::<u64>().ok())
725        .filter(|s| *s > 0)
726        .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
727}
728
729/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
730/// so cannot be imported here. Duplicated deliberately and named, rather than
731/// left as a bare `60` inside the parse chain, so the drift is at least visible
732/// if the scheduler's value ever moves.
733const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
734
735/// Grace period after boot before a poller that has never ticked is called
736/// `stale` rather than `not-yet-ticked`.
737///
738/// Without this the two are indistinguishable forever, which matters precisely
739/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
740/// cycles the poller never reaches its first tick, so `/health` reported the
741/// benign `not-yet-ticked` on every single probe and the heartbeat could not
742/// detect the failure mode it exists for. `run_poller` returning early — a failed
743/// HTTP client build — has the same shape and was equally invisible.
744///
745/// Sized off the poller's own startup delay plus its tick, with slack.
746const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
747
748/// `GET /health` — does this process still work, and what are its loops doing?
749///
750/// This used to return a constant string, touching no database, no pool and no
751/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
752/// sole other failure detector is a child process exiting. It proved the HTTP
753/// listener was up and nothing else.
754///
755/// **What can fail the check: the database, and only the database.** A process
756/// that cannot reach its store serves nothing, so a restart is the right
757/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
758/// mode is not blocked by any writer — so the retention sweep, the poller and a
759/// login burst cannot make this flap. That property is the reason it is a read
760/// and not, say, a write canary.
761///
762/// **What is reported but never fails the check: everything else.** A stale poll
763/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
764/// and none of them a reason to stop serving.
765///
766/// That last clause is the whole justification, and it is NOT the one this
767/// comment used to give. It said "Fly restarts on a failed check", which is
768/// false — verified against Fly's own docs, which state it three times: *"your
769/// Machines won't automatically restart or stop due to failing their health
770/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
771/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
772/// V1 (`restart_limit`) and has no successor on Machines.
773///
774/// The corrected model makes the conclusion stronger, not weaker. With one
775/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
776/// failover — it is a total outage that lasts exactly as long as the condition,
777/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
778/// question the status code answers is not "would a restart fix this" but **"can
779/// this process still serve a useful request at all"**. A stale poller can. A
780/// database it cannot read cannot.
781///
782/// Re-registration is automatic: the proxy keeps probing and routes again the
783/// moment the check passes. That is what makes a 503 recoverable without
784/// intervention — not a restart, which never comes.
785///
786/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
787/// it is publishable on the same terms as `/stats`. It is also the non-session
788/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
789/// (which needs a live admin session) is exactly as unreachable as the thing it
790/// would diagnose, while this is reachable with `curl`.
791async fn health(State(state): State<AppState>) -> Response {
792    let now = chrono::Utc::now().timestamp();
793    let rh = &state.runtime_health;
794
795    use crate::runtime_health::DbProbe;
796    let db = match rh.begin_db_probe() {
797        // A probe is already in flight; report its predecessor rather than
798        // starting a second one. See `RuntimeHealth::begin_db_probe`.
799        Err(borrowed) => borrowed,
800        Ok(probe) => {
801            // **Spawned, so the probe cannot be cancelled by the caller.**
802            //
803            // Axum drops the handler future when a client disconnects. With the
804            // probe inline, that dropped it mid-flight and released the claim
805            // WITHOUT recording a verdict — which let an unauthenticated caller
806            // manufacture the no-verdict state on demand and freeze what every
807            // other caller, Fly's check included, reads. Running it detached
808            // means the verdict is always recorded and the claim is always
809            // released after it.
810            let pool = state.db.clone();
811            let task = tokio::spawn(async move {
812                // **`SELECT 1` was not a database probe.** It compiles to
813                // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
814                // never touches a b-tree, never reads a page, and never consults
815                // the file. Against a corrupted database it returns success
816                // while every real query returns SQLITE_CORRUPT. Reading one row
817                // from a real table costs the same and actually proves what the
818                // check claims. `LIMIT 1` keeps it to a single page; an empty
819                // table still opens the b-tree root, which is the part that
820                // matters.
821                let verdict =
822                    match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
823                        Ok(Ok(_)) => DbProbe::Ok,
824                        // Coarse, not the raw error. An unauthenticated caller
825                        // learning exactly which failure it hit is an
826                        // attack-progress oracle; the detail belongs in the log,
827                        // which gets it here.
828                        Ok(Err(err)) => {
829                            warn!(%err, "health: database probe failed");
830                            DbProbe::Failed("unavailable".to_string())
831                        }
832                        Err(_) => {
833                            warn!(
834                                timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
835                                "health: database probe timed out (pool exhausted?)"
836                            );
837                            DbProbe::Failed("timeout".to_string())
838                        }
839                    };
840                probe.record(verdict.clone());
841                verdict
842            });
843            // A panicking task drops the guard, which releases the claim without
844            // a verdict — the only remaining path to that state, and not one a
845            // caller can drive.
846            task.await.unwrap_or(DbProbe::Unknown)
847        }
848    };
849
850    let uptime = rh.uptime_secs(now);
851    let poller = if !rh.schedulers_enabled() {
852        // Not a fault. Dev runs and the seam tests disable the loops on purpose,
853        // and reporting that as "stale" would be a false alarm on every one.
854        "disabled".to_string()
855    } else {
856        match rh.secs_since_poll_tick(now) {
857            // "Never ticked" is benign right after boot and alarming well after
858            // it — so it is read against UPTIME, not left permanently benign.
859            None => match uptime {
860                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
861                    format!("stale never-ticked {up}s")
862                }
863                _ => "not-yet-ticked".to_string(),
864            },
865            Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
866                format!("stale {secs}s")
867            }
868            Some(secs) => format!("ok {secs}s"),
869        }
870    };
871
872    // **Only a MEASURED failure fails the check.**
873    //
874    // `Unknown` means no probe has completed — a concurrent request arrived
875    // before the first one finished, or a previous owner was cancelled before
876    // recording. It is reported and returns 200, because an unmeasured database
877    // is not evidence of a broken one, and this endpoint is reachable by
878    // unauthenticated callers who can manufacture that state. Treating it as a
879    // failure handed them a lever on the only signal the platform acts on.
880    let mut body = String::new();
881    let status = match &db {
882        DbProbe::Ok => {
883            body.push_str(&format!("ok featherreader/{VERSION}\n"));
884            body.push_str("db: ok\n");
885            StatusCode::OK
886        }
887        // **Not `ok`.** The first token is the state, and this one is neither
888        // healthy nor failed. It used to print a line byte-identical to the
889        // healthy branch, which mattered because `fly.toml` tells operators to
890        // alert on the BODY for everything the status code deliberately ignores
891        // — so a monitor keying on `^ok` read green in exactly the state this
892        // enum exists to make visible.
893        DbProbe::Unknown => {
894            body.push_str(&format!("unknown featherreader/{VERSION}\n"));
895            body.push_str("db: unknown (no probe has completed yet)\n");
896            StatusCode::OK
897        }
898        DbProbe::Failed(why) => {
899            body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
900            body.push_str(&format!("db: {why}\n"));
901            StatusCode::SERVICE_UNAVAILABLE
902        }
903    };
904    // Uptime answers the first question anyone asks about a container under a
905    // supervisor that tears the machine down whenever a child exits: is this
906    // thing restarting? Nothing else on any surface could tell you.
907    body.push_str(&format!(
908        "uptime: {}\n",
909        match uptime {
910            Some(secs) => format!("{secs}s"),
911            None => "unknown".to_string(),
912        }
913    ));
914    body.push_str(&format!("poller: {poller}\n"));
915    body.push_str(&format!(
916        "polling-paused: {}\n",
917        if rh.watermark_paused() { "yes" } else { "no" }
918    ));
919    // Deliberately NOT the measured database size. `/health` is the one path
920    // exempted from the Caddy origin lock, so it answers direct hits to the Fly
921    // IP that never passed Cloudflare — which caps what belongs here at the
922    // class of facts `/stats` already publishes to anyone. "Polling is paused"
923    // is that; the exact byte count is a precise internal number that adds
924    // nothing an operator cannot get from `/stats` or the logs.
925    body.push_str(&format!(
926        "backend: {}\n",
927        state.config.repo_backend.as_str()
928    ));
929    body.push_str(&format!(
930        "oauth-runtime: {}\n",
931        if state.oauth.is_some() {
932            "built"
933        } else {
934            "absent"
935        }
936    ));
937
938    // Never cached: a stale health response is worse than none, and Cloudflare
939    // sits in front of this.
940    let mut resp = (status, body).into_response();
941    if let Ok(hv) = header::HeaderValue::from_str("no-store") {
942        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
943    }
944    resp
945}
946
947/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
948/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
949/// Readable whether or not a session exists.
950///
951/// Optionally carries one quiet line about network adoption
952/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
953/// default — the handler issues **zero** queries and the page is byte-identical
954/// to what it was before the probe existed.
955async fn about(State(state): State<AppState>) -> Response {
956    let adoption = if state.config.show_adoption {
957        adoption_line(&state).await
958    } else {
959        None
960    };
961    render(&AboutTemplate {
962        card: Card::public(
963            &state.config,
964            "/about",
965            "About — FeatherReader",
966            "What FeatherReader is and isn't: an open-source, atproto-native reader for \
967             RSS feeds and standard.site publications, run as an experiment, free to \
968             self-host under the AGPL.",
969        ),
970        version: VERSION,
971        repo_url: REPO_URL,
972        kofi_url: KOFI_URL,
973        adoption,
974        standard_site: state.config.standard_site,
975    })
976}
977
978/// `GET /standard-site` — the public feature page for standard.site
979/// publications: what a publication is, what FeatherReader shows from one, how
980/// to subscribe, the limits, and the latest releases. Readable whether or not
981/// a session exists, like `/about`. Every how-to-subscribe line is conditional
982/// on `Config::standard_site`, as on the other public pages.
983async fn standard_site(State(state): State<AppState>) -> Response {
984    render(&StandardSiteTemplate {
985        card: Card::public(
986            &state.config,
987            "/standard-site",
988            "standard.site — FeatherReader",
989            "Read standard.site publications beside your RSS feeds: articles \
990             published as atproto records, followed with the same portable \
991             subscription record.",
992        ),
993        version: VERSION,
994        repo_url: REPO_URL,
995        kofi_url: KOFI_URL,
996        standard_site: state.config.standard_site,
997        releases: RELEASES,
998    })
999}
1000
1001/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
1002///
1003/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
1004/// does not have. This deletes the record straight from the repo by its rkey,
1005/// and then clears any LOCAL star for the same article.
1006///
1007/// That second step is not belt-and-braces. "Has no local entry" is how the
1008/// starred view classifies a record, and it decides that through `sub_ref` — so
1009/// an article that really is cached, and really is starred, lands here whenever
1010/// the reader has unsubscribed from its feed. Deleting only the record left
1011/// `entry_state.starred = 1` behind: invisible, because the starred list is
1012/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
1013/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
1014/// places it lives.
1015async fn unsave_record(
1016    State(state): State<AppState>,
1017    headers: HeaderMap,
1018    Path(rkey): Path<String>,
1019) -> Response {
1020    let Some(did) = current_did(&state, &headers).await else {
1021        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1022    };
1023
1024    // Read the record's identity BEFORE deleting it — afterwards there is
1025    // nothing left to learn it from. Best-effort: a failure here must not block
1026    // the deletion the reader actually asked for, so it degrades to the old
1027    // behaviour (record gone, local star possibly stale) and says so.
1028    let identity = match state.repo().list_saved(&did).await {
1029        Ok(records) => records
1030            .into_iter()
1031            .find(|(k, _)| *k == rkey)
1032            .map(|(_, rec)| (rec.url, rec.entry_id)),
1033        Err(err) => {
1034            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1035                                      a local star for the same article may survive");
1036            None
1037        }
1038    };
1039
1040    match state.repo().remove_saved(&did, &rkey).await {
1041        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1042        Err(err) => {
1043            warn!(%err, %did, %rkey, "could not remove the saved record");
1044            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1045        }
1046    }
1047
1048    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1049    // saved, so clearing the local star before knowing the record is gone would
1050    // be the desync in the other direction.
1051    if let Some((url, guid)) = identity {
1052        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1053            Ok(0) => {}
1054            Ok(n) => {
1055                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1056            }
1057            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1058        }
1059    }
1060    // htmx swaps the row out; a plain form post goes back to the starred list.
1061    if is_htmx(&headers) {
1062        return (StatusCode::OK, "").into_response();
1063    }
1064    Redirect::to("/?view=starred").into_response()
1065}
1066
1067/// What the poller is doing, as one word for `/stats`.
1068///
1069/// **Parity with `/health` is the point.** `polling_paused` alone reported
1070/// "running" for three different states including the two where nothing polls,
1071/// on the page added to answer exactly that. The first attempt at fixing it
1072/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1073/// poll loop that ticked once at boot and then WEDGED still read as running.
1074/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1075/// original finding's exact shape surviving its own fix.
1076///
1077/// Shares the staleness threshold with `/health` rather than picking its own, so
1078/// the two pages cannot disagree about what "stale" means.
1079fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1080    if !rh.schedulers_enabled() {
1081        return "off";
1082    }
1083    // Checked before the pause: a wedged poller cannot clear a pause either, so
1084    // reporting "paused" would name the symptom and hide the cause.
1085    match rh.secs_since_poll_tick(now_unix) {
1086        None => {
1087            // Never ticked. Benign at boot, a dead loop long after — read
1088            // against uptime, exactly as `/health` does.
1089            match rh.uptime_secs(now_unix) {
1090                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1091                _ => "starting",
1092            }
1093        }
1094        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1095        _ if rh.watermark_paused() => "paused",
1096        _ => "running",
1097    }
1098}
1099
1100/// `GET /stats` — public poll health.
1101async fn stats(State(state): State<AppState>) -> Response {
1102    let now = chrono::Utc::now();
1103    let health = match store::poll_health(
1104        &state.db,
1105        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1106        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1107    )
1108    .await
1109    {
1110        Ok(health) => health,
1111        Err(err) => {
1112            warn!(%err, "could not compute poll health");
1113            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1114        }
1115    };
1116
1117    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1118    // instance is not behind on anything.
1119    let polled_pct = if health.feeds_tracked == 0 {
1120        100
1121    } else {
1122        health.polled_last_hour * 100 / health.feeds_tracked
1123    };
1124
1125    render(&StatsTemplate {
1126        card: Card::public(
1127            &state.config,
1128            "/stats",
1129            "Stats — FeatherReader",
1130            "Is this instance's poller keeping up? Aggregate feed-polling health — \
1131             counts only; no feed and no reader is named.",
1132        ),
1133        version: VERSION,
1134        repo_url: REPO_URL,
1135        kofi_url: KOFI_URL,
1136        feeds_tracked: health.feeds_tracked,
1137        polled_last_hour: health.polled_last_hour,
1138        polled_pct,
1139        overdue: health.overdue,
1140        last_poll: humanise_ago(health.last_poll_secs_ago),
1141        oldest_poll: if health.never_polled > 0 {
1142            "never".to_string()
1143        } else {
1144            humanise_ago(health.oldest_poll_secs_ago)
1145        },
1146        never_polled: health.never_polled,
1147        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1148        // **The two states that actually stop feeds updating.**
1149        //
1150        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1151        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1152        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1153        // feed failing every fetch drops out of the backlog and makes the page
1154        // read healthier. Both of these are machine facts with no per-feed
1155        // detail, so they sit inside the page's stated contract.
1156        in_backoff: health.in_backoff,
1157        badly_broken: health.badly_broken,
1158        failure_kinds: health.failure_kinds,
1159        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1160    })
1161}
1162
1163/// "3h 11m ago", or "never" when there has been no poll at all.
1164///
1165/// `None` must not render as `0` — on a fresh instance that would read as
1166/// "polled just now", which is the opposite of the truth.
1167fn humanise_ago(secs: Option<i64>) -> String {
1168    let Some(secs) = secs else {
1169        return "never".to_string();
1170    };
1171    match secs {
1172        s if s < 60 => format!("{s}s ago"),
1173        s if s < 3600 => format!("{}m ago", s / 60),
1174        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1175    }
1176}
1177
1178/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1179/// observation of zero, or a store failure).
1180///
1181/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1182/// probe is never allowed to affect the reader, and that rule applies at the
1183/// display end too — a locked or corrupt DB costs the About page one log line,
1184/// not a 500.
1185async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1186    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1187        // A legitimate zero renders nothing rather than a sad "0 accounts".
1188        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1189            repos: stat.value,
1190            truncated: stat.truncated,
1191            observed_on: stat
1192                .observed_at
1193                .split('T')
1194                .next()
1195                .unwrap_or_default()
1196                .to_string(),
1197        }),
1198        Ok(_) => None,
1199        Err(err) => {
1200            warn!(%err, "about: adoption stat read failed; omitting the line");
1201            None
1202        }
1203    }
1204}
1205
1206/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1207/// lives in the user's PDS, what the server caches, and the session-token
1208/// handling. A static render; readable whether or not a session exists.
1209async fn privacy(State(state): State<AppState>) -> Response {
1210    render(&PrivacyTemplate {
1211        card: Card::public(
1212            &state.config,
1213            "/privacy",
1214            "Privacy — FeatherReader",
1215            "No account and no tracking: your subscriptions and reading state live in \
1216             your own PDS. What this server caches, for how long, and how the session \
1217             token is handled.",
1218        ),
1219        version: VERSION,
1220        repo_url: REPO_URL,
1221        kofi_url: KOFI_URL,
1222    })
1223}
1224
1225/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1226/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1227/// static render; readable whether or not a session exists.
1228async fn terms(State(state): State<AppState>) -> Response {
1229    render(&TermsTemplate {
1230        card: Card::public(
1231            &state.config,
1232            "/terms",
1233            "Terms — FeatherReader",
1234            "The terms of use: an experimental service offered as-is with no warranty, \
1235             what acceptable use means here, and the AGPL self-host note.",
1236        ),
1237        version: VERSION,
1238        repo_url: REPO_URL,
1239        kofi_url: KOFI_URL,
1240    })
1241}
1242
1243// ---------------------------------------------------------------------------
1244// View models
1245// ---------------------------------------------------------------------------
1246
1247/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1248/// and the PDS subscription rkey for management actions).
1249struct FeedView {
1250    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1251    rkey: String,
1252    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1253    url: String,
1254    title: String,
1255    unread: i64,
1256    /// Whether this feed is the currently-selected scope.
1257    selected: bool,
1258    /// The feed's current folder `at://` URI (from its subscription record), or
1259    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1260    /// rename row so an untouched folder dropdown does not silently un-folder the
1261    /// feed on save.
1262    folder: Option<String>,
1263}
1264
1265/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1266struct FolderView {
1267    /// PDS folder rkey — addresses the record for rename/delete.
1268    rkey: String,
1269    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1270    uri: String,
1271    name: String,
1272    feeds: Vec<FeedView>,
1273    /// Whether this folder is the currently-selected scope.
1274    selected: bool,
1275}
1276
1277/// One entry as shown in the article list / after an htmx swap.
1278struct EntryRow {
1279    id: i64,
1280    title: String,
1281    feed_title: String,
1282    published: String,
1283    read: bool,
1284    starred: bool,
1285    /// The reader link href, already carrying the scope/view query so opening an
1286    /// entry and paging back stays within the list it came from.
1287    link: SafeLink,
1288    /// Whether the article itself is in this instance's cache.
1289    ///
1290    /// `false` for a saved record that exists in the reader's PDS but whose
1291    /// entry was never cached here — starred in another atproto reader, or
1292    /// starred here and since evicted. There is no local row, so the row has no
1293    /// usable `id`: it links straight out to the article and carries no
1294    /// mark-read control, because there is nothing local to mark.
1295    cached: bool,
1296    /// The PDS record key, for un-saving a row that has no local entry.
1297    rkey: String,
1298}
1299
1300/// A folder as an option in the "move feed to folder" select.
1301struct FolderOption {
1302    uri: String,
1303    name: String,
1304}
1305
1306/// The shared navigation "rail" model: the same DOM element is the
1307/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1308/// manage) renders it from this one struct. Feed management lives on `/manage`,
1309/// not here — the rail is navigation only.
1310struct Nav {
1311    /// `@handle` for the identity chip (falls back to the DID's tail).
1312    handle: String,
1313    /// Two-letter avatar initials for the identity chip.
1314    avatar: String,
1315    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1316    view: String,
1317    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1318    /// empty for the unscoped "everything" views.
1319    scope_qs: String,
1320    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1321    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1322    folders: Vec<FolderView>,
1323    loose_feeds: Vec<FeedView>,
1324    /// Whether the "Manage feeds" rail tool is the current page.
1325    manage_active: bool,
1326}
1327
1328/// The subscribe input's `pattern` when standard.site is on, and the input is
1329/// `type="text"` (see `templates/manage.html`). It keeps the browser asking for
1330/// a scheme, as `type="url"` did, while admitting `at://`. Matched in any case,
1331/// because the handler canonicalises the scheme. Browsers compile `pattern`
1332/// with the `v` flag and anchor it at both ends. Unlike `type="url"`, a text
1333/// input does not strip surrounding whitespace before checking, so the pattern
1334/// allows it: a URL pasted with a leading space is common, and the handler
1335/// trims it.
1336pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1337
1338// ---------------------------------------------------------------------------
1339// Link cards (Open Graph / Twitter / Bluesky)
1340// ---------------------------------------------------------------------------
1341
1342/// The site's own title: the landing page's, and the one every private view
1343/// shows instead of its own.
1344const SITE_TITLE: &str = "FeatherReader — read, quietly";
1345
1346/// The site's one-paragraph description: the landing page's, and the one every
1347/// private view shows instead of its own.
1348const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1349standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1350password, no tracking.";
1351
1352/// Where the share image is served, relative to the public origin. The file is
1353/// `static/social-card.png`, rendered from `static/social-card.svg` by
1354/// `scripts/social-card.sh`; `base.html` advertises its dimensions, and a test
1355/// checks the PNG's own header agrees.
1356const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1357
1358/// What a link to a page unfurls as when it is posted — on Bluesky, in a chat,
1359/// anywhere that reads Open Graph tags. `base.html` renders it into `<head>`.
1360///
1361/// Measured before this existed: Bluesky's card service
1362/// (`cardyb.bsky.app/v1/extract?url=https://feather-reader.com/`) returned
1363/// `{"title":"FeatherReader — read, quietly","description":"","image":""}`,
1364/// because `<title>` was the only tag it could find. Card fetchers read the
1365/// initial HTML server-side, run no JS, and resolve nothing relative, so every
1366/// URL here is absolute on [`Config::public_url`] — `https://feather-reader.com`
1367/// in production, whatever `FEATHERREADER_PUBLIC_URL` says elsewhere.
1368#[derive(Debug, Clone)]
1369pub(crate) struct Card {
1370    /// `og:title`. On a public page, the same text as its `<title>`.
1371    pub title: String,
1372    /// `og:description` and `<meta name="description">`: one or two plain
1373    /// sentences about THIS page, not the site.
1374    pub description: String,
1375    /// `og:url` and `<link rel="canonical">`: absolute, on the public origin.
1376    pub url: String,
1377    /// `og:image`: absolute, on the public origin.
1378    pub image: String,
1379    /// Set on a page that renders a session's private view. The card is then
1380    /// the site's generic one — nothing from the view reaches `<head>` — and
1381    /// the page is `noindex`.
1382    pub private: bool,
1383}
1384
1385impl Card {
1386    /// The card of the public page at `path` (leading slash) on this instance.
1387    fn public(
1388        config: &Config,
1389        path: &str,
1390        title: impl Into<String>,
1391        description: impl Into<String>,
1392    ) -> Self {
1393        let origin = config.public_url.trim_end_matches('/');
1394        Card {
1395            title: title.into(),
1396            description: description.into(),
1397            url: format!("{origin}{path}"),
1398            image: format!("{origin}{SHARE_IMAGE_PATH}"),
1399            private: false,
1400        }
1401    }
1402
1403    /// The landing page's card: the site's own title and description.
1404    fn site(config: &Config) -> Self {
1405        Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1406    }
1407
1408    /// The card of a page that renders a session's private view: the site's
1409    /// generic card pointing at the front door, plus `noindex`. The view's
1410    /// heading, feed names and handle stay out of `<head>`.
1411    fn private(config: &Config) -> Self {
1412        Card {
1413            private: true,
1414            ..Card::site(config)
1415        }
1416    }
1417}
1418
1419/// The reader index (`GET /`).
1420#[derive(Template)]
1421#[template(path = "index.html")]
1422struct IndexTemplate {
1423    /// The link card. A private view: the site's generic card, `noindex`.
1424    card: Card,
1425    version: &'static str,
1426    repo_url: &'static str,
1427    kofi_url: &'static str,
1428    flash: String,
1429    /// Shown as `role="alert"` when the subscription list is the cached one
1430    /// because the PDS listing failed; empty otherwise.
1431    alert: String,
1432    /// The shared rail (drawer + desktop sidebar) navigation model.
1433    nav: Nav,
1434    /// The article list for the selected scope + view.
1435    entries: Vec<EntryRow>,
1436    /// The list heading (the selected view/feed/folder name).
1437    heading: String,
1438    /// Whether a feed scope is active (enables per-feed mark-all-read).
1439    feed_scope: Option<String>,
1440    /// Total CACHED entries in this scope + view across ALL pages. The count used
1441    /// to be `entries.len()`, which was the same number only because the list was
1442    /// unpaged — the thing this change exists to stop.
1443    ///
1444    /// The pager is derived from this, so it must not include the uncached PDS
1445    /// rows below: they are appended to the last page rather than paged, and
1446    /// counting them here advertised a page the clamp could never reach.
1447    total: i64,
1448    /// How many of `total` are PDS saved records the cache cannot show.
1449    ///
1450    /// A subset of `total`, not an addition to it — the heading says "N entries
1451    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1452    /// which double counted once `total` started including them, against an M
1453    /// that had become page-local in the same commit while the template stayed
1454    /// put.
1455    uncached_total: i64,
1456    /// 1-based current page.
1457    page: i64,
1458    /// Total pages, at least 1 (an empty list is page 1 of 1).
1459    page_count: i64,
1460    /// Link to the previous (newer) page, or `None` on the first.
1461    prev_href: Option<String>,
1462    /// Link to the next (older) page, or `None` on the last.
1463    next_href: Option<String>,
1464}
1465
1466/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1467#[derive(Template)]
1468#[template(path = "manage.html")]
1469struct ManageTemplate {
1470    /// The link card. A private view: the site's generic card, `noindex`.
1471    card: Card,
1472    version: &'static str,
1473    repo_url: &'static str,
1474    kofi_url: &'static str,
1475    flash: String,
1476    /// See [`IndexTemplate::alert`].
1477    alert: String,
1478    nav: Nav,
1479    /// All folders as move-targets for the subscribe folder select.
1480    folder_options: Vec<FolderOption>,
1481    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1482    folders: Vec<FolderView>,
1483    loose_feeds: Vec<FeedView>,
1484    /// `Config::standard_site`. With it on, the subscribe form says a
1485    /// `site.standard.publication` URI is accepted and its input drops
1486    /// `type="url"`, whose browser validation rejects the DID form. With it off
1487    /// `add_subscription` refuses every `at://` paste, so the form must not
1488    /// advertise one.
1489    standard_site: bool,
1490}
1491
1492/// The optional one-line adoption fact at the bottom of `/about`
1493/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1494/// probe has succeeded yet, or the read failed — the line then simply does not
1495/// render.
1496struct AdoptionLine {
1497    /// Repos a relay has indexed as holding the subscription collection.
1498    repos: i64,
1499    /// The probe hit its page cap, so the copy must say "at least".
1500    truncated: bool,
1501    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1502    observed_on: String,
1503}
1504
1505/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1506/// the optional adoption line.
1507#[derive(Template)]
1508#[template(path = "about.html")]
1509struct AboutTemplate {
1510    /// The link card: this page's own title and description.
1511    card: Card,
1512    version: &'static str,
1513    repo_url: &'static str,
1514    kofi_url: &'static str,
1515    adoption: Option<AdoptionLine>,
1516    /// `Config::standard_site`: whether the publications section may tell the
1517    /// reader how to subscribe to one here. See [`ManageTemplate::standard_site`].
1518    standard_site: bool,
1519}
1520
1521/// The public `/standard-site` feature page. Carries the same footer fields
1522/// as the other public pages, the standard.site flag, and the release list for
1523/// the "latest releases" call-out.
1524#[derive(Template)]
1525#[template(path = "standard_site.html")]
1526struct StandardSiteTemplate {
1527    /// The link card: this page's own title and description.
1528    card: Card,
1529    version: &'static str,
1530    repo_url: &'static str,
1531    kofi_url: &'static str,
1532    /// `Config::standard_site`: whether the page may tell a visitor how to
1533    /// subscribe to a publication here. See [`ManageTemplate::standard_site`].
1534    standard_site: bool,
1535    /// [`RELEASES`], newest first, for `templates/releases.html`.
1536    releases: &'static [Release],
1537}
1538
1539/// One tagged release, as the "latest releases" call-out
1540/// (`templates/releases.html`) shows it on `/standard-site` and the landing
1541/// page. The links are derived from `version` and `date`, so a release is
1542/// described in exactly one place: an entry in [`RELEASES`].
1543pub(crate) struct Release {
1544    /// The crate version, without the `v` (`"0.4.1"`). The tag is `v{version}`.
1545    pub(crate) version: &'static str,
1546    /// The release date, `YYYY-MM-DD`, as the CHANGELOG heading has it.
1547    pub(crate) date: &'static str,
1548    /// One or two plain sentences for a visitor. No markup: the template escapes it.
1549    pub(crate) summary: &'static str,
1550}
1551
1552impl Release {
1553    /// The GitHub release page: `{REPO_URL}/releases/tag/v{version}`.
1554    pub(crate) fn url(&self) -> String {
1555        format!("{REPO_URL}/releases/tag/v{}", self.version)
1556    }
1557
1558    /// The release's section of `CHANGELOG.md` on `main`. GitHub derives the
1559    /// anchor for a heading `## 0.4.1 — 2026-10-04` as `041--2026-10-04`: the
1560    /// dots dropped, the em dash dropped, each space a hyphen.
1561    pub(crate) fn changelog_url(&self) -> String {
1562        format!(
1563            "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1564            self.version.replace('.', ""),
1565            self.date
1566        )
1567    }
1568}
1569
1570/// **The one place a release is described for the website.** Newest first.
1571/// To announce the next release, add one entry at the top; the call-out on
1572/// `/standard-site` and the landing page, and both links, follow from it.
1573/// `releases_are_newest_first_and_link_the_tag_and_changelog` pins the shape.
1574pub(crate) const RELEASES: &[Release] = &[
1575    Release {
1576        version: "0.4.6",
1577        date: "2026-10-06",
1578        summary: "Renaming a subscription or a folder no longer overwrites \
1579                  what another app changed at the same moment, and a folder \
1580                  rename keeps everything but the name.",
1581    },
1582    Release {
1583        version: "0.4.5",
1584        date: "2026-10-06",
1585        summary: "An operator teardown now signs every user out at their own \
1586                  server before deleting anything, and the session-writing \
1587                  code is hardened against the races that work exposed.",
1588    },
1589    Release {
1590        version: "0.4.4",
1591        date: "2026-10-05",
1592        summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1593                  links unchanged and real RSS bylines, and the address guard \
1594                  refuses the reserved ranges it missed.",
1595    },
1596    Release {
1597        version: "0.4.3",
1598        date: "2026-10-05",
1599        summary: "Two write-path fixes for any PDS: large OPML imports and \
1600                  read-state syncs are sent in calls the PDS accepts, and a \
1601                  read-state sync that disagreed with the PDS recovers instead \
1602                  of failing every round.",
1603    },
1604    Release {
1605        version: "0.4.2",
1606        date: "2026-10-04",
1607        summary: "A public standard.site feature page with this list of recent \
1608                  releases, and link cards: a posted feather-reader.com link \
1609                  now unfurls with a description and an image.",
1610    },
1611    Release {
1612        version: "0.4.1",
1613        date: "2026-10-04",
1614        summary: "The public pages explain standard.site publications, and the \
1615                  subscribe form can submit the DID form of a publication URI, \
1616                  which browsers refused in 0.4.0.",
1617    },
1618    Release {
1619        version: "0.4.0",
1620        date: "2026-10-03",
1621        summary: "standard.site support: publications are read from their \
1622                  authors' atproto repos as subscriptions, beside RSS, on their \
1623                  own polling loop. Every stored field from a feed or a \
1624                  publication now has a size bound.",
1625    },
1626];
1627
1628/// The public `/stats` page — is the poller keeping up?
1629///
1630/// Aggregate only, deliberately. It is published to anyone, so it carries no
1631/// user counts and no per-feed detail: a reader does not need to know how many
1632/// people use an instance or which feeds are failing. What it does answer is the
1633/// question that decides whether an instance can take more readers — whether the
1634/// poller is servicing the feeds it already has.
1635///
1636/// The counts below are aggregate machine facts, which is why they fit that
1637/// contract: "12 feeds are in backoff" names no feed and no reader, while
1638/// answering the question the page was previously unable to answer at all.
1639#[derive(Template)]
1640#[template(path = "stats.html")]
1641struct StatsTemplate {
1642    /// The link card: this page's own title and description.
1643    card: Card,
1644    version: &'static str,
1645    repo_url: &'static str,
1646    kofi_url: &'static str,
1647    feeds_tracked: i64,
1648    polled_last_hour: i64,
1649    polled_pct: i64,
1650    overdue: i64,
1651    last_poll: String,
1652    oldest_poll: String,
1653    never_polled: i64,
1654    poll_interval_mins: i64,
1655    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1656    in_backoff: i64,
1657    /// Of those, the ones retried hours apart rather than minutes. **Not
1658    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1659    /// their next successful poll, and most of this instance's did.
1660    badly_broken: i64,
1661    /// Failing feeds by cause, descending — counts only, never which feed.
1662    failure_kinds: Vec<(String, i64)>,
1663    /// What the poller is actually doing: `running`, `paused` (at the size
1664    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1665    /// disabled). Three of those four used to render as "running".
1666    fetching: &'static str,
1667}
1668
1669/// The public `/privacy` page — what the server holds vs. what lives in the
1670/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1671/// footer include needs.
1672#[derive(Template)]
1673#[template(path = "privacy.html")]
1674struct PrivacyTemplate {
1675    /// The link card: this page's own title and description.
1676    card: Card,
1677    version: &'static str,
1678    repo_url: &'static str,
1679    kofi_url: &'static str,
1680}
1681
1682/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1683/// same fields the shared footer include needs.
1684#[derive(Template)]
1685#[template(path = "terms.html")]
1686struct TermsTemplate {
1687    /// The link card: this page's own title and description.
1688    card: Card,
1689    version: &'static str,
1690    repo_url: &'static str,
1691    kofi_url: &'static str,
1692}
1693
1694/// The signed-out landing page (`GET /` with no session) — the public front
1695/// door at feather-reader.com. A static render, no session required.
1696#[derive(Template)]
1697#[template(path = "landing.html")]
1698struct LandingTemplate {
1699    /// The link card: the site's own title and description.
1700    card: Card,
1701    version: &'static str,
1702    repo_url: &'static str,
1703    crates_url: &'static str,
1704    kofi_url: &'static str,
1705    /// `Config::standard_site`: whether the publications point may tell a
1706    /// visitor how to subscribe to one here. See [`ManageTemplate::standard_site`].
1707    standard_site: bool,
1708    /// [`RELEASES`], newest first, for the quiet "latest releases" strip.
1709    releases: &'static [Release],
1710}
1711
1712/// The single-entry reader view (`GET /entries/:id`).
1713#[derive(Template)]
1714#[template(path = "entry.html")]
1715struct EntryTemplate {
1716    /// The link card. A private view: the site's generic card, `noindex`.
1717    card: Card,
1718    version: &'static str,
1719    repo_url: &'static str,
1720    kofi_url: &'static str,
1721    nav: Nav,
1722    id: i64,
1723    title: String,
1724    feed_title: String,
1725    author: Option<String>,
1726    published: String,
1727    /// The entry's own link, for `entry.html`'s two `href`s.
1728    ///
1729    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1730    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1731    /// long way from the `href` and holds only while every future writer to
1732    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1733    /// defence that, on the saved-record row, turned out to be deletable with
1734    /// all 679 tests still green. `None` is the refusal: the template's
1735    /// no-URL branch already renders a disabled open-original button.
1736    url: Option<SafeLink>,
1737    content_html: Option<String>,
1738    read: bool,
1739    starred: bool,
1740    /// The query string to carry the reading context back to the list.
1741    back_qs: String,
1742    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1743    prev_id: Option<i64>,
1744    next_id: Option<i64>,
1745    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1746    oob: bool,
1747}
1748
1749/// The htmx swap fragment for a single entry row (`entry_row.html`).
1750#[derive(Template)]
1751#[template(path = "entry_row.html")]
1752struct EntryRowTemplate {
1753    e: EntryRow,
1754}
1755
1756/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1757/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1758/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1759/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1760#[derive(Template)]
1761#[template(path = "entry_actionbar.html")]
1762struct EntryActionBarTemplate {
1763    id: i64,
1764    read: bool,
1765    starred: bool,
1766    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1767    oob: bool,
1768}
1769
1770/// The login stub (`GET /login`).
1771#[derive(Template)]
1772#[template(path = "login.html")]
1773struct LoginTemplate {
1774    /// The link card: this page's own title and description.
1775    card: Card,
1776    repo_url: &'static str,
1777    error: String,
1778    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1779    /// distinct from `error`. Empty renders nothing.
1780    flash: String,
1781}
1782
1783/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1784#[derive(Template)]
1785#[template(path = "beta_redeem.html")]
1786struct BetaRedeemTemplate {
1787    /// The link card: this page's own title and description.
1788    card: Card,
1789    repo_url: &'static str,
1790    error: String,
1791    /// When true the seat cap is full: hide the form and show the "capacity
1792    /// full — try self-hosting" message instead.
1793    capacity_full: bool,
1794}
1795
1796// ---------------------------------------------------------------------------
1797// Rendering + error helpers
1798// ---------------------------------------------------------------------------
1799
1800/// Render an askama template into an HTML response, mapping a render failure to
1801/// a `500` rather than panicking (no `unwrap` in the request path).
1802fn render<T: Template>(tmpl: &T) -> Response {
1803    match tmpl.render() {
1804        Ok(body) => Html(body).into_response(),
1805        Err(err) => {
1806            warn!(%err, "template render failed");
1807            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1808        }
1809    }
1810}
1811
1812/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1813/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1814/// by default; a handler may override the status (e.g. `413` for an over-cap
1815/// upload) via [`WebError::with_status`].
1816struct WebError {
1817    err: anyhow::Error,
1818    status: StatusCode,
1819}
1820
1821impl<E: Into<anyhow::Error>> From<E> for WebError {
1822    fn from(err: E) -> Self {
1823        WebError {
1824            err: err.into(),
1825            status: StatusCode::INTERNAL_SERVER_ERROR,
1826        }
1827    }
1828}
1829
1830impl WebError {
1831    /// Attach an explicit HTTP status to render instead of the default `500`.
1832    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1833        WebError {
1834            err: err.into(),
1835            status,
1836        }
1837    }
1838}
1839
1840impl IntoResponse for WebError {
1841    fn into_response(self) -> Response {
1842        warn!(error = %self.err, status = %self.status, "request failed");
1843        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1844            "internal error"
1845        } else {
1846            self.status.canonical_reason().unwrap_or("error")
1847        };
1848        (self.status, body).into_response()
1849    }
1850}
1851
1852/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1853/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1854/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1855/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1856fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1857    let status = err.status();
1858    WebError::with_status(err, status)
1859}
1860
1861/// A short, human display of a feed/site title for the sidebar/list, falling
1862/// back to the host of a URL and finally to the raw string.
1863fn display_title(title: Option<&str>, url: &str) -> String {
1864    if let Some(t) = title {
1865        let t = t.trim();
1866        if !t.is_empty() {
1867            return t.to_string();
1868        }
1869    }
1870    url::Url::parse(url)
1871        .ok()
1872        .and_then(|u| u.host_str().map(str::to_string))
1873        .unwrap_or_else(|| url.to_string())
1874}
1875
1876/// A display `@handle` for the identity chip: the stored handle if present,
1877/// else the tail of the DID so the chip is never empty.
1878fn display_handle(handle: Option<&str>, did: &str) -> String {
1879    match handle {
1880        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1881        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1882    }
1883}
1884
1885/// Two-letter, lowercase avatar initials from a handle/DID.
1886fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1887    let source = handle
1888        .map(|h| h.trim().trim_start_matches('@'))
1889        .filter(|h| !h.is_empty())
1890        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1891    let letters: String = source
1892        .chars()
1893        .filter(|c| c.is_alphanumeric())
1894        .take(2)
1895        .collect::<String>()
1896        .to_lowercase();
1897    if letters.is_empty() {
1898        "fr".to_string()
1899    } else {
1900        letters
1901    }
1902}
1903
1904/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1905/// low-noise display. Falls back to the raw string if it doesn't look like one.
1906fn display_date(published: Option<&str>) -> String {
1907    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1908    // multi-byte character, and every caller used to pass a timestamp the feed
1909    // parser had produced. The saved-record path passes `createdAt` straight off
1910    // a PDS record, which the lexicon types as a bare string with no validation
1911    // — written by whatever atproto client the reader used. A `createdAt` of
1912    // "日本語日本語日本" took down the whole starred view, and there is no
1913    // catch-panic layer in the stack, so the page stayed down until the record
1914    // was removed from the very view that would not render.
1915    match published {
1916        Some(p) => p.chars().take(10).collect(),
1917        None => String::new(),
1918    }
1919}
1920
1921/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1922/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1923/// a bare value, and this keeps the scope-preserving links honest.
1924fn qenc(s: &str) -> String {
1925    let mut out = String::with_capacity(s.len() * 3);
1926    for b in s.bytes() {
1927        match b {
1928            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1929                out.push(b as char)
1930            }
1931            _ => out.push_str(&format!("%{b:02X}")),
1932        }
1933    }
1934    out
1935}
1936
1937// ---------------------------------------------------------------------------
1938// Reader: index
1939// ---------------------------------------------------------------------------
1940
1941/// Query for `GET /` — the scope + view selector.
1942#[derive(Debug, Deserialize, Default)]
1943struct IndexQuery {
1944    /// Filter to a single feed by its canonical URL.
1945    #[serde(default)]
1946    feed: Option<String>,
1947    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1948    #[serde(default)]
1949    folder: Option<String>,
1950    /// `unread` (default) | `all` | `starred`.
1951    #[serde(default)]
1952    view: Option<String>,
1953    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1954    #[serde(default)]
1955    page: Option<u32>,
1956    /// Optional flash message (e.g. after an action redirect).
1957    #[serde(default)]
1958    flash: Option<String>,
1959}
1960
1961/// Rows per page in the reader's list views.
1962///
1963/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1964/// so a page is on the order of tens of kilobytes rather than the tens or
1965/// hundreds of megabytes an unbounded list of full entries could reach. The page
1966/// bound is the second half of that fix: without it, a reader with a long
1967/// backlog still decides how much memory a single request allocates.
1968const ENTRIES_PER_PAGE: i64 = 100;
1969
1970/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1971/// pager reads "1 / 1" rather than "1 / 0".
1972fn page_count_for(total: i64) -> i64 {
1973    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1974}
1975
1976/// Ceiling on the reader's prev/next id list.
1977///
1978/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1979/// reader's position within it — so it is bounded by count rather than paged. At
1980/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1981/// resolving; the article itself still opens, and the list view still pages.
1982const PREV_NEXT_MAX: i64 = 5_000;
1983
1984/// Ceiling on the cached-starred identity set matched against PDS saved records.
1985///
1986/// Deliberately generous: under-reading this set makes a cached article look
1987/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1988/// than un-starring the entry. Truncating here would change what a click
1989/// destroys, so the cap exists only as a backstop against an absurd starred
1990/// count, not as a routine bound.
1991const STARRED_IDENTITY_MAX: i64 = 20_000;
1992
1993/// Most uncached PDS saved records this handler will hold in memory for one
1994/// request.
1995///
1996/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1997/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1998/// this only caps how many are collected before slicing. An earlier version used
1999/// it to cap what was SHOWN, which left everything past it invisible and —
2000/// because the un-save control lives on the row, and nothing else in the app
2001/// lists these — unremovable.
2002///
2003/// Well above the PDS list ceiling's practical reach for one reader, so a reader
2004/// meeting it has thousands of saved records and gets a logged, ordered prefix
2005/// rather than a failure.
2006const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
2007
2008/// A subscription resolved against the local cache: the PDS record + its
2009/// (possibly-missing) cached feed row.
2010struct ResolvedSub {
2011    rkey: String,
2012    sub: Subscription,
2013    feed: Option<store::Feed>,
2014}
2015
2016/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
2017/// local cache row so unread counts work, and return them resolved. Best-effort
2018/// on the sidecar: a failure falls back to the local cache alone.
2019async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
2020    resolve_subscriptions_noting(state, did).await.0
2021}
2022
2023/// What to tell a reader whose subscription list could not be read from their
2024/// PDS, so the last-known list being shown does not pass for a fresh one.
2025///
2026/// **A malformed record is named as such** (#177): the walk refuses rather than
2027/// drop that subscription, and "unreachable" would send the reader looking at
2028/// their network when the cause is a record some client wrote into their repo.
2029fn subscriptions_alert(err: &anyhow::Error) -> String {
2030    match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2031        Some(m) => format!(
2032            "{} record(s) in your subscription list could not be read, so it was not \
2033             refreshed. Showing your last-known subscriptions; nothing was removed.",
2034            m.count
2035        ),
2036        None => "Your subscription list could not be read from your PDS just now. \
2037                 Showing your last-known subscriptions."
2038            .to_string(),
2039    }
2040}
2041
2042/// [`resolve_subscriptions`], plus the alert to show when the list shown is the
2043/// cached one because the PDS listing failed.
2044async fn resolve_subscriptions_noting(
2045    state: &AppState,
2046    did: &str,
2047) -> (Vec<ResolvedSub>, Option<String>) {
2048    let pool = &state.db;
2049    let subs = match state.repo().list_subscriptions_sorted(did).await {
2050        Ok(s) => s,
2051        Err(err) => {
2052            let alert = subscriptions_alert(&err);
2053            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2054            // Fail CLOSED: the PDS is the source of truth for what this DID
2055            // follows. When it is unreachable we must NOT widen the caller's
2056            // authorization surface. Serve from the DID's OWN last-known
2057            // `sub_ref` projection (its own feeds, possibly stale) and leave
2058            // `sub_ref` untouched — never synthesize from every cached feed,
2059            // which would grant cross-tenant read+mutate during any outage.
2060            // A DB failure here is NOT the same as "this DID follows nothing",
2061            // but `unwrap_or_default` rendered it as exactly that: an empty
2062            // sidebar and an empty reader, which arrives as "all my feeds
2063            // vanished". It still degrades to empty — there is nothing better to
2064            // show — but it says so, so the support ticket and the log line can
2065            // be matched up.
2066            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2067                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2068                                   projection could not be read; rendering an EMPTY \
2069                                   feed list, which is not the same as having none");
2070                Vec::new()
2071            });
2072            let cached = feeds
2073                .into_iter()
2074                .map(|f| ResolvedSub {
2075                    rkey: String::new(),
2076                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
2077                    feed: Some(f),
2078                })
2079                .collect();
2080            return (cached, Some(alert));
2081        }
2082    };
2083
2084    // **Deliberately NOT truncated to `max_subs_per_did`.**
2085    //
2086    // The PDS list is unbounded in practice — any client can write subscription
2087    // records, and only the 20,000-record list ceiling stops it — and the first
2088    // attempt at bounding it truncated the list right here. That was the wrong
2089    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
2090    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
2091    // removed the reader's ability to read OR mutate those feeds. A query-shape
2092    // problem would have become an access problem.
2093    //
2094    // The shape problem was the scope filter emitting one SQL placeholder per
2095    // feed; `store::list_query_sql` now passes the whole set as a single
2096    // `json_each` bind, so there is no size to defend against here and nothing
2097    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
2098    // feeds — rather than becoming a silent read-time filter.
2099    let mut out = Vec::with_capacity(subs.len());
2100    for (rkey, sub) in subs {
2101        let feed = match store::get_feed_by_url(pool, &sub.url).await {
2102            Ok(Some(f)) => Some(f),
2103            Ok(None) => {
2104                // `sub.url` came out of an atproto record. The lexicon is open —
2105                // ANY client can write a subscription into a user's repo — so
2106                // this is untrusted input on the hot path of `GET /`, and it was
2107                // being stored with none of the three checks the add and import
2108                // paths apply. Two of those are capacity ceilings; this one is
2109                // the invariant in `FeedPrivacy`'s doc comment, which promises a
2110                // private feed URL is "never stored". Writing a
2111                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
2112                // that promise even though `net::guarded_get` still refuses to
2113                // fetch it.
2114                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2115                    || feed::classify_feed_privacy(&sub.url).is_private()
2116                {
2117                    warn!(
2118                        %did,
2119                        "skipping cache row for a subscription URL that is private or not http(s)"
2120                    );
2121                    out.push(ResolvedSub {
2122                        rkey,
2123                        sub,
2124                        feed: None,
2125                    });
2126                    continue;
2127                }
2128                // Upsert a cache row so the sidebar reflects the real follow-list.
2129                //
2130                // A silent failure here is a support ticket with no evidence: no
2131                // `feeds` row means the poller never selects this subscription,
2132                // so the reader sees "I added a feed and it never updates" while
2133                // the PDS record looks perfect. Logged with the URL so the
2134                // failing subscription is identifiable.
2135                if let Err(err) = store::upsert_feed(
2136                    pool,
2137                    &store::NewFeed {
2138                        url: sub.url.clone(),
2139                        title: sub.title.clone(),
2140                        site_url: sub.site_url.clone(),
2141                        ..Default::default()
2142                    },
2143                )
2144                .await
2145                {
2146                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2147                                                       it will not be polled");
2148                }
2149                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2150            }
2151            Err(err) => {
2152                warn!(%err, url = %sub.url, "get_feed_by_url failed");
2153                None
2154            }
2155        };
2156        out.push(ResolvedSub { rkey, sub, feed });
2157    }
2158    // Mirror the caller's resolved subscription set into `sub_ref`, so every
2159    // scoped entry/feed read + read/star mutation authorizes against exactly
2160    // the feeds this DID follows right now. This is THE per-DID isolation hook.
2161    sync_sub_refs(pool, did, &out).await;
2162    (out, None)
2163}
2164
2165/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
2166/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
2167/// fail closed / show fewer rows), never leaks another user's entries.
2168async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2169    let feed_ids: Vec<i64> = subs
2170        .iter()
2171        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2172        .collect();
2173    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2174        warn!(%err, %did, "failed to sync sub_ref projection");
2175    }
2176}
2177
2178/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
2179/// records layer) and the article list for the selected scope + view.
2180async fn index(
2181    State(state): State<AppState>,
2182    headers: HeaderMap,
2183    Query(q): Query<IndexQuery>,
2184) -> Result<Response, WebError> {
2185    let user = match current_session(&state, &headers).await {
2186        Some(u) => u,
2187        // Signed out: serve the public landing page rather than bouncing to
2188        // /login. /login remains the entry point for the actual OAuth sign-in.
2189        None => {
2190            return Ok(render(&LandingTemplate {
2191                card: Card::site(&state.config),
2192                version: VERSION,
2193                repo_url: REPO_URL,
2194                crates_url: CRATES_URL,
2195                kofi_url: KOFI_URL,
2196                standard_site: state.config.standard_site,
2197                releases: RELEASES,
2198            }))
2199        }
2200    };
2201    let did = user.did.clone();
2202    let pool = &state.db;
2203
2204    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2205
2206    // View: unread (default) | all | starred.
2207    let view = match q.view.as_deref() {
2208        Some("all") => "all",
2209        Some("starred") => "starred",
2210        _ => "unread",
2211    }
2212    .to_string();
2213    let list_view = list_view_of(q.view.as_deref());
2214
2215    // Which feed URLs are in scope?
2216    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2217    // …and the feed ids they resolve to. Scope is applied inside the query now,
2218    // so a page is a page of rows the reader will actually see. Filtering after
2219    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
2220    // any scope narrower than the whole subscription list.
2221    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2222
2223    let feed_title_by_id = |id: i64| -> String {
2224        subs.iter()
2225            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2226            .map(|s| {
2227                display_title(
2228                    s.sub
2229                        .title
2230                        .as_deref()
2231                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2232                    &s.sub.url,
2233                )
2234            })
2235            .unwrap_or_default()
2236    };
2237
2238    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
2239    //
2240    // All three views used to materialize every matching entry — `SELECT e.*`,
2241    // no `LIMIT`, article bodies included — and the "all" view additionally ran
2242    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
2243    // of the row fields below read the body. See `store::EntryListRow`.
2244    // **Saved records the cache cannot show.**
2245    //
2246    // The starred view is built from local `entries`, so a saved record whose
2247    // article was never cached here is invisible — the case that matters is
2248    // starring in ANOTHER atproto reader, which is the portability the shared
2249    // lexicon exists for. Those rows are rendered from the PDS record alone.
2250    let mut uncached: Vec<EntryRow> = Vec::new();
2251    if view == "starred" {
2252        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
2253        //
2254        // `source` has already been filtered by feed/folder. Matching against it
2255        // meant an entry that IS cached but sits outside the current filter
2256        // looked uncached — so it rendered as a "not cached" row whose star
2257        // button deletes the PDS RECORD instead of un-starring the entry. A
2258        // scope filter must not change what is destroyed. Paging is the same
2259        // hazard in a new form: matching against the visible PAGE would make
2260        // every cached article outside it look uncached. Hence a dedicated
2261        // identity query over the whole starred set — urls and guids only, no
2262        // bodies — rather than reusing `source`.
2263        //
2264        // One gap remains BY DESIGN, and is handled at the other end. This query
2265        // still carries the `sub_ref` predicate, so a starred, cached entry in a
2266        // feed the reader has UNSUBSCRIBED from is absent here and its record
2267        // renders as uncached. That is the right rendering — the article is no
2268        // longer part of any feed the reader follows, and the PDS record is what
2269        // still holds it — but it means the un-save button is the record-deleting
2270        // one. `unsave_record` therefore clears the local star too, so the two
2271        // stores agree however the row got classified. Dropping the predicate
2272        // here instead would have made the row link to `/entries/{id}`, which is
2273        // `sub_ref`-scoped and would 404.
2274        //
2275        // **Three ways this can be unusable, and all three fail CLOSED.** With an
2276        // incomplete identity set, a cached article looks uncached and renders an
2277        // un-save button that deletes the PDS RECORD. Showing no uncached rows
2278        // loses rows for one render; getting this wrong loses data permanently,
2279        // so every uncertain case suppresses them.
2280        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2281            Ok(store::StarredIdentities::All(rows)) => Some(rows),
2282            // The cap is a memory backstop, and reaching it means the set is an
2283            // arbitrary subset. It used to return that subset with no way to
2284            // tell, so every starred article outside it got the destructive
2285            // button.
2286            Ok(store::StarredIdentities::Truncated) => {
2287                warn!(
2288                    %did,
2289                    cap = STARRED_IDENTITY_MAX,
2290                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
2291                     rather than rendering record-deleting buttons for cached articles"
2292                );
2293                None
2294            }
2295            Err(err) => {
2296                warn!(%err, %did, "cached-starred identity lookup failed; \
2297                                    suppressing uncached saved rows this render");
2298                None
2299            }
2300        };
2301        // The escape hatch asks whether this DID has ANY cached starred entry —
2302        // not whether the current SCOPE does. `total` is narrowed by
2303        // `?feed=`/`?folder=` while the identity set spans every feed, so
2304        // comparing them waved the fail-closed condition through for any narrow
2305        // scope: a record whose `feedUrl` matched the filter while its cached
2306        // entry lived under another feed rendered as uncached.
2307        let identities_ok = identities.is_some();
2308        let identities = identities.unwrap_or_default();
2309        let cached_urls: std::collections::HashSet<&str> = identities
2310            .iter()
2311            .filter_map(|(url, _)| url.as_deref())
2312            .collect();
2313        let cached_guids: std::collections::HashSet<&str> =
2314            identities.iter().map(|(_, guid)| guid.as_str()).collect();
2315
2316        // Collected in full here, sliced per page later. They sort after every
2317        // cached row, so the two lists form one sequence that the pager walks —
2318        // see the slice below. Collected BEFORE the page is chosen because the
2319        // page count depends on how many there are.
2320        // Bounded like everything else on this page. These come from the PDS
2321        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
2322        // `backend=rust`, whose caps are a quarter of the other's) and are
2323        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
2324        // constrain them at all. The
2325        // cap is generous — a reader with more saved-elsewhere records than this
2326        // is not the case being designed for — but a response has to have a size
2327        // an operator can reason about.
2328        let mut uncached_dropped = 0usize;
2329        match state.repo().list_saved_sorted(&did).await {
2330            Ok(saved) if identities_ok => {
2331                for (rkey, item) in saved {
2332                    let known = cached_urls.contains(item.url.as_str())
2333                        || item
2334                            .entry_id
2335                            .as_deref()
2336                            .is_some_and(|g| cached_guids.contains(g));
2337                    if known {
2338                        continue;
2339                    }
2340                    // And the scope filter applies to these rows too. Without
2341                    // it, `?feed=X` still listed saved records from every other
2342                    // feed — the filter silently did nothing for them.
2343                    if let Some(urls) = &scope_urls {
2344                        match item.feed_url.as_deref() {
2345                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2346                            // A saved record with no `feedUrl` cannot be placed
2347                            // in any feed's scope, so it belongs only to the
2348                            // unfiltered view.
2349                            _ => continue,
2350                        }
2351                    }
2352                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2353                    //
2354                    // `item.url` is attacker-controlled — a saved record written
2355                    // by any client — and it lands in an `href`. Askama escapes
2356                    // HTML metacharacters but not SCHEMES, so `javascript:`
2357                    // survives escaping intact. This project already built the
2358                    // helper for exactly that, and `feed.rs` uses it on the
2359                    // equivalent link; this path was simply not routed through it.
2360                    //
2361                    // The real defect was what a failure DID: it `continue`d, so
2362                    // the row vanished entirely — no badge, no count, nothing —
2363                    // and the only trace was a `debug!` below any realistic
2364                    // filter. That makes the record unremovable FROM HERE, because
2365                    // the un-save button lives on the row; the reader has to open
2366                    // a different atproto client to get rid of it. A bad URL is a
2367                    // reason to withhold the LINK, not the row.
2368                    //
2369                    // The check also moved ABOVE the poll nudge. That is ordering
2370                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2371                    // on the URL being rejected here, and is already gated on the
2372                    // reader actually subscribing to that feed — so it was never
2373                    // reachable by an unusable `item.url`. Deciding whether a
2374                    // record is renderable before doing anything outbound on its
2375                    // behalf is simply the order that stays correct if either of
2376                    // those two facts later stops being true.
2377                    let link = SafeLink::external(&item.url);
2378                    if link.is_empty() {
2379                        warn!(
2380                            %did, %rkey,
2381                            "a saved record has an unusable URL; rendering it without a link \
2382                             so it can still be removed"
2383                        );
2384                    }
2385
2386                    // Opportunistic re-fetch: if the reader still subscribes to
2387                    // the feed, make it due now. If the article is still inside
2388                    // the feed's window the poller caches it normally and this
2389                    // row becomes a real entry on its own — no synthetic rows in
2390                    // the shared cache, which every subscriber would otherwise
2391                    // see as a content-less entry.
2392                    // **Bound the WORK, not just the response.** This check sat
2393                    // after the nudge and the `subs` scan below, so every render
2394                    // still walked all ≤20,000 PDS records, ran a subs-length
2395                    // string scan per record, and issued up to that many
2396                    // `mark_feed_due` round-trips on a 5-connection pool — then
2397                    // discarded everything past the cap. A cap that runs after
2398                    // the expensive part is a cap on the output only.
2399                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2400                        uncached_dropped += 1;
2401                        continue;
2402                    }
2403                    if let Some(feed_url) = item.feed_url.as_deref() {
2404                        if subs.iter().any(|s| s.sub.url == feed_url) {
2405                            // Bounded to one nudge per feed per poll interval —
2406                            // see `mark_feed_due`. Unbounded, a reload loop here
2407                            // becomes outbound amplification.
2408                            let stale_before = (chrono::Utc::now()
2409                                - chrono::Duration::from_std(state.config.poll_interval)
2410                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2411                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2412                            if let Err(err) =
2413                                store::mark_feed_due(pool, feed_url, &stale_before).await
2414                            {
2415                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2416                            }
2417                        }
2418                    }
2419                    uncached.push(EntryRow {
2420                        id: 0,
2421                        title: item
2422                            .title
2423                            .clone()
2424                            .filter(|t| !t.trim().is_empty())
2425                            // Falling back to the URL is fine for a link we are
2426                            // willing to render, and wrong for one we are not:
2427                            // it would put the exact string `safe_link` just
2428                            // rejected into the page as the record's name. The
2429                            // rkey is what the un-save button acts on, so it is
2430                            // the honest identifier for a row that has nothing
2431                            // else trustworthy to show.
2432                            .unwrap_or_else(|| {
2433                                if link.is_empty() {
2434                                    format!("Saved item {rkey}")
2435                                } else {
2436                                    item.url.clone()
2437                                }
2438                            }),
2439                        feed_title: item.feed_url.clone().unwrap_or_default(),
2440                        published: display_date(Some(&item.created_at)),
2441                        read: false,
2442                        starred: true,
2443                        // Empty = "render this row without an anchor". The
2444                        // template branches on it, so the rejected URL never
2445                        // reaches an `href` even as an escaped string.
2446                        link,
2447                        cached: false,
2448                        rkey,
2449                    });
2450                }
2451            }
2452            // Identity lookup was unusable — see the fail-closed note above.
2453            Ok(_) => {}
2454            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2455        }
2456        if uncached_dropped > 0 {
2457            warn!(
2458                %did,
2459                dropped = uncached_dropped,
2460                cap = MAX_UNCACHED_SAVED_ROWS,
2461                "more saved records than this instance will hold in one response; the \
2462                 rest are not reachable from here"
2463            );
2464        }
2465    }
2466
2467    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2468    // PDS records follow them, and the pager walks the concatenation.
2469    //
2470    // The first version appended the uncached rows to the last page only and
2471    // kept them out of `total`, which left everything past a cap invisible AND
2472    // unremovable — the un-save button lives on the row, and there is no other
2473    // surface in the app that lists these. That is the same "unremovable FROM
2474    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2475    // forty lines later by a bound meant to protect memory.
2476    //
2477    // Paging the concatenation makes every record reachable and needs no cap on
2478    // what is RENDERED — one page is one page either way. The version before
2479    // that inflated `total` while clamping on the cached count, which advertised
2480    // a page the clamp could never reach; both numbers come from the same total
2481    // now, which is what makes that impossible rather than merely fixed.
2482    let total_cached =
2483        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2484    let uncached_len = uncached.len();
2485    let total = total_cached + uncached_len as i64;
2486    // Clamped to the range that exists. Past the end the list is empty, and the
2487    // empty state renders instead of the pager — which would strand a reader who
2488    // typed a page number, or who paged to the end and then marked entries read
2489    // out from under their own URL. Showing the last page is the answer to both.
2490    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2491    let offset = (page - 1) * ENTRIES_PER_PAGE;
2492    // Past the cached rows this returns nothing, which is exactly right: the
2493    // page is then made up entirely of uncached ones.
2494    let source = store::list_entries(
2495        pool,
2496        &did,
2497        list_view,
2498        scope_ids.as_deref(),
2499        ENTRIES_PER_PAGE,
2500        offset,
2501    )
2502    .await?;
2503    // **Both halves of the page are computed from the COUNT alone.**
2504    //
2505    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2506    // queries, so they can disagree about how many cached rows exist. Any part of
2507    // the page composition that reads `source.len()` inherits that disagreement.
2508    //
2509    // `cached_allotment` is this page's cached share according to the snapshot,
2510    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2511    // pages tile the uncached list exactly, whichever way the count drifted.
2512    // `source` is then truncated to it only to avoid rendering rows the next page
2513    // will also claim.
2514    //
2515    // The previous version took `skip` from the count but `take` from
2516    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2517    // an un-star or a retention delete landing between the two queries — made
2518    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2519    // putting twenty rows, each carrying the record-DELETING un-save button, on
2520    // two pages at once. The comment claimed that shape was impossible; it was
2521    // merely rarer.
2522    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2523    let cached_here = cached_allotment.min(source.len());
2524    // Only compose when there is something to compose WITH. `uncached` is empty
2525    // on every view but `starred`, and truncating there just drops trailing rows
2526    // that no page then shows — the poller inserting between the COUNT and the
2527    // SELECT was enough to trigger it.
2528    let source = if uncached_len == 0 {
2529        &source[..]
2530    } else {
2531        &source[..cached_here]
2532    };
2533    let uncached_page: Vec<EntryRow> = {
2534        let skip = (offset - total_cached).max(0) as usize;
2535        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2536        uncached.into_iter().skip(skip).take(take).collect()
2537    };
2538    // This page's slice, used only to append below. The heading needs the
2539    // WHOLE-list figure, which is the set's size before slicing.
2540    let uncached_total = uncached_len as i64;
2541
2542    // The scope/view suffix carried onto every entry link (built once).
2543    let entry_scope_qs = {
2544        let mut parts = Vec::new();
2545        if let Some(f) = q.feed.as_deref() {
2546            parts.push(format!("feed={}", qenc(f)));
2547        }
2548        if let Some(f) = q.folder.as_deref() {
2549            parts.push(format!("folder={}", qenc(f)));
2550        }
2551        if view != "unread" {
2552            parts.push(format!("view={}", qenc(&view)));
2553        }
2554        parts.join("&")
2555    };
2556    let entries: Vec<EntryRow> = source
2557        .iter()
2558        .map(|e| EntryRow {
2559            id: e.id,
2560            title: e
2561                .title
2562                .clone()
2563                .filter(|t| !t.trim().is_empty())
2564                .unwrap_or_else(|| "(untitled)".to_string()),
2565            feed_title: feed_title_by_id(e.feed_id),
2566            published: display_date(e.published.as_deref()),
2567            // Both bits ride along on the row's own `entry_state` join now. They
2568            // used to be membership tests against the full unread and starred
2569            // sets, which is why those two lists were fetched in their entirety
2570            // on every render even when the page showed a hundred rows.
2571            read: e.read,
2572            starred: e.starred,
2573            link: SafeLink::entry(e.id, &entry_scope_qs),
2574            cached: true,
2575            rkey: String::new(),
2576        })
2577        .collect();
2578
2579    // The uncached slice for this page follows the cached rows.
2580    let mut entries = entries;
2581    entries.extend(uncached_page);
2582    let entries = entries;
2583
2584    let selected_feed = q.feed.as_deref();
2585    let selected_folder = q.folder.as_deref();
2586
2587    // Build the shared sidebar (folders + loose feeds, with unread counts).
2588    let (folder_views, loose_feeds, _folder_options) =
2589        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2590
2591    // Heading + scope query-string suffix.
2592    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2593        let name = subs
2594            .iter()
2595            .find(|s| s.sub.url == feed_url)
2596            .map(|s| {
2597                display_title(
2598                    s.sub
2599                        .title
2600                        .as_deref()
2601                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2602                    &s.sub.url,
2603                )
2604            })
2605            .unwrap_or_else(|| display_title(None, feed_url));
2606        (name, format!("feed={}", qenc(feed_url)))
2607    } else if let Some(folder_uri) = selected_folder {
2608        let name = folder_views
2609            .iter()
2610            .find(|f| f.uri == folder_uri)
2611            .map(|f| f.name.clone())
2612            .unwrap_or_else(|| "Folder".to_string());
2613        (name, format!("folder={}", qenc(folder_uri)))
2614    } else {
2615        let h = match view.as_str() {
2616            "all" => "All",
2617            "starred" => "Starred",
2618            _ => "Unread",
2619        };
2620        (h.to_string(), String::new())
2621    };
2622
2623    let feed_scope = selected_feed.map(str::to_string);
2624    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2625
2626    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2627    // page number is the only thing appended — which keeps a paged link
2628    // identical to an unpaged one in every other respect.
2629    let page_href = |n: i64| -> String {
2630        let mut parts = Vec::new();
2631        if !entry_scope_qs.is_empty() {
2632            parts.push(entry_scope_qs.clone());
2633        }
2634        if n > 1 {
2635            parts.push(format!("page={n}"));
2636        }
2637        if parts.is_empty() {
2638            "/".to_string()
2639        } else {
2640            format!("/?{}", parts.join("&"))
2641        }
2642    };
2643    let prev_href = (page > 1).then(|| page_href(page - 1));
2644    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2645
2646    let tmpl = IndexTemplate {
2647        card: Card::private(&state.config),
2648        version: VERSION,
2649        repo_url: REPO_URL,
2650        kofi_url: KOFI_URL,
2651        flash: q.flash.unwrap_or_default(),
2652        alert: alert.unwrap_or_default(),
2653        nav,
2654        entries,
2655        heading,
2656        feed_scope,
2657        total,
2658        // Whole-list figure, so it sits beside `total` without double counting.
2659        // The per-page slice is composed above and is not a heading number.
2660        uncached_total,
2661        page,
2662        page_count: page_count_for(total),
2663        prev_href,
2664        next_href,
2665    };
2666    Ok(render(&tmpl))
2667}
2668
2669/// Query for `GET /manage` — carries an optional flash after an action redirect.
2670#[derive(Debug, Deserialize, Default)]
2671struct ManageQuery {
2672    #[serde(default)]
2673    flash: Option<String>,
2674}
2675
2676/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2677/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2678/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2679/// mutation logic of its own.
2680async fn manage(
2681    State(state): State<AppState>,
2682    headers: HeaderMap,
2683    Query(q): Query<ManageQuery>,
2684) -> Result<Response, WebError> {
2685    let user = match current_session(&state, &headers).await {
2686        Some(u) => u,
2687        None => return Ok(Redirect::to("/login").into_response()),
2688    };
2689    let did = user.did.clone();
2690
2691    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2692    let (folder_views, loose_feeds, folder_options) =
2693        build_sidebar(&state, &did, &subs, None, None).await;
2694
2695    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2696    let nav = build_nav(
2697        &user,
2698        "unread",
2699        String::new(),
2700        folder_views.iter().map(clone_folder_view).collect(),
2701        loose_feeds.iter().map(clone_feed_view).collect(),
2702        true,
2703    );
2704
2705    let tmpl = ManageTemplate {
2706        card: Card::private(&state.config),
2707        version: VERSION,
2708        repo_url: REPO_URL,
2709        kofi_url: KOFI_URL,
2710        flash: q.flash.unwrap_or_default(),
2711        alert: alert.unwrap_or_default(),
2712        nav,
2713        folder_options,
2714        folders: folder_views,
2715        loose_feeds,
2716        standard_site: state.config.standard_site,
2717    };
2718    Ok(render(&tmpl))
2719}
2720
2721/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2722/// (`Nav`) and the page body without an extra DB round-trip.
2723fn clone_feed_view(f: &FeedView) -> FeedView {
2724    FeedView {
2725        rkey: f.rkey.clone(),
2726        url: f.url.clone(),
2727        title: f.title.clone(),
2728        unread: f.unread,
2729        selected: f.selected,
2730        folder: f.folder.clone(),
2731    }
2732}
2733
2734fn clone_folder_view(f: &FolderView) -> FolderView {
2735    FolderView {
2736        rkey: f.rkey.clone(),
2737        uri: f.uri.clone(),
2738        name: f.name.clone(),
2739        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2740        selected: f.selected,
2741    }
2742}
2743
2744/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2745/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2746/// unscoped "everything" view. A folder scope takes the feed scope when both are
2747/// somehow present (feed wins, matching the query precedence elsewhere).
2748fn scope_urls_for(
2749    subs: &[ResolvedSub],
2750    feed: Option<&str>,
2751    folder: Option<&str>,
2752) -> Option<Vec<String>> {
2753    if let Some(feed_url) = feed {
2754        Some(vec![feed_url.to_string()])
2755    } else {
2756        folder.map(|folder_uri| {
2757            subs.iter()
2758                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2759                .map(|s| s.sub.url.clone())
2760                .collect()
2761        })
2762    }
2763}
2764
2765/// The `at://` URI for a folder record given the owner DID + rkey.
2766fn folder_uri(did: &str, rkey: &str) -> String {
2767    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2768}
2769
2770/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2771/// DID — the shared source for both the reader index and the rail on every
2772/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2773async fn build_sidebar(
2774    state: &AppState,
2775    did: &str,
2776    subs: &[ResolvedSub],
2777    selected_feed: Option<&str>,
2778    selected_folder: Option<&str>,
2779) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2780    let pool = &state.db;
2781    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2782    // all — purely to `.filter().count()` them in Rust, on every page that
2783    // renders chrome, which made the sidebar the most frequently executed
2784    // instance of the unbounded-projection problem.
2785    let unread_counts = store::unread_counts_by_feed(pool, did)
2786        .await
2787        .unwrap_or_else(|err| {
2788            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2789            Default::default()
2790        });
2791    let folders = state
2792        .repo()
2793        .list_folders_sorted(did)
2794        .await
2795        .unwrap_or_default();
2796
2797    let unread_count = |feed_id: Option<i64>| -> i64 {
2798        feed_id
2799            .and_then(|id| unread_counts.get(&id).copied())
2800            .unwrap_or(0)
2801    };
2802    let mk_feed_view = |s: &ResolvedSub| FeedView {
2803        rkey: s.rkey.clone(),
2804        url: s.sub.url.clone(),
2805        title: display_title(
2806            s.sub
2807                .title
2808                .as_deref()
2809                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2810            &s.sub.url,
2811        ),
2812        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2813        selected: selected_feed == Some(s.sub.url.as_str()),
2814        folder: s.sub.folder.clone(),
2815    };
2816
2817    let mut folder_views = Vec::with_capacity(folders.len());
2818    for (rkey, folder) in &folders {
2819        let uri = folder_uri(did, rkey);
2820        let feeds: Vec<FeedView> = subs
2821            .iter()
2822            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2823            .map(mk_feed_view)
2824            .collect();
2825        folder_views.push(FolderView {
2826            rkey: rkey.clone(),
2827            uri: uri.clone(),
2828            name: folder.name.clone(),
2829            feeds,
2830            selected: selected_folder == Some(uri.as_str()),
2831        });
2832    }
2833
2834    let known_uris: std::collections::HashSet<String> =
2835        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2836    let loose_feeds: Vec<FeedView> = subs
2837        .iter()
2838        .filter(|s| {
2839            s.sub
2840                .folder
2841                .as_deref()
2842                .map(|f| !known_uris.contains(f))
2843                .unwrap_or(true)
2844        })
2845        .map(mk_feed_view)
2846        .collect();
2847
2848    let folder_options: Vec<FolderOption> = folders
2849        .iter()
2850        .map(|(rkey, folder)| FolderOption {
2851            name: folder.name.clone(),
2852            uri: folder_uri(did, rkey),
2853        })
2854        .collect();
2855
2856    (folder_views, loose_feeds, folder_options)
2857}
2858
2859/// Assemble the shared rail [`Nav`] for a chrome page.
2860fn build_nav(
2861    user: &CurrentUser,
2862    view: &str,
2863    scope_qs: String,
2864    folders: Vec<FolderView>,
2865    loose_feeds: Vec<FeedView>,
2866    manage_active: bool,
2867) -> Nav {
2868    Nav {
2869        handle: display_handle(user.handle.as_deref(), &user.did),
2870        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2871        view: view.to_string(),
2872        scope_qs,
2873        folders,
2874        loose_feeds,
2875        manage_active,
2876    }
2877}
2878
2879// ---------------------------------------------------------------------------
2880// Reader: single entry
2881// ---------------------------------------------------------------------------
2882
2883/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2884/// prev/next and "back" stay within the list the reader came from.
2885#[derive(Debug, Deserialize, Default)]
2886struct EntryQuery {
2887    #[serde(default)]
2888    feed: Option<String>,
2889    #[serde(default)]
2890    folder: Option<String>,
2891    #[serde(default)]
2892    view: Option<String>,
2893}
2894
2895/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2896/// within the current reading list.
2897async fn entry_view(
2898    State(state): State<AppState>,
2899    headers: HeaderMap,
2900    Path(id): Path<i64>,
2901    Query(q): Query<EntryQuery>,
2902) -> Result<Response, WebError> {
2903    let user = match current_session(&state, &headers).await {
2904        Some(u) => u,
2905        None => return Ok(Redirect::to("/login").into_response()),
2906    };
2907    let did = user.did.clone();
2908    let pool = &state.db;
2909
2910    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2911    // the per-DID entry gate below authorizes against the caller's current PDS
2912    // subscription set (not another user's cached feeds).
2913    let subs = resolve_subscriptions(&state, &did).await;
2914
2915    let entry = match get_entry_by_id(pool, &did, id).await? {
2916        Some(e) => e,
2917        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2918    };
2919
2920    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2921
2922    let read = entry_is_read(pool, &did, id).await?;
2923    let starred = entry_is_starred(pool, &did, id).await?;
2924
2925    // Reconstruct the current list to compute prev/next, so paging in the reader
2926    // matches what the list showed.
2927    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2928
2929    let back_qs = scope_query(&q);
2930
2931    let (folder_views, loose_feeds, _) =
2932        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2933    let nav_view = match q.view.as_deref() {
2934        Some("all") => "all",
2935        Some("starred") => "starred",
2936        _ => "unread",
2937    };
2938    let nav = build_nav(
2939        &user,
2940        nav_view,
2941        back_qs.clone(),
2942        folder_views,
2943        loose_feeds,
2944        false,
2945    );
2946
2947    let tmpl = EntryTemplate {
2948        card: Card::private(&state.config),
2949        version: VERSION,
2950        repo_url: REPO_URL,
2951        kofi_url: KOFI_URL,
2952        nav,
2953        id: entry.id,
2954        title: entry
2955            .title
2956            .clone()
2957            .filter(|t| !t.trim().is_empty())
2958            .unwrap_or_else(|| "(untitled)".to_string()),
2959        feed_title,
2960        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2961        published: display_date(entry.published.as_deref()),
2962        url: entry.url.as_deref().and_then(SafeLink::external_opt),
2963        content_html: entry.content_html.clone(),
2964        read,
2965        starred,
2966        back_qs,
2967        prev_id,
2968        next_id,
2969        oob: false,
2970    };
2971    Ok(render(&tmpl))
2972}
2973
2974/// Compute the prev/next entry ids around `current` within the reader's current
2975/// scope + view, so the reader view can offer keyboard/paging navigation.
2976async fn neighbors_in_scope(
2977    state: &AppState,
2978    did: &str,
2979    q: &EntryQuery,
2980    current: i64,
2981) -> (Option<i64>, Option<i64>) {
2982    let idx_q = IndexQuery {
2983        feed: q.feed.clone(),
2984        folder: q.folder.clone(),
2985        view: q.view.clone(),
2986        // Neighbours span the whole list, not the page the reader arrived from.
2987        page: None,
2988        flash: None,
2989    };
2990    let ids = list_entry_ids(state, did, &idx_q).await;
2991    let pos = ids.iter().position(|&x| x == current);
2992    match pos {
2993        Some(p) => {
2994            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2995            let next = ids.get(p + 1).copied();
2996            (prev, next)
2997        }
2998        None => (None, None),
2999    }
3000}
3001
3002/// The ordered entry ids for a scope + view — the same ordering `index` renders,
3003/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
3004async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
3005    let pool = &state.db;
3006    let subs = resolve_subscriptions(state, did).await;
3007
3008    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
3009
3010    // Ids only, and bounded. This used to fetch whole entries — bodies included
3011    // — for all three views and then throw everything but `id` away; the "all"
3012    // branch additionally ran one unbounded query PER FEED and sorted the union
3013    // in memory. Scope is now a feed-id restriction inside the query, so the
3014    // database does the filtering and the ordering exactly once.
3015    store::list_entry_ids(
3016        pool,
3017        did,
3018        list_view_of(q.view.as_deref()),
3019        scoped_feed_ids(&subs, &scope_urls).as_deref(),
3020        PREV_NEXT_MAX,
3021    )
3022    .await
3023    .unwrap_or_else(|err| {
3024        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3025        Vec::new()
3026    })
3027}
3028
3029/// Map the `?view=` query value onto the store's list view. Anything
3030/// unrecognised is the unread default, matching `index`.
3031fn list_view_of(view: Option<&str>) -> store::ListView {
3032    match view {
3033        Some("all") => store::ListView::All,
3034        Some("starred") => store::ListView::Starred,
3035        _ => store::ListView::Unread,
3036    }
3037}
3038
3039/// Translate a feed/folder scope into the feed ids to restrict a list query to.
3040///
3041/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
3042/// matched no local feed, which must return nothing rather than everything — so
3043/// the empty vec is deliberately preserved, not collapsed back into `None`.
3044fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3045    let urls = scope_urls.as_ref()?;
3046    Some(
3047        subs.iter()
3048            .filter(|s| urls.contains(&s.sub.url))
3049            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3050            .collect(),
3051    )
3052}
3053
3054/// Build a `?…` query string that preserves the reading scope + view for links.
3055fn scope_query(q: &EntryQuery) -> String {
3056    let mut parts = Vec::new();
3057    if let Some(f) = q.feed.as_deref() {
3058        parts.push(format!("feed={}", qenc(f)));
3059    }
3060    if let Some(f) = q.folder.as_deref() {
3061        parts.push(format!("folder={}", qenc(f)));
3062    }
3063    if let Some(v) = q.view.as_deref() {
3064        if v != "unread" {
3065            parts.push(format!("view={}", qenc(v)));
3066        }
3067    }
3068    parts.join("&")
3069}
3070
3071// ---------------------------------------------------------------------------
3072// Mark read / unread
3073// ---------------------------------------------------------------------------
3074
3075/// Form body for `POST /entries/:id/read`.
3076#[derive(Debug, Deserialize)]
3077struct ReadForm {
3078    #[serde(default)]
3079    read: Option<String>,
3080}
3081
3082/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
3083async fn mark_read(
3084    State(state): State<AppState>,
3085    Path(id): Path<i64>,
3086    headers: HeaderMap,
3087    Form(form): Form<ReadForm>,
3088) -> Result<Response, WebError> {
3089    let did = match current_did(&state, &headers).await {
3090        Some(d) => d,
3091        None => return Ok(Redirect::to("/login").into_response()),
3092    };
3093    let pool = &state.db;
3094
3095    let read = matches!(
3096        form.read.as_deref(),
3097        Some("true") | Some("1") | Some("on") | None
3098    );
3099
3100    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3101    // mutation: `mark_read` only writes when `did` subscribes to the entry's
3102    // feed. A non-subscriber gets a 404, never a mutation of someone else's
3103    // (or the shared cache's) state.
3104    resolve_subscriptions(&state, &did).await;
3105    if !store::mark_read(pool, &did, id, read).await? {
3106        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3107    }
3108
3109    if !is_htmx(&headers) {
3110        return Ok(Redirect::to("/").into_response());
3111    }
3112
3113    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
3114    // in the DOM), so its button's hidden value + aria-pressed update in place
3115    // and a second keypress can reverse the toggle. The list view swaps the row.
3116    if is_reader_request(&headers) {
3117        let starred = entry_is_starred(pool, &did, id).await?;
3118        return Ok(render(&EntryActionBarTemplate {
3119            id,
3120            read,
3121            starred,
3122            oob: true,
3123        }));
3124    }
3125
3126    let row = build_entry_row(pool, &did, id, Some(read)).await?;
3127    match row {
3128        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3129        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3130    }
3131}
3132
3133// ---------------------------------------------------------------------------
3134// Star / save
3135// ---------------------------------------------------------------------------
3136
3137/// Form body for `POST /entries/:id/star`.
3138#[derive(Debug, Deserialize)]
3139struct StarForm {
3140    #[serde(default)]
3141    starred: Option<String>,
3142}
3143
3144/// `POST /entries/:id/star` — star/unstar an entry.
3145///
3146/// Sets the local `starred` bit (fast working copy) and writes/removes a
3147/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
3148/// owning). The PDS write is best-effort — the local star still lands.
3149async fn toggle_star(
3150    State(state): State<AppState>,
3151    Path(id): Path<i64>,
3152    headers: HeaderMap,
3153    Form(form): Form<StarForm>,
3154) -> Result<Response, WebError> {
3155    let did = match current_did(&state, &headers).await {
3156        Some(d) => d,
3157        None => return Ok(Redirect::to("/login").into_response()),
3158    };
3159    let pool = &state.db;
3160
3161    let starred = matches!(
3162        form.starred.as_deref(),
3163        Some("true") | Some("1") | Some("on") | None
3164    );
3165
3166    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3167    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
3168    // feed. A non-subscriber gets a 404, never a mutation.
3169    resolve_subscriptions(&state, &did).await;
3170    if !store::mark_starred(pool, &did, id, starred).await? {
3171        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3172    }
3173
3174    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
3175    // to the caller's subscriptions, so this only ever acts on the caller's feed.
3176    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3177        let entry_url = entry.url.clone().unwrap_or_default();
3178        if !entry_url.is_empty() {
3179            if starred {
3180                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3181                saved.title = entry.title.clone();
3182                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3183                saved.entry_id = Some(entry.guid.clone());
3184                match state.repo().add_saved(&did, &saved).await {
3185                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3186                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3187                }
3188            } else {
3189                // Un-star: find and delete the matching saved record by URL.
3190                match state.repo().list_saved(&did).await {
3191                    Ok(records) => {
3192                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3193                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3194                                warn!(%err, %did, %rkey, "PDS saved delete failed");
3195                            }
3196                        }
3197                    }
3198                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3199                }
3200            }
3201        }
3202    }
3203
3204    if !is_htmx(&headers) {
3205        return Ok(Redirect::to("/").into_response());
3206    }
3207
3208    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
3209    if is_reader_request(&headers) {
3210        let read = entry_is_read(pool, &did, id).await?;
3211        return Ok(render(&EntryActionBarTemplate {
3212            id,
3213            read,
3214            starred,
3215            oob: true,
3216        }));
3217    }
3218
3219    let row = build_entry_row(pool, &did, id, None).await?;
3220    match row {
3221        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3222        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3223    }
3224}
3225
3226/// The feed URL for a cached feed id, if the row exists.
3227async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3228    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3229        .bind(feed_id)
3230        .fetch_optional(pool)
3231        .await
3232        .ok()
3233        .flatten()
3234}
3235
3236// ---------------------------------------------------------------------------
3237// Mark-all-read
3238// ---------------------------------------------------------------------------
3239
3240/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
3241/// absent means mark everything read.
3242#[derive(Debug, Deserialize, Default)]
3243struct ReadAllQuery {
3244    #[serde(default)]
3245    feed: Option<String>,
3246}
3247
3248/// `POST /read-all` — mark every entry read for the current DID, optionally
3249/// scoped to one feed (mark-all-read per feed or globally).
3250async fn mark_all_read(
3251    State(state): State<AppState>,
3252    headers: HeaderMap,
3253    Query(q): Query<ReadAllQuery>,
3254) -> Result<Response, WebError> {
3255    let did = match current_did(&state, &headers).await {
3256        Some(d) => d,
3257        None => return Ok(Redirect::to("/login").into_response()),
3258    };
3259    let pool = &state.db;
3260
3261    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
3262    // only ever touch feeds this DID actually subscribes to.
3263    resolve_subscriptions(&state, &did).await;
3264
3265    if let Some(feed_url) = q.feed.as_deref() {
3266        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3267            store::mark_feed_read(pool, &did, feed.id, true).await?;
3268        }
3269        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3270    }
3271
3272    // Global: mark every subscribed feed read. Fan out over the DID's feeds
3273    // (bounded by the per-DID subscription cap) using the batched per-feed path,
3274    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
3275    // state, but O(feeds) statements instead of O(unread entries).
3276    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3277        store::mark_feed_read(pool, &did, feed_id, true).await?;
3278    }
3279    Ok(Redirect::to("/").into_response())
3280}
3281
3282// ---------------------------------------------------------------------------
3283// Subscribe by URL
3284// ---------------------------------------------------------------------------
3285
3286/// Flash for a URL this instance cannot store as a feed — not private, just
3287/// not a kind of feed it supports (an `at://` publication with
3288/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
3289/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
3290/// false promise for a record that may already exist in the user's PDS.
3291const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3292    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3293
3294/// Shown when an OPML export is refused because the subscription list could not
3295/// be read in full.
3296///
3297/// **An empty export is worse than no export.** This path used to
3298/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
3299/// file — a blank backup, handed over at the moment the reader reached for one.
3300const EXPORT_INCOMPLETE_REFUSAL: &str =
3301    "Could not read your subscriptions in full, so nothing was exported. Your \
3302     feeds are unchanged — try again, and if it keeps failing the list may be \
3303     larger than this reader can page through.";
3304
3305/// Refusal message shown when a private/paid feed is submitted. FeatherReader
3306/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
3307/// only for now — a private feed's secret URL is never saved, fetched, or sent
3308/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
3309/// and the boot-smoke can assert on it.
3310const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3311    FeatherReader stores your subscriptions in your public PDS, so it supports public \
3312    feeds for now — private-feed support arrives when atproto's private data \
3313    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3314
3315/// Form body for `POST /subscriptions`.
3316#[derive(Debug, Deserialize)]
3317struct SubscribeForm {
3318    url: String,
3319    /// Optional folder `at://` URI to file the new feed under.
3320    #[serde(default)]
3321    folder: Option<String>,
3322}
3323
3324/// The DID-form URL to store for a pasted `at://` publication, or the flash
3325/// to refuse it with.
3326///
3327/// - The scheme is canonicalised: `At://` is the same publication, and
3328///   storing a second spelling makes a second row for it (#183).
3329/// - It must name a `site.standard.publication`; anything else is not a feed
3330///   this instance can read.
3331/// - A handle is resolved to its DID: a handle is a mutable name, and
3332///   `feeds.url` is keyed on identity, so only the DID form is stored.
3333async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3334    let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3335    let canonical = format!(
3336        "{}{}",
3337        crate::atproto::AT_URI_PREFIX,
3338        &input[crate::atproto::AT_URI_PREFIX.len()..]
3339    );
3340    let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3341    if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3342        return Err(unsupported());
3343    }
3344    let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3345        uri.authority.clone()
3346    } else {
3347        let handle =
3348            // Validated as a handle before it is sent anywhere: an authority
3349            // that is neither a DID nor a handle (`did:plc:TOOSHORT`, an
3350            // uppercase DID, a newline) is unsupported, not a lookup (found in
3351            // review).
3352            crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3353        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3354            .await
3355            .map_err(|err| {
3356                warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3357                format!("Couldn't resolve the handle {} to an account.", uri.authority)
3358            })?
3359    };
3360    let url = format!(
3361        "{}{did}/{}/{}",
3362        crate::atproto::AT_URI_PREFIX,
3363        uri.collection,
3364        uri.rkey
3365    );
3366    if !feed::is_storable_feed_url(&url, true) {
3367        return Err(unsupported());
3368    }
3369    Ok(url)
3370}
3371
3372/// `POST /subscriptions` — subscribe by URL.
3373async fn add_subscription(
3374    State(state): State<AppState>,
3375    headers: HeaderMap,
3376    Form(form): Form<SubscribeForm>,
3377) -> Result<Response, WebError> {
3378    let did = match current_did(&state, &headers).await {
3379        Some(d) => d,
3380        None => return Ok(Redirect::to("/login").into_response()),
3381    };
3382    let pool = &state.db;
3383    let input = form.url.trim().to_string();
3384    if input.is_empty() {
3385        return Ok(Redirect::to("/").into_response());
3386    }
3387
3388    // Per-DID subscription cap: bound one account's storage/poller footprint on
3389    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3390    // can't even trigger an outbound request. `<= 0` disables the cap.
3391    let cap = state.config.max_subs_per_did;
3392    if cap > 0 {
3393        match store::count_subscriptions_for_did(pool, &did).await {
3394            Ok(n) if n >= cap => {
3395                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3396                return Ok(Redirect::to(&format!(
3397                    "/?flash={}",
3398                    qenc(&format!(
3399                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3400                    ))
3401                ))
3402                .into_response());
3403            }
3404            Ok(_) => {}
3405            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3406        }
3407    }
3408
3409    // **An at:// paste is a standard.site publication, read by the poller
3410    // since 0.4.0**: canonicalised and resolved to its DID form here, then it
3411    // joins the ordinary path below. With the flag off it is refused as it
3412    // always was — the flag gates what may be stored.
3413    let is_at_uri = input
3414        .get(..crate::atproto::AT_URI_PREFIX.len())
3415        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3416    let publication_url = if is_at_uri {
3417        if !state.config.standard_site {
3418            info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3419            return Ok(
3420                Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3421                    .into_response(),
3422            );
3423        }
3424        match publication_url_from_paste(&state, &input).await {
3425            Ok(url) => Some(url),
3426            Err(flash) => {
3427                info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3428                return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3429            }
3430        }
3431    } else {
3432        None
3433    };
3434
3435    if let feed::FeedPrivacy::Private(reason) =
3436        feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3437    {
3438        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3439        return Ok(
3440            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3441        );
3442    }
3443
3444    let resolved = match publication_url {
3445        Some(url) => Ok(url),
3446        None => resolve_feed_url(&state.config, &input).await,
3447    };
3448    let feed_url = match resolved {
3449        Ok(u) => u,
3450        Err(err) => {
3451            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3452            return Ok(Redirect::to(&format!(
3453                "/?flash={}",
3454                qenc("Couldn't find a feed at that URL")
3455            ))
3456            .into_response());
3457        }
3458    };
3459
3460    // Defensive: resolution may have discovered a feed URL that itself carries a
3461    // secret (e.g. a public site page linking a tokened feed). Re-check the
3462    // resolved URL and refuse before storing/writing anything.
3463    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3464        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3465        return Ok(
3466            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3467        );
3468    }
3469
3470    // The URL about to be STORED is what must be storable — not the one the
3471    // user typed. Autodiscovery already yields only http(s), but this is the
3472    // path that writes the row and the PDS record, so the check lives here too:
3473    // the same gate the OPML and rename paths apply, on the same terms.
3474    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3475        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3476        return Ok(
3477            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3478                .into_response(),
3479        );
3480    }
3481
3482    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3483    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3484    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3485    let feeds_cap = state.config.max_feeds_global;
3486    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3487        match store::count_feeds(pool).await {
3488            Ok(n) if n >= feeds_cap => {
3489                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3490                return Ok(Redirect::to(&format!(
3491                    "/?flash={}",
3492                    qenc(
3493                        "This instance is at its feed capacity right now. Please try again later."
3494                    )
3495                ))
3496                .into_response());
3497            }
3498            Ok(_) => {}
3499            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3500        }
3501    }
3502
3503    store::upsert_feed(
3504        pool,
3505        &store::NewFeed {
3506            url: feed_url.clone(),
3507            ..Default::default()
3508        },
3509    )
3510    .await?;
3511
3512    if let Ok(client) = feed::build_client() {
3513        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3514            match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3515                Ok(outcome) => {
3516                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3517                    // **This path is not the scheduler, so it must settle the
3518                    // error columns itself.** `poll_feed` writes validators and
3519                    // `last_polled` and nothing else.
3520                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3521                }
3522                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3523            }
3524        }
3525    }
3526
3527    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3528    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3529        sub.title = feed_row.title.clone();
3530        sub.site_url = feed_row.site_url.clone();
3531    }
3532    sub.folder = form
3533        .folder
3534        .map(|f| f.trim().to_string())
3535        .filter(|f| !f.is_empty());
3536
3537    match state.repo().add_subscription(&did, &sub).await {
3538        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3539        Err(err) => {
3540            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3541        }
3542    }
3543
3544    Ok(Redirect::to("/").into_response())
3545}
3546
3547/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3548async fn delete_subscription(
3549    State(state): State<AppState>,
3550    headers: HeaderMap,
3551    Path(rkey): Path<String>,
3552) -> Result<Response, WebError> {
3553    let did = match current_did(&state, &headers).await {
3554        Some(d) => d,
3555        None => return Ok(Redirect::to("/login").into_response()),
3556    };
3557    match state.repo().remove_subscription(&did, &rkey).await {
3558        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3559        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3560    }
3561    Ok(Redirect::to("/").into_response())
3562}
3563
3564/// Form body for `POST /subscriptions/:rkey/rename`.
3565#[derive(Debug, Deserialize)]
3566struct RenameSubForm {
3567    url: String,
3568    #[serde(default)]
3569    title: Option<String>,
3570    #[serde(default)]
3571    site_url: Option<String>,
3572    #[serde(default)]
3573    folder: Option<String>,
3574    /// What the `url` input held when the page was rendered. With the `seen_*`
3575    /// fields the handler tells what the reader CHANGED from what they merely
3576    /// saw: every input is always posted, so its value alone cannot (#149).
3577    /// Absent (a hand-made POST, or a page from an older build), the handler's
3578    /// first read stands in for it.
3579    #[serde(default)]
3580    seen_url: Option<String>,
3581    /// What the title input was pre-filled with — the DISPLAY title, which
3582    /// falls back to the cached feed title or the URL for an untitled record.
3583    #[serde(default)]
3584    seen_title: Option<String>,
3585    /// The folder the select was pre-selected with (`""` for none). Posted
3586    /// only when the select is, so the two are present or absent together —
3587    /// and both absent means the reader never saw a folder to change.
3588    #[serde(default)]
3589    seen_folder: Option<String>,
3590}
3591
3592/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3593/// folder, rewriting the whole subscription record via `putRecord`.
3594async fn rename_subscription(
3595    State(state): State<AppState>,
3596    headers: HeaderMap,
3597    Path(rkey): Path<String>,
3598    Form(form): Form<RenameSubForm>,
3599) -> Result<Response, WebError> {
3600    let did = match current_did(&state, &headers).await {
3601        Some(d) => d,
3602        None => return Ok(Redirect::to("/login").into_response()),
3603    };
3604    let feed_url = form.url.trim().to_string();
3605
3606    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3607    // write a junk row to the cache or a malformed subscription record to the
3608    // PDS (add_subscription refuses an empty input the same way).
3609    if feed_url.is_empty() {
3610        return Ok(Redirect::to("/").into_response());
3611    }
3612
3613    // **A compare-and-swap, retried once (#149).** Each attempt reads the
3614    // record with its CID and writes with `swapRecord` set to it, so another
3615    // atproto client's write between the two is refused by the PDS rather than
3616    // erased by our whole-record put. A refused attempt reads again and
3617    // re-applies the form's fields — and only those — to the FRESH record,
3618    // re-running every gate against it. A second refusal is reported as a
3619    // conflict, never as success: a record that keeps moving is being edited
3620    // somewhere, and the reader is the one to decide which edit wins.
3621    //
3622    // **A retry MERGES; it does not replay the form.** Every input is always
3623    // posted, so replaying the form on the fresh record would put back each
3624    // field the reader never touched — a URL another client repointed, a title
3625    // another client changed. `base` is the record as first read, and each
3626    // attempt applies only what the reader changed relative to it; see
3627    // [`merge_rename`].
3628    let mut base: Option<Subscription> = None;
3629    for attempt in 1..=RENAME_ATTEMPTS {
3630        match rename_subscription_once(&state, &did, &rkey, &form, &mut base).await? {
3631            RenameAttempt::Done(resp) => return Ok(resp),
3632            RenameAttempt::Raced => {
3633                info!(%did, %rkey, attempt, "subscription changed between read and write; re-reading");
3634            }
3635        }
3636    }
3637    warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused rename: the subscription kept changing elsewhere");
3638    Ok(rename_conflict_response())
3639}
3640
3641/// The answer to a rename that conflicts with another client's write: nothing
3642/// was written, and the reader decides which edit wins.
3643fn rename_conflict_response() -> Response {
3644    Redirect::to(&format!(
3645        "/?flash={}",
3646        qenc(
3647            "This subscription was changed elsewhere while you were editing it — \
3648             nothing was renamed or moved. Reload and try again."
3649        )
3650    ))
3651    .into_response()
3652}
3653
3654/// Trim, and read an empty value as absent — how the form's optional fields
3655/// have always been taken.
3656fn form_value(v: Option<&str>) -> Option<String> {
3657    v.map(str::trim)
3658        .filter(|t| !t.is_empty())
3659        .map(str::to_string)
3660}
3661
3662/// The record a rename should write, from [`merge_rename`].
3663#[derive(Debug, PartialEq, Eq)]
3664struct MergedRename {
3665    /// The record to write.
3666    sub: Subscription,
3667    /// Whether THIS write repoints the subscription to a different feed URL.
3668    repoint: bool,
3669    /// Every field the reader changed already holds the reader's value — a
3670    /// double-submitted Save whose first request landed. Nothing to write.
3671    already_saved: bool,
3672}
3673
3674/// Which field the reader and another client both changed, differently.
3675#[derive(Debug, PartialEq, Eq)]
3676struct RenameConflict(&'static str);
3677
3678/// Apply the reader's edits to `fresh`: a three-way merge of the form against
3679/// `base`, the record as the handler FIRST read it (#149). Returns the record
3680/// to write and whether the reader repointed it to a different URL.
3681///
3682/// For each field the form carries (`url`, `title`, `folder`, `site_url`):
3683///
3684/// - **the reader did not change it** — the posted value equals the value the
3685///   input was pre-filled with (`seen_*`, or `base` when the form lacks it) —
3686///   so `fresh`'s value stands, whoever wrote it;
3687/// - **the reader changed it, and `fresh` still has `base`'s value** — the
3688///   reader's value is applied;
3689/// - **the reader changed it, and `fresh` already holds the reader's value** —
3690///   both made the same edit (or a double-submitted Save landed first): no
3691///   conflict, nothing to write for that field;
3692/// - **the reader changed it, and so did someone else, differently** — a
3693///   conflict; nothing is written.
3694///
3695/// A field whose input the page did not render (the folder select, without
3696/// folders to list) is untouched by the reader.
3697///
3698/// On the first attempt `fresh` IS `base`, so the only question is what the
3699/// reader changed. The repoint semantics — dropping `siteUrl` and `fetchHint`
3700/// as properties of the old feed — follow the READER's change, never the
3701/// difference between the form and a record another client moved.
3702///
3703/// **What `seen_*` closes, and what it leaves.** Without it, a field another
3704/// client changed between page load and the first read (so no swap fails)
3705/// read as the reader's change — the stale hidden URL repointed the record
3706/// back. With it, an untouched field is never written. A field BOTH changed
3707/// in that window is still last-writer-wins: the conflict check compares
3708/// against the first read, not the page-load record, because `seen_title` is
3709/// the display title (a fallback for an untitled record), not the record's.
3710fn merge_rename(
3711    form: &RenameSubForm,
3712    base: &Subscription,
3713    fresh: Subscription,
3714) -> Result<MergedRename, RenameConflict> {
3715    let mut sub = fresh;
3716    // Fields the reader changed, and how many of those still need writing:
3717    // a change `fresh` already holds — both sides made the same edit, or this
3718    // is a double-submitted Save whose first request landed — is agreement,
3719    // not a conflict, and there is nothing to write for it.
3720    let mut edited = 0;
3721    let mut to_write = 0;
3722
3723    let posted_url = form.url.trim();
3724    let seen_url = form.seen_url.as_deref().unwrap_or(&base.url).trim();
3725    let mut repoint = false;
3726    if posted_url != seen_url {
3727        edited += 1;
3728        if sub.url.trim() == posted_url {
3729            // Already there. Not a repoint by THIS write, so the fresh
3730            // record's siteUrl and fetchHint — perhaps the new feed's — stay.
3731        } else if sub.url.trim() != base.url.trim() {
3732            return Err(RenameConflict("url"));
3733        } else {
3734            to_write += 1;
3735            repoint = true;
3736        }
3737    }
3738    // Like for like: a record another client wrote may carry padding.
3739    sub.url = if repoint { posted_url } else { sub.url.trim() }.to_string();
3740
3741    let posted_title = form_value(form.title.as_deref());
3742    let seen_title = match form.seen_title.as_deref() {
3743        Some(seen) => form_value(Some(seen)),
3744        None => base.title.clone(),
3745    };
3746    if posted_title != seen_title {
3747        edited += 1;
3748        if sub.title == posted_title {
3749            // Already there.
3750        } else if sub.title != base.title {
3751            return Err(RenameConflict("title"));
3752        } else {
3753            to_write += 1;
3754            sub.title = posted_title;
3755        }
3756    }
3757
3758    // **The folder select is conditional; absent, the reader never saw a
3759    // folder.** The manage row renders it — and `seen_folder` with it — only
3760    // when it has folders to list, which a reader without folders, or a page
3761    // whose folder listing failed, does not. Posted with neither, the folder
3762    // is untouched; reading the absence as "no folder" un-foldered every
3763    // subscription retitled from such a page. (The title input is always
3764    // rendered, so an absent title keeps its old meaning.)
3765    if form.folder.is_some() || form.seen_folder.is_some() {
3766        let posted_folder = form_value(form.folder.as_deref());
3767        let seen_folder = match form.seen_folder.as_deref() {
3768            Some(seen) => form_value(Some(seen)),
3769            None => base.folder.clone(),
3770        };
3771        if posted_folder != seen_folder {
3772            edited += 1;
3773            if sub.folder == posted_folder {
3774                // Already there.
3775            } else if sub.folder != base.folder {
3776                return Err(RenameConflict("folder"));
3777            } else {
3778                to_write += 1;
3779                sub.folder = posted_folder;
3780            }
3781        }
3782    }
3783
3784    // `createdAt` and `private` carry over untouched — neither is a property
3785    // of which feed URL the subscription points at.
3786    //
3787    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3788    // repoint drops them rather than leaving a site link for the old feed
3789    // hanging off the new one. The manage row does not post `site_url`; a
3790    // value that is posted and differs from the base is an edit like any
3791    // other.
3792    match form_value(form.site_url.as_deref()) {
3793        Some(site) if Some(&site) != base.site_url.as_ref() => {
3794            edited += 1;
3795            if sub.site_url.as_ref() == Some(&site) {
3796                // Already there.
3797            } else if sub.site_url != base.site_url {
3798                return Err(RenameConflict("siteUrl"));
3799            } else {
3800                to_write += 1;
3801                sub.site_url = Some(site);
3802            }
3803        }
3804        Some(_) => {}
3805        None if repoint => sub.site_url = None,
3806        None => {}
3807    }
3808    if repoint {
3809        sub.fetch_hint = None;
3810    }
3811    Ok(MergedRename {
3812        sub,
3813        repoint,
3814        // A form with no edits is not "already saved": it writes, as it always
3815        // has — it is the reader asking for exactly this record.
3816        already_saved: edited > 0 && to_write == 0,
3817    })
3818}
3819
3820/// How many times [`rename_subscription`] and [`rename_folder`] read and
3821/// write before giving up on a record that keeps changing: the first try and
3822/// one retry.
3823const RENAME_ATTEMPTS: u32 = 2;
3824
3825/// The outcome of one read-then-write of a rename.
3826enum RenameAttempt {
3827    /// Answered: renamed, refused by a gate, or failed for a reason a re-read
3828    /// cannot fix.
3829    Done(Response),
3830    /// The PDS refused the write with `InvalidSwap`: the record moved after
3831    /// this attempt read it. Nothing was written.
3832    Raced,
3833}
3834
3835/// One attempt at [`rename_subscription`]: read the record and its CID, apply
3836/// the form to it, and write it back on the condition that it is still at
3837/// that CID.
3838///
3839/// `base` is the record as the FIRST attempt read it; this sets it on that
3840/// attempt, and every attempt merges against it — see [`merge_rename`].
3841async fn rename_subscription_once(
3842    state: &AppState,
3843    did: &str,
3844    rkey: &str,
3845    form: &RenameSubForm,
3846    base: &mut Option<Subscription>,
3847) -> Result<RenameAttempt, WebError> {
3848    use RenameAttempt::Done;
3849
3850    // **Read before write — `update_subscription` is a `putRecord`, and a
3851    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3852    //
3853    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3854    // and hand that over, so every field the form does not carry was written
3855    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3856    // `folder` — and nothing else — so a rename silently destroyed four fields:
3857    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3858    //
3859    // `createdAt` is the one that matters most: it is the reader's subscribe
3860    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3861    // repo rather than our cache, and once overwritten it is gone with nothing
3862    // in the UI to say so.
3863    //
3864    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3865    // and filters. That is one extra round trip on an action that is already
3866    // doing a PDS write, and it is bounded. A `get_subscription` was weighed
3867    // for #149 and not added: the sidecar has no `get` action, so it would be
3868    // new surface on the backend being retired, and the listing already
3869    // carries each record's CID.
3870    //
3871    // **A failed read refuses the rename.** Falling back to the old
3872    // rebuild-from-scratch here would reinstate the data loss on exactly the
3873    // flaky path, which is the worst place to have it. The write below already
3874    // takes this stance — "a failure here means nothing was renamed or moved" —
3875    // and the read gets the same one.
3876    //
3877    // **The CID comes with the record**, and the write below names it: that is
3878    // the whole compare-and-swap (#149).
3879    let found = match state.repo().list_subscriptions_with_cids(did).await {
3880        Ok(subs) => subs
3881            .into_iter()
3882            .find(|(k, _, _)| k == rkey)
3883            .map(|(_, cid, s)| (cid, s)),
3884        Err(err) => {
3885            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3886            return Ok(Done(
3887                Redirect::to(&format!(
3888                    "/?flash={}",
3889                    qenc("Could not reach your PDS — nothing was renamed or moved.")
3890                ))
3891                .into_response(),
3892            ));
3893        }
3894    };
3895    let Some((read_cid, fresh)) = found else {
3896        // The rkey is not in the reader's repo. Renaming a record that is not
3897        // there would CREATE one, which is not what "rename" means and would
3898        // give it a fresh `createdAt` — the bug this read exists to prevent.
3899        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3900        return Ok(Done(
3901            Redirect::to(&format!(
3902                "/?flash={}",
3903                qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3904            ))
3905            .into_response(),
3906        ));
3907    };
3908
3909    // The subscription can be repointed at a different feed URL. **Every gate
3910    // on the URL applies to a repoint and only a repoint** — the three below
3911    // were each, at one time, run before this line on the URL as posted, and
3912    // each refused a pure retitle of a record that already existed:
3913    //
3914    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3915    //   at-URI that is not a publication (a feed generator another client
3916    //   subscribed to), so the record became un-editable with a flash saying
3917    //   it "was not saved or sent anywhere";
3918    // - the global feeds ceiling keyed on "URL not in the cache", and an
3919    //   at:// record is never cached with the flag off, so at capacity a
3920    //   retitle was refused for a row the handler would not insert;
3921    // - storability, the same way.
3922    //
3923    // An unchanged URL is already in the reader's repo; refusing to retitle
3924    // it protects nothing and takes their own record away from them.
3925    // Like for like: the form value is trimmed, and a record another client
3926    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3927    //
3928    // Whether this IS a repoint is the reader's change, from the merge — not
3929    // the form against a record another client may have moved (#149).
3930    let base = base.get_or_insert_with(|| fresh.clone());
3931    let MergedRename {
3932        sub,
3933        repoint: url_changed,
3934        already_saved,
3935    } = match merge_rename(form, base, fresh) {
3936        Ok(merged) => merged,
3937        Err(RenameConflict(field)) => {
3938            warn!(%did, %rkey, field, "refused rename: the reader and another client both changed the same field");
3939            return Ok(Done(rename_conflict_response()));
3940        }
3941    };
3942    // Every change the reader made is already in the record: a Save submitted
3943    // twice, whose first request landed. Success, with nothing to write — and
3944    // no cache write either, since the request that wrote it made that too.
3945    if already_saved {
3946        info!(%did, %rkey, "rename already in the record; nothing to write");
3947        return Ok(Done(Redirect::to("/").into_response()));
3948    }
3949    let feed_url = sub.url.clone();
3950
3951    // **Storability, on the same terms as the add and OPML paths — for a
3952    // REPOINT, and FIRST.** A target this instance cannot store gets that
3953    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3954    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3955    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3956    // here; a review found it by enumerating every writer of the table. The
3957    // first fix ran this check before the repo lookup, on the URL as posted —
3958    // which refused a pure retitle of a subscription that already IS an
3959    // at-URI, on every instance with the flag off. The flag gates what the
3960    // cache may store, not whether a reader may edit their own record: an
3961    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3962    // row below.
3963    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3964    if url_changed && !storable {
3965        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3966        return Ok(Done(
3967            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3968                .into_response(),
3969        ));
3970    }
3971
3972    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3973    // and rename both upserts it to the local cache AND rewrites the PDS
3974    // subscription record (a public `putRecord`), so without this guard a
3975    // crafted rename could land a secret-bearing URL in the public PDS — the
3976    // exact leak the add and OPML paths already prevent.
3977    if url_changed {
3978        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3979            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3980            return Ok(Done(
3981                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3982            ));
3983        }
3984    }
3985
3986    // Global feeds ceiling parity with add_subscription: a repoint to a
3987    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3988    // shared cache is at capacity (an existing/duplicate URL adds no row and
3989    // is always fine). `<= 0` disables.
3990    let feeds_cap = state.config.max_feeds_global;
3991    if url_changed
3992        && feeds_cap > 0
3993        && store::get_feed_by_url(&state.db, &feed_url)
3994            .await?
3995            .is_none()
3996    {
3997        match store::count_feeds(&state.db).await {
3998            Ok(n) if n >= feeds_cap => {
3999                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
4000                return Ok(Done(
4001                    Redirect::to(&format!(
4002                        "/?flash={}",
4003                        qenc(
4004                            "This instance is at its feed capacity right now. Please try again later."
4005                        )
4006                    ))
4007                    .into_response(),
4008                ));
4009            }
4010            Ok(_) => {}
4011            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
4012        }
4013    }
4014
4015    // **The PDS write decides what the reader is told.**
4016    //
4017    // This used to `warn!` on failure and then redirect exactly as it does on
4018    // success, so a rename that did not happen was indistinguishable from one
4019    // that did — the reader saw their old title come back and had no reason to
4020    // think anything had gone wrong. The PDS record IS the subscription; a
4021    // failure here means nothing was renamed or moved.
4022    //
4023    // **Conditional on the CID read above (#149).** A listing with no CID is
4024    // a PDS outside the lexicon (`listRecords` requires one); the write then
4025    // goes unconditionally, as every write did before this, and says so.
4026    if read_cid.is_none() {
4027        warn!(%did, %rkey, "the PDS listed this subscription without a CID; renaming without a compare-and-swap");
4028    }
4029    let res = match state
4030        .repo()
4031        .update_subscription(did, rkey, &sub, read_cid.as_deref())
4032        .await
4033    {
4034        Ok(res) => res,
4035        // Another client wrote the record after the read above: nothing was
4036        // written, and the caller decides whether to read again.
4037        Err(err) if crate::atproto::is_invalid_swap(&err) => return Ok(RenameAttempt::Raced),
4038        Err(err) => {
4039            warn!(%err, %did, %rkey, "PDS subscription update failed");
4040            return Ok(Done(
4041                Redirect::to(&format!(
4042                    "/?flash={}",
4043                    qenc("Could not save that change to your PDS — nothing was renamed or moved.")
4044                ))
4045                .into_response(),
4046            ));
4047        }
4048    };
4049    info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
4050
4051    // **The cache follows the PDS, so it is written only now** — after the
4052    // write landed. It used to be written before the put, so a rename the PDS
4053    // refused (a failed save, or both attempts of a lost race, #149) still
4054    // left the new title on the cached row, or a fresh `feeds` row for a
4055    // repoint's URL that the poller then fetched for nobody. Every gate on
4056    // that URL (storability, privacy, the ceiling) ran above, before the put;
4057    // only the write itself moved.
4058    //
4059    // Keep the local cache title in step for the loose-feed fallback path —
4060    // for a row this instance would have. Two cases write nothing:
4061    //
4062    // - not storable (an existing at-URI with the flag off): the record is the
4063    //   reader's to edit, the cache row is not this instance's to create;
4064    // - an unchanged URL with no cache row: a retitle is never the write that
4065    //   CREATES a row. That covers two findings at once — the ceiling is
4066    //   checked on a repoint only, so a retitle must not insert past it; and
4067    //   a secret-bearing URL another client subscribed to has no row (the
4068    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
4069    //   refuses to cache it), so it cannot enter the shared table here, be
4070    //   polled, fail, and be printed on the admin page. A privacy re-check on
4071    //   this write was the first draft; mutation showed it dead — the row
4072    //   rule already refused every case it would have.
4073    //
4074    // A failed lookup skips the cache rather than failing the request: the
4075    // rename has already landed, and the reader must be told so.
4076    let cache_write = storable
4077        && (url_changed
4078            || match store::get_feed_by_url(&state.db, &sub.url).await {
4079                Ok(row) => row.is_some(),
4080                Err(err) => {
4081                    warn!(%err, %did, url = %sub.url, "could not look up the cached feed row after a rename");
4082                    false
4083                }
4084            });
4085    if !cache_write {
4086        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
4087    } else if let Err(err) = store::upsert_feed(
4088        &state.db,
4089        &store::NewFeed {
4090            url: sub.url.clone(),
4091            title: sub.title.clone(),
4092            site_url: sub.site_url.clone(),
4093            ..Default::default()
4094        },
4095    )
4096    .await
4097    {
4098        // Not fatal to the rename — the PDS record is the source of truth —
4099        // but a missing `feeds` row means this subscription is never polled.
4100        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
4101    }
4102
4103    Ok(Done(Redirect::to("/").into_response()))
4104}
4105
4106// ---------------------------------------------------------------------------
4107// Folders
4108// ---------------------------------------------------------------------------
4109
4110/// Form body for `POST /folders`.
4111#[derive(Debug, Deserialize)]
4112struct FolderForm {
4113    name: String,
4114}
4115
4116/// `POST /folders` — create a folder record.
4117async fn create_folder(
4118    State(state): State<AppState>,
4119    headers: HeaderMap,
4120    Form(form): Form<FolderForm>,
4121) -> Result<Response, WebError> {
4122    let did = match current_did(&state, &headers).await {
4123        Some(d) => d,
4124        None => return Ok(Redirect::to("/login").into_response()),
4125    };
4126    let name = form.name.trim();
4127    if name.is_empty() {
4128        return Ok(Redirect::to("/").into_response());
4129    }
4130    let folder = Folder::new(name.to_string(), now_rfc3339());
4131    match state.repo().add_folder(&did, &folder).await {
4132        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
4133        Err(err) => warn!(%err, %did, "PDS folder create failed"),
4134    }
4135    Ok(Redirect::to("/").into_response())
4136}
4137
4138/// Form body for `POST /folders/:rkey/rename`.
4139#[derive(Debug, Deserialize)]
4140struct RenameFolderForm {
4141    name: String,
4142    /// The name the input was pre-filled with — the record's own name, so it
4143    /// is the common ancestor of the reader's edit and any other client's
4144    /// (#268). Absent (a hand-made POST, or a page from an older build), the
4145    /// handler's first read stands in for it.
4146    #[serde(default)]
4147    seen_name: Option<String>,
4148}
4149
4150/// `POST /folders/:rkey/rename` — rename a folder record, changing its `name`
4151/// and nothing else.
4152///
4153/// **An edit of the record, not a replacement (#268).** This used to put
4154/// `Folder::new(name, now)` over the record, which reset `position`, replaced
4155/// `createdAt` with the rename time and dropped every field another
4156/// `community.lexicon.rss` client had added — and reported success whether or
4157/// not the write landed. It now reads the record with its CID, changes only
4158/// the name ([`Folder::extra`] carries the fields this build does not know),
4159/// and writes it back with `swapRecord` set to that CID, retried once on
4160/// `InvalidSwap` with a three-way merge — the same compare-and-swap as
4161/// [`rename_subscription`] (#149).
4162async fn rename_folder(
4163    State(state): State<AppState>,
4164    headers: HeaderMap,
4165    Path(rkey): Path<String>,
4166    Form(form): Form<RenameFolderForm>,
4167) -> Result<Response, WebError> {
4168    let did = match current_did(&state, &headers).await {
4169        Some(d) => d,
4170        None => return Ok(Redirect::to("/login").into_response()),
4171    };
4172    if form.name.trim().is_empty() {
4173        return Ok(Redirect::to("/").into_response());
4174    }
4175    let mut base: Option<Folder> = None;
4176    for attempt in 1..=RENAME_ATTEMPTS {
4177        match rename_folder_once(&state, &did, &rkey, &form, &mut base).await {
4178            RenameAttempt::Done(resp) => return Ok(resp),
4179            RenameAttempt::Raced => {
4180                info!(%did, %rkey, attempt, "folder changed between read and write; re-reading");
4181            }
4182        }
4183    }
4184    warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused folder rename: the folder kept changing elsewhere");
4185    Ok(folder_flash(FOLDER_RENAME_CONFLICT))
4186}
4187
4188/// The answer to a folder rename that conflicts with another client's write.
4189const FOLDER_RENAME_CONFLICT: &str = "This folder was changed elsewhere while you were renaming \
4190     it — it was not renamed. Reload and try again.";
4191
4192/// Redirect home with `message` as the flash.
4193fn folder_flash(message: &str) -> Response {
4194    Redirect::to(&format!("/?flash={}", qenc(message))).into_response()
4195}
4196
4197/// What a folder rename should do, from [`merge_folder_rename`].
4198#[derive(Debug, PartialEq, Eq)]
4199enum FolderMerge {
4200    /// Write this record: the fresh one, renamed.
4201    Write(Folder),
4202    /// The record already has the reader's name — a double-submitted Save
4203    /// whose first request landed, or the same rename made elsewhere.
4204    AlreadySaved,
4205    /// The reader did not change the name. Nothing to write.
4206    Unchanged,
4207}
4208
4209/// A three-way merge of the reader's rename against `fresh`, the record as
4210/// just read (#268).
4211///
4212/// The ancestor is `seen` — the name the input showed — or, without it,
4213/// `base`, the record as the handler first read it. The name is the folder's
4214/// only field the form edits; everything else comes from `fresh` untouched.
4215///
4216/// - the reader left the name as it was shown → [`FolderMerge::Unchanged`],
4217///   whatever `fresh` holds;
4218/// - `fresh` already has the reader's name → [`FolderMerge::AlreadySaved`];
4219/// - `fresh` still has the ancestor's name → write `fresh` renamed;
4220/// - otherwise someone else renamed it differently → a conflict.
4221///
4222/// Compared trimmed: the posted name is trimmed, and a record another client
4223/// wrote may carry padding.
4224fn merge_folder_rename(
4225    posted: &str,
4226    seen: Option<&str>,
4227    base: &Folder,
4228    fresh: Folder,
4229) -> Result<FolderMerge, RenameConflict> {
4230    let posted = posted.trim();
4231    let ancestor = seen.unwrap_or(&base.name).trim();
4232    if posted == ancestor {
4233        return Ok(FolderMerge::Unchanged);
4234    }
4235    let current = fresh.name.trim();
4236    if current == posted {
4237        return Ok(FolderMerge::AlreadySaved);
4238    }
4239    if current != ancestor {
4240        return Err(RenameConflict("name"));
4241    }
4242    let mut folder = fresh;
4243    folder.name = posted.to_string();
4244    Ok(FolderMerge::Write(folder))
4245}
4246
4247/// One attempt at [`rename_folder`]: read the folder and its CID, merge the
4248/// reader's rename into it, and write it back on the condition that it is
4249/// still at that CID. `base` is set by the first attempt's read.
4250async fn rename_folder_once(
4251    state: &AppState,
4252    did: &str,
4253    rkey: &str,
4254    form: &RenameFolderForm,
4255    base: &mut Option<Folder>,
4256) -> RenameAttempt {
4257    use RenameAttempt::Done;
4258
4259    // No single-record read on `Repo`, as for subscriptions: the sidecar has
4260    // no `get` action, and the listing already carries each record's CID.
4261    // A failed read refuses the rename — rebuilding the record from the form
4262    // is the loss this read exists to prevent.
4263    let found = match state.repo().list_folders_with_cids(did).await {
4264        Ok(folders) => folders
4265            .into_iter()
4266            .find(|(k, _, _)| k == rkey)
4267            .map(|(_, cid, f)| (cid, f)),
4268        Err(err) => {
4269            warn!(%err, %did, %rkey, "could not read the folder before renaming it");
4270            return Done(folder_flash(
4271                "Could not reach your PDS — the folder was not renamed.",
4272            ));
4273        }
4274    };
4275    let Some((read_cid, fresh)) = found else {
4276        // Deleted elsewhere (or never there). A put at a missing rkey would
4277        // CREATE the folder, which is not what "rename" means.
4278        warn!(%did, %rkey, "refused folder rename: no such folder in the repo");
4279        return Done(folder_flash(
4280            "That folder no longer exists — it may have been deleted elsewhere. \
4281             Nothing was renamed.",
4282        ));
4283    };
4284
4285    let base = base.get_or_insert_with(|| fresh.clone());
4286    let folder = match merge_folder_rename(&form.name, form.seen_name.as_deref(), base, fresh) {
4287        Ok(FolderMerge::Write(folder)) => folder,
4288        Ok(FolderMerge::AlreadySaved) => {
4289            info!(%did, %rkey, "folder already has this name; nothing to write");
4290            return Done(Redirect::to("/").into_response());
4291        }
4292        Ok(FolderMerge::Unchanged) => return Done(Redirect::to("/").into_response()),
4293        Err(RenameConflict(field)) => {
4294            warn!(%did, %rkey, field, "refused folder rename: the reader and another client both renamed it");
4295            return Done(folder_flash(FOLDER_RENAME_CONFLICT));
4296        }
4297    };
4298
4299    if read_cid.is_none() {
4300        warn!(%did, %rkey, "the PDS listed this folder without a CID; renaming without a compare-and-swap");
4301    }
4302    match state
4303        .repo()
4304        .rename_folder(did, rkey, &folder, read_cid.as_deref())
4305        .await
4306    {
4307        Ok(res) => {
4308            info!(%did, %rkey, uri = %res.uri, "renamed folder");
4309            Done(Redirect::to("/").into_response())
4310        }
4311        Err(err) if crate::atproto::is_invalid_swap(&err) => RenameAttempt::Raced,
4312        Err(err) => {
4313            warn!(%err, %did, %rkey, "PDS folder rename failed");
4314            Done(folder_flash(
4315                "Could not save that change to your PDS — the folder was not renamed.",
4316            ))
4317        }
4318    }
4319}
4320
4321/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
4322/// simply become un-foldered).
4323async fn delete_folder(
4324    State(state): State<AppState>,
4325    headers: HeaderMap,
4326    Path(rkey): Path<String>,
4327) -> Result<Response, WebError> {
4328    let did = match current_did(&state, &headers).await {
4329        Some(d) => d,
4330        None => return Ok(Redirect::to("/login").into_response()),
4331    };
4332    match state.repo().remove_folder(&did, &rkey).await {
4333        Ok(()) => info!(%did, %rkey, "deleted folder record"),
4334        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
4335    }
4336    Ok(Redirect::to("/").into_response())
4337}
4338
4339/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
4340/// feed document we take it as-is; if it yields an HTML page we run
4341/// autodiscovery over its `<link rel="alternate">` tags.
4342async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
4343    let parsed =
4344        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
4345
4346    let client = feed::build_client()?;
4347    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
4348    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
4349    // loopback / private hosts.
4350    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
4351    let final_url = resp.url().clone();
4352    let content_type = resp
4353        .headers()
4354        .get(axum::http::header::CONTENT_TYPE)
4355        .and_then(|v| v.to_str().ok())
4356        .unwrap_or("")
4357        .to_ascii_lowercase();
4358    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
4359    // gzip strips it, and this response is reflected into the UI.
4360    let raw = crate::net::read_capped(resp).await?;
4361    let body = String::from_utf8_lossy(&raw).into_owned();
4362
4363    let looks_like_feed = content_type.contains("xml")
4364        || content_type.contains("rss")
4365        || content_type.contains("atom")
4366        || content_type.contains("application/feed+json")
4367        || {
4368            let head = body.trim_start();
4369            head.starts_with("<?xml")
4370                || head.starts_with("<rss")
4371                || head.starts_with("<feed")
4372                || head.contains("<rss")
4373                || head.contains("<feed")
4374        };
4375    if looks_like_feed {
4376        return Ok(final_url.to_string());
4377    }
4378
4379    match feed::discover_feed(&body, Some(&final_url)) {
4380        Some(u) => Ok(u.to_string()),
4381        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
4382    }
4383}
4384
4385// ---------------------------------------------------------------------------
4386// Login (atproto OAuth via the sidecar)
4387// ---------------------------------------------------------------------------
4388
4389/// Query for `GET /login`.
4390#[derive(Debug, Deserialize, Default)]
4391struct LoginQuery {
4392    #[serde(default)]
4393    handle: Option<String>,
4394    #[serde(default)]
4395    error: Option<String>,
4396    #[serde(default)]
4397    flash: Option<String>,
4398}
4399
4400/// `GET /login` — start the atproto OAuth flow, or render the handle form.
4401///
4402/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
4403/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
4404/// session cookie *or* the submitted handle resolving to a seated DID) or a
4405/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
4406/// form (no handle) always renders.
4407async fn login_form(
4408    State(state): State<AppState>,
4409    headers: HeaderMap,
4410    Query(q): Query<LoginQuery>,
4411) -> Response {
4412    if let Some(handle) = q
4413        .handle
4414        .map(|h| h.trim().to_string())
4415        .filter(|h| !h.is_empty())
4416    {
4417        if !may_start_oauth(&state, &headers, &handle).await {
4418            return Redirect::to("/beta/redeem").into_response();
4419        }
4420        return start_oauth(&state, &handle).await;
4421    }
4422    render(&LoginTemplate {
4423        card: login_card(&state.config),
4424        repo_url: REPO_URL,
4425        error: q.error.unwrap_or_default(),
4426        flash: q.flash.unwrap_or_default(),
4427    })
4428}
4429
4430/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
4431/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
4432async fn login_submit(
4433    State(state): State<AppState>,
4434    headers: HeaderMap,
4435    Form(form): Form<LoginForm>,
4436) -> Response {
4437    let handle = form.handle.trim();
4438    if handle.is_empty() {
4439        return login_error(&state, "Enter your atproto handle.");
4440    }
4441    if !may_start_oauth(&state, &headers, handle).await {
4442        return Redirect::to("/beta/redeem").into_response();
4443    }
4444    start_oauth(&state, handle).await
4445}
4446
4447/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
4448/// admits, in order of cost:
4449///
4450/// 1. an existing beta member's cookie session whose DID already holds a seat;
4451/// 2. a fresh visitor carrying a valid reserving invite cookie;
4452/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
4453///    already holds a seat — this honors the **seeded admin's first login** on a
4454///    fresh deploy (and any returning member who cleared cookies) without a
4455///    session cookie or an invite code.
4456///
4457/// The cookie/invite fast paths run FIRST and short-circuit, so the network
4458/// handle→DID resolution is only attempted when neither applies. It fails
4459/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
4460/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
4461/// This keeps the anti-abuse intent — a rando now pays a cheap handle
4462/// resolution instead of a burned sidecar handshake (and `/login` is already in
4463/// the rate-limited path set).
4464async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
4465    // The production resolver is the app's existing atproto handle→DID path,
4466    // routed through the SSRF guard. Resolution is injected so tests can exercise
4467    // the gate without a live network call (the guard forbids loopback mocks).
4468    may_start_oauth_with(state, headers, handle, |h| async move {
4469        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
4470            .await
4471            .ok()
4472    })
4473    .await
4474}
4475
4476/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
4477/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
4478/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
4479/// only called when neither admits — keeping the network round-trip off the hot
4480/// path and preserving the fail-closed contract on resolution failure.
4481async fn may_start_oauth_with<F, Fut>(
4482    state: &AppState,
4483    headers: &HeaderMap,
4484    handle: &str,
4485    resolve: F,
4486) -> bool
4487where
4488    F: FnOnce(String) -> Fut,
4489    Fut: std::future::Future<Output = Option<String>>,
4490{
4491    // 1. An already-beta'd session may re-auth freely.
4492    if let Some(did) = current_did(state, headers).await {
4493        if store::has_beta_access(&state.db, &did)
4494            .await
4495            .unwrap_or(false)
4496        {
4497            return true;
4498        }
4499    }
4500    // 2. A valid reserving invite cookie.
4501    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4502        return true;
4503    }
4504    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
4505    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
4506    //    on any resolution error or unresolvable/malformed handle.
4507    match resolve(handle.to_string()).await {
4508        Some(did) => store::has_beta_access(&state.db, &did)
4509            .await
4510            .unwrap_or(false),
4511        None => {
4512            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4513            false
4514        }
4515    }
4516}
4517
4518/// Begin the OAuth handshake for `handle`, on whichever backend is live.
4519///
4520/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
4521/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
4522/// carries `form-action 'self'`. Browsers have historically disagreed about
4523/// whether that directive applies to redirects following a form submission, and
4524/// if it did here, login would break in a browser while every test passed.
4525///
4526/// It does not, and the evidence is the SIDECAR path, which is live in
4527/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
4528/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
4529/// whole redirect chain would already be blocking that. One checking only the
4530/// form's action URL sees `/login` in both cases. The two arms differ only in
4531/// how many same-origin hops precede the cross-origin one, so any policy that
4532/// permits the sidecar flow permits this one.
4533///
4534/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
4535/// its own `/login` and its own callback, so starting a login is one redirect
4536/// and nothing is stored here. The Rust backend pushes the authorization
4537/// request itself, which means this app now holds the pending login — and must
4538/// set the browser-binding cookie that the callback will be checked against.
4539async fn start_oauth(state: &AppState, handle: &str) -> Response {
4540    match state.config.repo_backend {
4541        crate::metrics::Backend::Sidecar => {
4542            let url = state.sidecar.login_url(handle, None);
4543            info!(%handle, "redirecting to OAuth sidecar login");
4544            Redirect::to(&url).into_response()
4545        }
4546        crate::metrics::Backend::Rust => {
4547            let Some(runtime) = state.oauth.as_deref() else {
4548                warn!("the rust backend is live but its OAuth runtime is absent");
4549                return login_error(state, "Login is not available right now.");
4550            };
4551            match crate::oauth::login::start(
4552                runtime,
4553                &state.http,
4554                &state.db,
4555                handle,
4556                crate::store::now_unix(),
4557            )
4558            .await
4559            {
4560                Ok(started) => {
4561                    info!(%handle, "pushed authorization request; redirecting to the PDS");
4562                    let mut resp = Redirect::to(&started.authorize_url).into_response();
4563                    set_cookie(
4564                        &mut resp,
4565                        &cookie::sign_value(
4566                            OAUTH_BINDING_COOKIE,
4567                            &started.binding_token,
4568                            &state.config.cookie_secret,
4569                            OAUTH_BINDING_MAX_AGE_SECS,
4570                        ),
4571                    );
4572                    resp
4573                }
4574                Err(err) => {
4575                    // The handle the user typed is logged; the error is not shown
4576                    // to them verbatim, since it can name internal hosts.
4577                    warn!(%err, %handle, "could not start the OAuth login");
4578                    login_error(state, "Could not start login for that handle.")
4579                }
4580            }
4581        }
4582    }
4583}
4584
4585/// Clear the browser-binding cookie. Called on every terminal outcome of a
4586/// callback, successful or not: the pending row is consumed either way, so a
4587/// lingering cookie can only ever match a login that no longer exists.
4588fn clear_binding_cookie(resp: &mut Response) {
4589    set_cookie(
4590        resp,
4591        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4592    );
4593}
4594
4595/// Form body for `POST /login`.
4596#[derive(Debug, Deserialize)]
4597struct LoginForm {
4598    handle: String,
4599}
4600
4601/// Query for `GET /oauth/callback`.
4602///
4603/// Carries BOTH shapes, because the two backends deliver different things to
4604/// the same URL: the sidecar hands back a one-shot `session_id` it has already
4605/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
4606/// for this app to exchange itself. Which fields are populated is decided by
4607/// which backend started the login, not by which is live now — so a flip with a
4608/// login already in flight still lands in the right arm.
4609#[derive(Debug, Deserialize, Default)]
4610struct CallbackQuery {
4611    /// Sidecar backend: the handoff id.
4612    #[serde(default)]
4613    session_id: Option<String>,
4614    /// Rust backend: the authorization code and its envelope.
4615    #[serde(default)]
4616    code: Option<String>,
4617    #[serde(default)]
4618    state: Option<String>,
4619    #[serde(default)]
4620    iss: Option<String>,
4621    /// JARM, which is not supported — carried only so it can be refused
4622    /// explicitly rather than read as "no code".
4623    #[serde(default)]
4624    response: Option<String>,
4625    #[serde(default)]
4626    error: Option<String>,
4627    #[serde(default)]
4628    error_description: Option<String>,
4629}
4630
4631/// `GET /oauth/callback` — establish the cookie session.
4632///
4633/// **Invite gate:** the verified DID must hold beta access. If it already does
4634/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
4635/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
4636/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
4637async fn oauth_callback(
4638    State(state): State<AppState>,
4639    headers: HeaderMap,
4640    Query(q): Query<CallbackQuery>,
4641) -> Response {
4642    // An error response is handled by the SAME arm that would have handled a
4643    // success, not short-circuited here.
4644    //
4645    // Returning early looks obviously right and is wrong on the Rust path: it
4646    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
4647    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
4648    // error originates from the intended AS". It also leaves the pending row
4649    // unconsumed, so a `state` that has already produced a callback stays usable
4650    // until it expires.
4651    //
4652    // The sidecar arm has no such check to reach, so it is short-circuited
4653    // below, preserving exactly what it did before.
4654    // **The arm is chosen by what the SERVER knows, not by what the caller
4655    // sent.** A `session_id` in the query used to select the sidecar arm on its
4656    // own — so a caller could pick which code path ran, and the sidecar arm has
4657    // no browser-binding check at all. It also short-circuited the error path
4658    // below, skipping the `iss` validation.
4659    //
4660    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
4661    // configured one, means the selection follows this deployment's own
4662    // configuration. A login started before a flip still completes, because the
4663    // Rust arm is reached whenever the Rust runtime exists and can match the
4664    // `state` against a pending row it actually wrote.
4665    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
4666    // and `?error=…&error_description=…` on its own failure. Keying only on
4667    // `session_id` sent the failure shape down the Rust arm, which then failed
4668    // with "no `state`" and replaced the specific reason with a generic one —
4669    // and `error_description` is exactly what the sidecar Caddy routing matches
4670    // to send that request here in the first place.
4671    let sidecar_shape =
4672        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4673    let sidecar_handoff = sidecar_shape
4674        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4675    if let Some(err) = q.error.clone() {
4676        // **Neither the code nor the description is echoed as sent.**
4677        //
4678        // Both are server-controlled free text arriving on a public GET, so
4679        // anyone who can make a browser fetch this URL chooses them. The raw
4680        // `error` used to go into a `warn!` AND into the rendered login page,
4681        // and `error_description` — arbitrary text, newlines included — went
4682        // into the log verbatim: a log-injection surface on one side and
4683        // attacker-chosen copy in the product's own voice on the other.
4684        //
4685        // `oauth::flow` already decided this exact question for the Rust arm:
4686        // reduce the code to a known slug, drop the description entirely. That
4687        // reasoning is not specific to which arm handles the callback, and this
4688        // one simply never got the same treatment. The description's LENGTH is
4689        // kept, because "the server sent a 4 KB explanation" is occasionally
4690        // worth knowing and cannot be used to inject anything.
4691        let slug = crate::oauth::flow::known_error_slug(&err);
4692        warn!(
4693            error = slug,
4694            desc_len = q.error_description.as_deref().map_or(0, str::len),
4695            "OAuth callback returned an error"
4696        );
4697        if sidecar_handoff || state.oauth.is_none() {
4698            return login_error(&state, &format!("Login failed: {slug}"));
4699        }
4700        // Fall through: the Rust arm consumes the pending row and validates
4701        // `iss` against it, and reports the failure afterwards.
4702    }
4703
4704    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
4705    // currently selected: a login started before a flip must still complete.
4706    let session = if sidecar_handoff {
4707        let session_id = q.session_id.clone().unwrap_or_default();
4708        match state.sidecar.resolve_session(&session_id).await {
4709            Ok(Some(s)) => s,
4710            Ok(None) => {
4711                warn!("OAuth callback session_id did not resolve (expired/unknown)");
4712                return login_error(&state, "Login session expired — please try again.");
4713            }
4714            Err(err) => {
4715                warn!(%err, "failed to resolve OAuth session via the sidecar");
4716                return login_error(&state, "Login failed talking to the auth service.");
4717            }
4718        }
4719    } else {
4720        let Some(runtime) = state.oauth.as_deref() else {
4721            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4722            return login_error(&state, "Login failed: this login could not be completed.");
4723        };
4724        let params = crate::oauth::flow::CallbackParams {
4725            code: q.code.clone(),
4726            state: q.state.clone(),
4727            iss: q.iss.clone(),
4728            // Passed through, NOT dropped: `verify_callback` checks `iss`
4729            // against the pending row's issuer before it reports the error, and
4730            // it cannot do that for an error it never sees.
4731            error: q.error.clone(),
4732            error_description: q.error_description.clone(),
4733            response: q.response.clone(),
4734        };
4735        let binding =
4736            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4737        match crate::oauth::login::complete(
4738            runtime,
4739            &state.http,
4740            &state.db,
4741            &params,
4742            binding.as_deref(),
4743            crate::store::now_unix(),
4744        )
4745        .await
4746        {
4747            Ok(done) => crate::atproto::SidecarSession {
4748                did: done.did,
4749                handle: done.handle,
4750            },
4751            Err(err) => {
4752                // Never echoed to the browser: the message can name the issuer,
4753                // the PDS, and why a binding check failed.
4754                warn!(%err, "could not complete the OAuth callback");
4755                let mut resp = login_error(&state, "Login failed — please try again.");
4756                clear_binding_cookie(&mut resp);
4757                return resp;
4758            }
4759        }
4760    };
4761
4762    // Bind the verified DID to the invite gate. Returns a response only on the
4763    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
4764    let mut clear_invite = false;
4765    if !store::has_beta_access(&state.db, &session.did)
4766        .await
4767        .unwrap_or(false)
4768    {
4769        // Not yet a member: consume the reserved invite code, if any.
4770        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4771            Some(c) => c,
4772            None => {
4773                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4774                return Redirect::to("/beta/redeem").into_response();
4775            }
4776        };
4777        match store::redeem_code(
4778            &state.db,
4779            &code,
4780            &session.did,
4781            session.handle.as_deref(),
4782            state.config.beta_cap,
4783        )
4784        .await
4785        {
4786            Ok(Ok(())) => {
4787                clear_invite = true;
4788                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4789            }
4790            Ok(Err(policy)) => {
4791                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4792                let mut resp = redeem_bounce(&state, &policy).into_response();
4793                // The reservation is spent/invalid — drop the stale invite cookie.
4794                clear_invite_cookie(&mut resp);
4795                return resp;
4796            }
4797            Err(err) => {
4798                warn!(%err, did = %session.did, "invite redeem infra error at callback");
4799                return login_error(&state, "Login failed while confirming your invite.");
4800            }
4801        }
4802    }
4803
4804    // Mint an opaque, random server-side session id and store the identity under
4805    // it; the cookie carries the (HMAC-signed) sid, never the DID.
4806    let sid = state.sessions.create(Session {
4807        did: session.did.clone(),
4808        handle: session.handle.clone(),
4809    });
4810    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4811    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4812
4813    let mut resp = Redirect::to("/").into_response();
4814    set_cookie(&mut resp, &cookie);
4815    clear_binding_cookie(&mut resp);
4816    if clear_invite {
4817        clear_invite_cookie(&mut resp);
4818    }
4819    resp
4820}
4821
4822/// Revoke a DID's OAuth session on BOTH backends, best-effort.
4823///
4824/// Not "whichever backend is live": during a cutover a user's tokens can be in
4825/// either store — they logged in under one backend and are logging out under
4826/// the other. Revoking only the live one would leave a live refresh token
4827/// behind in the other, which is the exact failure sign-out exists to prevent,
4828/// and it would be invisible because the sign-out itself looks successful.
4829///
4830/// Both arms are best-effort. The caller has already decided to sign the user
4831/// out, and a network failure must not trap them in a half-logged-out state.
4832/// How long sign-out will wait for a final read-state flush before revoking
4833/// anyway.
4834///
4835/// Bounded because the flush talks to the user's PDS, and a user trying to leave
4836/// must never be held by a server that is not answering. Three seconds is long
4837/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
4838/// and short enough that a dead PDS is an inconvenience rather than a trap.
4839const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4840
4841/// Flush whatever read-state is still dirty for `did`, then give up quietly.
4842///
4843/// **Called before revoking, because revoking first strands it (#117).**
4844/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
4845/// session cannot be sent by anyone — it parks until the user signs in again,
4846/// which may be never. Flushing first is what stops the common case from
4847/// becoming that.
4848///
4849/// Best-effort by construction: every failure path here falls through to the
4850/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4851/// the parked state the flusher now handles deliberately rather than retrying
4852/// forever.
4853async fn flush_before_revoke(state: &AppState, did: &str) {
4854    match tokio::time::timeout(
4855        SIGN_OUT_FLUSH_BUDGET,
4856        crate::readstate::flush_did(state, did),
4857    )
4858    .await
4859    {
4860        Ok(Ok(())) => {}
4861        Ok(Err(err)) => {
4862            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4863        }
4864        Err(_) => warn!(
4865            %did,
4866            budget = ?SIGN_OUT_FLUSH_BUDGET,
4867            "sign-out: final read-state flush timed out; it will park until next sign-in"
4868        ),
4869    }
4870}
4871
4872async fn revoke_everywhere(state: &AppState, did: &str) {
4873    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4874    // that recording only the rust arm let `oauth_revoke` report a clean success
4875    // while every sidecar revocation failed — and for anyone who logged in before
4876    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4877    // rust arm correctly returns NoSession and the metric reads all-clear while
4878    // live refresh tokens sit at the PDS.
4879    //
4880    // Same op name, different backend: the backend column is what distinguishes
4881    // them, so "no revocation failures" means checking both rows, not one.
4882    let sidecar_started = std::time::Instant::now();
4883    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4884        Ok(res) => {
4885            info!(%did, revoked = res.revoked, "sidecar session revoked");
4886            true
4887        }
4888        Err(err) => {
4889            warn!(%did, %err, "sidecar revoke failed; continuing");
4890            false
4891        }
4892    };
4893    state.metrics.record(
4894        crate::metrics::Backend::Sidecar,
4895        "oauth_revoke",
4896        sidecar_started.elapsed().as_micros() as u64,
4897        sidecar_ok,
4898    );
4899
4900    if let Some(runtime) = state.oauth.as_deref() {
4901        let revoke_started = std::time::Instant::now();
4902        let outcome = crate::oauth::revoke::sign_out_discovering(
4903            runtime,
4904            &state.http,
4905            &state.db,
4906            did,
4907            crate::store::now_unix(),
4908        )
4909        .await;
4910        // **Counted, because a warn! nobody reads is not observability.** Until
4911        // this existed, a revocation failure left exactly one trace: a log line.
4912        // "No revocation failures this week" was therefore a statement about
4913        // nobody having looked, which is not the same claim.
4914        //
4915        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4916        // there being nothing to revoke is the correct outcome, not a failure,
4917        // and counting it as an error would make the metric noisy in exactly
4918        // the case that is fine. Only `Failed` means the PDS still holds live
4919        // tokens we asked it to drop.
4920        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4921        state.metrics.record(
4922            crate::metrics::Backend::Rust,
4923            "oauth_revoke",
4924            revoke_started.elapsed().as_micros() as u64,
4925            revoke_ok,
4926        );
4927        match outcome {
4928            crate::oauth::revoke::Revocation::Revoked => {
4929                info!(%did, "rust OAuth session revoked at the PDS")
4930            }
4931            crate::oauth::revoke::Revocation::NoSession => {}
4932            crate::oauth::revoke::Revocation::Failed(reason) => {
4933                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4934            }
4935        }
4936    }
4937}
4938
4939/// `POST /logout` — end the session everywhere, not just in this browser.
4940///
4941/// Clearing the cookie only stops *this* device from presenting the session;
4942/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4943/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4944/// access tokens at the PDS and drops the sidecar's session rows. The local
4945/// registry entry is dropped and the cookie cleared regardless of whether the
4946/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4947/// user in a half-logged-out state).
4948async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4949    if let Some(user) = current_session(&state, &headers).await {
4950        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4951        // revoke; the dev-DID fallback never handshook the sidecar.
4952        if let Some(sid) = user.sid {
4953            state.sessions.remove(&sid);
4954            // BEFORE the revoke: afterwards there is no session to send it with.
4955            flush_before_revoke(&state, &user.did).await;
4956            revoke_everywhere(&state, &user.did).await;
4957        }
4958    }
4959    let mut resp = Redirect::to("/login").into_response();
4960    set_cookie(
4961        &mut resp,
4962        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4963    );
4964    resp
4965}
4966
4967/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4968/// `DELETE` into this field for the purge to run.
4969#[derive(Debug, Deserialize)]
4970struct DeleteAccountForm {
4971    #[serde(default)]
4972    confirm: String,
4973}
4974
4975/// The literal a user must type to confirm the destructive delete.
4976const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4977
4978/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4979///
4980/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4981/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4982///   1. purges **every** local row owned by the caller DID (`entry_state`,
4983///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4984///      DID created) via [`store::purge_did_data`], then
4985///   2. revokes the OAuth session at the PDS via `revoke_everywhere` — the
4986///      sidecar's `POST /internal/revoke {did}` and, when the Rust OAuth runtime
4987///      is configured, its RFC 7009 revocation too — then
4988///   3. drops the in-memory session and clears the cookie, signing the user out.
4989///
4990/// The subscription/folder/saved *records* in the user's own PDS are
4991/// intentionally left alone — they are the user's data on their own server; the
4992/// `/about` copy and this page's UI both say so, and export stays available.
4993async fn account_delete(
4994    State(state): State<AppState>,
4995    headers: HeaderMap,
4996    Form(form): Form<DeleteAccountForm>,
4997) -> Result<Response, WebError> {
4998    let user = match current_session(&state, &headers).await {
4999        Some(u) => u,
5000        None => return Ok(Redirect::to("/login").into_response()),
5001    };
5002    let did = user.did.clone();
5003
5004    // Confirm-gate: require the exact typed phrase before doing anything.
5005    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
5006        return Ok(Redirect::to(&format!(
5007            "/manage?flash={}",
5008            qenc("Type DELETE to confirm — nothing was deleted.")
5009        ))
5010        .into_response());
5011    }
5012
5013    // 1. Purge every local row this DID owns (single transaction).
5014    let counts = store::purge_did_data(&state.db, &did).await?;
5015    info!(
5016        %did,
5017        total = counts.total(),
5018        entry_state = counts.entry_state,
5019        read_cursor = counts.read_cursor,
5020        sub_ref = counts.sub_ref,
5021        beta_access = counts.beta_access,
5022        invite_codes = counts.invite_codes,
5023        "account/delete: local rows purged"
5024    );
5025
5026    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
5027    //    rows are already gone; a network blip must not block the sign-out).
5028    revoke_everywhere(&state, &did).await;
5029
5030    // 3. Drop the in-memory session and clear the cookie: sign the user out.
5031    if let Some(sid) = user.sid {
5032        state.sessions.remove(&sid);
5033    }
5034    let mut resp = Redirect::to(&format!(
5035        "/login?flash={}",
5036        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
5037    ))
5038    .into_response();
5039    set_cookie(
5040        &mut resp,
5041        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5042    );
5043    Ok(resp)
5044}
5045
5046/// The `/login` card, shared by the form and its error re-render.
5047fn login_card(config: &Config) -> Card {
5048    Card::public(
5049        config,
5050        "/login",
5051        "Sign in — FeatherReader",
5052        "Sign in to FeatherReader with your atproto handle. You approve access on \
5053         your own server — no signup, no password.",
5054    )
5055}
5056
5057/// Re-render the login form with an error banner.
5058fn login_error(state: &AppState, msg: &str) -> Response {
5059    render(&LoginTemplate {
5060        card: login_card(&state.config),
5061        repo_url: REPO_URL,
5062        error: msg.to_string(),
5063        flash: String::new(),
5064    })
5065}
5066
5067// ---------------------------------------------------------------------------
5068// Closed-beta invite gate (self-serve redeem + admin mint)
5069// ---------------------------------------------------------------------------
5070
5071/// Form body for `POST /beta/redeem`.
5072#[derive(Debug, Deserialize)]
5073struct RedeemForm {
5074    code: String,
5075}
5076
5077/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
5078/// already full we render the "capacity full" variant (no form).
5079async fn beta_redeem_form(State(state): State<AppState>) -> Response {
5080    let full = store::count_beta_access(&state.db)
5081        .await
5082        .map(|n| n >= state.config.beta_cap)
5083        .unwrap_or(false);
5084    render(&BetaRedeemTemplate {
5085        card: redeem_card(&state.config),
5086        repo_url: REPO_URL,
5087        error: String::new(),
5088        capacity_full: full,
5089    })
5090}
5091
5092/// `POST /beta/redeem` — the **pre-handshake** reservation.
5093///
5094/// Validates the pasted code is *redeemable right now* (exists, active,
5095/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
5096/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
5097/// reserving intent to redeem this code, then sends the visitor to `/login`. The
5098/// OAuth callback later binds the verified DID and atomically consumes the code
5099/// (`store::redeem_code`). This ordering means a non-invited visitor can never
5100/// start OAuth (and burn a sidecar handshake).
5101async fn beta_redeem_submit(
5102    State(state): State<AppState>,
5103    Form(form): Form<RedeemForm>,
5104) -> Response {
5105    let code = form.code.trim().to_uppercase();
5106    if code.is_empty() {
5107        return render(&BetaRedeemTemplate {
5108            card: redeem_card(&state.config),
5109            repo_url: REPO_URL,
5110            error: "Enter your invite code.".to_string(),
5111            capacity_full: false,
5112        });
5113    }
5114
5115    match preflight_code(&state, &code).await {
5116        Ok(()) => {
5117            let cookie = sign_invite(&code, &state.config.cookie_secret);
5118            let mut resp = Redirect::to("/login").into_response();
5119            set_cookie(&mut resp, &cookie);
5120            info!("invite code preflight OK; reserving intent + redirecting to /login");
5121            resp
5122        }
5123        Err(policy) => {
5124            warn!(?policy, "invite code preflight rejected");
5125            redeem_bounce(&state, &policy)
5126        }
5127    }
5128}
5129
5130/// Read-only preflight of an invite code for the pre-handshake reservation:
5131/// verify it exists, is active, is not past `expires_at`, and that a seat is
5132/// free — mirroring the checks `store::redeem_code` will re-run atomically at
5133/// callback time. Does NOT consume the code or grant a seat. Returns the same
5134/// typed [`store::RedeemError`] variants so the two paths share one message map.
5135async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
5136    // Cap check first: a clear "capacity full" beats "code invalid" when both.
5137    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
5138    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
5139    // still backstops the real cap inside its tx, so this is a consistency /
5140    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
5141    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
5142    // that might overrun the cap.
5143    let count = match store::count_beta_access(&state.db).await {
5144        Ok(n) => n,
5145        Err(err) => {
5146            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
5147            return Err(store::RedeemError::CapacityFull);
5148        }
5149    };
5150    if count >= state.config.beta_cap {
5151        return Err(store::RedeemError::CapacityFull);
5152    }
5153    // Look up the code's current status + expiry (read-only).
5154    let row = sqlx::query_as::<_, (String, i64)>(
5155        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
5156    )
5157    .bind(code)
5158    .fetch_optional(&state.db)
5159    .await
5160    .ok()
5161    .flatten();
5162    let (status, expires_at) = match row {
5163        Some(r) => r,
5164        None => return Err(store::RedeemError::NotFound),
5165    };
5166    let now = chrono::Utc::now().timestamp();
5167    match status.as_str() {
5168        "active" if expires_at >= now => Ok(()),
5169        "active" => Err(store::RedeemError::Expired),
5170        "expired" => Err(store::RedeemError::Expired),
5171        // "redeemed" or anything else non-active.
5172        _ => Err(store::RedeemError::AlreadyRedeemed),
5173    }
5174}
5175
5176/// Map a [`store::RedeemError`] to the invite page with the right message. Used
5177/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
5178fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
5179    use store::RedeemError::*;
5180    let (msg, capacity_full) = match policy {
5181        NotFound => ("That invite code isn't valid.", false),
5182        Expired => ("That invite code has expired.", false),
5183        AlreadyRedeemed => ("That invite code has already been used.", false),
5184        CapacityFull => ("", true),
5185    };
5186    render(&BetaRedeemTemplate {
5187        card: redeem_card(&state.config),
5188        repo_url: REPO_URL,
5189        error: msg.to_string(),
5190        capacity_full,
5191    })
5192}
5193
5194/// The `/beta/redeem` card, shared by the form, its re-renders and the claim
5195/// link's bounce.
5196fn redeem_card(config: &Config) -> Card {
5197    Card::public(
5198        config,
5199        "/beta/redeem",
5200        "Redeem an invite — FeatherReader",
5201        "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
5202         in with your atproto handle.",
5203    )
5204}
5205
5206/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
5207#[derive(Debug, Deserialize, Default)]
5208struct MintQuery {
5209    #[serde(default)]
5210    n: Option<u32>,
5211}
5212
5213/// `POST /admin/invites?n=N` — mint N invite codes.
5214///
5215/// `GET /oauth/client-metadata.json` — the client's published identity.
5216///
5217/// **This URL IS the `client_id`.** The PDS fetches it during every login and
5218/// caches it against every existing grant, so it must keep answering at exactly
5219/// this path across the cutover — the sidecar serves the same document at the
5220/// same URL today, proxied by the edge.
5221///
5222/// Served whatever backend is live: a request that arrives here is from a PDS
5223/// resolving our identity, and it has no idea which of our two implementations
5224/// is currently answering repo calls.
5225async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
5226    let Some(runtime) = state.oauth.as_deref() else {
5227        // The sidecar is serving this path in front of us, or nothing is.
5228        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
5229    };
5230    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
5231}
5232
5233/// `GET /oauth/jwks.json` — the client's public signing key.
5234///
5235/// Production only. The localhost dev client is a PUBLIC client: it registers no
5236/// key and signs no assertions, so publishing a JWKS there would advertise a
5237/// credential that is never used — and would make a dev deployment look like a
5238/// confidential client to anyone reading it.
5239async fn oauth_jwks(State(state): State<AppState>) -> Response {
5240    let Some(runtime) = state.oauth.as_deref() else {
5241        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
5242    };
5243    match runtime.client_key.as_ref() {
5244        Some(key) => match key.jwks_document() {
5245            Ok(doc) => axum::Json(doc).into_response(),
5246            Err(err) => {
5247                warn!(%err, "could not render the client JWKS");
5248                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
5249            }
5250        },
5251        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
5252    }
5253}
5254
5255/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
5256const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
5257
5258/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
5259///
5260/// Admin-gated on the same rule as the invite minter: the table names every
5261/// operation the reader performs and how often each fails, which is an
5262/// operational picture rather than public information.
5263///
5264/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
5265/// is safe, and the comparison is two rows side by side.
5266async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
5267    let did = match current_did(&state, &headers).await {
5268        Some(d) => d,
5269        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5270    };
5271    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5272        warn!(%did, "admin metrics denied: not an admin-seed DID");
5273        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5274    }
5275
5276    // Flush first, so the table includes this process's traffic up to now.
5277    // Then read the PERSISTED rows, which is the only place both backends can
5278    // appear at once -- a flip is a restart, and in-process memory only ever
5279    // holds the backend currently running.
5280    if let Err(err) =
5281        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
5282    {
5283        warn!(%err, "could not flush repo timings before rendering");
5284    }
5285    let rows = match crate::metrics::persisted_rows(&state.db).await {
5286        Ok(rows) => rows,
5287        Err(err) => {
5288            warn!(%err, "could not read persisted repo timings");
5289            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
5290        }
5291    };
5292
5293    // The live backend is named at the top: a table of two populated rows is
5294    // ambiguous about which one is currently serving users.
5295    // Parked read-state, alongside the timings. The flusher no longer logs
5296    // these every round (#117), so without a number here the state would be
5297    // silent — which is the failure the noisy loop at least did not have.
5298    let parked = match crate::store::parked_readstate_dids(&state.db).await {
5299        Ok(n) => n.to_string(),
5300        Err(err) => {
5301            warn!(%err, "could not count parked read-state DIDs");
5302            "unknown".to_string()
5303        }
5304    };
5305    // **The half the public histogram cannot carry.** `/stats` reports counts by
5306    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
5307    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
5308    // cannot separate "the publishers are gone" from "we are broken". #159 was
5309    // the latter and took a production investigation to establish. Named feeds
5310    // and their error text belong here, behind ALLOWED_DIDS.
5311    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
5312        Ok(f) => f,
5313        Err(err) => {
5314            warn!(%err, "could not list failing feeds");
5315            Vec::new()
5316        }
5317    };
5318    let mut failing_block = String::new();
5319    if !failing.is_empty() {
5320        failing_block.push_str("\nfailing feeds (worst first)\n");
5321        for f in &failing {
5322            failing_block.push_str(&format!(
5323                "  {:>4}x  {:<8}  {}\n          {}\n",
5324                f.consecutive_errors,
5325                f.kind.as_deref().unwrap_or("unknown"),
5326                f.url,
5327                f.detail.as_deref().unwrap_or("(no detail recorded)"),
5328            ));
5329        }
5330    }
5331
5332    // **Capacity that no other page can show.** The global ceiling counts every
5333    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
5334    // unpollable ones — so an instance can be at its cap with every public
5335    // number saying otherwise. A review found exactly that gap.
5336    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
5337        Ok(n) => n,
5338        Err(err) => {
5339            warn!(%err, "could not count unpollable feeds");
5340            -1
5341        }
5342    };
5343    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
5344
5345    let body = format!(
5346        "live backend: {}\nparked read-state DIDs: {}\n\
5347         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
5348        state.config.repo_backend.as_str(),
5349        parked,
5350        cached,
5351        state.config.max_feeds_global,
5352        unpollable,
5353        crate::metrics::render(&rows),
5354        failing_block,
5355    );
5356    (StatusCode::OK, body).into_response()
5357}
5358
5359/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
5360/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
5361/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
5362async fn admin_mint_invites(
5363    State(state): State<AppState>,
5364    headers: HeaderMap,
5365    Query(q): Query<MintQuery>,
5366) -> Response {
5367    // Require a real, current session (not just a DID string) whose DID is an
5368    // admin-seed DID. `current_did` already re-checks the beta gate.
5369    let did = match current_did(&state, &headers).await {
5370        Some(d) => d,
5371        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5372    };
5373    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5374        warn!(%did, "admin mint denied: not an admin-seed DID");
5375        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5376    }
5377
5378    let n = q.n.unwrap_or(1).clamp(1, 100);
5379    let mut codes = Vec::with_capacity(n as usize);
5380    for _ in 0..n {
5381        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
5382            Ok(code) => codes.push(code),
5383            Err(err) => {
5384                warn!(%err, %did, "admin mint_code failed");
5385                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5386            }
5387        }
5388    }
5389    info!(%did, count = codes.len(), "admin minted invite codes");
5390    let mut body = codes.join("\n");
5391    body.push('\n');
5392    (StatusCode::OK, body).into_response()
5393}
5394
5395// ---------------------------------------------------------------------------
5396// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
5397// ---------------------------------------------------------------------------
5398
5399/// Query for `GET /claim`.
5400#[derive(Debug, Deserialize)]
5401struct ClaimQuery {
5402    /// The opaque claim token from the bot's public follow-back skeet.
5403    t: Option<String>,
5404}
5405
5406/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
5407///
5408/// The follow→invite bot posts a public skeet mentioning a new follower with a
5409/// link here. The token wraps a pre-minted invite code (never the raw code — see
5410/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
5411/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
5412/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
5413/// callback atomically consumes the code (`store::redeem_code`) — the same
5414/// machinery as a pasted code. On any failure it bounces to the invite page with
5415/// the matching message.
5416///
5417/// Single-use / grabbability: a token in a public URL is grabbable. The code it
5418/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
5419/// here rejects an already-used / expired / capacity-full code before reserving,
5420/// so a replayed link past the first successful claim is refused. The residual
5421/// window is the same as any pasted invite code: whoever completes OAuth *first*
5422/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
5423/// blunts brute-force enumeration.
5424async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
5425    let token = match q.t {
5426        Some(t) if !t.is_empty() => t,
5427        _ => {
5428            warn!("claim link with no token");
5429            return redeem_bounce(&state, &store::RedeemError::NotFound);
5430        }
5431    };
5432
5433    // Unwrap the token → the invite code it reserves. A tampered/forged token
5434    // yields nothing → treat as an invalid code (don't leak whether it parsed).
5435    let code = match claim_token_code(&token, &state.config.cookie_secret) {
5436        Some(c) => c,
5437        None => {
5438            warn!("claim token invalid (bad signature / malformed)");
5439            return redeem_bounce(&state, &store::RedeemError::NotFound);
5440        }
5441    };
5442
5443    // Re-run the same preflight as the pasted-code path: exists, active,
5444    // unexpired, seat free. This is what makes a replayed link past first-claim
5445    // (or past cap) fail cleanly.
5446    match preflight_code(&state, &code).await {
5447        Ok(()) => {
5448            let cookie = sign_invite(&code, &state.config.cookie_secret);
5449            let mut resp = Redirect::to("/login").into_response();
5450            set_cookie(&mut resp, &cookie);
5451            info!("claim token preflight OK; reserving intent + redirecting to /login");
5452            resp
5453        }
5454        Err(policy) => {
5455            warn!(?policy, "claim token preflight rejected");
5456            redeem_bounce(&state, &policy)
5457        }
5458    }
5459}
5460
5461/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
5462///
5463/// Passing the follower DID makes the APP the authoritative deduper: the app can
5464/// short-circuit a DID that already holds a seat, and return the SAME code for a
5465/// DID that already has an outstanding claim — so a bot-host state loss cannot
5466/// re-mint or re-post per follower. Handle is advisory (logs only).
5467#[derive(Debug, Default, Deserialize)]
5468struct BotClaimRequest {
5469    /// The follower's DID (the idempotency key). Optional for backward-compat: an
5470    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
5471    #[serde(default)]
5472    did: Option<String>,
5473    /// The follower's handle (advisory; recorded for operator logs only).
5474    #[serde(default)]
5475    #[allow(dead_code)]
5476    handle: Option<String>,
5477}
5478
5479/// The JSON body `POST /bot/claims` returns on success.
5480#[derive(Debug, serde::Serialize)]
5481struct BotClaimResponse {
5482    /// Server-side dedupe outcome, so the bot knows whether to post:
5483    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
5484    /// already had an outstanding claim; the SAME code/token/url is returned, so an
5485    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
5486    /// beta access; code/token/url are empty and the bot should post NOTHING).
5487    status: &'static str,
5488    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
5489    /// store. NEVER post this publicly; post the `url` instead. Empty when
5490    /// `already_seated`.
5491    code: String,
5492    /// The opaque claim token (the code wrapped + signed). Empty when
5493    /// `already_seated`.
5494    token: String,
5495    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
5496    /// Empty when `already_seated`.
5497    url: String,
5498}
5499
5500/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
5501///
5502/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
5503/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
5504/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
5505/// (503), so a bare/dev instance never exposes an unauthenticated mint.
5506///
5507/// Server-side DID idempotency (the authoritative dedupe backstop): the request
5508/// body carries the follower `did`. The app — not the bot's local SQLite — is the
5509/// source of truth, so a bot-host state loss cannot re-mint or re-post per
5510/// follower:
5511///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
5512///     code/url; the bot marks it handled and posts NOTHING);
5513///   * DID already has an outstanding active claim → `200 {status:"existing"}`
5514///     returning the SAME code/token/url (idempotent — never a second mint);
5515///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
5516///
5517/// Cap accounting: the bot must not promise more claims than seats remain, so
5518/// this refuses with `409 Conflict {"error":"full"}` when
5519/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
5520/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
5521/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
5522/// minting past the cap.
5523///
5524/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
5525/// default 14d — the admin browser flow's 30-min TTL would expire before the
5526/// follower taps an async-delivered link).
5527async fn bot_mint_claim(
5528    State(state): State<AppState>,
5529    headers: HeaderMap,
5530    body: axum::body::Bytes,
5531) -> Response {
5532    // 1. The endpoint is OFF unless a bot secret is configured.
5533    let bot_secret = match state.config.bot_secret.as_deref() {
5534        Some(s) => s,
5535        None => {
5536            warn!(
5537                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5538            );
5539            return (
5540                StatusCode::SERVICE_UNAVAILABLE,
5541                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5542            )
5543                .into_response();
5544        }
5545    };
5546
5547    // 2. Constant-time bearer check on the X-Bot-Secret header.
5548    let presented = headers
5549        .get("x-bot-secret")
5550        .and_then(|v| v.to_str().ok())
5551        .unwrap_or("");
5552    if !bot_secret_matches(presented, bot_secret) {
5553        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5554        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5555    }
5556
5557    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
5558    // (legacy caller) parses to an all-None request; a malformed body is a 400.
5559    let req: BotClaimRequest = if body.is_empty() {
5560        BotClaimRequest::default()
5561    } else {
5562        match serde_json::from_slice(&body) {
5563            Ok(r) => r,
5564            Err(err) => {
5565                warn!(%err, "POST /bot/claims: bad JSON body");
5566                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5567            }
5568        }
5569    };
5570    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5571
5572    // 3. Server-side DID idempotency (only when a DID was supplied):
5573    if let Some(did) = follower_did {
5574        // 3a. Already seated → tell the bot to post nothing.
5575        match store::has_beta_access(&state.db, did).await {
5576            Ok(true) => {
5577                info!("bot mint: DID already holds beta access; already_seated");
5578                return bot_claim_json(BotClaimResponse {
5579                    status: "already_seated",
5580                    code: String::new(),
5581                    token: String::new(),
5582                    url: String::new(),
5583                });
5584            }
5585            Ok(false) => {}
5586            Err(err) => {
5587                // Fail closed: a DB error must not fall through to a fresh mint.
5588                warn!(%err, "bot mint: has_beta_access failed");
5589                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5590            }
5591        }
5592        // 3b. Outstanding active claim for this DID → return the SAME code (no
5593        // second mint). This is what survives a bot-host state loss.
5594        match store::find_active_code_for_did(&state.db, did).await {
5595            Ok(Some(code)) => {
5596                info!("bot mint: existing outstanding claim for DID; returning same code");
5597                let token = sign_claim_token(&code, &state.config.cookie_secret);
5598                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5599                return bot_claim_json(BotClaimResponse {
5600                    status: "existing",
5601                    code,
5602                    token,
5603                    url,
5604                });
5605            }
5606            Ok(None) => {}
5607            Err(err) => {
5608                warn!(%err, "bot mint: find_active_code_for_did failed");
5609                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5610            }
5611        }
5612    }
5613
5614    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
5615    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
5616    let granted = match store::count_beta_access(&state.db).await {
5617        Ok(n) => n,
5618        Err(err) => {
5619            warn!(%err, "bot mint: count_beta_access failed; failing closed");
5620            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5621        }
5622    };
5623    let outstanding = match store::count_active_codes(&state.db).await {
5624        Ok(n) => n,
5625        Err(err) => {
5626            warn!(%err, "bot mint: count_active_codes failed; failing closed");
5627            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5628        }
5629    };
5630    if granted + outstanding >= state.config.beta_cap {
5631        info!(
5632            granted,
5633            outstanding,
5634            cap = state.config.beta_cap,
5635            "bot mint refused: at capacity"
5636        );
5637        return (
5638            StatusCode::CONFLICT,
5639            [(header::CONTENT_TYPE, "application/json")],
5640            "{\"error\":\"full\"}\n",
5641        )
5642            .into_response();
5643    }
5644
5645    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
5646    //    so a re-request for the same DID returns THIS code idempotently.
5647    let bot_did = state
5648        .config
5649        .admin_seed_dids()
5650        .first()
5651        .cloned()
5652        .unwrap_or_else(|| "did:bot:featherreader".to_string());
5653    let minted = match follower_did {
5654        Some(did) => {
5655            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5656        }
5657        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5658    };
5659    let code = match minted {
5660        Ok(c) => c,
5661        // S4: the dedupe check (3b) and this mint are separate statements, so two
5662        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
5663        // The partial unique index `idx_invite_codes_intended_active` makes the
5664        // loser's INSERT fail (only one active row per intended DID), which
5665        // surfaces here as a conflict. Recover by returning the winner's existing
5666        // code (same shape as the 3b idempotent path) instead of a 500.
5667        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5668            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5669                Ok(Some(code)) => {
5670                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
5671                    let token = sign_claim_token(&code, &state.config.cookie_secret);
5672                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5673                    return bot_claim_json(BotClaimResponse {
5674                        status: "existing",
5675                        code,
5676                        token,
5677                        url,
5678                    });
5679                }
5680                // The winner's row vanished between the conflict and this lookup
5681                // (redeemed/expired/purged in the gap) — nothing to hand back.
5682                // Fail closed rather than silently mint past the just-hit guard.
5683                Ok(None) => {
5684                    warn!("bot mint: conflict but no active code found on recovery");
5685                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5686                }
5687                Err(err) => {
5688                    warn!(%err, "bot mint: recovery lookup after conflict failed");
5689                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5690                }
5691            }
5692        }
5693        Err(err) => {
5694            warn!(%err, "bot mint_code failed");
5695            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5696        }
5697    };
5698    let token = sign_claim_token(&code, &state.config.cookie_secret);
5699    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5700    info!("bot minted a claim code + token");
5701
5702    bot_claim_json(BotClaimResponse {
5703        status: "minted",
5704        code,
5705        token,
5706        url,
5707    })
5708}
5709
5710/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
5711/// `500` if serialization somehow fails).
5712fn bot_claim_json(resp: BotClaimResponse) -> Response {
5713    match serde_json::to_string(&resp) {
5714        Ok(body) => (
5715            StatusCode::OK,
5716            [(header::CONTENT_TYPE, "application/json")],
5717            body,
5718        )
5719            .into_response(),
5720        Err(err) => {
5721            warn!(%err, "serializing bot claim response failed");
5722            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5723        }
5724    }
5725}
5726
5727/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
5728/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
5729/// by the HMAC checks so there is one comparator to audit; a length mismatch
5730/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
5731fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5732    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5733}
5734
5735// ---------------------------------------------------------------------------
5736// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
5737// ---------------------------------------------------------------------------
5738
5739/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
5740/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
5741/// itself (base64url) rather than an opaque sid, since the code IS the reserved
5742/// intent the callback consumes.
5743fn sign_invite(code: &str, secret: &str) -> String {
5744    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5745}
5746
5747/// Verify + read the reserved invite code out of the request's invite cookie
5748/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
5749/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
5750/// authority on the code's live status.
5751fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5752    cookie::verify_value(headers, INVITE_COOKIE, secret)
5753}
5754
5755/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
5756/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
5757/// cookie value and vice-versa.
5758const CLAIM_TOKEN_LABEL: &str = "claim-token";
5759
5760/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
5761/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
5762///
5763/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
5764/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
5765/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
5766/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
5767/// code won't verify), the wrapped code is single-use (redeem flips
5768/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
5769/// token one self-contained string needing no server-side token table; it does
5770/// NOT hide the code.
5771fn sign_claim_token(code: &str, secret: &str) -> String {
5772    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5773}
5774
5775/// Verify a claim token and return the invite code it wraps (`None` on a tampered
5776/// / forged / malformed token). The code's live status (active/unexpired/seat
5777/// free) is re-checked by `preflight_code`; this only proves the token was minted
5778/// by this instance.
5779fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5780    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5781}
5782
5783/// Clear the invite cookie on a response (after a successful bind, or when the
5784/// reservation turned out to be stale).
5785fn clear_invite_cookie(resp: &mut Response) {
5786    set_cookie(
5787        resp,
5788        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5789    );
5790}
5791
5792// ---------------------------------------------------------------------------
5793// OPML import + export
5794// ---------------------------------------------------------------------------
5795
5796/// `POST /opml` — import subscriptions from an OPML document.
5797///
5798/// Accepts either a multipart file upload (field `file`) or a pasted textarea
5799/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
5800/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
5801/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
5802/// `applyWrites` round-trip per 200 feeds). Feeds are also upserted into the local cache so
5803/// they show immediately; polling is left to the background poller.
5804async fn import_opml(
5805    State(state): State<AppState>,
5806    headers: HeaderMap,
5807    mut multipart: Multipart,
5808) -> Result<Response, WebError> {
5809    let did = match current_did(&state, &headers).await {
5810        Some(d) => d,
5811        None => return Ok(Redirect::to("/login").into_response()),
5812    };
5813    let pool = &state.db;
5814
5815    // Collect the OPML text from whichever field carried it. Multipart errors
5816    // are mapped to their axum-native response so that an over-cap upload (the
5817    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
5818    // `413 Payload Too Large` rather than being swallowed by the blanket
5819    // `WebError` → `500` conversion.
5820    let mut opml_text = String::new();
5821    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5822        let name = field.name().unwrap_or("").to_string();
5823        if name == "opml" || name == "file" {
5824            let bytes = field.bytes().await.map_err(multipart_response)?;
5825            if !bytes.is_empty() {
5826                opml_text = String::from_utf8_lossy(&bytes).into_owned();
5827                if name == "file" {
5828                    break;
5829                }
5830            }
5831        }
5832    }
5833
5834    // A parse FAILURE and an empty-but-valid file are different things, and
5835    // `unwrap_or_default` collapsed them: a malformed export was reported to the
5836    // reader as "No feeds found in that OPML", which sends them looking at their
5837    // old reader for feeds that are right there in the file.
5838    let feeds =
5839        match opml::parse_opml(&opml_text) {
5840            Ok(feeds) => feeds,
5841            Err(err) => {
5842                warn!(%err, %did, "OPML import could not parse the uploaded file");
5843                return Ok(Redirect::to(&format!(
5844                "/?flash={}",
5845                qenc("That file could not be read as OPML. Export it again from your other reader?")
5846            ))
5847                .into_response());
5848            }
5849        };
5850    if feeds.is_empty() {
5851        info!(%did, "OPML import found no feeds");
5852        return Ok(
5853            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5854                .into_response(),
5855        );
5856    }
5857
5858    // Create any named folders first, mapping folder name → at:// URI so
5859    // subscriptions can reference them.
5860    let now = now_rfc3339();
5861    let mut folder_uris: std::collections::HashMap<String, String> =
5862        std::collections::HashMap::new();
5863    // Reuse existing folders where the name already exists.
5864    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5865        for (rkey, folder) in existing {
5866            folder_uris
5867                .entry(folder.name.clone())
5868                .or_insert_with(|| folder_uri(&did, &rkey));
5869        }
5870    }
5871    let mut wanted_folders: Vec<String> = feeds
5872        .iter()
5873        .filter_map(|f| f.folder.clone())
5874        .filter(|n| !n.is_empty())
5875        .collect();
5876    wanted_folders.sort();
5877    wanted_folders.dedup();
5878    for name in wanted_folders {
5879        if folder_uris.contains_key(&name) {
5880            continue;
5881        }
5882        let folder = Folder::new(name.clone(), now.clone());
5883        match state.repo().add_folder(&did, &folder).await {
5884            Ok(rkey) => {
5885                folder_uris.insert(name, folder_uri(&did, &rkey));
5886            }
5887            Err(err) => warn!(%err, %did, "OPML folder create failed"),
5888        }
5889    }
5890
5891    // Build one subscription record per PUBLIC feed + upsert the local cache row.
5892    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5893    // and reported back to the user — the same public-feeds-only stance as the
5894    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5895    // token onto the public network either.
5896    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5897    // the remaining headroom (cap − existing) once; public feeds beyond it are
5898    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5899    let sub_cap = state.config.max_subs_per_did;
5900    let mut headroom: Option<i64> = if sub_cap > 0 {
5901        let existing = store::count_subscriptions_for_did(pool, &did)
5902            .await
5903            .unwrap_or(0);
5904        Some((sub_cap - existing).max(0))
5905    } else {
5906        None
5907    };
5908    let mut trimmed_over_cap: usize = 0;
5909
5910    // Global feeds ceiling: an OPML import must not blow past the shared cache
5911    // ceiling any more than the single-add path may. Seed the remaining global
5912    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5913    // not already cached) consumes it. Existing/duplicate URLs add no row and
5914    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5915    // `<= 0` disables the ceiling.
5916    let feeds_cap = state.config.max_feeds_global;
5917    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5918        let existing = store::count_feeds(pool).await.unwrap_or(0);
5919        Some((feeds_cap - existing).max(0))
5920    } else {
5921        None
5922    };
5923    let mut trimmed_over_global: usize = 0;
5924
5925    let mut subs = Vec::with_capacity(feeds.len());
5926    let mut skipped_private: Vec<String> = Vec::new();
5927    // Imported into the PDS but not cached locally, so not pollable until the
5928    // next import touches them. Counted rather than only logged — see below.
5929    let mut uncached: usize = 0;
5930    // Entries this instance cannot store at all (an `at://` publication with
5931    // the flag off, an unsupported scheme). Counted, because the `continue`
5932    // below used to increment nothing while the privacy branch beside it
5933    // produced a label — so an OPML from a standard.site-enabled instance
5934    // imported "successfully" with entries missing and no reason given.
5935    let mut skipped_unsupported: usize = 0;
5936    for f in &feeds {
5937        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5938        // ever parsed it — the single-add path can't reach here because
5939        // `resolve_feed_url` must parse AND successfully fetch first. So
5940        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5941        // cached, and published as records to the user's PUBLIC repo. Note that
5942        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5943        // it returns `Public` for anything unparseable by design.
5944        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5945            info!(
5946                %did,
5947                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5948            );
5949            skipped_unsupported += 1;
5950            continue;
5951        }
5952        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5953            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5954            // Report by title where we have one, else the (public-safe) host.
5955            let label = f
5956                .title
5957                .clone()
5958                .filter(|t| !t.trim().is_empty())
5959                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5960            skipped_private.push(label);
5961            continue;
5962        }
5963
5964        // Over-cap: stop importing once headroom is exhausted (count the rest so
5965        // we can tell the user how many were dropped).
5966        if let Some(h) = headroom.as_mut() {
5967            if *h <= 0 {
5968                trimmed_over_cap += 1;
5969                continue;
5970            }
5971        }
5972
5973        // Global ceiling: a brand-new feed URL consumes global headroom. Once
5974        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5975        // free — they add no row). Checked before decrementing the per-DID
5976        // headroom so a dropped feed doesn't burn the caller's own quota.
5977        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5978            Ok(existing) => existing.is_none(),
5979            // On a lookup error, treat as existing (don't consume global
5980            // headroom) but still allow the upsert to proceed.
5981            Err(err) => {
5982                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5983                false
5984            }
5985        };
5986        if is_new {
5987            if let Some(g) = global_headroom.as_mut() {
5988                if *g <= 0 {
5989                    trimmed_over_global += 1;
5990                    continue;
5991                }
5992                *g -= 1;
5993            }
5994        }
5995
5996        // Passed both caps: consume the per-DID headroom now that the feed is
5997        // actually being imported.
5998        if let Some(h) = headroom.as_mut() {
5999            *h -= 1;
6000        }
6001
6002        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
6003        sub.title = f.title.clone();
6004        sub.site_url = f.site_url.clone();
6005        sub.folder = f
6006            .folder
6007            .as_ref()
6008            .and_then(|name| folder_uris.get(name).cloned());
6009        subs.push(sub);
6010        // Same support ticket as the single-add path: no `feeds` row means the
6011        // poller never selects this subscription, so the import looks like it
6012        // worked and the feed silently never updates. Counted as well as logged,
6013        // because one line per feed in a 200-feed import is not something anyone
6014        // reads — the count goes to the reader.
6015        if let Err(err) = store::upsert_feed(
6016            pool,
6017            &store::NewFeed {
6018                url: f.feed_url.clone(),
6019                title: f.title.clone(),
6020                site_url: f.site_url.clone(),
6021                ..Default::default()
6022            },
6023        )
6024        .await
6025        {
6026            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
6027                                                  it will not be polled");
6028            uncached += 1;
6029        }
6030    }
6031
6032    // **A failed PDS write is not an import.**
6033    //
6034    // The subscriptions live in the reader's repo; a local `feeds` row is just a
6035    // poller hint. This used to `warn!` and then report "Imported N feeds"
6036    // regardless, so a total failure read as a total success — and the reader
6037    // would only discover otherwise on their next visit, with an empty sidebar.
6038    //
6039    // **And a part-landed write is not a failed one.** The batch goes out in
6040    // calls of at most 200 (#240: the reference PDS refuses more), sent in
6041    // order and stopped at the first failure, so what landed is a prefix of
6042    // `subs` and the error says how long. Saying "nothing was imported" after
6043    // the first 200 of 450 landed would send the reader to import the file
6044    // again, which adds those 200 a second time. Nothing local needs undoing
6045    // either way: the reader's `sub_ref` projection is rebuilt from the repo on
6046    // the next read, and a cached `feeds` row with no subscriber is the same
6047    // poller hint the total-failure path has always left behind.
6048    let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
6049        Ok(rkeys) => {
6050            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
6051            rkeys.len()
6052        }
6053        Err(err) => {
6054            let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
6055            warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
6056            landed
6057        }
6058    };
6059    if landed == 0 && !subs.is_empty() {
6060        return Ok(Redirect::to(&format!(
6061            "/?flash={}",
6062            qenc(
6063                "Could not save those subscriptions to your PDS, so nothing was imported. \
6064                 Try again in a moment."
6065            )
6066        ))
6067        .into_response());
6068    }
6069
6070    // Report the import count, plus any private/paid feeds skipped as unsupported.
6071    let mut flash = if landed < subs.len() {
6072        format!(
6073            "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
6074             other {} may not have been saved. Importing the same file again would add the first \
6075             {landed} a second time",
6076            subs.len(),
6077            subs.len() - landed
6078        )
6079    } else {
6080        format!("Imported {} feeds", subs.len())
6081    };
6082    if uncached > 0 {
6083        flash.push_str(&format!(
6084            ". {uncached} of them could not be cached locally and may not update until the next import."
6085        ));
6086    }
6087    if trimmed_over_cap > 0 {
6088        flash.push_str(&format!(
6089            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
6090        ));
6091    }
6092    if trimmed_over_global > 0 {
6093        flash.push_str(&format!(
6094            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
6095        ));
6096    }
6097    if !skipped_private.is_empty() {
6098        flash.push_str(&format!(
6099            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
6100            skipped_private.len(),
6101            skipped_private.join(", ")
6102        ));
6103    }
6104    if skipped_unsupported > 0 {
6105        // By count only — the URL is whatever the file said, and unlike the
6106        // private branch there is no public-safe label to give.
6107        flash.push_str(&format!(
6108            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
6109        ));
6110    }
6111    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
6112}
6113
6114/// A public-safe label for a skipped private feed when it has no title: just the
6115/// host, so we never echo the secret-bearing path/query back to the user.
6116fn private_feed_label(url: &str) -> String {
6117    url::Url::parse(url)
6118        .ok()
6119        .and_then(|u| u.host_str().map(str::to_string))
6120        .unwrap_or_else(|| "a private feed".to_string())
6121}
6122
6123/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
6124async fn export_opml(
6125    State(state): State<AppState>,
6126    headers: HeaderMap,
6127) -> Result<Response, WebError> {
6128    let did = match current_did(&state, &headers).await {
6129        Some(d) => d,
6130        None => return Ok(Redirect::to("/login").into_response()),
6131    };
6132
6133    // **An export must never be silently empty.** `unwrap_or_default` here turned
6134    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
6135    // backup, blank, at exactly the moment they reached for it. That was survivable
6136    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
6137    // this is the one caller that converts a refusal into data loss, and it is also
6138    // the recovery route the changelog points a locked-out reader at.
6139    let subs = match state.repo().list_subscriptions_sorted(&did).await {
6140        Ok(subs) => subs,
6141        Err(err) => {
6142            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
6143            return Ok(Redirect::to(&format!(
6144                "/manage?flash={}",
6145                qenc(EXPORT_INCOMPLETE_REFUSAL)
6146            ))
6147            .into_response());
6148        }
6149    };
6150    let folders = match state.repo().list_folders_sorted(&did).await {
6151        Ok(folders) => folders,
6152        Err(err) => {
6153            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
6154            return Ok(Redirect::to(&format!(
6155                "/manage?flash={}",
6156                qenc(EXPORT_INCOMPLETE_REFUSAL)
6157            ))
6158            .into_response());
6159        }
6160    };
6161    // The exporter matches a subscription's `folder` at-uri against the folder's
6162    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
6163    let folder_pairs: Vec<(String, Folder)> = folders
6164        .into_iter()
6165        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
6166        .collect();
6167
6168    let body = opml::to_opml(&subs, &folder_pairs);
6169    let mut resp = (StatusCode::OK, body).into_response();
6170    resp.headers_mut().insert(
6171        header::CONTENT_TYPE,
6172        "text/x-opml; charset=utf-8".parse().unwrap(),
6173    );
6174    resp.headers_mut().insert(
6175        header::CONTENT_DISPOSITION,
6176        "attachment; filename=\"featherreader-subscriptions.opml\""
6177            .parse()
6178            .unwrap(),
6179    );
6180    Ok(resp)
6181}
6182
6183// ---------------------------------------------------------------------------
6184// Signed session cookie (HMAC-SHA256, dependency-free)
6185// ---------------------------------------------------------------------------
6186
6187/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
6188fn set_cookie(resp: &mut Response, cookie: &str) {
6189    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
6190        resp.headers_mut()
6191            .append(axum::http::header::SET_COOKIE, value);
6192    }
6193}
6194
6195/// Whether the request came from htmx (the `HX-Request` header).
6196fn is_htmx(headers: &HeaderMap) -> bool {
6197    headers
6198        .get("HX-Request")
6199        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
6200}
6201
6202/// Whether a mark-read / star request originated from the single-entry READER
6203/// (as opposed to the list view). The reader's forms tag themselves with
6204/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
6205/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
6206/// isn't in the DOM), the list gets the row (`entry_row.html`).
6207fn is_reader_request(headers: &HeaderMap) -> bool {
6208    headers
6209        .get("X-FR-Reader")
6210        .is_some_and(|v| v.as_bytes() == b"1")
6211}
6212
6213/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
6214/// server-minted **session id** (never the DID — so the cookie can't be forged
6215/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
6216/// server-side session id).
6217mod cookie {
6218    use super::{HeaderMap, SESSION_COOKIE};
6219
6220    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
6221    pub fn sign_session(sid: &str, secret: &str) -> String {
6222        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
6223    }
6224
6225    /// Verify the request's session cookie and return the session id it carries.
6226    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
6227        verify_value(headers, SESSION_COOKIE, secret)
6228    }
6229
6230    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
6231    /// value`), so a signature minted for one cookie can't verify under another —
6232    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
6233    /// The NUL separator can't appear in a cookie name, so the encoding is
6234    /// unambiguous.
6235    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
6236        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
6237        msg.extend_from_slice(name.as_bytes());
6238        msg.push(0);
6239        msg.extend_from_slice(value.as_bytes());
6240        msg
6241    }
6242
6243    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
6244    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
6245    /// generic form behind both the session cookie and the short-lived invite
6246    /// cookie; domain-separating by name keeps a signature valid only for the
6247    /// cookie it was minted for.
6248    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
6249        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
6250        let b64 = b64url_encode(value.as_bytes());
6251        format!(
6252            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
6253        )
6254    }
6255
6256    /// Verify + read a value out of the named signed cookie (`None` on absent /
6257    /// tampered / forged / cross-cookie). The generic form behind both readers.
6258    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
6259        let raw = cookie_value(headers, name)?;
6260        let (b64, sig) = raw.split_once('.')?;
6261        let bytes = b64url_decode(b64)?;
6262        let value = String::from_utf8(bytes).ok()?;
6263        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
6264        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6265            Some(value)
6266        } else {
6267            None
6268        }
6269    }
6270
6271    /// Sign an arbitrary `value` into an opaque, URL-safe token string
6272    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
6273    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
6274    /// URL query param (the bot's claim link). `label` domain-separates it from
6275    /// the cookies so a token can't be replayed as a cookie value.
6276    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
6277        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
6278        let b64 = b64url_encode(value.as_bytes());
6279        format!("{b64}.{sig}")
6280    }
6281
6282    /// Verify a token minted by [`sign_token`] and return the wrapped value
6283    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
6284    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
6285        let (b64, sig) = token.split_once('.')?;
6286        let bytes = b64url_decode(b64)?;
6287        let value = String::from_utf8(bytes).ok()?;
6288        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
6289        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6290            Some(value)
6291        } else {
6292            None
6293        }
6294    }
6295
6296    /// Pull one cookie value out of the `Cookie` request header.
6297    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
6298        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
6299        for part in header.split(';') {
6300            let part = part.trim();
6301            if let Some((k, v)) = part.split_once('=') {
6302                if k == name {
6303                    return Some(v.to_string());
6304                }
6305            }
6306        }
6307        None
6308    }
6309
6310    /// Constant-time byte comparison (avoid signature-timing leaks). Public
6311    /// within the module so the bot-secret bearer check reuses the exact same
6312    /// comparator as the cookie/token HMAC checks (one implementation to audit).
6313    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
6314        if a.len() != b.len() {
6315            return false;
6316        }
6317        let mut diff = 0u8;
6318        for (x, y) in a.iter().zip(b.iter()) {
6319            diff |= x ^ y;
6320        }
6321        diff == 0
6322    }
6323
6324    // -- URL-safe base64 (no padding), std-only --------------------------------
6325
6326    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6327
6328    fn b64url_encode(input: &[u8]) -> String {
6329        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
6330        for chunk in input.chunks(3) {
6331            let b = [
6332                chunk[0],
6333                *chunk.get(1).unwrap_or(&0),
6334                *chunk.get(2).unwrap_or(&0),
6335            ];
6336            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
6337            out.push(B64[((n >> 18) & 63) as usize] as char);
6338            out.push(B64[((n >> 12) & 63) as usize] as char);
6339            if chunk.len() > 1 {
6340                out.push(B64[((n >> 6) & 63) as usize] as char);
6341            }
6342            if chunk.len() > 2 {
6343                out.push(B64[(n & 63) as usize] as char);
6344            }
6345        }
6346        out
6347    }
6348
6349    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
6350        fn val(c: u8) -> Option<u32> {
6351            match c {
6352                b'A'..=b'Z' => Some((c - b'A') as u32),
6353                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6354                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6355                b'-' => Some(62),
6356                b'_' => Some(63),
6357                _ => None,
6358            }
6359        }
6360        let bytes = input.as_bytes();
6361        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
6362        for chunk in bytes.chunks(4) {
6363            let mut n = 0u32;
6364            let mut valid = 0;
6365            for (i, &c) in chunk.iter().enumerate() {
6366                n |= val(c)? << (18 - 6 * i);
6367                valid += 1;
6368            }
6369            out.push((n >> 16) as u8);
6370            if valid > 2 {
6371                out.push((n >> 8) as u8);
6372            }
6373            if valid > 3 {
6374                out.push(n as u8);
6375            }
6376        }
6377        Some(out)
6378    }
6379
6380    // -- HMAC-SHA256, std-only -------------------------------------------------
6381
6382    /// HMAC-SHA256(key, msg) as lowercase hex.
6383    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
6384        const BLOCK: usize = 64;
6385        let mut k = [0u8; BLOCK];
6386        if key.len() > BLOCK {
6387            let d = sha256(key);
6388            k[..32].copy_from_slice(&d);
6389        } else {
6390            k[..key.len()].copy_from_slice(key);
6391        }
6392        let mut ipad = [0x36u8; BLOCK];
6393        let mut opad = [0x5cu8; BLOCK];
6394        for i in 0..BLOCK {
6395            ipad[i] ^= k[i];
6396            opad[i] ^= k[i];
6397        }
6398        let mut inner = Vec::with_capacity(BLOCK + msg.len());
6399        inner.extend_from_slice(&ipad);
6400        inner.extend_from_slice(msg);
6401        let inner_hash = sha256(&inner);
6402        let mut outer = Vec::with_capacity(BLOCK + 32);
6403        outer.extend_from_slice(&opad);
6404        outer.extend_from_slice(&inner_hash);
6405        let mac = sha256(&outer);
6406        let mut hex = String::with_capacity(64);
6407        for b in mac {
6408            hex.push_str(&format!("{b:02x}"));
6409        }
6410        hex
6411    }
6412
6413    /// SHA-256 (FIPS 180-4), std-only.
6414    fn sha256(data: &[u8]) -> [u8; 32] {
6415        const K: [u32; 64] = [
6416            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
6417            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
6418            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
6419            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
6420            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
6421            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
6422            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
6423            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
6424            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
6425            0xc67178f2,
6426        ];
6427        let mut h: [u32; 8] = [
6428            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
6429            0x5be0cd19,
6430        ];
6431
6432        let bit_len = (data.len() as u64) * 8;
6433        let mut msg = data.to_vec();
6434        msg.push(0x80);
6435        while msg.len() % 64 != 56 {
6436            msg.push(0);
6437        }
6438        msg.extend_from_slice(&bit_len.to_be_bytes());
6439
6440        for block in msg.chunks(64) {
6441            let mut w = [0u32; 64];
6442            for i in 0..16 {
6443                w[i] = u32::from_be_bytes([
6444                    block[i * 4],
6445                    block[i * 4 + 1],
6446                    block[i * 4 + 2],
6447                    block[i * 4 + 3],
6448                ]);
6449            }
6450            for i in 16..64 {
6451                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
6452                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
6453                w[i] = w[i - 16]
6454                    .wrapping_add(s0)
6455                    .wrapping_add(w[i - 7])
6456                    .wrapping_add(s1);
6457            }
6458            let mut a = h;
6459            for i in 0..64 {
6460                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
6461                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
6462                let t1 = a[7]
6463                    .wrapping_add(s1)
6464                    .wrapping_add(ch)
6465                    .wrapping_add(K[i])
6466                    .wrapping_add(w[i]);
6467                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
6468                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
6469                let t2 = s0.wrapping_add(maj);
6470                a[7] = a[6];
6471                a[6] = a[5];
6472                a[5] = a[4];
6473                a[4] = a[3].wrapping_add(t1);
6474                a[3] = a[2];
6475                a[2] = a[1];
6476                a[1] = a[0];
6477                a[0] = t1.wrapping_add(t2);
6478            }
6479            for i in 0..8 {
6480                h[i] = h[i].wrapping_add(a[i]);
6481            }
6482        }
6483
6484        let mut out = [0u8; 32];
6485        for (i, word) in h.iter().enumerate() {
6486            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6487        }
6488        out
6489    }
6490
6491    #[cfg(test)]
6492    mod tests {
6493        use super::*;
6494
6495        #[test]
6496        fn sha256_known_vector() {
6497            let d = sha256(b"abc");
6498            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6499            assert_eq!(
6500                hex,
6501                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6502            );
6503        }
6504
6505        #[test]
6506        fn hmac_known_vector() {
6507            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6508            assert_eq!(
6509                mac,
6510                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6511            );
6512        }
6513
6514        #[test]
6515        fn sign_verify_round_trips() {
6516            let secret = "test-secret";
6517            let sid = "9f2c-opaque-session-id";
6518            let cookie = sign_session(sid, secret);
6519            let pair = cookie.split(';').next().unwrap().to_string();
6520            let mut headers = HeaderMap::new();
6521            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6522            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6523            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
6524            assert!(verify_session(&headers, "other-secret").is_none());
6525        }
6526
6527        #[test]
6528        fn forged_and_tampered_cookies_are_rejected() {
6529            let secret = "test-secret";
6530
6531            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
6532            //    the secret, so an arbitrary signature must not verify.
6533            let forged = format!(
6534                "{SESSION_COOKIE}={}.{}",
6535                b64url_encode(b"attacker-chosen-sid"),
6536                "deadbeef".repeat(8) // 64 hex chars, wrong sig
6537            );
6538            let mut headers = HeaderMap::new();
6539            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6540            assert!(verify_session(&headers, secret).is_none());
6541
6542            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
6543            //    keeping the original signature — must not verify.
6544            let cookie = sign_session("real-sid", secret);
6545            let pair = cookie.split(';').next().unwrap();
6546            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6547            let tampered = format!(
6548                "{SESSION_COOKIE}={}.{}",
6549                b64url_encode(b"different-sid"),
6550                sig
6551            );
6552            let mut headers2 = HeaderMap::new();
6553            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6554            assert!(verify_session(&headers2, secret).is_none());
6555        }
6556
6557        #[test]
6558        fn b64url_round_trips() {
6559            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6560                let enc = b64url_encode(s.as_bytes());
6561                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6562            }
6563        }
6564    }
6565}
6566
6567// ---------------------------------------------------------------------------
6568// Small store helpers local to the web layer
6569// ---------------------------------------------------------------------------
6570
6571/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
6572///
6573/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
6574/// `did` does not subscribe to its feed. This is the per-DID read gate for the
6575/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
6576/// deduped by URL, but no DID can read another DID's cached article.
6577///
6578/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
6579/// that renders `content_html`, and it fetches exactly one row. The list views
6580/// go through [`store::list_entries`], which is both paged and body-free — see
6581/// [`store::EntryListRow`] for why they had to stop sharing this projection.
6582async fn get_entry_by_id(
6583    pool: &store::Pool,
6584    did: &str,
6585    id: i64,
6586) -> anyhow::Result<Option<store::Entry>> {
6587    let entry = sqlx::query_as::<_, store::Entry>(
6588        r#"
6589        SELECT e.* FROM entries e
6590        WHERE e.id = ?2
6591          AND EXISTS (
6592              SELECT 1 FROM sub_ref sr
6593              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6594          )
6595        "#,
6596    )
6597    .bind(did)
6598    .bind(id)
6599    .fetch_optional(pool)
6600    .await?;
6601    Ok(entry)
6602}
6603
6604/// Whether `entry_id` is marked read for `did` (absent state row = unread).
6605async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6606    let read: Option<bool> =
6607        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6608            .bind(did)
6609            .bind(entry_id)
6610            .fetch_optional(pool)
6611            .await?
6612            .flatten();
6613    Ok(read.unwrap_or(false))
6614}
6615
6616/// Whether `entry_id` is starred for `did` (absent state row = not starred).
6617async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6618    let starred: Option<bool> =
6619        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6620            .bind(did)
6621            .bind(entry_id)
6622            .fetch_optional(pool)
6623            .await?
6624            .flatten();
6625    Ok(starred.unwrap_or(false))
6626}
6627
6628/// Feed display title for one entry's feed id (via a single lookup).
6629async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6630    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6631        .bind(feed_id)
6632        .fetch_optional(pool)
6633        .await
6634    {
6635        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6636        _ => String::new(),
6637    }
6638}
6639
6640/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
6641/// be forced (mark-read path) or looked up (`None` — star path).
6642async fn build_entry_row(
6643    pool: &store::Pool,
6644    did: &str,
6645    id: i64,
6646    read: Option<bool>,
6647) -> anyhow::Result<Option<EntryRow>> {
6648    let entry = match get_entry_by_id(pool, did, id).await? {
6649        Some(e) => e,
6650        None => return Ok(None),
6651    };
6652    let read = match read {
6653        Some(r) => r,
6654        None => entry_is_read(pool, did, id).await?,
6655    };
6656    let starred = entry_is_starred(pool, did, id).await?;
6657    Ok(Some(EntryRow {
6658        id: entry.id,
6659        title: entry
6660            .title
6661            .clone()
6662            .filter(|t| !t.trim().is_empty())
6663            .unwrap_or_else(|| "(untitled)".to_string()),
6664        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6665        published: display_date(entry.published.as_deref()),
6666        read,
6667        starred,
6668        link: SafeLink::entry(id, ""),
6669        cached: true,
6670        rkey: String::new(),
6671    }))
6672}
6673
6674/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
6675fn now_rfc3339() -> String {
6676    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6677}
6678
6679#[cfg(test)]
6680mod tests {
6681    use super::*;
6682
6683    #[test]
6684    fn qenc_encodes_reserved() {
6685        assert_eq!(qenc("a b"), "a%20b");
6686        assert_eq!(
6687            qenc("https://example.com/feed.xml"),
6688            "https%3A%2F%2Fexample.com%2Ffeed.xml"
6689        );
6690        assert_eq!(
6691            qenc("at://did:plc:x/c/r"),
6692            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6693        );
6694        // Unreserved chars pass through untouched.
6695        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6696    }
6697
6698    #[test]
6699    fn folder_uri_shape() {
6700        assert_eq!(
6701            folder_uri("did:plc:abc", "3kfolder"),
6702            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6703        );
6704    }
6705
6706    // -- public-feeds-only: private/paid feeds are refused --------------------
6707
6708    #[test]
6709    fn private_feeds_are_classified_private_across_providers() {
6710        // The add + OPML paths both gate on this classifier; assert it flags a
6711        // spread of paid providers (newsletters + private podcasts) and the
6712        // generic credential-in-URL shapes.
6713        for url in [
6714            "https://author.substack.com/feed/private/deadbeefcafe1234",
6715            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6716            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6717            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6718            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6719            "https://user:pass@example.com/feed",
6720        ] {
6721            assert!(
6722                feed::classify_feed_privacy(url).is_private(),
6723                "expected private: {url}"
6724            );
6725        }
6726    }
6727
6728    #[test]
6729    fn public_feeds_stay_public() {
6730        for url in [
6731            "https://author.substack.com/feed",
6732            "https://wordpress.example.com/feed/",
6733            "https://example.com/rss.xml",
6734            "https://example.org/atom.xml",
6735            // YouTube channel/playlist RSS is fully public — must not false-block.
6736            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6737            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6738        ] {
6739            assert!(
6740                !feed::classify_feed_privacy(url).is_private(),
6741                "expected public: {url}"
6742            );
6743        }
6744    }
6745
6746    #[test]
6747    fn private_feed_label_is_public_safe_host_only() {
6748        // The OPML skip report must never echo the secret path/query, only the host.
6749        let label =
6750            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6751        assert_eq!(label, "author.substack.com");
6752        assert!(!label.contains("deadbeefcafe1234token"));
6753        assert!(!label.contains("/private/"));
6754        // An unparseable URL degrades to a generic label.
6755        assert_eq!(private_feed_label("not a url"), "a private feed");
6756    }
6757
6758    #[test]
6759    fn refusal_message_promises_nothing_stored() {
6760        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6761        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6762    }
6763
6764    #[test]
6765    fn scope_query_preserves_context() {
6766        let q = EntryQuery {
6767            feed: Some("https://example.com/feed.xml".to_string()),
6768            folder: None,
6769            view: Some("all".to_string()),
6770        };
6771        let s = scope_query(&q);
6772        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6773        assert!(s.contains("view=all"));
6774
6775        // Default view is omitted.
6776        let q2 = EntryQuery {
6777            feed: None,
6778            folder: None,
6779            view: Some("unread".to_string()),
6780        };
6781        assert_eq!(scope_query(&q2), "");
6782    }
6783
6784    // -- closed-beta invite gate + rate-limit + cache-control ------------------
6785
6786    use axum::body::Body;
6787    use axum::http::Request;
6788    use tower::ServiceExt; // for `oneshot`
6789
6790    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
6791    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
6792    /// can forge matching cookies.
6793    async fn test_state(allowed: &[&str]) -> AppState {
6794        let db = store::init_url("sqlite::memory:").await.unwrap();
6795        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6796        store::ensure_seed(&db, &dids).await.unwrap();
6797        let config = Config {
6798            allowed_dids: dids,
6799            cookie_secret: "test-cookie-secret-000".to_string(),
6800            beta_cap: 3,
6801            ..Config::default()
6802        };
6803        AppState::new(config, db).unwrap()
6804    }
6805
6806    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
6807    /// looked up in the registry, so create the session first).
6808    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6809        let sid = state.sessions.create(Session {
6810            did: did.to_string(),
6811            handle: handle.map(str::to_string),
6812        });
6813        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6814        sc.split(';').next().unwrap().to_string()
6815    }
6816
6817    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
6818    /// long time to accept distinct source IPs on two unauthenticated guarded
6819    /// routes.
6820    #[test]
6821    fn the_rate_limit_map_is_bounded() {
6822        let rl = RateLimiter::shared();
6823        let now = Instant::now();
6824        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6825            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
6826            // ordering below is well-defined.
6827            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6828            rl.check_at(ip, now + Duration::from_millis(i as u64));
6829        }
6830        let len = rl.inner.lock().unwrap().buckets.len();
6831        assert!(
6832            len <= MAX_RATE_BUCKETS,
6833            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6834        );
6835    }
6836
6837    /// Eviction must not hand a throttled attacker a fresh burst.
6838    ///
6839    /// The bound is LRU, so the one bucket an attacker can never evict is their
6840    /// own — it is the most recently touched thing in the map. If this inverted,
6841    /// the size cap would become a rate-limit bypass: spray addresses until the
6842    /// map overflows, then resume.
6843    #[test]
6844    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6845        let rl = RateLimiter::shared();
6846        let base = Instant::now();
6847        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6848        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
6849        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
6850        // millisecond step made the whole flood take a second, and the refill —
6851        // working correctly — then looked exactly like an eviction bypass.
6852        let at = |n: u64| base + Duration::from_nanos(n);
6853
6854        // Spend the burst. `RATE_BURST` allowed, then refused.
6855        for i in 0..(RATE_BURST as u64) {
6856            assert!(rl.check_at(attacker, at(i)));
6857        }
6858        assert!(
6859            !rl.check_at(attacker, at(RATE_BURST as u64)),
6860            "burst was not exhausted; the rest of this test proves nothing"
6861        );
6862
6863        // Now overflow the map from other addresses, interleaving the attacker
6864        // so their bucket stays hot — the realistic shape of the attack.
6865        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6866            let t = at(100 + i as u64 * 2);
6867            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6868            rl.check_at(ip, t);
6869            assert!(
6870                !rl.check_at(attacker, t),
6871                "the attacker got a token back after evictions at i={i}"
6872            );
6873        }
6874    }
6875
6876    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
6877    /// of the whole map on every guarded request, on one shared core.
6878    #[test]
6879    fn the_idle_sweep_does_not_run_on_every_request() {
6880        let rl = RateLimiter::shared();
6881        let start = Instant::now();
6882        let a: IpAddr = "198.51.100.1".parse().unwrap();
6883        let b: IpAddr = "198.51.100.2".parse().unwrap();
6884
6885        rl.check_at(a, start);
6886        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
6887        // the sweep interval has elapsed too, so this request does sweep it.
6888        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6889        assert!(
6890            !rl.inner.lock().unwrap().buckets.contains_key(&a),
6891            "an idle bucket survived a sweep that was due"
6892        );
6893
6894        // A second request moments later must NOT re-sweep — `b` is still there,
6895        // and the recorded sweep time must not have moved.
6896        let before = rl.inner.lock().unwrap().last_sweep;
6897        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6898        assert_eq!(
6899            rl.inner.lock().unwrap().last_sweep,
6900            before,
6901            "the sweep ran again within the interval"
6902        );
6903    }
6904
6905    #[test]
6906    fn rate_limited_paths_match_expected() {
6907        use axum::http::Method;
6908        assert!(is_rate_limited_path("/login", &Method::GET));
6909        assert!(is_rate_limited_path("/login", &Method::POST));
6910        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6911        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6912        assert!(is_rate_limited_path("/opml", &Method::POST));
6913        assert!(is_rate_limited_path("/read-all", &Method::POST));
6914        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6915        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6916        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6917        // Read-only navigation is NOT limited.
6918        assert!(!is_rate_limited_path("/", &Method::GET));
6919        assert!(!is_rate_limited_path("/about", &Method::GET));
6920        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6921        assert!(!is_rate_limited_path("/login", &Method::HEAD));
6922    }
6923
6924    #[test]
6925    fn rate_limiter_allows_burst_then_429s() {
6926        let rl = RateLimiter::shared();
6927        let ip: IpAddr = "203.0.113.7".parse().unwrap();
6928        // The full burst passes.
6929        for _ in 0..(RATE_BURST as usize) {
6930            assert!(rl.check(ip));
6931        }
6932        // The next one (no time elapsed → no refill) is rejected.
6933        assert!(!rl.check(ip));
6934        // A different IP has its own bucket.
6935        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6936        assert!(rl.check(ip2));
6937    }
6938
6939    #[test]
6940    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6941        // With NO trusted header configured, a client-supplied X-Forwarded-For
6942        // must be ignored entirely — the limiter keys on the real socket peer,
6943        // so an attacker can't mint a fresh bucket per forged XFF value.
6944        let mut h = HeaderMap::new();
6945        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6946        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6947        assert_eq!(
6948            client_ip(&h, Some(&sock), None),
6949            Some("203.0.113.55".parse().unwrap()),
6950            "spoofed XFF must not override the socket peer"
6951        );
6952    }
6953
6954    #[test]
6955    fn client_ip_uses_trusted_header_last_hop() {
6956        // With a trusted proxy header configured, the client IP comes from THAT
6957        // header (the proxy overwrites any client copy). On a comma list we take
6958        // the RIGHT-most hop — the one the trusted proxy appended — so a
6959        // client-forged left-most value is ignored.
6960        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6961
6962        let mut h = HeaderMap::new();
6963        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6964        assert_eq!(
6965            client_ip(&h, Some(&sock), Some("fly-client-ip")),
6966            Some("198.51.100.9".parse().unwrap())
6967        );
6968
6969        // Attacker prepends a forged hop; the trusted proxy appends the real one.
6970        let mut h2 = HeaderMap::new();
6971        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6972        assert_eq!(
6973            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6974            Some("198.51.100.9".parse().unwrap()),
6975            "must take the right-most (trusted) hop, not the forged left-most"
6976        );
6977
6978        // Trusted header absent → fall back to the socket peer.
6979        let h3 = HeaderMap::new();
6980        assert_eq!(
6981            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6982            Some("10.0.0.1".parse().unwrap())
6983        );
6984    }
6985
6986    #[test]
6987    fn invite_cookie_round_trips_and_rejects_tamper() {
6988        let secret = "test-cookie-secret-000";
6989        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6990        let pair = sc.split(';').next().unwrap();
6991        let mut h = HeaderMap::new();
6992        h.insert(header::COOKIE, pair.parse().unwrap());
6993        assert_eq!(
6994            invite_cookie_code(&h, secret).as_deref(),
6995            Some("FEATHER-ABCDWXYZ")
6996        );
6997        // Wrong secret → rejected.
6998        assert!(invite_cookie_code(&h, "other").is_none());
6999    }
7000
7001    #[tokio::test]
7002    async fn preflight_valid_expired_and_full() {
7003        let state = test_state(&["did:plc:admin"]).await;
7004        // A minted, active code preflights OK.
7005        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7006            .await
7007            .unwrap();
7008        assert!(preflight_code(&state, &code).await.is_ok());
7009
7010        // A code whose expiry is in the past preflights as Expired. (mint_code
7011        // clamps negative ttl to 0, so back-date the row directly for a
7012        // deterministic past expiry.)
7013        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
7014            .await
7015            .unwrap();
7016        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
7017            .bind(chrono::Utc::now().timestamp() - 3600)
7018            .bind(&expired)
7019            .execute(&state.db)
7020            .await
7021            .unwrap();
7022        assert_eq!(
7023            preflight_code(&state, &expired).await,
7024            Err(store::RedeemError::Expired)
7025        );
7026
7027        // Unknown code → NotFound.
7028        assert_eq!(
7029            preflight_code(&state, "FEATHER-NOPENOPE").await,
7030            Err(store::RedeemError::NotFound)
7031        );
7032
7033        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
7034        // must report CapacityFull.
7035        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7036            .await
7037            .unwrap();
7038        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7039            .await
7040            .unwrap();
7041        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7042        assert_eq!(
7043            preflight_code(&state, &code).await,
7044            Err(store::RedeemError::CapacityFull)
7045        );
7046    }
7047
7048    // -- Bot claim link + shared-secret mint ---------------------------------
7049
7050    /// A test state with a configured bot secret (so `/bot/claims` is live).
7051    async fn bot_state(bot_secret: &str) -> AppState {
7052        let db = store::init_url("sqlite::memory:").await.unwrap();
7053        store::ensure_seed(&db, &["did:plc:admin".to_string()])
7054            .await
7055            .unwrap();
7056        let config = Config {
7057            allowed_dids: vec!["did:plc:admin".to_string()],
7058            cookie_secret: "test-cookie-secret-000".to_string(),
7059            beta_cap: 3,
7060            bot_secret: Some(bot_secret.to_string()),
7061            public_url: "https://feather-reader.com".to_string(),
7062            ..Config::default()
7063        };
7064        AppState::new(config, db).unwrap()
7065    }
7066
7067    #[test]
7068    fn claim_token_round_trips_and_rejects_tamper() {
7069        let secret = "test-cookie-secret-000";
7070        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
7071        // No cookie framing — a bare URL-safe token.
7072        assert!(!token.contains(';'));
7073        assert_eq!(
7074            claim_token_code(&token, secret).as_deref(),
7075            Some("FEATHER-ABCDWXYZ")
7076        );
7077        // Wrong secret → rejected.
7078        assert!(claim_token_code(&token, "other").is_none());
7079        // Tampered token → rejected.
7080        let mut bad = token.clone();
7081        bad.push('x');
7082        assert!(claim_token_code(&bad, secret).is_none());
7083        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
7084        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
7085        // recover it WITHOUT the secret). The security is single-use + HMAC
7086        // integrity + rate-limit, not secrecy of the code. Assert the code half is
7087        // publicly decodable (a plain base64url decode, no secret involved).
7088        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
7089        assert_eq!(
7090            test_b64url_decode(b64).as_deref(),
7091            Some("FEATHER-ABCDWXYZ".as_bytes()),
7092            "the code half of the token is plain base64url, decodable by anyone"
7093        );
7094    }
7095
7096    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
7097    /// claim token's code half needs NO secret to recover (it is not confidential).
7098    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
7099        fn val(c: u8) -> Option<u32> {
7100            match c {
7101                b'A'..=b'Z' => Some((c - b'A') as u32),
7102                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
7103                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
7104                b'-' => Some(62),
7105                b'_' => Some(63),
7106                _ => None,
7107            }
7108        }
7109        let mut out = Vec::with_capacity(input.len() / 4 * 3);
7110        for chunk in input.as_bytes().chunks(4) {
7111            let mut n = 0u32;
7112            let mut bits = 0;
7113            for &c in chunk {
7114                n = (n << 6) | val(c)?;
7115                bits += 6;
7116            }
7117            let bytes = bits / 8;
7118            n <<= 24 - bits;
7119            for i in 0..bytes {
7120                out.push((n >> (16 - i * 8)) as u8);
7121            }
7122        }
7123        Some(out)
7124    }
7125
7126    #[tokio::test]
7127    async fn bot_mint_then_claim_grants_a_seat() {
7128        let state = bot_state("bot-secret-abcdef").await;
7129        let app = router(state.clone());
7130
7131        // 1. Mint a claim via the shared-secret endpoint.
7132        let resp = app
7133            .clone()
7134            .oneshot(
7135                Request::builder()
7136                    .method("POST")
7137                    .uri("/bot/claims")
7138                    .header("x-bot-secret", "bot-secret-abcdef")
7139                    .body(Body::empty())
7140                    .unwrap(),
7141            )
7142            .await
7143            .unwrap();
7144        assert_eq!(resp.status(), StatusCode::OK);
7145        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7146            .await
7147            .unwrap();
7148        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7149        let token = json["token"].as_str().unwrap().to_string();
7150        let url = json["url"].as_str().unwrap();
7151        assert!(url.starts_with("https://feather-reader.com/claim?t="));
7152        // The raw code is returned for the bot's records but not embedded in url.
7153        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
7154        assert!(!url.contains("FEATHER-"));
7155
7156        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
7157        let resp = app
7158            .clone()
7159            .oneshot(
7160                Request::builder()
7161                    .method("GET")
7162                    .uri(format!("/claim?t={}", qenc(&token)))
7163                    .body(Body::empty())
7164                    .unwrap(),
7165            )
7166            .await
7167            .unwrap();
7168        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7169        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7170        let set_cookie = resp
7171            .headers()
7172            .get(header::SET_COOKIE)
7173            .unwrap()
7174            .to_str()
7175            .unwrap();
7176        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
7177
7178        // 3. The reserved cookie carries the same code the token wrapped, and
7179        //    redeeming it (the callback's machinery) grants a seat.
7180        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
7181        let out = store::redeem_code(
7182            &state.db,
7183            &code,
7184            "did:plc:follower",
7185            None,
7186            state.config.beta_cap,
7187        )
7188        .await
7189        .unwrap();
7190        assert_eq!(out, Ok(()));
7191        assert!(store::has_beta_access(&state.db, "did:plc:follower")
7192            .await
7193            .unwrap());
7194    }
7195
7196    #[tokio::test]
7197    async fn claim_with_invalid_token_bounces() {
7198        let state = bot_state("bot-secret-abcdef").await;
7199        let app = router(state);
7200        let resp = app
7201            .oneshot(
7202                Request::builder()
7203                    .method("GET")
7204                    .uri("/claim?t=not-a-real-token")
7205                    .body(Body::empty())
7206                    .unwrap(),
7207            )
7208            .await
7209            .unwrap();
7210        // Renders the invite page (200), NOT a redirect to /login.
7211        assert_eq!(resp.status(), StatusCode::OK);
7212    }
7213
7214    #[tokio::test]
7215    async fn claim_with_used_token_is_refused() {
7216        let state = bot_state("bot-secret-abcdef").await;
7217        // Mint a code + wrap it, then redeem it out from under the token.
7218        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7219            .await
7220            .unwrap();
7221        let token = sign_claim_token(&code, &state.config.cookie_secret);
7222        store::redeem_code(
7223            &state.db,
7224            &code,
7225            "did:plc:someone",
7226            None,
7227            state.config.beta_cap,
7228        )
7229        .await
7230        .unwrap()
7231        .unwrap();
7232        let app = router(state);
7233        let resp = app
7234            .oneshot(
7235                Request::builder()
7236                    .method("GET")
7237                    .uri(format!("/claim?t={}", qenc(&token)))
7238                    .body(Body::empty())
7239                    .unwrap(),
7240            )
7241            .await
7242            .unwrap();
7243        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
7244        assert_eq!(resp.status(), StatusCode::OK);
7245        assert!(resp.headers().get(header::SET_COOKIE).is_none());
7246    }
7247
7248    #[tokio::test]
7249    async fn bot_claims_rejects_bad_and_missing_secret() {
7250        let state = bot_state("bot-secret-abcdef").await;
7251        let app = router(state);
7252        // Wrong secret.
7253        let resp = app
7254            .clone()
7255            .oneshot(
7256                Request::builder()
7257                    .method("POST")
7258                    .uri("/bot/claims")
7259                    .header("x-bot-secret", "wrong")
7260                    .body(Body::empty())
7261                    .unwrap(),
7262            )
7263            .await
7264            .unwrap();
7265        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7266        // Missing secret.
7267        let resp = app
7268            .oneshot(
7269                Request::builder()
7270                    .method("POST")
7271                    .uri("/bot/claims")
7272                    .body(Body::empty())
7273                    .unwrap(),
7274            )
7275            .await
7276            .unwrap();
7277        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7278    }
7279
7280    #[tokio::test]
7281    async fn bot_claims_disabled_when_secret_unset() {
7282        // test_state configures NO bot secret → the endpoint is off (503).
7283        let state = test_state(&["did:plc:admin"]).await;
7284        let app = router(state);
7285        let resp = app
7286            .oneshot(
7287                Request::builder()
7288                    .method("POST")
7289                    .uri("/bot/claims")
7290                    .header("x-bot-secret", "anything")
7291                    .body(Body::empty())
7292                    .unwrap(),
7293            )
7294            .await
7295            .unwrap();
7296        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7297    }
7298
7299    #[tokio::test]
7300    async fn bot_claims_refuses_at_capacity() {
7301        let state = bot_state("bot-secret-abcdef").await;
7302        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
7303        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7304            .await
7305            .unwrap();
7306        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7307            .await
7308            .unwrap();
7309        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7310        let app = router(state);
7311        let resp = app
7312            .oneshot(
7313                Request::builder()
7314                    .method("POST")
7315                    .uri("/bot/claims")
7316                    .header("x-bot-secret", "bot-secret-abcdef")
7317                    .body(Body::empty())
7318                    .unwrap(),
7319            )
7320            .await
7321            .unwrap();
7322        assert_eq!(resp.status(), StatusCode::CONFLICT);
7323        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7324            .await
7325            .unwrap();
7326        assert!(String::from_utf8_lossy(&bytes).contains("full"));
7327    }
7328
7329    #[tokio::test]
7330    async fn bot_claims_counts_outstanding_codes_against_cap() {
7331        let state = bot_state("bot-secret-abcdef").await;
7332        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
7333        store::mint_code(&state.db, "did:plc:admin", 3600)
7334            .await
7335            .unwrap();
7336        store::mint_code(&state.db, "did:plc:admin", 3600)
7337            .await
7338            .unwrap();
7339        let app = router(state);
7340        let resp = app
7341            .oneshot(
7342                Request::builder()
7343                    .method("POST")
7344                    .uri("/bot/claims")
7345                    .header("x-bot-secret", "bot-secret-abcdef")
7346                    .body(Body::empty())
7347                    .unwrap(),
7348            )
7349            .await
7350            .unwrap();
7351        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
7352        assert_eq!(resp.status(), StatusCode::CONFLICT);
7353    }
7354
7355    /// POST /bot/claims with a JSON body carrying the follower DID.
7356    async fn post_bot_claim_for(
7357        app: &axum::Router,
7358        secret: &str,
7359        did: &str,
7360    ) -> (StatusCode, serde_json::Value) {
7361        let resp = app
7362            .clone()
7363            .oneshot(
7364                Request::builder()
7365                    .method("POST")
7366                    .uri("/bot/claims")
7367                    .header("x-bot-secret", secret)
7368                    .header("content-type", "application/json")
7369                    .body(Body::from(format!(
7370                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
7371                    )))
7372                    .unwrap(),
7373            )
7374            .await
7375            .unwrap();
7376        let status = resp.status();
7377        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7378            .await
7379            .unwrap();
7380        let json = if bytes.is_empty() {
7381            serde_json::Value::Null
7382        } else {
7383            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
7384        };
7385        (status, json)
7386    }
7387
7388    #[tokio::test]
7389    async fn bot_claims_returns_already_seated_for_a_member() {
7390        // A DID that already holds beta access must get `already_seated` with NO
7391        // code/url — the bot posts nothing. This is the server-side backstop that
7392        // survives a bot-host state loss (it would otherwise re-mint + re-post).
7393        let state = bot_state("bot-secret-abcdef").await;
7394        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
7395            .await
7396            .unwrap();
7397        let app = router(state.clone());
7398        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
7399        assert_eq!(status, StatusCode::OK);
7400        assert_eq!(json["status"], "already_seated");
7401        assert_eq!(json["code"], "");
7402        assert_eq!(json["url"], "");
7403        // No new invite code was minted for the seated DID.
7404        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
7405            .await
7406            .unwrap()
7407            .is_none());
7408    }
7409
7410    #[tokio::test]
7411    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
7412        // Two mint requests for the SAME follower DID must return the SAME code
7413        // (the app is authoritative), never a second one — so a bot-host state loss
7414        // re-requesting cannot double-mint or double-post.
7415        let state = bot_state("bot-secret-abcdef").await;
7416        let app = router(state.clone());
7417
7418        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7419        assert_eq!(s1, StatusCode::OK);
7420        assert_eq!(j1["status"], "minted");
7421        let code1 = j1["code"].as_str().unwrap().to_string();
7422
7423        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7424        assert_eq!(s2, StatusCode::OK);
7425        assert_eq!(j2["status"], "existing");
7426        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
7427        assert_eq!(j2["url"], j1["url"], "same url returned");
7428
7429        // Exactly ONE active code exists for that DID.
7430        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
7431    }
7432
7433    #[tokio::test]
7434    async fn bot_claims_records_intended_did_at_mint() {
7435        // A fresh mint records the follower DID so the lookup finds it.
7436        let state = bot_state("bot-secret-abcdef").await;
7437        let app = router(state.clone());
7438        let (status, json) =
7439            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
7440        assert_eq!(status, StatusCode::OK);
7441        let code = json["code"].as_str().unwrap();
7442        assert_eq!(
7443            store::find_active_code_for_did(&state.db, "did:plc:follower2")
7444                .await
7445                .unwrap()
7446                .as_deref(),
7447            Some(code)
7448        );
7449    }
7450
7451    #[tokio::test]
7452    async fn bot_claims_concurrent_same_did_never_double_mints() {
7453        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
7454        // active code. The dedupe check (3b) and the mint are separate statements,
7455        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
7456        // then makes the loser's INSERT conflict, and the handler recovers by
7457        // returning the winner's code (status `existing`) rather than 500-ing.
7458        // Result: exactly ONE active code, and BOTH callers get a usable code.
7459        let state = bot_state("bot-secret-abcdef").await;
7460        let app = router(state.clone());
7461
7462        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7463        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7464        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
7465
7466        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
7467        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
7468
7469        // Exactly one active code for the DID — the whole point of the fix.
7470        assert_eq!(
7471            store::count_active_codes(&state.db).await.unwrap(),
7472            1,
7473            "concurrent mints must not create two active codes"
7474        );
7475
7476        // Both callers received the SAME (single) code, and neither got a 500.
7477        let ca = ja["code"].as_str().unwrap_or("");
7478        let cb = jb["code"].as_str().unwrap_or("");
7479        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7480        assert_eq!(ca, cb, "both callers must get the one minted code");
7481        // One is `minted` (the winner), the other `minted` or `existing` depending
7482        // on interleaving — but never an error status.
7483        for st in [&ja["status"], &jb["status"]] {
7484            let s = st.as_str().unwrap_or("");
7485            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7486        }
7487    }
7488
7489    #[tokio::test]
7490    async fn bot_claims_rejects_malformed_json_body() {
7491        let state = bot_state("bot-secret-abcdef").await;
7492        let app = router(state);
7493        let resp = app
7494            .oneshot(
7495                Request::builder()
7496                    .method("POST")
7497                    .uri("/bot/claims")
7498                    .header("x-bot-secret", "bot-secret-abcdef")
7499                    .header("content-type", "application/json")
7500                    .body(Body::from("{not json"))
7501                    .unwrap(),
7502            )
7503            .await
7504            .unwrap();
7505        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7506    }
7507
7508    #[tokio::test]
7509    async fn favicon_ico_served_at_root() {
7510        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
7511        // tags in <head>; the root route must serve the icon, not 404.
7512        let state = test_state(&[]).await;
7513        let app = router(state);
7514        let resp = app
7515            .oneshot(
7516                Request::builder()
7517                    .uri("/favicon.ico")
7518                    .body(Body::empty())
7519                    .unwrap(),
7520            )
7521            .await
7522            .unwrap();
7523        assert_eq!(resp.status(), StatusCode::OK);
7524        let ct = resp
7525            .headers()
7526            .get(header::CONTENT_TYPE)
7527            .unwrap()
7528            .to_str()
7529            .unwrap();
7530        assert!(
7531            ct.contains("icon") || ct.starts_with("image/"),
7532            "content-type = {ct}"
7533        );
7534    }
7535
7536    #[tokio::test]
7537    async fn login_without_invite_redirects_to_beta_redeem() {
7538        // No allow-list seed, no invite cookie: starting OAuth must be refused.
7539        let state = test_state(&[]).await;
7540        let app = router(state);
7541        let resp = app
7542            .oneshot(
7543                Request::builder()
7544                    .method("POST")
7545                    .uri("/login")
7546                    .header("content-type", "application/x-www-form-urlencoded")
7547                    .body(Body::from("handle=alice.bsky.social"))
7548                    .unwrap(),
7549            )
7550            .await
7551            .unwrap();
7552        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7553        assert_eq!(
7554            resp.headers().get(header::LOCATION).unwrap(),
7555            "/beta/redeem"
7556        );
7557    }
7558
7559    #[tokio::test]
7560    async fn login_with_valid_invite_cookie_starts_oauth() {
7561        let state = test_state(&[]).await;
7562        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7563        let cookie = cookie.split(';').next().unwrap().to_string();
7564        let app = router(state);
7565        let resp = app
7566            .oneshot(
7567                Request::builder()
7568                    .method("POST")
7569                    .uri("/login")
7570                    .header("content-type", "application/x-www-form-urlencoded")
7571                    .header(header::COOKIE, cookie)
7572                    .body(Body::from("handle=alice.bsky.social"))
7573                    .unwrap(),
7574            )
7575            .await
7576            .unwrap();
7577        // Redirects into the sidecar login (not to /beta/redeem).
7578        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7579        let loc = resp
7580            .headers()
7581            .get(header::LOCATION)
7582            .unwrap()
7583            .to_str()
7584            .unwrap();
7585        assert!(loc.contains("/login"), "loc = {loc}");
7586        assert_ne!(loc, "/beta/redeem");
7587    }
7588
7589    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
7590    /// any network resolution and that a resolution failure fails closed.
7591    async fn resolver_never(_handle: String) -> Option<String> {
7592        None
7593    }
7594
7595    /// A resolver that maps every handle to `did`.
7596    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7597        move |_handle| std::future::ready(Some(did.to_string()))
7598    }
7599
7600    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
7601    /// that already holds a seat (the seeded-admin first-login case) passes the
7602    /// gate — no session cookie, no invite code.
7603    #[tokio::test]
7604    async fn may_start_oauth_honors_seat_via_resolved_handle() {
7605        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
7606        // no cookie on a fresh deploy.
7607        let state = test_state(&["did:plc:admin"]).await;
7608        let headers = HeaderMap::new();
7609        assert!(
7610            may_start_oauth_with(
7611                &state,
7612                &headers,
7613                "admin.example",
7614                resolver_to("did:plc:admin")
7615            )
7616            .await,
7617            "a handle resolving to a seated DID must pass the gate"
7618        );
7619    }
7620
7621    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
7622    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
7623    /// fails).
7624    #[tokio::test]
7625    async fn may_start_oauth_bounces_non_member_handle() {
7626        let state = test_state(&["did:plc:admin"]).await;
7627        let headers = HeaderMap::new();
7628        assert!(
7629            !may_start_oauth_with(
7630                &state,
7631                &headers,
7632                "rando.example",
7633                resolver_to("did:plc:rando")
7634            )
7635            .await,
7636            "a resolved DID with no seat must be bounced"
7637        );
7638    }
7639
7640    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
7641    /// bounces gracefully — no panic, no handshake.
7642    #[tokio::test]
7643    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7644        let state = test_state(&["did:plc:admin"]).await;
7645        let headers = HeaderMap::new();
7646        assert!(
7647            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7648            "an unresolvable handle must fail closed"
7649        );
7650    }
7651
7652    /// The session-cookie fast path admits a seated member WITHOUT calling the
7653    /// resolver (proven by injecting `resolver_never`, which would otherwise
7654    /// bounce).
7655    #[tokio::test]
7656    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7657        let state = test_state(&[]).await;
7658        let did = "did:plc:member";
7659        store::grant_access(&state.db, did, Some("member.example"), "test", None)
7660            .await
7661            .unwrap();
7662        let cookie = session_cookie(&state, did, Some("member.example"));
7663        let mut headers = HeaderMap::new();
7664        headers.insert(header::COOKIE, cookie.parse().unwrap());
7665        assert!(
7666            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7667            "a seated session cookie must pass without resolution"
7668        );
7669    }
7670
7671    /// The invite-cookie fast path admits WITHOUT calling the resolver.
7672    #[tokio::test]
7673    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7674        let state = test_state(&[]).await;
7675        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7676        let cookie = cookie.split(';').next().unwrap().to_string();
7677        let mut headers = HeaderMap::new();
7678        headers.insert(header::COOKIE, cookie.parse().unwrap());
7679        assert!(
7680            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7681            "a valid invite cookie must pass without resolution"
7682        );
7683    }
7684
7685    #[tokio::test]
7686    async fn admin_mint_requires_admin_seed_did() {
7687        let state = test_state(&["did:plc:admin"]).await;
7688        // A non-admin (but beta'd) session is forbidden.
7689        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7690            .await
7691            .unwrap();
7692        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7693        // An admin session is allowed.
7694        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7695        let app = router(state);
7696
7697        let forbidden = app
7698            .clone()
7699            .oneshot(
7700                Request::builder()
7701                    .method("POST")
7702                    .uri("/admin/invites?n=2")
7703                    .header(header::COOKIE, rando_cookie)
7704                    .body(Body::empty())
7705                    .unwrap(),
7706            )
7707            .await
7708            .unwrap();
7709        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7710
7711        let ok = app
7712            .oneshot(
7713                Request::builder()
7714                    .method("POST")
7715                    .uri("/admin/invites?n=2")
7716                    .header(header::COOKIE, admin_cookie)
7717                    .body(Body::empty())
7718                    .unwrap(),
7719            )
7720            .await
7721            .unwrap();
7722        assert_eq!(ok.status(), StatusCode::OK);
7723        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7724            .await
7725            .unwrap();
7726        let body = String::from_utf8(bytes.to_vec()).unwrap();
7727        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7728        assert_eq!(minted.len(), 2);
7729        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7730    }
7731
7732    #[tokio::test]
7733    async fn admin_mint_unauthenticated_is_401() {
7734        let state = test_state(&["did:plc:admin"]).await;
7735        let app = router(state);
7736        let resp = app
7737            .oneshot(
7738                Request::builder()
7739                    .method("POST")
7740                    .uri("/admin/invites")
7741                    .body(Body::empty())
7742                    .unwrap(),
7743            )
7744            .await
7745            .unwrap();
7746        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7747    }
7748
7749    /// A state whose `/about` renders the adoption line, seeded with one
7750    /// observation.
7751    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7752        let db = store::init_url("sqlite::memory:").await.unwrap();
7753        store::record_network_stat(
7754            &db,
7755            &store::NetworkStat {
7756                key: store::ADOPTION_STAT_KEY.to_string(),
7757                source: "https://relay1.us-west.bsky.network".to_string(),
7758                value: repos,
7759                truncated,
7760                observed_at: "2026-08-13T04:05:06Z".to_string(),
7761            },
7762        )
7763        .await
7764        .unwrap();
7765        let config = Config {
7766            cookie_secret: "test-cookie-secret-000".to_string(),
7767            show_adoption: true,
7768            ..Config::default()
7769        };
7770        AppState::new(config, db).unwrap()
7771    }
7772
7773    async fn about_body(state: AppState) -> String {
7774        let resp = router(state)
7775            .oneshot(
7776                Request::builder()
7777                    .uri("/about")
7778                    .body(Body::empty())
7779                    .unwrap(),
7780            )
7781            .await
7782            .unwrap();
7783        assert_eq!(resp.status(), StatusCode::OK);
7784        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7785            .await
7786            .unwrap();
7787        String::from_utf8(bytes.to_vec()).unwrap()
7788    }
7789
7790    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
7791    #[tokio::test]
7792    async fn about_omits_adoption_line_by_default() {
7793        let state = test_state(&[]).await;
7794        assert!(!state.config.show_adoption);
7795        let body = about_body(state).await;
7796        assert!(
7797            !body.contains("atproto network"),
7798            "the adoption line must not render by default"
7799        );
7800    }
7801
7802    #[tokio::test]
7803    async fn about_renders_adoption_line_when_enabled() {
7804        // **A distinctive count, and asserted IN ITS SENTENCE.**
7805        //
7806        // This used to seed 4 and assert `body.contains("4")`, which the
7807        // colophon's `width="44"` satisfies whatever the count is — so
7808        // hardcoding the rendered number passed. Both halves are needed: a
7809        // digit that does not occur incidentally, and the assertion tied to the
7810        // phrase it belongs to.
7811        let body = about_body(adoption_state(7_318, false).await).await;
7812        // The count and its phrase are on separate template lines, so compare
7813        // against a whitespace-collapsed copy rather than the raw HTML.
7814        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7815        assert!(
7816            flat.contains("7318 accounts on the atproto network hold"),
7817            "the count did not render in its own sentence: {flat}",
7818        );
7819        assert!(
7820            body.contains("accounts on the atproto network hold"),
7821            "{body}"
7822        );
7823        assert!(
7824            body.contains("2026-08-13"),
7825            "the observation date must render"
7826        );
7827        assert!(
7828            body.contains("lower bound"),
7829            "the non-archival caveat must ride along with the number"
7830        );
7831        assert!(
7832            !body.contains("At least"),
7833            "an untruncated count is exact-ish"
7834        );
7835    }
7836
7837    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
7838    #[tokio::test]
7839    async fn about_adoption_line_is_singular_at_one() {
7840        let body = about_body(adoption_state(1, false).await).await;
7841        assert!(
7842            body.contains("account on the atproto network holds"),
7843            "{body}"
7844        );
7845    }
7846
7847    /// A truncated observation is a floor, and must say so.
7848    #[tokio::test]
7849    async fn about_adoption_line_says_at_least_when_truncated() {
7850        let body = about_body(adoption_state(25_000, true).await).await;
7851        assert!(body.contains("At least"), "{body}");
7852    }
7853
7854    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
7855    #[tokio::test]
7856    async fn about_omits_line_when_enabled_with_no_observation() {
7857        let db = store::init_url("sqlite::memory:").await.unwrap();
7858        let config = Config {
7859            cookie_secret: "test-cookie-secret-000".to_string(),
7860            show_adoption: true,
7861            ..Config::default()
7862        };
7863        let body = about_body(AppState::new(config, db).unwrap()).await;
7864        assert!(!body.contains("atproto network"));
7865    }
7866
7867    // ---- standard.site on the public pages and the subscribe form ----------
7868    //
7869    // `FEATHERREADER_STANDARD_SITE` gates what may be STORED (`add_subscription`
7870    // refuses every `at://` paste with it off), so a page that tells the reader
7871    // to paste a publication URI is advertising a form that will be refused
7872    // unless the flag is on. These pin both halves: with the flag on the pages
7873    // say how; with it off they do not.
7874
7875    /// A state with the standard.site flag chosen, and `did` holding a seat so
7876    /// `/manage` renders for it.
7877    async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7878        let db = store::init_url("sqlite::memory:").await.unwrap();
7879        store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7880        let config = Config {
7881            allowed_dids: vec![did.to_string()],
7882            cookie_secret: "test-cookie-secret-000".to_string(),
7883            beta_cap: 3,
7884            standard_site,
7885            ..Config::default()
7886        };
7887        AppState::new(config, db).unwrap()
7888    }
7889
7890    /// `GET path` as `did`, asserted 200, body as a string.
7891    async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7892        let cookie = session_cookie(&state, did, Some("reader.example"));
7893        let resp = router(state)
7894            .oneshot(
7895                Request::builder()
7896                    .uri(path)
7897                    .header(header::COOKIE, cookie)
7898                    .body(Body::empty())
7899                    .unwrap(),
7900            )
7901            .await
7902            .unwrap();
7903        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7904        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7905            .await
7906            .unwrap();
7907        String::from_utf8(bytes.to_vec()).unwrap()
7908    }
7909
7910    /// `GET path` signed out, asserted 200, body as a string.
7911    async fn public_body(state: AppState, path: &str) -> String {
7912        let resp = router(state)
7913            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7914            .await
7915            .unwrap();
7916        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7917        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7918            .await
7919            .unwrap();
7920        String::from_utf8(bytes.to_vec()).unwrap()
7921    }
7922
7923    /// The `<input … id="feed-url" …>` tag of the subscribe form, whole.
7924    fn feed_url_input(body: &str) -> &str {
7925        let start = body
7926            .find("id=\"feed-url\"")
7927            .and_then(|i| body[..i].rfind("<input"))
7928            .expect("the subscribe form's URL input renders");
7929        let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7930        &body[start..end]
7931    }
7932
7933    /// Flag on: the subscribe form says a publication URI is accepted, and shows
7934    /// both spellings the handler takes (DID and handle).
7935    #[tokio::test]
7936    async fn manage_hints_at_publications_when_the_flag_is_on() {
7937        let did = "did:plc:reader";
7938        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7939        assert!(
7940            body.contains("at://did:plc:…/site.standard.publication/…"),
7941            "the DID form must be shown: {body}"
7942        );
7943        assert!(
7944            body.contains("at://alice.example.com/site.standard.publication/…"),
7945            "the handle form must be shown: {body}"
7946        );
7947    }
7948
7949    /// Flag on: the URL input must not be `type="url"`. A browser validates
7950    /// that type with the WHATWG URL parser, which REJECTS the DID form —
7951    /// `at://did:plc:…/…` fails as an invalid port, the same failure
7952    /// `url::Url::parse` has (see `feed::is_storable_feed_url`) — so the form
7953    /// would refuse to submit the very string the hint asks for.
7954    #[tokio::test]
7955    async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7956        let did = "did:plc:reader";
7957        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7958        let input = feed_url_input(&body);
7959        assert!(
7960            input.contains("type=\"text\""),
7961            "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7962        );
7963        assert!(
7964            input.contains("inputmode=\"url\""),
7965            "the URL keyboard is still wanted: {input}"
7966        );
7967    }
7968
7969    /// Flag on, `type="text"` drops the browser's scheme check, so a pasted
7970    /// `example.com/blog` would reach the handler and come back as "Couldn't
7971    /// find a feed" — wrong, the site likely has one. A `pattern` keeps the
7972    /// browser asking for a scheme while still admitting `at://` (both cases:
7973    /// the handler canonicalises the scheme).
7974    #[tokio::test]
7975    async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7976        let did = "did:plc:reader";
7977        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7978        let input = feed_url_input(&body);
7979        assert!(
7980            input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7981            "the text input must keep a scheme check: {input}"
7982        );
7983    }
7984
7985    /// Flag off: every `at://` paste is refused, so the form must not say
7986    /// publications are accepted — and the input keeps browser URL validation.
7987    #[tokio::test]
7988    async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7989        let did = "did:plc:reader";
7990        let state = standard_site_state(false, did).await;
7991        assert!(!state.config.standard_site);
7992        let page = signed_in_body(state, "/manage", did).await;
7993        // The `<head>` carries the site's link card, whose one-line description
7994        // names standard.site whatever the flag says — as the landing page does
7995        // with the flag off (a stored publication is polled regardless). What
7996        // must not advertise is the page: everything after `</head>`.
7997        let body = &page[page.find("</head>").expect("a <head>")..];
7998        assert!(
7999            !body.contains("site.standard.publication"),
8000            "a refused form must not be advertised: {body}"
8001        );
8002        // The shared footer links the `/standard-site` feature page on every
8003        // page, flag on or off — that page itself says the instance isn't
8004        // accepting new publication subscriptions — so the check is on the
8005        // page above the footer, where the form and its hints are.
8006        let above_footer = body
8007            .split("<footer")
8008            .next()
8009            .expect("split yields at least one piece");
8010        assert!(
8011            above_footer.contains("id=\"feed-url\""),
8012            "the form must be above the footer: {body}"
8013        );
8014        assert!(
8015            !above_footer.contains("standard.site"),
8016            "a refused form must not be advertised: {body}"
8017        );
8018        assert!(
8019            feed_url_input(body).contains("type=\"url\""),
8020            "with the flag off the input is unchanged"
8021        );
8022    }
8023
8024    /// Flag on: the landing page says publications sit beside feeds AND how to
8025    /// subscribe to one.
8026    #[tokio::test]
8027    async fn landing_describes_publications_and_how_to_subscribe_when_on() {
8028        let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
8029        assert!(body.contains("standard.site"), "{body}");
8030        assert!(
8031            body.contains("at://did:plc:…/site.standard.publication/…"),
8032            "the landing page must show the DID form: {body}"
8033        );
8034        assert!(
8035            body.contains("at://alice.example.com/site.standard.publication/…"),
8036            "the landing page must show the handle form: {body}"
8037        );
8038    }
8039
8040    /// Flag off: the landing page still says what a publication is (a stored
8041    /// one is polled whatever the flag says), but shows no paste instructions
8042    /// and says new ones are not accepted here.
8043    #[tokio::test]
8044    async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
8045        let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
8046        assert!(body.contains("standard.site"), "{body}");
8047        assert!(
8048            !body.contains("at://did:plc:…/site.standard.publication/…"),
8049            "no paste instructions with the flag off: {body}"
8050        );
8051        assert!(
8052            !body.contains("at://alice.example.com/site.standard.publication/…"),
8053            "no paste instructions with the flag off: {body}"
8054        );
8055        assert!(
8056            body.contains("isn't accepting new publication subscriptions"),
8057            "the page must say the form is closed here: {body}"
8058        );
8059    }
8060
8061    /// Flag on: /about has a publications section with both spellings.
8062    #[tokio::test]
8063    async fn about_describes_publications_and_how_to_subscribe_when_on() {
8064        let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
8065        assert!(body.contains("site.standard.publication"), "{body}");
8066        assert!(body.contains("site.standard.document"), "{body}");
8067        assert!(
8068            body.contains("at://did:plc:…/site.standard.publication/…"),
8069            "{body}"
8070        );
8071        assert!(
8072            body.contains("at://alice.example.com/site.standard.publication/…"),
8073            "{body}"
8074        );
8075    }
8076
8077    /// Flag off: /about keeps the description, drops the paste instructions.
8078    #[tokio::test]
8079    async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
8080        let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
8081        assert!(body.contains("site.standard.publication"), "{body}");
8082        assert!(
8083            !body.contains("at://did:plc:…/site.standard.publication/…"),
8084            "no paste instructions with the flag off: {body}"
8085        );
8086        assert!(
8087            !body.contains("at://alice.example.com/site.standard.publication/…"),
8088            "no paste instructions with the flag off: {body}"
8089        );
8090        assert!(
8091            body.contains("isn't accepting new publication subscriptions"),
8092            "{body}"
8093        );
8094    }
8095
8096    // ---- the standard.site feature page (`/standard-site`) -----------------
8097    //
8098    // A public page, like `/about`: what a publication is, what is shown from
8099    // it, how to subscribe (flag-conditional, as on the other public pages),
8100    // and the honest limits. It also carries the "latest releases" call-out.
8101
8102    /// Signed out, with the default config, the page renders.
8103    #[tokio::test]
8104    async fn standard_site_page_renders_signed_out() {
8105        let body = public_body(test_state(&[]).await, "/standard-site").await;
8106        assert!(body.contains("site.standard.publication"), "{body}");
8107        assert!(body.contains("site.standard.document"), "{body}");
8108        assert!(
8109            body.contains("<title>standard.site — FeatherReader</title>"),
8110            "{body}"
8111        );
8112    }
8113
8114    /// Flag on: the page says how to subscribe, in both spellings, and that a
8115    /// handle is resolved to its DID.
8116    #[tokio::test]
8117    async fn standard_site_page_tells_how_to_subscribe_when_on() {
8118        let body = public_body(
8119            standard_site_state(true, "did:plc:x").await,
8120            "/standard-site",
8121        )
8122        .await;
8123        assert!(
8124            body.contains("at://did:plc:…/site.standard.publication/…"),
8125            "the DID form must be shown: {body}"
8126        );
8127        assert!(
8128            body.contains("at://alice.example.com/site.standard.publication/…"),
8129            "the handle form must be shown: {body}"
8130        );
8131        assert!(
8132            body.contains("resolved to its DID"),
8133            "the handle resolution must be stated: {body}"
8134        );
8135        assert!(
8136            !body.contains("isn't accepting new publication subscriptions"),
8137            "{body}"
8138        );
8139    }
8140
8141    /// Flag off: `add_subscription` refuses every `at://` paste, so the page
8142    /// must not tell visitors to paste one — it says new publication
8143    /// subscriptions are not accepted here, and that stored ones are still read.
8144    #[tokio::test]
8145    async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
8146        let state = standard_site_state(false, "did:plc:x").await;
8147        assert!(!state.config.standard_site);
8148        let body = public_body(state, "/standard-site").await;
8149        assert!(body.contains("site.standard.publication"), "{body}");
8150        assert!(
8151            !body.contains("at://did:plc:…/site.standard.publication/…"),
8152            "no paste instructions with the flag off: {body}"
8153        );
8154        assert!(
8155            !body.contains("at://alice.example.com/site.standard.publication/…"),
8156            "no paste instructions with the flag off: {body}"
8157        );
8158        assert!(
8159            body.contains("isn't accepting new publication subscriptions"),
8160            "the page must say the form is closed here: {body}"
8161        );
8162        assert!(
8163            body.contains("already follows are still read"),
8164            "stored publications are polled whatever the flag says: {body}"
8165        );
8166    }
8167
8168    /// The releases call-out links each release's GitHub page and the
8169    /// changelog, on the feature page and on the landing page.
8170    #[tokio::test]
8171    async fn releases_callout_links_the_release_pages() {
8172        for path in ["/standard-site", "/"] {
8173            let body = public_body(test_state(&[]).await, path).await;
8174            for tag in ["v0.4.1", "v0.4.0"] {
8175                let href = format!(
8176                    "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
8177                );
8178                assert!(body.contains(&href), "{path} must link {tag}: {body}");
8179            }
8180            assert!(
8181                body.contains(
8182                    "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
8183                ),
8184                "{path} must link the changelog: {body}"
8185            );
8186        }
8187    }
8188
8189    /// The feature page is reachable from the landing page, from `/about`, and
8190    /// from the shared footer (`/privacy` renders nothing but prose and that
8191    /// footer, so it stands in for every page that includes it).
8192    #[tokio::test]
8193    async fn landing_about_and_footer_link_the_standard_site_page() {
8194        for path in ["/", "/about", "/privacy"] {
8195            let body = public_body(test_state(&[]).await, path).await;
8196            assert!(
8197                body.contains("href=\"/standard-site\""),
8198                "{path} must link the feature page: {body}"
8199            );
8200        }
8201    }
8202
8203    /// `RELEASES` is the one place a release is described, so its shape is
8204    /// pinned: newest first, dates as the CHANGELOG headings spell them, and
8205    /// both derived links pointing where the template promises.
8206    #[test]
8207    fn releases_are_newest_first_and_link_the_tag_and_changelog() {
8208        assert!(!RELEASES.is_empty());
8209        let parse = |v: &str| -> Vec<u32> {
8210            v.split('.')
8211                .map(|p| p.parse::<u32>().expect("a numeric version part"))
8212                .collect()
8213        };
8214        for pair in RELEASES.windows(2) {
8215            assert!(
8216                parse(pair[0].version) > parse(pair[1].version),
8217                "{} must come before {}",
8218                pair[0].version,
8219                pair[1].version
8220            );
8221        }
8222        for r in RELEASES {
8223            assert_eq!(parse(r.version).len(), 3, "{}", r.version);
8224            assert!(
8225                chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
8226                "{} is not YYYY-MM-DD",
8227                r.date
8228            );
8229            assert!(!r.summary.trim().is_empty());
8230            assert!(!r.summary.contains('<'), "the summary is plain text");
8231            assert_eq!(
8232                r.url(),
8233                format!(
8234                    "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
8235                    r.version
8236                )
8237            );
8238        }
8239        // The newest entry is this build's own version, so a release cannot
8240        // ship without adding itself to the call-out.
8241        let latest = &RELEASES[0];
8242        assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
8243        assert_eq!(
8244            latest.changelog_url(),
8245            "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#046--2026-10-06"
8246        );
8247    }
8248
8249    /// Public and static like `/about`, so it is cacheable on the same terms.
8250    #[tokio::test]
8251    async fn standard_site_page_is_publicly_cacheable() {
8252        let resp = router(test_state(&[]).await)
8253            .oneshot(
8254                Request::builder()
8255                    .uri("/standard-site")
8256                    .body(Body::empty())
8257                    .unwrap(),
8258            )
8259            .await
8260            .unwrap();
8261        assert_eq!(resp.status(), StatusCode::OK);
8262        assert_eq!(
8263            resp.headers().get(header::CACHE_CONTROL).unwrap(),
8264            "public, max-age=300"
8265        );
8266    }
8267
8268    #[tokio::test]
8269    async fn cache_control_public_on_about_no_store_on_authed() {
8270        let state = test_state(&["did:plc:admin"]).await;
8271        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
8272        let app = router(state);
8273
8274        // /about → public, cacheable.
8275        let about = app
8276            .clone()
8277            .oneshot(
8278                Request::builder()
8279                    .uri("/about")
8280                    .body(Body::empty())
8281                    .unwrap(),
8282            )
8283            .await
8284            .unwrap();
8285        assert_eq!(
8286            about.headers().get(header::CACHE_CONTROL).unwrap(),
8287            "public, max-age=300"
8288        );
8289        // The security headers are still intact.
8290        // The VALUE, spelled out here rather than compared to the constant —
8291        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
8292        // used to assert only that the header existed, which a policy of
8293        // `default-src *` satisfies.
8294        assert_eq!(
8295            about.headers()["content-security-policy"],
8296            EXPECTED_CSP,
8297            "the CSP is not the policy the router promises"
8298        );
8299        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
8300
8301        // /privacy and /terms are static public pages → public, cacheable.
8302        for path in ["/privacy", "/terms"] {
8303            let resp = app
8304                .clone()
8305                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8306                .await
8307                .unwrap();
8308            assert_eq!(resp.status(), StatusCode::OK);
8309            assert_eq!(
8310                resp.headers().get(header::CACHE_CONTROL).unwrap(),
8311                "public, max-age=300",
8312                "{path} should be publicly cacheable"
8313            );
8314            // Security headers apply to these pages too.
8315            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
8316            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
8317        }
8318
8319        // The bare /login landing → public, cacheable.
8320        let login = app
8321            .clone()
8322            .oneshot(
8323                Request::builder()
8324                    .uri("/login")
8325                    .body(Body::empty())
8326                    .unwrap(),
8327            )
8328            .await
8329            .unwrap();
8330        assert_eq!(
8331            login.headers().get(header::CACHE_CONTROL).unwrap(),
8332            "public, max-age=300"
8333        );
8334
8335        // An authenticated page → no-store.
8336        let home = app
8337            .oneshot(
8338                Request::builder()
8339                    .uri("/")
8340                    .header(header::COOKIE, admin_cookie)
8341                    .body(Body::empty())
8342                    .unwrap(),
8343            )
8344            .await
8345            .unwrap();
8346        assert_eq!(
8347            home.headers().get(header::CACHE_CONTROL).unwrap(),
8348            "no-store"
8349        );
8350    }
8351
8352    // -- link cards (Open Graph) -----------------------------------------------
8353    //
8354    // Bluesky's card service fetches the HTML server-side, runs no JS, and
8355    // resolves nothing relative. Measured before these tags existed:
8356    // `cardyb.bsky.app/v1/extract?url=https://feather-reader.com/` returned
8357    // `{"title":"FeatherReader — read, quietly","description":"","image":""}`.
8358
8359    /// Everything up to `</head>` — the only part a card fetcher reads.
8360    fn head(body: &str) -> &str {
8361        let end = body.find("</head>").expect("a <head>");
8362        &body[..end]
8363    }
8364
8365    /// The `content` of the first `<meta …>` tag carrying `attr` (e.g.
8366    /// `property="og:title"`), or `None` when no tag carries it.
8367    fn meta(head: &str, attr: &str) -> Option<String> {
8368        let tag_start = head.find(attr)?;
8369        let rest = &head[tag_start..];
8370        let tag_end = rest.find('>')?;
8371        let tag = &rest[..tag_end];
8372        let content = tag.find("content=\"")? + "content=\"".len();
8373        let close = tag[content..].find('"')?;
8374        Some(tag[content..content + close].to_string())
8375    }
8376
8377    /// A state whose public origin is production's. The card URLs must be
8378    /// absolute on THAT origin: a relative `/static/…` is what the card
8379    /// fetcher cannot use.
8380    async fn production_origin_state() -> AppState {
8381        let db = store::init_url("sqlite::memory:").await.unwrap();
8382        store::ensure_seed(&db, &["did:plc:admin".to_string()])
8383            .await
8384            .unwrap();
8385        let config = Config {
8386            allowed_dids: vec!["did:plc:admin".to_string()],
8387            cookie_secret: "test-cookie-secret-000".to_string(),
8388            beta_cap: 3,
8389            public_url: "https://feather-reader.com".to_string(),
8390            ..Config::default()
8391        };
8392        AppState::new(config, db).unwrap()
8393    }
8394
8395    /// The landing page and /about each carry a complete card with absolute
8396    /// https URLs, and the two describe different things.
8397    #[tokio::test]
8398    async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
8399        let landing = public_body(production_origin_state().await, "/").await;
8400        let about = public_body(production_origin_state().await, "/about").await;
8401        let (lh, ah) = (head(&landing), head(&about));
8402
8403        assert_eq!(
8404            meta(lh, "property=\"og:title\"").as_deref(),
8405            Some("FeatherReader — read, quietly"),
8406            "{lh}"
8407        );
8408        assert_eq!(
8409            meta(ah, "property=\"og:title\"").as_deref(),
8410            Some("About — FeatherReader"),
8411            "{ah}"
8412        );
8413        for (h, path) in [(lh, "/"), (ah, "/about")] {
8414            let url = format!("https://feather-reader.com{path}");
8415            assert_eq!(
8416                meta(h, "property=\"og:url\"").as_deref(),
8417                Some(url.as_str())
8418            );
8419            assert!(
8420                h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
8421                "{path} must carry a canonical link: {h}"
8422            );
8423            let image = meta(h, "property=\"og:image\"").unwrap_or_default();
8424            assert!(
8425                image.starts_with("https://feather-reader.com/static/"),
8426                "{path}: og:image must be absolute on the public origin, got {image:?}"
8427            );
8428            assert_eq!(
8429                meta(h, "name=\"twitter:card\"").as_deref(),
8430                Some("summary_large_image")
8431            );
8432            assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
8433            assert_eq!(
8434                meta(h, "property=\"og:site_name\"").as_deref(),
8435                Some("FeatherReader")
8436            );
8437            let description = meta(h, "property=\"og:description\"").unwrap_or_default();
8438            assert!(!description.is_empty(), "{path}: og:description is empty");
8439            assert_eq!(
8440                meta(h, "name=\"description\"").as_deref(),
8441                Some(description.as_str()),
8442                "{path}: the meta description and og:description must agree"
8443            );
8444        }
8445        assert_ne!(
8446            meta(lh, "property=\"og:description\""),
8447            meta(ah, "property=\"og:description\""),
8448            "the landing page and /about must not share a description"
8449        );
8450    }
8451
8452    /// The origin comes from `FEATHERREADER_PUBLIC_URL`, not a constant.
8453    #[tokio::test]
8454    async fn card_urls_follow_the_configured_public_url() {
8455        let db = store::init_url("sqlite::memory:").await.unwrap();
8456        store::ensure_seed(&db, &[]).await.unwrap();
8457        let config = Config {
8458            cookie_secret: "test-cookie-secret-000".to_string(),
8459            public_url: "https://reader.example.org".to_string(),
8460            ..Config::default()
8461        };
8462        let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
8463        let h = head(&body);
8464        assert_eq!(
8465            meta(h, "property=\"og:url\"").as_deref(),
8466            Some("https://reader.example.org/privacy")
8467        );
8468        assert_eq!(
8469            meta(h, "property=\"og:image\"").as_deref(),
8470            Some("https://reader.example.org/static/social-card.png")
8471        );
8472    }
8473
8474    /// Every signed-out page describes itself: no two share a description,
8475    /// and each `og:url` is its own path.
8476    #[tokio::test]
8477    async fn public_pages_each_carry_their_own_description() {
8478        let paths = [
8479            "/",
8480            "/about",
8481            "/privacy",
8482            "/terms",
8483            "/stats",
8484            "/standard-site",
8485            "/login",
8486            "/beta/redeem",
8487        ];
8488        let mut seen = std::collections::HashSet::new();
8489        for path in paths {
8490            let body = public_body(production_origin_state().await, path).await;
8491            let h = head(&body);
8492            let description = meta(h, "name=\"description\"").unwrap_or_default();
8493            assert!(!description.is_empty(), "{path} has no description: {h}");
8494            assert!(
8495                seen.insert(description.clone()),
8496                "{path} repeats another page's description: {description:?}"
8497            );
8498            assert_eq!(
8499                meta(h, "property=\"og:url\"").as_deref(),
8500                Some(format!("https://feather-reader.com{path}").as_str()),
8501                "{path}"
8502            );
8503            assert!(
8504                !h.contains("name=\"robots\""),
8505                "{path} is public and must not be noindex: {h}"
8506            );
8507        }
8508    }
8509
8510    /// The share image is served from `/static` as a PNG of the dimensions the
8511    /// tags promise, well under the 1 MB card fetchers tolerate, and cacheable.
8512    #[tokio::test]
8513    async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8514        let landing = public_body(production_origin_state().await, "/").await;
8515        let h = head(&landing);
8516        let image = meta(h, "property=\"og:image\"").unwrap();
8517        let path = image.strip_prefix("https://feather-reader.com").unwrap();
8518        let width: u32 = meta(h, "property=\"og:image:width\"")
8519            .unwrap()
8520            .parse()
8521            .unwrap();
8522        let height: u32 = meta(h, "property=\"og:image:height\"")
8523            .unwrap()
8524            .parse()
8525            .unwrap();
8526        assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8527        assert_eq!(
8528            meta(h, "property=\"og:image:type\"").as_deref(),
8529            Some("image/png")
8530        );
8531        assert!(
8532            !meta(h, "property=\"og:image:alt\"")
8533                .unwrap_or_default()
8534                .is_empty(),
8535            "the image needs alt text"
8536        );
8537
8538        let resp = router(production_origin_state().await)
8539            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8540            .await
8541            .unwrap();
8542        assert_eq!(resp.status(), StatusCode::OK, "{path}");
8543        assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8544        assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8545        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8546            .await
8547            .expect("the image is under 1 MB");
8548        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8549        // IHDR: width and height, big-endian, at offsets 16 and 20.
8550        let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8551        assert_eq!(
8552            (be(16), be(20)),
8553            (width, height),
8554            "the PNG's own dimensions must match the tags"
8555        );
8556    }
8557
8558    /// A page that renders a session's private view carries the site's generic
8559    /// card — nothing from the view reaches `<head>` — and is `noindex`.
8560    #[tokio::test]
8561    async fn private_pages_keep_user_data_out_of_the_card() {
8562        for path in ["/", "/manage"] {
8563            let state = production_origin_state().await;
8564            let body = signed_in_body(state, path, "did:plc:admin").await;
8565            let h = head(&body);
8566            assert!(
8567                h.contains("<meta name=\"robots\" content=\"noindex\""),
8568                "{path}: a private view must be noindex: {h}"
8569            );
8570            assert_eq!(
8571                meta(h, "property=\"og:title\"").as_deref(),
8572                Some("FeatherReader — read, quietly"),
8573                "{path}: the card of a private view is the site's generic one"
8574            );
8575            assert_eq!(
8576                meta(h, "property=\"og:url\"").as_deref(),
8577                Some("https://feather-reader.com/"),
8578                "{path}: og:url of a private view is the front door, not the private path"
8579            );
8580            for private in ["reader.example", "did:plc:admin"] {
8581                assert!(
8582                    !h.contains(private),
8583                    "{path}: {private:?} must not reach <head>: {h}"
8584                );
8585            }
8586        }
8587    }
8588
8589    #[tokio::test]
8590    async fn beta_redeem_page_renders() {
8591        let state = test_state(&[]).await;
8592        let app = router(state);
8593        let resp = app
8594            .oneshot(
8595                Request::builder()
8596                    .uri("/beta/redeem")
8597                    .body(Body::empty())
8598                    .unwrap(),
8599            )
8600            .await
8601            .unwrap();
8602        assert_eq!(resp.status(), StatusCode::OK);
8603        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8604            .await
8605            .unwrap();
8606        let html = String::from_utf8(bytes.to_vec()).unwrap();
8607        assert!(html.contains("Invite code"));
8608        assert!(html.contains("/beta/redeem"));
8609    }
8610
8611    #[tokio::test]
8612    async fn rate_limit_returns_429_after_burst() {
8613        // Configure a trusted proxy header so the limiter keys on the forwarded
8614        // IP (the oneshot harness sets no ConnectInfo socket peer).
8615        let db = store::init_url("sqlite::memory:").await.unwrap();
8616        store::ensure_seed(&db, &[]).await.unwrap();
8617        let config = Config {
8618            cookie_secret: "test-cookie-secret-000".to_string(),
8619            beta_cap: 3,
8620            trusted_ip_header: Some("cf-connecting-ip".to_string()),
8621            ..Config::default()
8622        };
8623        let state = AppState::new(config, db).unwrap();
8624        let app = router(state);
8625        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
8626        // handler itself returns 200 (re-render) on a bad code; the limiter is
8627        // what eventually yields 429.
8628        let mut saw_429 = false;
8629        for _ in 0..(RATE_BURST as usize + 5) {
8630            let resp = app
8631                .clone()
8632                .oneshot(
8633                    Request::builder()
8634                        .method("POST")
8635                        .uri("/beta/redeem")
8636                        .header("content-type", "application/x-www-form-urlencoded")
8637                        .header("cf-connecting-ip", "203.0.113.200")
8638                        .body(Body::from("code=FEATHER-NOPENOPE"))
8639                        .unwrap(),
8640                )
8641                .await
8642                .unwrap();
8643            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8644                saw_429 = true;
8645                break;
8646            }
8647        }
8648        assert!(saw_429, "expected a 429 after exhausting the burst");
8649    }
8650
8651    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
8652    /// the middleware's comment cites this test as proof of.
8653    ///
8654    /// The previous version rotated the forged header and asserted that no
8655    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
8656    /// burst, so that assertion held whether the header was trusted or
8657    /// ignored — it passed in the vulnerable configuration too. And with no
8658    /// socket peer the limiter fails open, so nothing could have been keyed on
8659    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
8660    /// a DIFFERENT forged header, and the last must be 429: they all landed in
8661    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
8662    /// per request and never trips — which is exactly what the mutation does.
8663    #[tokio::test]
8664    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8665        let state = test_state(&[]).await;
8666        assert!(
8667            state.config.trusted_ip_header.is_none(),
8668            "no proxy header is trusted here"
8669        );
8670        let app = router(state);
8671        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8672        let mut saw_429 = false;
8673        for i in 0..(RATE_BURST as usize + 5) {
8674            let forged = format!("10.9.8.{}", i % 250);
8675            let resp = app
8676                .clone()
8677                .oneshot(
8678                    Request::builder()
8679                        .method("POST")
8680                        .uri("/beta/redeem")
8681                        .header("content-type", "application/x-www-form-urlencoded")
8682                        .header("x-forwarded-for", forged)
8683                        .extension(axum::extract::ConnectInfo(peer))
8684                        .body(Body::from("code=FEATHER-NOPENOPE"))
8685                        .unwrap(),
8686                )
8687                .await
8688                .unwrap();
8689            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8690                saw_429 = true;
8691                break;
8692            }
8693        }
8694        assert!(
8695            saw_429,
8696            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8697        );
8698    }
8699
8700    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
8701
8702    /// **A private feed is refused BEFORE it is fetched.** The add path's
8703    /// privacy gate had no test at all — `private_feeds_are_classified_private_
8704    /// across_providers` says "the add + OPML paths both gate on this
8705    /// classifier" and nothing checked either. The gate exists so a
8706    /// token-bearing URL never reaches the network; the assertion that
8707    /// matters is the server's hit count: zero.
8708    #[tokio::test]
8709    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8710        let did = "did:plc:privateadder";
8711        let state = test_state_with_caps(did, 0, 0).await;
8712        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8713        let port: u16 = base
8714            .trim_end_matches('/')
8715            .rsplit(':')
8716            .next()
8717            .unwrap()
8718            .parse()
8719            .unwrap();
8720        crate::net::test_host_override(
8721            "private-add.test",
8722            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8723        );
8724        let cookie = session_cookie(&state, did, None);
8725        let resp = router(state.clone())
8726            .oneshot(
8727                Request::builder()
8728                    .method("POST")
8729                    .uri("/subscriptions")
8730                    .header(header::COOKIE, cookie)
8731                    .header("content-type", "application/x-www-form-urlencoded")
8732                    .body(Body::from(format!(
8733                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8734                    )))
8735                    .unwrap(),
8736            )
8737            .await
8738            .unwrap();
8739        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8740        let loc = resp
8741            .headers()
8742            .get(header::LOCATION)
8743            .unwrap()
8744            .to_str()
8745            .unwrap();
8746        assert!(loc.contains("Private"), "not refused as private: {loc}");
8747        assert_eq!(
8748            hits.load(std::sync::atomic::Ordering::SeqCst),
8749            0,
8750            "the private feed was FETCHED before being refused"
8751        );
8752        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8753    }
8754
8755    /// **OPML import skips a private feed without storing or publishing it.**
8756    /// The import path does not fetch, so "never fetched" is not the signal
8757    /// here; "never stored, never written to the PDS" is. The batch write's
8758    /// bytes are captured and must not carry the URL.
8759    #[tokio::test]
8760    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8761        let did = "did:plc:renamer4";
8762        let (sidecar, bodies) = spawn_logging_sidecar().await;
8763        let state = test_state_with_sidecar(&[did], &sidecar).await;
8764        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8765        let opml = format!(
8766            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8767             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8768             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8769             </body></opml>"
8770        );
8771        let (ct, body) = opml_multipart(opml.as_bytes());
8772        let cookie = session_cookie(&state, did, None);
8773        let resp = router(state.clone())
8774            .oneshot(
8775                Request::builder()
8776                    .method("POST")
8777                    .uri("/opml")
8778                    .header(header::COOKIE, cookie)
8779                    .header("content-type", ct)
8780                    .body(Body::from(body))
8781                    .unwrap(),
8782            )
8783            .await
8784            .unwrap();
8785        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8786        let loc = resp
8787            .headers()
8788            .get(header::LOCATION)
8789            .unwrap()
8790            .to_str()
8791            .unwrap();
8792        assert!(
8793            loc.contains("skipped%20as%20private"),
8794            "not reported as skipped: {loc}"
8795        );
8796        assert!(store::get_feed_by_url(&state.db, tokened)
8797            .await
8798            .unwrap()
8799            .is_none());
8800        let sent = bodies.lock().unwrap().join("\n");
8801        assert!(
8802            sent.contains("public.example"),
8803            "the public feed was not written: {sent}"
8804        );
8805        assert!(
8806            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8807            "the secret was PUBLISHED to the PDS: {sent}"
8808        );
8809    }
8810
8811    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
8812    /// tested; the GET form starts the same handshake and had no test, so
8813    /// deleting its gate left the suite green.
8814    #[tokio::test]
8815    async fn get_login_without_a_seat_is_refused() {
8816        let state = test_state(&[]).await;
8817        let resp = router(state)
8818            .oneshot(
8819                Request::builder()
8820                    .method("GET")
8821                    .uri("/login?handle=alice.bsky.social")
8822                    .body(Body::empty())
8823                    .unwrap(),
8824            )
8825            .await
8826            .unwrap();
8827        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8828        assert_eq!(
8829            resp.headers().get(header::LOCATION).unwrap(),
8830            "/beta/redeem"
8831        );
8832    }
8833
8834    /// A sidecar fake that answers every request `ok` and records the PATH of
8835    /// each in arrival order, plus every body — for asserting what was sent,
8836    /// and in what order.
8837    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8838        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8839        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8840        let addr = listener.local_addr().unwrap();
8841        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8842        let sink = log.clone();
8843        tokio::spawn(async move {
8844            loop {
8845                let Ok((mut sock, _)) = listener.accept().await else {
8846                    break;
8847                };
8848                let mut raw: Vec<u8> = Vec::new();
8849                let mut chunk = [0u8; 4096];
8850                let text = loop {
8851                    let Ok(n) = sock.read(&mut chunk).await else {
8852                        break String::new();
8853                    };
8854                    if n == 0 {
8855                        break String::from_utf8_lossy(&raw).to_string();
8856                    }
8857                    raw.extend_from_slice(&chunk[..n]);
8858                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8859                        continue;
8860                    };
8861                    let (head, body) = raw.split_at(split + 4);
8862                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8863                        let (k, v) = l.split_once(':')?;
8864                        k.eq_ignore_ascii_case("content-length")
8865                            .then(|| v.trim().parse::<usize>().ok())?
8866                    });
8867                    if want.is_none_or(|w| body.len() >= w) {
8868                        break String::from_utf8_lossy(&raw).to_string();
8869                    }
8870                };
8871                let path = text
8872                    .lines()
8873                    .next()
8874                    .and_then(|l| l.split_whitespace().nth(1))
8875                    .unwrap_or("")
8876                    .to_string();
8877                let body_text = text
8878                    .split_once("\r\n\r\n")
8879                    .map(|(_, b)| b)
8880                    .unwrap_or("")
8881                    .to_string();
8882                sink.lock().unwrap().push(format!("{path} {body_text}"));
8883                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();
8884                let resp = format!(
8885                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8886                    body.len(),
8887                    body
8888                );
8889                let _ = sock.write_all(resp.as_bytes()).await;
8890                let _ = sock.flush().await;
8891            }
8892        });
8893        (format!("http://{addr}"), log)
8894    }
8895
8896    /// **The sign-out flush settles a split flush's landed prefix too.** It is
8897    /// `readstate::flush_did` under a timeout, so it shares the fix — pinned
8898    /// here so a sign-out path that grew its own flush would not silently lose
8899    /// it. Call 1 of 2 lands, call 2's connection drops: the landed cursors are
8900    /// created and clean, the rest stay dirty to park until the next sign-in.
8901    #[tokio::test]
8902    async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8903        use crate::readstate::tests as rs;
8904        for backend in [
8905            crate::metrics::Backend::Sidecar,
8906            crate::metrics::Backend::Rust,
8907        ] {
8908            let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8909            let state = rs::state_on(backend, &fake).await;
8910            for i in 0..250 {
8911                rs::mark_read(&state, i, "1").await;
8912            }
8913            fake.lock().unwrap().drop_call = Some(2);
8914
8915            flush_before_revoke(&state, rs::DID).await;
8916
8917            let order = rs::send_order(250);
8918            let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8919            for &i in landed {
8920                let c = rs::cursor(&state, i).await;
8921                assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8922            }
8923            for &i in rest {
8924                let c = rs::cursor(&state, i).await;
8925                assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8926            }
8927            assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8928        }
8929    }
8930
8931    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
8932    /// route.** The previous version of this test called
8933    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
8934    /// flush attempt; its doc claimed deleting the call from the handler
8935    /// "drops that to zero", which was false — the handler was never run.
8936    /// Deleting the call left the suite green: #117 regressing in full, with
8937    /// the test named after it still passing. Now `POST /logout` is driven and
8938    /// the sidecar's log must show a repo write BEFORE the revoke.
8939    #[tokio::test]
8940    async fn signing_out_flushes_before_it_revokes_through_the_route() {
8941        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8942        let (sidecar, log) = spawn_logging_sidecar().await;
8943        let state = test_state_with_sidecar(&[did], &sidecar).await;
8944        crate::store::upsert_cursor(
8945            &state.db,
8946            &crate::store::ReadCursor {
8947                did: did.to_string(),
8948                feed_url: "https://example.com/feed.xml".into(),
8949                read_through: None,
8950                read_ids: "[\"1\"]".into(),
8951                unread_ids: "[]".into(),
8952                dirty: true,
8953                pds_created: false,
8954                updated_at: "2026-09-13T21:22:40Z".into(),
8955            },
8956        )
8957        .await
8958        .unwrap();
8959        let cookie = session_cookie(&state, did, None);
8960        let resp = router(state.clone())
8961            .oneshot(
8962                Request::builder()
8963                    .method("POST")
8964                    .uri("/logout")
8965                    .header(header::COOKIE, cookie)
8966                    .body(Body::empty())
8967                    .unwrap(),
8968            )
8969            .await
8970            .unwrap();
8971        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8972
8973        let entries = log.lock().unwrap().clone();
8974        let flush = entries
8975            .iter()
8976            .position(|e| e.starts_with("/internal/repo "));
8977        let revoke = entries
8978            .iter()
8979            .position(|e| e.starts_with("/internal/revoke "));
8980        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8981        assert!(
8982            flush.is_some(),
8983            "sign-out did not attempt a flush before revoking: {entries:?}"
8984        );
8985        assert!(
8986            flush < revoke,
8987            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8988        );
8989    }
8990
8991    /// The policy, as a literal: the backstop the router calls "neutralises any
8992    /// XSS that slips past sanitization". `script-src 'self'` and no
8993    /// `'unsafe-inline'` on it are the two clauses that make it one.
8994    const EXPECTED_CSP: &str = "default-src 'self'; \
8995     script-src 'self'; \
8996     style-src 'self' 'unsafe-inline'; \
8997     img-src 'self' https: data:; \
8998     font-src 'self'; \
8999     connect-src 'self'; \
9000     form-action 'self'; \
9001     base-uri 'self'; \
9002     frame-ancestors 'none'; \
9003     object-src 'none'";
9004
9005    /// Build a `multipart/form-data` body carrying a single `file` field whose
9006    /// contents are `payload`, returning `(content_type, body_bytes)`.
9007    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
9008        let boundary = "----featherreadertestboundary";
9009        let mut body = Vec::new();
9010        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
9011        body.extend_from_slice(
9012            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
9013        );
9014        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
9015        body.extend_from_slice(payload);
9016        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
9017        (format!("multipart/form-data; boundary={boundary}"), body)
9018    }
9019
9020    #[tokio::test]
9021    async fn opml_import_oversize_upload_returns_413() {
9022        let state = test_state(&["did:plc:admin"]).await;
9023        let cookie = session_cookie(&state, "did:plc:admin", None);
9024        let app = router(state);
9025
9026        // A payload comfortably above the route cap.
9027        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
9028        let (content_type, body) = opml_multipart(&payload);
9029
9030        let resp = app
9031            .oneshot(
9032                Request::builder()
9033                    .method("POST")
9034                    .uri("/opml")
9035                    .header("content-type", content_type)
9036                    .header(header::COOKIE, cookie)
9037                    .body(Body::from(body))
9038                    .unwrap(),
9039            )
9040            .await
9041            .unwrap();
9042        assert_eq!(
9043            resp.status(),
9044            StatusCode::PAYLOAD_TOO_LARGE,
9045            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
9046        );
9047    }
9048
9049    /// **The route's own cap is what refuses this, not the framework's.**
9050    ///
9051    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
9052    /// the route's layer was a no-op — deleting it left every test green, and
9053    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
9054    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
9055    /// sits BETWEEN the two: over ours, under the framework's. Only the
9056    /// route's layer can refuse it — remove the layer and this payload is
9057    /// accepted, which is also what demonstrates the framework's default is
9058    /// the larger of the two.
9059    #[tokio::test]
9060    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
9061        let state = test_state(&["did:plc:admin"]).await;
9062        let cookie = session_cookie(&state, "did:plc:admin", None);
9063        let app = router(state);
9064
9065        // Between the two ceilings: the framework would accept this.
9066        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
9067        let (content_type, body) = opml_multipart(&payload);
9068
9069        let resp = app
9070            .oneshot(
9071                Request::builder()
9072                    .method("POST")
9073                    .uri("/opml")
9074                    .header("content-type", content_type)
9075                    .header(header::COOKIE, cookie)
9076                    .body(Body::from(body))
9077                    .unwrap(),
9078            )
9079            .await
9080            .unwrap();
9081        assert_eq!(
9082            resp.status(),
9083            StatusCode::PAYLOAD_TOO_LARGE,
9084            "a payload over the route's cap but under the framework's was accepted — \
9085             the route's own DefaultBodyLimit layer is not doing anything"
9086        );
9087    }
9088
9089    #[tokio::test]
9090    async fn opml_import_under_limit_upload_is_accepted() {
9091        let state = test_state(&["did:plc:admin"]).await;
9092        let cookie = session_cookie(&state, "did:plc:admin", None);
9093        let db = state.db.clone();
9094        let app = router(state);
9095
9096        // A small, valid OPML well under the cap: must be accepted (the handler
9097        // redirects to `/` or a flash), i.e. never 413.
9098        let opml = br#"<?xml version="1.0"?>
9099<opml version="2.0"><body>
9100  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
9101</body></opml>"#;
9102        let (content_type, body) = opml_multipart(opml);
9103
9104        let resp = app
9105            .oneshot(
9106                Request::builder()
9107                    .method("POST")
9108                    .uri("/opml")
9109                    .header("content-type", content_type)
9110                    .header(header::COOKIE, cookie)
9111                    .body(Body::from(body))
9112                    .unwrap(),
9113            )
9114            .await
9115            .unwrap();
9116        // **Assert it was ACCEPTED, not merely that it was not a 413.**
9117        //
9118        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
9119        // 500 satisfies — so making `import_opml` fail unconditionally left this
9120        // green. Three other OPML tests caught that mutation; the one whose name
9121        // promises to cover the under-cap case did not.
9122        assert_eq!(
9123            resp.status(),
9124            StatusCode::SEE_OTHER,
9125            "an under-cap OPML upload was not accepted (status {})",
9126            resp.status(),
9127        );
9128        // **303 alone is not acceptance.** `import_opml` redirects on several
9129        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
9130        // by a cap — so an import that stored nothing satisfied the status check.
9131        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
9132            .bind("https://example.com/feed.xml")
9133            .fetch_one(&db)
9134            .await
9135            .unwrap();
9136        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
9137        let location = resp
9138            .headers()
9139            .get(header::LOCATION)
9140            .and_then(|v| v.to_str().ok())
9141            .unwrap_or_default()
9142            .to_string();
9143        assert!(
9144            !location.starts_with("/login"),
9145            "the import bounced to login instead of being accepted: {location}",
9146        );
9147    }
9148
9149    #[tokio::test]
9150    async fn opml_import_logged_out_redirects_to_login() {
9151        // Logged-out callers are redirected before the body is consumed; assert
9152        // the auth short-circuit rather than a body-cap rejection.
9153        let state = test_state(&["did:plc:admin"]).await;
9154        let app = router(state);
9155
9156        let opml = b"<opml version=\"2.0\"><body></body></opml>";
9157        let (content_type, body) = opml_multipart(opml);
9158
9159        let resp = app
9160            .oneshot(
9161                Request::builder()
9162                    .method("POST")
9163                    .uri("/opml")
9164                    .header("content-type", content_type)
9165                    .body(Body::from(body))
9166                    .unwrap(),
9167            )
9168            .await
9169            .unwrap();
9170        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9171        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
9172    }
9173
9174    // -- delete-my-data (POST /account/delete) --------------------------------
9175
9176    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
9177    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
9178    /// channel) the DID it was asked to revoke. Enough to prove the delete
9179    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
9180    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
9181        use tokio::io::{AsyncReadExt, AsyncWriteExt};
9182        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9183        let addr = listener.local_addr().unwrap();
9184        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
9185        tokio::spawn(async move {
9186            let (mut sock, _) = listener.accept().await.unwrap();
9187            let mut buf = vec![0u8; 4096];
9188            let n = sock.read(&mut buf).await.unwrap();
9189            let req = String::from_utf8_lossy(&buf[..n]).to_string();
9190            // Pull the DID out of the JSON body (last line of the request).
9191            let did = req
9192                .split("\r\n\r\n")
9193                .nth(1)
9194                .and_then(|body| {
9195                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
9196                    v.get("did")?.as_str().map(str::to_string)
9197                })
9198                .unwrap_or_default();
9199            let is_revoke = req.starts_with("POST /internal/revoke");
9200            let body = serde_json::json!({
9201                "ok": true, "did": did, "revoked": true, "hadSession": true
9202            })
9203            .to_string();
9204            let resp = format!(
9205                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9206                body.len(),
9207                body
9208            );
9209            sock.write_all(resp.as_bytes()).await.unwrap();
9210            sock.flush().await.unwrap();
9211            let _ = tx.send(if is_revoke { did } else { String::new() });
9212        });
9213        (format!("http://{addr}"), rx)
9214    }
9215
9216    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
9217    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
9218        let defaults = Config::default();
9219        test_state_with_sidecar_and(
9220            allowed,
9221            sidecar_url,
9222            defaults.standard_site,
9223            defaults.max_feeds_global,
9224        )
9225        .await
9226    }
9227
9228    /// [`test_state_with_sidecar`] with the standard.site flag and the global
9229    /// feeds ceiling chosen — the two settings the at:// paths branch on.
9230    async fn test_state_with_sidecar_and(
9231        allowed: &[&str],
9232        sidecar_url: &str,
9233        standard_site: bool,
9234        max_feeds_global: i64,
9235    ) -> AppState {
9236        let db = store::init_url("sqlite::memory:").await.unwrap();
9237        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
9238        store::ensure_seed(&db, &dids).await.unwrap();
9239        let mut config = Config {
9240            allowed_dids: dids,
9241            cookie_secret: "test-cookie-secret-000".to_string(),
9242            beta_cap: 3,
9243            standard_site,
9244            max_feeds_global,
9245            ..Config::default()
9246        };
9247        config.sidecar.public_url = sidecar_url.to_string();
9248        config.sidecar.internal_url = sidecar_url.to_string();
9249        AppState::new(config, db).unwrap()
9250    }
9251
9252    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
9253    /// the sidecar revoke for that DID, and clears the session cookie.
9254    #[tokio::test]
9255    async fn account_delete_purges_rows_and_triggers_revoke() {
9256        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
9257        let did = "did:plc:leaver";
9258        let state = test_state_with_sidecar(&[], &sidecar_url).await;
9259
9260        // Seed the DID with local rows across the per-DID tables.
9261        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
9262            .await
9263            .unwrap();
9264        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
9265        store::mint_code(&state.db, did, 3600).await.unwrap();
9266        assert!(store::has_beta_access(&state.db, did).await.unwrap());
9267
9268        let cookie = session_cookie(&state, did, Some("leaver.example"));
9269        let app = router(state.clone());
9270
9271        let resp = app
9272            .oneshot(
9273                Request::builder()
9274                    .method("POST")
9275                    .uri("/account/delete")
9276                    .header(header::COOKIE, cookie)
9277                    .header("content-type", "application/x-www-form-urlencoded")
9278                    .body(Body::from("confirm=DELETE"))
9279                    .unwrap(),
9280            )
9281            .await
9282            .unwrap();
9283
9284        // Signed out: redirect to /login with the cookie cleared.
9285        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9286        assert!(resp
9287            .headers()
9288            .get(header::LOCATION)
9289            .unwrap()
9290            .to_str()
9291            .unwrap()
9292            .starts_with("/login"));
9293        let set_cookie = resp
9294            .headers()
9295            .get(header::SET_COOKIE)
9296            .unwrap()
9297            .to_str()
9298            .unwrap();
9299        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
9300
9301        // The sidecar revoke was called for exactly this DID.
9302        //
9303        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
9304        // that simply never called the sidecar — hung this test forever instead
9305        // of failing it: a wedged CI job rather than a red one, which is the
9306        // worse of the two signals because nobody reads it as a defect.
9307        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
9308            .await
9309            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
9310            .unwrap();
9311        assert_eq!(
9312            revoked_did, did,
9313            "sidecar revoke must fire for the caller DID"
9314        );
9315
9316        // Local rows are gone.
9317        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
9318        let codes: i64 =
9319            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
9320                .bind(did)
9321                .fetch_one(&state.db)
9322                .await
9323                .unwrap();
9324        assert_eq!(codes, 0);
9325    }
9326
9327    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
9328    /// nothing and bounces back to /manage.
9329    #[tokio::test]
9330    async fn account_delete_without_confirm_is_a_noop() {
9331        let did = "did:plc:staying";
9332        let state = test_state(&[]).await;
9333        store::grant_access(&state.db, did, None, "test", None)
9334            .await
9335            .unwrap();
9336        let cookie = session_cookie(&state, did, None);
9337        let app = router(state.clone());
9338
9339        let resp = app
9340            .oneshot(
9341                Request::builder()
9342                    .method("POST")
9343                    .uri("/account/delete")
9344                    .header(header::COOKIE, cookie)
9345                    .header("content-type", "application/x-www-form-urlencoded")
9346                    .body(Body::from("confirm=nope"))
9347                    .unwrap(),
9348            )
9349            .await
9350            .unwrap();
9351
9352        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9353        assert!(resp
9354            .headers()
9355            .get(header::LOCATION)
9356            .unwrap()
9357            .to_str()
9358            .unwrap()
9359            .starts_with("/manage"));
9360        // Nothing deleted.
9361        assert!(store::has_beta_access(&state.db, did).await.unwrap());
9362    }
9363
9364    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
9365    /// this harness — the default sidecar URL is not served), a DID must STILL
9366    /// be unable to read or mutate an entry in a feed it does not subscribe to.
9367    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
9368    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
9369    /// every cached feed.
9370    #[tokio::test]
9371    async fn pds_outage_does_not_widen_cross_did_access() {
9372        let did_a = "did:plc:aaaa";
9373        let state = test_state(&[]).await;
9374        store::grant_access(&state.db, did_a, None, "test", None)
9375            .await
9376            .unwrap();
9377
9378        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
9379        // lives in feed_b — the one A must never touch during the outage.
9380        let feed_a = store::upsert_feed(
9381            &state.db,
9382            &store::NewFeed {
9383                url: "https://a.example/feed.xml".to_string(),
9384                title: Some("A".to_string()),
9385                ..Default::default()
9386            },
9387        )
9388        .await
9389        .unwrap();
9390        let feed_b = store::upsert_feed(
9391            &state.db,
9392            &store::NewFeed {
9393                url: "https://b.example/feed.xml".to_string(),
9394                title: Some("B".to_string()),
9395                ..Default::default()
9396            },
9397        )
9398        .await
9399        .unwrap();
9400        store::insert_entries(
9401            &state.db,
9402            feed_b,
9403            &[store::NewEntry {
9404                guid: "b-1".to_string(),
9405                url: Some("https://b.example/1".to_string()),
9406                title: Some("B one".to_string()),
9407                published: Some("2026-07-11T00:00:00Z".to_string()),
9408                content_html: Some("<p>secret B body</p>".to_string()),
9409                ..Default::default()
9410            }],
9411            0,
9412        )
9413        .await
9414        .unwrap();
9415        // A subscribes ONLY to feed_a.
9416        store::replace_sub_refs(&state.db, did_a, &[feed_a])
9417            .await
9418            .unwrap();
9419        // Read B's entry id via a transient sub_ref, then drop it so only the
9420        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
9421        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
9422            .await
9423            .unwrap();
9424        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
9425            .await
9426            .unwrap()[0]
9427            .id;
9428        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
9429            .await
9430            .unwrap();
9431
9432        let cookie = session_cookie(&state, did_a, None);
9433        let app = router(state.clone());
9434
9435        // GET /entries/{b} as A → 404 even during the outage.
9436        let get_b = app
9437            .clone()
9438            .oneshot(
9439                Request::builder()
9440                    .method("GET")
9441                    .uri(format!("/entries/{b_entry_id}"))
9442                    .header(header::COOKIE, cookie.clone())
9443                    .body(Body::empty())
9444                    .unwrap(),
9445            )
9446            .await
9447            .unwrap();
9448        assert_eq!(
9449            get_b.status(),
9450            StatusCode::NOT_FOUND,
9451            "A must not read B's entry during a PDS outage"
9452        );
9453
9454        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
9455        let read_b = app
9456            .oneshot(
9457                Request::builder()
9458                    .method("POST")
9459                    .uri(format!("/entries/{b_entry_id}/read"))
9460                    .header(header::COOKIE, cookie)
9461                    .header("content-type", "application/x-www-form-urlencoded")
9462                    .body(Body::from("read=true"))
9463                    .unwrap(),
9464            )
9465            .await
9466            .unwrap();
9467        assert_eq!(
9468            read_b.status(),
9469            StatusCode::NOT_FOUND,
9470            "A must not mark B's entry read during a PDS outage"
9471        );
9472
9473        // The fallback must NOT have widened A's sub_ref to feed_b.
9474        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
9475            .bind(did_a)
9476            .fetch_all(&state.db)
9477            .await
9478            .unwrap();
9479        assert_eq!(
9480            a_feed_ids,
9481            vec![feed_a],
9482            "outage fallback must not add feeds A never subscribed to"
9483        );
9484        // And B's entry has zero read-state (A's attempt did not mutate).
9485        let es_count: i64 =
9486            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9487                .bind(did_a)
9488                .bind(b_entry_id)
9489                .fetch_one(&state.db)
9490                .await
9491                .unwrap();
9492        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9493    }
9494
9495    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
9496    /// nothing. The other arm is counted separately.**
9497    ///
9498    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
9499    /// error would make the metric noisy in exactly the case that is fine.
9500    ///
9501    /// But `revoke_everywhere` has TWO arms, and a review found that counting
9502    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
9503    /// revocation failed. For anyone who logged in before the cutover the sidecar
9504    /// store is the only one holding tokens, so the rust arm correctly says
9505    /// NoSession and the metric said nothing was wrong. Both arms are now
9506    /// recorded, distinguished by the backend column — so this test pins the
9507    /// BACKEND as well as the outcome.
9508    #[tokio::test]
9509    async fn a_logout_with_no_session_counts_as_success() {
9510        let did = "did:plc:aaaa";
9511        let state = test_state(&[]).await;
9512        assert!(
9513            state.oauth.is_some(),
9514            "meaningless without an oauth runtime; the revoke arm would be skipped",
9515        );
9516
9517        revoke_everywhere(&state, did).await;
9518        let rows = state.metrics.snapshot();
9519        let find = |b: crate::metrics::Backend| {
9520            rows.iter()
9521                .find(|r| r.op == "oauth_revoke" && r.backend == b)
9522                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9523        };
9524
9525        // Rust arm: nothing stored for this DID, so NoSession -> ok.
9526        let rust = find(crate::metrics::Backend::Rust);
9527        assert_eq!(
9528            rust.stats.err_count, 0,
9529            "NoSession was counted as a failure; logout is idempotent",
9530        );
9531        assert_eq!(rust.stats.ok_count, 1);
9532
9533        // Sidecar arm: unreachable in a test, so it must be recorded as an
9534        // ERROR under its own backend — not silently dropped, and not folded
9535        // into the rust row.
9536        let sidecar = find(crate::metrics::Backend::Sidecar);
9537        assert_eq!(
9538            sidecar.stats.err_count, 1,
9539            "a failed sidecar revoke was not counted",
9540        );
9541    }
9542
9543    /// **`Failed` must count as an error — the half the metric exists for.**
9544    ///
9545    /// A review found this unpinned: replacing the mapping with
9546    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
9547    /// asserted the `NoSession -> ok` half, so the branch that actually means
9548    /// "the PDS still holds tokens we asked it to drop" was untested.
9549    ///
9550    /// Driven through the same handler, with a session present but the PDS
9551    /// unreachable, so `sign_out_discovering` returns `Failed`.
9552    #[tokio::test]
9553    async fn a_failed_rust_revoke_counts_as_an_error() {
9554        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9555        let state = test_state(&[]).await;
9556        let runtime = state.oauth.as_deref().expect("oauth runtime");
9557        crate::oauth::store::put_session(
9558            &state.db,
9559            &runtime.codec,
9560            &crate::oauth::store::OAuthSession {
9561                sub: did.into(),
9562                issuer: "https://auth.invalid".into(),
9563                aud: "https://pds.invalid".into(),
9564                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9565                    .to_jwk_json()
9566                    .unwrap(),
9567                access_token: "at".into(),
9568                refresh_token: "rt".into(),
9569                token_type: "DPoP".into(),
9570                granted_scope: "atproto".into(),
9571                expires_at: Some(crate::store::now_unix() + 3600),
9572            },
9573        )
9574        .await
9575        .unwrap();
9576
9577        revoke_everywhere(&state, did).await;
9578
9579        let rows = state.metrics.snapshot();
9580        let rust = rows
9581            .iter()
9582            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9583            .expect("no rust oauth_revoke row");
9584        assert_eq!(
9585            rust.stats.err_count, 1,
9586            "an unreachable PDS must count as a revocation failure",
9587        );
9588        assert_eq!(rust.stats.ok_count, 0);
9589    }
9590
9591    /// **The `href` defence is now carried by the TYPE, not by remembering.**
9592    ///
9593    /// `EntryRow.link` used to be a `String`, and the guard was "call
9594    /// `net::safe_link` before assigning it". Deleting that call left all 679
9595    /// tests passing — a live XSS defence with nothing protecting it.
9596    ///
9597    /// `SafeLink` has no `From<String>` and no public member, so the only way to
9598    /// get foreign input into an `href` is `external`, which does the check
9599    /// itself. This test pins that constructor; the *wiring* is now pinned by
9600    /// the compiler, which is the part a test could never hold down.
9601    ///
9602    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
9603    /// so the template renders the row WITHOUT an anchor. Dropping the row
9604    /// instead would make the record unremovable, because the un-save button
9605    /// lives on it.
9606    #[test]
9607    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9608        for hostile in [
9609            "javascript:alert(1)",
9610            "JavaScript:alert(1)",
9611            "  javascript:alert(1)",
9612            "data:text/html;base64,PHNjcmlwdD4=",
9613            "vbscript:msgbox(1)",
9614            "file:///etc/passwd",
9615            // Protocol-relative: inherits the page's scheme, so it is an
9616            // off-site link wearing a same-site costume. Carried over from the
9617            // test this one replaces, which was its only unique input.
9618            "//evil.example/path",
9619        ] {
9620            let link = SafeLink::external(hostile);
9621            assert!(
9622                link.is_empty(),
9623                "{hostile:?} produced a non-empty href: {link}",
9624            );
9625            assert!(
9626                !link.to_string().to_ascii_lowercase().contains("script"),
9627                "{hostile:?} leaked into the rendered link",
9628            );
9629        }
9630
9631        // And the other direction: a check that rejects everything would satisfy
9632        // the loop above while breaking every real saved record.
9633        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9634            let link = SafeLink::external(good);
9635            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9636            assert_eq!(link.to_string(), good);
9637        }
9638    }
9639
9640    /// **The WIRING, not the helper — this is the one that catches the real
9641    /// mistake.**
9642    ///
9643    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
9644    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
9645    /// *calls* it, and a review proved that gap was live twice over: swapping
9646    /// `external` for the app-path constructor, and constructing the tuple
9647    /// directly, both restored the whole `javascript:` hole with every test
9648    /// green. The type now blocks both — `entry` takes an `i64`, and the field
9649    /// lives in another module — but the wiring deserves a test of its own
9650    /// rather than resting on the shape of a signature.
9651    ///
9652    /// Renders the actual row through the actual handler, from a record whose
9653    /// URL is hostile.
9654    #[tokio::test]
9655    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9656        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9657        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9658        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9659        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9660
9661        let resp = router(state)
9662            .oneshot(
9663                Request::builder()
9664                    .uri("/?view=starred")
9665                    .body(Body::empty())
9666                    .unwrap(),
9667            )
9668            .await
9669            .unwrap();
9670        assert_eq!(resp.status(), StatusCode::OK);
9671        let body = String::from_utf8(
9672            axum::body::to_bytes(resp.into_body(), usize::MAX)
9673                .await
9674                .unwrap()
9675                .to_vec(),
9676        )
9677        .unwrap();
9678
9679        // Not in an href, and not as the title either — the title falls back to
9680        // the URL for links we DO render, so both paths must withhold it.
9681        assert!(
9682            !body.to_ascii_lowercase().contains("javascript:"),
9683            "the hostile scheme reached the rendered page",
9684        );
9685        // But the row must survive: the un-save button lives on it, so dropping
9686        // the row would make the record unremovable from here.
9687        assert!(
9688            body.contains("unusable link"),
9689            "the row was dropped instead of rendering without an anchor",
9690        );
9691    }
9692
9693    /// **The reader view's two `href`s, through the actual handler.**
9694    ///
9695    /// The sibling above covers the LIST row. `entry.html` has its own pair of
9696    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
9697    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
9698    ///
9699    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
9700    /// this was never a live hole. But that guard is procedural and sits a long
9701    /// way from the `href`: it holds only as long as every future writer to
9702    /// `entries.url` remembers to go through `feed.rs`. This test does not
9703    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
9704    /// is precisely the state the ingest check cannot speak for.
9705    ///
9706    /// **Both directions, deliberately.** A fix that renders no link at all
9707    /// satisfies every negative assertion here, and would break every real
9708    /// entry. The second half is what makes the first half mean something.
9709    #[tokio::test]
9710    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9711        let did = "did:plc:readerhref";
9712        let state = test_state(&[]).await;
9713        store::grant_access(&state.db, did, None, "test", None)
9714            .await
9715            .unwrap();
9716        let feed = store::upsert_feed(
9717            &state.db,
9718            &store::NewFeed {
9719                url: "https://href.example/feed.xml".to_string(),
9720                title: Some("Href".to_string()),
9721                ..Default::default()
9722            },
9723        )
9724        .await
9725        .unwrap();
9726        // Straight into the column, bypassing `feed.rs` — the whole point.
9727        store::insert_entries(
9728            &state.db,
9729            feed,
9730            &[
9731                store::NewEntry {
9732                    guid: "hostile-1".to_string(),
9733                    url: Some("javascript:alert(1)".to_string()),
9734                    title: Some("Hostile entry".to_string()),
9735                    published: Some("2026-07-11T00:00:00Z".to_string()),
9736                    ..Default::default()
9737                },
9738                store::NewEntry {
9739                    guid: "benign-1".to_string(),
9740                    url: Some("https://href.example/post".to_string()),
9741                    title: Some("Benign entry".to_string()),
9742                    published: Some("2026-07-10T00:00:00Z".to_string()),
9743                    ..Default::default()
9744                },
9745            ],
9746            0,
9747        )
9748        .await
9749        .unwrap();
9750        store::replace_sub_refs(&state.db, did, &[feed])
9751            .await
9752            .unwrap();
9753        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9754        let id_of = |guid: &str| {
9755            rows.iter()
9756                .find(|r| r.guid == guid)
9757                .unwrap_or_else(|| panic!("{guid} was not inserted"))
9758                .id
9759        };
9760
9761        let cookie = session_cookie(&state, did, None);
9762        let app = router(state.clone());
9763
9764        let render = |id: i64| {
9765            let app = app.clone();
9766            let cookie = cookie.clone();
9767            async move {
9768                let resp = app
9769                    .oneshot(
9770                        Request::builder()
9771                            .method("GET")
9772                            .uri(format!("/entries/{id}"))
9773                            .header(header::COOKIE, cookie)
9774                            .body(Body::empty())
9775                            .unwrap(),
9776                    )
9777                    .await
9778                    .unwrap();
9779                assert_eq!(resp.status(), StatusCode::OK);
9780                String::from_utf8(
9781                    axum::body::to_bytes(resp.into_body(), usize::MAX)
9782                        .await
9783                        .unwrap()
9784                        .to_vec(),
9785                )
9786                .unwrap()
9787            }
9788        };
9789
9790        let hostile = render(id_of("hostile-1")).await;
9791        // The reader page for THIS entry actually rendered. Without this the
9792        // three negatives below are satisfied by an empty body.
9793        assert!(
9794            hostile.contains("Hostile entry"),
9795            "the reader did not render the entry: {hostile}",
9796        );
9797        assert!(
9798            !hostile.to_ascii_lowercase().contains("javascript:"),
9799            "the hostile scheme reached the reader page: {hostile}",
9800        );
9801        // Not merely escaped — the template took its no-link branch. Both
9802        // `href`s are gated on the same `Option`, so this covers the byline
9803        // link and the action-bar button together.
9804        assert!(
9805            !hostile.contains("actionbar-open"),
9806            "the action bar rendered an open-original link for a refused URL: {hostile}",
9807        );
9808        assert!(
9809            !hostile.contains("Original \u{2197}"),
9810            "the byline rendered an original link for a refused URL: {hostile}",
9811        );
9812
9813        // The other direction: a legitimate entry still links out, so "render
9814        // nothing" cannot pass as a fix.
9815        let benign = render(id_of("benign-1")).await;
9816        assert!(
9817            benign.contains("Benign entry"),
9818            "the reader did not render the benign entry: {benign}",
9819        );
9820        // BOTH `href`s, counted. The negatives above fire on the action bar
9821        // first, so without this the byline needle `Original \u{2197}` is never
9822        // once observed failing — a misspelled needle would pass forever.
9823        assert_eq!(
9824            benign
9825                .matches(r#"href="https://href.example/post""#)
9826                .count(),
9827            2,
9828            "entry.html has two `href`s for the entry URL — the byline link and \
9829             the action-bar button — and this render produced a different \
9830             number: {benign}",
9831        );
9832        assert!(
9833            benign.contains("actionbar-open"),
9834            "a legitimate entry lost its open-original button: {benign}",
9835        );
9836        assert!(
9837            benign.contains("Original \u{2197}"),
9838            "a legitimate entry lost its byline link: {benign}",
9839        );
9840    }
9841
9842    /// **The outage fallback must not widen what the caller can READ — and the
9843    /// sibling test above can only see what it WRITES.**
9844    ///
9845    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
9846    /// on `entry_state`: the fallback's side effects. But the fail-open it names
9847    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
9848    /// leaks through the list it *hands back* — the sidebar and the reader render
9849    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
9850    /// perfectly honest and every existing assertion stays green.
9851    ///
9852    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
9853    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
9854    /// exact historical bug the fallback's comment describes — left **all 663
9855    /// tests passing**. Cross-tenant isolation is the one property this project
9856    /// cannot regress quietly, and nothing observed it.
9857    ///
9858    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
9859    /// user, and it deliberately does not look at `sub_ref` at all — that half is
9860    /// already covered above.
9861    #[tokio::test]
9862    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9863        let did_a = "did:plc:aaaa";
9864        let state = test_state(&[]).await;
9865        store::grant_access(&state.db, did_a, None, "test", None)
9866            .await
9867            .unwrap();
9868
9869        let feed_a = store::upsert_feed(
9870            &state.db,
9871            &store::NewFeed {
9872                url: "https://a.example/feed.xml".to_string(),
9873                title: Some("A".to_string()),
9874                ..Default::default()
9875            },
9876        )
9877        .await
9878        .unwrap();
9879        let _feed_b = store::upsert_feed(
9880            &state.db,
9881            &store::NewFeed {
9882                url: "https://b.example/feed.xml".to_string(),
9883                title: Some("B".to_string()),
9884                ..Default::default()
9885            },
9886        )
9887        .await
9888        .unwrap();
9889        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
9890        // to nobody — exactly the row a whole-cache fallback would hand to A.
9891        store::replace_sub_refs(&state.db, did_a, &[feed_a])
9892            .await
9893            .unwrap();
9894
9895        // No sidecar and no PDS are reachable from a test, so
9896        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
9897        // that, rather than assuming it: if the repo ever starts succeeding here,
9898        // this test would silently stop exercising the fallback at all.
9899        assert!(
9900            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9901            "this test is only meaningful on the outage path; the repo answered",
9902        );
9903
9904        let resolved = resolve_subscriptions(&state, did_a).await;
9905
9906        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9907        assert_eq!(
9908            urls,
9909            vec!["https://a.example/feed.xml"],
9910            "the outage fallback must return the caller's OWN subscriptions only; \
9911             any other feed here is cross-tenant read access granted by an outage",
9912        );
9913    }
9914
9915    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
9916    /// seeding `did` a beta seat + session-capable state.
9917    async fn test_state_with_caps(
9918        did: &str,
9919        max_subs_per_did: i64,
9920        max_feeds_global: i64,
9921    ) -> AppState {
9922        let db = store::init_url("sqlite::memory:").await.unwrap();
9923        let config = Config {
9924            cookie_secret: "test-cookie-secret-000".to_string(),
9925            beta_cap: 100,
9926            max_subs_per_did,
9927            max_feeds_global,
9928            ..Config::default()
9929        };
9930        store::grant_access(&db, did, None, "test", None)
9931            .await
9932            .unwrap();
9933        AppState::new(config, db).unwrap()
9934    }
9935
9936    /// An OPML document with `n` distinct public feeds.
9937    fn opml_with_feeds(n: usize) -> String {
9938        let mut outlines = String::new();
9939        for i in 0..n {
9940            outlines.push_str(&format!(
9941                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9942            ));
9943        }
9944        format!(
9945            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9946        )
9947    }
9948
9949    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
9950    /// distinct new feeds than the shared cache can hold caches only up to the
9951    /// ceiling — the rest are trimmed. (Regression: the import loop previously
9952    /// bypassed `max_feeds_global` entirely.)
9953    #[tokio::test]
9954    async fn opml_import_enforces_global_feeds_ceiling() {
9955        let did = "did:plc:importer";
9956        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
9957        let state = test_state_with_caps(did, 0, 3).await;
9958        let cookie = session_cookie(&state, did, None);
9959        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9960        let app = router(state.clone());
9961
9962        let resp = app
9963            .oneshot(
9964                Request::builder()
9965                    .method("POST")
9966                    .uri("/opml")
9967                    .header(header::COOKIE, cookie)
9968                    .header("content-type", ct)
9969                    .body(Body::from(body))
9970                    .unwrap(),
9971            )
9972            .await
9973            .unwrap();
9974        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9975
9976        let feeds = store::count_feeds(&state.db).await.unwrap();
9977        assert!(
9978            feeds <= 3,
9979            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9980        );
9981    }
9982
9983    /// POST an OPML of `n` feeds as `did` against a strict fake PDS behind the
9984    /// sidecar, and return the flash it redirected with plus the fake's log.
9985    async fn import_against_strict_pds(
9986        did: &str,
9987        n: usize,
9988        fail_call: Option<usize>,
9989    ) -> (String, crate::atproto::tests::ApplyWritesLog) {
9990        let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
9991        let state = test_state_with_sidecar(&[did], &sidecar).await;
9992        let cookie = session_cookie(&state, did, None);
9993        let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
9994        let resp = router(state)
9995            .oneshot(
9996                Request::builder()
9997                    .method("POST")
9998                    .uri("/opml")
9999                    .header(header::COOKIE, cookie)
10000                    .header("content-type", ct)
10001                    .body(Body::from(body))
10002                    .unwrap(),
10003            )
10004            .await
10005            .unwrap();
10006        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10007        let loc = resp.headers()[header::LOCATION].to_str().unwrap();
10008        let flash = url::Url::parse(&format!("http://x{loc}"))
10009            .unwrap()
10010            .query_pairs()
10011            .find(|(k, _)| k == "flash")
10012            .map(|(_, v)| v.into_owned())
10013            .unwrap_or_default();
10014        (flash, log)
10015    }
10016
10017    /// **An OPML import of more than 200 feeds succeeds** against a PDS that
10018    /// refuses more than 200 writes a call, as the reference PDS does. It used
10019    /// to go out as one `applyWrites` and fail outright, importing nothing.
10020    #[tokio::test]
10021    async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
10022        let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
10023        assert_eq!(flash, "Imported 450 feeds", "{flash}");
10024        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
10025    }
10026
10027    /// **A part-landed import says so.** Chunk 2 of 3 fails: chunk 1's 200
10028    /// feeds are in the reader's repo, and "nothing was imported" — what the
10029    /// handler said for any failure — would be false.
10030    #[tokio::test]
10031    async fn opml_import_that_part_lands_reports_what_landed() {
10032        let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
10033        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
10034        assert!(
10035            flash.contains("200 of 450"),
10036            "the landed count is not reported: {flash}"
10037        );
10038        assert!(
10039            !flash.contains("nothing was imported"),
10040            "200 feeds landed and the reader was told none did: {flash}"
10041        );
10042    }
10043
10044    /// A batch that failed on its first call still reports that nothing was
10045    /// imported — true, since nothing after a failed call is sent.
10046    #[tokio::test]
10047    async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
10048        let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
10049        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
10050        assert!(flash.contains("nothing was imported"), "{flash}");
10051    }
10052
10053    /// **A malformed `at://` on the add path is "not a kind of feed we take",
10054    /// not "private/paid".** The first gate was the privacy classifier, whose
10055    /// at:// arm fails closed as `Private` for anything not a well-formed
10056    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
10057    /// the private-feed flash and a "refused private/paid feed" log line. On
10058    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
10059    /// feed". Storability is decided first for an at:// input, with its own
10060    /// message.
10061    #[tokio::test]
10062    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
10063        let did = "did:plc:typoist";
10064        let state = test_state_with_caps(did, 0, 0).await;
10065        let cookie = session_cookie(&state, did, None);
10066        for input in [
10067            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
10068            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10069        ] {
10070            let resp = router(state.clone())
10071                .oneshot(
10072                    Request::builder()
10073                        .method("POST")
10074                        .uri("/subscriptions")
10075                        .header(header::COOKIE, cookie.clone())
10076                        .header("content-type", "application/x-www-form-urlencoded")
10077                        .body(Body::from(format!("url={input}")))
10078                        .unwrap(),
10079                )
10080                .await
10081                .unwrap();
10082            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10083            let loc = resp
10084                .headers()
10085                .get(header::LOCATION)
10086                .unwrap()
10087                .to_str()
10088                .unwrap();
10089            assert!(
10090                loc.contains("kind%20of%20feed"),
10091                "expected the unsupported-feed flash for {input}, got {loc}"
10092            );
10093            assert!(
10094                !loc.contains("Private"),
10095                "a storability refusal was reported as a privacy one for {input}: {loc}"
10096            );
10097        }
10098        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10099    }
10100
10101    /// **An OPML entry this instance cannot store is counted and reported, not
10102    /// silently dropped.** The storability `continue` incremented nothing,
10103    /// while the privacy branch beside it produced a user-visible label — so
10104    /// an OPML exported from a standard.site-enabled instance imported
10105    /// "successfully" with entries missing and no reason given. The reader is
10106    /// told how many, and why.
10107    #[tokio::test]
10108    async fn opml_import_reports_entries_this_instance_cannot_store() {
10109        let did = "did:plc:renamer4";
10110        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10111        let state = test_state_with_sidecar(&[did], &sidecar).await;
10112        assert!(!state.config.standard_site);
10113        let opml = format!(
10114            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10115             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10116             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10117             </body></opml>"
10118        );
10119        let (ct, body) = opml_multipart(opml.as_bytes());
10120        let cookie = session_cookie(&state, did, None);
10121        let resp = router(state.clone())
10122            .oneshot(
10123                Request::builder()
10124                    .method("POST")
10125                    .uri("/opml")
10126                    .header(header::COOKIE, cookie)
10127                    .header("content-type", ct)
10128                    .body(Body::from(body))
10129                    .unwrap(),
10130            )
10131            .await
10132            .unwrap();
10133        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10134        let loc = resp
10135            .headers()
10136            .get(header::LOCATION)
10137            .unwrap()
10138            .to_str()
10139            .unwrap();
10140        assert!(
10141            loc.contains("Imported%201%20feed"),
10142            "unexpected flash: {loc}"
10143        );
10144        assert!(
10145            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
10146            "the dropped entry was not reported: {loc}"
10147        );
10148        // Reported by count only: the at-URI itself is not echoed back.
10149        assert!(
10150            !loc.contains("site.standard.publication"),
10151            "the URI was echoed: {loc}"
10152        );
10153    }
10154
10155    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
10156    /// cap imports zero new feeds.
10157    #[tokio::test]
10158    async fn opml_import_enforces_per_did_cap() {
10159        let did = "did:plc:capped";
10160        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
10161        let state = test_state_with_caps(did, 2, 0).await;
10162        let existing_a = store::upsert_feed(
10163            &state.db,
10164            &store::NewFeed {
10165                url: "https://have-a.example/feed.xml".to_string(),
10166                ..Default::default()
10167            },
10168        )
10169        .await
10170        .unwrap();
10171        let existing_b = store::upsert_feed(
10172            &state.db,
10173            &store::NewFeed {
10174                url: "https://have-b.example/feed.xml".to_string(),
10175                ..Default::default()
10176            },
10177        )
10178        .await
10179        .unwrap();
10180        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
10181            .await
10182            .unwrap();
10183        let before = store::count_feeds(&state.db).await.unwrap();
10184
10185        let cookie = session_cookie(&state, did, None);
10186        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10187        let app = router(state.clone());
10188        let resp = app
10189            .oneshot(
10190                Request::builder()
10191                    .method("POST")
10192                    .uri("/opml")
10193                    .header(header::COOKIE, cookie)
10194                    .header("content-type", ct)
10195                    .body(Body::from(body))
10196                    .unwrap(),
10197            )
10198            .await
10199            .unwrap();
10200        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10201        // Headroom was 0 → no new feeds imported into the shared cache.
10202        let after = store::count_feeds(&state.db).await.unwrap();
10203        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
10204    }
10205
10206    /// Single-add per-DID cap: a DID at its subscription cap is refused before
10207    /// any fetch, with the limit flash.
10208    #[tokio::test]
10209    async fn single_add_enforces_per_did_cap() {
10210        let did = "did:plc:subcapped";
10211        let state = test_state_with_caps(did, 1, 0).await;
10212        let f = store::upsert_feed(
10213            &state.db,
10214            &store::NewFeed {
10215                url: "https://have.example/feed.xml".to_string(),
10216                ..Default::default()
10217            },
10218        )
10219        .await
10220        .unwrap();
10221        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
10222        let cookie = session_cookie(&state, did, None);
10223        let app = router(state.clone());
10224        let resp = app
10225            .oneshot(
10226                Request::builder()
10227                    .method("POST")
10228                    .uri("/subscriptions")
10229                    .header(header::COOKIE, cookie)
10230                    .header("content-type", "application/x-www-form-urlencoded")
10231                    .body(Body::from("url=https://another.example/feed.xml"))
10232                    .unwrap(),
10233            )
10234            .await
10235            .unwrap();
10236        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10237        let loc = resp
10238            .headers()
10239            .get(header::LOCATION)
10240            .unwrap()
10241            .to_str()
10242            .unwrap();
10243        assert!(
10244            loc.contains("Subscription%20limit%20reached"),
10245            "expected sub-limit flash, got {loc}"
10246        );
10247    }
10248
10249    /// `GET /` renders at most one page of rows and offers a way to the rest.
10250    ///
10251    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
10252    /// `LIMIT`, article bodies included — and hand the lot to the template. With
10253    /// 250 entries that is the whole list in one response; with a real backlog on
10254    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
10255    /// is capped, the heading still reports the true total, and page 2 is
10256    /// reachable and disjoint.
10257    #[tokio::test]
10258    async fn the_reader_index_pages_instead_of_rendering_everything() {
10259        let did = "did:plc:pager";
10260        let state = test_state(&[]).await;
10261        store::grant_access(&state.db, did, None, "test", None)
10262            .await
10263            .unwrap();
10264        let feed = store::upsert_feed(
10265            &state.db,
10266            &store::NewFeed {
10267                url: "https://pager.example/feed.xml".to_string(),
10268                title: Some("Pager".to_string()),
10269                ..Default::default()
10270            },
10271        )
10272        .await
10273        .unwrap();
10274        let total = 250_usize;
10275        let entries: Vec<store::NewEntry> = (0..total)
10276            .map(|i| store::NewEntry {
10277                guid: format!("p-{i:04}"),
10278                url: Some(format!("https://pager.example/{i}")),
10279                title: Some(format!("Article {i:04}")),
10280                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
10281                content_html: Some("x".repeat(4_000)),
10282                ..Default::default()
10283            })
10284            .collect();
10285        store::insert_entries(&state.db, feed, &entries, 0)
10286            .await
10287            .unwrap();
10288        store::replace_sub_refs(&state.db, did, &[feed])
10289            .await
10290            .unwrap();
10291
10292        let cookie = session_cookie(&state, did, None);
10293        let app = router(state.clone());
10294        let get = |uri: &str| {
10295            let app = app.clone();
10296            let cookie = cookie.clone();
10297            let uri = uri.to_string();
10298            async move {
10299                let resp = app
10300                    .oneshot(
10301                        Request::builder()
10302                            .uri(uri)
10303                            .header(header::COOKIE, cookie)
10304                            .body(Body::empty())
10305                            .unwrap(),
10306                    )
10307                    .await
10308                    .unwrap();
10309                assert_eq!(resp.status(), StatusCode::OK);
10310                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
10311                    .await
10312                    .unwrap();
10313                String::from_utf8(bytes.to_vec()).unwrap()
10314            }
10315        };
10316
10317        let page1 = get("/").await;
10318        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
10319        // over-count: each row carries several (the link plus the read/star
10320        // forms).
10321        let rows1 = page1.matches("<li class=\"entry").count();
10322        assert!(
10323            rows1 <= ENTRIES_PER_PAGE as usize,
10324            "page 1 rendered {rows1} entry links; the list is unbounded"
10325        );
10326        assert!(
10327            rows1 > 0,
10328            "page 1 rendered nothing at all: the page bound swallowed the list"
10329        );
10330        // The count is the TRUE total, not the page size — otherwise paging
10331        // would quietly relabel a 250-entry backlog as a 100-entry one.
10332        assert!(
10333            page1.contains("250 entries"),
10334            "heading must report the full total, not the page"
10335        );
10336        assert!(
10337            page1.contains("page=2"),
10338            "no way to reach the rest of the list: {}",
10339            &page1[..page1.len().min(400)]
10340        );
10341        // The body never belongs in a list response.
10342        assert!(
10343            !page1.contains(&"x".repeat(4_000)),
10344            "the list response carried an article body"
10345        );
10346
10347        let page2 = get("/?page=2").await;
10348        assert!(
10349            page2.matches("<li class=\"entry").count() > 0,
10350            "page 2 rendered no rows at all"
10351        );
10352        assert!(
10353            page2.contains("page=1") || page2.contains("Newer"),
10354            "page 2 offers no way back"
10355        );
10356        // Disjoint: an article on page 1 must not reappear on page 2.
10357        let first_title = (0..total)
10358            .map(|i| format!("Article {i:04}"))
10359            .find(|t| page1.contains(t))
10360            .expect("page 1 shows at least one titled article");
10361        assert!(
10362            !page2.contains(&first_title),
10363            "{first_title} appears on both pages"
10364        );
10365
10366        // A page past the end must not be a dead end. The empty state renders
10367        // instead of the pager, so an out-of-range page would leave a reader
10368        // with no link back — reachable by typing a number, and reachable
10369        // WITHOUT typing anything by paging to the end and then marking entries
10370        // read, which shrinks the list under the URL already in the address bar.
10371        let past_end = get("/?page=999").await;
10372        assert!(
10373            past_end.matches("<li class=\"entry").count() > 0,
10374            "an out-of-range page rendered nothing and offered no way back"
10375        );
10376        assert!(
10377            past_end.contains("page=2"),
10378            "the clamped page offers no pager"
10379        );
10380    }
10381
10382    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
10383    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
10384    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
10385    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
10386    /// view (no reader header) instead swaps the row. This guards the reader OOB
10387    /// toggle wiring, which had no test.
10388    #[tokio::test]
10389    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
10390        let did = "did:plc:reader";
10391        let state = test_state(&[]).await;
10392        store::grant_access(&state.db, did, None, "test", None)
10393            .await
10394            .unwrap();
10395        let feed = store::upsert_feed(
10396            &state.db,
10397            &store::NewFeed {
10398                url: "https://reader.example/feed.xml".to_string(),
10399                title: Some("Reader".to_string()),
10400                ..Default::default()
10401            },
10402        )
10403        .await
10404        .unwrap();
10405        store::insert_entries(
10406            &state.db,
10407            feed,
10408            &[store::NewEntry {
10409                guid: "r-1".to_string(),
10410                url: Some("https://reader.example/1".to_string()),
10411                title: Some("Article".to_string()),
10412                published: Some("2026-07-11T00:00:00Z".to_string()),
10413                content_html: Some("<p>body</p>".to_string()),
10414                ..Default::default()
10415            }],
10416            0,
10417        )
10418        .await
10419        .unwrap();
10420        store::replace_sub_refs(&state.db, did, &[feed])
10421            .await
10422            .unwrap();
10423        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10424
10425        let cookie = session_cookie(&state, did, None);
10426        let app = router(state.clone());
10427
10428        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
10429        let resp = app
10430            .clone()
10431            .oneshot(
10432                Request::builder()
10433                    .method("POST")
10434                    .uri(format!("/entries/{entry_id}/read"))
10435                    .header(header::COOKIE, cookie.clone())
10436                    .header("HX-Request", "true")
10437                    .header("X-FR-Reader", "1")
10438                    .header("content-type", "application/x-www-form-urlencoded")
10439                    .body(Body::from("read=true"))
10440                    .unwrap(),
10441            )
10442            .await
10443            .unwrap();
10444        assert_eq!(resp.status(), StatusCode::OK);
10445        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10446            .await
10447            .unwrap();
10448        let html = String::from_utf8(bytes.to_vec()).unwrap();
10449        assert!(
10450            html.contains("hx-swap-oob=\"outerHTML\""),
10451            "reader response must be an OOB swap: {html}"
10452        );
10453        assert!(
10454            html.contains(r#"id="entry-actionbar""#),
10455            "reader response must be the action-bar fragment: {html}"
10456        );
10457        // Now READ: the read button reflects it (aria-pressed=true) and the
10458        // hidden value flips to `false` so the next tap marks it UNREAD.
10459        assert!(
10460            html.contains(r#"aria-pressed="true""#),
10461            "read button must show pressed after marking read: {html}"
10462        );
10463        assert!(
10464            html.contains(r#"name="read" value="false""#),
10465            "hidden read value must flip to false so a second tap reverses: {html}"
10466        );
10467
10468        // A second reader mark-read (submitting the flipped `read=false`) marks
10469        // it UNREAD again — the toggle reverses.
10470        let resp2 = app
10471            .oneshot(
10472                Request::builder()
10473                    .method("POST")
10474                    .uri(format!("/entries/{entry_id}/read"))
10475                    .header(header::COOKIE, cookie)
10476                    .header("HX-Request", "true")
10477                    .header("X-FR-Reader", "1")
10478                    .header("content-type", "application/x-www-form-urlencoded")
10479                    .body(Body::from("read=false"))
10480                    .unwrap(),
10481            )
10482            .await
10483            .unwrap();
10484        assert_eq!(resp2.status(), StatusCode::OK);
10485        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10486            .await
10487            .unwrap();
10488        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10489        assert!(
10490            html2.contains(r#"aria-pressed="false""#),
10491            "read button must show un-pressed after reversing: {html2}"
10492        );
10493        assert!(
10494            html2.contains(r#"name="read" value="true""#),
10495            "hidden read value must flip back to true: {html2}"
10496        );
10497    }
10498
10499    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
10500    /// action-bar — the counterpart to the reader-OOB test above.
10501    #[tokio::test]
10502    async fn list_mark_read_returns_row_not_oob_actionbar() {
10503        let did = "did:plc:listv";
10504        let state = test_state(&[]).await;
10505        store::grant_access(&state.db, did, None, "test", None)
10506            .await
10507            .unwrap();
10508        let feed = store::upsert_feed(
10509            &state.db,
10510            &store::NewFeed {
10511                url: "https://list.example/feed.xml".to_string(),
10512                title: Some("List".to_string()),
10513                ..Default::default()
10514            },
10515        )
10516        .await
10517        .unwrap();
10518        store::insert_entries(
10519            &state.db,
10520            feed,
10521            &[store::NewEntry {
10522                guid: "l-1".to_string(),
10523                url: Some("https://list.example/1".to_string()),
10524                title: Some("Article".to_string()),
10525                published: Some("2026-07-11T00:00:00Z".to_string()),
10526                ..Default::default()
10527            }],
10528            0,
10529        )
10530        .await
10531        .unwrap();
10532        store::replace_sub_refs(&state.db, did, &[feed])
10533            .await
10534            .unwrap();
10535        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10536
10537        let cookie = session_cookie(&state, did, None);
10538        let app = router(state.clone());
10539
10540        let resp = app
10541            .oneshot(
10542                Request::builder()
10543                    .method("POST")
10544                    .uri(format!("/entries/{entry_id}/read"))
10545                    .header(header::COOKIE, cookie)
10546                    .header("HX-Request", "true")
10547                    .header("content-type", "application/x-www-form-urlencoded")
10548                    .body(Body::from("read=true"))
10549                    .unwrap(),
10550            )
10551            .await
10552            .unwrap();
10553        assert_eq!(resp.status(), StatusCode::OK);
10554        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10555            .await
10556            .unwrap();
10557        let html = String::from_utf8(bytes.to_vec()).unwrap();
10558        assert!(
10559            !html.contains("hx-swap-oob"),
10560            "list-view response must NOT be an OOB swap: {html}"
10561        );
10562        // **And it must actually BE the row.** The assertion above is satisfied
10563        // by an empty body, or by any response that simply omits the attribute —
10564        // so on its own it pins half a property and the name promises the other
10565        // half.
10566        assert!(
10567            html.contains(&format!("/entries/{entry_id}")),
10568            "the response is not the row for this entry: {html}",
10569        );
10570        assert!(
10571            html.contains("Article"),
10572            "the row rendered without its title: {html}",
10573        );
10574        // **The row comes back carrying read state. That is all this proves.**
10575        //
10576        // It does NOT prove the state was persisted: the handler renders
10577        // `Some(read)` from the form value, so making `mark_read` roll back
10578        // instead of commit fails 11 store tests and leaves this one green.
10579        //
10580        // It does not prove the OVERRIDE either, which an earlier version of
10581        // this comment claimed. Verified: changing the call site to
10582        // `build_entry_row(pool, &did, id, None)` — deleting the override
10583        // wholesale — keeps the whole suite green, because `mark_read` has
10584        // already persisted the same value two lines earlier, so reading it back
10585        // from the database produces an identical row.
10586        //
10587        // Distinguishing the two needs a case where the override and the stored
10588        // state DISAGREE, which this handler never produces: it writes the value
10589        // it then renders. Left as a known gap rather than described as covered.
10590        assert!(
10591            html.contains("is-read"),
10592            "the row came back without the read state it was just given: {html}",
10593        );
10594    }
10595
10596    // -----------------------------------------------------------------------
10597    // Rename parity (POST /subscriptions/{rkey}/rename)
10598    // -----------------------------------------------------------------------
10599
10600    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
10601    ///
10602    /// The add path gates the URL the user *typed*; the URL it *stores* is
10603    /// whatever `resolve_feed_url` returns, which for an HTML page is a
10604    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
10605    /// that: `discover_feed` yields only http(s), and the add path re-checks
10606    /// storability on the resolved URL. This test pins the DISJUNCTION —
10607    /// each layer alone holds it, both removed fails it — driven through the
10608    /// real route against a real local server.
10609    ///
10610    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
10611    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
10612    /// form: once storage became DID-only the privacy classifier refused it
10613    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
10614    /// — the colons in the DID), so `discover_feed` drops it before either
10615    /// layer exists. An at:// link cannot come out of autodiscovery under
10616    /// ANY mutation of the layers, so no test through this route can pin
10617    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
10618    /// structure and pinned where it lives: `discover_skips_a_non_http_
10619    /// alternate` and the storability tests in `feed.rs`.
10620    #[tokio::test]
10621    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10622        let did = "did:plc:autodiscovered";
10623        // Access granted, both caps disabled — the only gates left are the
10624        // two under test.
10625        let state = test_state_with_caps(did, 0, 0).await;
10626
10627        let page = r#"<!doctype html><html><head><title>Blog</title>
10628            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10629            </head><body>hi</body></html>"#;
10630        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10631        let port: u16 = base
10632            .trim_end_matches('/')
10633            .rsplit(':')
10634            .next()
10635            .unwrap()
10636            .parse()
10637            .unwrap();
10638        crate::net::test_host_override(
10639            "autodiscover-ftp.test",
10640            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10641        );
10642
10643        let cookie = session_cookie(&state, did, None);
10644        let resp = router(state.clone())
10645            .oneshot(
10646                Request::builder()
10647                    .method("POST")
10648                    .uri("/subscriptions")
10649                    .header(header::COOKIE, cookie)
10650                    .header("content-type", "application/x-www-form-urlencoded")
10651                    .body(Body::from(format!(
10652                        "url=http://autodiscover-ftp.test:{port}/"
10653                    )))
10654                    .unwrap(),
10655            )
10656            .await
10657            .unwrap();
10658        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10659        let loc = resp
10660            .headers()
10661            .get(header::LOCATION)
10662            .unwrap()
10663            .to_str()
10664            .unwrap();
10665        assert_ne!(loc, "/login", "the test never reached the add path");
10666        assert_ne!(loc, "/", "the subscribe succeeded");
10667
10668        assert_eq!(
10669            store::count_feeds(&state.db).await.unwrap(),
10670            0,
10671            "a non-http(s) URL from autodiscovery was stored"
10672        );
10673        assert_eq!(
10674            store::count_subscriptions_for_did(&state.db, did)
10675                .await
10676                .unwrap(),
10677            0
10678        );
10679    }
10680
10681    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
10682    /// its global ceiling must be refused (capacity flash) and must NOT insert a
10683    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
10684    /// rename loop can't inflate the shared cache past the cap.
10685    #[tokio::test]
10686    async fn rename_to_new_url_refused_at_global_feeds_cap() {
10687        let did = "did:plc:renamer4";
10688        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10689        // Global cap 1; pre-fill it with one feed so headroom is 0.
10690        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10691        store::upsert_feed(
10692            &state.db,
10693            &store::NewFeed {
10694                url: "https://existing.example/feed.xml".to_string(),
10695                ..Default::default()
10696            },
10697        )
10698        .await
10699        .unwrap();
10700        let before = store::count_feeds(&state.db).await.unwrap();
10701        assert_eq!(before, 1);
10702
10703        let cookie = session_cookie(&state, did, None);
10704        let resp = router(state.clone())
10705            .oneshot(
10706                Request::builder()
10707                    .method("POST")
10708                    .uri("/subscriptions/rk-keep/rename")
10709                    .header(header::COOKIE, cookie)
10710                    .header("content-type", "application/x-www-form-urlencoded")
10711                    // A URL not in the cache → would be a NEW feeds row.
10712                    .body(Body::from(
10713                        "url=https://brand-new.example/feed.xml&title=Renamed",
10714                    ))
10715                    .unwrap(),
10716            )
10717            .await
10718            .unwrap();
10719        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10720        let loc = resp
10721            .headers()
10722            .get(header::LOCATION)
10723            .unwrap()
10724            .to_str()
10725            .unwrap();
10726        assert!(
10727            loc.contains("feed%20capacity"),
10728            "expected the feed-capacity flash, got {loc}"
10729        );
10730        // No new feeds row was inserted, and nothing reached the PDS.
10731        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10732        assert!(
10733            puts.lock().unwrap().is_empty(),
10734            "a refused repoint reached the PDS"
10735        );
10736    }
10737
10738    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
10739    /// global cap (only new URLs are gated) — the other half of the guard.
10740    ///
10741    /// On the sidecar fake, so "allowed" means the put actually happened: the
10742    /// earlier harness had no sidecar, and this passed on a "could not reach
10743    /// your PDS" flash that merely was not the capacity one.
10744    #[tokio::test]
10745    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10746        let did = "did:plc:renamer4";
10747        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10748        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10749        store::upsert_feed(
10750            &state.db,
10751            &store::NewFeed {
10752                url: "https://existing.example/feed.xml".to_string(),
10753                ..Default::default()
10754            },
10755        )
10756        .await
10757        .unwrap();
10758        let before = store::count_feeds(&state.db).await.unwrap();
10759
10760        let cookie = session_cookie(&state, did, None);
10761        let resp = router(state.clone())
10762            .oneshot(
10763                Request::builder()
10764                    .method("POST")
10765                    .uri("/subscriptions/rk-keep/rename")
10766                    .header(header::COOKIE, cookie)
10767                    .header("content-type", "application/x-www-form-urlencoded")
10768                    .body(Body::from(
10769                        "url=https://existing.example/feed.xml&title=Retitled",
10770                    ))
10771                    .unwrap(),
10772            )
10773            .await
10774            .unwrap();
10775        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10776        let loc = resp
10777            .headers()
10778            .get(header::LOCATION)
10779            .unwrap()
10780            .to_str()
10781            .unwrap();
10782        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10783        assert_eq!(
10784            puts.lock().unwrap().len(),
10785            1,
10786            "the repoint did not reach the PDS"
10787        );
10788        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10789    }
10790
10791    /// A rename with a blank URL writes nothing anywhere.
10792    #[tokio::test]
10793    async fn rename_with_blank_url_writes_nothing() {
10794        let did = "did:plc:renamer3";
10795        let state = test_state_with_caps(did, 0, 0).await;
10796        let before = store::count_feeds(&state.db).await.unwrap();
10797        assert_eq!(before, 0);
10798
10799        let cookie = session_cookie(&state, did, None);
10800        let app = router(state.clone());
10801        let resp = app
10802            .oneshot(
10803                Request::builder()
10804                    .method("POST")
10805                    .uri("/subscriptions/rkey123/rename")
10806                    .header(header::COOKIE, cookie)
10807                    .header("content-type", "application/x-www-form-urlencoded")
10808                    // Whitespace-only URL trims to empty.
10809                    .body(Body::from("url=%20%20&title=Nope"))
10810                    .unwrap(),
10811            )
10812            .await
10813            .unwrap();
10814        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10815        assert_eq!(
10816            resp.headers()
10817                .get(header::LOCATION)
10818                .unwrap()
10819                .to_str()
10820                .unwrap(),
10821            "/",
10822        );
10823        // Nothing was cached.
10824        assert_eq!(
10825            store::count_feeds(&state.db).await.unwrap(),
10826            0,
10827            "blank-URL rename wrote a junk feeds row"
10828        );
10829    }
10830
10831    /// A sidecar mock that serves ONE existing subscription record and captures
10832    /// every `put` body a rename produces.
10833    ///
10834    /// **Reads to `content-length` rather than taking one `read`.** A single
10835    /// read gets whatever one segment carried; if the head and body land
10836    /// separately the capture holds no record and every field assertion below
10837    /// passes for the wrong reason. Each captured body must also mention the
10838    /// collection, so an empty capture fails loudly instead of quietly.
10839    async fn spawn_rename_sidecar(
10840        existing: serde_json::Value,
10841    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10842        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10843        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10844        let addr = listener.local_addr().unwrap();
10845        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10846        let sink = puts.clone();
10847        tokio::spawn(async move {
10848            loop {
10849                let Ok((mut sock, _)) = listener.accept().await else {
10850                    break;
10851                };
10852                let mut raw: Vec<u8> = Vec::new();
10853                let mut chunk = [0u8; 4096];
10854                let body_text = loop {
10855                    let Ok(n) = sock.read(&mut chunk).await else {
10856                        break String::new();
10857                    };
10858                    if n == 0 {
10859                        break String::from_utf8_lossy(&raw).to_string();
10860                    }
10861                    raw.extend_from_slice(&chunk[..n]);
10862                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10863                        continue;
10864                    };
10865                    let (head, body) = raw.split_at(split + 4);
10866                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10867                        let (k, v) = l.split_once(':')?;
10868                        k.eq_ignore_ascii_case("content-length")
10869                            .then(|| v.trim().parse::<usize>().ok())?
10870                    });
10871                    if want.is_none_or(|want| body.len() >= want) {
10872                        break String::from_utf8_lossy(body).to_string();
10873                    }
10874                };
10875
10876                // `"action":"put"` is the rename write; anything else is the read.
10877                let is_put = body_text.contains("\"action\":\"put\"");
10878                let data = if is_put {
10879                    sink.lock().unwrap().push(body_text.clone());
10880                    serde_json::json!({
10881                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10882                        "cid": "bafyreiafter"
10883                    })
10884                } else {
10885                    serde_json::json!({ "records": [existing.clone()] })
10886                };
10887                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10888                let resp = format!(
10889                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10890                    body.len(),
10891                    body
10892                );
10893                let _ = sock.write_all(resp.as_bytes()).await;
10894                let _ = sock.flush().await;
10895            }
10896        });
10897        (format!("http://{addr}"), puts)
10898    }
10899
10900    /// The existing record a rename must not destroy.
10901    fn seeded_subscription() -> serde_json::Value {
10902        serde_json::json!({
10903            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10904            "cid": "bafyreibefore",
10905            "value": {
10906                "$type": "community.lexicon.rss.subscription",
10907                "url": "https://example.com/feed.xml",
10908                "title": "Old title",
10909                "siteUrl": "https://example.com/blog",
10910                "fetchHint": "hourly",
10911                "private": false,
10912                "createdAt": "2024-03-01T00:00:00.000Z"
10913            }
10914        })
10915    }
10916
10917    /// An existing standard.site subscription, as the 19 in production are:
10918    /// written before this reader refused the scheme, still in the repo.
10919    fn seeded_at_uri_subscription() -> serde_json::Value {
10920        seeded_subscription_with_url(AT_URI_SUB)
10921    }
10922    /// An existing subscription record at `rk-keep` with the given URL.
10923    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10924        serde_json::json!({
10925            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10926            "cid": "bafyreibefore",
10927            "value": {
10928                "$type": "community.lexicon.rss.subscription",
10929                "url": url,
10930                "title": "Old title",
10931                "private": false,
10932                "createdAt": "2024-03-01T00:00:00.000Z"
10933            }
10934        })
10935    }
10936    const AT_URI_SUB: &str =
10937        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10938    const AT_URI_SUB_ENC: &str =
10939        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10940
10941    /// **Retitling an existing `at://` subscription must work with the flag off.**
10942    ///
10943    /// The storability guard was placed before the repo lookup, so it refused
10944    /// any rename whose URL is an at-URI — including a pure title or folder
10945    /// change on a record that already exists. On main that rename succeeded;
10946    /// the 19 production records would have become un-editable. The flag gates
10947    /// what may be STORED in the cache, not whether a reader may edit their own
10948    /// record: the PDS write goes through, the cache row is simply not created.
10949    #[tokio::test]
10950    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10951        let did = "did:plc:renamer5";
10952        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10953        let state = test_state_with_sidecar(&[did], &sidecar).await;
10954        assert!(
10955            !state.config.standard_site,
10956            "the flag must be off for this test"
10957        );
10958        let cookie = session_cookie(&state, did, None);
10959        let resp = router(state.clone())
10960            .oneshot(
10961                Request::builder()
10962                    .method("POST")
10963                    .uri("/subscriptions/rk-keep/rename")
10964                    .header(header::COOKIE, cookie)
10965                    .header("content-type", "application/x-www-form-urlencoded")
10966                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10967                    .unwrap(),
10968            )
10969            .await
10970            .unwrap();
10971        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10972        let loc = resp
10973            .headers()
10974            .get(header::LOCATION)
10975            .unwrap()
10976            .to_str()
10977            .unwrap();
10978        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10979
10980        let bodies = puts.lock().unwrap().clone();
10981        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10982        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10983        assert_eq!(
10984            sent["record"]["title"], "New title",
10985            "the rename did not apply"
10986        );
10987        assert_eq!(
10988            sent["record"]["url"], AT_URI_SUB,
10989            "the rename changed the URL"
10990        );
10991
10992        // The flag still means what it says for the CACHE: no at:// row.
10993        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10994        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10995    }
10996
10997    /// **Repointing a subscription AT an `at://` URI is still refused with the
10998    /// flag off** — the half of the guard that has to survive the fix above.
10999    /// Nothing reaches the PDS and nothing reaches the cache.
11000    #[tokio::test]
11001    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
11002        let did = "did:plc:renamer4";
11003        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11004        let state = test_state_with_sidecar(&[did], &sidecar).await;
11005        let cookie = session_cookie(&state, did, None);
11006        let resp = router(state.clone())
11007            .oneshot(
11008                Request::builder()
11009                    .method("POST")
11010                    .uri("/subscriptions/rk-keep/rename")
11011                    .header(header::COOKIE, cookie)
11012                    .header("content-type", "application/x-www-form-urlencoded")
11013                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11014                    .unwrap(),
11015            )
11016            .await
11017            .unwrap();
11018        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11019        let loc = resp
11020            .headers()
11021            .get(header::LOCATION)
11022            .unwrap()
11023            .to_str()
11024            .unwrap();
11025        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
11026        assert!(
11027            !loc.contains("Private"),
11028            "a storability refusal was reported as a privacy one: {loc}"
11029        );
11030        assert!(
11031            puts.lock().unwrap().is_empty(),
11032            "the repoint reached the PDS"
11033        );
11034        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11035        assert_eq!(cached, 0);
11036    }
11037
11038    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
11039    /// redirect location.
11040    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
11041        let cookie = session_cookie(state, did, None);
11042        let resp = router(state.clone())
11043            .oneshot(
11044                Request::builder()
11045                    .method("POST")
11046                    .uri("/subscriptions/rk-keep/rename")
11047                    .header(header::COOKIE, cookie)
11048                    .header("content-type", "application/x-www-form-urlencoded")
11049                    .body(Body::from(format!("url={url_enc}&title=New+title")))
11050                    .unwrap(),
11051            )
11052            .await
11053            .unwrap();
11054        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11055        resp.headers()
11056            .get(header::LOCATION)
11057            .unwrap()
11058            .to_str()
11059            .unwrap()
11060            .to_string()
11061    }
11062
11063    /// **The privacy gate has the same ordering bug the storable gate had.**
11064    ///
11065    /// Another client can write a subscription whose URL is an at-URI that is
11066    /// not a well-formed publication URI at all — a feed generator, say. On
11067    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
11068    /// the classifier reads as `Public`). The narrowed at:// arm now fails
11069    /// closed as `Private` for it, and the gate ran before `url_changed` was
11070    /// known — so the record became un-editable, with a flash claiming it "was
11071    /// not saved or sent anywhere". Both gates now apply to a repoint only.
11072    #[tokio::test]
11073    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
11074        let did = "did:plc:renamer5";
11075        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
11076        let other_enc =
11077            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
11078        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
11079        let state = test_state_with_sidecar(&[did], &sidecar).await;
11080        let loc = retitle_unchanged(&state, did, other_enc).await;
11081        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11082        let bodies = puts.lock().unwrap().clone();
11083        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11084        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11085        assert_eq!(sent["record"]["title"], "New title");
11086        assert_eq!(sent["record"]["url"], other);
11087    }
11088
11089    /// **A repoint to a secret-bearing URL is still refused** — the half of
11090    /// the privacy gate that has to survive moving it behind `url_changed`.
11091    /// Found by mutation: with the gate deleted outright, nothing failed.
11092    #[tokio::test]
11093    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
11094        let did = "did:plc:renamer4";
11095        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11096        let state = test_state_with_sidecar(&[did], &sidecar).await;
11097        let cookie = session_cookie(&state, did, None);
11098        let resp = router(state.clone())
11099            .oneshot(
11100                Request::builder()
11101                    .method("POST")
11102                    .uri("/subscriptions/rk-keep/rename")
11103                    .header(header::COOKIE, cookie)
11104                    .header("content-type", "application/x-www-form-urlencoded")
11105                    .body(Body::from(
11106                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
11107                    ))
11108                    .unwrap(),
11109            )
11110            .await
11111            .unwrap();
11112        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11113        let loc = resp
11114            .headers()
11115            .get(header::LOCATION)
11116            .unwrap()
11117            .to_str()
11118            .unwrap();
11119        assert!(
11120            loc.contains("Private"),
11121            "the private repoint was not refused: {loc}"
11122        );
11123        assert!(
11124            puts.lock().unwrap().is_empty(),
11125            "a secret-bearing URL reached the PDS"
11126        );
11127        // The repo's fixture token: opaque enough for the classifier, not a real
11128        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
11129        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
11130        assert!(store::get_feed_by_url(&state.db, leaked)
11131            .await
11132            .unwrap()
11133            .is_none());
11134    }
11135
11136    /// **A retitle of a never-cached at:// subscription is not "at feed
11137    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
11138    /// and an at:// record is never cached with the flag off — so at capacity,
11139    /// a pure retitle was refused for a row the handler would not insert. The
11140    /// check now runs once `url_changed` is known and only for a repoint.
11141    #[tokio::test]
11142    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
11143        let did = "did:plc:renamer5";
11144        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11145        // Ceiling 1, and one real feed already fills it.
11146        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11147        store::upsert_feed(
11148            &state.db,
11149            &store::NewFeed {
11150                url: "https://filler.example/feed.xml".to_string(),
11151                ..Default::default()
11152            },
11153        )
11154        .await
11155        .unwrap();
11156        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11157        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11158        assert_eq!(
11159            puts.lock().unwrap().len(),
11160            1,
11161            "the retitle did not reach the PDS"
11162        );
11163        assert_eq!(
11164            store::count_feeds(&state.db).await.unwrap(),
11165            1,
11166            "a row was inserted"
11167        );
11168    }
11169
11170    /// POST `/subscriptions` with `url`, returning the redirect target.
11171    async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
11172        let cookie = session_cookie(state, did, None);
11173        let resp = router(state.clone())
11174            .oneshot(
11175                Request::builder()
11176                    .method("POST")
11177                    .uri("/subscriptions")
11178                    .header(header::COOKIE, cookie)
11179                    .header("content-type", "application/x-www-form-urlencoded")
11180                    .body(Body::from(format!("url={url_enc}")))
11181                    .unwrap(),
11182            )
11183            .await
11184            .unwrap();
11185        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11186        resp.headers()
11187            .get(header::LOCATION)
11188            .unwrap()
11189            .to_str()
11190            .unwrap()
11191            .to_string()
11192    }
11193
11194    /// A `com.atproto.identity.resolveHandle` that answers `did` for anything.
11195    async fn serve_resolver(did: &str) -> String {
11196        let base = crate::net::tests::serve_body(
11197            serde_json::json!({ "did": did }).to_string().into_bytes(),
11198        )
11199        .await;
11200        let port: u16 = base
11201            .trim_end_matches('/')
11202            .rsplit(':')
11203            .next()
11204            .unwrap()
11205            .parse()
11206            .unwrap();
11207        let host = format!("resolver-{port}.test");
11208        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11209        format!("http://{host}:{port}")
11210    }
11211
11212    fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
11213        let mut config = (*state.config).clone();
11214        f(&mut config);
11215        state.config = std::sync::Arc::new(config);
11216        state
11217    }
11218
11219    /// **0.4.0 step 3: with the flag ON, a well-formed at:// paste is
11220    /// subscribed.** It was refused as unsupported while nothing could read a
11221    /// publication; the poller reads them now. Stored in DID form, as a
11222    /// `publication`, and written to the reader's PDS like any subscription.
11223    #[tokio::test]
11224    async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
11225        let did = "did:plc:renamer5";
11226        let (sidecar, log) = spawn_logging_sidecar().await;
11227        let state = with_config(
11228            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11229            |c| {
11230                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11231            },
11232        );
11233        let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
11234        assert_eq!(loc, "/", "the paste was refused: {loc}");
11235        let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
11236            .await
11237            .unwrap()
11238            .expect("no feed row");
11239        assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
11240        let sent = log.lock().unwrap().join("\n");
11241        assert!(
11242            sent.contains(AT_URI_SUB),
11243            "the subscription was not written to the PDS: {sent}"
11244        );
11245    }
11246
11247    /// **A0 through the form** — the 0.4.0 exit test, end to end: a reader
11248    /// pastes a publication, it is stored and written to their PDS, and the
11249    /// first poll — the one subscribing runs at once — stores its documents.
11250    #[tokio::test]
11251    async fn a0_subscribing_from_the_form_delivers_entries() {
11252        let did = "did:plc:renamer5";
11253        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11254        let site = AT_URI_SUB;
11255        let (plc, _) = crate::standard_site::tests::serve_repo(
11256            author,
11257            vec![
11258                (
11259                    lexicon::nsid::STANDARD_PUBLICATION,
11260                    "3lab2c4d5e6f7g8h",
11261                    serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
11262                ),
11263                (
11264                    lexicon::nsid::STANDARD_DOCUMENT,
11265                    "3l2a0frmaaa2a",
11266                    serde_json::json!({ "title": "From the form", "path": "/f",
11267                        "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
11268                ),
11269            ],
11270        )
11271        .await;
11272        let (sidecar, _log) = spawn_logging_sidecar().await;
11273        let state = with_config(
11274            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11275            |c| {
11276                c.oauth.plc_directory = plc;
11277            },
11278        );
11279        assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
11280        let row = store::get_feed_by_url(&state.db, site)
11281            .await
11282            .unwrap()
11283            .unwrap();
11284        let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
11285            .bind(row.id)
11286            .fetch_all(&state.db)
11287            .await
11288            .unwrap();
11289        assert_eq!(
11290            titles,
11291            vec!["From the form".to_string()],
11292            "the first poll stored nothing"
11293        );
11294        assert_eq!(row.title.as_deref(), Some("A0 Journal"));
11295    }
11296
11297    /// A handle-form paste is resolved to the DID before it is stored: a
11298    /// handle is a mutable name, and `feeds.url` is keyed on identity.
11299    #[tokio::test]
11300    async fn a_handle_form_paste_is_stored_by_its_did() {
11301        let did = "did:plc:renamer5";
11302        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11303        let (sidecar, _log) = spawn_logging_sidecar().await;
11304        let resolver = serve_resolver(author).await;
11305        let state = with_config(
11306            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11307            |c| {
11308                c.resolver_base = resolver;
11309                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11310            },
11311        );
11312        let loc = subscribe(
11313            &state,
11314            did,
11315            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11316        )
11317        .await;
11318        assert_eq!(loc, "/", "the paste was refused: {loc}");
11319        assert!(
11320            store::get_feed_by_url(&state.db, AT_URI_SUB)
11321                .await
11322                .unwrap()
11323                .is_some(),
11324            "not stored by its DID"
11325        );
11326        assert_eq!(
11327            store::count_feeds(&state.db).await.unwrap(),
11328            1,
11329            "the handle form was stored too"
11330        );
11331    }
11332
11333    /// A resolver answering `did` that counts how often it was asked.
11334    async fn serve_counting_resolver(
11335        did: &str,
11336    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
11337        let (base, hits) = crate::net::tests::serve_body_counted(
11338            serde_json::json!({ "did": did }).to_string().into_bytes(),
11339        )
11340        .await;
11341        let port: u16 = base
11342            .trim_end_matches('/')
11343            .rsplit(':')
11344            .next()
11345            .unwrap()
11346            .parse()
11347            .unwrap();
11348        let host = format!("counting-resolver-{port}.test");
11349        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11350        (format!("http://{host}:{port}"), hits)
11351    }
11352
11353    /// Review of #230: the per-DID cap is documented as checked "BEFORE any
11354    /// fetch/resolve so an over-cap account can't even trigger an outbound
11355    /// request" — a handle paste resolved the handle first.
11356    #[tokio::test]
11357    async fn an_over_cap_handle_paste_makes_no_outbound_request() {
11358        let did = "did:plc:renamer5";
11359        let (sidecar, _log) = spawn_logging_sidecar().await;
11360        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
11361        let state = with_config(
11362            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11363            |c| {
11364                c.resolver_base = resolver;
11365                c.max_subs_per_did = 1;
11366            },
11367        );
11368        let feed_id = store::upsert_feed(
11369            &state.db,
11370            &store::NewFeed {
11371                url: "https://already.example/feed.xml".into(),
11372                ..Default::default()
11373            },
11374        )
11375        .await
11376        .unwrap();
11377        store::replace_sub_refs(&state.db, did, &[feed_id])
11378            .await
11379            .unwrap();
11380        let loc = subscribe(
11381            &state,
11382            did,
11383            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11384        )
11385        .await;
11386        assert!(
11387            loc.contains("Subscription%20limit"),
11388            "expected the cap flash: {loc}"
11389        );
11390        assert_eq!(
11391            hits.load(std::sync::atomic::Ordering::SeqCst),
11392            0,
11393            "an over-cap paste resolved a handle"
11394        );
11395    }
11396
11397    /// Review of #230: an authority that is neither a valid DID nor a valid
11398    /// handle — `did:plc:TOOSHORT`, an uppercase DID — went to the resolver as
11399    /// a "handle". It is unsupported, and asks nobody anything.
11400    #[tokio::test]
11401    async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
11402        let did = "did:plc:renamer5";
11403        let (sidecar, _log) = spawn_logging_sidecar().await;
11404        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
11405        let state = with_config(
11406            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11407            |c| {
11408                c.resolver_base = resolver;
11409            },
11410        );
11411        for authority in [
11412            "did%3Aplc%3ATOOSHORT",
11413            "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
11414            "bad%0Ahandle.example",
11415        ] {
11416            let loc = subscribe(
11417                &state,
11418                did,
11419                &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
11420            )
11421            .await;
11422            assert!(
11423                loc.contains("kind%20of%20feed"),
11424                "{authority}: expected the unsupported flash: {loc}"
11425            );
11426        }
11427        assert_eq!(
11428            hits.load(std::sync::atomic::Ordering::SeqCst),
11429            0,
11430            "a malformed authority reached the resolver"
11431        );
11432        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11433    }
11434
11435    /// A handle that does not resolve is refused, and nothing is stored.
11436    #[tokio::test]
11437    async fn an_unresolvable_handle_paste_is_refused() {
11438        let did = "did:plc:renamer5";
11439        let (sidecar, _log) = spawn_logging_sidecar().await;
11440        let state = with_config(
11441            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11442            |c| {
11443                c.resolver_base = "http://resolver.nowhere.invalid".into();
11444            },
11445        );
11446        let loc = subscribe(
11447            &state,
11448            did,
11449            "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11450        )
11451        .await;
11452        assert!(
11453            loc.contains("resolve%20the%20handle"),
11454            "expected the unresolvable-handle flash: {loc}"
11455        );
11456        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11457    }
11458
11459    /// An at:// URI that is not a publication is refused, flag on or off.
11460    #[tokio::test]
11461    async fn a_non_publication_at_uri_paste_is_refused() {
11462        let did = "did:plc:renamer5";
11463        let (sidecar, _log) = spawn_logging_sidecar().await;
11464        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11465        let loc = subscribe(
11466            &state,
11467            did,
11468            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
11469        )
11470        .await;
11471        assert!(
11472            loc.contains("kind%20of%20feed"),
11473            "expected the unsupported flash: {loc}"
11474        );
11475        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11476    }
11477
11478    /// A mixed-case scheme is canonicalised at input, not refused and not
11479    /// stored as a second spelling of the same publication.
11480    #[tokio::test]
11481    async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
11482        let did = "did:plc:renamer5";
11483        let (sidecar, _log) = spawn_logging_sidecar().await;
11484        let state = with_config(
11485            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11486            |c| {
11487                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11488            },
11489        );
11490        let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11491        assert_eq!(loc, "/", "the paste was refused: {loc}");
11492        assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11493            .await
11494            .unwrap()
11495            .is_some());
11496    }
11497
11498    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
11499    /// path that is meant to work today, asserted with the flag actually on.
11500    #[tokio::test]
11501    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11502        let did = "did:plc:renamer5";
11503        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11504        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11505        let opml = format!(
11506            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11507             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11508             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11509             </body></opml>"
11510        );
11511        let (ct, body) = opml_multipart(opml.as_bytes());
11512        let cookie = session_cookie(&state, did, None);
11513        let resp = router(state.clone())
11514            .oneshot(
11515                Request::builder()
11516                    .method("POST")
11517                    .uri("/opml")
11518                    .header(header::COOKIE, cookie)
11519                    .header("content-type", ct)
11520                    .body(Body::from(body))
11521                    .unwrap(),
11522            )
11523            .await
11524            .unwrap();
11525        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11526        let loc = resp
11527            .headers()
11528            .get(header::LOCATION)
11529            .unwrap()
11530            .to_str()
11531            .unwrap();
11532        assert!(
11533            loc.contains("Imported%202%20feeds"),
11534            "unexpected flash: {loc}"
11535        );
11536        assert!(
11537            !loc.contains("skipped"),
11538            "the at:// entry was skipped with the flag on: {loc}"
11539        );
11540        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11541        assert!(
11542            stored.is_some(),
11543            "the at:// entry was not stored with the flag on"
11544        );
11545    }
11546
11547    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
11548    /// gate behind `url_changed` was right for the PDS write — the record is
11549    /// the reader's — but the cache write was gated only on `storable`, which
11550    /// any http(s) URL is. So a retitle of a record another client wrote with
11551    /// a tokened feed URL inserted that URL into the shared `feeds` table,
11552    /// where the poller would fail it every cycle and print it on the admin
11553    /// page. main refused the whole rename; this keeps the record editable and
11554    /// the cache clean, as `resolve_subscriptions` already does for the same
11555    /// record.
11556    #[tokio::test]
11557    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11558        let did = "did:plc:renamer5";
11559        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11560        let tokened_enc =
11561            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11562        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11563        let state = test_state_with_sidecar(&[did], &sidecar).await;
11564        let loc = retitle_unchanged(&state, did, tokened_enc).await;
11565        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11566        assert_eq!(
11567            puts.lock().unwrap().len(),
11568            1,
11569            "the retitle did not reach the PDS"
11570        );
11571        assert!(
11572            store::get_feed_by_url(&state.db, tokened)
11573                .await
11574                .unwrap()
11575                .is_none(),
11576            "a secret-bearing URL was written to the shared cache by a retitle"
11577        );
11578    }
11579
11580    /// **On a repoint, storability is decided before privacy and capacity** —
11581    /// the same ordering the add path got. A malformed at:// target drew the
11582    /// private/paid flash, and at capacity a well-formed one drew "try again
11583    /// later" for a URL that can never be accepted with the flag off.
11584    #[tokio::test]
11585    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11586        let did = "did:plc:renamer4";
11587        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11588        let state = test_state_with_sidecar(&[did], &sidecar).await;
11589        let cookie = session_cookie(&state, did, None);
11590        let resp = router(state.clone())
11591            .oneshot(
11592                Request::builder()
11593                    .method("POST")
11594                    .uri("/subscriptions/rk-keep/rename")
11595                    .header(header::COOKIE, cookie)
11596                    .header("content-type", "application/x-www-form-urlencoded")
11597                    .body(Body::from(
11598                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11599                    ))
11600                    .unwrap(),
11601            )
11602            .await
11603            .unwrap();
11604        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11605        let loc = resp
11606            .headers()
11607            .get(header::LOCATION)
11608            .unwrap()
11609            .to_str()
11610            .unwrap();
11611        assert!(
11612            loc.contains("kind%20of%20feed"),
11613            "expected the unsupported flash: {loc}"
11614        );
11615        assert!(
11616            !loc.contains("Private"),
11617            "a typo was reported as a paid feed: {loc}"
11618        );
11619        assert!(puts.lock().unwrap().is_empty());
11620    }
11621
11622    #[tokio::test]
11623    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11624        let did = "did:plc:renamer4";
11625        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11626        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11627        store::upsert_feed(
11628            &state.db,
11629            &store::NewFeed {
11630                url: "https://filler.example/feed.xml".to_string(),
11631                ..Default::default()
11632            },
11633        )
11634        .await
11635        .unwrap();
11636        let cookie = session_cookie(&state, did, None);
11637        let resp = router(state.clone())
11638            .oneshot(
11639                Request::builder()
11640                    .method("POST")
11641                    .uri("/subscriptions/rk-keep/rename")
11642                    .header(header::COOKIE, cookie)
11643                    .header("content-type", "application/x-www-form-urlencoded")
11644                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11645                    .unwrap(),
11646            )
11647            .await
11648            .unwrap();
11649        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11650        let loc = resp
11651            .headers()
11652            .get(header::LOCATION)
11653            .unwrap()
11654            .to_str()
11655            .unwrap();
11656        assert!(
11657            loc.contains("kind%20of%20feed"),
11658            "expected the unsupported flash: {loc}"
11659        );
11660        assert!(
11661            !loc.contains("capacity"),
11662            "an unacceptable URL was reported as a capacity problem: {loc}"
11663        );
11664        assert!(puts.lock().unwrap().is_empty());
11665    }
11666
11667    /// **`url_changed` compares like for like.** The form value is trimmed;
11668    /// the record's URL was compared raw, so a record another client wrote
11669    /// with a trailing space read as a repoint on every retitle and re-armed
11670    /// every gate — including the one that made an at:// record un-editable.
11671    #[tokio::test]
11672    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11673        let did = "did:plc:renamer5";
11674        let padded = format!("{AT_URI_SUB} ");
11675        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11676        let state = test_state_with_sidecar(&[did], &sidecar).await;
11677        // The manage row posts the record's URL verbatim, padding included.
11678        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11679        assert_eq!(
11680            loc, "/",
11681            "the retitle was treated as a repoint and refused: {loc}"
11682        );
11683        let bodies = puts.lock().unwrap().clone();
11684        assert_eq!(bodies.len(), 1);
11685        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11686        assert_eq!(
11687            sent["record"]["url"], AT_URI_SUB,
11688            "the padding was not normalised away"
11689        );
11690    }
11691
11692    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
11693    /// only, so the trailing upsert must not create a row for an unchanged URL
11694    /// that has none — with the flag on and the cache full, each retitle of a
11695    /// never-cached at:// record was a row past the cap. An existing row still
11696    /// gets its title kept in step.
11697    #[tokio::test]
11698    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11699        let did = "did:plc:renamer5";
11700        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11701        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11702        store::upsert_feed(
11703            &state.db,
11704            &store::NewFeed {
11705                url: "https://filler.example/feed.xml".to_string(),
11706                ..Default::default()
11707            },
11708        )
11709        .await
11710        .unwrap();
11711        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11712        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11713        assert_eq!(puts.lock().unwrap().len(), 1);
11714        assert_eq!(
11715            store::count_feeds(&state.db).await.unwrap(),
11716            1,
11717            "a retitle inserted a cache row past the ceiling"
11718        );
11719    }
11720
11721    /// **The add path's at:// pre-check is about the MESSAGE, so it is
11722    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
11723    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
11724    /// tripped the secret heuristic on the rkey — the private/paid flash the
11725    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
11726    /// touch it.
11727    #[tokio::test]
11728    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11729        let did = "did:plc:typoist";
11730        let state = test_state_with_caps(did, 0, 0).await;
11731        let cookie = session_cookie(&state, did, None);
11732        let resp = router(state.clone())
11733            .oneshot(
11734                Request::builder()
11735                    .method("POST")
11736                    .uri("/subscriptions")
11737                    .header(header::COOKIE, cookie)
11738                    .header("content-type", "application/x-www-form-urlencoded")
11739                    .body(Body::from(
11740                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11741                    ))
11742                    .unwrap(),
11743            )
11744            .await
11745            .unwrap();
11746        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11747        let loc = resp
11748            .headers()
11749            .get(header::LOCATION)
11750            .unwrap()
11751            .to_str()
11752            .unwrap();
11753        assert!(
11754            loc.contains("kind%20of%20feed"),
11755            "expected the unsupported flash: {loc}"
11756        );
11757        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11758    }
11759
11760    // -- #149: a rename that races another client's write -------------------
11761
11762    /// One subscription record behind a fake repo that ENFORCES `swapRecord`
11763    /// the way the reference PDS does: a put naming a CID the record is no
11764    /// longer at is refused `400 InvalidSwap`; a put with no swap always lands.
11765    #[derive(Default)]
11766    struct SwapRepo {
11767        /// The record's current value.
11768        value: serde_json::Value,
11769        /// Bumped on every write, so each version has its own CID.
11770        version: u32,
11771        /// Every put request body received, in order, landed or not.
11772        puts: Vec<serde_json::Value>,
11773        /// Another client's write, landed the moment our FIRST put arrives —
11774        /// i.e. between our read and our write.
11775        concurrent: Option<serde_json::Value>,
11776        /// Refuse every put that carries a swap, whatever CID it names.
11777        refuse_every_swap: bool,
11778        /// Refuse every put with this (status, error) — a non-swap failure.
11779        fail_puts: Option<(u16, &'static str)>,
11780        /// The collection the record lives in; the subscription one when unset.
11781        collection: Option<&'static str>,
11782        /// The record is not in the repo: the listing comes back empty.
11783        missing: bool,
11784        /// Every listing fails `502`.
11785        fail_list: bool,
11786    }
11787
11788    impl SwapRepo {
11789        fn cid(&self) -> String {
11790            format!("bafyreiversion{}", self.version)
11791        }
11792
11793        fn nsid(&self) -> &'static str {
11794            self.collection
11795                .unwrap_or(crate::lexicon::nsid::SUBSCRIPTION)
11796        }
11797
11798        fn page(&self) -> serde_json::Value {
11799            if self.missing {
11800                return serde_json::json!({ "records": [] });
11801            }
11802            serde_json::json!({ "records": [{
11803                "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
11804                "cid": self.cid(),
11805                "value": self.value,
11806            }] })
11807        }
11808
11809        /// A put: `Ok(strong ref)` or `Err((status, error name))`.
11810        fn put(&mut self, body: &serde_json::Value) -> Result<serde_json::Value, (u16, String)> {
11811            self.puts.push(body.clone());
11812            if let Some(theirs) = self.concurrent.take() {
11813                self.value = theirs;
11814                self.version += 1;
11815            }
11816            if let Some((status, error)) = self.fail_puts {
11817                return Err((status, error.to_string()));
11818            }
11819            if let Some(swap) = body.get("swapRecord").and_then(|v| v.as_str()) {
11820                if self.refuse_every_swap || swap != self.cid() {
11821                    return Err((400, "InvalidSwap".to_string()));
11822                }
11823            }
11824            self.value = body["record"].clone();
11825            self.version += 1;
11826            Ok(serde_json::json!({
11827                "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
11828                "cid": self.cid(),
11829            }))
11830        }
11831    }
11832
11833    const RACE_DID: &str = "did:plc:racer149";
11834
11835    /// Serve `repo` as both a sidecar (`/internal/repo`) and a PDS (`/xrpc/*`),
11836    /// so one fixture drives either backend. Returns the sidecar base URL and
11837    /// the PDS audience a Rust-backend session should carry.
11838    async fn serve_swap_repo(repo: std::sync::Arc<std::sync::Mutex<SwapRepo>>) -> (String, String) {
11839        use axum::response::IntoResponse as _;
11840        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11841        let addr = listener.local_addr().unwrap();
11842        let host = format!("pds-{}.race.test", addr.port());
11843        crate::net::test_host_override(&host, addr);
11844        let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
11845            let repo = std::sync::Arc::clone(&repo);
11846            async move {
11847                let (parts, body) = req.into_parts();
11848                let raw = axum::body::to_bytes(body, usize::MAX).await.unwrap();
11849                let body: serde_json::Value =
11850                    serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null);
11851                let reply = |status: u16, body: serde_json::Value| {
11852                    (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
11853                };
11854                let mut repo = repo.lock().unwrap();
11855                match (parts.uri.path(), body["action"].as_str()) {
11856                    ("/internal/repo", Some("list")) if repo.fail_list => reply(
11857                        502,
11858                        serde_json::json!({
11859                            "ok": false, "error": "UpstreamFailure", "message": "down", "status": 502,
11860                        }),
11861                    ),
11862                    ("/internal/repo", Some("list")) => {
11863                        reply(200, serde_json::json!({ "ok": true, "data": repo.page() }))
11864                    }
11865                    ("/internal/repo", Some("put")) => match repo.put(&body) {
11866                        Ok(data) => reply(200, serde_json::json!({ "ok": true, "data": data })),
11867                        Err((status, error)) => reply(
11868                            status,
11869                            serde_json::json!({
11870                                "ok": false, "error": error, "message": "refused", "status": status,
11871                            }),
11872                        ),
11873                    },
11874                    ("/xrpc/com.atproto.repo.listRecords", _) if repo.fail_list => reply(
11875                        502,
11876                        serde_json::json!({ "error": "UpstreamFailure", "message": "down" }),
11877                    ),
11878                    ("/xrpc/com.atproto.repo.listRecords", _) => reply(200, repo.page()),
11879                    ("/xrpc/com.atproto.repo.putRecord", _) => match repo.put(&body) {
11880                        Ok(data) => reply(200, data),
11881                        Err((status, error)) => reply(
11882                            status,
11883                            serde_json::json!({ "error": error, "message": "refused" }),
11884                        ),
11885                    },
11886                    other => panic!("unexpected request {other:?}"),
11887                }
11888            }
11889        });
11890        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
11891        (
11892            format!("http://{addr}"),
11893            format!("http://{host}:{}", addr.port()),
11894        )
11895    }
11896
11897    /// An `AppState` on `backend`, pointed at `repo` — the sidecar through its
11898    /// internal URL, the Rust client through a live OAuth session whose `aud`
11899    /// is the fake.
11900    async fn race_state(
11901        backend: crate::metrics::Backend,
11902        repo: &std::sync::Arc<std::sync::Mutex<SwapRepo>>,
11903    ) -> AppState {
11904        let (sidecar, aud) = serve_swap_repo(std::sync::Arc::clone(repo)).await;
11905        let db = store::init_url("sqlite::memory:").await.unwrap();
11906        store::ensure_seed(&db, &[RACE_DID.to_string()])
11907            .await
11908            .unwrap();
11909        let mut config = Config {
11910            allowed_dids: vec![RACE_DID.to_string()],
11911            cookie_secret: "test-cookie-secret-000".to_string(),
11912            beta_cap: 3,
11913            repo_backend: backend,
11914            oauth: crate::config::OauthConfig {
11915                // Per test, never the relative default — see `repo::tests`.
11916                key_path: std::env::temp_dir().join(format!(
11917                    "fr-race-oauth-key-{}-{:p}.json",
11918                    std::process::id(),
11919                    &db as *const _
11920                )),
11921                encryption_key: Some("a".repeat(43)),
11922                ..crate::config::OauthConfig::default()
11923            },
11924            ..Config::default()
11925        };
11926        config.sidecar.public_url = sidecar.clone();
11927        config.sidecar.internal_url = sidecar;
11928        let state = AppState::new(config, db).unwrap();
11929        if backend == crate::metrics::Backend::Rust {
11930            let runtime = state.oauth.as_deref().expect("oauth runtime");
11931            crate::oauth::store::put_session(
11932                &state.db,
11933                &runtime.codec,
11934                &crate::oauth::store::OAuthSession {
11935                    sub: RACE_DID.into(),
11936                    issuer: "https://auth.invalid".into(),
11937                    aud,
11938                    dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
11939                        .to_jwk_json()
11940                        .unwrap(),
11941                    access_token: "at".into(),
11942                    refresh_token: "rt".into(),
11943                    token_type: "DPoP".into(),
11944                    granted_scope: "atproto".into(),
11945                    expires_at: Some(store::now_unix() + 3600),
11946                },
11947            )
11948            .await
11949            .unwrap();
11950        }
11951        state
11952    }
11953
11954    /// The record before anyone touches it — the seeded one, as a value.
11955    fn race_seed() -> serde_json::Value {
11956        seeded_subscription()["value"].clone()
11957    }
11958
11959    /// Post the manage row's rename (url unchanged, a new title and folder) and
11960    /// return the redirect location.
11961    async fn post_race_rename(state: &AppState) -> String {
11962        post_race_rename_body(
11963            state,
11964            "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11965        )
11966        .await
11967    }
11968
11969    /// Post `body` as the rename of `rk-keep`; returns the redirect location.
11970    async fn post_race_rename_body(state: &AppState, body: &str) -> String {
11971        let cookie = session_cookie(state, RACE_DID, None);
11972        let resp = router(state.clone())
11973            .oneshot(
11974                Request::builder()
11975                    .method("POST")
11976                    .uri("/subscriptions/rk-keep/rename")
11977                    .header(header::COOKIE, cookie)
11978                    .header("content-type", "application/x-www-form-urlencoded")
11979                    .body(Body::from(body.to_string()))
11980                    .unwrap(),
11981            )
11982            .await
11983            .unwrap();
11984        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11985        resp.headers()
11986            .get(header::LOCATION)
11987            .unwrap()
11988            .to_str()
11989            .unwrap()
11990            .to_string()
11991    }
11992
11993    const RACE_BACKENDS: [crate::metrics::Backend; 2] = [
11994        crate::metrics::Backend::Sidecar,
11995        crate::metrics::Backend::Rust,
11996    ];
11997
11998    /// **The key test of #149: a rename that loses a race keeps the other
11999    /// client's change AND lands its own.**
12000    ///
12001    /// The fake lands another client's edit (a new `siteUrl` and `fetchHint`)
12002    /// between the handler's read and its write. The write names the CID it
12003    /// read, so the PDS refuses it; the handler re-reads, re-applies the form's
12004    /// fields to the FRESH record, and writes again under the new CID.
12005    ///
12006    /// With `swapRecord` dropped anywhere on the way out, the first put lands
12007    /// unconditionally and the other client's edit is gone — which is what
12008    /// the final-record assertions catch. Run on both backends: production is
12009    /// on `rust`, and a backend whose put ignores the swap is the exact gap.
12010    #[tokio::test]
12011    async fn a_rename_that_loses_a_race_keeps_the_concurrent_edit_and_lands() {
12012        for backend in RACE_BACKENDS {
12013            let mut theirs = race_seed();
12014            theirs["siteUrl"] = serde_json::json!("https://elsewhere.example/blog");
12015            theirs["fetchHint"] = serde_json::json!("daily");
12016            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12017                value: race_seed(),
12018                concurrent: Some(theirs),
12019                ..SwapRepo::default()
12020            }));
12021            let state = race_state(backend, &repo).await;
12022
12023            let loc = post_race_rename(&state).await;
12024
12025            let repo = repo.lock().unwrap();
12026            assert_eq!(
12027                loc, "/",
12028                "{backend:?}: a rename that converged was not reported as done"
12029            );
12030            assert_eq!(
12031                repo.puts.len(),
12032                2,
12033                "{backend:?}: expected the refused put and one retry: {:?}",
12034                repo.puts
12035            );
12036            assert_eq!(
12037                repo.puts[0]["swapRecord"], "bafyreiversion0",
12038                "{backend:?}: the first put did not name the CID it read: {}",
12039                repo.puts[0]
12040            );
12041            assert_eq!(
12042                repo.puts[1]["swapRecord"], "bafyreiversion1",
12043                "{backend:?}: the retry did not name the RE-READ CID: {}",
12044                repo.puts[1]
12045            );
12046            let landed = &repo.value;
12047            // The reader's change landed...
12048            assert_eq!(landed["title"], "New title", "{backend:?}: {landed}");
12049            assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12050            // ...on top of the other client's, not over it.
12051            assert_eq!(
12052                landed["siteUrl"], "https://elsewhere.example/blog",
12053                "{backend:?}: the concurrent edit was lost: {landed}"
12054            );
12055            assert_eq!(
12056                landed["fetchHint"], "daily",
12057                "{backend:?}: the concurrent edit was lost: {landed}"
12058            );
12059            // And #147's preservation still holds on the retried record.
12060            assert_eq!(
12061                landed["createdAt"], "2024-03-01T00:00:00.000Z",
12062                "{backend:?}: {landed}"
12063            );
12064        }
12065    }
12066
12067    /// **A rename the PDS refuses on every attempt is reported as a conflict,
12068    /// never as done — and is not retried forever.** One retry, so at most two
12069    /// puts; then the reader is told the subscription changed elsewhere.
12070    #[tokio::test]
12071    async fn a_rename_refused_on_every_swap_reports_the_conflict() {
12072        for backend in RACE_BACKENDS {
12073            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12074                value: race_seed(),
12075                refuse_every_swap: true,
12076                ..SwapRepo::default()
12077            }));
12078            let state = race_state(backend, &repo).await;
12079
12080            let loc = post_race_rename(&state).await;
12081
12082            let repo = repo.lock().unwrap();
12083            assert_ne!(loc, "/", "{backend:?}: a refused rename reported success");
12084            assert!(
12085                loc.contains("changed%20elsewhere"),
12086                "{backend:?}: expected the conflict flash, got {loc}"
12087            );
12088            assert!(
12089                (1..=2).contains(&repo.puts.len()),
12090                "{backend:?}: expected at most two put attempts, got {}",
12091                repo.puts.len()
12092            );
12093            assert_eq!(
12094                repo.value,
12095                race_seed(),
12096                "{backend:?}: the record changed though every put was refused"
12097            );
12098        }
12099    }
12100
12101    /// **A put refused for any OTHER reason is not retried**, and keeps the
12102    /// message it had: a re-read cannot fix a rejected record or an outage,
12103    /// and calling it a conflict would send the reader looking for an edit
12104    /// nobody made.
12105    #[tokio::test]
12106    async fn a_rename_refused_for_another_reason_is_not_retried() {
12107        for backend in RACE_BACKENDS {
12108            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12109                value: race_seed(),
12110                fail_puts: Some((400, "InvalidRequest")),
12111                ..SwapRepo::default()
12112            }));
12113            let state = race_state(backend, &repo).await;
12114
12115            let loc = post_race_rename(&state).await;
12116
12117            let repo = repo.lock().unwrap();
12118            assert_eq!(
12119                repo.puts.len(),
12120                1,
12121                "{backend:?}: a non-swap refusal was retried"
12122            );
12123            assert!(
12124                loc.contains("Could%20not%20save"),
12125                "{backend:?}: expected the save-failed flash, got {loc}"
12126            );
12127            assert!(
12128                !loc.contains("changed%20elsewhere"),
12129                "{backend:?}: a non-swap refusal was reported as a conflict: {loc}"
12130            );
12131        }
12132    }
12133
12134    /// What the manage row posts for a reader who changed only the title: the
12135    /// url and folder as they were, plus the `seen_*` values the inputs were
12136    /// pre-filled with.
12137    const SEEN_SEED: &str = "seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12138                             &seen_title=Old+title&seen_folder=";
12139
12140    /// **A retry merges; it does not replay the whole form.** Another client
12141    /// repoints the record (A -> B, with B's own siteUrl and fetchHint) while
12142    /// the reader only retitles it. The form still carries URL A — it is a
12143    /// hidden input — so replaying it on the fresh record "repointed" back to
12144    /// A, cleared the other client's siteUrl and fetchHint, and reported
12145    /// success. The reader changed the title and nothing else, so the title is
12146    /// all that may move. Run with and without the `seen_*` inputs: without
12147    /// them the base is the handler's first read.
12148    #[tokio::test]
12149    async fn a_retry_keeps_a_concurrent_repoint_the_reader_did_not_make() {
12150        for backend in RACE_BACKENDS {
12151            for with_seen in [true, false] {
12152                let mut theirs = race_seed();
12153                theirs["url"] = serde_json::json!("https://moved.example/feed.xml");
12154                theirs["siteUrl"] = serde_json::json!("https://moved.example/");
12155                theirs["fetchHint"] = serde_json::json!("daily");
12156                let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12157                    value: race_seed(),
12158                    concurrent: Some(theirs),
12159                    ..SwapRepo::default()
12160                }));
12161                let state = race_state(backend, &repo).await;
12162                let mut body =
12163                    "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title".to_string();
12164                if with_seen {
12165                    body.push_str(
12166                        "&seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml&seen_title=Old+title",
12167                    );
12168                }
12169
12170                let loc = post_race_rename_body(&state, &body).await;
12171
12172                let repo = repo.lock().unwrap();
12173                let ctx = format!("{backend:?} seen={with_seen}");
12174                assert_eq!(loc, "/", "{ctx}: the rename did not land: {loc}");
12175                assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12176                let landed = &repo.value;
12177                assert_eq!(landed["title"], "New title", "{ctx}: {landed}");
12178                assert_eq!(
12179                    landed["url"], "https://moved.example/feed.xml",
12180                    "{ctx}: the retry repointed the record back to the stale URL: {landed}"
12181                );
12182                assert_eq!(
12183                    landed["siteUrl"], "https://moved.example/",
12184                    "{ctx}: {landed}"
12185                );
12186                assert_eq!(landed["fetchHint"], "daily", "{ctx}: {landed}");
12187            }
12188        }
12189    }
12190
12191    /// The other client retitles; the reader only moves the folder. Their
12192    /// title is kept and the folder applied — the reader's stale copy of the
12193    /// title (posted because the input is always submitted) is not a change.
12194    #[tokio::test]
12195    async fn a_retry_keeps_a_concurrent_retitle_when_the_reader_only_moved_it() {
12196        for backend in RACE_BACKENDS {
12197            let mut theirs = race_seed();
12198            theirs["title"] = serde_json::json!("Their title");
12199            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12200                value: race_seed(),
12201                concurrent: Some(theirs),
12202                ..SwapRepo::default()
12203            }));
12204            let state = race_state(backend, &repo).await;
12205
12206            let loc = post_race_rename_body(
12207                &state,
12208                &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Old+title&folder=Tech&{SEEN_SEED}"),
12209            )
12210            .await;
12211
12212            let repo = repo.lock().unwrap();
12213            assert_eq!(loc, "/", "{backend:?}: {loc}");
12214            let landed = &repo.value;
12215            assert_eq!(
12216                landed["title"], "Their title",
12217                "{backend:?}: the reader's untouched title overwrote the other client's: {landed}"
12218            );
12219            assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12220        }
12221    }
12222
12223    /// **Both changed the same field: a conflict, and nothing is written.**
12224    /// Neither edit can be chosen for the reader, so they are told, and the
12225    /// other client's title stays.
12226    #[tokio::test]
12227    async fn both_retitling_is_a_conflict_that_writes_nothing() {
12228        for backend in RACE_BACKENDS {
12229            let mut theirs = race_seed();
12230            theirs["title"] = serde_json::json!("Their title");
12231            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12232                value: race_seed(),
12233                concurrent: Some(theirs.clone()),
12234                ..SwapRepo::default()
12235            }));
12236            let state = race_state(backend, &repo).await;
12237
12238            let loc = post_race_rename_body(
12239                &state,
12240                &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12241            )
12242            .await;
12243
12244            let repo = repo.lock().unwrap();
12245            assert!(
12246                loc.contains("changed%20elsewhere"),
12247                "{backend:?}: expected the conflict flash, got {loc}"
12248            );
12249            assert_eq!(
12250                repo.puts.len(),
12251                1,
12252                "{backend:?}: a conflicting retry was written: {:?}",
12253                repo.puts
12254            );
12255            assert_eq!(
12256                repo.value, theirs,
12257                "{backend:?}: their title was overwritten"
12258            );
12259        }
12260    }
12261
12262    /// **The page-load window: an edit that landed BEFORE the handler's first
12263    /// read.** The manage page showed URL A; another client repointed to B
12264    /// before the reader pressed Save, so the first read already sees B and
12265    /// no swap fails. The `seen_url` the page was rendered with is what says
12266    /// the reader never touched the URL — without it, the stale hidden `url`
12267    /// reads as a repoint back to A.
12268    #[tokio::test]
12269    async fn a_repoint_before_the_first_read_is_kept_when_the_reader_only_retitled() {
12270        for backend in RACE_BACKENDS {
12271            let mut moved = race_seed();
12272            moved["url"] = serde_json::json!("https://moved.example/feed.xml");
12273            moved["siteUrl"] = serde_json::json!("https://moved.example/");
12274            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12275                value: moved,
12276                ..SwapRepo::default()
12277            }));
12278            let state = race_state(backend, &repo).await;
12279
12280            let loc = post_race_rename_body(
12281                &state,
12282                &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12283            )
12284            .await;
12285
12286            let repo = repo.lock().unwrap();
12287            assert_eq!(loc, "/", "{backend:?}: {loc}");
12288            assert_eq!(repo.puts.len(), 1, "{backend:?}");
12289            let landed = &repo.value;
12290            assert_eq!(
12291                landed["url"], "https://moved.example/feed.xml",
12292                "{backend:?}: the stale hidden url repointed the record: {landed}"
12293            );
12294            assert_eq!(landed["siteUrl"], "https://moved.example/", "{backend:?}");
12295            assert_eq!(landed["title"], "New title", "{backend:?}");
12296        }
12297    }
12298
12299    /// **The cache follows the PDS, never leads it.** A rename that did not
12300    /// land — every swap refused, or the put failed for another reason — must
12301    /// leave the local `feeds` cache as it was: no row for a repoint's new URL
12302    /// (the poller would fetch a feed nobody subscribes to), and no new title
12303    /// on the existing row. One that landed updates it as before.
12304    #[tokio::test]
12305    async fn a_rename_that_did_not_land_leaves_the_cache_alone() {
12306        const NEW_URL: &str = "https://other.example/feed.xml";
12307        const OLD_URL: &str = "https://example.com/feed.xml";
12308        // (refuse every swap, fail every put, expect the write to land)
12309        for (refuse_every_swap, fail_puts, lands) in [
12310            (true, None, false),
12311            (false, Some((400, "InvalidRequest")), false),
12312            (false, Some((502, "UpstreamFailure")), false),
12313            (false, None, true),
12314        ] {
12315            for backend in RACE_BACKENDS {
12316                let ctx = format!("{backend:?} refuse={refuse_every_swap} fail={fail_puts:?}");
12317                for repoint in [false, true] {
12318                    let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12319                        value: race_seed(),
12320                        refuse_every_swap,
12321                        fail_puts,
12322                        ..SwapRepo::default()
12323                    }));
12324                    let state = race_state(backend, &repo).await;
12325                    store::upsert_feed(
12326                        &state.db,
12327                        &store::NewFeed {
12328                            url: OLD_URL.to_string(),
12329                            title: Some("Cached title".to_string()),
12330                            ..Default::default()
12331                        },
12332                    )
12333                    .await
12334                    .unwrap();
12335                    let url = if repoint { NEW_URL } else { OLD_URL };
12336                    let body = format!("url={}&title=New+title&{SEEN_SEED}", qenc(url));
12337
12338                    let loc = post_race_rename_body(&state, &body).await;
12339
12340                    let new_row = store::get_feed_by_url(&state.db, NEW_URL).await.unwrap();
12341                    let old_row = store::get_feed_by_url(&state.db, OLD_URL)
12342                        .await
12343                        .unwrap()
12344                        .expect("the old row");
12345                    let ctx = format!("{ctx} repoint={repoint} -> {loc}");
12346                    if lands {
12347                        assert_eq!(loc, "/", "{ctx}");
12348                        if repoint {
12349                            assert!(
12350                                new_row.is_some(),
12351                                "{ctx}: a landed repoint got no cache row"
12352                            );
12353                        } else {
12354                            assert_eq!(old_row.title.as_deref(), Some("New title"), "{ctx}");
12355                        }
12356                    } else {
12357                        assert_ne!(loc, "/", "{ctx}");
12358                        assert!(
12359                            new_row.is_none(),
12360                            "{ctx}: a repoint that did not land left a feeds row for its URL"
12361                        );
12362                        assert_eq!(
12363                            old_row.title.as_deref(),
12364                            Some("Cached title"),
12365                            "{ctx}: a rename that did not land changed the cached title"
12366                        );
12367                    }
12368                }
12369            }
12370        }
12371    }
12372
12373    /// **A page with no folder dropdown does not un-folder.** The select (and
12374    /// its `seen_folder`) render only when the reader has folders the page
12375    /// could list — none, or a failed folder listing, and neither is posted.
12376    /// That is "the reader never saw a folder", not "the reader chose none":
12377    /// a retitle from such a page used to un-folder the subscription, and on
12378    /// a retry could report a conflict on a field the reader never saw.
12379    #[tokio::test]
12380    async fn a_rename_from_a_page_without_a_folder_select_keeps_the_folder() {
12381        for backend in RACE_BACKENDS {
12382            for raced in [false, true] {
12383                let mut seed = race_seed();
12384                seed["folder"] = serde_json::json!("at://did:plc:racer149/folder/kept");
12385                let concurrent = raced.then(|| {
12386                    let mut theirs = seed.clone();
12387                    theirs["folder"] = serde_json::json!("at://did:plc:racer149/folder/theirs");
12388                    theirs
12389                });
12390                let want_folder = concurrent
12391                    .as_ref()
12392                    .map_or(seed["folder"].clone(), |t| t["folder"].clone());
12393                let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12394                    value: seed,
12395                    concurrent,
12396                    ..SwapRepo::default()
12397                }));
12398                let state = race_state(backend, &repo).await;
12399
12400                // Exactly what the manage row posts with no folder select.
12401                let loc = post_race_rename_body(
12402                    &state,
12403                    "url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12404                     &seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12405                     &seen_title=Old+title&title=New+title",
12406                )
12407                .await;
12408
12409                let repo = repo.lock().unwrap();
12410                let ctx = format!("{backend:?} raced={raced}");
12411                assert_eq!(loc, "/", "{ctx}: {loc}");
12412                assert_eq!(repo.value["title"], "New title", "{ctx}");
12413                assert_eq!(
12414                    repo.value["folder"], want_folder,
12415                    "{ctx}: a page that never showed a folder changed it: {}",
12416                    repo.value
12417                );
12418            }
12419        }
12420    }
12421
12422    /// **A double-clicked Save is not a conflict.** Both POSTs read the same
12423    /// CID; the first lands; the second's swap fails, and its re-read finds
12424    /// the record already saying exactly what the reader asked for. That is
12425    /// success, with nothing left to write — not "nothing was renamed".
12426    #[tokio::test]
12427    async fn a_double_submitted_rename_reports_success_and_writes_once() {
12428        for backend in RACE_BACKENDS {
12429            // The first submission's write, landing between the second's read
12430            // and its put.
12431            let mut first = race_seed();
12432            first["title"] = serde_json::json!("New title");
12433            first["folder"] = serde_json::json!("Tech");
12434            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12435                value: race_seed(),
12436                concurrent: Some(first.clone()),
12437                ..SwapRepo::default()
12438            }));
12439            let state = race_state(backend, &repo).await;
12440
12441            let loc = post_race_rename_body(
12442                &state,
12443                &format!(
12444                    "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech&{SEEN_SEED}"
12445                ),
12446            )
12447            .await;
12448
12449            let repo = repo.lock().unwrap();
12450            assert_eq!(
12451                loc, "/",
12452                "{backend:?}: a save that landed was reported as a conflict: {loc}"
12453            );
12454            assert_eq!(
12455                repo.puts.len(),
12456                1,
12457                "{backend:?}: only the refused put; the re-read has nothing left to write: {:?}",
12458                repo.puts
12459            );
12460            assert_eq!(repo.value, first, "{backend:?}");
12461        }
12462    }
12463
12464    // -- #268: renaming a folder edits the record, it does not replace it ----
12465
12466    /// A folder record as another `community.lexicon.rss` client might have
12467    /// left it: a sort position, an old `createdAt`, and a field this build
12468    /// does not know.
12469    fn folder_seed() -> serde_json::Value {
12470        serde_json::json!({
12471            "$type": crate::lexicon::nsid::FOLDER,
12472            "name": "Old name",
12473            "position": 3,
12474            "createdAt": "2024-01-01T00:00:00.000Z",
12475            "color": "#abc",
12476        })
12477    }
12478
12479    /// A [`SwapRepo`] holding `value` as the folder `rk-keep`.
12480    fn folder_repo(value: serde_json::Value) -> SwapRepo {
12481        SwapRepo {
12482            value,
12483            collection: Some(crate::lexicon::nsid::FOLDER),
12484            ..SwapRepo::default()
12485        }
12486    }
12487
12488    /// Post `body` as the rename of folder `rk-keep`; returns the redirect.
12489    async fn post_folder_rename(state: &AppState, body: &str) -> String {
12490        let cookie = session_cookie(state, RACE_DID, None);
12491        let resp = router(state.clone())
12492            .oneshot(
12493                Request::builder()
12494                    .method("POST")
12495                    .uri("/folders/rk-keep/rename")
12496                    .header(header::COOKIE, cookie)
12497                    .header("content-type", "application/x-www-form-urlencoded")
12498                    .body(Body::from(body.to_string()))
12499                    .unwrap(),
12500            )
12501            .await
12502            .unwrap();
12503        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12504        resp.headers()
12505            .get(header::LOCATION)
12506            .unwrap()
12507            .to_str()
12508            .unwrap()
12509            .to_string()
12510    }
12511
12512    /// `value` with its name set to `name`.
12513    fn renamed(mut value: serde_json::Value, name: &str) -> serde_json::Value {
12514        value["name"] = serde_json::json!(name);
12515        value
12516    }
12517
12518    /// **The key test of #268: a rename changes the name and nothing else.**
12519    /// The handler used to put `Folder::new(name, now)` over the record, which
12520    /// reset `position`, replaced `createdAt` with the rename time and dropped
12521    /// every field another client had added. The exact body put is asserted,
12522    /// so any field lost or invented on the way fails it — on both backends.
12523    #[tokio::test]
12524    async fn renaming_a_folder_changes_only_its_name() {
12525        for backend in RACE_BACKENDS {
12526            let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(folder_seed())));
12527            let state = race_state(backend, &repo).await;
12528
12529            let loc = post_folder_rename(&state, "name=New+name").await;
12530
12531            let repo = repo.lock().unwrap();
12532            assert_eq!(
12533                loc, "/",
12534                "{backend:?}: a landed rename was not reported as done"
12535            );
12536            assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
12537            assert_eq!(
12538                repo.puts[0]["record"],
12539                renamed(folder_seed(), "New name"),
12540                "{backend:?}: the put did not keep the record whole: {}",
12541                repo.puts[0]
12542            );
12543            assert_eq!(
12544                repo.puts[0]["collection"],
12545                crate::lexicon::nsid::FOLDER,
12546                "{backend:?}"
12547            );
12548            assert_eq!(
12549                repo.puts[0]["swapRecord"], "bafyreiversion0",
12550                "{backend:?}: the put did not name the CID it read: {}",
12551                repo.puts[0]
12552            );
12553        }
12554    }
12555
12556    /// **A rename that loses a race keeps the other client's change and
12557    /// lands.** Another client moves the folder (and adds a field) between the
12558    /// read and the write; the swap is refused, the handler re-reads and
12559    /// renames the FRESH record.
12560    #[tokio::test]
12561    async fn a_folder_rename_that_loses_a_race_keeps_the_concurrent_edit() {
12562        for backend in RACE_BACKENDS {
12563            for with_seen in [true, false] {
12564                let mut theirs = folder_seed();
12565                theirs["position"] = serde_json::json!(7);
12566                theirs["icon"] = serde_json::json!("star");
12567                let mut fake = folder_repo(folder_seed());
12568                fake.concurrent = Some(theirs.clone());
12569                let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12570                let state = race_state(backend, &repo).await;
12571
12572                let body = if with_seen {
12573                    "name=New+name&seen_name=Old+name"
12574                } else {
12575                    "name=New+name"
12576                };
12577                let loc = post_folder_rename(&state, body).await;
12578
12579                let repo = repo.lock().unwrap();
12580                let ctx = format!("{backend:?} with_seen={with_seen}");
12581                assert_eq!(
12582                    loc, "/",
12583                    "{ctx}: a converged rename was not reported as done"
12584                );
12585                assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12586                assert_eq!(repo.puts[0]["swapRecord"], "bafyreiversion0", "{ctx}");
12587                assert_eq!(
12588                    repo.puts[1]["swapRecord"], "bafyreiversion1",
12589                    "{ctx}: the retry did not name the RE-READ CID"
12590                );
12591                assert_eq!(
12592                    repo.value,
12593                    renamed(theirs, "New name"),
12594                    "{ctx}: the concurrent edit was lost"
12595                );
12596            }
12597        }
12598    }
12599
12600    /// **Both renaming the folder, differently, is a conflict that writes
12601    /// nothing** — the reader is told, and the other client's name stands.
12602    /// With and without `seen_name`: without it, the first read is the
12603    /// ancestor.
12604    #[tokio::test]
12605    async fn both_renaming_a_folder_differently_is_a_conflict() {
12606        for backend in RACE_BACKENDS {
12607            for body in ["name=New+name&seen_name=Old+name", "name=New+name"] {
12608                let theirs = renamed(folder_seed(), "Their name");
12609                let mut fake = folder_repo(folder_seed());
12610                fake.concurrent = Some(theirs.clone());
12611                let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12612                let state = race_state(backend, &repo).await;
12613
12614                let loc = post_folder_rename(&state, body).await;
12615
12616                let repo = repo.lock().unwrap();
12617                assert!(
12618                    loc.contains("changed%20elsewhere"),
12619                    "{backend:?} {body}: expected the conflict flash, got {loc}"
12620                );
12621                assert_eq!(
12622                    repo.puts.len(),
12623                    1,
12624                    "{backend:?} {body}: only the refused put: {:?}",
12625                    repo.puts
12626                );
12627                assert_eq!(
12628                    repo.value, theirs,
12629                    "{backend:?} {body}: the other client's name was overwritten"
12630                );
12631            }
12632        }
12633    }
12634
12635    /// **Both renaming it to the SAME name is agreement** — a double-submitted
12636    /// Save whose first request landed. Success, and no second write.
12637    #[tokio::test]
12638    async fn both_renaming_a_folder_the_same_is_success_without_a_write() {
12639        for backend in RACE_BACKENDS {
12640            let theirs = renamed(folder_seed(), "New name");
12641            let mut fake = folder_repo(folder_seed());
12642            fake.concurrent = Some(theirs.clone());
12643            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12644            let state = race_state(backend, &repo).await;
12645
12646            let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
12647
12648            let repo = repo.lock().unwrap();
12649            assert_eq!(
12650                loc, "/",
12651                "{backend:?}: agreement reported as a failure: {loc}"
12652            );
12653            assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
12654            assert_eq!(repo.value, theirs, "{backend:?}");
12655        }
12656    }
12657
12658    /// **A folder that keeps moving is a conflict after one retry**, never
12659    /// reported as renamed and never retried forever.
12660    #[tokio::test]
12661    async fn a_folder_rename_refused_on_every_swap_reports_the_conflict() {
12662        for backend in RACE_BACKENDS {
12663            let mut fake = folder_repo(folder_seed());
12664            fake.refuse_every_swap = true;
12665            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12666            let state = race_state(backend, &repo).await;
12667
12668            let loc = post_folder_rename(&state, "name=New+name").await;
12669
12670            let repo = repo.lock().unwrap();
12671            assert!(
12672                loc.contains("changed%20elsewhere"),
12673                "{backend:?}: expected the conflict flash, got {loc}"
12674            );
12675            assert_eq!(repo.puts.len(), 2, "{backend:?}: one try and one retry");
12676            assert_eq!(repo.value, folder_seed(), "{backend:?}");
12677        }
12678    }
12679
12680    /// **A failed rename tells the reader**, instead of redirecting as if it
12681    /// had worked — and a failure a re-read cannot fix is not retried.
12682    #[tokio::test]
12683    async fn a_failed_folder_rename_shows_an_error() {
12684        for backend in RACE_BACKENDS {
12685            let mut fake = folder_repo(folder_seed());
12686            fake.fail_puts = Some((502, "UpstreamFailure"));
12687            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12688            let state = race_state(backend, &repo).await;
12689
12690            let loc = post_folder_rename(&state, "name=New+name").await;
12691
12692            let repo = repo.lock().unwrap();
12693            assert_ne!(loc, "/", "{backend:?}: a failed rename reported success");
12694            assert!(
12695                loc.contains("Could%20not%20save"),
12696                "{backend:?}: expected the save-failed flash, got {loc}"
12697            );
12698            assert!(!loc.contains("changed%20elsewhere"), "{backend:?}: {loc}");
12699            assert_eq!(
12700                repo.puts.len(),
12701                1,
12702                "{backend:?}: a non-swap failure was retried"
12703            );
12704        }
12705    }
12706
12707    /// **A folder deleted elsewhere is not recreated.** A put at a missing
12708    /// rkey creates the record, which a rename must not do.
12709    #[tokio::test]
12710    async fn renaming_a_folder_that_no_longer_exists_writes_nothing() {
12711        for backend in RACE_BACKENDS {
12712            let mut fake = folder_repo(folder_seed());
12713            fake.missing = true;
12714            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12715            let state = race_state(backend, &repo).await;
12716
12717            let loc = post_folder_rename(&state, "name=New+name").await;
12718
12719            let repo = repo.lock().unwrap();
12720            assert!(
12721                loc.contains("no%20longer%20exists"),
12722                "{backend:?}: expected the missing-folder flash, got {loc}"
12723            );
12724            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
12725        }
12726    }
12727
12728    /// **A folder that cannot be read is not renamed** — rebuilding it from
12729    /// the form instead is the record loss this read exists to prevent.
12730    #[tokio::test]
12731    async fn a_folder_rename_whose_read_fails_writes_nothing() {
12732        for backend in RACE_BACKENDS {
12733            let mut fake = folder_repo(folder_seed());
12734            fake.fail_list = true;
12735            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12736            let state = race_state(backend, &repo).await;
12737
12738            let loc = post_folder_rename(&state, "name=New+name").await;
12739
12740            let repo = repo.lock().unwrap();
12741            assert!(
12742                loc.contains("Could%20not%20reach"),
12743                "{backend:?}: expected the read-failed flash, got {loc}"
12744            );
12745            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
12746        }
12747    }
12748
12749    /// **`seen_name` closes the page-load window.** Another client renamed the
12750    /// folder after the page was rendered but before the handler read it, so
12751    /// no swap fails; the form still says what the reader saw, and their
12752    /// different rename is a conflict rather than a silent overwrite.
12753    #[tokio::test]
12754    async fn a_rename_elsewhere_after_page_load_is_a_conflict_with_seen_name() {
12755        for backend in RACE_BACKENDS {
12756            let theirs = renamed(folder_seed(), "Their name");
12757            let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
12758            let state = race_state(backend, &repo).await;
12759
12760            let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
12761
12762            let repo = repo.lock().unwrap();
12763            assert!(
12764                loc.contains("changed%20elsewhere"),
12765                "{backend:?}: expected the conflict flash, got {loc}"
12766            );
12767            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
12768            assert_eq!(repo.value, theirs, "{backend:?}");
12769        }
12770    }
12771
12772    /// A form whose name is the one it showed changes nothing: no write, and
12773    /// a rename another client made since stands.
12774    #[tokio::test]
12775    async fn an_unchanged_folder_name_writes_nothing() {
12776        for backend in RACE_BACKENDS {
12777            let theirs = renamed(folder_seed(), "Their name");
12778            let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
12779            let state = race_state(backend, &repo).await;
12780
12781            let loc = post_folder_rename(&state, "name=Old+name&seen_name=Old+name").await;
12782
12783            let repo = repo.lock().unwrap();
12784            assert_eq!(loc, "/", "{backend:?}");
12785            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
12786            assert_eq!(repo.value, theirs, "{backend:?}");
12787        }
12788    }
12789
12790    /// The folder merge, case by case (#268).
12791    #[test]
12792    fn merge_folder_rename_is_a_three_way_merge_on_the_name() {
12793        let base = Folder::new("Old", "2024-01-01T00:00:00.000Z");
12794        let with = |name: &str| {
12795            let mut f = base.clone();
12796            f.name = name.to_string();
12797            f.position = Some(3);
12798            f.extra
12799                .insert("color".to_string(), serde_json::json!("#abc"));
12800            f
12801        };
12802        // The reader left the name as shown: nothing to write.
12803        assert_eq!(
12804            merge_folder_rename("Old", Some("Old"), &base, with("Other")),
12805            Ok(FolderMerge::Unchanged)
12806        );
12807        // Only the reader changed it: the FRESH record, renamed.
12808        assert_eq!(
12809            merge_folder_rename("New", Some("Old"), &base, with("Old")),
12810            Ok(FolderMerge::Write(with("New")))
12811        );
12812        // Fresh already holds the reader's name: agreement.
12813        assert_eq!(
12814            merge_folder_rename("New", Some("Old"), &base, with("New")),
12815            Ok(FolderMerge::AlreadySaved)
12816        );
12817        // Both changed it, differently: a conflict.
12818        assert_eq!(
12819            merge_folder_rename("New", Some("Old"), &base, with("Other")),
12820            Err(RenameConflict("name"))
12821        );
12822        // Without seen_name the first read is the ancestor.
12823        assert_eq!(
12824            merge_folder_rename("New", None, &with("Other"), with("Other")),
12825            Ok(FolderMerge::Write(with("New")))
12826        );
12827        assert_eq!(
12828            merge_folder_rename("New", None, &base, with("Other")),
12829            Err(RenameConflict("name"))
12830        );
12831        // Padding is not a change.
12832        assert_eq!(
12833            merge_folder_rename(" Old ", Some("Old "), &base, with("Other")),
12834            Ok(FolderMerge::Unchanged)
12835        );
12836        assert_eq!(
12837            merge_folder_rename("New ", Some("Old"), &base, with(" Old ")),
12838            Ok(FolderMerge::Write(with("New")))
12839        );
12840    }
12841
12842    /// Both sides changing a field to the SAME value is agreement, not a
12843    /// conflict — for every field the merge handles. Alongside a field still
12844    /// to apply, the write goes ahead with it; alone, there is nothing to
12845    /// write and the save is already done.
12846    #[test]
12847    fn the_same_change_on_both_sides_is_not_a_conflict() {
12848        let base = merge_base();
12849        let url = "https://a.example/feed.xml";
12850        let new_url = "https://c.example/feed.xml";
12851
12852        // title: both "New".
12853        let mut fresh = base.clone();
12854        fresh.title = Some("New".to_string());
12855        let merged = merge_rename(&merge_form(url, "New", Some("at://f/old")), &base, fresh)
12856            .expect("same title is no conflict");
12857        assert!(merged.already_saved, "nothing left to write");
12858
12859        // folder: both moved to the same folder, while the reader also retitles.
12860        let mut fresh = base.clone();
12861        fresh.folder = Some("at://f/new".to_string());
12862        let merged = merge_rename(&merge_form(url, "Mine", Some("at://f/new")), &base, fresh)
12863            .expect("same folder is no conflict");
12864        assert!(!merged.already_saved, "the title is still to write");
12865        assert_eq!(merged.sub.title.as_deref(), Some("Mine"));
12866        assert_eq!(merged.sub.folder.as_deref(), Some("at://f/new"));
12867
12868        // url: both repointed to the same URL. Not a repoint by THIS write, so
12869        // the fresh record's siteUrl (which may be for the new feed) stays.
12870        let mut fresh = base.clone();
12871        fresh.url = new_url.to_string();
12872        fresh.site_url = Some("https://c.example/".to_string());
12873        let merged = merge_rename(
12874            &merge_form(new_url, "Old", Some("at://f/old")),
12875            &base,
12876            fresh,
12877        )
12878        .expect("same url is no conflict");
12879        assert!(merged.already_saved);
12880        assert!(!merged.repoint);
12881        assert_eq!(merged.sub.site_url.as_deref(), Some("https://c.example/"));
12882
12883        // siteUrl: both set it the same.
12884        let mut fresh = base.clone();
12885        fresh.site_url = Some("https://same.example/".to_string());
12886        let mut form = merge_form(url, "Old", Some("at://f/old"));
12887        form.site_url = Some("https://same.example/".to_string());
12888        let merged = merge_rename(&form, &base, fresh).expect("same siteUrl is no conflict");
12889        assert!(merged.already_saved);
12890
12891        // A form with no edits at all is NOT "already saved": it writes, as
12892        // it always has.
12893        let merged = merge_rename(
12894            &merge_form(url, "Old", Some("at://f/old")),
12895            &base,
12896            base.clone(),
12897        )
12898        .unwrap();
12899        assert!(!merged.already_saved);
12900    }
12901
12902    fn merge_form(url: &str, title: &str, folder: Option<&str>) -> RenameSubForm {
12903        RenameSubForm {
12904            url: url.to_string(),
12905            title: Some(title.to_string()),
12906            site_url: None,
12907            folder: folder.map(str::to_string),
12908            seen_url: None,
12909            seen_title: None,
12910            seen_folder: None,
12911        }
12912    }
12913
12914    fn merge_base() -> Subscription {
12915        let mut s = Subscription::new("https://a.example/feed.xml", "2024-03-01T00:00:00.000Z");
12916        s.title = Some("Old".to_string());
12917        s.folder = Some("at://f/old".to_string());
12918        s.site_url = Some("https://a.example/".to_string());
12919        s
12920    }
12921
12922    /// The merge, field by field, for the branches the handler tests do not
12923    /// each reach: every field the reader changed that someone else also
12924    /// changed is a conflict; every field only one side changed merges.
12925    #[test]
12926    fn merge_rename_is_a_three_way_merge_per_field() {
12927        let base = merge_base();
12928        let url = "https://a.example/feed.xml";
12929
12930        // Folder: both moved it -> conflict; only the reader -> applied.
12931        let mut theirs = base.clone();
12932        theirs.folder = Some("at://f/theirs".to_string());
12933        assert_eq!(
12934            merge_rename(
12935                &merge_form(url, "Old", Some("at://f/mine")),
12936                &base,
12937                theirs.clone()
12938            ),
12939            Err(RenameConflict("folder"))
12940        );
12941        let merged = merge_rename(
12942            &merge_form(url, "Old", Some("at://f/mine")),
12943            &base,
12944            base.clone(),
12945        )
12946        .unwrap();
12947        assert_eq!(merged.sub.folder.as_deref(), Some("at://f/mine"));
12948        assert!(!merged.repoint);
12949        // Only they moved it: theirs stands.
12950        let merged =
12951            merge_rename(&merge_form(url, "Old", Some("at://f/old")), &base, theirs).unwrap();
12952        assert_eq!(merged.sub.folder.as_deref(), Some("at://f/theirs"));
12953
12954        // URL: both repointed -> conflict.
12955        let mut moved = base.clone();
12956        moved.url = "https://b.example/feed.xml".to_string();
12957        assert_eq!(
12958            merge_rename(
12959                &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
12960                &base,
12961                moved
12962            ),
12963            Err(RenameConflict("url"))
12964        );
12965
12966        // siteUrl: a posted value both sides changed -> conflict.
12967        let mut resited = base.clone();
12968        resited.site_url = Some("https://theirs.example/".to_string());
12969        let mut form = merge_form(url, "Old", Some("at://f/old"));
12970        form.site_url = Some("https://mine.example/".to_string());
12971        assert_eq!(
12972            merge_rename(&form, &base, resited),
12973            Err(RenameConflict("siteUrl"))
12974        );
12975
12976        // A reader's repoint drops the old feed's properties.
12977        let merged = merge_rename(
12978            &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
12979            &base,
12980            base.clone(),
12981        )
12982        .unwrap();
12983        assert!(merged.repoint);
12984        assert_eq!(merged.sub.url, "https://c.example/feed.xml");
12985        assert_eq!(merged.sub.site_url, None);
12986
12987        // seen_* wins over base for "did the reader change it": the input was
12988        // pre-filled with a display title, and posting it back is no edit.
12989        let mut untitled = base.clone();
12990        untitled.title = None;
12991        let mut form = merge_form(url, "A display fallback", Some("at://f/old"));
12992        form.seen_title = Some("A display fallback".to_string());
12993        let merged = merge_rename(&form, &untitled, untitled.clone()).unwrap();
12994        assert_eq!(
12995            merged.sub.title, None,
12996            "an untouched display title was written"
12997        );
12998    }
12999
13000    /// **A rename must not destroy the fields the form never carries.**
13001    ///
13002    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
13003    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
13004    /// every field absent from `templates/manage_row.html` (which posts only
13005    /// `url`, `title`, `folder`) was written back as its default:
13006    ///
13007    /// | field | before | after |
13008    /// |---|---|---|
13009    /// | `siteUrl` | whatever the feed advertised | gone |
13010    /// | `fetchHint` | as set | gone |
13011    /// | `private` | as set | gone |
13012    /// | `createdAt` | original subscribe time | reset to now |
13013    ///
13014    /// `createdAt` is the worst of the four: it is the sort key for "when did I
13015    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
13016    /// tells the reader it moved.
13017    ///
13018    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
13019    /// in the test — the record only becomes wrong on the way out, so checking
13020    /// the value we passed in would pass just as happily with the fix removed.
13021    #[tokio::test]
13022    async fn renaming_preserves_the_fields_the_form_never_carries() {
13023        let did = "did:plc:renamer4";
13024        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13025        let state = test_state_with_sidecar(&[did], &sidecar).await;
13026        let cookie = session_cookie(&state, did, None);
13027
13028        let resp = router(state.clone())
13029            .oneshot(
13030                Request::builder()
13031                    .method("POST")
13032                    .uri("/subscriptions/rk-keep/rename")
13033                    .header(header::COOKIE, cookie)
13034                    .header("content-type", "application/x-www-form-urlencoded")
13035                    // Exactly what the manage row posts: url, title, folder.
13036                    .body(Body::from(
13037                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
13038                    ))
13039                    .unwrap(),
13040            )
13041            .await
13042            .unwrap();
13043        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13044
13045        let bodies = puts.lock().unwrap().clone();
13046        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13047        let body = &bodies[0];
13048        // Anchors the negative assertions: an empty capture would satisfy them.
13049        assert!(
13050            body.contains("community.lexicon.rss.subscription"),
13051            "captured no usable put body: {body:?}"
13052        );
13053
13054        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
13055        let record = &sent["record"];
13056
13057        // What the form DID carry must be applied.
13058        assert_eq!(record["title"], "New title", "the rename did not apply");
13059        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
13060
13061        // What the form did NOT carry must survive.
13062        assert_eq!(
13063            record["createdAt"], "2024-03-01T00:00:00.000Z",
13064            "the rename reset createdAt — the reader's subscribe time is gone \
13065             from their own repo, and nothing told them"
13066        );
13067        assert_eq!(
13068            record["siteUrl"], "https://example.com/blog",
13069            "the rename erased siteUrl"
13070        );
13071        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
13072        assert_eq!(record["private"], false, "the rename erased private");
13073    }
13074
13075    /// **Repointing at a different feed drops that feed's properties, but not
13076    /// the subscription's.**
13077    ///
13078    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
13079    /// so carrying them onto a different URL would leave a site link for the old
13080    /// feed hanging off the new one. `createdAt` and `private` are properties of
13081    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
13082    /// subscribed, whatever the URL was later corrected to.
13083    #[tokio::test]
13084    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
13085        let did = "did:plc:renamer4";
13086        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13087        let state = test_state_with_sidecar(&[did], &sidecar).await;
13088        let cookie = session_cookie(&state, did, None);
13089
13090        let resp = router(state.clone())
13091            .oneshot(
13092                Request::builder()
13093                    .method("POST")
13094                    .uri("/subscriptions/rk-keep/rename")
13095                    .header(header::COOKIE, cookie)
13096                    .header("content-type", "application/x-www-form-urlencoded")
13097                    // A DIFFERENT feed URL from the seeded record.
13098                    .body(Body::from(
13099                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
13100                    ))
13101                    .unwrap(),
13102            )
13103            .await
13104            .unwrap();
13105        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13106
13107        let bodies = puts.lock().unwrap().clone();
13108        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13109        assert!(
13110            bodies[0].contains("community.lexicon.rss.subscription"),
13111            "captured no usable put body: {:?}",
13112            bodies[0]
13113        );
13114        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13115        let record = &sent["record"];
13116
13117        assert_eq!(record["url"], "https://other.example/feed.xml");
13118        // The old feed's properties are gone rather than misattributed.
13119        assert!(
13120            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
13121            "the old feed's site link followed the subscription to a new feed: {record}"
13122        );
13123        assert!(
13124            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
13125            "the old feed's fetch hint followed the subscription to a new feed: {record}"
13126        );
13127        // The subscription's own properties survive.
13128        assert_eq!(
13129            record["createdAt"], "2024-03-01T00:00:00.000Z",
13130            "a repoint is still not a new subscription; createdAt must not move"
13131        );
13132        assert_eq!(record["private"], false, "the repoint erased private");
13133    }
13134
13135    /// **A rename against an rkey that is not in the repo writes NOTHING.**
13136    ///
13137    /// `update_subscription` is a `putRecord`, which CREATES the record when the
13138    /// rkey does not exist — with whatever `createdAt` we hand it. So without
13139    /// this refusal a rename against a stale or wrong rkey manufactures a
13140    /// subscription dated today, which is the bug this whole change exists to
13141    /// fix, arriving by a different door.
13142    ///
13143    /// The guard was untested when first written: removing it left all 733 tests
13144    /// green. An untested guard against the exact defect being fixed is how the
13145    /// two previous rounds of this problem got through.
13146    #[tokio::test]
13147    async fn renaming_an_unknown_rkey_writes_nothing() {
13148        let did = "did:plc:renamer4";
13149        // The sidecar serves exactly one record, at rkey `rk-keep`.
13150        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13151        let state = test_state_with_sidecar(&[did], &sidecar).await;
13152        let cookie = session_cookie(&state, did, None);
13153
13154        let resp = router(state.clone())
13155            .oneshot(
13156                Request::builder()
13157                    .method("POST")
13158                    // ...and this is not it.
13159                    .uri("/subscriptions/rk-does-not-exist/rename")
13160                    .header(header::COOKIE, cookie)
13161                    .header("content-type", "application/x-www-form-urlencoded")
13162                    .body(Body::from(
13163                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
13164                    ))
13165                    .unwrap(),
13166            )
13167            .await
13168            .unwrap();
13169
13170        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13171        let loc = resp
13172            .headers()
13173            .get(header::LOCATION)
13174            .unwrap()
13175            .to_str()
13176            .unwrap();
13177        assert!(
13178            loc.contains("flash="),
13179            "an unknown rkey redirected as though the rename had worked: {loc}"
13180        );
13181        assert!(
13182            puts.lock().unwrap().is_empty(),
13183            "a rename against an unknown rkey wrote a record — putRecord would \
13184             CREATE it, dated today: {:?}",
13185            puts.lock().unwrap()
13186        );
13187    }
13188
13189    /// **A `site_url` the client actually sends is applied, not dropped.**
13190    ///
13191    /// `templates/manage_row.html` does not post this field, so it is tempting
13192    /// to read the arm that handles it as dead code. It is not:
13193    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
13194    /// today. Discarding the value instead of applying it left all 733 tests
13195    /// green.
13196    ///
13197    /// The value is scheme-checked on the way out by the repo-boundary vet, so
13198    /// this is a coverage gap rather than an exposure — but an untested path
13199    /// that writes a URL into the reader's PDS should not stay untested.
13200    #[tokio::test]
13201    async fn a_client_supplied_site_url_reaches_the_record() {
13202        let did = "did:plc:renamer4";
13203        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13204        let state = test_state_with_sidecar(&[did], &sidecar).await;
13205        let cookie = session_cookie(&state, did, None);
13206
13207        let resp = router(state.clone())
13208            .oneshot(
13209                Request::builder()
13210                    .method("POST")
13211                    .uri("/subscriptions/rk-keep/rename")
13212                    .header(header::COOKIE, cookie)
13213                    .header("content-type", "application/x-www-form-urlencoded")
13214                    // Same feed URL, but carrying a site_url the manage row
13215                    // never sends.
13216                    .body(Body::from(
13217                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
13218                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
13219                    ))
13220                    .unwrap(),
13221            )
13222            .await
13223            .unwrap();
13224        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13225
13226        let bodies = puts.lock().unwrap().clone();
13227        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13228        assert!(
13229            bodies[0].contains("community.lexicon.rss.subscription"),
13230            "captured no usable put body: {:?}",
13231            bodies[0]
13232        );
13233        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13234        assert_eq!(
13235            sent["record"]["siteUrl"], "https://typed.example/site",
13236            "the client's siteUrl was dropped; the seeded record's survived instead"
13237        );
13238    }
13239
13240    /// **A rename whose read fails writes NOTHING.**
13241    ///
13242    /// This is the property most easily lost when someone later touches this
13243    /// handler: falling back to `Subscription::new` on a read error looks like
13244    /// graceful degradation and is in fact the original bug, reinstated on
13245    /// exactly the path where it is hardest to notice. The reader must be told
13246    /// instead.
13247    #[tokio::test]
13248    async fn a_rename_whose_read_fails_writes_nothing() {
13249        let did = "did:plc:renamer5";
13250        // A port that accepts nothing: the read cannot succeed.
13251        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13252        let dead = format!("http://{}", listener.local_addr().unwrap());
13253        drop(listener);
13254
13255        let state = test_state_with_sidecar(&[did], &dead).await;
13256        let cookie = session_cookie(&state, did, None);
13257        let before = store::count_feeds(&state.db).await.unwrap();
13258
13259        let resp = router(state.clone())
13260            .oneshot(
13261                Request::builder()
13262                    .method("POST")
13263                    .uri("/subscriptions/rk-keep/rename")
13264                    .header(header::COOKIE, cookie)
13265                    .header("content-type", "application/x-www-form-urlencoded")
13266                    .body(Body::from(
13267                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
13268                    ))
13269                    .unwrap(),
13270            )
13271            .await
13272            .unwrap();
13273
13274        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13275        let loc = resp
13276            .headers()
13277            .get(header::LOCATION)
13278            .unwrap()
13279            .to_str()
13280            .unwrap();
13281        assert!(
13282            loc.contains("flash="),
13283            "a failed read redirected as though the rename had worked: {loc}"
13284        );
13285        assert_eq!(
13286            store::count_feeds(&state.db).await.unwrap(),
13287            before,
13288            "a rename that could not read the record still wrote to the cache"
13289        );
13290    }
13291
13292    /// Folder pre-selection regression: the manage rename row must mark the
13293    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
13294    /// re-submits the current folder instead of silently un-foldering the feed.
13295    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
13296    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
13297    #[test]
13298    fn manage_rename_row_preselects_current_folder() {
13299        let nav = Nav {
13300            handle: "@reader.example".to_string(),
13301            avatar: "RE".to_string(),
13302            view: "unread".to_string(),
13303            scope_qs: String::new(),
13304            folders: Vec::new(),
13305            loose_feeds: Vec::new(),
13306            manage_active: true,
13307        };
13308        let folder_options = vec![
13309            FolderOption {
13310                uri: "at://did:plc:x/app.folder/work".to_string(),
13311                name: "Work".to_string(),
13312            },
13313            FolderOption {
13314                uri: "at://did:plc:x/app.folder/fun".to_string(),
13315                name: "Fun".to_string(),
13316            },
13317        ];
13318        // A foldered feed (in "Work") and a loose feed (no folder), each with a
13319        // non-empty rkey so the rename form renders.
13320        let foldered = FeedView {
13321            rkey: "sub-foldered".to_string(),
13322            url: "https://work.example/feed.xml".to_string(),
13323            title: "Work Feed".to_string(),
13324            unread: 0,
13325            selected: false,
13326            folder: Some("at://did:plc:x/app.folder/work".to_string()),
13327        };
13328        let loose = FeedView {
13329            rkey: "sub-loose".to_string(),
13330            url: "https://loose.example/feed.xml".to_string(),
13331            title: "Loose Feed".to_string(),
13332            unread: 0,
13333            selected: false,
13334            folder: None,
13335        };
13336        let tmpl = ManageTemplate {
13337            card: Card::private(&Config::default()),
13338            version: VERSION,
13339            repo_url: REPO_URL,
13340            kofi_url: KOFI_URL,
13341            flash: String::new(),
13342            alert: String::new(),
13343            nav,
13344            folder_options,
13345            folders: vec![FolderView {
13346                rkey: "folder-work".to_string(),
13347                uri: "at://did:plc:x/app.folder/work".to_string(),
13348                name: "Work".to_string(),
13349                feeds: vec![foldered],
13350                selected: false,
13351            }],
13352            loose_feeds: vec![loose],
13353            standard_site: false,
13354        };
13355        let html = tmpl.render().unwrap();
13356
13357        // The foldered feed's "Work" option is pre-selected.
13358        assert!(
13359            html.contains(
13360                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
13361            ),
13362            "foldered feed must pre-select its current folder: {html}"
13363        );
13364        // The loose feed's "No folder" option is pre-selected (appears for the
13365        // loose row, which has folder=None).
13366        assert!(
13367            html.contains(r#"<option value="" selected>No folder</option>"#),
13368            "loose feed must pre-select 'No folder': {html}"
13369        );
13370
13371        // #149: the values each input was pre-filled with ride along, so the
13372        // handler can tell what the reader changed from what they merely saw.
13373        for want in [
13374            r#"<input type="hidden" name="seen_url" value="https://work.example/feed.xml" />"#,
13375            r#"<input type="hidden" name="seen_title" value="Work Feed" />"#,
13376            r#"<input type="hidden" name="seen_folder" value="at://did:plc:x/app.folder/work" />"#,
13377            r#"<input type="hidden" name="seen_folder" value="" />"#,
13378            // #268: the folder rename form says which name it showed.
13379            r#"<input type="hidden" name="seen_name" value="Work" />"#,
13380        ] {
13381            assert!(html.contains(want), "missing {want}: {html}");
13382        }
13383    }
13384
13385    /// **The public stats page carries no user data.**
13386    ///
13387    /// It is reachable by anyone, so the thing worth pinning is what it does
13388    /// NOT say: nothing about how many people use the instance, nothing about
13389    /// which feeds fail, nothing about who reads what.
13390    #[tokio::test]
13391    async fn the_public_stats_page_exposes_no_user_data() {
13392        let state = test_state(&[]).await;
13393        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
13394            .await
13395            .unwrap();
13396
13397        let resp = router(state)
13398            .oneshot(
13399                Request::builder()
13400                    .uri("/stats")
13401                    .body(Body::empty())
13402                    .unwrap(),
13403            )
13404            .await
13405            .unwrap();
13406        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
13407
13408        let body = String::from_utf8(
13409            axum::body::to_bytes(resp.into_body(), usize::MAX)
13410                .await
13411                .unwrap()
13412                .to_vec(),
13413        )
13414        .unwrap();
13415
13416        // Structural checks, not word checks. The page's own prose says it
13417        // publishes no error rates, so searching for that PHRASE finds the
13418        // disclaimer rather than a leak — the first version of this test failed
13419        // on exactly that. What matters is whether identifiers or the
13420        // admin-only figures are present.
13421        assert!(
13422            !body.contains("did:"),
13423            "the public stats page leaked an identifier"
13424        );
13425        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
13426            assert!(
13427                !body.contains(admin_only),
13428                "the public page is showing the admin metrics column {admin_only:?}"
13429            );
13430        }
13431        // And it does render the aggregate it exists for.
13432        assert!(body.contains("Feeds tracked"));
13433        assert!(body.contains("Waiting to be polled"));
13434    }
13435
13436    /// **The two states that stop feeds updating must be visible.**
13437    ///
13438    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
13439    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
13440    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
13441    /// the backlog and makes the page read healthier. That inversion is what this
13442    /// test pins: a broken feed must raise a number, not lower one.
13443    #[tokio::test]
13444    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
13445        let state = test_state(&[]).await;
13446        // Three feeds: one healthy, one flaky, one long dead.
13447        for (url, errors) in [
13448            ("https://ok.example/f.xml", 0),
13449            ("https://flaky.example/f.xml", 2),
13450            ("https://dead.example/f.xml", 9),
13451        ] {
13452            store::upsert_feed(
13453                &state.db,
13454                &store::NewFeed {
13455                    url: url.to_string(),
13456                    // Pushed forward, exactly as backoff does — so none of these
13457                    // are counted as `overdue`.
13458                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13459                    ..Default::default()
13460                },
13461            )
13462            .await
13463            .unwrap();
13464            for _ in 0..errors {
13465                store::bump_feed_errors(
13466                    &state.db,
13467                    url,
13468                    feed::FailureKind::Fetch,
13469                    "connection refused",
13470                )
13471                .await
13472                .unwrap();
13473            }
13474        }
13475
13476        let render_stats = |state: AppState| async move {
13477            let resp = router(state)
13478                .oneshot(
13479                    Request::builder()
13480                        .uri("/stats")
13481                        .body(Body::empty())
13482                        .unwrap(),
13483                )
13484                .await
13485                .unwrap();
13486            assert_eq!(resp.status(), StatusCode::OK);
13487            String::from_utf8(
13488                axum::body::to_bytes(resp.into_body(), usize::MAX)
13489                    .await
13490                    .unwrap()
13491                    .to_vec(),
13492            )
13493            .unwrap()
13494        };
13495
13496        // **The fixture must actually be RUNNING, or this test measures nothing.**
13497        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
13498        // checks that BEFORE the watermark — so without these two lines every
13499        // render below reports "off" and the watermark can never surface. The
13500        // assertions still passed, for reasons unrelated to what they name: see
13501        // the two comments below.
13502        state.runtime_health.set_schedulers_enabled(true);
13503        state
13504            .runtime_health
13505            .poll_tick_completed(crate::store::now_unix());
13506
13507        let body = render_stats(state.clone()).await;
13508        assert!(
13509            body.contains("Failing"),
13510            "backoff is still invisible on the public page"
13511        );
13512        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
13513        // value rather than on surrounding whitespace, so re-indenting the
13514        // template cannot break this.
13515        assert!(
13516            body.contains("2, 1 badly"),
13517            "expected '2, 1 badly' in the failing row; got:\n{}",
13518            body.split("Failing")
13519                .nth(1)
13520                .unwrap_or("")
13521                .chars()
13522                .take(300)
13523                .collect::<String>()
13524        );
13525        // Not paused, and the backlog is genuinely empty — which is exactly the
13526        // reading that used to be indistinguishable from healthy.
13527        //
13528        // **Asserted by EXCLUDING the other states, not by matching "running".**
13529        // The `off` row reads "the poller is not running on this instance", which
13530        // contains "running" — so the bare substring passed while the page was
13531        // reporting the exact opposite of what this line claims to check.
13532        assert!(
13533            !body.contains("the poller is not running")
13534                && !body.contains("the cache is at its size limit")
13535                && !body.contains("has not completed a round"),
13536            "expected the running state; the page reported a stopped one",
13537        );
13538
13539        // Now trip the watermark. Nothing in the database changes; only the
13540        // recorded runtime state does — which is the whole reason it needed a
13541        // home outside the log stream.
13542        state.runtime_health.set_watermark(true);
13543        let paused = render_stats(state.clone()).await;
13544        // Matched on the paused row's OWN sentence. The bare word "paused" also
13545        // appeared in the page's explanatory prose, so this assertion passed
13546        // whether or not the row rendered — and trimming that prose is what
13547        // exposed it. This phrase exists only inside the `paused` branch.
13548        assert!(
13549            paused.contains("the cache is at its size limit"),
13550            "a watermark pause is still invisible on the public page"
13551        );
13552
13553        // Still no identifiers: these are counts, not feeds.
13554        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
13555            assert!(
13556                !paused.contains(leak),
13557                "the public page leaked {leak:?} while reporting failures"
13558            );
13559        }
13560    }
13561
13562    /// **`/admin/metrics` is gated, and nothing checked that it was.**
13563    ///
13564    /// Deleting the `admin_seed_dids` check left the entire suite green. That
13565    /// was survivable while the page held only aggregate timings; it is not now,
13566    /// because this branch puts **per-feed URLs and remote error text** behind
13567    /// that gate. A guarantee nothing checks is a comment, and this one is now
13568    /// the only thing standing between a signed-in stranger and the operational
13569    /// picture the handler's own doc says is not public.
13570    ///
13571    /// All three doors: no session, a session that is not an admin, and the
13572    /// admin itself.
13573    #[tokio::test]
13574    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
13575        let admin = "did:plc:adminseed";
13576        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
13577        // IS that list — deliberately, per its doc: "the same people I trust on
13578        // this instance". Production sets it to the bootstrap DID alone.
13579        //
13580        // A genuine non-admin is therefore someone holding a beta seat granted
13581        // by an invite, not by the allow-list. Seeding both would have made
13582        // both admins and quietly turned the 403 assertion below into a test of
13583        // nothing — which is exactly what the first draft of this did.
13584        let state = test_state(&[admin]).await;
13585        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
13586            .await
13587            .unwrap();
13588        let url = "https://broken.example/f.xml";
13589        store::upsert_feed(
13590            &state.db,
13591            &store::NewFeed {
13592                url: url.to_string(),
13593                ..Default::default()
13594            },
13595        )
13596        .await
13597        .unwrap();
13598        store::bump_feed_errors(
13599            &state.db,
13600            url,
13601            feed::FailureKind::Fetch,
13602            "SENTINEL_ADMIN_ONLY",
13603        )
13604        .await
13605        .unwrap();
13606
13607        let get = |state: AppState, cookie: Option<String>| async move {
13608            let mut req = Request::builder().uri("/admin/metrics");
13609            if let Some(c) = cookie {
13610                req = req.header(header::COOKIE, c);
13611            }
13612            let resp = router(state)
13613                .oneshot(req.body(Body::empty()).unwrap())
13614                .await
13615                .unwrap();
13616            let status = resp.status();
13617            let body = String::from_utf8(
13618                axum::body::to_bytes(resp.into_body(), usize::MAX)
13619                    .await
13620                    .unwrap()
13621                    .to_vec(),
13622            )
13623            .unwrap();
13624            (status, body)
13625        };
13626
13627        // No session at all.
13628        let (status, body) = get(state.clone(), None).await;
13629        assert_eq!(status, StatusCode::UNAUTHORIZED);
13630        assert!(
13631            !body.contains("SENTINEL_ADMIN_ONLY"),
13632            "leaked to anonymous: {body}"
13633        );
13634
13635        // A real, signed-in user who is not an admin.
13636        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
13637        let (status, body) = get(state.clone(), Some(ordinary)).await;
13638        assert_eq!(
13639            status,
13640            StatusCode::FORBIDDEN,
13641            "a non-admin session was let in"
13642        );
13643        assert!(
13644            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
13645            "leaked to a non-admin: {body}",
13646        );
13647
13648        // The admin does get it — otherwise the two refusals above are
13649        // satisfied by the endpoint being broken for everyone.
13650        let admin_cookie = session_cookie(&state, admin, None);
13651        let (status, body) = get(state, Some(admin_cookie)).await;
13652        assert_eq!(status, StatusCode::OK);
13653        assert!(
13654            body.contains("SENTINEL_ADMIN_ONLY"),
13655            "admin cannot see it: {body}"
13656        );
13657    }
13658
13659    /// **The cause a public count cannot carry belongs on the admin page.**
13660    ///
13661    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
13662    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
13663    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
13664    /// have separated "sixty dead publishers" from "one bug here", which is the
13665    /// case it was justified by.
13666    ///
13667    /// The answer is not a finer public vocabulary — `/stats` promises never
13668    /// which feed and never whose, and a bucket per error string would break
13669    /// that. It is to put the detail where per-feed data is already allowed.
13670    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
13671    /// operational picture.
13672    ///
13673    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
13674    /// public one.
13675    #[tokio::test]
13676    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
13677        let admin = "did:plc:adminseed";
13678        let state = test_state(&[admin]).await;
13679        let url = "https://broken.example/f.xml";
13680        store::upsert_feed(
13681            &state.db,
13682            &store::NewFeed {
13683                url: url.to_string(),
13684                ..Default::default()
13685            },
13686        )
13687        .await
13688        .unwrap();
13689        store::bump_feed_errors(
13690            &state.db,
13691            url,
13692            feed::FailureKind::Fetch,
13693            "SENTINEL_REDIRECT_NO_LOCATION",
13694        )
13695        .await
13696        .unwrap();
13697
13698        let cookie = session_cookie(&state, admin, None);
13699        let resp = router(state.clone())
13700            .oneshot(
13701                Request::builder()
13702                    .uri("/admin/metrics")
13703                    .header(header::COOKIE, cookie)
13704                    .body(Body::empty())
13705                    .unwrap(),
13706            )
13707            .await
13708            .unwrap();
13709        assert_eq!(resp.status(), StatusCode::OK);
13710        let admin_body = String::from_utf8(
13711            axum::body::to_bytes(resp.into_body(), usize::MAX)
13712                .await
13713                .unwrap()
13714                .to_vec(),
13715        )
13716        .unwrap();
13717        assert!(
13718            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
13719            "the admin page does not carry the failure detail: {admin_body}",
13720        );
13721        assert!(
13722            admin_body.contains("broken.example"),
13723            "the admin page does not name the failing feed: {admin_body}",
13724        );
13725
13726        // The public page still carries neither.
13727        let resp = router(state)
13728            .oneshot(
13729                Request::builder()
13730                    .uri("/stats")
13731                    .body(Body::empty())
13732                    .unwrap(),
13733            )
13734            .await
13735            .unwrap();
13736        let public = String::from_utf8(
13737            axum::body::to_bytes(resp.into_body(), usize::MAX)
13738                .await
13739                .unwrap()
13740                .to_vec(),
13741        )
13742        .unwrap();
13743        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
13744            assert!(
13745                !public.contains(secret),
13746                "{secret:?} reached the PUBLIC stats page: {public}",
13747            );
13748        }
13749    }
13750
13751    /// **A direct poll must settle the error columns, like the scheduler does.**
13752    ///
13753    /// `add_subscription` polls through `feed::poll_feed` rather than the
13754    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
13755    /// touches `consecutive_errors` — that is the scheduler's job, and this path
13756    /// is not the scheduler.
13757    ///
13758    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
13759    /// its old count and its old cause: the public page went on reporting it
13760    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
13761    /// the stale backoff horizon lasted — up to 24h — while the reader was
13762    /// demonstrably fetching it.
13763    #[tokio::test]
13764    async fn a_successful_direct_poll_clears_a_stale_failure() {
13765        let state = test_state(&[]).await;
13766        let url = "https://recovered.example/f.xml";
13767        store::upsert_feed(
13768            &state.db,
13769            &store::NewFeed {
13770                url: url.to_string(),
13771                ..Default::default()
13772            },
13773        )
13774        .await
13775        .unwrap();
13776        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
13777            .await
13778            .unwrap();
13779        // Park it on a stale backoff horizon, as a real failing feed would be.
13780        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
13781            .bind(url)
13782            .execute(&state.db)
13783            .await
13784            .unwrap();
13785
13786        // The publisher is fixed: a successful poll happens on this path.
13787        feed::settle_poll(
13788            &state.db,
13789            url,
13790            &feed::PollOutcome::NotModified,
13791            state.config.poll_interval,
13792        )
13793        .await;
13794
13795        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
13796            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
13797        )
13798        .bind(url)
13799        .fetch_one(&state.db)
13800        .await
13801        .unwrap();
13802        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
13803        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
13804        // **The half the first fix missed.** Clearing the count fixed the
13805        // REPORTING; the feed stayed parked until 2099. A working feed must be
13806        // rescheduled on its normal cadence, not left on the failure horizon.
13807        let next = row.2.expect("next_poll was cleared to NULL");
13808        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
13809        // backoff. A mutation that reschedules successes with backoff_for(1)
13810        // (5 min) also moves it off 2099, so the interval is asserted.
13811        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
13812        let delta = parsed
13813            .signed_duration_since(chrono::Utc::now())
13814            .num_seconds();
13815        let cadence = state.config.poll_interval.as_secs() as i64;
13816        assert!(
13817            (cadence - 60..=cadence + 60).contains(&delta),
13818            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
13819        );
13820    }
13821
13822    /// The mirror case: a first poll that FAILS must be visible at all.
13823    ///
13824    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
13825    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
13826    /// with a NULL cause — invisible to the page built to count exactly that.
13827    #[tokio::test]
13828    async fn a_failing_direct_poll_is_recorded() {
13829        let state = test_state(&[]).await;
13830        let url = "https://born-broken.example/f.xml";
13831        store::upsert_feed(
13832            &state.db,
13833            &store::NewFeed {
13834                url: url.to_string(),
13835                ..Default::default()
13836            },
13837        )
13838        .await
13839        .unwrap();
13840
13841        feed::settle_poll(
13842            &state.db,
13843            url,
13844            &feed::PollOutcome::Failed {
13845                backoff: std::time::Duration::from_secs(300),
13846                kind: feed::FailureKind::Parse,
13847                detail: "SENTINEL_BORN_BROKEN".to_string(),
13848            },
13849            state.config.poll_interval,
13850        )
13851        .await;
13852
13853        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
13854            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
13855        )
13856        .bind(url)
13857        .fetch_one(&state.db)
13858        .await
13859        .unwrap();
13860        assert_eq!(row.0, 1, "a failed first poll was not counted");
13861        assert_eq!(
13862            row.1.as_deref(),
13863            Some("parse"),
13864            "its cause was not recorded"
13865        );
13866        // And it is BACKED OFF on the schedule the scheduler would use — not
13867        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
13868        // on the very next tick.
13869        let next = row.2.expect("a failed direct poll left next_poll NULL");
13870        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
13871        let delta = parsed
13872            .signed_duration_since(chrono::Utc::now())
13873            .num_seconds();
13874        assert!(
13875            (240..=360).contains(&delta),
13876            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
13877        );
13878    }
13879
13880    /// **The breakdown must sum to the Failing figure above it.**
13881    ///
13882    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
13883    /// `consecutive_errors > 0`. On a migrated database every row that was
13884    /// already failing has a NULL kind — correctly, it was never recorded — so
13885    /// the two do not reconcile and the page shows "70 failing" beside "3
13886    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
13887    /// entirely while the prose still promises a breakdown.
13888    ///
13889    /// An explicit `unknown` bucket is the honest shape: the page says how many
13890    /// it cannot explain rather than omitting them.
13891    #[tokio::test]
13892    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
13893        let state = test_state(&[]).await;
13894        // Two legacy rows: failing, with no recorded cause.
13895        for url in [
13896            "https://legacy1.example/f.xml",
13897            "https://legacy2.example/f.xml",
13898        ] {
13899            store::upsert_feed(
13900                &state.db,
13901                &store::NewFeed {
13902                    url: url.to_string(),
13903                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13904                    ..Default::default()
13905                },
13906            )
13907            .await
13908            .unwrap();
13909            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
13910                .bind(url)
13911                .execute(&state.db)
13912                .await
13913                .unwrap();
13914        }
13915        // One row with a recorded cause.
13916        store::upsert_feed(
13917            &state.db,
13918            &store::NewFeed {
13919                url: "https://known.example/f.xml".to_string(),
13920                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13921                ..Default::default()
13922            },
13923        )
13924        .await
13925        .unwrap();
13926        store::bump_feed_errors(
13927            &state.db,
13928            "https://known.example/f.xml",
13929            feed::FailureKind::Status,
13930            "SENTINEL",
13931        )
13932        .await
13933        .unwrap();
13934
13935        let now = chrono::Utc::now();
13936        let health = store::poll_health(
13937            &state.db,
13938            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
13939            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
13940        )
13941        .await
13942        .unwrap();
13943        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
13944        assert_eq!(
13945            counted, health.in_backoff,
13946            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
13947            health.in_backoff, health.failure_kinds,
13948        );
13949        assert!(
13950            health
13951                .failure_kinds
13952                .iter()
13953                .any(|(k, n)| k == "unknown" && *n == 2),
13954            "no unknown bucket for the legacy rows: {:?}",
13955            health.failure_kinds,
13956        );
13957    }
13958
13959    /// **The breakdown is ordered by count, and the assertion can see it.**
13960    ///
13961    /// The first version of this asserted with three `contains` calls, which
13962    /// cannot observe order — deleting `ORDER BY` from the query passed.
13963    #[tokio::test]
13964    async fn the_failure_breakdown_is_ordered_by_count() {
13965        let state = test_state(&[]).await;
13966        for (url, kind, n) in [
13967            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
13968            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
13969            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
13970            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
13971            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
13972            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
13973        ] {
13974            store::upsert_feed(
13975                &state.db,
13976                &store::NewFeed {
13977                    url: url.to_string(),
13978                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13979                    ..Default::default()
13980                },
13981            )
13982            .await
13983            .unwrap();
13984            for _ in 0..n {
13985                store::bump_feed_errors(&state.db, url, kind, "d")
13986                    .await
13987                    .unwrap();
13988            }
13989        }
13990        let now = chrono::Utc::now();
13991        let health = store::poll_health(
13992            &state.db,
13993            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
13994            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
13995        )
13996        .await
13997        .unwrap();
13998        let labels: Vec<&str> = health
13999            .failure_kinds
14000            .iter()
14001            .map(|(k, _)| k.as_str())
14002            .collect();
14003        assert_eq!(
14004            labels,
14005            ["fetch", "status", "parse"],
14006            "not ordered by count, descending: {:?}",
14007            health.failure_kinds,
14008        );
14009    }
14010
14011    /// **Failing feeds are grouped by CAUSE, and still never named.**
14012    ///
14013    /// `badly_broken` could say that sixty feeds were failing and not whether
14014    /// that was sixty dead publishers or one bug here. It was the latter — #159,
14015    /// a `304 Not Modified` read as a malformed redirect — and the page could
14016    /// not say so, which is most of why it went unexamined.
14017    ///
14018    /// The second half of this test is the constraint that shapes the first:
14019    /// `/stats` is public and promises machines-not-people, *never which feed
14020    /// and never whose*. A histogram of causes keeps that promise; a list of
14021    /// failing URLs would break it, and is the obvious way to build this.
14022    #[tokio::test]
14023    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
14024        let state = test_state(&[]).await;
14025        for (url, kind, detail, errors) in [
14026            // Detail strings are distinctive SENTINELS, not plausible English.
14027            // A first pass used "not a feed", which the page's own explanation
14028            // of the `parse` kind contains verbatim — the privacy assertion
14029            // fired on static copy rather than on a leak. A sentinel cannot
14030            // collide with prose.
14031            (
14032                "https://a.example/f.xml",
14033                feed::FailureKind::Fetch,
14034                "SENTINEL_CONNREFUSED",
14035                3,
14036            ),
14037            (
14038                "https://b.example/f.xml",
14039                feed::FailureKind::Fetch,
14040                "SENTINEL_DNSFAIL",
14041                2,
14042            ),
14043            (
14044                "https://c.example/f.xml",
14045                feed::FailureKind::Status,
14046                "SENTINEL_404",
14047                1,
14048            ),
14049            (
14050                "https://d.example/f.xml",
14051                feed::FailureKind::Parse,
14052                "SENTINEL_UNPARSEABLE",
14053                1,
14054            ),
14055        ] {
14056            store::upsert_feed(
14057                &state.db,
14058                &store::NewFeed {
14059                    url: url.to_string(),
14060                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14061                    ..Default::default()
14062                },
14063            )
14064            .await
14065            .unwrap();
14066            for _ in 0..errors {
14067                store::bump_feed_errors(&state.db, url, kind, detail)
14068                    .await
14069                    .unwrap();
14070            }
14071        }
14072
14073        let resp = router(state.clone())
14074            .oneshot(
14075                Request::builder()
14076                    .uri("/stats")
14077                    .body(Body::empty())
14078                    .unwrap(),
14079            )
14080            .await
14081            .unwrap();
14082        assert_eq!(resp.status(), StatusCode::OK);
14083        let body = String::from_utf8(
14084            axum::body::to_bytes(resp.into_body(), usize::MAX)
14085                .await
14086                .unwrap()
14087                .to_vec(),
14088        )
14089        .unwrap();
14090
14091        // Descending by count: two fetch, then one each, tie-broken by name.
14092        assert!(
14093            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
14094            "the cause histogram did not render: {body}",
14095        );
14096
14097        // **The privacy half.** No feed URL, host, or error detail reaches the
14098        // public page — only counts by kind.
14099        for secret in [
14100            "a.example",
14101            "b.example",
14102            "c.example",
14103            "d.example",
14104            "SENTINEL_CONNREFUSED",
14105            "SENTINEL_DNSFAIL",
14106            "SENTINEL_404",
14107            "SENTINEL_UNPARSEABLE",
14108        ] {
14109            assert!(
14110                !body.contains(secret),
14111                "{secret:?} reached the PUBLIC stats page: {body}",
14112            );
14113        }
14114    }
14115
14116    /// `/health` must prove the process can reach its database, and must report
14117    /// the loop state without letting it change the status code.
14118    #[tokio::test]
14119    async fn health_checks_the_database_and_reports_the_loops() {
14120        let state = test_state(&[]).await;
14121        let body_of = |state: AppState| async move {
14122            let resp = router(state)
14123                .oneshot(
14124                    Request::builder()
14125                        .uri("/health")
14126                        .body(Body::empty())
14127                        .unwrap(),
14128                )
14129                .await
14130                .unwrap();
14131            let status = resp.status();
14132            let body = String::from_utf8(
14133                axum::body::to_bytes(resp.into_body(), usize::MAX)
14134                    .await
14135                    .unwrap()
14136                    .to_vec(),
14137            )
14138            .unwrap();
14139            (status, body)
14140        };
14141
14142        // The boot stamp is what `main` sets; the router alone does not, so this
14143        // starts "unknown" and the uptime branch below drives it explicitly.
14144        state
14145            .runtime_health
14146            .set_started_at(chrono::Utc::now().timestamp());
14147
14148        let (status, body) = body_of(state.clone()).await;
14149        assert_eq!(status, StatusCode::OK);
14150        assert!(
14151            body.contains("db: ok"),
14152            "health did not probe the DB: {body}"
14153        );
14154        assert!(
14155            body.contains("uptime:"),
14156            "no uptime — the first thing anyone asks about a container that may \
14157             be restarting: {body}"
14158        );
14159        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
14160        assert!(body.contains("polling-paused: no"), "{body}");
14161        assert!(body.contains("backend:"), "{body}");
14162        assert!(body.contains("oauth-runtime:"), "{body}");
14163
14164        // A watermark pause is REPORTED but must not fail the check. A failed
14165        // check DEREGISTERS this machine from the proxy — and it is the only
14166        // machine — so it would turn "feeds are behind" into "the site is down"
14167        // for as long as the disk stays full.
14168        state.runtime_health.set_watermark(true);
14169        state.runtime_health.set_schedulers_enabled(true);
14170        let (status, body) = body_of(state.clone()).await;
14171        assert_eq!(
14172            status,
14173            StatusCode::OK,
14174            "a watermark pause must not fail the liveness check: {body}"
14175        );
14176        assert!(body.contains("polling-paused: yes"), "{body}");
14177        // Schedulers on but no tick yet — and that must not read as "0s ago",
14178        // which is the healthiest possible answer to an unanswered question.
14179        assert!(
14180            body.contains("poller: not-yet-ticked"),
14181            "a never-ticked poller must say so: {body}"
14182        );
14183
14184        // A stale heartbeat is likewise reported, not fatal.
14185        let stale_after = health_tick_stale_secs(configured_poll_tick());
14186        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
14187        state.runtime_health.poll_tick_completed(long_ago);
14188        let (status, body) = body_of(state.clone()).await;
14189        assert_eq!(
14190            status,
14191            StatusCode::OK,
14192            "a stale poller must not 503: {body}"
14193        );
14194        assert!(body.contains("poller: stale"), "{body}");
14195
14196        // **A poller that has never ticked stops being benign.**
14197        //
14198        // In a crash loop with 30 s+ boot cycles the poller never reaches its
14199        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
14200        // could not detect the one failure mode the startup delays were added
14201        // for. It is read against uptime now.
14202        state.runtime_health.poll_tick_completed(0); // reset to "never"
14203        state
14204            .runtime_health
14205            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
14206        let (status, body) = body_of(state.clone()).await;
14207        assert_eq!(status, StatusCode::OK);
14208        assert!(
14209            body.contains("poller: stale never-ticked"),
14210            "a poller that never ticked long after boot still reads as benign: {body}"
14211        );
14212
14213        // A closed pool is a real outage: nothing can be served, and a restart is
14214        // the correct response. THIS is what the status code is for.
14215        state.db.close().await;
14216        let (status, body) = body_of(state.clone()).await;
14217        assert_eq!(
14218            status,
14219            StatusCode::SERVICE_UNAVAILABLE,
14220            "an unreachable database must fail the check: {body}"
14221        );
14222        assert!(body.starts_with("FAIL"), "{body}");
14223        // Coarse, not the raw sqlx error: an unauthenticated caller learning
14224        // exactly which failure it hit is an attack-progress oracle, and this
14225        // endpoint is exempt from the origin lock.
14226        assert!(
14227            !body.contains("PoolClosed") && !body.contains("sqlx"),
14228            "health leaked the raw database error to an unauthenticated caller: {body}"
14229        );
14230    }
14231
14232    /// The staleness threshold must track the configured tick.
14233    ///
14234    /// Hardcoded at 15 minutes, an operator who raised
14235    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
14236    /// in the body the deployment docs tell them to alert on.
14237    #[test]
14238    fn the_stale_threshold_follows_the_poll_tick() {
14239        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
14240        // alerting that early would fire on any brief hiccup.
14241        assert_eq!(
14242            health_tick_stale_secs(Duration::from_secs(60)),
14243            HEALTH_TICK_STALE_FLOOR_SECS
14244        );
14245        // A slow tick raises it, so a legitimately-configured loop is never
14246        // permanently "stale".
14247        let slow = Duration::from_secs(30 * 60);
14248        assert!(
14249            health_tick_stale_secs(slow) > slow.as_secs() as i64,
14250            "a 30-minute tick must not be stale after one interval"
14251        );
14252        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
14253        // And it cannot overflow into nonsense on an absurd value.
14254        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
14255    }
14256
14257    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
14258    ///
14259    /// `polling_paused` alone rendered "running" for three different states,
14260    /// including the two where nothing polls at all — on the page added to
14261    /// answer exactly that question.
14262    #[tokio::test]
14263    async fn stats_does_not_call_a_stopped_poller_running() {
14264        let state = test_state(&[]).await;
14265        let render = |state: AppState| async move {
14266            let resp = router(state)
14267                .oneshot(
14268                    Request::builder()
14269                        .uri("/stats")
14270                        .body(Body::empty())
14271                        .unwrap(),
14272                )
14273                .await
14274                .unwrap();
14275            assert_eq!(resp.status(), StatusCode::OK);
14276            String::from_utf8(
14277                axum::body::to_bytes(resp.into_body(), usize::MAX)
14278                    .await
14279                    .unwrap()
14280                    .to_vec(),
14281            )
14282            .unwrap()
14283        };
14284
14285        // Schedulers never started: not "running".
14286        let body = render(state.clone()).await;
14287        assert!(
14288            body.contains("the poller is not running on this instance"),
14289            "a disabled poller renders as healthy"
14290        );
14291
14292        // Started, but no tick has finished yet.
14293        state.runtime_health.set_schedulers_enabled(true);
14294        let body = render(state.clone()).await;
14295        assert!(
14296            body.contains("no poll has finished since this instance booted"),
14297            "a poller that has not ticked renders as healthy"
14298        );
14299
14300        // Ticking: running.
14301        state
14302            .runtime_health
14303            .poll_tick_completed(chrono::Utc::now().timestamp());
14304        let body = render(state.clone()).await;
14305        assert!(
14306            body.contains("running"),
14307            "a healthy poller must read as running"
14308        );
14309
14310        // Paused at the watermark still wins over "running".
14311        state.runtime_health.set_watermark(true);
14312        let body = render(state.clone()).await;
14313        assert!(
14314            body.contains("the cache is at its size limit"),
14315            "a watermark pause is hidden once the poller is ticking"
14316        );
14317    }
14318
14319    /// **An UNMEASURED database must not fail the check.**
14320    ///
14321    /// `/health` is the one path exempt from the Cloudflare origin lock and
14322    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
14323    /// drop WITHOUT recording a verdict — so a cancelled request (a client
14324    /// disconnect is enough) leaves the verdict at "none", and a concurrent
14325    /// caller reads it. Treating that as a failure turned an unauthenticated
14326    /// request into a lever on the only signal the platform acts on. The
14327    /// previous version of this code had the opposite bug and reported `ok` for
14328    /// a database nothing had read; "unknown" is neither.
14329    #[tokio::test]
14330    async fn health_reports_an_unmeasured_database_without_failing() {
14331        use crate::runtime_health::DbProbe;
14332        let state = test_state(&[]).await;
14333
14334        // Hold the probe claim, exactly as an in-flight request would, and never
14335        // record a verdict — the cancelled-request state.
14336        let held = state
14337            .runtime_health
14338            .begin_db_probe()
14339            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
14340
14341        let resp = router(state.clone())
14342            .oneshot(
14343                Request::builder()
14344                    .uri("/health")
14345                    .body(Body::empty())
14346                    .unwrap(),
14347            )
14348            .await
14349            .unwrap();
14350        let status = resp.status();
14351        let body = String::from_utf8(
14352            axum::body::to_bytes(resp.into_body(), usize::MAX)
14353                .await
14354                .unwrap()
14355                .to_vec(),
14356        )
14357        .unwrap();
14358        drop(held);
14359
14360        assert_eq!(
14361            status,
14362            StatusCode::OK,
14363            "an unmeasured database failed the check, which an unauthenticated \
14364             caller can cause on demand: {body}"
14365        );
14366        assert!(
14367            body.contains("db: unknown"),
14368            "the unmeasured state must still be REPORTED: {body}"
14369        );
14370        assert!(!body.starts_with("FAIL"), "{body}");
14371        // **And it must not read as `ok` either.** `fly.toml` tells operators to
14372        // alert on the BODY for everything the status code ignores, so a first
14373        // line identical to the healthy one makes a monitor keying on `^ok` read
14374        // green in exactly the state this enum exists to surface.
14375        assert!(
14376            !body.starts_with("ok"),
14377            "the unmeasured state is indistinguishable from healthy to a \
14378             body-matching monitor: {body}"
14379        );
14380        assert!(body.starts_with("unknown"), "{body}");
14381
14382        // **A BORROWED failure must 503 too.**
14383        //
14384        // This previously recorded `Failed` and then closed the pool — but
14385        // `record` consumes the guard and releases the claim, so the request won
14386        // it, ran a live probe against the closed pool, and failed on its own.
14387        // The 503 passed for the wrong reason and the borrow path — the whole
14388        // point of the three-state enum on the read side — had no coverage.
14389        //
14390        // Holding the claim forces the borrow, so the recorded verdict is what
14391        // gets reported.
14392        let held = state
14393            .runtime_health
14394            .begin_db_probe()
14395            .unwrap_or_else(|_| panic!("claim"));
14396        state
14397            .runtime_health
14398            .record_for_test(DbProbe::Failed("unavailable".to_string()));
14399        let resp = router(state.clone())
14400            .oneshot(
14401                Request::builder()
14402                    .uri("/health")
14403                    .body(Body::empty())
14404                    .unwrap(),
14405            )
14406            .await
14407            .unwrap();
14408        let status = resp.status();
14409        let body = String::from_utf8(
14410            axum::body::to_bytes(resp.into_body(), usize::MAX)
14411                .await
14412                .unwrap()
14413                .to_vec(),
14414        )
14415        .unwrap();
14416        drop(held);
14417        assert_eq!(
14418            status,
14419            StatusCode::SERVICE_UNAVAILABLE,
14420            "a BORROWED failure verdict must fail the check, not just a freshly \
14421             measured one: {body}"
14422        );
14423        assert!(body.starts_with("FAIL"), "{body}");
14424
14425        state.db.close().await;
14426        let resp = router(state.clone())
14427            .oneshot(
14428                Request::builder()
14429                    .uri("/health")
14430                    .body(Body::empty())
14431                    .unwrap(),
14432            )
14433            .await
14434            .unwrap();
14435        assert_eq!(
14436            resp.status(),
14437            StatusCode::SERVICE_UNAVAILABLE,
14438            "a measured database failure must still fail the check"
14439        );
14440    }
14441
14442    /// **A disconnected client must not be able to cancel the probe.**
14443    ///
14444    /// Axum drops the handler future when a caller goes away. With the probe
14445    /// inline that dropped it mid-flight and released the claim WITHOUT
14446    /// recording a verdict — which let an unauthenticated caller manufacture the
14447    /// no-verdict state on demand and freeze what every other caller, including
14448    /// Fly's own check, reads. The probe runs detached now, so the verdict is
14449    /// recorded whatever happens to the request that started it.
14450    #[tokio::test]
14451    async fn an_abandoned_request_still_records_its_probe() {
14452        use crate::runtime_health::DbProbe;
14453        let state = test_state(&[]).await;
14454        let rh = state.runtime_health.clone();
14455
14456        // Drive /health and abandon it immediately — the disconnect case.
14457        let app = router(state.clone());
14458        let fut = app.oneshot(
14459            Request::builder()
14460                .uri("/health")
14461                .body(Body::empty())
14462                .unwrap(),
14463        );
14464        let handle = tokio::spawn(fut);
14465        handle.abort();
14466        let _ = handle.await;
14467
14468        // The detached probe still completes and publishes a verdict, so the
14469        // claim is free and the next caller gets a MEASURED answer.
14470        for _ in 0..50 {
14471            if rh.begin_db_probe().is_ok() {
14472                break;
14473            }
14474            tokio::time::sleep(Duration::from_millis(20)).await;
14475        }
14476        let resp = router(state.clone())
14477            .oneshot(
14478                Request::builder()
14479                    .uri("/health")
14480                    .body(Body::empty())
14481                    .unwrap(),
14482            )
14483            .await
14484            .unwrap();
14485        let body = String::from_utf8(
14486            axum::body::to_bytes(resp.into_body(), usize::MAX)
14487                .await
14488                .unwrap()
14489                .to_vec(),
14490        )
14491        .unwrap();
14492        assert!(
14493            body.contains("db: ok"),
14494            "after an abandoned request the next caller still reads an \
14495             unmeasured database — the probe was cancelled with it: {body}"
14496        );
14497        // Sanity: the type still distinguishes the three states.
14498        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
14499    }
14500
14501    /// **The probe must read a real page.**
14502    ///
14503    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
14504    /// it never touches a b-tree and returns success against a corrupted
14505    /// database. Asserted by asking SQLite what the statement actually compiles
14506    /// to, so it survives someone "simplifying" the query later.
14507    #[tokio::test]
14508    async fn the_health_probe_opens_a_real_table() {
14509        use sqlx::Row;
14510        let state = test_state(&[]).await;
14511        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
14512        let opcodes = |sql: &'static str| {
14513            let db = state.db.clone();
14514            async move {
14515                sqlx::query(sql)
14516                    .fetch_all(&db)
14517                    .await
14518                    .unwrap()
14519                    .into_iter()
14520                    .map(|r| r.get::<String, _>("opcode"))
14521                    .collect::<Vec<String>>()
14522            }
14523        };
14524
14525        // The statement `health_db_probe` really runs — it is the sole path, so
14526        // there is no second string for the handler to use instead.
14527        let explain: &'static str =
14528            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
14529        let probe = opcodes(explain).await;
14530        // And the probe itself works against a real schema.
14531        assert!(
14532            health_db_probe(&state.db).await.is_ok(),
14533            "the probe does not run against the real schema",
14534        );
14535        assert!(
14536            probe.iter().any(|op| op == "OpenRead"),
14537            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
14538        );
14539        // And the bare form genuinely does not, which is the whole point.
14540        let bare = opcodes("EXPLAIN SELECT 1").await;
14541        assert!(
14542            !bare.iter().any(|op| op == "OpenRead"),
14543            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
14544        );
14545    }
14546
14547    /// A fresh instance says "never", not "0" — which would read as "polled
14548    /// just now", the opposite of the truth.
14549    #[test]
14550    fn an_instance_that_has_never_polled_says_so() {
14551        assert_eq!(humanise_ago(None), "never");
14552        assert_eq!(humanise_ago(Some(0)), "0s ago");
14553        assert_eq!(humanise_ago(Some(59)), "59s ago");
14554        assert_eq!(humanise_ago(Some(60)), "1m ago");
14555        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
14556        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
14557    }
14558
14559    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
14560    /// record, and anything else with an empty list. Serves repeatedly.
14561    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
14562        use tokio::io::{AsyncReadExt, AsyncWriteExt};
14563        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14564        let addr = listener.local_addr().unwrap();
14565        let (url, title) = (saved_url.to_string(), saved_title.to_string());
14566        tokio::spawn(async move {
14567            loop {
14568                let Ok((mut sock, _)) = listener.accept().await else {
14569                    break;
14570                };
14571                let mut buf = vec![0u8; 8192];
14572                let Ok(n) = sock.read(&mut buf).await else {
14573                    continue;
14574                };
14575                let req = String::from_utf8_lossy(&buf[..n]).to_string();
14576                let wants_saved = req.contains("community.lexicon.rss.saved");
14577                let records = if wants_saved {
14578                    serde_json::json!([{
14579                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
14580                        "cid": "bafy",
14581                        "value": {
14582                            "$type": "community.lexicon.rss.saved",
14583                            "url": url,
14584                            "title": title,
14585                            "createdAt": "2026-01-01T00:00:00Z"
14586                        }
14587                    }])
14588                } else {
14589                    serde_json::json!([])
14590                };
14591                let body = serde_json::json!({
14592                    "ok": true, "data": { "records": records }
14593                })
14594                .to_string();
14595                let resp = format!(
14596                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
14597                    body.len(), body
14598                );
14599                let _ = sock.write_all(resp.as_bytes()).await;
14600                let _ = sock.flush().await;
14601            }
14602        });
14603        format!("http://{addr}")
14604    }
14605
14606    /// A sidecar mock serving `n` distinct saved records, none of them cached
14607    /// locally — the shape that exercises the uncached-row append.
14608    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
14609        let feed = subscribed_feed.to_string();
14610        use tokio::io::{AsyncReadExt, AsyncWriteExt};
14611        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14612        let addr = listener.local_addr().unwrap();
14613        tokio::spawn(async move {
14614            loop {
14615                let Ok((mut sock, _)) = listener.accept().await else {
14616                    break;
14617                };
14618                let mut buf = vec![0u8; 8192];
14619                let Ok(read) = sock.read(&mut buf).await else {
14620                    continue;
14621                };
14622                let req = String::from_utf8_lossy(&buf[..read]).to_string();
14623                let records = if req.contains("community.lexicon.rss.saved") {
14624                    serde_json::Value::Array(
14625                        (0..n)
14626                            .map(|i| {
14627                                serde_json::json!({
14628                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
14629                                    "cid": "bafy",
14630                                    "value": {
14631                                        "$type": "community.lexicon.rss.saved",
14632                                        "url": format!("https://elsewhere.example/{i}"),
14633                                        "title": format!("Elsewhere {i}"),
14634                                        "createdAt": "2026-01-01T00:00:00Z"
14635                                    }
14636                                })
14637                            })
14638                            .collect(),
14639                    )
14640                } else if req.contains("community.lexicon.rss.subscription") {
14641                    // Without this the handler's `sync_sub_refs` would REPLACE
14642                    // sub_ref with an empty set on every render, and every
14643                    // sub_ref-scoped read — including the cached starred list
14644                    // this test is about — would come back empty.
14645                    serde_json::json!([{
14646                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
14647                        "cid": "bafy",
14648                        "value": {
14649                            "$type": "community.lexicon.rss.subscription",
14650                            "url": feed,
14651                            "createdAt": "2026-01-01T00:00:00Z"
14652                        }
14653                    }])
14654                } else {
14655                    serde_json::json!([])
14656                };
14657                let body =
14658                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
14659                let resp = format!(
14660                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
14661                    body.len(), body
14662                );
14663                let _ = sock.write_all(resp.as_bytes()).await;
14664                let _ = sock.flush().await;
14665            }
14666        });
14667        format!("http://{addr}")
14668    }
14669
14670    /// **The pager must not advertise a page the clamp cannot reach.**
14671    ///
14672    /// The page clamp is computed from the CACHED total; the uncached PDS rows
14673    /// are appended to the last page rather than paged. Inflating `total` with
14674    /// them made `page_count` and the "Older →" link point one page past the end:
14675    /// requesting it clamped straight back, re-rendered the same last page, and
14676    /// still offered the link. An infinite "next" that never advances.
14677    #[tokio::test]
14678    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
14679        let did = "did:plc:pagerloop";
14680        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
14681        let state = test_state_with_sidecar(&[], &sidecar).await;
14682        store::grant_access(&state.db, did, None, "test", None)
14683            .await
14684            .unwrap();
14685        let feed = store::upsert_feed(
14686            &state.db,
14687            &store::NewFeed {
14688                url: "https://loop.example/feed.xml".to_string(),
14689                title: Some("Loop".to_string()),
14690                ..Default::default()
14691            },
14692        )
14693        .await
14694        .unwrap();
14695        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
14696        // and the old arithmetic reported a fourth page.
14697        let entries: Vec<store::NewEntry> = (0..250)
14698            .map(|i| store::NewEntry {
14699                guid: format!("s-{i:04}"),
14700                url: Some(format!("https://loop.example/{i}")),
14701                title: Some(format!("Starred {i:04}")),
14702                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
14703                ..Default::default()
14704            })
14705            .collect();
14706        store::insert_entries(&state.db, feed, &entries, 0)
14707            .await
14708            .unwrap();
14709        store::replace_sub_refs(&state.db, did, &[feed])
14710            .await
14711            .unwrap();
14712        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
14713            .await
14714            .unwrap()
14715        {
14716            store::mark_starred(&state.db, did, row.id, true)
14717                .await
14718                .unwrap();
14719        }
14720
14721        let cookie = session_cookie(&state, did, None);
14722        let app = router(state.clone());
14723        let get = |uri: &str| {
14724            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
14725            async move {
14726                let resp = app
14727                    .oneshot(
14728                        Request::builder()
14729                            .uri(uri)
14730                            .header(header::COOKIE, cookie)
14731                            .body(Body::empty())
14732                            .unwrap(),
14733                    )
14734                    .await
14735                    .unwrap();
14736                assert_eq!(resp.status(), StatusCode::OK);
14737                String::from_utf8(
14738                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
14739                        .await
14740                        .unwrap()
14741                        .to_vec(),
14742                )
14743                .unwrap()
14744            }
14745        };
14746
14747        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
14748        // clamp must agree on that, and EVERY page it offers must have content —
14749        // the original bug advertised a fourth page that clamped back to the
14750        // third and re-rendered it, still offering the link.
14751        let p3 = get("/?view=starred&page=3").await;
14752        assert!(
14753            p3.contains("Page 3 of 4"),
14754            "the pager and the clamp disagree on the total: {}",
14755            p3.split("pager-pos")
14756                .nth(1)
14757                .unwrap_or("")
14758                .chars()
14759                .take(120)
14760                .collect::<String>()
14761        );
14762        // Page 3 is the boundary: the last 50 cached rows, then the first 50
14763        // uncached ones.
14764        assert!(
14765            p3.contains("Elsewhere 0"),
14766            "page 3 should start the uncached run"
14767        );
14768        assert_eq!(
14769            p3.matches("<li class=\"entry").count(),
14770            ENTRIES_PER_PAGE as usize,
14771            "the boundary page is not full"
14772        );
14773
14774        // **The heading, which the previous round broke by deleting this.**
14775        //
14776        // `total` includes the uncached records, so the parenthetical is a
14777        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
14778        // The version that said "plus N" double counted once `total` started
14779        // including them, and N had become page-local in the same commit while
14780        // the template stayed put. It shipped because this assertion was deleted
14781        // rather than updated.
14782        {
14783            let body = &p3;
14784            assert!(
14785                body.contains("330 entries"),
14786                "the heading must count the whole sequence: {}",
14787                body.split("content-count")
14788                    .nth(1)
14789                    .unwrap_or("")
14790                    .chars()
14791                    .take(120)
14792                    .collect::<String>()
14793            );
14794            assert!(
14795                body.contains("(80 saved elsewhere)"),
14796                "the heading must say how many of the total the cache cannot show, \
14797                 as a whole-list figure and not a per-page one: {}",
14798                body.split("content-count")
14799                    .nth(1)
14800                    .unwrap_or("")
14801                    .chars()
14802                    .take(120)
14803                    .collect::<String>()
14804            );
14805            assert!(
14806                !body.contains("plus 50") && !body.contains("plus 80"),
14807                "the heading is adding the uncached rows to a total that already \
14808                 includes them"
14809            );
14810        }
14811
14812        let p4 = get("/?view=starred&page=4").await;
14813        assert!(
14814            p4.contains("Page 4 of 4"),
14815            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
14816        );
14817        assert_eq!(
14818            p4.matches("<li class=\"entry").count(),
14819            30,
14820            "page 4 should hold the remaining 30 uncached records"
14821        );
14822        assert!(
14823            p4.contains("Elsewhere 79"),
14824            "the LAST saved record is unreachable — it can only be removed from here"
14825        );
14826
14827        // No uncached record appears on two pages.
14828        assert!(
14829            !p4.contains("Elsewhere 0"),
14830            "an uncached record was rendered on more than one page"
14831        );
14832        // Page 1 is all cached — and still reports the same whole-list heading,
14833        // because the parenthetical describes the LIST, not the page.
14834        let first = get("/?view=starred").await;
14835        assert!(
14836            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
14837            "the heading changed between pages; it describes the list, not the page"
14838        );
14839        assert!(
14840            !first.contains("Elsewhere "),
14841            "uncached saved records leaked onto the first page"
14842        );
14843    }
14844
14845    /// **A saved record whose article is not cached here is still shown.**
14846    ///
14847    /// The starred view is built from local `entries`, so before this a record
14848    /// starred in ANOTHER atproto reader — the portability the shared lexicon
14849    /// exists for — was simply invisible. It now renders from the PDS record,
14850    /// visually distinct, linking straight out.
14851    #[tokio::test]
14852    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
14853        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
14854        let sidecar =
14855            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
14856        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
14857        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
14858
14859        let resp = router(state)
14860            .oneshot(
14861                Request::builder()
14862                    .uri("/?view=starred")
14863                    .body(Body::empty())
14864                    .unwrap(),
14865            )
14866            .await
14867            .unwrap();
14868        assert_eq!(resp.status(), StatusCode::OK);
14869        let body = String::from_utf8(
14870            axum::body::to_bytes(resp.into_body(), usize::MAX)
14871                .await
14872                .unwrap()
14873                .to_vec(),
14874        )
14875        .unwrap();
14876
14877        assert!(
14878            body.contains("Starred elsewhere"),
14879            "the saved record was not rendered at all"
14880        );
14881        assert!(
14882            body.contains("entry-uncached"),
14883            "it was not marked as uncached, so it looks like a normal entry"
14884        );
14885        assert!(
14886            body.contains("https://elsewhere.example/article"),
14887            "the row must link straight to the article"
14888        );
14889        assert!(
14890            !body.contains("/entries/0/"),
14891            "an uncached row must not offer entry actions against a nonexistent id"
14892        );
14893    }
14894
14895    /// **A PDS `createdAt` must not be able to panic the starred view.**
14896    ///
14897    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
14898    /// timestamp the feed parser produced; the saved-record path passes a bare
14899    /// string off a PDS record, written by whatever client the reader used. A
14900    /// multi-byte value panicked the handler, and with no catch-panic layer the
14901    /// view stayed down until the record was removed — from that same view.
14902    #[test]
14903    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
14904        for hostile in [
14905            "日本語日本語日本",
14906            "é",
14907            "",
14908            "2026",
14909            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
14910        ] {
14911            let out = display_date(Some(hostile));
14912            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
14913        }
14914        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
14915        assert_eq!(display_date(None), "");
14916    }
14917
14918    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
14919    /// its neighbours are limited. It was added as a route and not added here.
14920    #[test]
14921    fn the_unsave_route_is_rate_limited() {
14922        use axum::http::Method;
14923        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
14924        // And the neighbours still are.
14925        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
14926    }
14927
14928    /// **The probe detects a broken database — asserted through `/health`
14929    /// itself, not through a string.**
14930    ///
14931    /// A named constant did not bind the handler: it stayed free to call
14932    /// `query_scalar` with a different literal, so degrading the real probe to
14933    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
14934    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
14935    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
14936    #[tokio::test]
14937    async fn health_reports_a_broken_database() {
14938        let state = test_state(&[]).await;
14939        // Sanity: healthy first, so the assertion below is about the damage.
14940        assert!(
14941            health_db_probe(&state.db).await.is_ok(),
14942            "the fixture was not healthy to begin with",
14943        );
14944
14945        sqlx::query("DROP TABLE feeds")
14946            .execute(&state.db)
14947            .await
14948            .unwrap();
14949
14950        assert!(
14951            health_db_probe(&state.db).await.is_err(),
14952            "the probe reported success against a database missing the table it \
14953             claims to read; `SELECT 1` would do exactly this",
14954        );
14955
14956        let resp = router(state)
14957            .oneshot(
14958                Request::builder()
14959                    .uri("/health")
14960                    .body(Body::empty())
14961                    .unwrap(),
14962            )
14963            .await
14964            .unwrap();
14965        let body = String::from_utf8(
14966            axum::body::to_bytes(resp.into_body(), usize::MAX)
14967                .await
14968                .unwrap()
14969                .to_vec(),
14970        )
14971        .unwrap();
14972        // The documented contract: the FIRST token is the state.
14973        assert!(
14974            body.starts_with("FAIL"),
14975            "/health did not report FAIL for a broken database: {body}",
14976        );
14977        assert!(
14978            !body.contains("db: ok"),
14979            "/health still called the database ok: {body}",
14980        );
14981    }
14982
14983    /// A sidecar mock for the OPML export: serves one subscription and one
14984    /// folder, except for the collection named in `fail_on`, which answers
14985    /// `500` — the shape a refused (short or unreadable) walk takes at this
14986    /// boundary.
14987    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
14988        use tokio::io::{AsyncReadExt, AsyncWriteExt};
14989        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14990        let addr = listener.local_addr().unwrap();
14991        tokio::spawn(async move {
14992            loop {
14993                let Ok((mut sock, _)) = listener.accept().await else {
14994                    break;
14995                };
14996                let mut buf = vec![0u8; 8192];
14997                let Ok(n) = sock.read(&mut buf).await else {
14998                    continue;
14999                };
15000                let req = String::from_utf8_lossy(&buf[..n]).to_string();
15001                let wants = |c: &str| req.contains(c);
15002                if fail_on.is_some_and(wants) {
15003                    let body = r#"{"ok":false,"error":"ShortList"}"#;
15004                    let resp = format!(
15005                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15006                        body.len(),
15007                        body
15008                    );
15009                    let _ = sock.write_all(resp.as_bytes()).await;
15010                    let _ = sock.flush().await;
15011                    continue;
15012                }
15013                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
15014                    serde_json::json!([{
15015                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
15016                        "cid": "bafy",
15017                        "value": {
15018                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
15019                            "url": "https://kept.example/feed.xml",
15020                            "title": "Kept",
15021                            // Inside the folder, so the healthy export has to
15022                            // carry BOTH walks' results: an exporter that lost
15023                            // the folder list would flatten this outline out of
15024                            // its group with nothing else changing.
15025                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15026                            "createdAt": "2026-01-01T00:00:00Z"
15027                        }
15028                    }])
15029                } else if wants(crate::lexicon::nsid::FOLDER) {
15030                    serde_json::json!([{
15031                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15032                        "cid": "bafy",
15033                        "value": {
15034                            "$type": crate::lexicon::nsid::FOLDER,
15035                            "name": "Kept folder",
15036                            "createdAt": "2026-01-01T00:00:00Z"
15037                        }
15038                    }])
15039                } else {
15040                    serde_json::json!([])
15041                };
15042                let body =
15043                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15044                let resp = format!(
15045                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15046                    body.len(),
15047                    body
15048                );
15049                let _ = sock.write_all(resp.as_bytes()).await;
15050                let _ = sock.flush().await;
15051            }
15052        });
15053        format!("http://{addr}")
15054    }
15055
15056    /// A sidecar whose every `listRecords` page carries one good record and
15057    /// one with no `uri` — the #177 shape — for any collection.
15058    async fn spawn_malformed_sidecar() -> String {
15059        use tokio::io::{AsyncReadExt, AsyncWriteExt};
15060        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15061        let addr = listener.local_addr().unwrap();
15062        tokio::spawn(async move {
15063            loop {
15064                let Ok((mut sock, _)) = listener.accept().await else {
15065                    break;
15066                };
15067                let mut buf = vec![0u8; 8192];
15068                let _ = sock.read(&mut buf).await;
15069                let body = serde_json::json!({ "ok": true, "data": { "records": [
15070                    { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
15071                    { "cid": "bafy", "value": {} },
15072                ]}})
15073                .to_string();
15074                let resp = format!(
15075                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15076                    body.len(),
15077                    body
15078                );
15079                let _ = sock.write_all(resp.as_bytes()).await;
15080                let _ = sock.flush().await;
15081            }
15082        });
15083        format!("http://{addr}")
15084    }
15085
15086    async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
15087        let cookie = session_cookie(&state, did, None);
15088        let resp = router(state)
15089            .oneshot(
15090                Request::builder()
15091                    .uri(uri)
15092                    .header(header::COOKIE, cookie)
15093                    .body(Body::empty())
15094                    .unwrap(),
15095            )
15096            .await
15097            .unwrap();
15098        let status = resp.status();
15099        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
15100            .await
15101            .unwrap();
15102        (status, String::from_utf8_lossy(&body).to_string())
15103    }
15104
15105    /// **0.4.0 step 4: a publication document with neither summary field
15106    /// renders as a title, a date and a link** — 8% of measured documents
15107    /// (37 of 449) carry neither `description` nor `textContent`. That is what
15108    /// an RSS reader shows for a title-only feed, not an error, in the list and
15109    /// on the article page alike.
15110    #[tokio::test]
15111    async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
15112        let did = "did:plc:displayer";
15113        let state = test_state(&[did]).await;
15114        let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
15115        let feed_id = store::upsert_feed(
15116            &state.db,
15117            &store::NewFeed {
15118                url: url.into(),
15119                title: Some("Quiet Journal".into()),
15120                ..Default::default()
15121            },
15122        )
15123        .await
15124        .unwrap();
15125        store::replace_sub_refs(&state.db, did, &[feed_id])
15126            .await
15127            .unwrap();
15128        store::insert_entries(
15129            &state.db,
15130            feed_id,
15131            &[store::NewEntry {
15132                guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
15133                    .into(),
15134                url: Some("https://quiet.example/no-summary".into()),
15135                title: Some("A title-only article".into()),
15136                published: Some("2026-07-11T00:00:00Z".into()),
15137                content_html: None,
15138                ..Default::default()
15139            }],
15140            0,
15141        )
15142        .await
15143        .unwrap();
15144        let (status, list) = page_body(state.clone(), did, "/?view=all").await;
15145        assert_eq!(status, StatusCode::OK);
15146        assert!(
15147            list.contains("A title-only article"),
15148            "the entry is missing from the list"
15149        );
15150
15151        let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
15152            .bind(feed_id)
15153            .fetch_one(&state.db)
15154            .await
15155            .unwrap();
15156        let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
15157        assert_eq!(
15158            status,
15159            StatusCode::OK,
15160            "the article page failed for an entry with no body"
15161        );
15162        assert!(page.contains("A title-only article"));
15163        assert!(
15164            page.contains("https://quiet.example/no-summary"),
15165            "no link to the original"
15166        );
15167        assert!(
15168            page.contains(r#"<time datetime=""#),
15169            "no date on the article page"
15170        );
15171    }
15172
15173    /// **#177: a malformed record in the reader's own repo is refused, and the
15174    /// reader is told.** Refusing keeps `replace_sub_refs` from dropping the
15175    /// subscription that record was; telling them keeps the stale list from
15176    /// looking like the real one. Both the reading page and the manage page.
15177    #[tokio::test]
15178    async fn a_malformed_subscription_record_raises_an_alert() {
15179        let did = "did:plc:alerted";
15180        for page in ["/", "/manage"] {
15181            let sidecar = spawn_malformed_sidecar().await;
15182            let state = test_state_with_sidecar(&[did], &sidecar).await;
15183            let (status, body) = page_body(state, did, page).await;
15184            assert_eq!(status, StatusCode::OK, "{page} did not render");
15185            assert!(
15186                body.contains(r#"role="alert""#) && body.contains("could not be read"),
15187                "{page} rendered no alert for a refused subscription list"
15188            );
15189            assert!(
15190                body.contains("1 record(s) in your subscription list"),
15191                "{page} gave the generic alert, not the malformed-record one"
15192            );
15193        }
15194    }
15195
15196    /// The control: a healthy listing raises no alert.
15197    #[tokio::test]
15198    async fn a_healthy_subscription_listing_raises_no_alert() {
15199        let did = "did:plc:exporter";
15200        let sidecar = spawn_export_sidecar(None).await;
15201        let state = test_state_with_sidecar(&[did], &sidecar).await;
15202        let (status, body) = page_body(state, did, "/").await;
15203        assert_eq!(status, StatusCode::OK);
15204        assert!(
15205            !body.contains("could not be read"),
15206            "a healthy listing raised an alert"
15207        );
15208    }
15209
15210    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
15211    async fn export_opml_response(
15212        fail_on: Option<&'static str>,
15213    ) -> (StatusCode, HeaderMap, String) {
15214        let did = "did:plc:exporter";
15215        let sidecar = spawn_export_sidecar(fail_on).await;
15216        let state = test_state_with_sidecar(&[did], &sidecar).await;
15217        let cookie = session_cookie(&state, did, None);
15218        let resp = router(state)
15219            .oneshot(
15220                Request::builder()
15221                    .uri("/opml/export")
15222                    .header(header::COOKIE, cookie)
15223                    .body(Body::empty())
15224                    .unwrap(),
15225            )
15226            .await
15227            .unwrap();
15228        let status = resp.status();
15229        let headers = resp.headers().clone();
15230        let body = String::from_utf8_lossy(
15231            &axum::body::to_bytes(resp.into_body(), usize::MAX)
15232                .await
15233                .unwrap(),
15234        )
15235        .to_string();
15236        (status, headers, body)
15237    }
15238
15239    /// **An empty export is worse than no export, and this is the caller that
15240    /// used to produce one.**
15241    ///
15242    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
15243    /// truncated walk refuses instead of returning a short list, that turned the
15244    /// refusal into `200 OK` carrying a zero-feed
15245    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
15246    /// the moment a locked-out reader reached for one, and the changelog points
15247    /// them at this route as the recovery path.
15248    ///
15249    /// Asserts the three things a reader can actually observe: no success status,
15250    /// no download offered, and no OPML document in the body.
15251    #[tokio::test]
15252    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
15253        let (status, headers, body) =
15254            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
15255
15256        assert_ne!(
15257            status,
15258            StatusCode::OK,
15259            "a failed subscription walk answered 200: {body}",
15260        );
15261        assert!(
15262            !headers.contains_key(header::CONTENT_DISPOSITION),
15263            "a failed subscription walk still offered a download: {headers:?}",
15264        );
15265        assert!(
15266            !body.contains("<opml"),
15267            "a failed subscription walk still served an OPML document: {body}",
15268        );
15269    }
15270
15271    /// The folders half of the same hole. The two walks are separate calls, and
15272    /// fixing only the first leaves an export that silently loses every folder —
15273    /// a flat list that reimports as one, with no sign anything was lost.
15274    #[tokio::test]
15275    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
15276        let (status, headers, body) =
15277            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
15278
15279        assert_ne!(
15280            status,
15281            StatusCode::OK,
15282            "a failed folder walk answered 200: {body}",
15283        );
15284        assert!(
15285            !headers.contains_key(header::CONTENT_DISPOSITION),
15286            "a failed folder walk still offered a download: {headers:?}",
15287        );
15288        assert!(
15289            !body.contains("<opml"),
15290            "a failed folder walk still served an OPML document: {body}",
15291        );
15292    }
15293
15294    /// The other direction, without which "refuse everything" would pass both
15295    /// tests above: a healthy read still serves the file, with the feed in it.
15296    #[tokio::test]
15297    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
15298        let (status, headers, body) = export_opml_response(None).await;
15299
15300        assert_eq!(
15301            status,
15302            StatusCode::OK,
15303            "a healthy export did not answer 200"
15304        );
15305        assert_eq!(
15306            headers
15307                .get(header::CONTENT_DISPOSITION)
15308                .and_then(|v| v.to_str().ok()),
15309            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
15310            "a healthy export did not offer the download",
15311        );
15312        assert!(
15313            body.contains("https://kept.example/feed.xml"),
15314            "the exported OPML lost the subscription: {body}",
15315        );
15316        assert!(
15317            body.contains("Kept folder"),
15318            "the exported OPML lost the folder: {body}",
15319        );
15320    }
15321}