Skip to main content

feather_reader/
web.rs

1//! The axum web layer — server-rendered HTML + a dash of htmx, **no SPA**.
2//!
3//! This module owns the HTTP surface: [`router`] builds an `axum::Router` over
4//! the shared [`AppState`], wiring the store, feed, atproto, and config seams into
5//! a small set of typography-first, dark-mode-ready views rendered with
6//! `askama` templates (under `templates/`). Progressive enhancement is a single
7//! vendored `htmx` script plus a tiny keyboard handler (`static/keyboard.js`);
8//! every interaction also works as a plain HTML form POST, so the reader is fully
9//! usable with JavaScript disabled.
10//!
11//! ## HTTP surface
12//!
13//! * `GET  /health` — liveness + version, as `text/plain`.
14//! * `GET  /about`, `/standard-site`, `/stats`, `/privacy`, `/terms` — static
15//!   and status pages.
16//! * `GET  /manage` — feed and folder management.
17//! * `GET  /` — the reader: a folders/feeds sidebar (from the PDS records layer)
18//!   plus the main article list. Query params pick the scope (`?feed=…` /
19//!   `?folder=…` / all) and the view (`?view=unread|all|starred`).
20//! * `GET  /entries/{id}` — the clean, distraction-free reader for one entry,
21//!   with prev/next within the current list.
22//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
23//! * `POST /entries/{id}/star` — star/unstar; writes a
24//!   `community.lexicon.rss.saved` record to the user's PDS.
25//! * `POST /saved/{rkey}/delete` — unsave a `saved` record.
26//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
27//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
28//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
29//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
30//! * `POST /folders` — create a folder record.
31//! * `POST /folders/{rkey}/rename` — rename a folder record.
32//! * `POST /folders/{rkey}/delete` — delete a folder record.
33//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
34//!   subscription records in the PDS.
35//! * `GET  /opml/export` — OPML export (records → a downloadable document).
36//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
37//!   OAuth sign-in flow, on whichever backend `FEATHERREADER_REPO_BACKEND`
38//!   selects. `GET /oauth/client-metadata.json` and `/oauth/jwks.json` publish
39//!   the Rust client's identity.
40//! * `POST /account/delete` — revoke the session and drop this DID's local state.
41//! * `GET|POST /beta/redeem` — closed-beta invite redemption.
42//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
43//!   reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
44//!   (sets the reserving cookie → `/login`).
45//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
46//!   code + token/url for the bot to post. Cap-aware (409 when full).
47//! * `POST /admin/invites`, `GET /admin/metrics` — admin-only invite minting and
48//!   the backend metrics view.
49//!
50//! ## Identity — a cookie-resolved atproto session
51//!
52//! Per-request identity comes from a **signed session cookie** (`fr_session`)
53//! keyed by the logged-in DID, set by `oauth_callback` and read by
54//! `current_session` / `current_did`. For local runs without the sidecar,
55//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
56//! All PDS reads and writes route through [`AppState::repo`], which dispatches to
57//! the sidecar or the Rust OAuth client by `FEATHERREADER_REPO_BACKEND`.
58
59use std::collections::HashMap;
60use std::net::IpAddr;
61use std::sync::Mutex;
62use std::time::{Duration, Instant};
63
64use askama::Template;
65use axum::{
66    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
67    http::{header, HeaderMap, StatusCode},
68    middleware::{self, Next},
69    response::{Html, IntoResponse, Redirect, Response},
70    routing::{get, post},
71    Form, Router,
72};
73use serde::Deserialize;
74use std::net::SocketAddr;
75use tower_http::services::{ServeDir, ServeFile};
76use tower_http::set_header::SetResponseHeaderLayer;
77use tower_http::trace::TraceLayer;
78use tracing::{info, warn};
79
80use crate::config::Config;
81use crate::lexicon::{self, Folder, Saved, Subscription};
82use crate::safe_link::SafeLink;
83use crate::sanitized_html::{BodyRender, BodyRenderer};
84use crate::{feed, store, AppState, Session, VERSION};
85
86// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
87// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
88// here via an explicit path so the reader's OPML routes can use the canonical
89// `parse_opml` / `to_opml` without duplicating that logic.
90#[path = "opml.rs"]
91mod opml;
92
93/// The name of the signed session cookie.
94const SESSION_COOKIE: &str = "fr_session";
95
96/// The name of the short-lived signed **invite** cookie.
97///
98/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
99/// OAuth callback. It reserves *intent* to redeem a specific code before the
100/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
101/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
102/// signed with the same key as the session cookie.
103const INVITE_COOKIE: &str = "fr_invite";
104
105/// Browser-binding cookie for an in-flight OAuth login (Rust backend only).
106///
107/// `state` alone cannot stop a login CSRF: it lives in a server-global table, so
108/// a stolen `state` replayed from ANOTHER browser matches just as well as from
109/// the one that started the flow. This cookie is what makes the callback
110/// browser-specific — the pending row stores only its hash, and a callback that
111/// cannot present it is refused.
112const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
113
114/// How long an in-flight login may sit, matching the pending row's own TTL.
115const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
116
117/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
118/// Short enough that a reserved-but-unclaimed seat frees quickly.
119const INVITE_TTL_SECS: i64 = 1800;
120
121/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
122/// sign-in pitch, and `/about`.
123const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
124
125/// The tip / support link (cloud plan public-experiment UI).
126const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
127
128/// The published crate on crates.io — surfaced on the signed-out landing page.
129const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
130
131/// The Content-Security-Policy applied to every response.
132///
133/// Tuned to keep the app fully working while neutralising injected script:
134/// * `default-src 'self'` — same-origin baseline.
135/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
136///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
137///   `javascript:` href (F4) cannot execute. (The design's templates carry no
138///   inline event handlers — every control is wired in `keyboard.js`.)
139/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
140///   inline styles htmx toggles for its request indicators.
141/// * `img-src 'self' https: data:` — feed content routinely embeds remote
142///   images; allow https + data URIs but not other schemes.
143/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
144///   down form posts, `<base>` hijacking, and clickjacking.
145/// * `object-src 'none'` — no plugins.
146const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
147     script-src 'self'; \
148     style-src 'self' 'unsafe-inline'; \
149     img-src 'self' https: data:; \
150     font-src 'self'; \
151     connect-src 'self'; \
152     form-action 'self'; \
153     base-uri 'self'; \
154     frame-ancestors 'none'; \
155     object-src 'none'";
156
157/// The resolved identity for the current request.
158///
159/// `did` is the primary key for all per-user local state; `handle` is display
160/// only; `sid` is the opaque server-side session id the cookie carried (needed
161/// so logout can revoke exactly this session). Sourced from the signed cookie
162/// (real login) or, if none, the configured dev DID fallback.
163#[derive(Clone, Debug)]
164struct CurrentUser {
165    did: String,
166    handle: Option<String>,
167    /// The opaque session id, if this identity came from a real cookie session
168    /// (absent for the dev-DID fallback, which has no server-side session row).
169    sid: Option<String>,
170}
171
172/// Resolve the current request's session from the signed cookie, falling back to
173/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
174///
175/// The cookie carries an opaque server-minted session id (not the DID). We
176/// verify its HMAC, look the id up in the registry, and — crucially —
177/// **re-check the DID against the closed-beta gate on every request**
178/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
179/// DID's beta seat takes effect immediately for already-issued cookies. (The
180/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
181/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
182async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
183    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
184        if let Some(session) = state.sessions.get(&sid) {
185            if store::has_beta_access(&state.db, &session.did)
186                .await
187                .unwrap_or(false)
188            {
189                return Some(CurrentUser {
190                    did: session.did,
191                    handle: session.handle,
192                    sid: Some(sid),
193                });
194            }
195            // DID no longer holds a beta seat: treat as logged out (and drop the
196            // stale server-side session so the dead cookie can't linger).
197            state.sessions.remove(&sid);
198        }
199    }
200    // No valid cookie: dev fallback only if explicitly configured *and* still
201    // inside the beta gate (seeded via ensure_seed / a redeemed code).
202    if let Some(did) = state.config.dev_did.clone() {
203        if store::has_beta_access(&state.db, &did)
204            .await
205            .unwrap_or(false)
206        {
207            return Some(CurrentUser {
208                did,
209                handle: None,
210                sid: None,
211            });
212        }
213    }
214    None
215}
216
217/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
218async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
219    current_session(state, headers).await.map(|u| u.did)
220}
221
222/// Build the application router over shared [`AppState`].
223///
224/// Wires the reader routes, the health check, and the `/static` asset mount
225/// (the stylesheet, vendored htmx, and the keyboard handler, served from
226/// `static/` via `ServeDir`). A `TraceLayer` gives per-request tracing.
227pub fn router(state: AppState) -> Router {
228    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
229    // and the write endpoints). One instance is cloned into the state closure of
230    // the `rate_limit` middleware.
231    let limiter = RateLimiter::shared();
232    // The trusted client-IP source for the limiter (a proxy header the operator
233    // controls, or the socket peer when unset). Bundled with the limiter so the
234    // middleware derives a spoof-resistant IP.
235    let rl_state = RateLimitState {
236        limiter,
237        trusted_header: state.config.trusted_ip_header.clone(),
238    };
239
240    Router::new()
241        .route("/health", get(health))
242        .route("/about", get(about))
243        .route("/standard-site", get(standard_site))
244        .route("/stats", get(stats))
245        .route("/privacy", get(privacy))
246        .route("/terms", get(terms))
247        .route("/manage", get(manage))
248        .route("/", get(index))
249        .route("/entries/{id}", get(entry_view))
250        .route("/entries/{id}/read", post(mark_read))
251        .route("/entries/{id}/star", post(toggle_star))
252        .route("/saved/{rkey}/delete", post(unsave_record))
253        .route("/read-all", post(mark_all_read))
254        .route("/subscriptions", post(add_subscription))
255        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
256        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
257        .route("/folders", post(create_folder))
258        .route("/folders/{rkey}/rename", post(rename_folder))
259        .route("/folders/{rkey}/delete", post(delete_folder))
260        // OPML import takes untrusted uploads: cap the body so a huge upload
261        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
262        .route(
263            "/opml",
264            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
265        )
266        .route("/opml/export", get(export_opml))
267        .route("/login", get(login_form).post(login_submit))
268        .route(
269            "/beta/redeem",
270            get(beta_redeem_form).post(beta_redeem_submit),
271        )
272        // The follow→invite bot's claim link: a public skeet points a new
273        // follower here with an opaque token that reserves a pre-minted code.
274        .route("/claim", get(claim))
275        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
276        // code + returns its token/url for the bot to post.
277        .route("/bot/claims", post(bot_mint_claim))
278        .route("/admin/invites", post(admin_mint_invites))
279        .route("/admin/metrics", get(admin_metrics))
280        .route("/oauth/client-metadata.json", get(oauth_client_metadata))
281        .route("/oauth/jwks.json", get(oauth_jwks))
282        .route("/account/delete", post(account_delete))
283        .route("/oauth/callback", get(oauth_callback))
284        .route("/logout", post(logout))
285        .nest_service("/static", ServeDir::new("static"))
286        // Browsers (and some feed clients) request /favicon.ico at the root
287        // regardless of the <link rel="icon"> tags; serve the same icon that
288        // lives under /static so the bare path stops 404-ing.
289        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
290        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
291        // logged-out landing + static assets, `no-store` on anything that
292        // rendered a session's private view. Runs *inside* the security layers so
293        // the CSP/nosniff/frame headers are untouched.
294        .layer(middleware::from_fn(cache_control))
295        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
296        // as a middleware so it sees the matched path + the peer IP.
297        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
298        .layer(TraceLayer::new_for_http())
299        // Baseline security headers on *every* response (F4). The CSP is the
300        // backstop that neutralises any XSS that slips past sanitization; the
301        // others harden sniffing, framing, and referrer leakage.
302        .layer(static_header_layer(
303            "content-security-policy",
304            CONTENT_SECURITY_POLICY,
305        ))
306        .layer(static_header_layer("x-content-type-options", "nosniff"))
307        .layer(static_header_layer(
308            "referrer-policy",
309            "strict-origin-when-cross-origin",
310        ))
311        .layer(static_header_layer("x-frame-options", "DENY"))
312        .with_state(state)
313}
314
315/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
316/// axum's 2 MiB default.**
317///
318/// The value used to BE the framework default, which made the route's own
319/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
320/// nothing could test it, and the ceiling this route wanted was whatever the
321/// framework happened to pick. Sized to this route instead — one outline is
322/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
323/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
324/// larger is not a subscription list.
325///
326/// Being strictly tighter than the default is what makes the layer both real
327/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
328/// uploads a payload that only this limit refuses.
329const OPML_BODY_LIMIT: usize = 1024 * 1024;
330
331/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
332/// uploads a payload between the two ceilings.
333///
334/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
335/// number is exact — axum does not export it, so it cannot be imported. The
336/// exceeding is what the test's mutation demonstrates: with the route's layer
337/// removed, a payload of this size is accepted. If axum ever lowers its
338/// default below ours, that mutation stops failing and the compile-time
339/// assertion below is the thing to revisit.
340#[cfg(test)]
341const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
342
343/// The route's cap must stay strictly tighter than the framework's, or its
344/// layer is a no-op again. A compile error, not a test failure: this is a
345/// property of the two constants, and nothing should be able to build a binary
346/// where it is false.
347#[cfg(test)]
348const _: () = assert!(
349    OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
350    "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
351);
352
353/// A response-header layer that sets `name: value` on every response, overriding
354/// any existing header of that name. `name`/`value` must be valid static header
355/// tokens (they are, for our fixed security headers).
356fn static_header_layer(
357    name: &'static str,
358    value: &'static str,
359) -> SetResponseHeaderLayer<header::HeaderValue> {
360    SetResponseHeaderLayer::overriding(
361        header::HeaderName::from_static(name),
362        header::HeaderValue::from_static(value),
363    )
364}
365
366// ---------------------------------------------------------------------------
367// Per-IP rate limiting (token bucket, self-contained — no extra crate)
368// ---------------------------------------------------------------------------
369
370/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
371/// kick-off and callback, the invite redeem, logout, the mutating write
372/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
373/// intentionally *not* limited.
374///
375/// The criterion is **does this path make an outbound request**, not "does it
376/// mutate" — the two diverge, and every miss so far has been on the outbound
377/// side. This is an allowlist a new route has to be added to by hand, which is
378/// exactly why it has now been missed three times: `/saved/` (fixed), then
379/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
380/// only path here reachable with no session at all.
381///
382/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
383/// make PDS calls but are ordinary authenticated navigation, and throttling them
384/// would degrade normal reading. They are bounded by needing a valid session.
385fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
386    use axum::http::Method;
387    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
388    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
389    // like the other abuse-prone entry points — not just `/login`.
390    // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
391    // real outbound round-trip — a sidecar `resolve_session` or a full token
392    // exchange against a PDS. Anyone could spend one outbound request per hit.
393    // It is the only entry point here that needs no session at all.
394    if method != Method::POST
395        && !(method == Method::GET
396            && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
397    {
398        return false;
399    }
400    match path {
401        // `/logout` and `/oauth/callback` are here because they make outbound
402        // calls, not because they mutate: logout revokes at the PDS (up to two
403        // round-trips) and the callback exchanges a code. The list is by
404        // *network cost*, which is what the limiter is actually for.
405        "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
406        | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
407        | "/folders" => true,
408        // Every per-record subscription/folder mutation (delete/rename) and the
409        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
410        p => {
411            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
412                // Unsaving makes a DPoP-signed deleteRecord round-trip to the
413                // PDS, which is exactly the reason the neighbours above are
414                // limited. It was added as a new route and not added here.
415                || p.starts_with("/saved/")
416                || p.starts_with("/subscriptions/")
417                || p.starts_with("/folders/")
418        }
419    }
420}
421
422/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
423/// client-IP header (if any). Cloned into every request; both fields are cheap.
424#[derive(Clone)]
425struct RateLimitState {
426    limiter: RateLimiter,
427    /// The lowercased proxy header the operator trusts for the client IP, or
428    /// `None` to trust only the socket peer. See [`client_ip`].
429    trusted_header: Option<String>,
430}
431
432/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
433/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
434/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
435/// dependency → no network fetch at build, deterministic offline CI).
436#[derive(Clone)]
437struct RateLimiter {
438    inner: std::sync::Arc<Mutex<RateLimiterState>>,
439}
440
441/// The limiter's shared state: the buckets plus when they were last swept.
442struct RateLimiterState {
443    buckets: HashMap<IpAddr, Bucket>,
444    last_sweep: Instant,
445}
446
447/// One IP's token bucket: a fractional token count + the last-refill instant.
448struct Bucket {
449    tokens: f64,
450    last: Instant,
451}
452
453/// Burst capacity per IP — how many requests can arrive back-to-back.
454const RATE_BURST: f64 = 20.0;
455/// Steady-state refill rate (tokens/sec) once the burst is spent.
456const RATE_REFILL_PER_SEC: f64 = 1.0;
457/// Evict idle buckets older than this so the map can't grow unbounded.
458const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
459
460/// How often the idle sweep may actually run.
461///
462/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
463/// map to find entries that, by construction, can only age out on an hour
464/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
465/// under any volume of distinct source IPs the server spent its single shared
466/// core re-walking a map whose contents had not changed. Once a minute is
467/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
468const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
469
470/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
471/// target, sized so ordinary traffic never reaches it.
472///
473/// The idle eviction above was the only bound, and it is a TIME bound, which
474/// says nothing about how many distinct IPs can arrive inside one hour.
475/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
476/// this one did not.
477const MAX_RATE_BUCKETS: usize = 10_000;
478
479/// When the cap is hit, evict down to this fraction of it rather than removing
480/// a single entry — so the O(n) eviction happens once per `cap/8` requests
481/// instead of once per request while the map sits full.
482const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
483
484impl RateLimiter {
485    /// A fresh, shared limiter (cloned into the middleware state).
486    fn shared() -> Self {
487        Self {
488            inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
489                buckets: HashMap::new(),
490                last_sweep: Instant::now(),
491            })),
492        }
493    }
494
495    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
496    /// bucket is empty (→ 429).
497    fn check(&self, ip: IpAddr) -> bool {
498        self.check_at(ip, Instant::now())
499    }
500
501    /// [`check`](Self::check) with the clock injected, so the sweep and eviction
502    /// paths below are reachable in a test without sleeping through an hour.
503    fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
504        let mut state = match self.inner.lock() {
505            Ok(m) => m,
506            // A poisoned lock shouldn't take the site down — fail open.
507            Err(p) => p.into_inner(),
508        };
509
510        // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
511        if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
512            state
513                .buckets
514                .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
515            state.last_sweep = now;
516        }
517
518        // Hard size bound, independent of the time bound above.
519        //
520        // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
521        // attacker cannot use eviction to clear their OWN throttled bucket: that
522        // bucket is by definition the most recently touched, so it is the last
523        // thing this removes. Going quiet long enough to become the oldest entry
524        // is exactly what the refill already grants for free.
525        if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
526            let mut by_age: Vec<(IpAddr, Instant)> =
527                state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
528            by_age.sort_unstable_by_key(|(_, last)| *last);
529            for (victim, _) in by_age
530                .into_iter()
531                .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
532            {
533                state.buckets.remove(&victim);
534            }
535            warn!(
536                buckets = state.buckets.len(),
537                "rate-limit bucket cap reached; evicted the least recently seen clients"
538            );
539        }
540
541        let bucket = state.buckets.entry(ip).or_insert(Bucket {
542            tokens: RATE_BURST,
543            last: now,
544        });
545        let elapsed = now.duration_since(bucket.last).as_secs_f64();
546        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
547        bucket.last = now;
548        if bucket.tokens >= 1.0 {
549            bucket.tokens -= 1.0;
550            true
551        } else {
552            false
553        }
554    }
555}
556
557/// The **trusted** client IP for a request.
558///
559/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
560/// is fully bypassable — the left-most value is attacker-supplied (any client
561/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
562/// bucket and the per-IP limit never bites. We therefore derive the IP only from
563/// a source the operator controls:
564///
565/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
566///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
567///   set by the proxy we run in front and overwrites any client-supplied copy.
568///   We take the LAST value if the header happens to be a comma list (the hop
569///   the trusted proxy appended), which is also the correct read for a
570///   right-most-`X-Forwarded-For` deployment where the operator points
571///   `trusted_header` at `x-forwarded-for`.
572/// * Otherwise we ignore all forwarding headers and use the socket peer
573///   (`ConnectInfo`) — correct for a direct bind with no proxy.
574///
575/// Returns `None` only when neither source yields a parseable IP (the limiter
576/// then fails open for that one request).
577fn client_ip(
578    headers: &HeaderMap,
579    conn: Option<&SocketAddr>,
580    trusted_header: Option<&str>,
581) -> Option<IpAddr> {
582    if let Some(name) = trusted_header {
583        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
584            // Right-most hop is the one the trusted proxy appended; earlier
585            // entries may be client-forged, so never trust the left-most.
586            if let Some(last) = raw.split(',').next_back() {
587                if let Ok(ip) = last.trim().parse::<IpAddr>() {
588                    return Some(ip);
589                }
590            }
591        }
592        // Trusted header absent/unparseable → fall through to the socket peer.
593    }
594    conn.map(|s| s.ip())
595}
596
597/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
598/// empty; every other request (and every non-guarded path) passes through. The
599/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
600/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
601async fn rate_limit(
602    State(rl): State<RateLimitState>,
603    req: axum::extract::Request,
604    next: Next,
605) -> Response {
606    let path = req.uri().path().to_string();
607    let method = req.method().clone();
608    if is_rate_limited_path(&path, &method) {
609        let conn = req
610            .extensions()
611            .get::<ConnectInfo<SocketAddr>>()
612            .map(|c| c.0);
613        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
614        // Deliberately fail OPEN when no client IP is derivable (no trusted
615        // header / no socket peer): there is no per-IP key to enforce, and a
616        // blanket 429 would self-DoS every guarded path (incl. /login). This is
617        // safe precisely because we never key on an attacker-forged XFF — see
618        // `rate_limit_ignores_spoofed_xff_rotation`.
619        if let Some(ip) = ip {
620            if !rl.limiter.check(ip) {
621                warn!(%ip, %path, "rate limit exceeded");
622                return (
623                    StatusCode::TOO_MANY_REQUESTS,
624                    [(header::RETRY_AFTER, "1")],
625                    "rate limit exceeded\n",
626                )
627                    .into_response();
628            }
629        }
630    }
631    next.run(req).await
632}
633
634// ---------------------------------------------------------------------------
635// Cache-Control (viral / CDN vs. private authenticated views)
636// ---------------------------------------------------------------------------
637
638/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
639/// logged-out surfaces (the `/login` landing without a handle, `/about`,
640/// `/standard-site`, `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
641/// authenticated app pages, so a CDN /
642/// browser can hold the viral landing while never caching a signed-in user's
643/// private view. Never overrides a handler that already set Cache-Control.
644async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
645    let path = req.uri().path().to_string();
646    // The logged-out landing is only cacheable when it's the bare form — a
647    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
648    let is_login_landing = path == "/login"
649        && req.method() == axum::http::Method::GET
650        && !req.uri().query().unwrap_or("").contains("handle=");
651    let public = is_login_landing
652        || path == "/about"
653        || path == "/standard-site"
654        || path == "/privacy"
655        || path == "/terms"
656        || path.starts_with("/static/");
657
658    let mut resp = next.run(req).await;
659    if resp.headers().contains_key(header::CACHE_CONTROL) {
660        return resp;
661    }
662    let value = if public {
663        "public, max-age=300"
664    } else {
665        "no-store"
666    };
667    if let Ok(hv) = header::HeaderValue::from_str(value) {
668        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
669    }
670    resp
671}
672
673// ---------------------------------------------------------------------------
674// Health
675// ---------------------------------------------------------------------------
676
677/// Run `/health`'s database probe. **The single path, so a test cannot assert
678/// on a string the handler is free to ignore** — a named constant alone was not
679/// enough: the test read the constant while the handler passed `query_scalar`
680/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
681async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
682    sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
683        .fetch_optional(pool)
684        .await
685}
686
687/// The statement `/health` uses to prove the database is readable.
688///
689/// **A named constant so the test can assert on the query that actually runs.**
690/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
691/// this string, so degrading the real probe to `SELECT 1` — which opens no page
692/// and therefore cannot detect a broken database — left the suite green.
693const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
694
695/// How long `/health` will wait for its database ping before calling it broken.
696///
697/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
698/// handler chose rather than a timeout Fly inferred — the difference between a
699/// log line that says why and one that says nothing.
700const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
701
702/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
703/// see the handler for why.
704///
705/// The threshold itself is derived from the configured tick
706/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
707/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
708/// stale` in the body the deployment docs now tell them to alert on.
709const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
710
711/// How long without a completed tick before the poller reads as stale: several
712/// tick intervals, floored, so a normally-paced loop never trips it and a
713/// genuinely wedged one always does.
714fn health_tick_stale_secs(tick: Duration) -> i64 {
715    let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
716    tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
717}
718
719/// The poll tick this instance is configured for. Read from the same env var
720/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
721/// handler cannot see its constants.
722fn configured_poll_tick() -> Duration {
723    std::env::var("FEATHERREADER_POLL_TICK_SECS")
724        .ok()
725        .and_then(|v| v.trim().parse::<u64>().ok())
726        .filter(|s| *s > 0)
727        .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
728}
729
730/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
731/// so cannot be imported here. Duplicated deliberately and named, rather than
732/// left as a bare `60` inside the parse chain, so the drift is at least visible
733/// if the scheduler's value ever moves.
734const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
735
736/// Grace period after boot before a poller that has never ticked is called
737/// `stale` rather than `not-yet-ticked`.
738///
739/// Without this the two are indistinguishable forever, which matters precisely
740/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
741/// cycles the poller never reaches its first tick, so `/health` reported the
742/// benign `not-yet-ticked` on every single probe and the heartbeat could not
743/// detect the failure mode it exists for. `run_poller` returning early — a failed
744/// HTTP client build — has the same shape and was equally invisible.
745///
746/// Sized off the poller's own startup delay plus its tick, with slack.
747const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
748
749/// `GET /health` — does this process still work, and what are its loops doing?
750///
751/// This used to return a constant string, touching no database, no pool and no
752/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
753/// sole other failure detector is a child process exiting. It proved the HTTP
754/// listener was up and nothing else.
755///
756/// **What can fail the check: the database, and only the database.** A process
757/// that cannot reach its store serves nothing, so a restart is the right
758/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
759/// mode is not blocked by any writer — so the retention sweep, the poller and a
760/// login burst cannot make this flap. That property is the reason it is a read
761/// and not, say, a write canary.
762///
763/// **What is reported but never fails the check: everything else.** A stale poll
764/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
765/// and none of them a reason to stop serving.
766///
767/// That last clause is the whole justification, and it is NOT the one this
768/// comment used to give. It said "Fly restarts on a failed check", which is
769/// false — verified against Fly's own docs, which state it three times: *"your
770/// Machines won't automatically restart or stop due to failing their health
771/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
772/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
773/// V1 (`restart_limit`) and has no successor on Machines.
774///
775/// The corrected model makes the conclusion stronger, not weaker. With one
776/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
777/// failover — it is a total outage that lasts exactly as long as the condition,
778/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
779/// question the status code answers is not "would a restart fix this" but **"can
780/// this process still serve a useful request at all"**. A stale poller can. A
781/// database it cannot read cannot.
782///
783/// Re-registration is automatic: the proxy keeps probing and routes again the
784/// moment the check passes. That is what makes a 503 recoverable without
785/// intervention — not a restart, which never comes.
786///
787/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
788/// it is publishable on the same terms as `/stats`. It is also the non-session
789/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
790/// (which needs a live admin session) is exactly as unreachable as the thing it
791/// would diagnose, while this is reachable with `curl`.
792async fn health(State(state): State<AppState>) -> Response {
793    let now = chrono::Utc::now().timestamp();
794    let rh = &state.runtime_health;
795
796    use crate::runtime_health::DbProbe;
797    let db = match rh.begin_db_probe() {
798        // A probe is already in flight; report its predecessor rather than
799        // starting a second one. See `RuntimeHealth::begin_db_probe`.
800        Err(borrowed) => borrowed,
801        Ok(probe) => {
802            // **Spawned, so the probe cannot be cancelled by the caller.**
803            //
804            // Axum drops the handler future when a client disconnects. With the
805            // probe inline, that dropped it mid-flight and released the claim
806            // WITHOUT recording a verdict — which let an unauthenticated caller
807            // manufacture the no-verdict state on demand and freeze what every
808            // other caller, Fly's check included, reads. Running it detached
809            // means the verdict is always recorded and the claim is always
810            // released after it.
811            let pool = state.db.clone();
812            let task = tokio::spawn(async move {
813                // **`SELECT 1` was not a database probe.** It compiles to
814                // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
815                // never touches a b-tree, never reads a page, and never consults
816                // the file. Against a corrupted database it returns success
817                // while every real query returns SQLITE_CORRUPT. Reading one row
818                // from a real table costs the same and actually proves what the
819                // check claims. `LIMIT 1` keeps it to a single page; an empty
820                // table still opens the b-tree root, which is the part that
821                // matters.
822                let verdict =
823                    match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
824                        Ok(Ok(_)) => DbProbe::Ok,
825                        // Coarse, not the raw error. An unauthenticated caller
826                        // learning exactly which failure it hit is an
827                        // attack-progress oracle; the detail belongs in the log,
828                        // which gets it here.
829                        Ok(Err(err)) => {
830                            warn!(%err, "health: database probe failed");
831                            DbProbe::Failed("unavailable".to_string())
832                        }
833                        Err(_) => {
834                            warn!(
835                                timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
836                                "health: database probe timed out (pool exhausted?)"
837                            );
838                            DbProbe::Failed("timeout".to_string())
839                        }
840                    };
841                probe.record(verdict.clone());
842                verdict
843            });
844            // A panicking task drops the guard, which releases the claim without
845            // a verdict — the only remaining path to that state, and not one a
846            // caller can drive.
847            task.await.unwrap_or(DbProbe::Unknown)
848        }
849    };
850
851    let uptime = rh.uptime_secs(now);
852    let poller = if !rh.schedulers_enabled() {
853        // Not a fault. Dev runs and the seam tests disable the loops on purpose,
854        // and reporting that as "stale" would be a false alarm on every one.
855        "disabled".to_string()
856    } else {
857        match rh.secs_since_poll_tick(now) {
858            // "Never ticked" is benign right after boot and alarming well after
859            // it — so it is read against UPTIME, not left permanently benign.
860            None => match uptime {
861                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
862                    format!("stale never-ticked {up}s")
863                }
864                _ => "not-yet-ticked".to_string(),
865            },
866            Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
867                format!("stale {secs}s")
868            }
869            Some(secs) => format!("ok {secs}s"),
870        }
871    };
872
873    // **Only a MEASURED failure fails the check.**
874    //
875    // `Unknown` means no probe has completed — a concurrent request arrived
876    // before the first one finished, or a previous owner was cancelled before
877    // recording. It is reported and returns 200, because an unmeasured database
878    // is not evidence of a broken one, and this endpoint is reachable by
879    // unauthenticated callers who can manufacture that state. Treating it as a
880    // failure handed them a lever on the only signal the platform acts on.
881    let mut body = String::new();
882    let status = match &db {
883        DbProbe::Ok => {
884            body.push_str(&format!("ok featherreader/{VERSION}\n"));
885            body.push_str("db: ok\n");
886            StatusCode::OK
887        }
888        // **Not `ok`.** The first token is the state, and this one is neither
889        // healthy nor failed. It used to print a line byte-identical to the
890        // healthy branch, which mattered because `fly.toml` tells operators to
891        // alert on the BODY for everything the status code deliberately ignores
892        // — so a monitor keying on `^ok` read green in exactly the state this
893        // enum exists to make visible.
894        DbProbe::Unknown => {
895            body.push_str(&format!("unknown featherreader/{VERSION}\n"));
896            body.push_str("db: unknown (no probe has completed yet)\n");
897            StatusCode::OK
898        }
899        DbProbe::Failed(why) => {
900            body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
901            body.push_str(&format!("db: {why}\n"));
902            StatusCode::SERVICE_UNAVAILABLE
903        }
904    };
905    // Uptime answers the first question anyone asks about a container under a
906    // supervisor that tears the machine down whenever a child exits: is this
907    // thing restarting? Nothing else on any surface could tell you.
908    body.push_str(&format!(
909        "uptime: {}\n",
910        match uptime {
911            Some(secs) => format!("{secs}s"),
912            None => "unknown".to_string(),
913        }
914    ));
915    body.push_str(&format!("poller: {poller}\n"));
916    body.push_str(&format!(
917        "polling-paused: {}\n",
918        if rh.watermark_paused() { "yes" } else { "no" }
919    ));
920    // Deliberately NOT the measured database size. `/health` is the one path
921    // exempted from the Caddy origin lock, so it answers direct hits to the Fly
922    // IP that never passed Cloudflare — which caps what belongs here at the
923    // class of facts `/stats` already publishes to anyone. "Polling is paused"
924    // is that; the exact byte count is a precise internal number that adds
925    // nothing an operator cannot get from `/stats` or the logs.
926    body.push_str(&format!(
927        "backend: {}\n",
928        state.config.repo_backend.as_str()
929    ));
930    body.push_str(&format!(
931        "oauth-runtime: {}\n",
932        if state.oauth.is_some() {
933            "built"
934        } else {
935            "absent"
936        }
937    ));
938
939    // Never cached: a stale health response is worse than none, and Cloudflare
940    // sits in front of this.
941    let mut resp = (status, body).into_response();
942    if let Ok(hv) = header::HeaderValue::from_str("no-store") {
943        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
944    }
945    resp
946}
947
948/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
949/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
950/// Readable whether or not a session exists.
951///
952/// Optionally carries one quiet line about network adoption
953/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
954/// default — the handler issues **zero** queries and the page is byte-identical
955/// to what it was before the probe existed.
956async fn about(State(state): State<AppState>) -> Response {
957    let adoption = if state.config.show_adoption {
958        adoption_line(&state).await
959    } else {
960        None
961    };
962    render(&AboutTemplate {
963        card: Card::public(
964            &state.config,
965            "/about",
966            "About — FeatherReader",
967            "What FeatherReader is and isn't: an open-source, atproto-native reader for \
968             RSS feeds and standard.site publications, run as an experiment, free to \
969             self-host under the AGPL.",
970        ),
971        version: VERSION,
972        repo_url: REPO_URL,
973        kofi_url: KOFI_URL,
974        adoption,
975        standard_site: state.config.standard_site,
976    })
977}
978
979/// `GET /standard-site` — the public feature page for standard.site
980/// publications: what a publication is, what FeatherReader shows from one, how
981/// to subscribe, the limits, and the latest releases. Readable whether or not
982/// a session exists, like `/about`. Every how-to-subscribe line is conditional
983/// on `Config::standard_site`, as on the other public pages.
984async fn standard_site(State(state): State<AppState>) -> Response {
985    render(&StandardSiteTemplate {
986        card: Card::public(
987            &state.config,
988            "/standard-site",
989            "standard.site — FeatherReader",
990            "Read standard.site publications beside your RSS feeds: articles \
991             published as atproto records, followed with the same portable \
992             subscription record.",
993        ),
994        version: VERSION,
995        repo_url: REPO_URL,
996        kofi_url: KOFI_URL,
997        standard_site: state.config.standard_site,
998        releases: RELEASES,
999    })
1000}
1001
1002/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
1003///
1004/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
1005/// does not have. This deletes the record straight from the repo by its rkey,
1006/// and then clears any LOCAL star for the same article.
1007///
1008/// That second step is not belt-and-braces. "Has no local entry" is how the
1009/// starred view classifies a record, and it decides that through `sub_ref` — so
1010/// an article that really is cached, and really is starred, lands here whenever
1011/// the reader has unsubscribed from its feed. Deleting only the record left
1012/// `entry_state.starred = 1` behind: invisible, because the starred list is
1013/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
1014/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
1015/// places it lives.
1016async fn unsave_record(
1017    State(state): State<AppState>,
1018    headers: HeaderMap,
1019    Path(rkey): Path<String>,
1020) -> Response {
1021    let Some(did) = current_did(&state, &headers).await else {
1022        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1023    };
1024
1025    // Read the record's identity BEFORE deleting it — afterwards there is
1026    // nothing left to learn it from. Best-effort: a failure here must not block
1027    // the deletion the reader actually asked for, so it degrades to the old
1028    // behaviour (record gone, local star possibly stale) and says so.
1029    let identity = match state.repo().list_saved(&did).await {
1030        Ok(records) => records
1031            .into_iter()
1032            .find(|(k, _)| *k == rkey)
1033            .map(|(_, rec)| (rec.url, rec.entry_id)),
1034        Err(err) => {
1035            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1036                                      a local star for the same article may survive");
1037            None
1038        }
1039    };
1040
1041    match state.repo().remove_saved(&did, &rkey).await {
1042        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1043        Err(err) => {
1044            warn!(%err, %did, %rkey, "could not remove the saved record");
1045            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1046        }
1047    }
1048
1049    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1050    // saved, so clearing the local star before knowing the record is gone would
1051    // be the desync in the other direction.
1052    if let Some((url, guid)) = identity {
1053        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1054            Ok(0) => {}
1055            Ok(n) => {
1056                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1057            }
1058            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1059        }
1060    }
1061    // htmx swaps the row out; a plain form post goes back to the starred list.
1062    if is_htmx(&headers) {
1063        return (StatusCode::OK, "").into_response();
1064    }
1065    Redirect::to("/?view=starred").into_response()
1066}
1067
1068/// What the poller is doing, as one word for `/stats`.
1069///
1070/// **Parity with `/health` is the point.** `polling_paused` alone reported
1071/// "running" for three different states including the two where nothing polls,
1072/// on the page added to answer exactly that. The first attempt at fixing it
1073/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1074/// poll loop that ticked once at boot and then WEDGED still read as running.
1075/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1076/// original finding's exact shape surviving its own fix.
1077///
1078/// Shares the staleness threshold with `/health` rather than picking its own, so
1079/// the two pages cannot disagree about what "stale" means.
1080fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1081    if !rh.schedulers_enabled() {
1082        return "off";
1083    }
1084    // Checked before the pause: a wedged poller cannot clear a pause either, so
1085    // reporting "paused" would name the symptom and hide the cause.
1086    match rh.secs_since_poll_tick(now_unix) {
1087        None => {
1088            // Never ticked. Benign at boot, a dead loop long after — read
1089            // against uptime, exactly as `/health` does.
1090            match rh.uptime_secs(now_unix) {
1091                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1092                _ => "starting",
1093            }
1094        }
1095        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1096        _ if rh.watermark_paused() => "paused",
1097        _ => "running",
1098    }
1099}
1100
1101/// `GET /stats` — public poll health.
1102async fn stats(State(state): State<AppState>) -> Response {
1103    let now = chrono::Utc::now();
1104    let health = match store::poll_health(
1105        &state.db,
1106        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1107        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1108    )
1109    .await
1110    {
1111        Ok(health) => health,
1112        Err(err) => {
1113            warn!(%err, "could not compute poll health");
1114            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1115        }
1116    };
1117
1118    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1119    // instance is not behind on anything.
1120    let (deferred, starved_since) = state.sanitize_starvation.snapshot();
1121
1122    let polled_pct = if health.feeds_tracked == 0 {
1123        100
1124    } else {
1125        health.polled_last_hour * 100 / health.feeds_tracked
1126    };
1127
1128    render(&StatsTemplate {
1129        card: Card::public(
1130            &state.config,
1131            "/stats",
1132            "Stats — FeatherReader",
1133            "Is this instance's poller keeping up? Aggregate feed-polling health — \
1134             counts only; no feed and no reader is named.",
1135        ),
1136        version: VERSION,
1137        repo_url: REPO_URL,
1138        kofi_url: KOFI_URL,
1139        feeds_tracked: health.feeds_tracked,
1140        polled_last_hour: health.polled_last_hour,
1141        polled_pct,
1142        overdue: health.overdue,
1143        last_poll: humanise_ago(health.last_poll_secs_ago),
1144        oldest_poll: if health.never_polled > 0 {
1145            "never".to_string()
1146        } else {
1147            humanise_ago(health.oldest_poll_secs_ago)
1148        },
1149        never_polled: health.never_polled,
1150        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1151        // **The two states that actually stop feeds updating.**
1152        //
1153        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1154        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1155        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1156        // feed failing every fetch drops out of the backlog and makes the page
1157        // read healthier. Both of these are machine facts with no per-feed
1158        // detail, so they sit inside the page's stated contract.
1159        in_backoff: health.in_backoff,
1160        badly_broken: health.badly_broken,
1161        failure_kinds: health.failure_kinds,
1162        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1163        deferred_no_permit: deferred,
1164        starved_since: starved_since.map(|since| humanise_ago(Some(now.timestamp() - since))),
1165    })
1166}
1167
1168/// "3h 11m ago", or "never" when there has been no poll at all.
1169///
1170/// `None` must not render as `0` — on a fresh instance that would read as
1171/// "polled just now", which is the opposite of the truth.
1172fn humanise_ago(secs: Option<i64>) -> String {
1173    let Some(secs) = secs else {
1174        return "never".to_string();
1175    };
1176    match secs {
1177        s if s < 60 => format!("{s}s ago"),
1178        s if s < 3600 => format!("{}m ago", s / 60),
1179        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1180    }
1181}
1182
1183/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1184/// observation of zero, or a store failure).
1185///
1186/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1187/// probe is never allowed to affect the reader, and that rule applies at the
1188/// display end too — a locked or corrupt DB costs the About page one log line,
1189/// not a 500.
1190async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1191    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1192        // A legitimate zero renders nothing rather than a sad "0 accounts".
1193        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1194            repos: stat.value,
1195            truncated: stat.truncated,
1196            observed_on: stat
1197                .observed_at
1198                .split('T')
1199                .next()
1200                .unwrap_or_default()
1201                .to_string(),
1202        }),
1203        Ok(_) => None,
1204        Err(err) => {
1205            warn!(%err, "about: adoption stat read failed; omitting the line");
1206            None
1207        }
1208    }
1209}
1210
1211/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1212/// lives in the user's PDS, what the server caches, and the session-token
1213/// handling. A static render; readable whether or not a session exists.
1214async fn privacy(State(state): State<AppState>) -> Response {
1215    render(&PrivacyTemplate {
1216        card: Card::public(
1217            &state.config,
1218            "/privacy",
1219            "Privacy — FeatherReader",
1220            "No account and no tracking: your subscriptions and reading state live in \
1221             your own PDS. What this server caches, for how long, and how the session \
1222             token is handled.",
1223        ),
1224        version: VERSION,
1225        repo_url: REPO_URL,
1226        kofi_url: KOFI_URL,
1227    })
1228}
1229
1230/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1231/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1232/// static render; readable whether or not a session exists.
1233async fn terms(State(state): State<AppState>) -> Response {
1234    render(&TermsTemplate {
1235        card: Card::public(
1236            &state.config,
1237            "/terms",
1238            "Terms — FeatherReader",
1239            "The terms of use: an experimental service offered as-is with no warranty, \
1240             what acceptable use means here, and the AGPL self-host note.",
1241        ),
1242        version: VERSION,
1243        repo_url: REPO_URL,
1244        kofi_url: KOFI_URL,
1245    })
1246}
1247
1248// ---------------------------------------------------------------------------
1249// View models
1250// ---------------------------------------------------------------------------
1251
1252/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1253/// and the PDS subscription rkey for management actions).
1254struct FeedView {
1255    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1256    rkey: String,
1257    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1258    url: String,
1259    title: String,
1260    unread: i64,
1261    /// Whether this feed is the currently-selected scope.
1262    selected: bool,
1263    /// The feed's current folder `at://` URI (from its subscription record), or
1264    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1265    /// rename row so an untouched folder dropdown does not silently un-folder the
1266    /// feed on save.
1267    folder: Option<String>,
1268}
1269
1270/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1271struct FolderView {
1272    /// PDS folder rkey — addresses the record for rename/delete.
1273    rkey: String,
1274    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1275    uri: String,
1276    name: String,
1277    feeds: Vec<FeedView>,
1278    /// Whether this folder is the currently-selected scope.
1279    selected: bool,
1280}
1281
1282/// One entry as shown in the article list / after an htmx swap.
1283struct EntryRow {
1284    id: i64,
1285    title: String,
1286    feed_title: String,
1287    published: String,
1288    read: bool,
1289    starred: bool,
1290    /// The reader link href, already carrying the scope/view query so opening an
1291    /// entry and paging back stays within the list it came from.
1292    link: SafeLink,
1293    /// Whether the article itself is in this instance's cache.
1294    ///
1295    /// `false` for a saved record that exists in the reader's PDS but whose
1296    /// entry was never cached here — starred in another atproto reader, or
1297    /// starred here and since evicted. There is no local row, so the row has no
1298    /// usable `id`: it links straight out to the article and carries no
1299    /// mark-read control, because there is nothing local to mark.
1300    cached: bool,
1301    /// The PDS record key, for un-saving a row that has no local entry.
1302    rkey: String,
1303}
1304
1305/// A folder as an option in the "move feed to folder" select.
1306struct FolderOption {
1307    uri: String,
1308    name: String,
1309}
1310
1311/// The shared navigation "rail" model: the same DOM element is the
1312/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1313/// manage) renders it from this one struct. Feed management lives on `/manage`,
1314/// not here — the rail is navigation only.
1315struct Nav {
1316    /// `@handle` for the identity chip (falls back to the DID's tail).
1317    handle: String,
1318    /// Two-letter avatar initials for the identity chip.
1319    avatar: String,
1320    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1321    view: String,
1322    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1323    /// empty for the unscoped "everything" views.
1324    scope_qs: String,
1325    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1326    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1327    folders: Vec<FolderView>,
1328    loose_feeds: Vec<FeedView>,
1329    /// Whether the "Manage feeds" rail tool is the current page.
1330    manage_active: bool,
1331}
1332
1333/// The subscribe input's `pattern` when standard.site is on, and the input is
1334/// `type="text"` (see `templates/manage.html`). It keeps the browser asking for
1335/// a scheme, as `type="url"` did, while admitting `at://`. Matched in any case,
1336/// because the handler canonicalises the scheme. Browsers compile `pattern`
1337/// with the `v` flag and anchor it at both ends. Unlike `type="url"`, a text
1338/// input does not strip surrounding whitespace before checking, so the pattern
1339/// allows it: a URL pasted with a leading space is common, and the handler
1340/// trims it.
1341pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1342
1343// ---------------------------------------------------------------------------
1344// Link cards (Open Graph / Twitter / Bluesky)
1345// ---------------------------------------------------------------------------
1346
1347/// The site's own title: the landing page's, and the one every private view
1348/// shows instead of its own.
1349const SITE_TITLE: &str = "FeatherReader — read, quietly";
1350
1351/// The site's one-paragraph description: the landing page's, and the one every
1352/// private view shows instead of its own.
1353const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1354standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1355password, no tracking.";
1356
1357/// Where the share image is served, relative to the public origin. The file is
1358/// `static/social-card.png`, rendered from `static/social-card.svg` by
1359/// `scripts/social-card.sh`; `base.html` advertises its dimensions, and a test
1360/// checks the PNG's own header agrees.
1361const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1362
1363/// What a link to a page unfurls as when it is posted — on Bluesky, in a chat,
1364/// anywhere that reads Open Graph tags. `base.html` renders it into `<head>`.
1365///
1366/// Measured before this existed: Bluesky's card service
1367/// (`cardyb.bsky.app/v1/extract?url=https://feather-reader.com/`) returned
1368/// `{"title":"FeatherReader — read, quietly","description":"","image":""}`,
1369/// because `<title>` was the only tag it could find. Card fetchers read the
1370/// initial HTML server-side, run no JS, and resolve nothing relative, so every
1371/// URL here is absolute on [`Config::public_url`] — `https://feather-reader.com`
1372/// in production, whatever `FEATHERREADER_PUBLIC_URL` says elsewhere.
1373#[derive(Debug, Clone)]
1374pub(crate) struct Card {
1375    /// `og:title`. On a public page, the same text as its `<title>`.
1376    pub title: String,
1377    /// `og:description` and `<meta name="description">`: one or two plain
1378    /// sentences about THIS page, not the site.
1379    pub description: String,
1380    /// `og:url` and `<link rel="canonical">`: absolute, on the public origin.
1381    pub url: String,
1382    /// `og:image`: absolute, on the public origin.
1383    pub image: String,
1384    /// Set on a page that renders a session's private view. The card is then
1385    /// the site's generic one — nothing from the view reaches `<head>` — and
1386    /// the page is `noindex`.
1387    pub private: bool,
1388}
1389
1390impl Card {
1391    /// The card of the public page at `path` (leading slash) on this instance.
1392    fn public(
1393        config: &Config,
1394        path: &str,
1395        title: impl Into<String>,
1396        description: impl Into<String>,
1397    ) -> Self {
1398        let origin = config.public_url.trim_end_matches('/');
1399        Card {
1400            title: title.into(),
1401            description: description.into(),
1402            url: format!("{origin}{path}"),
1403            image: format!("{origin}{SHARE_IMAGE_PATH}"),
1404            private: false,
1405        }
1406    }
1407
1408    /// The landing page's card: the site's own title and description.
1409    fn site(config: &Config) -> Self {
1410        Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1411    }
1412
1413    /// The card of a page that renders a session's private view: the site's
1414    /// generic card pointing at the front door, plus `noindex`. The view's
1415    /// heading, feed names and handle stay out of `<head>`.
1416    fn private(config: &Config) -> Self {
1417        Card {
1418            private: true,
1419            ..Card::site(config)
1420        }
1421    }
1422}
1423
1424/// The reader index (`GET /`).
1425#[derive(Template)]
1426#[template(path = "index.html")]
1427struct IndexTemplate {
1428    /// The link card. A private view: the site's generic card, `noindex`.
1429    card: Card,
1430    version: &'static str,
1431    repo_url: &'static str,
1432    kofi_url: &'static str,
1433    flash: String,
1434    /// Shown as `role="alert"` when the subscription list is the cached one
1435    /// because the PDS listing failed; empty otherwise.
1436    alert: String,
1437    /// The shared rail (drawer + desktop sidebar) navigation model.
1438    nav: Nav,
1439    /// The article list for the selected scope + view.
1440    entries: Vec<EntryRow>,
1441    /// The list heading (the selected view/feed/folder name).
1442    heading: String,
1443    /// Whether a feed scope is active (enables per-feed mark-all-read).
1444    feed_scope: Option<String>,
1445    /// Total CACHED entries in this scope + view across ALL pages. The count used
1446    /// to be `entries.len()`, which was the same number only because the list was
1447    /// unpaged — the thing this change exists to stop.
1448    ///
1449    /// The pager is derived from this, so it must not include the uncached PDS
1450    /// rows below: they are appended to the last page rather than paged, and
1451    /// counting them here advertised a page the clamp could never reach.
1452    total: i64,
1453    /// How many of `total` are PDS saved records the cache cannot show.
1454    ///
1455    /// A subset of `total`, not an addition to it — the heading says "N entries
1456    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1457    /// which double counted once `total` started including them, against an M
1458    /// that had become page-local in the same commit while the template stayed
1459    /// put.
1460    uncached_total: i64,
1461    /// 1-based current page.
1462    page: i64,
1463    /// Total pages, at least 1 (an empty list is page 1 of 1).
1464    page_count: i64,
1465    /// Link to the previous (newer) page, or `None` on the first.
1466    prev_href: Option<String>,
1467    /// Link to the next (older) page, or `None` on the last.
1468    next_href: Option<String>,
1469}
1470
1471/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1472#[derive(Template)]
1473#[template(path = "manage.html")]
1474struct ManageTemplate {
1475    /// The link card. A private view: the site's generic card, `noindex`.
1476    card: Card,
1477    version: &'static str,
1478    repo_url: &'static str,
1479    kofi_url: &'static str,
1480    flash: String,
1481    /// See [`IndexTemplate::alert`].
1482    alert: String,
1483    nav: Nav,
1484    /// All folders as move-targets for the subscribe folder select.
1485    folder_options: Vec<FolderOption>,
1486    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1487    folders: Vec<FolderView>,
1488    loose_feeds: Vec<FeedView>,
1489    /// `Config::standard_site`. With it on, the subscribe form says a
1490    /// `site.standard.publication` URI is accepted and its input drops
1491    /// `type="url"`, whose browser validation rejects the DID form. With it off
1492    /// `add_subscription` refuses every `at://` paste, so the form must not
1493    /// advertise one.
1494    standard_site: bool,
1495}
1496
1497/// The optional one-line adoption fact at the bottom of `/about`
1498/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1499/// probe has succeeded yet, or the read failed — the line then simply does not
1500/// render.
1501struct AdoptionLine {
1502    /// Repos a relay has indexed as holding the subscription collection.
1503    repos: i64,
1504    /// The probe hit its page cap, so the copy must say "at least".
1505    truncated: bool,
1506    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1507    observed_on: String,
1508}
1509
1510/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1511/// the optional adoption line.
1512#[derive(Template)]
1513#[template(path = "about.html")]
1514struct AboutTemplate {
1515    /// The link card: this page's own title and description.
1516    card: Card,
1517    version: &'static str,
1518    repo_url: &'static str,
1519    kofi_url: &'static str,
1520    adoption: Option<AdoptionLine>,
1521    /// `Config::standard_site`: whether the publications section may tell the
1522    /// reader how to subscribe to one here. See [`ManageTemplate::standard_site`].
1523    standard_site: bool,
1524}
1525
1526/// The public `/standard-site` feature page. Carries the same footer fields
1527/// as the other public pages, the standard.site flag, and the release list for
1528/// the "latest releases" call-out.
1529#[derive(Template)]
1530#[template(path = "standard_site.html")]
1531struct StandardSiteTemplate {
1532    /// The link card: this page's own title and description.
1533    card: Card,
1534    version: &'static str,
1535    repo_url: &'static str,
1536    kofi_url: &'static str,
1537    /// `Config::standard_site`: whether the page may tell a visitor how to
1538    /// subscribe to a publication here. See [`ManageTemplate::standard_site`].
1539    standard_site: bool,
1540    /// [`RELEASES`], newest first, for `templates/releases.html`.
1541    releases: &'static [Release],
1542}
1543
1544/// One tagged release, as the "latest releases" call-out
1545/// (`templates/releases.html`) shows it on `/standard-site` and the landing
1546/// page. The links are derived from `version` and `date`, so a release is
1547/// described in exactly one place: an entry in [`RELEASES`].
1548pub(crate) struct Release {
1549    /// The crate version, without the `v` (`"0.4.1"`). The tag is `v{version}`.
1550    pub(crate) version: &'static str,
1551    /// The release date, `YYYY-MM-DD`, as the CHANGELOG heading has it.
1552    pub(crate) date: &'static str,
1553    /// One or two plain sentences for a visitor. No markup: the template escapes it.
1554    pub(crate) summary: &'static str,
1555}
1556
1557impl Release {
1558    /// The GitHub release page: `{REPO_URL}/releases/tag/v{version}`.
1559    pub(crate) fn url(&self) -> String {
1560        format!("{REPO_URL}/releases/tag/v{}", self.version)
1561    }
1562
1563    /// The release's section of `CHANGELOG.md` on `main`. GitHub derives the
1564    /// anchor for a heading `## 0.4.1 — 2026-10-04` as `041--2026-10-04`: the
1565    /// dots dropped, the em dash dropped, each space a hyphen.
1566    pub(crate) fn changelog_url(&self) -> String {
1567        format!(
1568            "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1569            self.version.replace('.', ""),
1570            self.date
1571        )
1572    }
1573}
1574
1575/// **The one place a release is described for the website.** Newest first.
1576/// To announce the next release, add one entry at the top; the call-out on
1577/// `/standard-site` and the landing page, and both links, follow from it.
1578/// `releases_are_newest_first_and_link_the_tag_and_changelog` pins the shape.
1579pub(crate) const RELEASES: &[Release] = &[
1580    Release {
1581        version: "0.4.7",
1582        date: "2026-10-07",
1583        summary: "A feed can no longer stall the reader with an article that is \
1584                  slow to clean up, and every stored article is cleaned again \
1585                  as it is shown.",
1586    },
1587    Release {
1588        version: "0.4.6",
1589        date: "2026-10-06",
1590        summary: "Renaming a subscription or a folder no longer overwrites \
1591                  what another app changed at the same moment, and a folder \
1592                  rename keeps everything but the name.",
1593    },
1594    Release {
1595        version: "0.4.5",
1596        date: "2026-10-06",
1597        summary: "An operator teardown now signs every user out at their own \
1598                  server before deleting anything, and the session-writing \
1599                  code is hardened against the races that work exposed.",
1600    },
1601    Release {
1602        version: "0.4.4",
1603        date: "2026-10-05",
1604        summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1605                  links unchanged and real RSS bylines, and the address guard \
1606                  refuses the reserved ranges it missed.",
1607    },
1608    Release {
1609        version: "0.4.3",
1610        date: "2026-10-05",
1611        summary: "Two write-path fixes for any PDS: large OPML imports and \
1612                  read-state syncs are sent in calls the PDS accepts, and a \
1613                  read-state sync that disagreed with the PDS recovers instead \
1614                  of failing every round.",
1615    },
1616    Release {
1617        version: "0.4.2",
1618        date: "2026-10-04",
1619        summary: "A public standard.site feature page with this list of recent \
1620                  releases, and link cards: a posted feather-reader.com link \
1621                  now unfurls with a description and an image.",
1622    },
1623    Release {
1624        version: "0.4.1",
1625        date: "2026-10-04",
1626        summary: "The public pages explain standard.site publications, and the \
1627                  subscribe form can submit the DID form of a publication URI, \
1628                  which browsers refused in 0.4.0.",
1629    },
1630    Release {
1631        version: "0.4.0",
1632        date: "2026-10-03",
1633        summary: "standard.site support: publications are read from their \
1634                  authors' atproto repos as subscriptions, beside RSS, on their \
1635                  own polling loop. Every stored field from a feed or a \
1636                  publication now has a size bound.",
1637    },
1638];
1639
1640/// The public `/stats` page — is the poller keeping up?
1641///
1642/// Aggregate only, deliberately. It is published to anyone, so it carries no
1643/// user counts and no per-feed detail: a reader does not need to know how many
1644/// people use an instance or which feeds are failing. What it does answer is the
1645/// question that decides whether an instance can take more readers — whether the
1646/// poller is servicing the feeds it already has.
1647///
1648/// The counts below are aggregate machine facts, which is why they fit that
1649/// contract: "12 feeds are in backoff" names no feed and no reader, while
1650/// answering the question the page was previously unable to answer at all.
1651#[derive(Template)]
1652#[template(path = "stats.html")]
1653struct StatsTemplate {
1654    /// The link card: this page's own title and description.
1655    card: Card,
1656    version: &'static str,
1657    repo_url: &'static str,
1658    kofi_url: &'static str,
1659    feeds_tracked: i64,
1660    polled_last_hour: i64,
1661    polled_pct: i64,
1662    overdue: i64,
1663    last_poll: String,
1664    oldest_poll: String,
1665    never_polled: i64,
1666    poll_interval_mins: i64,
1667    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1668    in_backoff: i64,
1669    /// Of those, the ones retried hours apart rather than minutes. **Not
1670    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1671    /// their next successful poll, and most of this instance's did.
1672    badly_broken: i64,
1673    /// Failing feeds by cause, descending — counts only, never which feed.
1674    failure_kinds: Vec<(String, i64)>,
1675    /// What the poller is actually doing: `running`, `paused` (at the size
1676    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1677    /// disabled). Three of those four used to render as "running".
1678    fetching: &'static str,
1679    /// Polls deferred since boot because no sanitize permit came free (#226),
1680    /// and — while that is still so — since when, humanised. Deferred polls
1681    /// store nothing and file nothing against their feeds, so this row is the
1682    /// only sign that hostile bodies' sanitizes hold every permit.
1683    deferred_no_permit: u64,
1684    starved_since: Option<String>,
1685}
1686
1687/// The public `/privacy` page — what the server holds vs. what lives in the
1688/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1689/// footer include needs.
1690#[derive(Template)]
1691#[template(path = "privacy.html")]
1692struct PrivacyTemplate {
1693    /// The link card: this page's own title and description.
1694    card: Card,
1695    version: &'static str,
1696    repo_url: &'static str,
1697    kofi_url: &'static str,
1698}
1699
1700/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1701/// same fields the shared footer include needs.
1702#[derive(Template)]
1703#[template(path = "terms.html")]
1704struct TermsTemplate {
1705    /// The link card: this page's own title and description.
1706    card: Card,
1707    version: &'static str,
1708    repo_url: &'static str,
1709    kofi_url: &'static str,
1710}
1711
1712/// The signed-out landing page (`GET /` with no session) — the public front
1713/// door at feather-reader.com. A static render, no session required.
1714#[derive(Template)]
1715#[template(path = "landing.html")]
1716struct LandingTemplate {
1717    /// The link card: the site's own title and description.
1718    card: Card,
1719    version: &'static str,
1720    repo_url: &'static str,
1721    crates_url: &'static str,
1722    kofi_url: &'static str,
1723    /// `Config::standard_site`: whether the publications point may tell a
1724    /// visitor how to subscribe to one here. See [`ManageTemplate::standard_site`].
1725    standard_site: bool,
1726    /// [`RELEASES`], newest first, for the quiet "latest releases" strip.
1727    releases: &'static [Release],
1728}
1729
1730/// The single-entry reader view (`GET /entries/:id`).
1731#[derive(Template)]
1732#[template(path = "entry.html")]
1733struct EntryTemplate {
1734    /// The link card. A private view: the site's generic card, `noindex`.
1735    card: Card,
1736    version: &'static str,
1737    repo_url: &'static str,
1738    kofi_url: &'static str,
1739    nav: Nav,
1740    id: i64,
1741    title: String,
1742    feed_title: String,
1743    author: Option<String>,
1744    published: String,
1745    /// The entry's own link, for `entry.html`'s two `href`s.
1746    ///
1747    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1748    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1749    /// long way from the `href` and holds only while every future writer to
1750    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1751    /// defence that, on the saved-record row, turned out to be deletable with
1752    /// all 679 tests still green. `None` is the refusal: the template's
1753    /// no-URL branch already renders a disabled open-original button.
1754    url: Option<SafeLink>,
1755    /// The article body, **re-sanitized for this render** (#151), or the
1756    /// reason it is not shown.
1757    ///
1758    /// Not the stored `String`: that reached the page through `|safe` and was
1759    /// safe only because `feed.rs` had sanitized it at ingest — the same
1760    /// every-writer-remembers guard `url` above used to rest on, and the more
1761    /// dangerous of the two. A `SanitizedHtml` can only be built by running
1762    /// the ingest sanitizer, so the template renders it unescaped without a
1763    /// `|safe` on a raw string anywhere. The other two variants are the
1764    /// renderer's bounds: a body over the stored size cap, or no sanitizer
1765    /// permit in time; the template shows a note and the original's link.
1766    content_html: Option<BodyRender>,
1767    read: bool,
1768    starred: bool,
1769    /// The query string to carry the reading context back to the list.
1770    back_qs: String,
1771    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1772    prev_id: Option<i64>,
1773    next_id: Option<i64>,
1774    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1775    oob: bool,
1776}
1777
1778/// The htmx swap fragment for a single entry row (`entry_row.html`).
1779#[derive(Template)]
1780#[template(path = "entry_row.html")]
1781struct EntryRowTemplate {
1782    e: EntryRow,
1783}
1784
1785/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1786/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1787/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1788/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1789#[derive(Template)]
1790#[template(path = "entry_actionbar.html")]
1791struct EntryActionBarTemplate {
1792    id: i64,
1793    read: bool,
1794    starred: bool,
1795    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1796    oob: bool,
1797}
1798
1799/// The login stub (`GET /login`).
1800#[derive(Template)]
1801#[template(path = "login.html")]
1802struct LoginTemplate {
1803    /// The link card: this page's own title and description.
1804    card: Card,
1805    repo_url: &'static str,
1806    error: String,
1807    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1808    /// distinct from `error`. Empty renders nothing.
1809    flash: String,
1810}
1811
1812/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1813#[derive(Template)]
1814#[template(path = "beta_redeem.html")]
1815struct BetaRedeemTemplate {
1816    /// The link card: this page's own title and description.
1817    card: Card,
1818    repo_url: &'static str,
1819    error: String,
1820    /// When true the seat cap is full: hide the form and show the "capacity
1821    /// full — try self-hosting" message instead.
1822    capacity_full: bool,
1823}
1824
1825// ---------------------------------------------------------------------------
1826// Rendering + error helpers
1827// ---------------------------------------------------------------------------
1828
1829/// Render an askama template into an HTML response, mapping a render failure to
1830/// a `500` rather than panicking (no `unwrap` in the request path).
1831fn render<T: Template>(tmpl: &T) -> Response {
1832    match tmpl.render() {
1833        Ok(body) => Html(body).into_response(),
1834        Err(err) => {
1835            warn!(%err, "template render failed");
1836            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1837        }
1838    }
1839}
1840
1841/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1842/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1843/// by default; a handler may override the status (e.g. `413` for an over-cap
1844/// upload) via [`WebError::with_status`].
1845struct WebError {
1846    err: anyhow::Error,
1847    status: StatusCode,
1848}
1849
1850impl<E: Into<anyhow::Error>> From<E> for WebError {
1851    fn from(err: E) -> Self {
1852        WebError {
1853            err: err.into(),
1854            status: StatusCode::INTERNAL_SERVER_ERROR,
1855        }
1856    }
1857}
1858
1859impl WebError {
1860    /// Attach an explicit HTTP status to render instead of the default `500`.
1861    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1862        WebError {
1863            err: err.into(),
1864            status,
1865        }
1866    }
1867}
1868
1869impl IntoResponse for WebError {
1870    fn into_response(self) -> Response {
1871        warn!(error = %self.err, status = %self.status, "request failed");
1872        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1873            "internal error"
1874        } else {
1875            self.status.canonical_reason().unwrap_or("error")
1876        };
1877        (self.status, body).into_response()
1878    }
1879}
1880
1881/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1882/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1883/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1884/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1885fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1886    let status = err.status();
1887    WebError::with_status(err, status)
1888}
1889
1890/// A short, human display of a feed/site title for the sidebar/list, falling
1891/// back to the host of a URL and finally to the raw string.
1892fn display_title(title: Option<&str>, url: &str) -> String {
1893    if let Some(t) = title {
1894        let t = t.trim();
1895        if !t.is_empty() {
1896            return t.to_string();
1897        }
1898    }
1899    url::Url::parse(url)
1900        .ok()
1901        .and_then(|u| u.host_str().map(str::to_string))
1902        .unwrap_or_else(|| url.to_string())
1903}
1904
1905/// A display `@handle` for the identity chip: the stored handle if present,
1906/// else the tail of the DID so the chip is never empty.
1907fn display_handle(handle: Option<&str>, did: &str) -> String {
1908    match handle {
1909        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1910        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1911    }
1912}
1913
1914/// Two-letter, lowercase avatar initials from a handle/DID.
1915fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1916    let source = handle
1917        .map(|h| h.trim().trim_start_matches('@'))
1918        .filter(|h| !h.is_empty())
1919        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1920    let letters: String = source
1921        .chars()
1922        .filter(|c| c.is_alphanumeric())
1923        .take(2)
1924        .collect::<String>()
1925        .to_lowercase();
1926    if letters.is_empty() {
1927        "fr".to_string()
1928    } else {
1929        letters
1930    }
1931}
1932
1933/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1934/// low-noise display. Falls back to the raw string if it doesn't look like one.
1935fn display_date(published: Option<&str>) -> String {
1936    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1937    // multi-byte character, and every caller used to pass a timestamp the feed
1938    // parser had produced. The saved-record path passes `createdAt` straight off
1939    // a PDS record, which the lexicon types as a bare string with no validation
1940    // — written by whatever atproto client the reader used. A `createdAt` of
1941    // "日本語日本語日本" took down the whole starred view, and there is no
1942    // catch-panic layer in the stack, so the page stayed down until the record
1943    // was removed from the very view that would not render.
1944    match published {
1945        Some(p) => p.chars().take(10).collect(),
1946        None => String::new(),
1947    }
1948}
1949
1950/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1951/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1952/// a bare value, and this keeps the scope-preserving links honest.
1953fn qenc(s: &str) -> String {
1954    let mut out = String::with_capacity(s.len() * 3);
1955    for b in s.bytes() {
1956        match b {
1957            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1958                out.push(b as char)
1959            }
1960            _ => out.push_str(&format!("%{b:02X}")),
1961        }
1962    }
1963    out
1964}
1965
1966// ---------------------------------------------------------------------------
1967// Reader: index
1968// ---------------------------------------------------------------------------
1969
1970/// Query for `GET /` — the scope + view selector.
1971#[derive(Debug, Deserialize, Default)]
1972struct IndexQuery {
1973    /// Filter to a single feed by its canonical URL.
1974    #[serde(default)]
1975    feed: Option<String>,
1976    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1977    #[serde(default)]
1978    folder: Option<String>,
1979    /// `unread` (default) | `all` | `starred`.
1980    #[serde(default)]
1981    view: Option<String>,
1982    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1983    #[serde(default)]
1984    page: Option<u32>,
1985    /// Optional flash message (e.g. after an action redirect).
1986    #[serde(default)]
1987    flash: Option<String>,
1988}
1989
1990/// Rows per page in the reader's list views.
1991///
1992/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1993/// so a page is on the order of tens of kilobytes rather than the tens or
1994/// hundreds of megabytes an unbounded list of full entries could reach. The page
1995/// bound is the second half of that fix: without it, a reader with a long
1996/// backlog still decides how much memory a single request allocates.
1997const ENTRIES_PER_PAGE: i64 = 100;
1998
1999/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
2000/// pager reads "1 / 1" rather than "1 / 0".
2001fn page_count_for(total: i64) -> i64 {
2002    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
2003}
2004
2005/// Ceiling on the reader's prev/next id list.
2006///
2007/// Unlike the page above, this genuinely spans the whole list — prev/next is the
2008/// reader's position within it — so it is bounded by count rather than paged. At
2009/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
2010/// resolving; the article itself still opens, and the list view still pages.
2011const PREV_NEXT_MAX: i64 = 5_000;
2012
2013/// Ceiling on the cached-starred identity set matched against PDS saved records.
2014///
2015/// Deliberately generous: under-reading this set makes a cached article look
2016/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
2017/// than un-starring the entry. Truncating here would change what a click
2018/// destroys, so the cap exists only as a backstop against an absurd starred
2019/// count, not as a routine bound.
2020const STARRED_IDENTITY_MAX: i64 = 20_000;
2021
2022/// Most uncached PDS saved records this handler will hold in memory for one
2023/// request.
2024///
2025/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
2026/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
2027/// this only caps how many are collected before slicing. An earlier version used
2028/// it to cap what was SHOWN, which left everything past it invisible and —
2029/// because the un-save control lives on the row, and nothing else in the app
2030/// lists these — unremovable.
2031///
2032/// Well above the PDS list ceiling's practical reach for one reader, so a reader
2033/// meeting it has thousands of saved records and gets a logged, ordered prefix
2034/// rather than a failure.
2035const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
2036
2037/// A subscription resolved against the local cache: the PDS record + its
2038/// (possibly-missing) cached feed row.
2039struct ResolvedSub {
2040    rkey: String,
2041    sub: Subscription,
2042    feed: Option<store::Feed>,
2043}
2044
2045/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
2046/// local cache row so unread counts work, and return them resolved. Best-effort
2047/// on the sidecar: a failure falls back to the local cache alone.
2048async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
2049    resolve_subscriptions_noting(state, did).await.0
2050}
2051
2052/// What to tell a reader whose subscription list could not be read from their
2053/// PDS, so the last-known list being shown does not pass for a fresh one.
2054///
2055/// **A malformed record is named as such** (#177): the walk refuses rather than
2056/// drop that subscription, and "unreachable" would send the reader looking at
2057/// their network when the cause is a record some client wrote into their repo.
2058fn subscriptions_alert(err: &anyhow::Error) -> String {
2059    match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2060        Some(m) => format!(
2061            "{} record(s) in your subscription list could not be read, so it was not \
2062             refreshed. Showing your last-known subscriptions; nothing was removed.",
2063            m.count
2064        ),
2065        None => "Your subscription list could not be read from your PDS just now. \
2066                 Showing your last-known subscriptions."
2067            .to_string(),
2068    }
2069}
2070
2071/// [`resolve_subscriptions`], plus the alert to show when the list shown is the
2072/// cached one because the PDS listing failed.
2073async fn resolve_subscriptions_noting(
2074    state: &AppState,
2075    did: &str,
2076) -> (Vec<ResolvedSub>, Option<String>) {
2077    let pool = &state.db;
2078    let subs = match state.repo().list_subscriptions_sorted(did).await {
2079        Ok(s) => s,
2080        Err(err) => {
2081            let alert = subscriptions_alert(&err);
2082            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2083            // Fail CLOSED: the PDS is the source of truth for what this DID
2084            // follows. When it is unreachable we must NOT widen the caller's
2085            // authorization surface. Serve from the DID's OWN last-known
2086            // `sub_ref` projection (its own feeds, possibly stale) and leave
2087            // `sub_ref` untouched — never synthesize from every cached feed,
2088            // which would grant cross-tenant read+mutate during any outage.
2089            // A DB failure here is NOT the same as "this DID follows nothing",
2090            // but `unwrap_or_default` rendered it as exactly that: an empty
2091            // sidebar and an empty reader, which arrives as "all my feeds
2092            // vanished". It still degrades to empty — there is nothing better to
2093            // show — but it says so, so the support ticket and the log line can
2094            // be matched up.
2095            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2096                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2097                                   projection could not be read; rendering an EMPTY \
2098                                   feed list, which is not the same as having none");
2099                Vec::new()
2100            });
2101            let cached = feeds
2102                .into_iter()
2103                .map(|f| ResolvedSub {
2104                    rkey: String::new(),
2105                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
2106                    feed: Some(f),
2107                })
2108                .collect();
2109            return (cached, Some(alert));
2110        }
2111    };
2112
2113    // **Deliberately NOT truncated to `max_subs_per_did`.**
2114    //
2115    // The PDS list is unbounded in practice — any client can write subscription
2116    // records, and only the 20,000-record list ceiling stops it — and the first
2117    // attempt at bounding it truncated the list right here. That was the wrong
2118    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
2119    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
2120    // removed the reader's ability to read OR mutate those feeds. A query-shape
2121    // problem would have become an access problem.
2122    //
2123    // The shape problem was the scope filter emitting one SQL placeholder per
2124    // feed; `store::list_query_sql` now passes the whole set as a single
2125    // `json_each` bind, so there is no size to defend against here and nothing
2126    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
2127    // feeds — rather than becoming a silent read-time filter.
2128    let mut out = Vec::with_capacity(subs.len());
2129    for (rkey, sub) in subs {
2130        let feed = match store::get_feed_by_url(pool, &sub.url).await {
2131            Ok(Some(f)) => Some(f),
2132            Ok(None) => {
2133                // `sub.url` came out of an atproto record. The lexicon is open —
2134                // ANY client can write a subscription into a user's repo — so
2135                // this is untrusted input on the hot path of `GET /`, and it was
2136                // being stored with none of the three checks the add and import
2137                // paths apply. Two of those are capacity ceilings; this one is
2138                // the invariant in `FeedPrivacy`'s doc comment, which promises a
2139                // private feed URL is "never stored". Writing a
2140                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
2141                // that promise even though `net::guarded_get` still refuses to
2142                // fetch it.
2143                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2144                    || feed::classify_feed_privacy(&sub.url).is_private()
2145                {
2146                    warn!(
2147                        %did,
2148                        "skipping cache row for a subscription URL that is private or not http(s)"
2149                    );
2150                    out.push(ResolvedSub {
2151                        rkey,
2152                        sub,
2153                        feed: None,
2154                    });
2155                    continue;
2156                }
2157                // Upsert a cache row so the sidebar reflects the real follow-list.
2158                //
2159                // A silent failure here is a support ticket with no evidence: no
2160                // `feeds` row means the poller never selects this subscription,
2161                // so the reader sees "I added a feed and it never updates" while
2162                // the PDS record looks perfect. Logged with the URL so the
2163                // failing subscription is identifiable.
2164                if let Err(err) = store::upsert_feed(
2165                    pool,
2166                    &store::NewFeed {
2167                        url: sub.url.clone(),
2168                        title: sub.title.clone(),
2169                        site_url: sub.site_url.clone(),
2170                        ..Default::default()
2171                    },
2172                )
2173                .await
2174                {
2175                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2176                                                       it will not be polled");
2177                }
2178                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2179            }
2180            Err(err) => {
2181                warn!(%err, url = %sub.url, "get_feed_by_url failed");
2182                None
2183            }
2184        };
2185        out.push(ResolvedSub { rkey, sub, feed });
2186    }
2187    // Mirror the caller's resolved subscription set into `sub_ref`, so every
2188    // scoped entry/feed read + read/star mutation authorizes against exactly
2189    // the feeds this DID follows right now. This is THE per-DID isolation hook.
2190    sync_sub_refs(pool, did, &out).await;
2191    (out, None)
2192}
2193
2194/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
2195/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
2196/// fail closed / show fewer rows), never leaks another user's entries.
2197async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2198    let feed_ids: Vec<i64> = subs
2199        .iter()
2200        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2201        .collect();
2202    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2203        warn!(%err, %did, "failed to sync sub_ref projection");
2204    }
2205}
2206
2207/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
2208/// records layer) and the article list for the selected scope + view.
2209async fn index(
2210    State(state): State<AppState>,
2211    headers: HeaderMap,
2212    Query(q): Query<IndexQuery>,
2213) -> Result<Response, WebError> {
2214    let user = match current_session(&state, &headers).await {
2215        Some(u) => u,
2216        // Signed out: serve the public landing page rather than bouncing to
2217        // /login. /login remains the entry point for the actual OAuth sign-in.
2218        None => {
2219            return Ok(render(&LandingTemplate {
2220                card: Card::site(&state.config),
2221                version: VERSION,
2222                repo_url: REPO_URL,
2223                crates_url: CRATES_URL,
2224                kofi_url: KOFI_URL,
2225                standard_site: state.config.standard_site,
2226                releases: RELEASES,
2227            }))
2228        }
2229    };
2230    let did = user.did.clone();
2231    let pool = &state.db;
2232
2233    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2234
2235    // View: unread (default) | all | starred.
2236    let view = match q.view.as_deref() {
2237        Some("all") => "all",
2238        Some("starred") => "starred",
2239        _ => "unread",
2240    }
2241    .to_string();
2242    let list_view = list_view_of(q.view.as_deref());
2243
2244    // Which feed URLs are in scope?
2245    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2246    // …and the feed ids they resolve to. Scope is applied inside the query now,
2247    // so a page is a page of rows the reader will actually see. Filtering after
2248    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
2249    // any scope narrower than the whole subscription list.
2250    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2251
2252    let feed_title_by_id = |id: i64| -> String {
2253        subs.iter()
2254            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2255            .map(|s| {
2256                display_title(
2257                    s.sub
2258                        .title
2259                        .as_deref()
2260                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2261                    &s.sub.url,
2262                )
2263            })
2264            .unwrap_or_default()
2265    };
2266
2267    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
2268    //
2269    // All three views used to materialize every matching entry — `SELECT e.*`,
2270    // no `LIMIT`, article bodies included — and the "all" view additionally ran
2271    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
2272    // of the row fields below read the body. See `store::EntryListRow`.
2273    // **Saved records the cache cannot show.**
2274    //
2275    // The starred view is built from local `entries`, so a saved record whose
2276    // article was never cached here is invisible — the case that matters is
2277    // starring in ANOTHER atproto reader, which is the portability the shared
2278    // lexicon exists for. Those rows are rendered from the PDS record alone.
2279    let mut uncached: Vec<EntryRow> = Vec::new();
2280    if view == "starred" {
2281        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
2282        //
2283        // `source` has already been filtered by feed/folder. Matching against it
2284        // meant an entry that IS cached but sits outside the current filter
2285        // looked uncached — so it rendered as a "not cached" row whose star
2286        // button deletes the PDS RECORD instead of un-starring the entry. A
2287        // scope filter must not change what is destroyed. Paging is the same
2288        // hazard in a new form: matching against the visible PAGE would make
2289        // every cached article outside it look uncached. Hence a dedicated
2290        // identity query over the whole starred set — urls and guids only, no
2291        // bodies — rather than reusing `source`.
2292        //
2293        // One gap remains BY DESIGN, and is handled at the other end. This query
2294        // still carries the `sub_ref` predicate, so a starred, cached entry in a
2295        // feed the reader has UNSUBSCRIBED from is absent here and its record
2296        // renders as uncached. That is the right rendering — the article is no
2297        // longer part of any feed the reader follows, and the PDS record is what
2298        // still holds it — but it means the un-save button is the record-deleting
2299        // one. `unsave_record` therefore clears the local star too, so the two
2300        // stores agree however the row got classified. Dropping the predicate
2301        // here instead would have made the row link to `/entries/{id}`, which is
2302        // `sub_ref`-scoped and would 404.
2303        //
2304        // **Three ways this can be unusable, and all three fail CLOSED.** With an
2305        // incomplete identity set, a cached article looks uncached and renders an
2306        // un-save button that deletes the PDS RECORD. Showing no uncached rows
2307        // loses rows for one render; getting this wrong loses data permanently,
2308        // so every uncertain case suppresses them.
2309        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2310            Ok(store::StarredIdentities::All(rows)) => Some(rows),
2311            // The cap is a memory backstop, and reaching it means the set is an
2312            // arbitrary subset. It used to return that subset with no way to
2313            // tell, so every starred article outside it got the destructive
2314            // button.
2315            Ok(store::StarredIdentities::Truncated) => {
2316                warn!(
2317                    %did,
2318                    cap = STARRED_IDENTITY_MAX,
2319                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
2320                     rather than rendering record-deleting buttons for cached articles"
2321                );
2322                None
2323            }
2324            Err(err) => {
2325                warn!(%err, %did, "cached-starred identity lookup failed; \
2326                                    suppressing uncached saved rows this render");
2327                None
2328            }
2329        };
2330        // The escape hatch asks whether this DID has ANY cached starred entry —
2331        // not whether the current SCOPE does. `total` is narrowed by
2332        // `?feed=`/`?folder=` while the identity set spans every feed, so
2333        // comparing them waved the fail-closed condition through for any narrow
2334        // scope: a record whose `feedUrl` matched the filter while its cached
2335        // entry lived under another feed rendered as uncached.
2336        let identities_ok = identities.is_some();
2337        let identities = identities.unwrap_or_default();
2338        let cached_urls: std::collections::HashSet<&str> = identities
2339            .iter()
2340            .filter_map(|(url, _)| url.as_deref())
2341            .collect();
2342        let cached_guids: std::collections::HashSet<&str> =
2343            identities.iter().map(|(_, guid)| guid.as_str()).collect();
2344
2345        // Collected in full here, sliced per page later. They sort after every
2346        // cached row, so the two lists form one sequence that the pager walks —
2347        // see the slice below. Collected BEFORE the page is chosen because the
2348        // page count depends on how many there are.
2349        // Bounded like everything else on this page. These come from the PDS
2350        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
2351        // `backend=rust`, whose caps are a quarter of the other's) and are
2352        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
2353        // constrain them at all. The
2354        // cap is generous — a reader with more saved-elsewhere records than this
2355        // is not the case being designed for — but a response has to have a size
2356        // an operator can reason about.
2357        let mut uncached_dropped = 0usize;
2358        match state.repo().list_saved_sorted(&did).await {
2359            Ok(saved) if identities_ok => {
2360                for (rkey, item) in saved {
2361                    let known = cached_urls.contains(item.url.as_str())
2362                        || item
2363                            .entry_id
2364                            .as_deref()
2365                            .is_some_and(|g| cached_guids.contains(g));
2366                    if known {
2367                        continue;
2368                    }
2369                    // And the scope filter applies to these rows too. Without
2370                    // it, `?feed=X` still listed saved records from every other
2371                    // feed — the filter silently did nothing for them.
2372                    if let Some(urls) = &scope_urls {
2373                        match item.feed_url.as_deref() {
2374                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2375                            // A saved record with no `feedUrl` cannot be placed
2376                            // in any feed's scope, so it belongs only to the
2377                            // unfiltered view.
2378                            _ => continue,
2379                        }
2380                    }
2381                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2382                    //
2383                    // `item.url` is attacker-controlled — a saved record written
2384                    // by any client — and it lands in an `href`. Askama escapes
2385                    // HTML metacharacters but not SCHEMES, so `javascript:`
2386                    // survives escaping intact. This project already built the
2387                    // helper for exactly that, and `feed.rs` uses it on the
2388                    // equivalent link; this path was simply not routed through it.
2389                    //
2390                    // The real defect was what a failure DID: it `continue`d, so
2391                    // the row vanished entirely — no badge, no count, nothing —
2392                    // and the only trace was a `debug!` below any realistic
2393                    // filter. That makes the record unremovable FROM HERE, because
2394                    // the un-save button lives on the row; the reader has to open
2395                    // a different atproto client to get rid of it. A bad URL is a
2396                    // reason to withhold the LINK, not the row.
2397                    //
2398                    // The check also moved ABOVE the poll nudge. That is ordering
2399                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2400                    // on the URL being rejected here, and is already gated on the
2401                    // reader actually subscribing to that feed — so it was never
2402                    // reachable by an unusable `item.url`. Deciding whether a
2403                    // record is renderable before doing anything outbound on its
2404                    // behalf is simply the order that stays correct if either of
2405                    // those two facts later stops being true.
2406                    let link = SafeLink::external(&item.url);
2407                    if link.is_empty() {
2408                        warn!(
2409                            %did, %rkey,
2410                            "a saved record has an unusable URL; rendering it without a link \
2411                             so it can still be removed"
2412                        );
2413                    }
2414
2415                    // Opportunistic re-fetch: if the reader still subscribes to
2416                    // the feed, make it due now. If the article is still inside
2417                    // the feed's window the poller caches it normally and this
2418                    // row becomes a real entry on its own — no synthetic rows in
2419                    // the shared cache, which every subscriber would otherwise
2420                    // see as a content-less entry.
2421                    // **Bound the WORK, not just the response.** This check sat
2422                    // after the nudge and the `subs` scan below, so every render
2423                    // still walked all ≤20,000 PDS records, ran a subs-length
2424                    // string scan per record, and issued up to that many
2425                    // `mark_feed_due` round-trips on a 5-connection pool — then
2426                    // discarded everything past the cap. A cap that runs after
2427                    // the expensive part is a cap on the output only.
2428                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2429                        uncached_dropped += 1;
2430                        continue;
2431                    }
2432                    if let Some(feed_url) = item.feed_url.as_deref() {
2433                        if subs.iter().any(|s| s.sub.url == feed_url) {
2434                            // Bounded to one nudge per feed per poll interval —
2435                            // see `mark_feed_due`. Unbounded, a reload loop here
2436                            // becomes outbound amplification.
2437                            let stale_before = (chrono::Utc::now()
2438                                - chrono::Duration::from_std(state.config.poll_interval)
2439                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2440                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2441                            if let Err(err) =
2442                                store::mark_feed_due(pool, feed_url, &stale_before).await
2443                            {
2444                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2445                            }
2446                        }
2447                    }
2448                    uncached.push(EntryRow {
2449                        id: 0,
2450                        title: item
2451                            .title
2452                            .clone()
2453                            .filter(|t| !t.trim().is_empty())
2454                            // Falling back to the URL is fine for a link we are
2455                            // willing to render, and wrong for one we are not:
2456                            // it would put the exact string `safe_link` just
2457                            // rejected into the page as the record's name. The
2458                            // rkey is what the un-save button acts on, so it is
2459                            // the honest identifier for a row that has nothing
2460                            // else trustworthy to show.
2461                            .unwrap_or_else(|| {
2462                                if link.is_empty() {
2463                                    format!("Saved item {rkey}")
2464                                } else {
2465                                    item.url.clone()
2466                                }
2467                            }),
2468                        feed_title: item.feed_url.clone().unwrap_or_default(),
2469                        published: display_date(Some(&item.created_at)),
2470                        read: false,
2471                        starred: true,
2472                        // Empty = "render this row without an anchor". The
2473                        // template branches on it, so the rejected URL never
2474                        // reaches an `href` even as an escaped string.
2475                        link,
2476                        cached: false,
2477                        rkey,
2478                    });
2479                }
2480            }
2481            // Identity lookup was unusable — see the fail-closed note above.
2482            Ok(_) => {}
2483            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2484        }
2485        if uncached_dropped > 0 {
2486            warn!(
2487                %did,
2488                dropped = uncached_dropped,
2489                cap = MAX_UNCACHED_SAVED_ROWS,
2490                "more saved records than this instance will hold in one response; the \
2491                 rest are not reachable from here"
2492            );
2493        }
2494    }
2495
2496    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2497    // PDS records follow them, and the pager walks the concatenation.
2498    //
2499    // The first version appended the uncached rows to the last page only and
2500    // kept them out of `total`, which left everything past a cap invisible AND
2501    // unremovable — the un-save button lives on the row, and there is no other
2502    // surface in the app that lists these. That is the same "unremovable FROM
2503    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2504    // forty lines later by a bound meant to protect memory.
2505    //
2506    // Paging the concatenation makes every record reachable and needs no cap on
2507    // what is RENDERED — one page is one page either way. The version before
2508    // that inflated `total` while clamping on the cached count, which advertised
2509    // a page the clamp could never reach; both numbers come from the same total
2510    // now, which is what makes that impossible rather than merely fixed.
2511    let total_cached =
2512        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2513    let uncached_len = uncached.len();
2514    let total = total_cached + uncached_len as i64;
2515    // Clamped to the range that exists. Past the end the list is empty, and the
2516    // empty state renders instead of the pager — which would strand a reader who
2517    // typed a page number, or who paged to the end and then marked entries read
2518    // out from under their own URL. Showing the last page is the answer to both.
2519    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2520    let offset = (page - 1) * ENTRIES_PER_PAGE;
2521    // Past the cached rows this returns nothing, which is exactly right: the
2522    // page is then made up entirely of uncached ones.
2523    let source = store::list_entries(
2524        pool,
2525        &did,
2526        list_view,
2527        scope_ids.as_deref(),
2528        ENTRIES_PER_PAGE,
2529        offset,
2530    )
2531    .await?;
2532    // **Both halves of the page are computed from the COUNT alone.**
2533    //
2534    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2535    // queries, so they can disagree about how many cached rows exist. Any part of
2536    // the page composition that reads `source.len()` inherits that disagreement.
2537    //
2538    // `cached_allotment` is this page's cached share according to the snapshot,
2539    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2540    // pages tile the uncached list exactly, whichever way the count drifted.
2541    // `source` is then truncated to it only to avoid rendering rows the next page
2542    // will also claim.
2543    //
2544    // The previous version took `skip` from the count but `take` from
2545    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2546    // an un-star or a retention delete landing between the two queries — made
2547    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2548    // putting twenty rows, each carrying the record-DELETING un-save button, on
2549    // two pages at once. The comment claimed that shape was impossible; it was
2550    // merely rarer.
2551    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2552    let cached_here = cached_allotment.min(source.len());
2553    // Only compose when there is something to compose WITH. `uncached` is empty
2554    // on every view but `starred`, and truncating there just drops trailing rows
2555    // that no page then shows — the poller inserting between the COUNT and the
2556    // SELECT was enough to trigger it.
2557    let source = if uncached_len == 0 {
2558        &source[..]
2559    } else {
2560        &source[..cached_here]
2561    };
2562    let uncached_page: Vec<EntryRow> = {
2563        let skip = (offset - total_cached).max(0) as usize;
2564        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2565        uncached.into_iter().skip(skip).take(take).collect()
2566    };
2567    // This page's slice, used only to append below. The heading needs the
2568    // WHOLE-list figure, which is the set's size before slicing.
2569    let uncached_total = uncached_len as i64;
2570
2571    // The scope/view suffix carried onto every entry link (built once).
2572    let entry_scope_qs = {
2573        let mut parts = Vec::new();
2574        if let Some(f) = q.feed.as_deref() {
2575            parts.push(format!("feed={}", qenc(f)));
2576        }
2577        if let Some(f) = q.folder.as_deref() {
2578            parts.push(format!("folder={}", qenc(f)));
2579        }
2580        if view != "unread" {
2581            parts.push(format!("view={}", qenc(&view)));
2582        }
2583        parts.join("&")
2584    };
2585    let entries: Vec<EntryRow> = source
2586        .iter()
2587        .map(|e| EntryRow {
2588            id: e.id,
2589            title: e
2590                .title
2591                .clone()
2592                .filter(|t| !t.trim().is_empty())
2593                .unwrap_or_else(|| "(untitled)".to_string()),
2594            feed_title: feed_title_by_id(e.feed_id),
2595            published: display_date(e.published.as_deref()),
2596            // Both bits ride along on the row's own `entry_state` join now. They
2597            // used to be membership tests against the full unread and starred
2598            // sets, which is why those two lists were fetched in their entirety
2599            // on every render even when the page showed a hundred rows.
2600            read: e.read,
2601            starred: e.starred,
2602            link: SafeLink::entry(e.id, &entry_scope_qs),
2603            cached: true,
2604            rkey: String::new(),
2605        })
2606        .collect();
2607
2608    // The uncached slice for this page follows the cached rows.
2609    let mut entries = entries;
2610    entries.extend(uncached_page);
2611    let entries = entries;
2612
2613    let selected_feed = q.feed.as_deref();
2614    let selected_folder = q.folder.as_deref();
2615
2616    // Build the shared sidebar (folders + loose feeds, with unread counts).
2617    let (folder_views, loose_feeds, _folder_options) =
2618        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2619
2620    // Heading + scope query-string suffix.
2621    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2622        let name = subs
2623            .iter()
2624            .find(|s| s.sub.url == feed_url)
2625            .map(|s| {
2626                display_title(
2627                    s.sub
2628                        .title
2629                        .as_deref()
2630                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2631                    &s.sub.url,
2632                )
2633            })
2634            .unwrap_or_else(|| display_title(None, feed_url));
2635        (name, format!("feed={}", qenc(feed_url)))
2636    } else if let Some(folder_uri) = selected_folder {
2637        let name = folder_views
2638            .iter()
2639            .find(|f| f.uri == folder_uri)
2640            .map(|f| f.name.clone())
2641            .unwrap_or_else(|| "Folder".to_string());
2642        (name, format!("folder={}", qenc(folder_uri)))
2643    } else {
2644        let h = match view.as_str() {
2645            "all" => "All",
2646            "starred" => "Starred",
2647            _ => "Unread",
2648        };
2649        (h.to_string(), String::new())
2650    };
2651
2652    let feed_scope = selected_feed.map(str::to_string);
2653    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2654
2655    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2656    // page number is the only thing appended — which keeps a paged link
2657    // identical to an unpaged one in every other respect.
2658    let page_href = |n: i64| -> String {
2659        let mut parts = Vec::new();
2660        if !entry_scope_qs.is_empty() {
2661            parts.push(entry_scope_qs.clone());
2662        }
2663        if n > 1 {
2664            parts.push(format!("page={n}"));
2665        }
2666        if parts.is_empty() {
2667            "/".to_string()
2668        } else {
2669            format!("/?{}", parts.join("&"))
2670        }
2671    };
2672    let prev_href = (page > 1).then(|| page_href(page - 1));
2673    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2674
2675    let tmpl = IndexTemplate {
2676        card: Card::private(&state.config),
2677        version: VERSION,
2678        repo_url: REPO_URL,
2679        kofi_url: KOFI_URL,
2680        flash: q.flash.unwrap_or_default(),
2681        alert: alert.unwrap_or_default(),
2682        nav,
2683        entries,
2684        heading,
2685        feed_scope,
2686        total,
2687        // Whole-list figure, so it sits beside `total` without double counting.
2688        // The per-page slice is composed above and is not a heading number.
2689        uncached_total,
2690        page,
2691        page_count: page_count_for(total),
2692        prev_href,
2693        next_href,
2694    };
2695    Ok(render(&tmpl))
2696}
2697
2698/// Query for `GET /manage` — carries an optional flash after an action redirect.
2699#[derive(Debug, Deserialize, Default)]
2700struct ManageQuery {
2701    #[serde(default)]
2702    flash: Option<String>,
2703}
2704
2705/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2706/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2707/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2708/// mutation logic of its own.
2709async fn manage(
2710    State(state): State<AppState>,
2711    headers: HeaderMap,
2712    Query(q): Query<ManageQuery>,
2713) -> Result<Response, WebError> {
2714    let user = match current_session(&state, &headers).await {
2715        Some(u) => u,
2716        None => return Ok(Redirect::to("/login").into_response()),
2717    };
2718    let did = user.did.clone();
2719
2720    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2721    let (folder_views, loose_feeds, folder_options) =
2722        build_sidebar(&state, &did, &subs, None, None).await;
2723
2724    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2725    let nav = build_nav(
2726        &user,
2727        "unread",
2728        String::new(),
2729        folder_views.iter().map(clone_folder_view).collect(),
2730        loose_feeds.iter().map(clone_feed_view).collect(),
2731        true,
2732    );
2733
2734    let tmpl = ManageTemplate {
2735        card: Card::private(&state.config),
2736        version: VERSION,
2737        repo_url: REPO_URL,
2738        kofi_url: KOFI_URL,
2739        flash: q.flash.unwrap_or_default(),
2740        alert: alert.unwrap_or_default(),
2741        nav,
2742        folder_options,
2743        folders: folder_views,
2744        loose_feeds,
2745        standard_site: state.config.standard_site,
2746    };
2747    Ok(render(&tmpl))
2748}
2749
2750/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2751/// (`Nav`) and the page body without an extra DB round-trip.
2752fn clone_feed_view(f: &FeedView) -> FeedView {
2753    FeedView {
2754        rkey: f.rkey.clone(),
2755        url: f.url.clone(),
2756        title: f.title.clone(),
2757        unread: f.unread,
2758        selected: f.selected,
2759        folder: f.folder.clone(),
2760    }
2761}
2762
2763fn clone_folder_view(f: &FolderView) -> FolderView {
2764    FolderView {
2765        rkey: f.rkey.clone(),
2766        uri: f.uri.clone(),
2767        name: f.name.clone(),
2768        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2769        selected: f.selected,
2770    }
2771}
2772
2773/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2774/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2775/// unscoped "everything" view. A folder scope takes the feed scope when both are
2776/// somehow present (feed wins, matching the query precedence elsewhere).
2777fn scope_urls_for(
2778    subs: &[ResolvedSub],
2779    feed: Option<&str>,
2780    folder: Option<&str>,
2781) -> Option<Vec<String>> {
2782    if let Some(feed_url) = feed {
2783        Some(vec![feed_url.to_string()])
2784    } else {
2785        folder.map(|folder_uri| {
2786            subs.iter()
2787                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2788                .map(|s| s.sub.url.clone())
2789                .collect()
2790        })
2791    }
2792}
2793
2794/// The `at://` URI for a folder record given the owner DID + rkey.
2795fn folder_uri(did: &str, rkey: &str) -> String {
2796    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2797}
2798
2799/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2800/// DID — the shared source for both the reader index and the rail on every
2801/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2802async fn build_sidebar(
2803    state: &AppState,
2804    did: &str,
2805    subs: &[ResolvedSub],
2806    selected_feed: Option<&str>,
2807    selected_folder: Option<&str>,
2808) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2809    let pool = &state.db;
2810    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2811    // all — purely to `.filter().count()` them in Rust, on every page that
2812    // renders chrome, which made the sidebar the most frequently executed
2813    // instance of the unbounded-projection problem.
2814    let unread_counts = store::unread_counts_by_feed(pool, did)
2815        .await
2816        .unwrap_or_else(|err| {
2817            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2818            Default::default()
2819        });
2820    let folders = state
2821        .repo()
2822        .list_folders_sorted(did)
2823        .await
2824        .unwrap_or_default();
2825
2826    let unread_count = |feed_id: Option<i64>| -> i64 {
2827        feed_id
2828            .and_then(|id| unread_counts.get(&id).copied())
2829            .unwrap_or(0)
2830    };
2831    let mk_feed_view = |s: &ResolvedSub| FeedView {
2832        rkey: s.rkey.clone(),
2833        url: s.sub.url.clone(),
2834        title: display_title(
2835            s.sub
2836                .title
2837                .as_deref()
2838                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2839            &s.sub.url,
2840        ),
2841        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2842        selected: selected_feed == Some(s.sub.url.as_str()),
2843        folder: s.sub.folder.clone(),
2844    };
2845
2846    let mut folder_views = Vec::with_capacity(folders.len());
2847    for (rkey, folder) in &folders {
2848        let uri = folder_uri(did, rkey);
2849        let feeds: Vec<FeedView> = subs
2850            .iter()
2851            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2852            .map(mk_feed_view)
2853            .collect();
2854        folder_views.push(FolderView {
2855            rkey: rkey.clone(),
2856            uri: uri.clone(),
2857            name: folder.name.clone(),
2858            feeds,
2859            selected: selected_folder == Some(uri.as_str()),
2860        });
2861    }
2862
2863    let known_uris: std::collections::HashSet<String> =
2864        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2865    let loose_feeds: Vec<FeedView> = subs
2866        .iter()
2867        .filter(|s| {
2868            s.sub
2869                .folder
2870                .as_deref()
2871                .map(|f| !known_uris.contains(f))
2872                .unwrap_or(true)
2873        })
2874        .map(mk_feed_view)
2875        .collect();
2876
2877    let folder_options: Vec<FolderOption> = folders
2878        .iter()
2879        .map(|(rkey, folder)| FolderOption {
2880            name: folder.name.clone(),
2881            uri: folder_uri(did, rkey),
2882        })
2883        .collect();
2884
2885    (folder_views, loose_feeds, folder_options)
2886}
2887
2888/// Assemble the shared rail [`Nav`] for a chrome page.
2889fn build_nav(
2890    user: &CurrentUser,
2891    view: &str,
2892    scope_qs: String,
2893    folders: Vec<FolderView>,
2894    loose_feeds: Vec<FeedView>,
2895    manage_active: bool,
2896) -> Nav {
2897    Nav {
2898        handle: display_handle(user.handle.as_deref(), &user.did),
2899        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2900        view: view.to_string(),
2901        scope_qs,
2902        folders,
2903        loose_feeds,
2904        manage_active,
2905    }
2906}
2907
2908// ---------------------------------------------------------------------------
2909// Reader: single entry
2910// ---------------------------------------------------------------------------
2911
2912/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2913/// prev/next and "back" stay within the list the reader came from.
2914#[derive(Debug, Deserialize, Default)]
2915struct EntryQuery {
2916    #[serde(default)]
2917    feed: Option<String>,
2918    #[serde(default)]
2919    folder: Option<String>,
2920    #[serde(default)]
2921    view: Option<String>,
2922}
2923
2924/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2925/// within the current reading list.
2926async fn entry_view(
2927    State(state): State<AppState>,
2928    headers: HeaderMap,
2929    Path(id): Path<i64>,
2930    Query(q): Query<EntryQuery>,
2931) -> Result<Response, WebError> {
2932    let user = match current_session(&state, &headers).await {
2933        Some(u) => u,
2934        None => return Ok(Redirect::to("/login").into_response()),
2935    };
2936    let did = user.did.clone();
2937    let pool = &state.db;
2938
2939    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2940    // the per-DID entry gate below authorizes against the caller's current PDS
2941    // subscription set (not another user's cached feeds).
2942    let subs = resolve_subscriptions(&state, &did).await;
2943
2944    let entry = match get_entry_by_id(pool, &did, id).await? {
2945        Some(e) => e,
2946        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2947    };
2948
2949    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2950
2951    let read = entry_is_read(pool, &did, id).await?;
2952    let starred = entry_is_starred(pool, &did, id).await?;
2953
2954    // Reconstruct the current list to compute prev/next, so paging in the reader
2955    // matches what the list showed.
2956    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2957
2958    let back_qs = scope_query(&q);
2959
2960    // Re-sanitize the stored body for this render, through the process-wide
2961    // renderer: size-capped, at most two cleans at once, cached by content
2962    // hash, all off the async runtime (#151).
2963    let content_html = match entry.content_html.clone() {
2964        Some(raw) => Some(BodyRenderer::shared().render(raw).await?),
2965        None => None,
2966    };
2967
2968    let (folder_views, loose_feeds, _) =
2969        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2970    let nav_view = match q.view.as_deref() {
2971        Some("all") => "all",
2972        Some("starred") => "starred",
2973        _ => "unread",
2974    };
2975    let nav = build_nav(
2976        &user,
2977        nav_view,
2978        back_qs.clone(),
2979        folder_views,
2980        loose_feeds,
2981        false,
2982    );
2983
2984    let tmpl = EntryTemplate {
2985        card: Card::private(&state.config),
2986        version: VERSION,
2987        repo_url: REPO_URL,
2988        kofi_url: KOFI_URL,
2989        nav,
2990        id: entry.id,
2991        title: entry
2992            .title
2993            .clone()
2994            .filter(|t| !t.trim().is_empty())
2995            .unwrap_or_else(|| "(untitled)".to_string()),
2996        feed_title,
2997        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2998        published: display_date(entry.published.as_deref()),
2999        url: entry.url.as_deref().and_then(SafeLink::external_opt),
3000        content_html,
3001        read,
3002        starred,
3003        back_qs,
3004        prev_id,
3005        next_id,
3006        oob: false,
3007    };
3008    Ok(render(&tmpl))
3009}
3010
3011/// Compute the prev/next entry ids around `current` within the reader's current
3012/// scope + view, so the reader view can offer keyboard/paging navigation.
3013async fn neighbors_in_scope(
3014    state: &AppState,
3015    did: &str,
3016    q: &EntryQuery,
3017    current: i64,
3018) -> (Option<i64>, Option<i64>) {
3019    let idx_q = IndexQuery {
3020        feed: q.feed.clone(),
3021        folder: q.folder.clone(),
3022        view: q.view.clone(),
3023        // Neighbours span the whole list, not the page the reader arrived from.
3024        page: None,
3025        flash: None,
3026    };
3027    let ids = list_entry_ids(state, did, &idx_q).await;
3028    let pos = ids.iter().position(|&x| x == current);
3029    match pos {
3030        Some(p) => {
3031            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
3032            let next = ids.get(p + 1).copied();
3033            (prev, next)
3034        }
3035        None => (None, None),
3036    }
3037}
3038
3039/// The ordered entry ids for a scope + view — the same ordering `index` renders,
3040/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
3041async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
3042    let pool = &state.db;
3043    let subs = resolve_subscriptions(state, did).await;
3044
3045    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
3046
3047    // Ids only, and bounded. This used to fetch whole entries — bodies included
3048    // — for all three views and then throw everything but `id` away; the "all"
3049    // branch additionally ran one unbounded query PER FEED and sorted the union
3050    // in memory. Scope is now a feed-id restriction inside the query, so the
3051    // database does the filtering and the ordering exactly once.
3052    store::list_entry_ids(
3053        pool,
3054        did,
3055        list_view_of(q.view.as_deref()),
3056        scoped_feed_ids(&subs, &scope_urls).as_deref(),
3057        PREV_NEXT_MAX,
3058    )
3059    .await
3060    .unwrap_or_else(|err| {
3061        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3062        Vec::new()
3063    })
3064}
3065
3066/// Map the `?view=` query value onto the store's list view. Anything
3067/// unrecognised is the unread default, matching `index`.
3068fn list_view_of(view: Option<&str>) -> store::ListView {
3069    match view {
3070        Some("all") => store::ListView::All,
3071        Some("starred") => store::ListView::Starred,
3072        _ => store::ListView::Unread,
3073    }
3074}
3075
3076/// Translate a feed/folder scope into the feed ids to restrict a list query to.
3077///
3078/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
3079/// matched no local feed, which must return nothing rather than everything — so
3080/// the empty vec is deliberately preserved, not collapsed back into `None`.
3081fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3082    let urls = scope_urls.as_ref()?;
3083    Some(
3084        subs.iter()
3085            .filter(|s| urls.contains(&s.sub.url))
3086            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3087            .collect(),
3088    )
3089}
3090
3091/// Build a `?…` query string that preserves the reading scope + view for links.
3092fn scope_query(q: &EntryQuery) -> String {
3093    let mut parts = Vec::new();
3094    if let Some(f) = q.feed.as_deref() {
3095        parts.push(format!("feed={}", qenc(f)));
3096    }
3097    if let Some(f) = q.folder.as_deref() {
3098        parts.push(format!("folder={}", qenc(f)));
3099    }
3100    if let Some(v) = q.view.as_deref() {
3101        if v != "unread" {
3102            parts.push(format!("view={}", qenc(v)));
3103        }
3104    }
3105    parts.join("&")
3106}
3107
3108// ---------------------------------------------------------------------------
3109// Mark read / unread
3110// ---------------------------------------------------------------------------
3111
3112/// Form body for `POST /entries/:id/read`.
3113#[derive(Debug, Deserialize)]
3114struct ReadForm {
3115    #[serde(default)]
3116    read: Option<String>,
3117}
3118
3119/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
3120async fn mark_read(
3121    State(state): State<AppState>,
3122    Path(id): Path<i64>,
3123    headers: HeaderMap,
3124    Form(form): Form<ReadForm>,
3125) -> Result<Response, WebError> {
3126    let did = match current_did(&state, &headers).await {
3127        Some(d) => d,
3128        None => return Ok(Redirect::to("/login").into_response()),
3129    };
3130    let pool = &state.db;
3131
3132    let read = matches!(
3133        form.read.as_deref(),
3134        Some("true") | Some("1") | Some("on") | None
3135    );
3136
3137    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3138    // mutation: `mark_read` only writes when `did` subscribes to the entry's
3139    // feed. A non-subscriber gets a 404, never a mutation of someone else's
3140    // (or the shared cache's) state.
3141    resolve_subscriptions(&state, &did).await;
3142    if !store::mark_read(pool, &did, id, read).await? {
3143        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3144    }
3145
3146    if !is_htmx(&headers) {
3147        return Ok(Redirect::to("/").into_response());
3148    }
3149
3150    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
3151    // in the DOM), so its button's hidden value + aria-pressed update in place
3152    // and a second keypress can reverse the toggle. The list view swaps the row.
3153    if is_reader_request(&headers) {
3154        let starred = entry_is_starred(pool, &did, id).await?;
3155        return Ok(render(&EntryActionBarTemplate {
3156            id,
3157            read,
3158            starred,
3159            oob: true,
3160        }));
3161    }
3162
3163    let row = build_entry_row(pool, &did, id, Some(read)).await?;
3164    match row {
3165        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3166        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3167    }
3168}
3169
3170// ---------------------------------------------------------------------------
3171// Star / save
3172// ---------------------------------------------------------------------------
3173
3174/// Form body for `POST /entries/:id/star`.
3175#[derive(Debug, Deserialize)]
3176struct StarForm {
3177    #[serde(default)]
3178    starred: Option<String>,
3179}
3180
3181/// `POST /entries/:id/star` — star/unstar an entry.
3182///
3183/// Sets the local `starred` bit (fast working copy) and writes/removes a
3184/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
3185/// owning). The PDS write is best-effort — the local star still lands.
3186async fn toggle_star(
3187    State(state): State<AppState>,
3188    Path(id): Path<i64>,
3189    headers: HeaderMap,
3190    Form(form): Form<StarForm>,
3191) -> Result<Response, WebError> {
3192    let did = match current_did(&state, &headers).await {
3193        Some(d) => d,
3194        None => return Ok(Redirect::to("/login").into_response()),
3195    };
3196    let pool = &state.db;
3197
3198    let starred = matches!(
3199        form.starred.as_deref(),
3200        Some("true") | Some("1") | Some("on") | None
3201    );
3202
3203    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3204    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
3205    // feed. A non-subscriber gets a 404, never a mutation.
3206    resolve_subscriptions(&state, &did).await;
3207    if !store::mark_starred(pool, &did, id, starred).await? {
3208        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3209    }
3210
3211    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
3212    // to the caller's subscriptions, so this only ever acts on the caller's feed.
3213    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3214        let entry_url = entry.url.clone().unwrap_or_default();
3215        if !entry_url.is_empty() {
3216            if starred {
3217                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3218                saved.title = entry.title.clone();
3219                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3220                saved.entry_id = Some(entry.guid.clone());
3221                match state.repo().add_saved(&did, &saved).await {
3222                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3223                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3224                }
3225            } else {
3226                // Un-star: find and delete the matching saved record by URL.
3227                match state.repo().list_saved(&did).await {
3228                    Ok(records) => {
3229                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3230                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3231                                warn!(%err, %did, %rkey, "PDS saved delete failed");
3232                            }
3233                        }
3234                    }
3235                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3236                }
3237            }
3238        }
3239    }
3240
3241    if !is_htmx(&headers) {
3242        return Ok(Redirect::to("/").into_response());
3243    }
3244
3245    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
3246    if is_reader_request(&headers) {
3247        let read = entry_is_read(pool, &did, id).await?;
3248        return Ok(render(&EntryActionBarTemplate {
3249            id,
3250            read,
3251            starred,
3252            oob: true,
3253        }));
3254    }
3255
3256    let row = build_entry_row(pool, &did, id, None).await?;
3257    match row {
3258        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3259        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3260    }
3261}
3262
3263/// The feed URL for a cached feed id, if the row exists.
3264async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3265    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3266        .bind(feed_id)
3267        .fetch_optional(pool)
3268        .await
3269        .ok()
3270        .flatten()
3271}
3272
3273// ---------------------------------------------------------------------------
3274// Mark-all-read
3275// ---------------------------------------------------------------------------
3276
3277/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
3278/// absent means mark everything read.
3279#[derive(Debug, Deserialize, Default)]
3280struct ReadAllQuery {
3281    #[serde(default)]
3282    feed: Option<String>,
3283}
3284
3285/// `POST /read-all` — mark every entry read for the current DID, optionally
3286/// scoped to one feed (mark-all-read per feed or globally).
3287async fn mark_all_read(
3288    State(state): State<AppState>,
3289    headers: HeaderMap,
3290    Query(q): Query<ReadAllQuery>,
3291) -> Result<Response, WebError> {
3292    let did = match current_did(&state, &headers).await {
3293        Some(d) => d,
3294        None => return Ok(Redirect::to("/login").into_response()),
3295    };
3296    let pool = &state.db;
3297
3298    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
3299    // only ever touch feeds this DID actually subscribes to.
3300    resolve_subscriptions(&state, &did).await;
3301
3302    if let Some(feed_url) = q.feed.as_deref() {
3303        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3304            store::mark_feed_read(pool, &did, feed.id, true).await?;
3305        }
3306        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3307    }
3308
3309    // Global: mark every subscribed feed read. Fan out over the DID's feeds
3310    // (bounded by the per-DID subscription cap) using the batched per-feed path,
3311    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
3312    // state, but O(feeds) statements instead of O(unread entries).
3313    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3314        store::mark_feed_read(pool, &did, feed_id, true).await?;
3315    }
3316    Ok(Redirect::to("/").into_response())
3317}
3318
3319// ---------------------------------------------------------------------------
3320// Subscribe by URL
3321// ---------------------------------------------------------------------------
3322
3323/// Flash for a URL this instance cannot store as a feed — not private, just
3324/// not a kind of feed it supports (an `at://` publication with
3325/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
3326/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
3327/// false promise for a record that may already exist in the user's PDS.
3328const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3329    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3330
3331/// Shown when an OPML export is refused because the subscription list could not
3332/// be read in full.
3333///
3334/// **An empty export is worse than no export.** This path used to
3335/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
3336/// file — a blank backup, handed over at the moment the reader reached for one.
3337const EXPORT_INCOMPLETE_REFUSAL: &str =
3338    "Could not read your subscriptions in full, so nothing was exported. Your \
3339     feeds are unchanged — try again, and if it keeps failing the list may be \
3340     larger than this reader can page through.";
3341
3342/// Refusal message shown when a private/paid feed is submitted. FeatherReader
3343/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
3344/// only for now — a private feed's secret URL is never saved, fetched, or sent
3345/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
3346/// and the boot-smoke can assert on it.
3347const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3348    FeatherReader stores your subscriptions in your public PDS, so it supports public \
3349    feeds for now — private-feed support arrives when atproto's private data \
3350    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3351
3352/// Form body for `POST /subscriptions`.
3353#[derive(Debug, Deserialize)]
3354struct SubscribeForm {
3355    url: String,
3356    /// Optional folder `at://` URI to file the new feed under.
3357    #[serde(default)]
3358    folder: Option<String>,
3359}
3360
3361/// The DID-form URL to store for a pasted `at://` publication, or the flash
3362/// to refuse it with.
3363///
3364/// - The scheme is canonicalised: `At://` is the same publication, and
3365///   storing a second spelling makes a second row for it (#183).
3366/// - It must name a `site.standard.publication`; anything else is not a feed
3367///   this instance can read.
3368/// - A handle is resolved to its DID: a handle is a mutable name, and
3369///   `feeds.url` is keyed on identity, so only the DID form is stored.
3370async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3371    let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3372    let canonical = format!(
3373        "{}{}",
3374        crate::atproto::AT_URI_PREFIX,
3375        &input[crate::atproto::AT_URI_PREFIX.len()..]
3376    );
3377    let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3378    if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3379        return Err(unsupported());
3380    }
3381    let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3382        uri.authority.clone()
3383    } else {
3384        let handle =
3385            // Validated as a handle before it is sent anywhere: an authority
3386            // that is neither a DID nor a handle (`did:plc:TOOSHORT`, an
3387            // uppercase DID, a newline) is unsupported, not a lookup (found in
3388            // review).
3389            crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3390        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3391            .await
3392            .map_err(|err| {
3393                warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3394                format!("Couldn't resolve the handle {} to an account.", uri.authority)
3395            })?
3396    };
3397    let url = format!(
3398        "{}{did}/{}/{}",
3399        crate::atproto::AT_URI_PREFIX,
3400        uri.collection,
3401        uri.rkey
3402    );
3403    if !feed::is_storable_feed_url(&url, true) {
3404        return Err(unsupported());
3405    }
3406    Ok(url)
3407}
3408
3409/// `POST /subscriptions` — subscribe by URL.
3410async fn add_subscription(
3411    State(state): State<AppState>,
3412    headers: HeaderMap,
3413    Form(form): Form<SubscribeForm>,
3414) -> Result<Response, WebError> {
3415    let did = match current_did(&state, &headers).await {
3416        Some(d) => d,
3417        None => return Ok(Redirect::to("/login").into_response()),
3418    };
3419    let pool = &state.db;
3420    let input = form.url.trim().to_string();
3421    if input.is_empty() {
3422        return Ok(Redirect::to("/").into_response());
3423    }
3424
3425    // Per-DID subscription cap: bound one account's storage/poller footprint on
3426    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3427    // can't even trigger an outbound request. `<= 0` disables the cap.
3428    let cap = state.config.max_subs_per_did;
3429    if cap > 0 {
3430        match store::count_subscriptions_for_did(pool, &did).await {
3431            Ok(n) if n >= cap => {
3432                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3433                return Ok(Redirect::to(&format!(
3434                    "/?flash={}",
3435                    qenc(&format!(
3436                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3437                    ))
3438                ))
3439                .into_response());
3440            }
3441            Ok(_) => {}
3442            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3443        }
3444    }
3445
3446    // **An at:// paste is a standard.site publication, read by the poller
3447    // since 0.4.0**: canonicalised and resolved to its DID form here, then it
3448    // joins the ordinary path below. With the flag off it is refused as it
3449    // always was — the flag gates what may be stored.
3450    let is_at_uri = input
3451        .get(..crate::atproto::AT_URI_PREFIX.len())
3452        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3453    let publication_url = if is_at_uri {
3454        if !state.config.standard_site {
3455            info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3456            return Ok(
3457                Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3458                    .into_response(),
3459            );
3460        }
3461        match publication_url_from_paste(&state, &input).await {
3462            Ok(url) => Some(url),
3463            Err(flash) => {
3464                info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3465                return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3466            }
3467        }
3468    } else {
3469        None
3470    };
3471
3472    if let feed::FeedPrivacy::Private(reason) =
3473        feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3474    {
3475        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3476        return Ok(
3477            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3478        );
3479    }
3480
3481    let resolved = match publication_url {
3482        Some(url) => Ok(url),
3483        None => resolve_feed_url(&state.config, &input).await,
3484    };
3485    let feed_url = match resolved {
3486        Ok(u) => u,
3487        Err(err) => {
3488            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3489            return Ok(Redirect::to(&format!(
3490                "/?flash={}",
3491                qenc("Couldn't find a feed at that URL")
3492            ))
3493            .into_response());
3494        }
3495    };
3496
3497    // Defensive: resolution may have discovered a feed URL that itself carries a
3498    // secret (e.g. a public site page linking a tokened feed). Re-check the
3499    // resolved URL and refuse before storing/writing anything.
3500    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3501        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3502        return Ok(
3503            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3504        );
3505    }
3506
3507    // The URL about to be STORED is what must be storable — not the one the
3508    // user typed. Autodiscovery already yields only http(s), but this is the
3509    // path that writes the row and the PDS record, so the check lives here too:
3510    // the same gate the OPML and rename paths apply, on the same terms.
3511    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3512        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3513        return Ok(
3514            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3515                .into_response(),
3516        );
3517    }
3518
3519    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3520    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3521    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3522    let feeds_cap = state.config.max_feeds_global;
3523    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3524        match store::count_feeds(pool).await {
3525            Ok(n) if n >= feeds_cap => {
3526                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3527                return Ok(Redirect::to(&format!(
3528                    "/?flash={}",
3529                    qenc(
3530                        "This instance is at its feed capacity right now. Please try again later."
3531                    )
3532                ))
3533                .into_response());
3534            }
3535            Ok(_) => {}
3536            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3537        }
3538    }
3539
3540    store::upsert_feed(
3541        pool,
3542        &store::NewFeed {
3543            url: feed_url.clone(),
3544            ..Default::default()
3545        },
3546    )
3547    .await?;
3548
3549    if let Ok(client) = feed::build_client() {
3550        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3551            match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3552                Ok(outcome) => {
3553                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3554                    // **This path is not the scheduler, so it must settle the
3555                    // error columns itself.** `poll_feed` writes validators and
3556                    // `last_polled` and nothing else.
3557                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3558                }
3559                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3560            }
3561        }
3562    }
3563
3564    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3565    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3566        sub.title = feed_row.title.clone();
3567        sub.site_url = feed_row.site_url.clone();
3568    }
3569    sub.folder = form
3570        .folder
3571        .map(|f| f.trim().to_string())
3572        .filter(|f| !f.is_empty());
3573
3574    match state.repo().add_subscription(&did, &sub).await {
3575        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3576        Err(err) => {
3577            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3578        }
3579    }
3580
3581    Ok(Redirect::to("/").into_response())
3582}
3583
3584/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3585async fn delete_subscription(
3586    State(state): State<AppState>,
3587    headers: HeaderMap,
3588    Path(rkey): Path<String>,
3589) -> Result<Response, WebError> {
3590    let did = match current_did(&state, &headers).await {
3591        Some(d) => d,
3592        None => return Ok(Redirect::to("/login").into_response()),
3593    };
3594    match state.repo().remove_subscription(&did, &rkey).await {
3595        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3596        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3597    }
3598    Ok(Redirect::to("/").into_response())
3599}
3600
3601/// Form body for `POST /subscriptions/:rkey/rename`.
3602#[derive(Debug, Deserialize)]
3603struct RenameSubForm {
3604    url: String,
3605    #[serde(default)]
3606    title: Option<String>,
3607    #[serde(default)]
3608    site_url: Option<String>,
3609    #[serde(default)]
3610    folder: Option<String>,
3611    /// What the `url` input held when the page was rendered. With the `seen_*`
3612    /// fields the handler tells what the reader CHANGED from what they merely
3613    /// saw: every input is always posted, so its value alone cannot (#149).
3614    /// Absent (a hand-made POST, or a page from an older build), the handler's
3615    /// first read stands in for it.
3616    #[serde(default)]
3617    seen_url: Option<String>,
3618    /// What the title input was pre-filled with — the DISPLAY title, which
3619    /// falls back to the cached feed title or the URL for an untitled record.
3620    #[serde(default)]
3621    seen_title: Option<String>,
3622    /// The folder the select was pre-selected with (`""` for none). Posted
3623    /// only when the select is, so the two are present or absent together —
3624    /// and both absent means the reader never saw a folder to change.
3625    #[serde(default)]
3626    seen_folder: Option<String>,
3627}
3628
3629/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3630/// folder, rewriting the whole subscription record via `putRecord`.
3631async fn rename_subscription(
3632    State(state): State<AppState>,
3633    headers: HeaderMap,
3634    Path(rkey): Path<String>,
3635    Form(form): Form<RenameSubForm>,
3636) -> Result<Response, WebError> {
3637    let did = match current_did(&state, &headers).await {
3638        Some(d) => d,
3639        None => return Ok(Redirect::to("/login").into_response()),
3640    };
3641    let feed_url = form.url.trim().to_string();
3642
3643    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3644    // write a junk row to the cache or a malformed subscription record to the
3645    // PDS (add_subscription refuses an empty input the same way).
3646    if feed_url.is_empty() {
3647        return Ok(Redirect::to("/").into_response());
3648    }
3649
3650    // **A compare-and-swap, retried once (#149).** Each attempt reads the
3651    // record with its CID and writes with `swapRecord` set to it, so another
3652    // atproto client's write between the two is refused by the PDS rather than
3653    // erased by our whole-record put. A refused attempt reads again and
3654    // re-applies the form's fields — and only those — to the FRESH record,
3655    // re-running every gate against it. A second refusal is reported as a
3656    // conflict, never as success: a record that keeps moving is being edited
3657    // somewhere, and the reader is the one to decide which edit wins.
3658    //
3659    // **A retry MERGES; it does not replay the form.** Every input is always
3660    // posted, so replaying the form on the fresh record would put back each
3661    // field the reader never touched — a URL another client repointed, a title
3662    // another client changed. `base` is the record as first read, and each
3663    // attempt applies only what the reader changed relative to it; see
3664    // [`merge_rename`].
3665    let mut base: Option<Subscription> = None;
3666    for attempt in 1..=RENAME_ATTEMPTS {
3667        match rename_subscription_once(&state, &did, &rkey, &form, &mut base).await? {
3668            RenameAttempt::Done(resp) => return Ok(resp),
3669            RenameAttempt::Raced => {
3670                info!(%did, %rkey, attempt, "subscription changed between read and write; re-reading");
3671            }
3672        }
3673    }
3674    warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused rename: the subscription kept changing elsewhere");
3675    Ok(rename_conflict_response())
3676}
3677
3678/// The answer to a rename that conflicts with another client's write: nothing
3679/// was written, and the reader decides which edit wins.
3680fn rename_conflict_response() -> Response {
3681    Redirect::to(&format!(
3682        "/?flash={}",
3683        qenc(
3684            "This subscription was changed elsewhere while you were editing it — \
3685             nothing was renamed or moved. Reload and try again."
3686        )
3687    ))
3688    .into_response()
3689}
3690
3691/// Trim, and read an empty value as absent — how the form's optional fields
3692/// have always been taken.
3693fn form_value(v: Option<&str>) -> Option<String> {
3694    v.map(str::trim)
3695        .filter(|t| !t.is_empty())
3696        .map(str::to_string)
3697}
3698
3699/// The record a rename should write, from [`merge_rename`].
3700#[derive(Debug, PartialEq, Eq)]
3701struct MergedRename {
3702    /// The record to write.
3703    sub: Subscription,
3704    /// Whether THIS write repoints the subscription to a different feed URL.
3705    repoint: bool,
3706    /// Every field the reader changed already holds the reader's value — a
3707    /// double-submitted Save whose first request landed. Nothing to write.
3708    already_saved: bool,
3709}
3710
3711/// Which field the reader and another client both changed, differently.
3712#[derive(Debug, PartialEq, Eq)]
3713struct RenameConflict(&'static str);
3714
3715/// Apply the reader's edits to `fresh`: a three-way merge of the form against
3716/// `base`, the record as the handler FIRST read it (#149). Returns the record
3717/// to write and whether the reader repointed it to a different URL.
3718///
3719/// For each field the form carries (`url`, `title`, `folder`, `site_url`):
3720///
3721/// - **the reader did not change it** — the posted value equals the value the
3722///   input was pre-filled with (`seen_*`, or `base` when the form lacks it) —
3723///   so `fresh`'s value stands, whoever wrote it;
3724/// - **the reader changed it, and `fresh` still has `base`'s value** — the
3725///   reader's value is applied;
3726/// - **the reader changed it, and `fresh` already holds the reader's value** —
3727///   both made the same edit (or a double-submitted Save landed first): no
3728///   conflict, nothing to write for that field;
3729/// - **the reader changed it, and so did someone else, differently** — a
3730///   conflict; nothing is written.
3731///
3732/// A field whose input the page did not render (the folder select, without
3733/// folders to list) is untouched by the reader.
3734///
3735/// On the first attempt `fresh` IS `base`, so the only question is what the
3736/// reader changed. The repoint semantics — dropping `siteUrl` and `fetchHint`
3737/// as properties of the old feed — follow the READER's change, never the
3738/// difference between the form and a record another client moved.
3739///
3740/// **What `seen_*` closes, and what it leaves.** Without it, a field another
3741/// client changed between page load and the first read (so no swap fails)
3742/// read as the reader's change — the stale hidden URL repointed the record
3743/// back. With it, an untouched field is never written. A field BOTH changed
3744/// in that window is still last-writer-wins: the conflict check compares
3745/// against the first read, not the page-load record, because `seen_title` is
3746/// the display title (a fallback for an untitled record), not the record's.
3747fn merge_rename(
3748    form: &RenameSubForm,
3749    base: &Subscription,
3750    fresh: Subscription,
3751) -> Result<MergedRename, RenameConflict> {
3752    let mut sub = fresh;
3753    // Fields the reader changed, and how many of those still need writing:
3754    // a change `fresh` already holds — both sides made the same edit, or this
3755    // is a double-submitted Save whose first request landed — is agreement,
3756    // not a conflict, and there is nothing to write for it.
3757    let mut edited = 0;
3758    let mut to_write = 0;
3759
3760    let posted_url = form.url.trim();
3761    let seen_url = form.seen_url.as_deref().unwrap_or(&base.url).trim();
3762    let mut repoint = false;
3763    if posted_url != seen_url {
3764        edited += 1;
3765        if sub.url.trim() == posted_url {
3766            // Already there. Not a repoint by THIS write, so the fresh
3767            // record's siteUrl and fetchHint — perhaps the new feed's — stay.
3768        } else if sub.url.trim() != base.url.trim() {
3769            return Err(RenameConflict("url"));
3770        } else {
3771            to_write += 1;
3772            repoint = true;
3773        }
3774    }
3775    // Like for like: a record another client wrote may carry padding.
3776    sub.url = if repoint { posted_url } else { sub.url.trim() }.to_string();
3777
3778    let posted_title = form_value(form.title.as_deref());
3779    let seen_title = match form.seen_title.as_deref() {
3780        Some(seen) => form_value(Some(seen)),
3781        None => base.title.clone(),
3782    };
3783    if posted_title != seen_title {
3784        edited += 1;
3785        if sub.title == posted_title {
3786            // Already there.
3787        } else if sub.title != base.title {
3788            return Err(RenameConflict("title"));
3789        } else {
3790            to_write += 1;
3791            sub.title = posted_title;
3792        }
3793    }
3794
3795    // **The folder select is conditional; absent, the reader never saw a
3796    // folder.** The manage row renders it — and `seen_folder` with it — only
3797    // when it has folders to list, which a reader without folders, or a page
3798    // whose folder listing failed, does not. Posted with neither, the folder
3799    // is untouched; reading the absence as "no folder" un-foldered every
3800    // subscription retitled from such a page. (The title input is always
3801    // rendered, so an absent title keeps its old meaning.)
3802    if form.folder.is_some() || form.seen_folder.is_some() {
3803        let posted_folder = form_value(form.folder.as_deref());
3804        let seen_folder = match form.seen_folder.as_deref() {
3805            Some(seen) => form_value(Some(seen)),
3806            None => base.folder.clone(),
3807        };
3808        if posted_folder != seen_folder {
3809            edited += 1;
3810            if sub.folder == posted_folder {
3811                // Already there.
3812            } else if sub.folder != base.folder {
3813                return Err(RenameConflict("folder"));
3814            } else {
3815                to_write += 1;
3816                sub.folder = posted_folder;
3817            }
3818        }
3819    }
3820
3821    // `createdAt` and `private` carry over untouched — neither is a property
3822    // of which feed URL the subscription points at.
3823    //
3824    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3825    // repoint drops them rather than leaving a site link for the old feed
3826    // hanging off the new one. The manage row does not post `site_url`; a
3827    // value that is posted and differs from the base is an edit like any
3828    // other.
3829    match form_value(form.site_url.as_deref()) {
3830        Some(site) if Some(&site) != base.site_url.as_ref() => {
3831            edited += 1;
3832            if sub.site_url.as_ref() == Some(&site) {
3833                // Already there.
3834            } else if sub.site_url != base.site_url {
3835                return Err(RenameConflict("siteUrl"));
3836            } else {
3837                to_write += 1;
3838                sub.site_url = Some(site);
3839            }
3840        }
3841        Some(_) => {}
3842        None if repoint => sub.site_url = None,
3843        None => {}
3844    }
3845    if repoint {
3846        sub.fetch_hint = None;
3847    }
3848    Ok(MergedRename {
3849        sub,
3850        repoint,
3851        // A form with no edits is not "already saved": it writes, as it always
3852        // has — it is the reader asking for exactly this record.
3853        already_saved: edited > 0 && to_write == 0,
3854    })
3855}
3856
3857/// How many times [`rename_subscription`] and [`rename_folder`] read and
3858/// write before giving up on a record that keeps changing: the first try and
3859/// one retry.
3860const RENAME_ATTEMPTS: u32 = 2;
3861
3862/// The outcome of one read-then-write of a rename.
3863enum RenameAttempt {
3864    /// Answered: renamed, refused by a gate, or failed for a reason a re-read
3865    /// cannot fix.
3866    Done(Response),
3867    /// The PDS refused the write with `InvalidSwap`: the record moved after
3868    /// this attempt read it. Nothing was written.
3869    Raced,
3870}
3871
3872/// One attempt at [`rename_subscription`]: read the record and its CID, apply
3873/// the form to it, and write it back on the condition that it is still at
3874/// that CID.
3875///
3876/// `base` is the record as the FIRST attempt read it; this sets it on that
3877/// attempt, and every attempt merges against it — see [`merge_rename`].
3878async fn rename_subscription_once(
3879    state: &AppState,
3880    did: &str,
3881    rkey: &str,
3882    form: &RenameSubForm,
3883    base: &mut Option<Subscription>,
3884) -> Result<RenameAttempt, WebError> {
3885    use RenameAttempt::Done;
3886
3887    // **Read before write — `update_subscription` is a `putRecord`, and a
3888    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3889    //
3890    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3891    // and hand that over, so every field the form does not carry was written
3892    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3893    // `folder` — and nothing else — so a rename silently destroyed four fields:
3894    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3895    //
3896    // `createdAt` is the one that matters most: it is the reader's subscribe
3897    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3898    // repo rather than our cache, and once overwritten it is gone with nothing
3899    // in the UI to say so.
3900    //
3901    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3902    // and filters. That is one extra round trip on an action that is already
3903    // doing a PDS write, and it is bounded. A `get_subscription` was weighed
3904    // for #149 and not added: the sidecar has no `get` action, so it would be
3905    // new surface on the backend being retired, and the listing already
3906    // carries each record's CID.
3907    //
3908    // **A failed read refuses the rename.** Falling back to the old
3909    // rebuild-from-scratch here would reinstate the data loss on exactly the
3910    // flaky path, which is the worst place to have it. The write below already
3911    // takes this stance — "a failure here means nothing was renamed or moved" —
3912    // and the read gets the same one.
3913    //
3914    // **The CID comes with the record**, and the write below names it: that is
3915    // the whole compare-and-swap (#149).
3916    let found = match state.repo().list_subscriptions_with_cids(did).await {
3917        Ok(subs) => subs
3918            .into_iter()
3919            .find(|(k, _, _)| k == rkey)
3920            .map(|(_, cid, s)| (cid, s)),
3921        Err(err) => {
3922            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3923            return Ok(Done(
3924                Redirect::to(&format!(
3925                    "/?flash={}",
3926                    qenc("Could not reach your PDS — nothing was renamed or moved.")
3927                ))
3928                .into_response(),
3929            ));
3930        }
3931    };
3932    let Some((read_cid, fresh)) = found else {
3933        // The rkey is not in the reader's repo. Renaming a record that is not
3934        // there would CREATE one, which is not what "rename" means and would
3935        // give it a fresh `createdAt` — the bug this read exists to prevent.
3936        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3937        return Ok(Done(
3938            Redirect::to(&format!(
3939                "/?flash={}",
3940                qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3941            ))
3942            .into_response(),
3943        ));
3944    };
3945
3946    // The subscription can be repointed at a different feed URL. **Every gate
3947    // on the URL applies to a repoint and only a repoint** — the three below
3948    // were each, at one time, run before this line on the URL as posted, and
3949    // each refused a pure retitle of a record that already existed:
3950    //
3951    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3952    //   at-URI that is not a publication (a feed generator another client
3953    //   subscribed to), so the record became un-editable with a flash saying
3954    //   it "was not saved or sent anywhere";
3955    // - the global feeds ceiling keyed on "URL not in the cache", and an
3956    //   at:// record is never cached with the flag off, so at capacity a
3957    //   retitle was refused for a row the handler would not insert;
3958    // - storability, the same way.
3959    //
3960    // An unchanged URL is already in the reader's repo; refusing to retitle
3961    // it protects nothing and takes their own record away from them.
3962    // Like for like: the form value is trimmed, and a record another client
3963    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3964    //
3965    // Whether this IS a repoint is the reader's change, from the merge — not
3966    // the form against a record another client may have moved (#149).
3967    let base = base.get_or_insert_with(|| fresh.clone());
3968    let MergedRename {
3969        sub,
3970        repoint: url_changed,
3971        already_saved,
3972    } = match merge_rename(form, base, fresh) {
3973        Ok(merged) => merged,
3974        Err(RenameConflict(field)) => {
3975            warn!(%did, %rkey, field, "refused rename: the reader and another client both changed the same field");
3976            return Ok(Done(rename_conflict_response()));
3977        }
3978    };
3979    // Every change the reader made is already in the record: a Save submitted
3980    // twice, whose first request landed. Success, with nothing to write — and
3981    // no cache write either, since the request that wrote it made that too.
3982    if already_saved {
3983        info!(%did, %rkey, "rename already in the record; nothing to write");
3984        return Ok(Done(Redirect::to("/").into_response()));
3985    }
3986    let feed_url = sub.url.clone();
3987
3988    // **Storability, on the same terms as the add and OPML paths — for a
3989    // REPOINT, and FIRST.** A target this instance cannot store gets that
3990    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3991    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3992    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3993    // here; a review found it by enumerating every writer of the table. The
3994    // first fix ran this check before the repo lookup, on the URL as posted —
3995    // which refused a pure retitle of a subscription that already IS an
3996    // at-URI, on every instance with the flag off. The flag gates what the
3997    // cache may store, not whether a reader may edit their own record: an
3998    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3999    // row below.
4000    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
4001    if url_changed && !storable {
4002        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
4003        return Ok(Done(
4004            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
4005                .into_response(),
4006        ));
4007    }
4008
4009    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
4010    // and rename both upserts it to the local cache AND rewrites the PDS
4011    // subscription record (a public `putRecord`), so without this guard a
4012    // crafted rename could land a secret-bearing URL in the public PDS — the
4013    // exact leak the add and OPML paths already prevent.
4014    if url_changed {
4015        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
4016            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
4017            return Ok(Done(
4018                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
4019            ));
4020        }
4021    }
4022
4023    // Global feeds ceiling parity with add_subscription: a repoint to a
4024    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
4025    // shared cache is at capacity (an existing/duplicate URL adds no row and
4026    // is always fine). `<= 0` disables.
4027    let feeds_cap = state.config.max_feeds_global;
4028    if url_changed
4029        && feeds_cap > 0
4030        && store::get_feed_by_url(&state.db, &feed_url)
4031            .await?
4032            .is_none()
4033    {
4034        match store::count_feeds(&state.db).await {
4035            Ok(n) if n >= feeds_cap => {
4036                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
4037                return Ok(Done(
4038                    Redirect::to(&format!(
4039                        "/?flash={}",
4040                        qenc(
4041                            "This instance is at its feed capacity right now. Please try again later."
4042                        )
4043                    ))
4044                    .into_response(),
4045                ));
4046            }
4047            Ok(_) => {}
4048            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
4049        }
4050    }
4051
4052    // **The PDS write decides what the reader is told.**
4053    //
4054    // This used to `warn!` on failure and then redirect exactly as it does on
4055    // success, so a rename that did not happen was indistinguishable from one
4056    // that did — the reader saw their old title come back and had no reason to
4057    // think anything had gone wrong. The PDS record IS the subscription; a
4058    // failure here means nothing was renamed or moved.
4059    //
4060    // **Conditional on the CID read above (#149).** A listing with no CID is
4061    // a PDS outside the lexicon (`listRecords` requires one); the write then
4062    // goes unconditionally, as every write did before this, and says so.
4063    if read_cid.is_none() {
4064        warn!(%did, %rkey, "the PDS listed this subscription without a CID; renaming without a compare-and-swap");
4065    }
4066    let res = match state
4067        .repo()
4068        .update_subscription(did, rkey, &sub, read_cid.as_deref())
4069        .await
4070    {
4071        Ok(res) => res,
4072        // Another client wrote the record after the read above: nothing was
4073        // written, and the caller decides whether to read again.
4074        Err(err) if crate::atproto::is_invalid_swap(&err) => return Ok(RenameAttempt::Raced),
4075        Err(err) => {
4076            warn!(%err, %did, %rkey, "PDS subscription update failed");
4077            return Ok(Done(
4078                Redirect::to(&format!(
4079                    "/?flash={}",
4080                    qenc("Could not save that change to your PDS — nothing was renamed or moved.")
4081                ))
4082                .into_response(),
4083            ));
4084        }
4085    };
4086    info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
4087
4088    // **The cache follows the PDS, so it is written only now** — after the
4089    // write landed. It used to be written before the put, so a rename the PDS
4090    // refused (a failed save, or both attempts of a lost race, #149) still
4091    // left the new title on the cached row, or a fresh `feeds` row for a
4092    // repoint's URL that the poller then fetched for nobody. Every gate on
4093    // that URL (storability, privacy, the ceiling) ran above, before the put;
4094    // only the write itself moved.
4095    //
4096    // Keep the local cache title in step for the loose-feed fallback path —
4097    // for a row this instance would have. Two cases write nothing:
4098    //
4099    // - not storable (an existing at-URI with the flag off): the record is the
4100    //   reader's to edit, the cache row is not this instance's to create;
4101    // - an unchanged URL with no cache row: a retitle is never the write that
4102    //   CREATES a row. That covers two findings at once — the ceiling is
4103    //   checked on a repoint only, so a retitle must not insert past it; and
4104    //   a secret-bearing URL another client subscribed to has no row (the
4105    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
4106    //   refuses to cache it), so it cannot enter the shared table here, be
4107    //   polled, fail, and be printed on the admin page. A privacy re-check on
4108    //   this write was the first draft; mutation showed it dead — the row
4109    //   rule already refused every case it would have.
4110    //
4111    // A failed lookup skips the cache rather than failing the request: the
4112    // rename has already landed, and the reader must be told so.
4113    let cache_write = storable
4114        && (url_changed
4115            || match store::get_feed_by_url(&state.db, &sub.url).await {
4116                Ok(row) => row.is_some(),
4117                Err(err) => {
4118                    warn!(%err, %did, url = %sub.url, "could not look up the cached feed row after a rename");
4119                    false
4120                }
4121            });
4122    if !cache_write {
4123        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
4124    } else if let Err(err) = store::upsert_feed(
4125        &state.db,
4126        &store::NewFeed {
4127            url: sub.url.clone(),
4128            title: sub.title.clone(),
4129            site_url: sub.site_url.clone(),
4130            ..Default::default()
4131        },
4132    )
4133    .await
4134    {
4135        // Not fatal to the rename — the PDS record is the source of truth —
4136        // but a missing `feeds` row means this subscription is never polled.
4137        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
4138    }
4139
4140    Ok(Done(Redirect::to("/").into_response()))
4141}
4142
4143// ---------------------------------------------------------------------------
4144// Folders
4145// ---------------------------------------------------------------------------
4146
4147/// Form body for `POST /folders`.
4148#[derive(Debug, Deserialize)]
4149struct FolderForm {
4150    name: String,
4151}
4152
4153/// `POST /folders` — create a folder record.
4154async fn create_folder(
4155    State(state): State<AppState>,
4156    headers: HeaderMap,
4157    Form(form): Form<FolderForm>,
4158) -> Result<Response, WebError> {
4159    let did = match current_did(&state, &headers).await {
4160        Some(d) => d,
4161        None => return Ok(Redirect::to("/login").into_response()),
4162    };
4163    let name = form.name.trim();
4164    if name.is_empty() {
4165        return Ok(Redirect::to("/").into_response());
4166    }
4167    let folder = Folder::new(name.to_string(), now_rfc3339());
4168    match state.repo().add_folder(&did, &folder).await {
4169        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
4170        Err(err) => warn!(%err, %did, "PDS folder create failed"),
4171    }
4172    Ok(Redirect::to("/").into_response())
4173}
4174
4175/// Form body for `POST /folders/:rkey/rename`.
4176#[derive(Debug, Deserialize)]
4177struct RenameFolderForm {
4178    name: String,
4179    /// The name the input was pre-filled with — the record's own name, so it
4180    /// is the common ancestor of the reader's edit and any other client's
4181    /// (#268). Absent (a hand-made POST, or a page from an older build), the
4182    /// handler's first read stands in for it.
4183    #[serde(default)]
4184    seen_name: Option<String>,
4185}
4186
4187/// `POST /folders/:rkey/rename` — rename a folder record, changing its `name`
4188/// and nothing else.
4189///
4190/// **An edit of the record, not a replacement (#268).** This used to put
4191/// `Folder::new(name, now)` over the record, which reset `position`, replaced
4192/// `createdAt` with the rename time and dropped every field another
4193/// `community.lexicon.rss` client had added — and reported success whether or
4194/// not the write landed. It now reads the record with its CID, changes only
4195/// the name ([`Folder::extra`] carries the fields this build does not know),
4196/// and writes it back with `swapRecord` set to that CID, retried once on
4197/// `InvalidSwap` with a three-way merge — the same compare-and-swap as
4198/// [`rename_subscription`] (#149).
4199async fn rename_folder(
4200    State(state): State<AppState>,
4201    headers: HeaderMap,
4202    Path(rkey): Path<String>,
4203    Form(form): Form<RenameFolderForm>,
4204) -> Result<Response, WebError> {
4205    let did = match current_did(&state, &headers).await {
4206        Some(d) => d,
4207        None => return Ok(Redirect::to("/login").into_response()),
4208    };
4209    if form.name.trim().is_empty() {
4210        return Ok(Redirect::to("/").into_response());
4211    }
4212    let mut base: Option<Folder> = None;
4213    for attempt in 1..=RENAME_ATTEMPTS {
4214        match rename_folder_once(&state, &did, &rkey, &form, &mut base).await {
4215            RenameAttempt::Done(resp) => return Ok(resp),
4216            RenameAttempt::Raced => {
4217                info!(%did, %rkey, attempt, "folder changed between read and write; re-reading");
4218            }
4219        }
4220    }
4221    warn!(%did, %rkey, attempts = RENAME_ATTEMPTS, "refused folder rename: the folder kept changing elsewhere");
4222    Ok(folder_flash(FOLDER_RENAME_CONFLICT))
4223}
4224
4225/// The answer to a folder rename that conflicts with another client's write.
4226const FOLDER_RENAME_CONFLICT: &str = "This folder was changed elsewhere while you were renaming \
4227     it — it was not renamed. Reload and try again.";
4228
4229/// Redirect home with `message` as the flash.
4230fn folder_flash(message: &str) -> Response {
4231    Redirect::to(&format!("/?flash={}", qenc(message))).into_response()
4232}
4233
4234/// What a folder rename should do, from [`merge_folder_rename`].
4235#[derive(Debug, PartialEq, Eq)]
4236enum FolderMerge {
4237    /// Write this record: the fresh one, renamed.
4238    Write(Folder),
4239    /// The record already has the reader's name — a double-submitted Save
4240    /// whose first request landed, or the same rename made elsewhere.
4241    AlreadySaved,
4242    /// The reader did not change the name. Nothing to write.
4243    Unchanged,
4244}
4245
4246/// A three-way merge of the reader's rename against `fresh`, the record as
4247/// just read (#268).
4248///
4249/// The ancestor is `seen` — the name the input showed — or, without it,
4250/// `base`, the record as the handler first read it. The name is the folder's
4251/// only field the form edits; everything else comes from `fresh` untouched.
4252///
4253/// - the reader left the name as it was shown → [`FolderMerge::Unchanged`],
4254///   whatever `fresh` holds;
4255/// - `fresh` already has the reader's name → [`FolderMerge::AlreadySaved`];
4256/// - `fresh` still has the ancestor's name → write `fresh` renamed;
4257/// - otherwise someone else renamed it differently → a conflict.
4258///
4259/// Compared trimmed: the posted name is trimmed, and a record another client
4260/// wrote may carry padding.
4261fn merge_folder_rename(
4262    posted: &str,
4263    seen: Option<&str>,
4264    base: &Folder,
4265    fresh: Folder,
4266) -> Result<FolderMerge, RenameConflict> {
4267    let posted = posted.trim();
4268    let ancestor = seen.unwrap_or(&base.name).trim();
4269    if posted == ancestor {
4270        return Ok(FolderMerge::Unchanged);
4271    }
4272    let current = fresh.name.trim();
4273    if current == posted {
4274        return Ok(FolderMerge::AlreadySaved);
4275    }
4276    if current != ancestor {
4277        return Err(RenameConflict("name"));
4278    }
4279    let mut folder = fresh;
4280    folder.name = posted.to_string();
4281    Ok(FolderMerge::Write(folder))
4282}
4283
4284/// One attempt at [`rename_folder`]: read the folder and its CID, merge the
4285/// reader's rename into it, and write it back on the condition that it is
4286/// still at that CID. `base` is set by the first attempt's read.
4287async fn rename_folder_once(
4288    state: &AppState,
4289    did: &str,
4290    rkey: &str,
4291    form: &RenameFolderForm,
4292    base: &mut Option<Folder>,
4293) -> RenameAttempt {
4294    use RenameAttempt::Done;
4295
4296    // No single-record read on `Repo`, as for subscriptions: the sidecar has
4297    // no `get` action, and the listing already carries each record's CID.
4298    // A failed read refuses the rename — rebuilding the record from the form
4299    // is the loss this read exists to prevent.
4300    let found = match state.repo().list_folders_with_cids(did).await {
4301        Ok(folders) => folders
4302            .into_iter()
4303            .find(|(k, _, _)| k == rkey)
4304            .map(|(_, cid, f)| (cid, f)),
4305        Err(err) => {
4306            warn!(%err, %did, %rkey, "could not read the folder before renaming it");
4307            return Done(folder_flash(
4308                "Could not reach your PDS — the folder was not renamed.",
4309            ));
4310        }
4311    };
4312    let Some((read_cid, fresh)) = found else {
4313        // Deleted elsewhere (or never there). A put at a missing rkey would
4314        // CREATE the folder, which is not what "rename" means.
4315        warn!(%did, %rkey, "refused folder rename: no such folder in the repo");
4316        return Done(folder_flash(
4317            "That folder no longer exists — it may have been deleted elsewhere. \
4318             Nothing was renamed.",
4319        ));
4320    };
4321
4322    let base = base.get_or_insert_with(|| fresh.clone());
4323    let folder = match merge_folder_rename(&form.name, form.seen_name.as_deref(), base, fresh) {
4324        Ok(FolderMerge::Write(folder)) => folder,
4325        Ok(FolderMerge::AlreadySaved) => {
4326            info!(%did, %rkey, "folder already has this name; nothing to write");
4327            return Done(Redirect::to("/").into_response());
4328        }
4329        Ok(FolderMerge::Unchanged) => return Done(Redirect::to("/").into_response()),
4330        Err(RenameConflict(field)) => {
4331            warn!(%did, %rkey, field, "refused folder rename: the reader and another client both renamed it");
4332            return Done(folder_flash(FOLDER_RENAME_CONFLICT));
4333        }
4334    };
4335
4336    if read_cid.is_none() {
4337        warn!(%did, %rkey, "the PDS listed this folder without a CID; renaming without a compare-and-swap");
4338    }
4339    match state
4340        .repo()
4341        .rename_folder(did, rkey, &folder, read_cid.as_deref())
4342        .await
4343    {
4344        Ok(res) => {
4345            info!(%did, %rkey, uri = %res.uri, "renamed folder");
4346            Done(Redirect::to("/").into_response())
4347        }
4348        Err(err) if crate::atproto::is_invalid_swap(&err) => RenameAttempt::Raced,
4349        Err(err) => {
4350            warn!(%err, %did, %rkey, "PDS folder rename failed");
4351            Done(folder_flash(
4352                "Could not save that change to your PDS — the folder was not renamed.",
4353            ))
4354        }
4355    }
4356}
4357
4358/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
4359/// simply become un-foldered).
4360async fn delete_folder(
4361    State(state): State<AppState>,
4362    headers: HeaderMap,
4363    Path(rkey): Path<String>,
4364) -> Result<Response, WebError> {
4365    let did = match current_did(&state, &headers).await {
4366        Some(d) => d,
4367        None => return Ok(Redirect::to("/login").into_response()),
4368    };
4369    match state.repo().remove_folder(&did, &rkey).await {
4370        Ok(()) => info!(%did, %rkey, "deleted folder record"),
4371        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
4372    }
4373    Ok(Redirect::to("/").into_response())
4374}
4375
4376/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
4377/// feed document we take it as-is; if it yields an HTML page we run
4378/// autodiscovery over its `<link rel="alternate">` tags.
4379async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
4380    let parsed =
4381        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
4382
4383    let client = feed::build_client()?;
4384    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
4385    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
4386    // loopback / private hosts.
4387    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
4388    let final_url = resp.url().clone();
4389    let content_type = resp
4390        .headers()
4391        .get(axum::http::header::CONTENT_TYPE)
4392        .and_then(|v| v.to_str().ok())
4393        .unwrap_or("")
4394        .to_ascii_lowercase();
4395    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
4396    // gzip strips it, and this response is reflected into the UI.
4397    let raw = crate::net::read_capped(resp).await?;
4398    let body = String::from_utf8_lossy(&raw).into_owned();
4399
4400    let looks_like_feed = content_type.contains("xml")
4401        || content_type.contains("rss")
4402        || content_type.contains("atom")
4403        || content_type.contains("application/feed+json")
4404        || {
4405            let head = body.trim_start();
4406            head.starts_with("<?xml")
4407                || head.starts_with("<rss")
4408                || head.starts_with("<feed")
4409                || head.contains("<rss")
4410                || head.contains("<feed")
4411        };
4412    if looks_like_feed {
4413        return Ok(final_url.to_string());
4414    }
4415
4416    match feed::discover_feed(&body, Some(&final_url)) {
4417        Some(u) => Ok(u.to_string()),
4418        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
4419    }
4420}
4421
4422// ---------------------------------------------------------------------------
4423// Login (atproto OAuth via the sidecar)
4424// ---------------------------------------------------------------------------
4425
4426/// Query for `GET /login`.
4427#[derive(Debug, Deserialize, Default)]
4428struct LoginQuery {
4429    #[serde(default)]
4430    handle: Option<String>,
4431    #[serde(default)]
4432    error: Option<String>,
4433    #[serde(default)]
4434    flash: Option<String>,
4435}
4436
4437/// `GET /login` — start the atproto OAuth flow, or render the handle form.
4438///
4439/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
4440/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
4441/// session cookie *or* the submitted handle resolving to a seated DID) or a
4442/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
4443/// form (no handle) always renders.
4444async fn login_form(
4445    State(state): State<AppState>,
4446    headers: HeaderMap,
4447    Query(q): Query<LoginQuery>,
4448) -> Response {
4449    if let Some(handle) = q
4450        .handle
4451        .map(|h| h.trim().to_string())
4452        .filter(|h| !h.is_empty())
4453    {
4454        if !may_start_oauth(&state, &headers, &handle).await {
4455            return Redirect::to("/beta/redeem").into_response();
4456        }
4457        return start_oauth(&state, &handle).await;
4458    }
4459    render(&LoginTemplate {
4460        card: login_card(&state.config),
4461        repo_url: REPO_URL,
4462        error: q.error.unwrap_or_default(),
4463        flash: q.flash.unwrap_or_default(),
4464    })
4465}
4466
4467/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
4468/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
4469async fn login_submit(
4470    State(state): State<AppState>,
4471    headers: HeaderMap,
4472    Form(form): Form<LoginForm>,
4473) -> Response {
4474    let handle = form.handle.trim();
4475    if handle.is_empty() {
4476        return login_error(&state, "Enter your atproto handle.");
4477    }
4478    if !may_start_oauth(&state, &headers, handle).await {
4479        return Redirect::to("/beta/redeem").into_response();
4480    }
4481    start_oauth(&state, handle).await
4482}
4483
4484/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
4485/// admits, in order of cost:
4486///
4487/// 1. an existing beta member's cookie session whose DID already holds a seat;
4488/// 2. a fresh visitor carrying a valid reserving invite cookie;
4489/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
4490///    already holds a seat — this honors the **seeded admin's first login** on a
4491///    fresh deploy (and any returning member who cleared cookies) without a
4492///    session cookie or an invite code.
4493///
4494/// The cookie/invite fast paths run FIRST and short-circuit, so the network
4495/// handle→DID resolution is only attempted when neither applies. It fails
4496/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
4497/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
4498/// This keeps the anti-abuse intent — a rando now pays a cheap handle
4499/// resolution instead of a burned sidecar handshake (and `/login` is already in
4500/// the rate-limited path set).
4501async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
4502    // The production resolver is the app's existing atproto handle→DID path,
4503    // routed through the SSRF guard. Resolution is injected so tests can exercise
4504    // the gate without a live network call (the guard forbids loopback mocks).
4505    may_start_oauth_with(state, headers, handle, |h| async move {
4506        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
4507            .await
4508            .ok()
4509    })
4510    .await
4511}
4512
4513/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
4514/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
4515/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
4516/// only called when neither admits — keeping the network round-trip off the hot
4517/// path and preserving the fail-closed contract on resolution failure.
4518async fn may_start_oauth_with<F, Fut>(
4519    state: &AppState,
4520    headers: &HeaderMap,
4521    handle: &str,
4522    resolve: F,
4523) -> bool
4524where
4525    F: FnOnce(String) -> Fut,
4526    Fut: std::future::Future<Output = Option<String>>,
4527{
4528    // 1. An already-beta'd session may re-auth freely.
4529    if let Some(did) = current_did(state, headers).await {
4530        if store::has_beta_access(&state.db, &did)
4531            .await
4532            .unwrap_or(false)
4533        {
4534            return true;
4535        }
4536    }
4537    // 2. A valid reserving invite cookie.
4538    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4539        return true;
4540    }
4541    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
4542    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
4543    //    on any resolution error or unresolvable/malformed handle.
4544    match resolve(handle.to_string()).await {
4545        Some(did) => store::has_beta_access(&state.db, &did)
4546            .await
4547            .unwrap_or(false),
4548        None => {
4549            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4550            false
4551        }
4552    }
4553}
4554
4555/// Begin the OAuth handshake for `handle`, on whichever backend is live.
4556///
4557/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
4558/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
4559/// carries `form-action 'self'`. Browsers have historically disagreed about
4560/// whether that directive applies to redirects following a form submission, and
4561/// if it did here, login would break in a browser while every test passed.
4562///
4563/// It does not, and the evidence is the SIDECAR path, which is live in
4564/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
4565/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
4566/// whole redirect chain would already be blocking that. One checking only the
4567/// form's action URL sees `/login` in both cases. The two arms differ only in
4568/// how many same-origin hops precede the cross-origin one, so any policy that
4569/// permits the sidecar flow permits this one.
4570///
4571/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
4572/// its own `/login` and its own callback, so starting a login is one redirect
4573/// and nothing is stored here. The Rust backend pushes the authorization
4574/// request itself, which means this app now holds the pending login — and must
4575/// set the browser-binding cookie that the callback will be checked against.
4576async fn start_oauth(state: &AppState, handle: &str) -> Response {
4577    match state.config.repo_backend {
4578        crate::metrics::Backend::Sidecar => {
4579            let url = state.sidecar.login_url(handle, None);
4580            info!(%handle, "redirecting to OAuth sidecar login");
4581            Redirect::to(&url).into_response()
4582        }
4583        crate::metrics::Backend::Rust => {
4584            let Some(runtime) = state.oauth.as_deref() else {
4585                warn!("the rust backend is live but its OAuth runtime is absent");
4586                return login_error(state, "Login is not available right now.");
4587            };
4588            match crate::oauth::login::start(
4589                runtime,
4590                &state.http,
4591                &state.db,
4592                handle,
4593                crate::store::now_unix(),
4594            )
4595            .await
4596            {
4597                Ok(started) => {
4598                    info!(%handle, "pushed authorization request; redirecting to the PDS");
4599                    let mut resp = Redirect::to(&started.authorize_url).into_response();
4600                    set_cookie(
4601                        &mut resp,
4602                        &cookie::sign_value(
4603                            OAUTH_BINDING_COOKIE,
4604                            &started.binding_token,
4605                            &state.config.cookie_secret,
4606                            OAUTH_BINDING_MAX_AGE_SECS,
4607                        ),
4608                    );
4609                    resp
4610                }
4611                Err(err) => {
4612                    // The handle the user typed is logged; the error is not shown
4613                    // to them verbatim, since it can name internal hosts.
4614                    warn!(%err, %handle, "could not start the OAuth login");
4615                    login_error(state, "Could not start login for that handle.")
4616                }
4617            }
4618        }
4619    }
4620}
4621
4622/// Clear the browser-binding cookie. Called on every terminal outcome of a
4623/// callback, successful or not: the pending row is consumed either way, so a
4624/// lingering cookie can only ever match a login that no longer exists.
4625fn clear_binding_cookie(resp: &mut Response) {
4626    set_cookie(
4627        resp,
4628        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4629    );
4630}
4631
4632/// Form body for `POST /login`.
4633#[derive(Debug, Deserialize)]
4634struct LoginForm {
4635    handle: String,
4636}
4637
4638/// Query for `GET /oauth/callback`.
4639///
4640/// Carries BOTH shapes, because the two backends deliver different things to
4641/// the same URL: the sidecar hands back a one-shot `session_id` it has already
4642/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
4643/// for this app to exchange itself. Which fields are populated is decided by
4644/// which backend started the login, not by which is live now — so a flip with a
4645/// login already in flight still lands in the right arm.
4646#[derive(Debug, Deserialize, Default)]
4647struct CallbackQuery {
4648    /// Sidecar backend: the handoff id.
4649    #[serde(default)]
4650    session_id: Option<String>,
4651    /// Rust backend: the authorization code and its envelope.
4652    #[serde(default)]
4653    code: Option<String>,
4654    #[serde(default)]
4655    state: Option<String>,
4656    #[serde(default)]
4657    iss: Option<String>,
4658    /// JARM, which is not supported — carried only so it can be refused
4659    /// explicitly rather than read as "no code".
4660    #[serde(default)]
4661    response: Option<String>,
4662    #[serde(default)]
4663    error: Option<String>,
4664    #[serde(default)]
4665    error_description: Option<String>,
4666}
4667
4668/// `GET /oauth/callback` — establish the cookie session.
4669///
4670/// **Invite gate:** the verified DID must hold beta access. If it already does
4671/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
4672/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
4673/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
4674async fn oauth_callback(
4675    State(state): State<AppState>,
4676    headers: HeaderMap,
4677    Query(q): Query<CallbackQuery>,
4678) -> Response {
4679    // An error response is handled by the SAME arm that would have handled a
4680    // success, not short-circuited here.
4681    //
4682    // Returning early looks obviously right and is wrong on the Rust path: it
4683    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
4684    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
4685    // error originates from the intended AS". It also leaves the pending row
4686    // unconsumed, so a `state` that has already produced a callback stays usable
4687    // until it expires.
4688    //
4689    // The sidecar arm has no such check to reach, so it is short-circuited
4690    // below, preserving exactly what it did before.
4691    // **The arm is chosen by what the SERVER knows, not by what the caller
4692    // sent.** A `session_id` in the query used to select the sidecar arm on its
4693    // own — so a caller could pick which code path ran, and the sidecar arm has
4694    // no browser-binding check at all. It also short-circuited the error path
4695    // below, skipping the `iss` validation.
4696    //
4697    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
4698    // configured one, means the selection follows this deployment's own
4699    // configuration. A login started before a flip still completes, because the
4700    // Rust arm is reached whenever the Rust runtime exists and can match the
4701    // `state` against a pending row it actually wrote.
4702    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
4703    // and `?error=…&error_description=…` on its own failure. Keying only on
4704    // `session_id` sent the failure shape down the Rust arm, which then failed
4705    // with "no `state`" and replaced the specific reason with a generic one —
4706    // and `error_description` is exactly what the sidecar Caddy routing matches
4707    // to send that request here in the first place.
4708    let sidecar_shape =
4709        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4710    let sidecar_handoff = sidecar_shape
4711        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4712    if let Some(err) = q.error.clone() {
4713        // **Neither the code nor the description is echoed as sent.**
4714        //
4715        // Both are server-controlled free text arriving on a public GET, so
4716        // anyone who can make a browser fetch this URL chooses them. The raw
4717        // `error` used to go into a `warn!` AND into the rendered login page,
4718        // and `error_description` — arbitrary text, newlines included — went
4719        // into the log verbatim: a log-injection surface on one side and
4720        // attacker-chosen copy in the product's own voice on the other.
4721        //
4722        // `oauth::flow` already decided this exact question for the Rust arm:
4723        // reduce the code to a known slug, drop the description entirely. That
4724        // reasoning is not specific to which arm handles the callback, and this
4725        // one simply never got the same treatment. The description's LENGTH is
4726        // kept, because "the server sent a 4 KB explanation" is occasionally
4727        // worth knowing and cannot be used to inject anything.
4728        let slug = crate::oauth::flow::known_error_slug(&err);
4729        warn!(
4730            error = slug,
4731            desc_len = q.error_description.as_deref().map_or(0, str::len),
4732            "OAuth callback returned an error"
4733        );
4734        if sidecar_handoff || state.oauth.is_none() {
4735            return login_error(&state, &format!("Login failed: {slug}"));
4736        }
4737        // Fall through: the Rust arm consumes the pending row and validates
4738        // `iss` against it, and reports the failure afterwards.
4739    }
4740
4741    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
4742    // currently selected: a login started before a flip must still complete.
4743    let session = if sidecar_handoff {
4744        let session_id = q.session_id.clone().unwrap_or_default();
4745        match state.sidecar.resolve_session(&session_id).await {
4746            Ok(Some(s)) => s,
4747            Ok(None) => {
4748                warn!("OAuth callback session_id did not resolve (expired/unknown)");
4749                return login_error(&state, "Login session expired — please try again.");
4750            }
4751            Err(err) => {
4752                warn!(%err, "failed to resolve OAuth session via the sidecar");
4753                return login_error(&state, "Login failed talking to the auth service.");
4754            }
4755        }
4756    } else {
4757        let Some(runtime) = state.oauth.as_deref() else {
4758            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4759            return login_error(&state, "Login failed: this login could not be completed.");
4760        };
4761        let params = crate::oauth::flow::CallbackParams {
4762            code: q.code.clone(),
4763            state: q.state.clone(),
4764            iss: q.iss.clone(),
4765            // Passed through, NOT dropped: `verify_callback` checks `iss`
4766            // against the pending row's issuer before it reports the error, and
4767            // it cannot do that for an error it never sees.
4768            error: q.error.clone(),
4769            error_description: q.error_description.clone(),
4770            response: q.response.clone(),
4771        };
4772        let binding =
4773            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4774        match crate::oauth::login::complete(
4775            runtime,
4776            &state.http,
4777            &state.db,
4778            &params,
4779            binding.as_deref(),
4780            crate::store::now_unix(),
4781        )
4782        .await
4783        {
4784            Ok(done) => crate::atproto::SidecarSession {
4785                did: done.did,
4786                handle: done.handle,
4787            },
4788            Err(err) => {
4789                // Never echoed to the browser: the message can name the issuer,
4790                // the PDS, and why a binding check failed.
4791                warn!(%err, "could not complete the OAuth callback");
4792                let mut resp = login_error(&state, "Login failed — please try again.");
4793                clear_binding_cookie(&mut resp);
4794                return resp;
4795            }
4796        }
4797    };
4798
4799    // Bind the verified DID to the invite gate. Returns a response only on the
4800    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
4801    let mut clear_invite = false;
4802    if !store::has_beta_access(&state.db, &session.did)
4803        .await
4804        .unwrap_or(false)
4805    {
4806        // Not yet a member: consume the reserved invite code, if any.
4807        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4808            Some(c) => c,
4809            None => {
4810                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4811                return Redirect::to("/beta/redeem").into_response();
4812            }
4813        };
4814        match store::redeem_code(
4815            &state.db,
4816            &code,
4817            &session.did,
4818            session.handle.as_deref(),
4819            state.config.beta_cap,
4820        )
4821        .await
4822        {
4823            Ok(Ok(())) => {
4824                clear_invite = true;
4825                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4826            }
4827            Ok(Err(policy)) => {
4828                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4829                let mut resp = redeem_bounce(&state, &policy).into_response();
4830                // The reservation is spent/invalid — drop the stale invite cookie.
4831                clear_invite_cookie(&mut resp);
4832                return resp;
4833            }
4834            Err(err) => {
4835                warn!(%err, did = %session.did, "invite redeem infra error at callback");
4836                return login_error(&state, "Login failed while confirming your invite.");
4837            }
4838        }
4839    }
4840
4841    // Mint an opaque, random server-side session id and store the identity under
4842    // it; the cookie carries the (HMAC-signed) sid, never the DID.
4843    let sid = state.sessions.create(Session {
4844        did: session.did.clone(),
4845        handle: session.handle.clone(),
4846    });
4847    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4848    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4849
4850    let mut resp = Redirect::to("/").into_response();
4851    set_cookie(&mut resp, &cookie);
4852    clear_binding_cookie(&mut resp);
4853    if clear_invite {
4854        clear_invite_cookie(&mut resp);
4855    }
4856    resp
4857}
4858
4859/// Revoke a DID's OAuth session on BOTH backends, best-effort.
4860///
4861/// Not "whichever backend is live": during a cutover a user's tokens can be in
4862/// either store — they logged in under one backend and are logging out under
4863/// the other. Revoking only the live one would leave a live refresh token
4864/// behind in the other, which is the exact failure sign-out exists to prevent,
4865/// and it would be invisible because the sign-out itself looks successful.
4866///
4867/// Both arms are best-effort. The caller has already decided to sign the user
4868/// out, and a network failure must not trap them in a half-logged-out state.
4869/// How long sign-out will wait for a final read-state flush before revoking
4870/// anyway.
4871///
4872/// Bounded because the flush talks to the user's PDS, and a user trying to leave
4873/// must never be held by a server that is not answering. Three seconds is long
4874/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
4875/// and short enough that a dead PDS is an inconvenience rather than a trap.
4876const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4877
4878/// Flush whatever read-state is still dirty for `did`, then give up quietly.
4879///
4880/// **Called before revoking, because revoking first strands it (#117).**
4881/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
4882/// session cannot be sent by anyone — it parks until the user signs in again,
4883/// which may be never. Flushing first is what stops the common case from
4884/// becoming that.
4885///
4886/// Best-effort by construction: every failure path here falls through to the
4887/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4888/// the parked state the flusher now handles deliberately rather than retrying
4889/// forever.
4890async fn flush_before_revoke(state: &AppState, did: &str) {
4891    match tokio::time::timeout(
4892        SIGN_OUT_FLUSH_BUDGET,
4893        crate::readstate::flush_did(state, did),
4894    )
4895    .await
4896    {
4897        Ok(Ok(())) => {}
4898        Ok(Err(err)) => {
4899            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4900        }
4901        Err(_) => warn!(
4902            %did,
4903            budget = ?SIGN_OUT_FLUSH_BUDGET,
4904            "sign-out: final read-state flush timed out; it will park until next sign-in"
4905        ),
4906    }
4907}
4908
4909async fn revoke_everywhere(state: &AppState, did: &str) {
4910    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4911    // that recording only the rust arm let `oauth_revoke` report a clean success
4912    // while every sidecar revocation failed — and for anyone who logged in before
4913    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4914    // rust arm correctly returns NoSession and the metric reads all-clear while
4915    // live refresh tokens sit at the PDS.
4916    //
4917    // Same op name, different backend: the backend column is what distinguishes
4918    // them, so "no revocation failures" means checking both rows, not one.
4919    let sidecar_started = std::time::Instant::now();
4920    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4921        Ok(res) => {
4922            info!(%did, revoked = res.revoked, "sidecar session revoked");
4923            true
4924        }
4925        Err(err) => {
4926            warn!(%did, %err, "sidecar revoke failed; continuing");
4927            false
4928        }
4929    };
4930    state.metrics.record(
4931        crate::metrics::Backend::Sidecar,
4932        "oauth_revoke",
4933        sidecar_started.elapsed().as_micros() as u64,
4934        sidecar_ok,
4935    );
4936
4937    if let Some(runtime) = state.oauth.as_deref() {
4938        let revoke_started = std::time::Instant::now();
4939        let outcome = crate::oauth::revoke::sign_out_discovering(
4940            runtime,
4941            &state.http,
4942            &state.db,
4943            did,
4944            crate::store::now_unix(),
4945        )
4946        .await;
4947        // **Counted, because a warn! nobody reads is not observability.** Until
4948        // this existed, a revocation failure left exactly one trace: a log line.
4949        // "No revocation failures this week" was therefore a statement about
4950        // nobody having looked, which is not the same claim.
4951        //
4952        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4953        // there being nothing to revoke is the correct outcome, not a failure,
4954        // and counting it as an error would make the metric noisy in exactly
4955        // the case that is fine. Only `Failed` means the PDS still holds live
4956        // tokens we asked it to drop.
4957        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4958        state.metrics.record(
4959            crate::metrics::Backend::Rust,
4960            "oauth_revoke",
4961            revoke_started.elapsed().as_micros() as u64,
4962            revoke_ok,
4963        );
4964        match outcome {
4965            crate::oauth::revoke::Revocation::Revoked => {
4966                info!(%did, "rust OAuth session revoked at the PDS")
4967            }
4968            crate::oauth::revoke::Revocation::NoSession => {}
4969            crate::oauth::revoke::Revocation::Failed(reason) => {
4970                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4971            }
4972        }
4973    }
4974}
4975
4976/// `POST /logout` — end the session everywhere, not just in this browser.
4977///
4978/// Clearing the cookie only stops *this* device from presenting the session;
4979/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4980/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4981/// access tokens at the PDS and drops the sidecar's session rows. The local
4982/// registry entry is dropped and the cookie cleared regardless of whether the
4983/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4984/// user in a half-logged-out state).
4985async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4986    if let Some(user) = current_session(&state, &headers).await {
4987        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4988        // revoke; the dev-DID fallback never handshook the sidecar.
4989        if let Some(sid) = user.sid {
4990            state.sessions.remove(&sid);
4991            // BEFORE the revoke: afterwards there is no session to send it with.
4992            flush_before_revoke(&state, &user.did).await;
4993            revoke_everywhere(&state, &user.did).await;
4994        }
4995    }
4996    let mut resp = Redirect::to("/login").into_response();
4997    set_cookie(
4998        &mut resp,
4999        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5000    );
5001    resp
5002}
5003
5004/// Form body for `POST /account/delete` — the confirm-gate. The user must type
5005/// `DELETE` into this field for the purge to run.
5006#[derive(Debug, Deserialize)]
5007struct DeleteAccountForm {
5008    #[serde(default)]
5009    confirm: String,
5010}
5011
5012/// The literal a user must type to confirm the destructive delete.
5013const DELETE_CONFIRM_PHRASE: &str = "DELETE";
5014
5015/// `POST /account/delete` (authed) — the "delete my data" endpoint.
5016///
5017/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
5018/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
5019///   1. purges **every** local row owned by the caller DID (`entry_state`,
5020///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
5021///      DID created) via [`store::purge_did_data`], then
5022///   2. revokes the OAuth session at the PDS via `revoke_everywhere` — the
5023///      sidecar's `POST /internal/revoke {did}` and, when the Rust OAuth runtime
5024///      is configured, its RFC 7009 revocation too — then
5025///   3. drops the in-memory session and clears the cookie, signing the user out.
5026///
5027/// The subscription/folder/saved *records* in the user's own PDS are
5028/// intentionally left alone — they are the user's data on their own server; the
5029/// `/about` copy and this page's UI both say so, and export stays available.
5030async fn account_delete(
5031    State(state): State<AppState>,
5032    headers: HeaderMap,
5033    Form(form): Form<DeleteAccountForm>,
5034) -> Result<Response, WebError> {
5035    let user = match current_session(&state, &headers).await {
5036        Some(u) => u,
5037        None => return Ok(Redirect::to("/login").into_response()),
5038    };
5039    let did = user.did.clone();
5040
5041    // Confirm-gate: require the exact typed phrase before doing anything.
5042    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
5043        return Ok(Redirect::to(&format!(
5044            "/manage?flash={}",
5045            qenc("Type DELETE to confirm — nothing was deleted.")
5046        ))
5047        .into_response());
5048    }
5049
5050    // 1. Purge every local row this DID owns (single transaction).
5051    let counts = store::purge_did_data(&state.db, &did).await?;
5052    info!(
5053        %did,
5054        total = counts.total(),
5055        entry_state = counts.entry_state,
5056        read_cursor = counts.read_cursor,
5057        sub_ref = counts.sub_ref,
5058        beta_access = counts.beta_access,
5059        invite_codes = counts.invite_codes,
5060        "account/delete: local rows purged"
5061    );
5062
5063    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
5064    //    rows are already gone; a network blip must not block the sign-out).
5065    revoke_everywhere(&state, &did).await;
5066
5067    // 3. Drop the in-memory session and clear the cookie: sign the user out.
5068    if let Some(sid) = user.sid {
5069        state.sessions.remove(&sid);
5070    }
5071    let mut resp = Redirect::to(&format!(
5072        "/login?flash={}",
5073        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
5074    ))
5075    .into_response();
5076    set_cookie(
5077        &mut resp,
5078        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5079    );
5080    Ok(resp)
5081}
5082
5083/// The `/login` card, shared by the form and its error re-render.
5084fn login_card(config: &Config) -> Card {
5085    Card::public(
5086        config,
5087        "/login",
5088        "Sign in — FeatherReader",
5089        "Sign in to FeatherReader with your atproto handle. You approve access on \
5090         your own server — no signup, no password.",
5091    )
5092}
5093
5094/// Re-render the login form with an error banner.
5095fn login_error(state: &AppState, msg: &str) -> Response {
5096    render(&LoginTemplate {
5097        card: login_card(&state.config),
5098        repo_url: REPO_URL,
5099        error: msg.to_string(),
5100        flash: String::new(),
5101    })
5102}
5103
5104// ---------------------------------------------------------------------------
5105// Closed-beta invite gate (self-serve redeem + admin mint)
5106// ---------------------------------------------------------------------------
5107
5108/// Form body for `POST /beta/redeem`.
5109#[derive(Debug, Deserialize)]
5110struct RedeemForm {
5111    code: String,
5112}
5113
5114/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
5115/// already full we render the "capacity full" variant (no form).
5116async fn beta_redeem_form(State(state): State<AppState>) -> Response {
5117    let full = store::count_beta_access(&state.db)
5118        .await
5119        .map(|n| n >= state.config.beta_cap)
5120        .unwrap_or(false);
5121    render(&BetaRedeemTemplate {
5122        card: redeem_card(&state.config),
5123        repo_url: REPO_URL,
5124        error: String::new(),
5125        capacity_full: full,
5126    })
5127}
5128
5129/// `POST /beta/redeem` — the **pre-handshake** reservation.
5130///
5131/// Validates the pasted code is *redeemable right now* (exists, active,
5132/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
5133/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
5134/// reserving intent to redeem this code, then sends the visitor to `/login`. The
5135/// OAuth callback later binds the verified DID and atomically consumes the code
5136/// (`store::redeem_code`). This ordering means a non-invited visitor can never
5137/// start OAuth (and burn a sidecar handshake).
5138async fn beta_redeem_submit(
5139    State(state): State<AppState>,
5140    Form(form): Form<RedeemForm>,
5141) -> Response {
5142    let code = form.code.trim().to_uppercase();
5143    if code.is_empty() {
5144        return render(&BetaRedeemTemplate {
5145            card: redeem_card(&state.config),
5146            repo_url: REPO_URL,
5147            error: "Enter your invite code.".to_string(),
5148            capacity_full: false,
5149        });
5150    }
5151
5152    match preflight_code(&state, &code).await {
5153        Ok(()) => {
5154            let cookie = sign_invite(&code, &state.config.cookie_secret);
5155            let mut resp = Redirect::to("/login").into_response();
5156            set_cookie(&mut resp, &cookie);
5157            info!("invite code preflight OK; reserving intent + redirecting to /login");
5158            resp
5159        }
5160        Err(policy) => {
5161            warn!(?policy, "invite code preflight rejected");
5162            redeem_bounce(&state, &policy)
5163        }
5164    }
5165}
5166
5167/// Read-only preflight of an invite code for the pre-handshake reservation:
5168/// verify it exists, is active, is not past `expires_at`, and that a seat is
5169/// free — mirroring the checks `store::redeem_code` will re-run atomically at
5170/// callback time. Does NOT consume the code or grant a seat. Returns the same
5171/// typed [`store::RedeemError`] variants so the two paths share one message map.
5172async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
5173    // Cap check first: a clear "capacity full" beats "code invalid" when both.
5174    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
5175    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
5176    // still backstops the real cap inside its tx, so this is a consistency /
5177    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
5178    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
5179    // that might overrun the cap.
5180    let count = match store::count_beta_access(&state.db).await {
5181        Ok(n) => n,
5182        Err(err) => {
5183            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
5184            return Err(store::RedeemError::CapacityFull);
5185        }
5186    };
5187    if count >= state.config.beta_cap {
5188        return Err(store::RedeemError::CapacityFull);
5189    }
5190    // Look up the code's current status + expiry (read-only).
5191    let row = sqlx::query_as::<_, (String, i64)>(
5192        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
5193    )
5194    .bind(code)
5195    .fetch_optional(&state.db)
5196    .await
5197    .ok()
5198    .flatten();
5199    let (status, expires_at) = match row {
5200        Some(r) => r,
5201        None => return Err(store::RedeemError::NotFound),
5202    };
5203    let now = chrono::Utc::now().timestamp();
5204    match status.as_str() {
5205        "active" if expires_at >= now => Ok(()),
5206        "active" => Err(store::RedeemError::Expired),
5207        "expired" => Err(store::RedeemError::Expired),
5208        // "redeemed" or anything else non-active.
5209        _ => Err(store::RedeemError::AlreadyRedeemed),
5210    }
5211}
5212
5213/// Map a [`store::RedeemError`] to the invite page with the right message. Used
5214/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
5215fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
5216    use store::RedeemError::*;
5217    let (msg, capacity_full) = match policy {
5218        NotFound => ("That invite code isn't valid.", false),
5219        Expired => ("That invite code has expired.", false),
5220        AlreadyRedeemed => ("That invite code has already been used.", false),
5221        CapacityFull => ("", true),
5222    };
5223    render(&BetaRedeemTemplate {
5224        card: redeem_card(&state.config),
5225        repo_url: REPO_URL,
5226        error: msg.to_string(),
5227        capacity_full,
5228    })
5229}
5230
5231/// The `/beta/redeem` card, shared by the form, its re-renders and the claim
5232/// link's bounce.
5233fn redeem_card(config: &Config) -> Card {
5234    Card::public(
5235        config,
5236        "/beta/redeem",
5237        "Redeem an invite — FeatherReader",
5238        "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
5239         in with your atproto handle.",
5240    )
5241}
5242
5243/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
5244#[derive(Debug, Deserialize, Default)]
5245struct MintQuery {
5246    #[serde(default)]
5247    n: Option<u32>,
5248}
5249
5250/// `POST /admin/invites?n=N` — mint N invite codes.
5251///
5252/// `GET /oauth/client-metadata.json` — the client's published identity.
5253///
5254/// **This URL IS the `client_id`.** The PDS fetches it during every login and
5255/// caches it against every existing grant, so it must keep answering at exactly
5256/// this path across the cutover — the sidecar serves the same document at the
5257/// same URL today, proxied by the edge.
5258///
5259/// Served whatever backend is live: a request that arrives here is from a PDS
5260/// resolving our identity, and it has no idea which of our two implementations
5261/// is currently answering repo calls.
5262async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
5263    let Some(runtime) = state.oauth.as_deref() else {
5264        // The sidecar is serving this path in front of us, or nothing is.
5265        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
5266    };
5267    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
5268}
5269
5270/// `GET /oauth/jwks.json` — the client's public signing key.
5271///
5272/// Production only. The localhost dev client is a PUBLIC client: it registers no
5273/// key and signs no assertions, so publishing a JWKS there would advertise a
5274/// credential that is never used — and would make a dev deployment look like a
5275/// confidential client to anyone reading it.
5276async fn oauth_jwks(State(state): State<AppState>) -> Response {
5277    let Some(runtime) = state.oauth.as_deref() else {
5278        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
5279    };
5280    match runtime.client_key.as_ref() {
5281        Some(key) => match key.jwks_document() {
5282            Ok(doc) => axum::Json(doc).into_response(),
5283            Err(err) => {
5284                warn!(%err, "could not render the client JWKS");
5285                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
5286            }
5287        },
5288        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
5289    }
5290}
5291
5292/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
5293const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
5294
5295/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
5296///
5297/// Admin-gated on the same rule as the invite minter: the table names every
5298/// operation the reader performs and how often each fails, which is an
5299/// operational picture rather than public information.
5300///
5301/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
5302/// is safe, and the comparison is two rows side by side.
5303async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
5304    let did = match current_did(&state, &headers).await {
5305        Some(d) => d,
5306        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5307    };
5308    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5309        warn!(%did, "admin metrics denied: not an admin-seed DID");
5310        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5311    }
5312
5313    // Flush first, so the table includes this process's traffic up to now.
5314    // Then read the PERSISTED rows, which is the only place both backends can
5315    // appear at once -- a flip is a restart, and in-process memory only ever
5316    // holds the backend currently running.
5317    if let Err(err) =
5318        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
5319    {
5320        warn!(%err, "could not flush repo timings before rendering");
5321    }
5322    let rows = match crate::metrics::persisted_rows(&state.db).await {
5323        Ok(rows) => rows,
5324        Err(err) => {
5325            warn!(%err, "could not read persisted repo timings");
5326            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
5327        }
5328    };
5329
5330    // The live backend is named at the top: a table of two populated rows is
5331    // ambiguous about which one is currently serving users.
5332    // Parked read-state, alongside the timings. The flusher no longer logs
5333    // these every round (#117), so without a number here the state would be
5334    // silent — which is the failure the noisy loop at least did not have.
5335    let parked = match crate::store::parked_readstate_dids(&state.db).await {
5336        Ok(n) => n.to_string(),
5337        Err(err) => {
5338            warn!(%err, "could not count parked read-state DIDs");
5339            "unknown".to_string()
5340        }
5341    };
5342    // **The half the public histogram cannot carry.** `/stats` reports counts by
5343    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
5344    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
5345    // cannot separate "the publishers are gone" from "we are broken". #159 was
5346    // the latter and took a production investigation to establish. Named feeds
5347    // and their error text belong here, behind ALLOWED_DIDS.
5348    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
5349        Ok(f) => f,
5350        Err(err) => {
5351            warn!(%err, "could not list failing feeds");
5352            Vec::new()
5353        }
5354    };
5355    let mut failing_block = String::new();
5356    if !failing.is_empty() {
5357        failing_block.push_str("\nfailing feeds (worst first)\n");
5358        for f in &failing {
5359            failing_block.push_str(&format!(
5360                "  {:>4}x  {:<8}  {}\n          {}\n",
5361                f.consecutive_errors,
5362                f.kind.as_deref().unwrap_or("unknown"),
5363                f.url,
5364                f.detail.as_deref().unwrap_or("(no detail recorded)"),
5365            ));
5366        }
5367    }
5368
5369    // **Capacity that no other page can show.** The global ceiling counts every
5370    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
5371    // unpollable ones — so an instance can be at its cap with every public
5372    // number saying otherwise. A review found exactly that gap.
5373    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
5374        Ok(n) => n,
5375        Err(err) => {
5376            warn!(%err, "could not count unpollable feeds");
5377            -1
5378        }
5379    };
5380    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
5381
5382    let body = format!(
5383        "live backend: {}\nparked read-state DIDs: {}\n\
5384         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
5385        state.config.repo_backend.as_str(),
5386        parked,
5387        cached,
5388        state.config.max_feeds_global,
5389        unpollable,
5390        crate::metrics::render(&rows),
5391        failing_block,
5392    );
5393    (StatusCode::OK, body).into_response()
5394}
5395
5396/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
5397/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
5398/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
5399async fn admin_mint_invites(
5400    State(state): State<AppState>,
5401    headers: HeaderMap,
5402    Query(q): Query<MintQuery>,
5403) -> Response {
5404    // Require a real, current session (not just a DID string) whose DID is an
5405    // admin-seed DID. `current_did` already re-checks the beta gate.
5406    let did = match current_did(&state, &headers).await {
5407        Some(d) => d,
5408        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
5409    };
5410    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
5411        warn!(%did, "admin mint denied: not an admin-seed DID");
5412        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
5413    }
5414
5415    let n = q.n.unwrap_or(1).clamp(1, 100);
5416    let mut codes = Vec::with_capacity(n as usize);
5417    for _ in 0..n {
5418        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
5419            Ok(code) => codes.push(code),
5420            Err(err) => {
5421                warn!(%err, %did, "admin mint_code failed");
5422                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5423            }
5424        }
5425    }
5426    info!(%did, count = codes.len(), "admin minted invite codes");
5427    let mut body = codes.join("\n");
5428    body.push('\n');
5429    (StatusCode::OK, body).into_response()
5430}
5431
5432// ---------------------------------------------------------------------------
5433// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
5434// ---------------------------------------------------------------------------
5435
5436/// Query for `GET /claim`.
5437#[derive(Debug, Deserialize)]
5438struct ClaimQuery {
5439    /// The opaque claim token from the bot's public follow-back skeet.
5440    t: Option<String>,
5441}
5442
5443/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
5444///
5445/// The follow→invite bot posts a public skeet mentioning a new follower with a
5446/// link here. The token wraps a pre-minted invite code (never the raw code — see
5447/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
5448/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
5449/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
5450/// callback atomically consumes the code (`store::redeem_code`) — the same
5451/// machinery as a pasted code. On any failure it bounces to the invite page with
5452/// the matching message.
5453///
5454/// Single-use / grabbability: a token in a public URL is grabbable. The code it
5455/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
5456/// here rejects an already-used / expired / capacity-full code before reserving,
5457/// so a replayed link past the first successful claim is refused. The residual
5458/// window is the same as any pasted invite code: whoever completes OAuth *first*
5459/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
5460/// blunts brute-force enumeration.
5461async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
5462    let token = match q.t {
5463        Some(t) if !t.is_empty() => t,
5464        _ => {
5465            warn!("claim link with no token");
5466            return redeem_bounce(&state, &store::RedeemError::NotFound);
5467        }
5468    };
5469
5470    // Unwrap the token → the invite code it reserves. A tampered/forged token
5471    // yields nothing → treat as an invalid code (don't leak whether it parsed).
5472    let code = match claim_token_code(&token, &state.config.cookie_secret) {
5473        Some(c) => c,
5474        None => {
5475            warn!("claim token invalid (bad signature / malformed)");
5476            return redeem_bounce(&state, &store::RedeemError::NotFound);
5477        }
5478    };
5479
5480    // Re-run the same preflight as the pasted-code path: exists, active,
5481    // unexpired, seat free. This is what makes a replayed link past first-claim
5482    // (or past cap) fail cleanly.
5483    match preflight_code(&state, &code).await {
5484        Ok(()) => {
5485            let cookie = sign_invite(&code, &state.config.cookie_secret);
5486            let mut resp = Redirect::to("/login").into_response();
5487            set_cookie(&mut resp, &cookie);
5488            info!("claim token preflight OK; reserving intent + redirecting to /login");
5489            resp
5490        }
5491        Err(policy) => {
5492            warn!(?policy, "claim token preflight rejected");
5493            redeem_bounce(&state, &policy)
5494        }
5495    }
5496}
5497
5498/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
5499///
5500/// Passing the follower DID makes the APP the authoritative deduper: the app can
5501/// short-circuit a DID that already holds a seat, and return the SAME code for a
5502/// DID that already has an outstanding claim — so a bot-host state loss cannot
5503/// re-mint or re-post per follower. Handle is advisory (logs only).
5504#[derive(Debug, Default, Deserialize)]
5505struct BotClaimRequest {
5506    /// The follower's DID (the idempotency key). Optional for backward-compat: an
5507    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
5508    #[serde(default)]
5509    did: Option<String>,
5510    /// The follower's handle (advisory; recorded for operator logs only).
5511    #[serde(default)]
5512    #[allow(dead_code)]
5513    handle: Option<String>,
5514}
5515
5516/// The JSON body `POST /bot/claims` returns on success.
5517#[derive(Debug, serde::Serialize)]
5518struct BotClaimResponse {
5519    /// Server-side dedupe outcome, so the bot knows whether to post:
5520    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
5521    /// already had an outstanding claim; the SAME code/token/url is returned, so an
5522    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
5523    /// beta access; code/token/url are empty and the bot should post NOTHING).
5524    status: &'static str,
5525    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
5526    /// store. NEVER post this publicly; post the `url` instead. Empty when
5527    /// `already_seated`.
5528    code: String,
5529    /// The opaque claim token (the code wrapped + signed). Empty when
5530    /// `already_seated`.
5531    token: String,
5532    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
5533    /// Empty when `already_seated`.
5534    url: String,
5535}
5536
5537/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
5538///
5539/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
5540/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
5541/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
5542/// (503), so a bare/dev instance never exposes an unauthenticated mint.
5543///
5544/// Server-side DID idempotency (the authoritative dedupe backstop): the request
5545/// body carries the follower `did`. The app — not the bot's local SQLite — is the
5546/// source of truth, so a bot-host state loss cannot re-mint or re-post per
5547/// follower:
5548///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
5549///     code/url; the bot marks it handled and posts NOTHING);
5550///   * DID already has an outstanding active claim → `200 {status:"existing"}`
5551///     returning the SAME code/token/url (idempotent — never a second mint);
5552///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
5553///
5554/// Cap accounting: the bot must not promise more claims than seats remain, so
5555/// this refuses with `409 Conflict {"error":"full"}` when
5556/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
5557/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
5558/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
5559/// minting past the cap.
5560///
5561/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
5562/// default 14d — the admin browser flow's 30-min TTL would expire before the
5563/// follower taps an async-delivered link).
5564async fn bot_mint_claim(
5565    State(state): State<AppState>,
5566    headers: HeaderMap,
5567    body: axum::body::Bytes,
5568) -> Response {
5569    // 1. The endpoint is OFF unless a bot secret is configured.
5570    let bot_secret = match state.config.bot_secret.as_deref() {
5571        Some(s) => s,
5572        None => {
5573            warn!(
5574                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5575            );
5576            return (
5577                StatusCode::SERVICE_UNAVAILABLE,
5578                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5579            )
5580                .into_response();
5581        }
5582    };
5583
5584    // 2. Constant-time bearer check on the X-Bot-Secret header.
5585    let presented = headers
5586        .get("x-bot-secret")
5587        .and_then(|v| v.to_str().ok())
5588        .unwrap_or("");
5589    if !bot_secret_matches(presented, bot_secret) {
5590        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5591        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5592    }
5593
5594    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
5595    // (legacy caller) parses to an all-None request; a malformed body is a 400.
5596    let req: BotClaimRequest = if body.is_empty() {
5597        BotClaimRequest::default()
5598    } else {
5599        match serde_json::from_slice(&body) {
5600            Ok(r) => r,
5601            Err(err) => {
5602                warn!(%err, "POST /bot/claims: bad JSON body");
5603                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5604            }
5605        }
5606    };
5607    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5608
5609    // 3. Server-side DID idempotency (only when a DID was supplied):
5610    if let Some(did) = follower_did {
5611        // 3a. Already seated → tell the bot to post nothing.
5612        match store::has_beta_access(&state.db, did).await {
5613            Ok(true) => {
5614                info!("bot mint: DID already holds beta access; already_seated");
5615                return bot_claim_json(BotClaimResponse {
5616                    status: "already_seated",
5617                    code: String::new(),
5618                    token: String::new(),
5619                    url: String::new(),
5620                });
5621            }
5622            Ok(false) => {}
5623            Err(err) => {
5624                // Fail closed: a DB error must not fall through to a fresh mint.
5625                warn!(%err, "bot mint: has_beta_access failed");
5626                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5627            }
5628        }
5629        // 3b. Outstanding active claim for this DID → return the SAME code (no
5630        // second mint). This is what survives a bot-host state loss.
5631        match store::find_active_code_for_did(&state.db, did).await {
5632            Ok(Some(code)) => {
5633                info!("bot mint: existing outstanding claim for DID; returning same code");
5634                let token = sign_claim_token(&code, &state.config.cookie_secret);
5635                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5636                return bot_claim_json(BotClaimResponse {
5637                    status: "existing",
5638                    code,
5639                    token,
5640                    url,
5641                });
5642            }
5643            Ok(None) => {}
5644            Err(err) => {
5645                warn!(%err, "bot mint: find_active_code_for_did failed");
5646                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5647            }
5648        }
5649    }
5650
5651    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
5652    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
5653    let granted = match store::count_beta_access(&state.db).await {
5654        Ok(n) => n,
5655        Err(err) => {
5656            warn!(%err, "bot mint: count_beta_access failed; failing closed");
5657            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5658        }
5659    };
5660    let outstanding = match store::count_active_codes(&state.db).await {
5661        Ok(n) => n,
5662        Err(err) => {
5663            warn!(%err, "bot mint: count_active_codes failed; failing closed");
5664            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5665        }
5666    };
5667    if granted + outstanding >= state.config.beta_cap {
5668        info!(
5669            granted,
5670            outstanding,
5671            cap = state.config.beta_cap,
5672            "bot mint refused: at capacity"
5673        );
5674        return (
5675            StatusCode::CONFLICT,
5676            [(header::CONTENT_TYPE, "application/json")],
5677            "{\"error\":\"full\"}\n",
5678        )
5679            .into_response();
5680    }
5681
5682    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
5683    //    so a re-request for the same DID returns THIS code idempotently.
5684    let bot_did = state
5685        .config
5686        .admin_seed_dids()
5687        .first()
5688        .cloned()
5689        .unwrap_or_else(|| "did:bot:featherreader".to_string());
5690    let minted = match follower_did {
5691        Some(did) => {
5692            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5693        }
5694        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5695    };
5696    let code = match minted {
5697        Ok(c) => c,
5698        // S4: the dedupe check (3b) and this mint are separate statements, so two
5699        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
5700        // The partial unique index `idx_invite_codes_intended_active` makes the
5701        // loser's INSERT fail (only one active row per intended DID), which
5702        // surfaces here as a conflict. Recover by returning the winner's existing
5703        // code (same shape as the 3b idempotent path) instead of a 500.
5704        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5705            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5706                Ok(Some(code)) => {
5707                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
5708                    let token = sign_claim_token(&code, &state.config.cookie_secret);
5709                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5710                    return bot_claim_json(BotClaimResponse {
5711                        status: "existing",
5712                        code,
5713                        token,
5714                        url,
5715                    });
5716                }
5717                // The winner's row vanished between the conflict and this lookup
5718                // (redeemed/expired/purged in the gap) — nothing to hand back.
5719                // Fail closed rather than silently mint past the just-hit guard.
5720                Ok(None) => {
5721                    warn!("bot mint: conflict but no active code found on recovery");
5722                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5723                }
5724                Err(err) => {
5725                    warn!(%err, "bot mint: recovery lookup after conflict failed");
5726                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5727                }
5728            }
5729        }
5730        Err(err) => {
5731            warn!(%err, "bot mint_code failed");
5732            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5733        }
5734    };
5735    let token = sign_claim_token(&code, &state.config.cookie_secret);
5736    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5737    info!("bot minted a claim code + token");
5738
5739    bot_claim_json(BotClaimResponse {
5740        status: "minted",
5741        code,
5742        token,
5743        url,
5744    })
5745}
5746
5747/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
5748/// `500` if serialization somehow fails).
5749fn bot_claim_json(resp: BotClaimResponse) -> Response {
5750    match serde_json::to_string(&resp) {
5751        Ok(body) => (
5752            StatusCode::OK,
5753            [(header::CONTENT_TYPE, "application/json")],
5754            body,
5755        )
5756            .into_response(),
5757        Err(err) => {
5758            warn!(%err, "serializing bot claim response failed");
5759            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5760        }
5761    }
5762}
5763
5764/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
5765/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
5766/// by the HMAC checks so there is one comparator to audit; a length mismatch
5767/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
5768fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5769    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5770}
5771
5772// ---------------------------------------------------------------------------
5773// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
5774// ---------------------------------------------------------------------------
5775
5776/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
5777/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
5778/// itself (base64url) rather than an opaque sid, since the code IS the reserved
5779/// intent the callback consumes.
5780fn sign_invite(code: &str, secret: &str) -> String {
5781    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5782}
5783
5784/// Verify + read the reserved invite code out of the request's invite cookie
5785/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
5786/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
5787/// authority on the code's live status.
5788fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5789    cookie::verify_value(headers, INVITE_COOKIE, secret)
5790}
5791
5792/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
5793/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
5794/// cookie value and vice-versa.
5795const CLAIM_TOKEN_LABEL: &str = "claim-token";
5796
5797/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
5798/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
5799///
5800/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
5801/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
5802/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
5803/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
5804/// code won't verify), the wrapped code is single-use (redeem flips
5805/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
5806/// token one self-contained string needing no server-side token table; it does
5807/// NOT hide the code.
5808fn sign_claim_token(code: &str, secret: &str) -> String {
5809    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5810}
5811
5812/// Verify a claim token and return the invite code it wraps (`None` on a tampered
5813/// / forged / malformed token). The code's live status (active/unexpired/seat
5814/// free) is re-checked by `preflight_code`; this only proves the token was minted
5815/// by this instance.
5816fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5817    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5818}
5819
5820/// Clear the invite cookie on a response (after a successful bind, or when the
5821/// reservation turned out to be stale).
5822fn clear_invite_cookie(resp: &mut Response) {
5823    set_cookie(
5824        resp,
5825        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5826    );
5827}
5828
5829// ---------------------------------------------------------------------------
5830// OPML import + export
5831// ---------------------------------------------------------------------------
5832
5833/// `POST /opml` — import subscriptions from an OPML document.
5834///
5835/// Accepts either a multipart file upload (field `file`) or a pasted textarea
5836/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
5837/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
5838/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
5839/// `applyWrites` round-trip per 200 feeds). Feeds are also upserted into the local cache so
5840/// they show immediately; polling is left to the background poller.
5841async fn import_opml(
5842    State(state): State<AppState>,
5843    headers: HeaderMap,
5844    mut multipart: Multipart,
5845) -> Result<Response, WebError> {
5846    let did = match current_did(&state, &headers).await {
5847        Some(d) => d,
5848        None => return Ok(Redirect::to("/login").into_response()),
5849    };
5850    let pool = &state.db;
5851
5852    // Collect the OPML text from whichever field carried it. Multipart errors
5853    // are mapped to their axum-native response so that an over-cap upload (the
5854    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
5855    // `413 Payload Too Large` rather than being swallowed by the blanket
5856    // `WebError` → `500` conversion.
5857    let mut opml_text = String::new();
5858    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5859        let name = field.name().unwrap_or("").to_string();
5860        if name == "opml" || name == "file" {
5861            let bytes = field.bytes().await.map_err(multipart_response)?;
5862            if !bytes.is_empty() {
5863                opml_text = String::from_utf8_lossy(&bytes).into_owned();
5864                if name == "file" {
5865                    break;
5866                }
5867            }
5868        }
5869    }
5870
5871    // A parse FAILURE and an empty-but-valid file are different things, and
5872    // `unwrap_or_default` collapsed them: a malformed export was reported to the
5873    // reader as "No feeds found in that OPML", which sends them looking at their
5874    // old reader for feeds that are right there in the file.
5875    let feeds =
5876        match opml::parse_opml(&opml_text) {
5877            Ok(feeds) => feeds,
5878            Err(err) => {
5879                warn!(%err, %did, "OPML import could not parse the uploaded file");
5880                return Ok(Redirect::to(&format!(
5881                "/?flash={}",
5882                qenc("That file could not be read as OPML. Export it again from your other reader?")
5883            ))
5884                .into_response());
5885            }
5886        };
5887    if feeds.is_empty() {
5888        info!(%did, "OPML import found no feeds");
5889        return Ok(
5890            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5891                .into_response(),
5892        );
5893    }
5894
5895    // Create any named folders first, mapping folder name → at:// URI so
5896    // subscriptions can reference them.
5897    let now = now_rfc3339();
5898    let mut folder_uris: std::collections::HashMap<String, String> =
5899        std::collections::HashMap::new();
5900    // Reuse existing folders where the name already exists.
5901    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5902        for (rkey, folder) in existing {
5903            folder_uris
5904                .entry(folder.name.clone())
5905                .or_insert_with(|| folder_uri(&did, &rkey));
5906        }
5907    }
5908    let mut wanted_folders: Vec<String> = feeds
5909        .iter()
5910        .filter_map(|f| f.folder.clone())
5911        .filter(|n| !n.is_empty())
5912        .collect();
5913    wanted_folders.sort();
5914    wanted_folders.dedup();
5915    for name in wanted_folders {
5916        if folder_uris.contains_key(&name) {
5917            continue;
5918        }
5919        let folder = Folder::new(name.clone(), now.clone());
5920        match state.repo().add_folder(&did, &folder).await {
5921            Ok(rkey) => {
5922                folder_uris.insert(name, folder_uri(&did, &rkey));
5923            }
5924            Err(err) => warn!(%err, %did, "OPML folder create failed"),
5925        }
5926    }
5927
5928    // Build one subscription record per PUBLIC feed + upsert the local cache row.
5929    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5930    // and reported back to the user — the same public-feeds-only stance as the
5931    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5932    // token onto the public network either.
5933    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5934    // the remaining headroom (cap − existing) once; public feeds beyond it are
5935    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5936    let sub_cap = state.config.max_subs_per_did;
5937    let mut headroom: Option<i64> = if sub_cap > 0 {
5938        let existing = store::count_subscriptions_for_did(pool, &did)
5939            .await
5940            .unwrap_or(0);
5941        Some((sub_cap - existing).max(0))
5942    } else {
5943        None
5944    };
5945    let mut trimmed_over_cap: usize = 0;
5946
5947    // Global feeds ceiling: an OPML import must not blow past the shared cache
5948    // ceiling any more than the single-add path may. Seed the remaining global
5949    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5950    // not already cached) consumes it. Existing/duplicate URLs add no row and
5951    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5952    // `<= 0` disables the ceiling.
5953    let feeds_cap = state.config.max_feeds_global;
5954    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5955        let existing = store::count_feeds(pool).await.unwrap_or(0);
5956        Some((feeds_cap - existing).max(0))
5957    } else {
5958        None
5959    };
5960    let mut trimmed_over_global: usize = 0;
5961
5962    let mut subs = Vec::with_capacity(feeds.len());
5963    let mut skipped_private: Vec<String> = Vec::new();
5964    // Imported into the PDS but not cached locally, so not pollable until the
5965    // next import touches them. Counted rather than only logged — see below.
5966    let mut uncached: usize = 0;
5967    // Entries this instance cannot store at all (an `at://` publication with
5968    // the flag off, an unsupported scheme). Counted, because the `continue`
5969    // below used to increment nothing while the privacy branch beside it
5970    // produced a label — so an OPML from a standard.site-enabled instance
5971    // imported "successfully" with entries missing and no reason given.
5972    let mut skipped_unsupported: usize = 0;
5973    for f in &feeds {
5974        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5975        // ever parsed it — the single-add path can't reach here because
5976        // `resolve_feed_url` must parse AND successfully fetch first. So
5977        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5978        // cached, and published as records to the user's PUBLIC repo. Note that
5979        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5980        // it returns `Public` for anything unparseable by design.
5981        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5982            info!(
5983                %did,
5984                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5985            );
5986            skipped_unsupported += 1;
5987            continue;
5988        }
5989        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5990            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5991            // Report by title where we have one, else the (public-safe) host.
5992            let label = f
5993                .title
5994                .clone()
5995                .filter(|t| !t.trim().is_empty())
5996                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5997            skipped_private.push(label);
5998            continue;
5999        }
6000
6001        // Over-cap: stop importing once headroom is exhausted (count the rest so
6002        // we can tell the user how many were dropped).
6003        if let Some(h) = headroom.as_mut() {
6004            if *h <= 0 {
6005                trimmed_over_cap += 1;
6006                continue;
6007            }
6008        }
6009
6010        // Global ceiling: a brand-new feed URL consumes global headroom. Once
6011        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
6012        // free — they add no row). Checked before decrementing the per-DID
6013        // headroom so a dropped feed doesn't burn the caller's own quota.
6014        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
6015            Ok(existing) => existing.is_none(),
6016            // On a lookup error, treat as existing (don't consume global
6017            // headroom) but still allow the upsert to proceed.
6018            Err(err) => {
6019                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
6020                false
6021            }
6022        };
6023        if is_new {
6024            if let Some(g) = global_headroom.as_mut() {
6025                if *g <= 0 {
6026                    trimmed_over_global += 1;
6027                    continue;
6028                }
6029                *g -= 1;
6030            }
6031        }
6032
6033        // Passed both caps: consume the per-DID headroom now that the feed is
6034        // actually being imported.
6035        if let Some(h) = headroom.as_mut() {
6036            *h -= 1;
6037        }
6038
6039        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
6040        sub.title = f.title.clone();
6041        sub.site_url = f.site_url.clone();
6042        sub.folder = f
6043            .folder
6044            .as_ref()
6045            .and_then(|name| folder_uris.get(name).cloned());
6046        subs.push(sub);
6047        // Same support ticket as the single-add path: no `feeds` row means the
6048        // poller never selects this subscription, so the import looks like it
6049        // worked and the feed silently never updates. Counted as well as logged,
6050        // because one line per feed in a 200-feed import is not something anyone
6051        // reads — the count goes to the reader.
6052        if let Err(err) = store::upsert_feed(
6053            pool,
6054            &store::NewFeed {
6055                url: f.feed_url.clone(),
6056                title: f.title.clone(),
6057                site_url: f.site_url.clone(),
6058                ..Default::default()
6059            },
6060        )
6061        .await
6062        {
6063            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
6064                                                  it will not be polled");
6065            uncached += 1;
6066        }
6067    }
6068
6069    // **A failed PDS write is not an import.**
6070    //
6071    // The subscriptions live in the reader's repo; a local `feeds` row is just a
6072    // poller hint. This used to `warn!` and then report "Imported N feeds"
6073    // regardless, so a total failure read as a total success — and the reader
6074    // would only discover otherwise on their next visit, with an empty sidebar.
6075    //
6076    // **And a part-landed write is not a failed one.** The batch goes out in
6077    // calls of at most 200 (#240: the reference PDS refuses more), sent in
6078    // order and stopped at the first failure, so what landed is a prefix of
6079    // `subs` and the error says how long. Saying "nothing was imported" after
6080    // the first 200 of 450 landed would send the reader to import the file
6081    // again, which adds those 200 a second time. Nothing local needs undoing
6082    // either way: the reader's `sub_ref` projection is rebuilt from the repo on
6083    // the next read, and a cached `feeds` row with no subscriber is the same
6084    // poller hint the total-failure path has always left behind.
6085    let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
6086        Ok(rkeys) => {
6087            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
6088            rkeys.len()
6089        }
6090        Err(err) => {
6091            let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
6092            warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
6093            landed
6094        }
6095    };
6096    if landed == 0 && !subs.is_empty() {
6097        return Ok(Redirect::to(&format!(
6098            "/?flash={}",
6099            qenc(
6100                "Could not save those subscriptions to your PDS, so nothing was imported. \
6101                 Try again in a moment."
6102            )
6103        ))
6104        .into_response());
6105    }
6106
6107    // Report the import count, plus any private/paid feeds skipped as unsupported.
6108    let mut flash = if landed < subs.len() {
6109        format!(
6110            "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
6111             other {} may not have been saved. Importing the same file again would add the first \
6112             {landed} a second time",
6113            subs.len(),
6114            subs.len() - landed
6115        )
6116    } else {
6117        format!("Imported {} feeds", subs.len())
6118    };
6119    if uncached > 0 {
6120        flash.push_str(&format!(
6121            ". {uncached} of them could not be cached locally and may not update until the next import."
6122        ));
6123    }
6124    if trimmed_over_cap > 0 {
6125        flash.push_str(&format!(
6126            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
6127        ));
6128    }
6129    if trimmed_over_global > 0 {
6130        flash.push_str(&format!(
6131            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
6132        ));
6133    }
6134    if !skipped_private.is_empty() {
6135        flash.push_str(&format!(
6136            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
6137            skipped_private.len(),
6138            skipped_private.join(", ")
6139        ));
6140    }
6141    if skipped_unsupported > 0 {
6142        // By count only — the URL is whatever the file said, and unlike the
6143        // private branch there is no public-safe label to give.
6144        flash.push_str(&format!(
6145            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
6146        ));
6147    }
6148    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
6149}
6150
6151/// A public-safe label for a skipped private feed when it has no title: just the
6152/// host, so we never echo the secret-bearing path/query back to the user.
6153fn private_feed_label(url: &str) -> String {
6154    url::Url::parse(url)
6155        .ok()
6156        .and_then(|u| u.host_str().map(str::to_string))
6157        .unwrap_or_else(|| "a private feed".to_string())
6158}
6159
6160/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
6161async fn export_opml(
6162    State(state): State<AppState>,
6163    headers: HeaderMap,
6164) -> Result<Response, WebError> {
6165    let did = match current_did(&state, &headers).await {
6166        Some(d) => d,
6167        None => return Ok(Redirect::to("/login").into_response()),
6168    };
6169
6170    // **An export must never be silently empty.** `unwrap_or_default` here turned
6171    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
6172    // backup, blank, at exactly the moment they reached for it. That was survivable
6173    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
6174    // this is the one caller that converts a refusal into data loss, and it is also
6175    // the recovery route the changelog points a locked-out reader at.
6176    let subs = match state.repo().list_subscriptions_sorted(&did).await {
6177        Ok(subs) => subs,
6178        Err(err) => {
6179            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
6180            return Ok(Redirect::to(&format!(
6181                "/manage?flash={}",
6182                qenc(EXPORT_INCOMPLETE_REFUSAL)
6183            ))
6184            .into_response());
6185        }
6186    };
6187    let folders = match state.repo().list_folders_sorted(&did).await {
6188        Ok(folders) => folders,
6189        Err(err) => {
6190            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
6191            return Ok(Redirect::to(&format!(
6192                "/manage?flash={}",
6193                qenc(EXPORT_INCOMPLETE_REFUSAL)
6194            ))
6195            .into_response());
6196        }
6197    };
6198    // The exporter matches a subscription's `folder` at-uri against the folder's
6199    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
6200    let folder_pairs: Vec<(String, Folder)> = folders
6201        .into_iter()
6202        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
6203        .collect();
6204
6205    let body = opml::to_opml(&subs, &folder_pairs);
6206    let mut resp = (StatusCode::OK, body).into_response();
6207    resp.headers_mut().insert(
6208        header::CONTENT_TYPE,
6209        "text/x-opml; charset=utf-8".parse().unwrap(),
6210    );
6211    resp.headers_mut().insert(
6212        header::CONTENT_DISPOSITION,
6213        "attachment; filename=\"featherreader-subscriptions.opml\""
6214            .parse()
6215            .unwrap(),
6216    );
6217    Ok(resp)
6218}
6219
6220// ---------------------------------------------------------------------------
6221// Signed session cookie (HMAC-SHA256, dependency-free)
6222// ---------------------------------------------------------------------------
6223
6224/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
6225fn set_cookie(resp: &mut Response, cookie: &str) {
6226    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
6227        resp.headers_mut()
6228            .append(axum::http::header::SET_COOKIE, value);
6229    }
6230}
6231
6232/// Whether the request came from htmx (the `HX-Request` header).
6233fn is_htmx(headers: &HeaderMap) -> bool {
6234    headers
6235        .get("HX-Request")
6236        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
6237}
6238
6239/// Whether a mark-read / star request originated from the single-entry READER
6240/// (as opposed to the list view). The reader's forms tag themselves with
6241/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
6242/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
6243/// isn't in the DOM), the list gets the row (`entry_row.html`).
6244fn is_reader_request(headers: &HeaderMap) -> bool {
6245    headers
6246        .get("X-FR-Reader")
6247        .is_some_and(|v| v.as_bytes() == b"1")
6248}
6249
6250/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
6251/// server-minted **session id** (never the DID — so the cookie can't be forged
6252/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
6253/// server-side session id).
6254mod cookie {
6255    use super::{HeaderMap, SESSION_COOKIE};
6256
6257    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
6258    pub fn sign_session(sid: &str, secret: &str) -> String {
6259        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
6260    }
6261
6262    /// Verify the request's session cookie and return the session id it carries.
6263    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
6264        verify_value(headers, SESSION_COOKIE, secret)
6265    }
6266
6267    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
6268    /// value`), so a signature minted for one cookie can't verify under another —
6269    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
6270    /// The NUL separator can't appear in a cookie name, so the encoding is
6271    /// unambiguous.
6272    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
6273        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
6274        msg.extend_from_slice(name.as_bytes());
6275        msg.push(0);
6276        msg.extend_from_slice(value.as_bytes());
6277        msg
6278    }
6279
6280    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
6281    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
6282    /// generic form behind both the session cookie and the short-lived invite
6283    /// cookie; domain-separating by name keeps a signature valid only for the
6284    /// cookie it was minted for.
6285    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
6286        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
6287        let b64 = b64url_encode(value.as_bytes());
6288        format!(
6289            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
6290        )
6291    }
6292
6293    /// Verify + read a value out of the named signed cookie (`None` on absent /
6294    /// tampered / forged / cross-cookie). The generic form behind both readers.
6295    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
6296        let raw = cookie_value(headers, name)?;
6297        let (b64, sig) = raw.split_once('.')?;
6298        let bytes = b64url_decode(b64)?;
6299        let value = String::from_utf8(bytes).ok()?;
6300        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
6301        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6302            Some(value)
6303        } else {
6304            None
6305        }
6306    }
6307
6308    /// Sign an arbitrary `value` into an opaque, URL-safe token string
6309    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
6310    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
6311    /// URL query param (the bot's claim link). `label` domain-separates it from
6312    /// the cookies so a token can't be replayed as a cookie value.
6313    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
6314        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
6315        let b64 = b64url_encode(value.as_bytes());
6316        format!("{b64}.{sig}")
6317    }
6318
6319    /// Verify a token minted by [`sign_token`] and return the wrapped value
6320    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
6321    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
6322        let (b64, sig) = token.split_once('.')?;
6323        let bytes = b64url_decode(b64)?;
6324        let value = String::from_utf8(bytes).ok()?;
6325        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
6326        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
6327            Some(value)
6328        } else {
6329            None
6330        }
6331    }
6332
6333    /// Pull one cookie value out of the `Cookie` request header.
6334    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
6335        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
6336        for part in header.split(';') {
6337            let part = part.trim();
6338            if let Some((k, v)) = part.split_once('=') {
6339                if k == name {
6340                    return Some(v.to_string());
6341                }
6342            }
6343        }
6344        None
6345    }
6346
6347    /// Constant-time byte comparison (avoid signature-timing leaks). Public
6348    /// within the module so the bot-secret bearer check reuses the exact same
6349    /// comparator as the cookie/token HMAC checks (one implementation to audit).
6350    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
6351        if a.len() != b.len() {
6352            return false;
6353        }
6354        let mut diff = 0u8;
6355        for (x, y) in a.iter().zip(b.iter()) {
6356            diff |= x ^ y;
6357        }
6358        diff == 0
6359    }
6360
6361    // -- URL-safe base64 (no padding), std-only --------------------------------
6362
6363    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6364
6365    fn b64url_encode(input: &[u8]) -> String {
6366        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
6367        for chunk in input.chunks(3) {
6368            let b = [
6369                chunk[0],
6370                *chunk.get(1).unwrap_or(&0),
6371                *chunk.get(2).unwrap_or(&0),
6372            ];
6373            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
6374            out.push(B64[((n >> 18) & 63) as usize] as char);
6375            out.push(B64[((n >> 12) & 63) as usize] as char);
6376            if chunk.len() > 1 {
6377                out.push(B64[((n >> 6) & 63) as usize] as char);
6378            }
6379            if chunk.len() > 2 {
6380                out.push(B64[(n & 63) as usize] as char);
6381            }
6382        }
6383        out
6384    }
6385
6386    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
6387        fn val(c: u8) -> Option<u32> {
6388            match c {
6389                b'A'..=b'Z' => Some((c - b'A') as u32),
6390                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6391                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6392                b'-' => Some(62),
6393                b'_' => Some(63),
6394                _ => None,
6395            }
6396        }
6397        let bytes = input.as_bytes();
6398        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
6399        for chunk in bytes.chunks(4) {
6400            let mut n = 0u32;
6401            let mut valid = 0;
6402            for (i, &c) in chunk.iter().enumerate() {
6403                n |= val(c)? << (18 - 6 * i);
6404                valid += 1;
6405            }
6406            out.push((n >> 16) as u8);
6407            if valid > 2 {
6408                out.push((n >> 8) as u8);
6409            }
6410            if valid > 3 {
6411                out.push(n as u8);
6412            }
6413        }
6414        Some(out)
6415    }
6416
6417    // -- HMAC-SHA256, std-only -------------------------------------------------
6418
6419    /// HMAC-SHA256(key, msg) as lowercase hex.
6420    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
6421        const BLOCK: usize = 64;
6422        let mut k = [0u8; BLOCK];
6423        if key.len() > BLOCK {
6424            let d = sha256(key);
6425            k[..32].copy_from_slice(&d);
6426        } else {
6427            k[..key.len()].copy_from_slice(key);
6428        }
6429        let mut ipad = [0x36u8; BLOCK];
6430        let mut opad = [0x5cu8; BLOCK];
6431        for i in 0..BLOCK {
6432            ipad[i] ^= k[i];
6433            opad[i] ^= k[i];
6434        }
6435        let mut inner = Vec::with_capacity(BLOCK + msg.len());
6436        inner.extend_from_slice(&ipad);
6437        inner.extend_from_slice(msg);
6438        let inner_hash = sha256(&inner);
6439        let mut outer = Vec::with_capacity(BLOCK + 32);
6440        outer.extend_from_slice(&opad);
6441        outer.extend_from_slice(&inner_hash);
6442        let mac = sha256(&outer);
6443        let mut hex = String::with_capacity(64);
6444        for b in mac {
6445            hex.push_str(&format!("{b:02x}"));
6446        }
6447        hex
6448    }
6449
6450    /// SHA-256 (FIPS 180-4), std-only.
6451    fn sha256(data: &[u8]) -> [u8; 32] {
6452        const K: [u32; 64] = [
6453            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
6454            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
6455            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
6456            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
6457            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
6458            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
6459            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
6460            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
6461            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
6462            0xc67178f2,
6463        ];
6464        let mut h: [u32; 8] = [
6465            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
6466            0x5be0cd19,
6467        ];
6468
6469        let bit_len = (data.len() as u64) * 8;
6470        let mut msg = data.to_vec();
6471        msg.push(0x80);
6472        while msg.len() % 64 != 56 {
6473            msg.push(0);
6474        }
6475        msg.extend_from_slice(&bit_len.to_be_bytes());
6476
6477        for block in msg.chunks(64) {
6478            let mut w = [0u32; 64];
6479            for i in 0..16 {
6480                w[i] = u32::from_be_bytes([
6481                    block[i * 4],
6482                    block[i * 4 + 1],
6483                    block[i * 4 + 2],
6484                    block[i * 4 + 3],
6485                ]);
6486            }
6487            for i in 16..64 {
6488                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
6489                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
6490                w[i] = w[i - 16]
6491                    .wrapping_add(s0)
6492                    .wrapping_add(w[i - 7])
6493                    .wrapping_add(s1);
6494            }
6495            let mut a = h;
6496            for i in 0..64 {
6497                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
6498                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
6499                let t1 = a[7]
6500                    .wrapping_add(s1)
6501                    .wrapping_add(ch)
6502                    .wrapping_add(K[i])
6503                    .wrapping_add(w[i]);
6504                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
6505                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
6506                let t2 = s0.wrapping_add(maj);
6507                a[7] = a[6];
6508                a[6] = a[5];
6509                a[5] = a[4];
6510                a[4] = a[3].wrapping_add(t1);
6511                a[3] = a[2];
6512                a[2] = a[1];
6513                a[1] = a[0];
6514                a[0] = t1.wrapping_add(t2);
6515            }
6516            for i in 0..8 {
6517                h[i] = h[i].wrapping_add(a[i]);
6518            }
6519        }
6520
6521        let mut out = [0u8; 32];
6522        for (i, word) in h.iter().enumerate() {
6523            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6524        }
6525        out
6526    }
6527
6528    #[cfg(test)]
6529    mod tests {
6530        use super::*;
6531
6532        #[test]
6533        fn sha256_known_vector() {
6534            let d = sha256(b"abc");
6535            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6536            assert_eq!(
6537                hex,
6538                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6539            );
6540        }
6541
6542        #[test]
6543        fn hmac_known_vector() {
6544            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6545            assert_eq!(
6546                mac,
6547                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6548            );
6549        }
6550
6551        #[test]
6552        fn sign_verify_round_trips() {
6553            let secret = "test-secret";
6554            let sid = "9f2c-opaque-session-id";
6555            let cookie = sign_session(sid, secret);
6556            let pair = cookie.split(';').next().unwrap().to_string();
6557            let mut headers = HeaderMap::new();
6558            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6559            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6560            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
6561            assert!(verify_session(&headers, "other-secret").is_none());
6562        }
6563
6564        #[test]
6565        fn forged_and_tampered_cookies_are_rejected() {
6566            let secret = "test-secret";
6567
6568            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
6569            //    the secret, so an arbitrary signature must not verify.
6570            let forged = format!(
6571                "{SESSION_COOKIE}={}.{}",
6572                b64url_encode(b"attacker-chosen-sid"),
6573                "deadbeef".repeat(8) // 64 hex chars, wrong sig
6574            );
6575            let mut headers = HeaderMap::new();
6576            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6577            assert!(verify_session(&headers, secret).is_none());
6578
6579            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
6580            //    keeping the original signature — must not verify.
6581            let cookie = sign_session("real-sid", secret);
6582            let pair = cookie.split(';').next().unwrap();
6583            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6584            let tampered = format!(
6585                "{SESSION_COOKIE}={}.{}",
6586                b64url_encode(b"different-sid"),
6587                sig
6588            );
6589            let mut headers2 = HeaderMap::new();
6590            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6591            assert!(verify_session(&headers2, secret).is_none());
6592        }
6593
6594        #[test]
6595        fn b64url_round_trips() {
6596            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6597                let enc = b64url_encode(s.as_bytes());
6598                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6599            }
6600        }
6601    }
6602}
6603
6604// ---------------------------------------------------------------------------
6605// Small store helpers local to the web layer
6606// ---------------------------------------------------------------------------
6607
6608/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
6609///
6610/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
6611/// `did` does not subscribe to its feed. This is the per-DID read gate for the
6612/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
6613/// deduped by URL, but no DID can read another DID's cached article.
6614///
6615/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
6616/// that renders `content_html`, and it fetches exactly one row. The list views
6617/// go through [`store::list_entries`], which is both paged and body-free — see
6618/// [`store::EntryListRow`] for why they had to stop sharing this projection.
6619async fn get_entry_by_id(
6620    pool: &store::Pool,
6621    did: &str,
6622    id: i64,
6623) -> anyhow::Result<Option<store::Entry>> {
6624    let entry = sqlx::query_as::<_, store::Entry>(
6625        r#"
6626        SELECT e.* FROM entries e
6627        WHERE e.id = ?2
6628          AND EXISTS (
6629              SELECT 1 FROM sub_ref sr
6630              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6631          )
6632        "#,
6633    )
6634    .bind(did)
6635    .bind(id)
6636    .fetch_optional(pool)
6637    .await?;
6638    Ok(entry)
6639}
6640
6641/// Whether `entry_id` is marked read for `did` (absent state row = unread).
6642async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6643    let read: Option<bool> =
6644        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6645            .bind(did)
6646            .bind(entry_id)
6647            .fetch_optional(pool)
6648            .await?
6649            .flatten();
6650    Ok(read.unwrap_or(false))
6651}
6652
6653/// Whether `entry_id` is starred for `did` (absent state row = not starred).
6654async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6655    let starred: Option<bool> =
6656        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6657            .bind(did)
6658            .bind(entry_id)
6659            .fetch_optional(pool)
6660            .await?
6661            .flatten();
6662    Ok(starred.unwrap_or(false))
6663}
6664
6665/// Feed display title for one entry's feed id (via a single lookup).
6666async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6667    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6668        .bind(feed_id)
6669        .fetch_optional(pool)
6670        .await
6671    {
6672        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6673        _ => String::new(),
6674    }
6675}
6676
6677/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
6678/// be forced (mark-read path) or looked up (`None` — star path).
6679async fn build_entry_row(
6680    pool: &store::Pool,
6681    did: &str,
6682    id: i64,
6683    read: Option<bool>,
6684) -> anyhow::Result<Option<EntryRow>> {
6685    let entry = match get_entry_by_id(pool, did, id).await? {
6686        Some(e) => e,
6687        None => return Ok(None),
6688    };
6689    let read = match read {
6690        Some(r) => r,
6691        None => entry_is_read(pool, did, id).await?,
6692    };
6693    let starred = entry_is_starred(pool, did, id).await?;
6694    Ok(Some(EntryRow {
6695        id: entry.id,
6696        title: entry
6697            .title
6698            .clone()
6699            .filter(|t| !t.trim().is_empty())
6700            .unwrap_or_else(|| "(untitled)".to_string()),
6701        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6702        published: display_date(entry.published.as_deref()),
6703        read,
6704        starred,
6705        link: SafeLink::entry(id, ""),
6706        cached: true,
6707        rkey: String::new(),
6708    }))
6709}
6710
6711/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
6712fn now_rfc3339() -> String {
6713    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6714}
6715
6716#[cfg(test)]
6717mod tests {
6718    use super::*;
6719
6720    #[test]
6721    fn qenc_encodes_reserved() {
6722        assert_eq!(qenc("a b"), "a%20b");
6723        assert_eq!(
6724            qenc("https://example.com/feed.xml"),
6725            "https%3A%2F%2Fexample.com%2Ffeed.xml"
6726        );
6727        assert_eq!(
6728            qenc("at://did:plc:x/c/r"),
6729            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6730        );
6731        // Unreserved chars pass through untouched.
6732        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6733    }
6734
6735    #[test]
6736    fn folder_uri_shape() {
6737        assert_eq!(
6738            folder_uri("did:plc:abc", "3kfolder"),
6739            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6740        );
6741    }
6742
6743    // -- public-feeds-only: private/paid feeds are refused --------------------
6744
6745    #[test]
6746    fn private_feeds_are_classified_private_across_providers() {
6747        // The add + OPML paths both gate on this classifier; assert it flags a
6748        // spread of paid providers (newsletters + private podcasts) and the
6749        // generic credential-in-URL shapes.
6750        for url in [
6751            "https://author.substack.com/feed/private/deadbeefcafe1234",
6752            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6753            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6754            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6755            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6756            "https://user:pass@example.com/feed",
6757        ] {
6758            assert!(
6759                feed::classify_feed_privacy(url).is_private(),
6760                "expected private: {url}"
6761            );
6762        }
6763    }
6764
6765    #[test]
6766    fn public_feeds_stay_public() {
6767        for url in [
6768            "https://author.substack.com/feed",
6769            "https://wordpress.example.com/feed/",
6770            "https://example.com/rss.xml",
6771            "https://example.org/atom.xml",
6772            // YouTube channel/playlist RSS is fully public — must not false-block.
6773            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6774            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6775        ] {
6776            assert!(
6777                !feed::classify_feed_privacy(url).is_private(),
6778                "expected public: {url}"
6779            );
6780        }
6781    }
6782
6783    #[test]
6784    fn private_feed_label_is_public_safe_host_only() {
6785        // The OPML skip report must never echo the secret path/query, only the host.
6786        let label =
6787            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6788        assert_eq!(label, "author.substack.com");
6789        assert!(!label.contains("deadbeefcafe1234token"));
6790        assert!(!label.contains("/private/"));
6791        // An unparseable URL degrades to a generic label.
6792        assert_eq!(private_feed_label("not a url"), "a private feed");
6793    }
6794
6795    #[test]
6796    fn refusal_message_promises_nothing_stored() {
6797        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6798        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6799    }
6800
6801    #[test]
6802    fn scope_query_preserves_context() {
6803        let q = EntryQuery {
6804            feed: Some("https://example.com/feed.xml".to_string()),
6805            folder: None,
6806            view: Some("all".to_string()),
6807        };
6808        let s = scope_query(&q);
6809        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6810        assert!(s.contains("view=all"));
6811
6812        // Default view is omitted.
6813        let q2 = EntryQuery {
6814            feed: None,
6815            folder: None,
6816            view: Some("unread".to_string()),
6817        };
6818        assert_eq!(scope_query(&q2), "");
6819    }
6820
6821    // -- closed-beta invite gate + rate-limit + cache-control ------------------
6822
6823    use axum::body::Body;
6824    use axum::http::Request;
6825    use tower::ServiceExt; // for `oneshot`
6826
6827    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
6828    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
6829    /// can forge matching cookies.
6830    async fn test_state(allowed: &[&str]) -> AppState {
6831        let db = store::init_url("sqlite::memory:").await.unwrap();
6832        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6833        store::ensure_seed(&db, &dids).await.unwrap();
6834        let config = Config {
6835            allowed_dids: dids,
6836            cookie_secret: "test-cookie-secret-000".to_string(),
6837            beta_cap: 3,
6838            ..Config::default()
6839        };
6840        AppState::new(config, db).unwrap()
6841    }
6842
6843    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
6844    /// looked up in the registry, so create the session first).
6845    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6846        let sid = state.sessions.create(Session {
6847            did: did.to_string(),
6848            handle: handle.map(str::to_string),
6849        });
6850        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6851        sc.split(';').next().unwrap().to_string()
6852    }
6853
6854    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
6855    /// long time to accept distinct source IPs on two unauthenticated guarded
6856    /// routes.
6857    #[test]
6858    fn the_rate_limit_map_is_bounded() {
6859        let rl = RateLimiter::shared();
6860        let now = Instant::now();
6861        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6862            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
6863            // ordering below is well-defined.
6864            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6865            rl.check_at(ip, now + Duration::from_millis(i as u64));
6866        }
6867        let len = rl.inner.lock().unwrap().buckets.len();
6868        assert!(
6869            len <= MAX_RATE_BUCKETS,
6870            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6871        );
6872    }
6873
6874    /// Eviction must not hand a throttled attacker a fresh burst.
6875    ///
6876    /// The bound is LRU, so the one bucket an attacker can never evict is their
6877    /// own — it is the most recently touched thing in the map. If this inverted,
6878    /// the size cap would become a rate-limit bypass: spray addresses until the
6879    /// map overflows, then resume.
6880    #[test]
6881    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6882        let rl = RateLimiter::shared();
6883        let base = Instant::now();
6884        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6885        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
6886        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
6887        // millisecond step made the whole flood take a second, and the refill —
6888        // working correctly — then looked exactly like an eviction bypass.
6889        let at = |n: u64| base + Duration::from_nanos(n);
6890
6891        // Spend the burst. `RATE_BURST` allowed, then refused.
6892        for i in 0..(RATE_BURST as u64) {
6893            assert!(rl.check_at(attacker, at(i)));
6894        }
6895        assert!(
6896            !rl.check_at(attacker, at(RATE_BURST as u64)),
6897            "burst was not exhausted; the rest of this test proves nothing"
6898        );
6899
6900        // Now overflow the map from other addresses, interleaving the attacker
6901        // so their bucket stays hot — the realistic shape of the attack.
6902        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6903            let t = at(100 + i as u64 * 2);
6904            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6905            rl.check_at(ip, t);
6906            assert!(
6907                !rl.check_at(attacker, t),
6908                "the attacker got a token back after evictions at i={i}"
6909            );
6910        }
6911    }
6912
6913    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
6914    /// of the whole map on every guarded request, on one shared core.
6915    #[test]
6916    fn the_idle_sweep_does_not_run_on_every_request() {
6917        let rl = RateLimiter::shared();
6918        let start = Instant::now();
6919        let a: IpAddr = "198.51.100.1".parse().unwrap();
6920        let b: IpAddr = "198.51.100.2".parse().unwrap();
6921
6922        rl.check_at(a, start);
6923        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
6924        // the sweep interval has elapsed too, so this request does sweep it.
6925        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6926        assert!(
6927            !rl.inner.lock().unwrap().buckets.contains_key(&a),
6928            "an idle bucket survived a sweep that was due"
6929        );
6930
6931        // A second request moments later must NOT re-sweep — `b` is still there,
6932        // and the recorded sweep time must not have moved.
6933        let before = rl.inner.lock().unwrap().last_sweep;
6934        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6935        assert_eq!(
6936            rl.inner.lock().unwrap().last_sweep,
6937            before,
6938            "the sweep ran again within the interval"
6939        );
6940    }
6941
6942    #[test]
6943    fn rate_limited_paths_match_expected() {
6944        use axum::http::Method;
6945        assert!(is_rate_limited_path("/login", &Method::GET));
6946        assert!(is_rate_limited_path("/login", &Method::POST));
6947        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6948        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6949        assert!(is_rate_limited_path("/opml", &Method::POST));
6950        assert!(is_rate_limited_path("/read-all", &Method::POST));
6951        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6952        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6953        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6954        // Read-only navigation is NOT limited.
6955        assert!(!is_rate_limited_path("/", &Method::GET));
6956        assert!(!is_rate_limited_path("/about", &Method::GET));
6957        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6958        assert!(!is_rate_limited_path("/login", &Method::HEAD));
6959    }
6960
6961    #[test]
6962    fn rate_limiter_allows_burst_then_429s() {
6963        let rl = RateLimiter::shared();
6964        let ip: IpAddr = "203.0.113.7".parse().unwrap();
6965        // The full burst passes.
6966        for _ in 0..(RATE_BURST as usize) {
6967            assert!(rl.check(ip));
6968        }
6969        // The next one (no time elapsed → no refill) is rejected.
6970        assert!(!rl.check(ip));
6971        // A different IP has its own bucket.
6972        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6973        assert!(rl.check(ip2));
6974    }
6975
6976    #[test]
6977    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6978        // With NO trusted header configured, a client-supplied X-Forwarded-For
6979        // must be ignored entirely — the limiter keys on the real socket peer,
6980        // so an attacker can't mint a fresh bucket per forged XFF value.
6981        let mut h = HeaderMap::new();
6982        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6983        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6984        assert_eq!(
6985            client_ip(&h, Some(&sock), None),
6986            Some("203.0.113.55".parse().unwrap()),
6987            "spoofed XFF must not override the socket peer"
6988        );
6989    }
6990
6991    #[test]
6992    fn client_ip_uses_trusted_header_last_hop() {
6993        // With a trusted proxy header configured, the client IP comes from THAT
6994        // header (the proxy overwrites any client copy). On a comma list we take
6995        // the RIGHT-most hop — the one the trusted proxy appended — so a
6996        // client-forged left-most value is ignored.
6997        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6998
6999        let mut h = HeaderMap::new();
7000        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
7001        assert_eq!(
7002            client_ip(&h, Some(&sock), Some("fly-client-ip")),
7003            Some("198.51.100.9".parse().unwrap())
7004        );
7005
7006        // Attacker prepends a forged hop; the trusted proxy appends the real one.
7007        let mut h2 = HeaderMap::new();
7008        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
7009        assert_eq!(
7010            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
7011            Some("198.51.100.9".parse().unwrap()),
7012            "must take the right-most (trusted) hop, not the forged left-most"
7013        );
7014
7015        // Trusted header absent → fall back to the socket peer.
7016        let h3 = HeaderMap::new();
7017        assert_eq!(
7018            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
7019            Some("10.0.0.1".parse().unwrap())
7020        );
7021    }
7022
7023    #[test]
7024    fn invite_cookie_round_trips_and_rejects_tamper() {
7025        let secret = "test-cookie-secret-000";
7026        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
7027        let pair = sc.split(';').next().unwrap();
7028        let mut h = HeaderMap::new();
7029        h.insert(header::COOKIE, pair.parse().unwrap());
7030        assert_eq!(
7031            invite_cookie_code(&h, secret).as_deref(),
7032            Some("FEATHER-ABCDWXYZ")
7033        );
7034        // Wrong secret → rejected.
7035        assert!(invite_cookie_code(&h, "other").is_none());
7036    }
7037
7038    #[tokio::test]
7039    async fn preflight_valid_expired_and_full() {
7040        let state = test_state(&["did:plc:admin"]).await;
7041        // A minted, active code preflights OK.
7042        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7043            .await
7044            .unwrap();
7045        assert!(preflight_code(&state, &code).await.is_ok());
7046
7047        // A code whose expiry is in the past preflights as Expired. (mint_code
7048        // clamps negative ttl to 0, so back-date the row directly for a
7049        // deterministic past expiry.)
7050        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
7051            .await
7052            .unwrap();
7053        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
7054            .bind(chrono::Utc::now().timestamp() - 3600)
7055            .bind(&expired)
7056            .execute(&state.db)
7057            .await
7058            .unwrap();
7059        assert_eq!(
7060            preflight_code(&state, &expired).await,
7061            Err(store::RedeemError::Expired)
7062        );
7063
7064        // Unknown code → NotFound.
7065        assert_eq!(
7066            preflight_code(&state, "FEATHER-NOPENOPE").await,
7067            Err(store::RedeemError::NotFound)
7068        );
7069
7070        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
7071        // must report CapacityFull.
7072        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7073            .await
7074            .unwrap();
7075        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7076            .await
7077            .unwrap();
7078        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7079        assert_eq!(
7080            preflight_code(&state, &code).await,
7081            Err(store::RedeemError::CapacityFull)
7082        );
7083    }
7084
7085    // -- Bot claim link + shared-secret mint ---------------------------------
7086
7087    /// A test state with a configured bot secret (so `/bot/claims` is live).
7088    async fn bot_state(bot_secret: &str) -> AppState {
7089        let db = store::init_url("sqlite::memory:").await.unwrap();
7090        store::ensure_seed(&db, &["did:plc:admin".to_string()])
7091            .await
7092            .unwrap();
7093        let config = Config {
7094            allowed_dids: vec!["did:plc:admin".to_string()],
7095            cookie_secret: "test-cookie-secret-000".to_string(),
7096            beta_cap: 3,
7097            bot_secret: Some(bot_secret.to_string()),
7098            public_url: "https://feather-reader.com".to_string(),
7099            ..Config::default()
7100        };
7101        AppState::new(config, db).unwrap()
7102    }
7103
7104    #[test]
7105    fn claim_token_round_trips_and_rejects_tamper() {
7106        let secret = "test-cookie-secret-000";
7107        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
7108        // No cookie framing — a bare URL-safe token.
7109        assert!(!token.contains(';'));
7110        assert_eq!(
7111            claim_token_code(&token, secret).as_deref(),
7112            Some("FEATHER-ABCDWXYZ")
7113        );
7114        // Wrong secret → rejected.
7115        assert!(claim_token_code(&token, "other").is_none());
7116        // Tampered token → rejected.
7117        let mut bad = token.clone();
7118        bad.push('x');
7119        assert!(claim_token_code(&bad, secret).is_none());
7120        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
7121        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
7122        // recover it WITHOUT the secret). The security is single-use + HMAC
7123        // integrity + rate-limit, not secrecy of the code. Assert the code half is
7124        // publicly decodable (a plain base64url decode, no secret involved).
7125        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
7126        assert_eq!(
7127            test_b64url_decode(b64).as_deref(),
7128            Some("FEATHER-ABCDWXYZ".as_bytes()),
7129            "the code half of the token is plain base64url, decodable by anyone"
7130        );
7131    }
7132
7133    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
7134    /// claim token's code half needs NO secret to recover (it is not confidential).
7135    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
7136        fn val(c: u8) -> Option<u32> {
7137            match c {
7138                b'A'..=b'Z' => Some((c - b'A') as u32),
7139                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
7140                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
7141                b'-' => Some(62),
7142                b'_' => Some(63),
7143                _ => None,
7144            }
7145        }
7146        let mut out = Vec::with_capacity(input.len() / 4 * 3);
7147        for chunk in input.as_bytes().chunks(4) {
7148            let mut n = 0u32;
7149            let mut bits = 0;
7150            for &c in chunk {
7151                n = (n << 6) | val(c)?;
7152                bits += 6;
7153            }
7154            let bytes = bits / 8;
7155            n <<= 24 - bits;
7156            for i in 0..bytes {
7157                out.push((n >> (16 - i * 8)) as u8);
7158            }
7159        }
7160        Some(out)
7161    }
7162
7163    #[tokio::test]
7164    async fn bot_mint_then_claim_grants_a_seat() {
7165        let state = bot_state("bot-secret-abcdef").await;
7166        let app = router(state.clone());
7167
7168        // 1. Mint a claim via the shared-secret endpoint.
7169        let resp = app
7170            .clone()
7171            .oneshot(
7172                Request::builder()
7173                    .method("POST")
7174                    .uri("/bot/claims")
7175                    .header("x-bot-secret", "bot-secret-abcdef")
7176                    .body(Body::empty())
7177                    .unwrap(),
7178            )
7179            .await
7180            .unwrap();
7181        assert_eq!(resp.status(), StatusCode::OK);
7182        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7183            .await
7184            .unwrap();
7185        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7186        let token = json["token"].as_str().unwrap().to_string();
7187        let url = json["url"].as_str().unwrap();
7188        assert!(url.starts_with("https://feather-reader.com/claim?t="));
7189        // The raw code is returned for the bot's records but not embedded in url.
7190        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
7191        assert!(!url.contains("FEATHER-"));
7192
7193        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
7194        let resp = app
7195            .clone()
7196            .oneshot(
7197                Request::builder()
7198                    .method("GET")
7199                    .uri(format!("/claim?t={}", qenc(&token)))
7200                    .body(Body::empty())
7201                    .unwrap(),
7202            )
7203            .await
7204            .unwrap();
7205        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7206        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7207        let set_cookie = resp
7208            .headers()
7209            .get(header::SET_COOKIE)
7210            .unwrap()
7211            .to_str()
7212            .unwrap();
7213        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
7214
7215        // 3. The reserved cookie carries the same code the token wrapped, and
7216        //    redeeming it (the callback's machinery) grants a seat.
7217        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
7218        let out = store::redeem_code(
7219            &state.db,
7220            &code,
7221            "did:plc:follower",
7222            None,
7223            state.config.beta_cap,
7224        )
7225        .await
7226        .unwrap();
7227        assert_eq!(out, Ok(()));
7228        assert!(store::has_beta_access(&state.db, "did:plc:follower")
7229            .await
7230            .unwrap());
7231    }
7232
7233    #[tokio::test]
7234    async fn claim_with_invalid_token_bounces() {
7235        let state = bot_state("bot-secret-abcdef").await;
7236        let app = router(state);
7237        let resp = app
7238            .oneshot(
7239                Request::builder()
7240                    .method("GET")
7241                    .uri("/claim?t=not-a-real-token")
7242                    .body(Body::empty())
7243                    .unwrap(),
7244            )
7245            .await
7246            .unwrap();
7247        // Renders the invite page (200), NOT a redirect to /login.
7248        assert_eq!(resp.status(), StatusCode::OK);
7249    }
7250
7251    #[tokio::test]
7252    async fn claim_with_used_token_is_refused() {
7253        let state = bot_state("bot-secret-abcdef").await;
7254        // Mint a code + wrap it, then redeem it out from under the token.
7255        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
7256            .await
7257            .unwrap();
7258        let token = sign_claim_token(&code, &state.config.cookie_secret);
7259        store::redeem_code(
7260            &state.db,
7261            &code,
7262            "did:plc:someone",
7263            None,
7264            state.config.beta_cap,
7265        )
7266        .await
7267        .unwrap()
7268        .unwrap();
7269        let app = router(state);
7270        let resp = app
7271            .oneshot(
7272                Request::builder()
7273                    .method("GET")
7274                    .uri(format!("/claim?t={}", qenc(&token)))
7275                    .body(Body::empty())
7276                    .unwrap(),
7277            )
7278            .await
7279            .unwrap();
7280        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
7281        assert_eq!(resp.status(), StatusCode::OK);
7282        assert!(resp.headers().get(header::SET_COOKIE).is_none());
7283    }
7284
7285    #[tokio::test]
7286    async fn bot_claims_rejects_bad_and_missing_secret() {
7287        let state = bot_state("bot-secret-abcdef").await;
7288        let app = router(state);
7289        // Wrong secret.
7290        let resp = app
7291            .clone()
7292            .oneshot(
7293                Request::builder()
7294                    .method("POST")
7295                    .uri("/bot/claims")
7296                    .header("x-bot-secret", "wrong")
7297                    .body(Body::empty())
7298                    .unwrap(),
7299            )
7300            .await
7301            .unwrap();
7302        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7303        // Missing secret.
7304        let resp = app
7305            .oneshot(
7306                Request::builder()
7307                    .method("POST")
7308                    .uri("/bot/claims")
7309                    .body(Body::empty())
7310                    .unwrap(),
7311            )
7312            .await
7313            .unwrap();
7314        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7315    }
7316
7317    #[tokio::test]
7318    async fn bot_claims_disabled_when_secret_unset() {
7319        // test_state configures NO bot secret → the endpoint is off (503).
7320        let state = test_state(&["did:plc:admin"]).await;
7321        let app = router(state);
7322        let resp = app
7323            .oneshot(
7324                Request::builder()
7325                    .method("POST")
7326                    .uri("/bot/claims")
7327                    .header("x-bot-secret", "anything")
7328                    .body(Body::empty())
7329                    .unwrap(),
7330            )
7331            .await
7332            .unwrap();
7333        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
7334    }
7335
7336    #[tokio::test]
7337    async fn bot_claims_refuses_at_capacity() {
7338        let state = bot_state("bot-secret-abcdef").await;
7339        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
7340        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
7341            .await
7342            .unwrap();
7343        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
7344            .await
7345            .unwrap();
7346        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
7347        let app = router(state);
7348        let resp = app
7349            .oneshot(
7350                Request::builder()
7351                    .method("POST")
7352                    .uri("/bot/claims")
7353                    .header("x-bot-secret", "bot-secret-abcdef")
7354                    .body(Body::empty())
7355                    .unwrap(),
7356            )
7357            .await
7358            .unwrap();
7359        assert_eq!(resp.status(), StatusCode::CONFLICT);
7360        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7361            .await
7362            .unwrap();
7363        assert!(String::from_utf8_lossy(&bytes).contains("full"));
7364    }
7365
7366    #[tokio::test]
7367    async fn bot_claims_counts_outstanding_codes_against_cap() {
7368        let state = bot_state("bot-secret-abcdef").await;
7369        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
7370        store::mint_code(&state.db, "did:plc:admin", 3600)
7371            .await
7372            .unwrap();
7373        store::mint_code(&state.db, "did:plc:admin", 3600)
7374            .await
7375            .unwrap();
7376        let app = router(state);
7377        let resp = app
7378            .oneshot(
7379                Request::builder()
7380                    .method("POST")
7381                    .uri("/bot/claims")
7382                    .header("x-bot-secret", "bot-secret-abcdef")
7383                    .body(Body::empty())
7384                    .unwrap(),
7385            )
7386            .await
7387            .unwrap();
7388        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
7389        assert_eq!(resp.status(), StatusCode::CONFLICT);
7390    }
7391
7392    /// POST /bot/claims with a JSON body carrying the follower DID.
7393    async fn post_bot_claim_for(
7394        app: &axum::Router,
7395        secret: &str,
7396        did: &str,
7397    ) -> (StatusCode, serde_json::Value) {
7398        let resp = app
7399            .clone()
7400            .oneshot(
7401                Request::builder()
7402                    .method("POST")
7403                    .uri("/bot/claims")
7404                    .header("x-bot-secret", secret)
7405                    .header("content-type", "application/json")
7406                    .body(Body::from(format!(
7407                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
7408                    )))
7409                    .unwrap(),
7410            )
7411            .await
7412            .unwrap();
7413        let status = resp.status();
7414        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
7415            .await
7416            .unwrap();
7417        let json = if bytes.is_empty() {
7418            serde_json::Value::Null
7419        } else {
7420            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
7421        };
7422        (status, json)
7423    }
7424
7425    #[tokio::test]
7426    async fn bot_claims_returns_already_seated_for_a_member() {
7427        // A DID that already holds beta access must get `already_seated` with NO
7428        // code/url — the bot posts nothing. This is the server-side backstop that
7429        // survives a bot-host state loss (it would otherwise re-mint + re-post).
7430        let state = bot_state("bot-secret-abcdef").await;
7431        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
7432            .await
7433            .unwrap();
7434        let app = router(state.clone());
7435        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
7436        assert_eq!(status, StatusCode::OK);
7437        assert_eq!(json["status"], "already_seated");
7438        assert_eq!(json["code"], "");
7439        assert_eq!(json["url"], "");
7440        // No new invite code was minted for the seated DID.
7441        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
7442            .await
7443            .unwrap()
7444            .is_none());
7445    }
7446
7447    #[tokio::test]
7448    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
7449        // Two mint requests for the SAME follower DID must return the SAME code
7450        // (the app is authoritative), never a second one — so a bot-host state loss
7451        // re-requesting cannot double-mint or double-post.
7452        let state = bot_state("bot-secret-abcdef").await;
7453        let app = router(state.clone());
7454
7455        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7456        assert_eq!(s1, StatusCode::OK);
7457        assert_eq!(j1["status"], "minted");
7458        let code1 = j1["code"].as_str().unwrap().to_string();
7459
7460        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
7461        assert_eq!(s2, StatusCode::OK);
7462        assert_eq!(j2["status"], "existing");
7463        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
7464        assert_eq!(j2["url"], j1["url"], "same url returned");
7465
7466        // Exactly ONE active code exists for that DID.
7467        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
7468    }
7469
7470    #[tokio::test]
7471    async fn bot_claims_records_intended_did_at_mint() {
7472        // A fresh mint records the follower DID so the lookup finds it.
7473        let state = bot_state("bot-secret-abcdef").await;
7474        let app = router(state.clone());
7475        let (status, json) =
7476            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
7477        assert_eq!(status, StatusCode::OK);
7478        let code = json["code"].as_str().unwrap();
7479        assert_eq!(
7480            store::find_active_code_for_did(&state.db, "did:plc:follower2")
7481                .await
7482                .unwrap()
7483                .as_deref(),
7484            Some(code)
7485        );
7486    }
7487
7488    #[tokio::test]
7489    async fn bot_claims_concurrent_same_did_never_double_mints() {
7490        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
7491        // active code. The dedupe check (3b) and the mint are separate statements,
7492        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
7493        // then makes the loser's INSERT conflict, and the handler recovers by
7494        // returning the winner's code (status `existing`) rather than 500-ing.
7495        // Result: exactly ONE active code, and BOTH callers get a usable code.
7496        let state = bot_state("bot-secret-abcdef").await;
7497        let app = router(state.clone());
7498
7499        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7500        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
7501        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
7502
7503        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
7504        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
7505
7506        // Exactly one active code for the DID — the whole point of the fix.
7507        assert_eq!(
7508            store::count_active_codes(&state.db).await.unwrap(),
7509            1,
7510            "concurrent mints must not create two active codes"
7511        );
7512
7513        // Both callers received the SAME (single) code, and neither got a 500.
7514        let ca = ja["code"].as_str().unwrap_or("");
7515        let cb = jb["code"].as_str().unwrap_or("");
7516        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7517        assert_eq!(ca, cb, "both callers must get the one minted code");
7518        // One is `minted` (the winner), the other `minted` or `existing` depending
7519        // on interleaving — but never an error status.
7520        for st in [&ja["status"], &jb["status"]] {
7521            let s = st.as_str().unwrap_or("");
7522            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7523        }
7524    }
7525
7526    #[tokio::test]
7527    async fn bot_claims_rejects_malformed_json_body() {
7528        let state = bot_state("bot-secret-abcdef").await;
7529        let app = router(state);
7530        let resp = app
7531            .oneshot(
7532                Request::builder()
7533                    .method("POST")
7534                    .uri("/bot/claims")
7535                    .header("x-bot-secret", "bot-secret-abcdef")
7536                    .header("content-type", "application/json")
7537                    .body(Body::from("{not json"))
7538                    .unwrap(),
7539            )
7540            .await
7541            .unwrap();
7542        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7543    }
7544
7545    #[tokio::test]
7546    async fn favicon_ico_served_at_root() {
7547        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
7548        // tags in <head>; the root route must serve the icon, not 404.
7549        let state = test_state(&[]).await;
7550        let app = router(state);
7551        let resp = app
7552            .oneshot(
7553                Request::builder()
7554                    .uri("/favicon.ico")
7555                    .body(Body::empty())
7556                    .unwrap(),
7557            )
7558            .await
7559            .unwrap();
7560        assert_eq!(resp.status(), StatusCode::OK);
7561        let ct = resp
7562            .headers()
7563            .get(header::CONTENT_TYPE)
7564            .unwrap()
7565            .to_str()
7566            .unwrap();
7567        assert!(
7568            ct.contains("icon") || ct.starts_with("image/"),
7569            "content-type = {ct}"
7570        );
7571    }
7572
7573    #[tokio::test]
7574    async fn login_without_invite_redirects_to_beta_redeem() {
7575        // No allow-list seed, no invite cookie: starting OAuth must be refused.
7576        let state = test_state(&[]).await;
7577        let app = router(state);
7578        let resp = app
7579            .oneshot(
7580                Request::builder()
7581                    .method("POST")
7582                    .uri("/login")
7583                    .header("content-type", "application/x-www-form-urlencoded")
7584                    .body(Body::from("handle=alice.bsky.social"))
7585                    .unwrap(),
7586            )
7587            .await
7588            .unwrap();
7589        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7590        assert_eq!(
7591            resp.headers().get(header::LOCATION).unwrap(),
7592            "/beta/redeem"
7593        );
7594    }
7595
7596    #[tokio::test]
7597    async fn login_with_valid_invite_cookie_starts_oauth() {
7598        let state = test_state(&[]).await;
7599        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7600        let cookie = cookie.split(';').next().unwrap().to_string();
7601        let app = router(state);
7602        let resp = app
7603            .oneshot(
7604                Request::builder()
7605                    .method("POST")
7606                    .uri("/login")
7607                    .header("content-type", "application/x-www-form-urlencoded")
7608                    .header(header::COOKIE, cookie)
7609                    .body(Body::from("handle=alice.bsky.social"))
7610                    .unwrap(),
7611            )
7612            .await
7613            .unwrap();
7614        // Redirects into the sidecar login (not to /beta/redeem).
7615        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7616        let loc = resp
7617            .headers()
7618            .get(header::LOCATION)
7619            .unwrap()
7620            .to_str()
7621            .unwrap();
7622        assert!(loc.contains("/login"), "loc = {loc}");
7623        assert_ne!(loc, "/beta/redeem");
7624    }
7625
7626    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
7627    /// any network resolution and that a resolution failure fails closed.
7628    async fn resolver_never(_handle: String) -> Option<String> {
7629        None
7630    }
7631
7632    /// A resolver that maps every handle to `did`.
7633    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7634        move |_handle| std::future::ready(Some(did.to_string()))
7635    }
7636
7637    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
7638    /// that already holds a seat (the seeded-admin first-login case) passes the
7639    /// gate — no session cookie, no invite code.
7640    #[tokio::test]
7641    async fn may_start_oauth_honors_seat_via_resolved_handle() {
7642        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
7643        // no cookie on a fresh deploy.
7644        let state = test_state(&["did:plc:admin"]).await;
7645        let headers = HeaderMap::new();
7646        assert!(
7647            may_start_oauth_with(
7648                &state,
7649                &headers,
7650                "admin.example",
7651                resolver_to("did:plc:admin")
7652            )
7653            .await,
7654            "a handle resolving to a seated DID must pass the gate"
7655        );
7656    }
7657
7658    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
7659    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
7660    /// fails).
7661    #[tokio::test]
7662    async fn may_start_oauth_bounces_non_member_handle() {
7663        let state = test_state(&["did:plc:admin"]).await;
7664        let headers = HeaderMap::new();
7665        assert!(
7666            !may_start_oauth_with(
7667                &state,
7668                &headers,
7669                "rando.example",
7670                resolver_to("did:plc:rando")
7671            )
7672            .await,
7673            "a resolved DID with no seat must be bounced"
7674        );
7675    }
7676
7677    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
7678    /// bounces gracefully — no panic, no handshake.
7679    #[tokio::test]
7680    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7681        let state = test_state(&["did:plc:admin"]).await;
7682        let headers = HeaderMap::new();
7683        assert!(
7684            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7685            "an unresolvable handle must fail closed"
7686        );
7687    }
7688
7689    /// The session-cookie fast path admits a seated member WITHOUT calling the
7690    /// resolver (proven by injecting `resolver_never`, which would otherwise
7691    /// bounce).
7692    #[tokio::test]
7693    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7694        let state = test_state(&[]).await;
7695        let did = "did:plc:member";
7696        store::grant_access(&state.db, did, Some("member.example"), "test", None)
7697            .await
7698            .unwrap();
7699        let cookie = session_cookie(&state, did, Some("member.example"));
7700        let mut headers = HeaderMap::new();
7701        headers.insert(header::COOKIE, cookie.parse().unwrap());
7702        assert!(
7703            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7704            "a seated session cookie must pass without resolution"
7705        );
7706    }
7707
7708    /// The invite-cookie fast path admits WITHOUT calling the resolver.
7709    #[tokio::test]
7710    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7711        let state = test_state(&[]).await;
7712        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7713        let cookie = cookie.split(';').next().unwrap().to_string();
7714        let mut headers = HeaderMap::new();
7715        headers.insert(header::COOKIE, cookie.parse().unwrap());
7716        assert!(
7717            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7718            "a valid invite cookie must pass without resolution"
7719        );
7720    }
7721
7722    #[tokio::test]
7723    async fn admin_mint_requires_admin_seed_did() {
7724        let state = test_state(&["did:plc:admin"]).await;
7725        // A non-admin (but beta'd) session is forbidden.
7726        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7727            .await
7728            .unwrap();
7729        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7730        // An admin session is allowed.
7731        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7732        let app = router(state);
7733
7734        let forbidden = app
7735            .clone()
7736            .oneshot(
7737                Request::builder()
7738                    .method("POST")
7739                    .uri("/admin/invites?n=2")
7740                    .header(header::COOKIE, rando_cookie)
7741                    .body(Body::empty())
7742                    .unwrap(),
7743            )
7744            .await
7745            .unwrap();
7746        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7747
7748        let ok = app
7749            .oneshot(
7750                Request::builder()
7751                    .method("POST")
7752                    .uri("/admin/invites?n=2")
7753                    .header(header::COOKIE, admin_cookie)
7754                    .body(Body::empty())
7755                    .unwrap(),
7756            )
7757            .await
7758            .unwrap();
7759        assert_eq!(ok.status(), StatusCode::OK);
7760        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7761            .await
7762            .unwrap();
7763        let body = String::from_utf8(bytes.to_vec()).unwrap();
7764        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7765        assert_eq!(minted.len(), 2);
7766        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7767    }
7768
7769    #[tokio::test]
7770    async fn admin_mint_unauthenticated_is_401() {
7771        let state = test_state(&["did:plc:admin"]).await;
7772        let app = router(state);
7773        let resp = app
7774            .oneshot(
7775                Request::builder()
7776                    .method("POST")
7777                    .uri("/admin/invites")
7778                    .body(Body::empty())
7779                    .unwrap(),
7780            )
7781            .await
7782            .unwrap();
7783        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7784    }
7785
7786    /// A state whose `/about` renders the adoption line, seeded with one
7787    /// observation.
7788    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7789        let db = store::init_url("sqlite::memory:").await.unwrap();
7790        store::record_network_stat(
7791            &db,
7792            &store::NetworkStat {
7793                key: store::ADOPTION_STAT_KEY.to_string(),
7794                source: "https://relay1.us-west.bsky.network".to_string(),
7795                value: repos,
7796                truncated,
7797                observed_at: "2026-08-13T04:05:06Z".to_string(),
7798            },
7799        )
7800        .await
7801        .unwrap();
7802        let config = Config {
7803            cookie_secret: "test-cookie-secret-000".to_string(),
7804            show_adoption: true,
7805            ..Config::default()
7806        };
7807        AppState::new(config, db).unwrap()
7808    }
7809
7810    async fn about_body(state: AppState) -> String {
7811        let resp = router(state)
7812            .oneshot(
7813                Request::builder()
7814                    .uri("/about")
7815                    .body(Body::empty())
7816                    .unwrap(),
7817            )
7818            .await
7819            .unwrap();
7820        assert_eq!(resp.status(), StatusCode::OK);
7821        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7822            .await
7823            .unwrap();
7824        String::from_utf8(bytes.to_vec()).unwrap()
7825    }
7826
7827    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
7828    #[tokio::test]
7829    async fn about_omits_adoption_line_by_default() {
7830        let state = test_state(&[]).await;
7831        assert!(!state.config.show_adoption);
7832        let body = about_body(state).await;
7833        assert!(
7834            !body.contains("atproto network"),
7835            "the adoption line must not render by default"
7836        );
7837    }
7838
7839    #[tokio::test]
7840    async fn about_renders_adoption_line_when_enabled() {
7841        // **A distinctive count, and asserted IN ITS SENTENCE.**
7842        //
7843        // This used to seed 4 and assert `body.contains("4")`, which the
7844        // colophon's `width="44"` satisfies whatever the count is — so
7845        // hardcoding the rendered number passed. Both halves are needed: a
7846        // digit that does not occur incidentally, and the assertion tied to the
7847        // phrase it belongs to.
7848        let body = about_body(adoption_state(7_318, false).await).await;
7849        // The count and its phrase are on separate template lines, so compare
7850        // against a whitespace-collapsed copy rather than the raw HTML.
7851        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7852        assert!(
7853            flat.contains("7318 accounts on the atproto network hold"),
7854            "the count did not render in its own sentence: {flat}",
7855        );
7856        assert!(
7857            body.contains("accounts on the atproto network hold"),
7858            "{body}"
7859        );
7860        assert!(
7861            body.contains("2026-08-13"),
7862            "the observation date must render"
7863        );
7864        assert!(
7865            body.contains("lower bound"),
7866            "the non-archival caveat must ride along with the number"
7867        );
7868        assert!(
7869            !body.contains("At least"),
7870            "an untruncated count is exact-ish"
7871        );
7872    }
7873
7874    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
7875    #[tokio::test]
7876    async fn about_adoption_line_is_singular_at_one() {
7877        let body = about_body(adoption_state(1, false).await).await;
7878        assert!(
7879            body.contains("account on the atproto network holds"),
7880            "{body}"
7881        );
7882    }
7883
7884    /// A truncated observation is a floor, and must say so.
7885    #[tokio::test]
7886    async fn about_adoption_line_says_at_least_when_truncated() {
7887        let body = about_body(adoption_state(25_000, true).await).await;
7888        assert!(body.contains("At least"), "{body}");
7889    }
7890
7891    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
7892    #[tokio::test]
7893    async fn about_omits_line_when_enabled_with_no_observation() {
7894        let db = store::init_url("sqlite::memory:").await.unwrap();
7895        let config = Config {
7896            cookie_secret: "test-cookie-secret-000".to_string(),
7897            show_adoption: true,
7898            ..Config::default()
7899        };
7900        let body = about_body(AppState::new(config, db).unwrap()).await;
7901        assert!(!body.contains("atproto network"));
7902    }
7903
7904    // ---- standard.site on the public pages and the subscribe form ----------
7905    //
7906    // `FEATHERREADER_STANDARD_SITE` gates what may be STORED (`add_subscription`
7907    // refuses every `at://` paste with it off), so a page that tells the reader
7908    // to paste a publication URI is advertising a form that will be refused
7909    // unless the flag is on. These pin both halves: with the flag on the pages
7910    // say how; with it off they do not.
7911
7912    /// A state with the standard.site flag chosen, and `did` holding a seat so
7913    /// `/manage` renders for it.
7914    async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7915        let db = store::init_url("sqlite::memory:").await.unwrap();
7916        store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7917        let config = Config {
7918            allowed_dids: vec![did.to_string()],
7919            cookie_secret: "test-cookie-secret-000".to_string(),
7920            beta_cap: 3,
7921            standard_site,
7922            ..Config::default()
7923        };
7924        AppState::new(config, db).unwrap()
7925    }
7926
7927    /// `GET path` as `did`, asserted 200, body as a string.
7928    async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7929        let cookie = session_cookie(&state, did, Some("reader.example"));
7930        let resp = router(state)
7931            .oneshot(
7932                Request::builder()
7933                    .uri(path)
7934                    .header(header::COOKIE, cookie)
7935                    .body(Body::empty())
7936                    .unwrap(),
7937            )
7938            .await
7939            .unwrap();
7940        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7941        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7942            .await
7943            .unwrap();
7944        String::from_utf8(bytes.to_vec()).unwrap()
7945    }
7946
7947    /// `GET path` signed out, asserted 200, body as a string.
7948    async fn public_body(state: AppState, path: &str) -> String {
7949        let resp = router(state)
7950            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7951            .await
7952            .unwrap();
7953        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7954        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7955            .await
7956            .unwrap();
7957        String::from_utf8(bytes.to_vec()).unwrap()
7958    }
7959
7960    /// The `<input … id="feed-url" …>` tag of the subscribe form, whole.
7961    fn feed_url_input(body: &str) -> &str {
7962        let start = body
7963            .find("id=\"feed-url\"")
7964            .and_then(|i| body[..i].rfind("<input"))
7965            .expect("the subscribe form's URL input renders");
7966        let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7967        &body[start..end]
7968    }
7969
7970    /// Flag on: the subscribe form says a publication URI is accepted, and shows
7971    /// both spellings the handler takes (DID and handle).
7972    #[tokio::test]
7973    async fn manage_hints_at_publications_when_the_flag_is_on() {
7974        let did = "did:plc:reader";
7975        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7976        assert!(
7977            body.contains("at://did:plc:…/site.standard.publication/…"),
7978            "the DID form must be shown: {body}"
7979        );
7980        assert!(
7981            body.contains("at://alice.example.com/site.standard.publication/…"),
7982            "the handle form must be shown: {body}"
7983        );
7984    }
7985
7986    /// Flag on: the URL input must not be `type="url"`. A browser validates
7987    /// that type with the WHATWG URL parser, which REJECTS the DID form —
7988    /// `at://did:plc:…/…` fails as an invalid port, the same failure
7989    /// `url::Url::parse` has (see `feed::is_storable_feed_url`) — so the form
7990    /// would refuse to submit the very string the hint asks for.
7991    #[tokio::test]
7992    async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7993        let did = "did:plc:reader";
7994        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7995        let input = feed_url_input(&body);
7996        assert!(
7997            input.contains("type=\"text\""),
7998            "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7999        );
8000        assert!(
8001            input.contains("inputmode=\"url\""),
8002            "the URL keyboard is still wanted: {input}"
8003        );
8004    }
8005
8006    /// Flag on, `type="text"` drops the browser's scheme check, so a pasted
8007    /// `example.com/blog` would reach the handler and come back as "Couldn't
8008    /// find a feed" — wrong, the site likely has one. A `pattern` keeps the
8009    /// browser asking for a scheme while still admitting `at://` (both cases:
8010    /// the handler canonicalises the scheme).
8011    #[tokio::test]
8012    async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
8013        let did = "did:plc:reader";
8014        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
8015        let input = feed_url_input(&body);
8016        assert!(
8017            input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
8018            "the text input must keep a scheme check: {input}"
8019        );
8020    }
8021
8022    /// Flag off: every `at://` paste is refused, so the form must not say
8023    /// publications are accepted — and the input keeps browser URL validation.
8024    #[tokio::test]
8025    async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
8026        let did = "did:plc:reader";
8027        let state = standard_site_state(false, did).await;
8028        assert!(!state.config.standard_site);
8029        let page = signed_in_body(state, "/manage", did).await;
8030        // The `<head>` carries the site's link card, whose one-line description
8031        // names standard.site whatever the flag says — as the landing page does
8032        // with the flag off (a stored publication is polled regardless). What
8033        // must not advertise is the page: everything after `</head>`.
8034        let body = &page[page.find("</head>").expect("a <head>")..];
8035        assert!(
8036            !body.contains("site.standard.publication"),
8037            "a refused form must not be advertised: {body}"
8038        );
8039        // The shared footer links the `/standard-site` feature page on every
8040        // page, flag on or off — that page itself says the instance isn't
8041        // accepting new publication subscriptions — so the check is on the
8042        // page above the footer, where the form and its hints are.
8043        let above_footer = body
8044            .split("<footer")
8045            .next()
8046            .expect("split yields at least one piece");
8047        assert!(
8048            above_footer.contains("id=\"feed-url\""),
8049            "the form must be above the footer: {body}"
8050        );
8051        assert!(
8052            !above_footer.contains("standard.site"),
8053            "a refused form must not be advertised: {body}"
8054        );
8055        assert!(
8056            feed_url_input(body).contains("type=\"url\""),
8057            "with the flag off the input is unchanged"
8058        );
8059    }
8060
8061    /// Flag on: the landing page says publications sit beside feeds AND how to
8062    /// subscribe to one.
8063    #[tokio::test]
8064    async fn landing_describes_publications_and_how_to_subscribe_when_on() {
8065        let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
8066        assert!(body.contains("standard.site"), "{body}");
8067        assert!(
8068            body.contains("at://did:plc:…/site.standard.publication/…"),
8069            "the landing page must show the DID form: {body}"
8070        );
8071        assert!(
8072            body.contains("at://alice.example.com/site.standard.publication/…"),
8073            "the landing page must show the handle form: {body}"
8074        );
8075    }
8076
8077    /// Flag off: the landing page still says what a publication is (a stored
8078    /// one is polled whatever the flag says), but shows no paste instructions
8079    /// and says new ones are not accepted here.
8080    #[tokio::test]
8081    async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
8082        let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
8083        assert!(body.contains("standard.site"), "{body}");
8084        assert!(
8085            !body.contains("at://did:plc:…/site.standard.publication/…"),
8086            "no paste instructions with the flag off: {body}"
8087        );
8088        assert!(
8089            !body.contains("at://alice.example.com/site.standard.publication/…"),
8090            "no paste instructions with the flag off: {body}"
8091        );
8092        assert!(
8093            body.contains("isn't accepting new publication subscriptions"),
8094            "the page must say the form is closed here: {body}"
8095        );
8096    }
8097
8098    /// Flag on: /about has a publications section with both spellings.
8099    #[tokio::test]
8100    async fn about_describes_publications_and_how_to_subscribe_when_on() {
8101        let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
8102        assert!(body.contains("site.standard.publication"), "{body}");
8103        assert!(body.contains("site.standard.document"), "{body}");
8104        assert!(
8105            body.contains("at://did:plc:…/site.standard.publication/…"),
8106            "{body}"
8107        );
8108        assert!(
8109            body.contains("at://alice.example.com/site.standard.publication/…"),
8110            "{body}"
8111        );
8112    }
8113
8114    /// Flag off: /about keeps the description, drops the paste instructions.
8115    #[tokio::test]
8116    async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
8117        let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
8118        assert!(body.contains("site.standard.publication"), "{body}");
8119        assert!(
8120            !body.contains("at://did:plc:…/site.standard.publication/…"),
8121            "no paste instructions with the flag off: {body}"
8122        );
8123        assert!(
8124            !body.contains("at://alice.example.com/site.standard.publication/…"),
8125            "no paste instructions with the flag off: {body}"
8126        );
8127        assert!(
8128            body.contains("isn't accepting new publication subscriptions"),
8129            "{body}"
8130        );
8131    }
8132
8133    // ---- the standard.site feature page (`/standard-site`) -----------------
8134    //
8135    // A public page, like `/about`: what a publication is, what is shown from
8136    // it, how to subscribe (flag-conditional, as on the other public pages),
8137    // and the honest limits. It also carries the "latest releases" call-out.
8138
8139    /// Signed out, with the default config, the page renders.
8140    #[tokio::test]
8141    async fn standard_site_page_renders_signed_out() {
8142        let body = public_body(test_state(&[]).await, "/standard-site").await;
8143        assert!(body.contains("site.standard.publication"), "{body}");
8144        assert!(body.contains("site.standard.document"), "{body}");
8145        assert!(
8146            body.contains("<title>standard.site — FeatherReader</title>"),
8147            "{body}"
8148        );
8149    }
8150
8151    /// Flag on: the page says how to subscribe, in both spellings, and that a
8152    /// handle is resolved to its DID.
8153    #[tokio::test]
8154    async fn standard_site_page_tells_how_to_subscribe_when_on() {
8155        let body = public_body(
8156            standard_site_state(true, "did:plc:x").await,
8157            "/standard-site",
8158        )
8159        .await;
8160        assert!(
8161            body.contains("at://did:plc:…/site.standard.publication/…"),
8162            "the DID form must be shown: {body}"
8163        );
8164        assert!(
8165            body.contains("at://alice.example.com/site.standard.publication/…"),
8166            "the handle form must be shown: {body}"
8167        );
8168        assert!(
8169            body.contains("resolved to its DID"),
8170            "the handle resolution must be stated: {body}"
8171        );
8172        assert!(
8173            !body.contains("isn't accepting new publication subscriptions"),
8174            "{body}"
8175        );
8176    }
8177
8178    /// Flag off: `add_subscription` refuses every `at://` paste, so the page
8179    /// must not tell visitors to paste one — it says new publication
8180    /// subscriptions are not accepted here, and that stored ones are still read.
8181    #[tokio::test]
8182    async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
8183        let state = standard_site_state(false, "did:plc:x").await;
8184        assert!(!state.config.standard_site);
8185        let body = public_body(state, "/standard-site").await;
8186        assert!(body.contains("site.standard.publication"), "{body}");
8187        assert!(
8188            !body.contains("at://did:plc:…/site.standard.publication/…"),
8189            "no paste instructions with the flag off: {body}"
8190        );
8191        assert!(
8192            !body.contains("at://alice.example.com/site.standard.publication/…"),
8193            "no paste instructions with the flag off: {body}"
8194        );
8195        assert!(
8196            body.contains("isn't accepting new publication subscriptions"),
8197            "the page must say the form is closed here: {body}"
8198        );
8199        assert!(
8200            body.contains("already follows are still read"),
8201            "stored publications are polled whatever the flag says: {body}"
8202        );
8203    }
8204
8205    /// The releases call-out links each release's GitHub page and the
8206    /// changelog, on the feature page and on the landing page.
8207    #[tokio::test]
8208    async fn releases_callout_links_the_release_pages() {
8209        for path in ["/standard-site", "/"] {
8210            let body = public_body(test_state(&[]).await, path).await;
8211            for tag in ["v0.4.1", "v0.4.0"] {
8212                let href = format!(
8213                    "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
8214                );
8215                assert!(body.contains(&href), "{path} must link {tag}: {body}");
8216            }
8217            assert!(
8218                body.contains(
8219                    "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
8220                ),
8221                "{path} must link the changelog: {body}"
8222            );
8223        }
8224    }
8225
8226    /// The feature page is reachable from the landing page, from `/about`, and
8227    /// from the shared footer (`/privacy` renders nothing but prose and that
8228    /// footer, so it stands in for every page that includes it).
8229    #[tokio::test]
8230    async fn landing_about_and_footer_link_the_standard_site_page() {
8231        for path in ["/", "/about", "/privacy"] {
8232            let body = public_body(test_state(&[]).await, path).await;
8233            assert!(
8234                body.contains("href=\"/standard-site\""),
8235                "{path} must link the feature page: {body}"
8236            );
8237        }
8238    }
8239
8240    /// `RELEASES` is the one place a release is described, so its shape is
8241    /// pinned: newest first, dates as the CHANGELOG headings spell them, and
8242    /// both derived links pointing where the template promises.
8243    #[test]
8244    fn releases_are_newest_first_and_link_the_tag_and_changelog() {
8245        assert!(!RELEASES.is_empty());
8246        let parse = |v: &str| -> Vec<u32> {
8247            v.split('.')
8248                .map(|p| p.parse::<u32>().expect("a numeric version part"))
8249                .collect()
8250        };
8251        for pair in RELEASES.windows(2) {
8252            assert!(
8253                parse(pair[0].version) > parse(pair[1].version),
8254                "{} must come before {}",
8255                pair[0].version,
8256                pair[1].version
8257            );
8258        }
8259        for r in RELEASES {
8260            assert_eq!(parse(r.version).len(), 3, "{}", r.version);
8261            assert!(
8262                chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
8263                "{} is not YYYY-MM-DD",
8264                r.date
8265            );
8266            assert!(!r.summary.trim().is_empty());
8267            assert!(!r.summary.contains('<'), "the summary is plain text");
8268            assert_eq!(
8269                r.url(),
8270                format!(
8271                    "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
8272                    r.version
8273                )
8274            );
8275        }
8276        // The newest entry is this build's own version, so a release cannot
8277        // ship without adding itself to the call-out.
8278        let latest = &RELEASES[0];
8279        assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
8280        assert_eq!(
8281            latest.changelog_url(),
8282            "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#047--2026-10-07"
8283        );
8284    }
8285
8286    /// Public and static like `/about`, so it is cacheable on the same terms.
8287    #[tokio::test]
8288    async fn standard_site_page_is_publicly_cacheable() {
8289        let resp = router(test_state(&[]).await)
8290            .oneshot(
8291                Request::builder()
8292                    .uri("/standard-site")
8293                    .body(Body::empty())
8294                    .unwrap(),
8295            )
8296            .await
8297            .unwrap();
8298        assert_eq!(resp.status(), StatusCode::OK);
8299        assert_eq!(
8300            resp.headers().get(header::CACHE_CONTROL).unwrap(),
8301            "public, max-age=300"
8302        );
8303    }
8304
8305    #[tokio::test]
8306    async fn cache_control_public_on_about_no_store_on_authed() {
8307        let state = test_state(&["did:plc:admin"]).await;
8308        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
8309        let app = router(state);
8310
8311        // /about → public, cacheable.
8312        let about = app
8313            .clone()
8314            .oneshot(
8315                Request::builder()
8316                    .uri("/about")
8317                    .body(Body::empty())
8318                    .unwrap(),
8319            )
8320            .await
8321            .unwrap();
8322        assert_eq!(
8323            about.headers().get(header::CACHE_CONTROL).unwrap(),
8324            "public, max-age=300"
8325        );
8326        // The security headers are still intact.
8327        // The VALUE, spelled out here rather than compared to the constant —
8328        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
8329        // used to assert only that the header existed, which a policy of
8330        // `default-src *` satisfies.
8331        assert_eq!(
8332            about.headers()["content-security-policy"],
8333            EXPECTED_CSP,
8334            "the CSP is not the policy the router promises"
8335        );
8336        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
8337
8338        // /privacy and /terms are static public pages → public, cacheable.
8339        for path in ["/privacy", "/terms"] {
8340            let resp = app
8341                .clone()
8342                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8343                .await
8344                .unwrap();
8345            assert_eq!(resp.status(), StatusCode::OK);
8346            assert_eq!(
8347                resp.headers().get(header::CACHE_CONTROL).unwrap(),
8348                "public, max-age=300",
8349                "{path} should be publicly cacheable"
8350            );
8351            // Security headers apply to these pages too.
8352            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
8353            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
8354        }
8355
8356        // The bare /login landing → public, cacheable.
8357        let login = app
8358            .clone()
8359            .oneshot(
8360                Request::builder()
8361                    .uri("/login")
8362                    .body(Body::empty())
8363                    .unwrap(),
8364            )
8365            .await
8366            .unwrap();
8367        assert_eq!(
8368            login.headers().get(header::CACHE_CONTROL).unwrap(),
8369            "public, max-age=300"
8370        );
8371
8372        // An authenticated page → no-store.
8373        let home = app
8374            .oneshot(
8375                Request::builder()
8376                    .uri("/")
8377                    .header(header::COOKIE, admin_cookie)
8378                    .body(Body::empty())
8379                    .unwrap(),
8380            )
8381            .await
8382            .unwrap();
8383        assert_eq!(
8384            home.headers().get(header::CACHE_CONTROL).unwrap(),
8385            "no-store"
8386        );
8387    }
8388
8389    // -- link cards (Open Graph) -----------------------------------------------
8390    //
8391    // Bluesky's card service fetches the HTML server-side, runs no JS, and
8392    // resolves nothing relative. Measured before these tags existed:
8393    // `cardyb.bsky.app/v1/extract?url=https://feather-reader.com/` returned
8394    // `{"title":"FeatherReader — read, quietly","description":"","image":""}`.
8395
8396    /// Everything up to `</head>` — the only part a card fetcher reads.
8397    fn head(body: &str) -> &str {
8398        let end = body.find("</head>").expect("a <head>");
8399        &body[..end]
8400    }
8401
8402    /// The `content` of the first `<meta …>` tag carrying `attr` (e.g.
8403    /// `property="og:title"`), or `None` when no tag carries it.
8404    fn meta(head: &str, attr: &str) -> Option<String> {
8405        let tag_start = head.find(attr)?;
8406        let rest = &head[tag_start..];
8407        let tag_end = rest.find('>')?;
8408        let tag = &rest[..tag_end];
8409        let content = tag.find("content=\"")? + "content=\"".len();
8410        let close = tag[content..].find('"')?;
8411        Some(tag[content..content + close].to_string())
8412    }
8413
8414    /// A state whose public origin is production's. The card URLs must be
8415    /// absolute on THAT origin: a relative `/static/…` is what the card
8416    /// fetcher cannot use.
8417    async fn production_origin_state() -> AppState {
8418        let db = store::init_url("sqlite::memory:").await.unwrap();
8419        store::ensure_seed(&db, &["did:plc:admin".to_string()])
8420            .await
8421            .unwrap();
8422        let config = Config {
8423            allowed_dids: vec!["did:plc:admin".to_string()],
8424            cookie_secret: "test-cookie-secret-000".to_string(),
8425            beta_cap: 3,
8426            public_url: "https://feather-reader.com".to_string(),
8427            ..Config::default()
8428        };
8429        AppState::new(config, db).unwrap()
8430    }
8431
8432    /// The landing page and /about each carry a complete card with absolute
8433    /// https URLs, and the two describe different things.
8434    #[tokio::test]
8435    async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
8436        let landing = public_body(production_origin_state().await, "/").await;
8437        let about = public_body(production_origin_state().await, "/about").await;
8438        let (lh, ah) = (head(&landing), head(&about));
8439
8440        assert_eq!(
8441            meta(lh, "property=\"og:title\"").as_deref(),
8442            Some("FeatherReader — read, quietly"),
8443            "{lh}"
8444        );
8445        assert_eq!(
8446            meta(ah, "property=\"og:title\"").as_deref(),
8447            Some("About — FeatherReader"),
8448            "{ah}"
8449        );
8450        for (h, path) in [(lh, "/"), (ah, "/about")] {
8451            let url = format!("https://feather-reader.com{path}");
8452            assert_eq!(
8453                meta(h, "property=\"og:url\"").as_deref(),
8454                Some(url.as_str())
8455            );
8456            assert!(
8457                h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
8458                "{path} must carry a canonical link: {h}"
8459            );
8460            let image = meta(h, "property=\"og:image\"").unwrap_or_default();
8461            assert!(
8462                image.starts_with("https://feather-reader.com/static/"),
8463                "{path}: og:image must be absolute on the public origin, got {image:?}"
8464            );
8465            assert_eq!(
8466                meta(h, "name=\"twitter:card\"").as_deref(),
8467                Some("summary_large_image")
8468            );
8469            assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
8470            assert_eq!(
8471                meta(h, "property=\"og:site_name\"").as_deref(),
8472                Some("FeatherReader")
8473            );
8474            let description = meta(h, "property=\"og:description\"").unwrap_or_default();
8475            assert!(!description.is_empty(), "{path}: og:description is empty");
8476            assert_eq!(
8477                meta(h, "name=\"description\"").as_deref(),
8478                Some(description.as_str()),
8479                "{path}: the meta description and og:description must agree"
8480            );
8481        }
8482        assert_ne!(
8483            meta(lh, "property=\"og:description\""),
8484            meta(ah, "property=\"og:description\""),
8485            "the landing page and /about must not share a description"
8486        );
8487    }
8488
8489    /// The origin comes from `FEATHERREADER_PUBLIC_URL`, not a constant.
8490    #[tokio::test]
8491    async fn card_urls_follow_the_configured_public_url() {
8492        let db = store::init_url("sqlite::memory:").await.unwrap();
8493        store::ensure_seed(&db, &[]).await.unwrap();
8494        let config = Config {
8495            cookie_secret: "test-cookie-secret-000".to_string(),
8496            public_url: "https://reader.example.org".to_string(),
8497            ..Config::default()
8498        };
8499        let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
8500        let h = head(&body);
8501        assert_eq!(
8502            meta(h, "property=\"og:url\"").as_deref(),
8503            Some("https://reader.example.org/privacy")
8504        );
8505        assert_eq!(
8506            meta(h, "property=\"og:image\"").as_deref(),
8507            Some("https://reader.example.org/static/social-card.png")
8508        );
8509    }
8510
8511    /// Every signed-out page describes itself: no two share a description,
8512    /// and each `og:url` is its own path.
8513    #[tokio::test]
8514    async fn public_pages_each_carry_their_own_description() {
8515        let paths = [
8516            "/",
8517            "/about",
8518            "/privacy",
8519            "/terms",
8520            "/stats",
8521            "/standard-site",
8522            "/login",
8523            "/beta/redeem",
8524        ];
8525        let mut seen = std::collections::HashSet::new();
8526        for path in paths {
8527            let body = public_body(production_origin_state().await, path).await;
8528            let h = head(&body);
8529            let description = meta(h, "name=\"description\"").unwrap_or_default();
8530            assert!(!description.is_empty(), "{path} has no description: {h}");
8531            assert!(
8532                seen.insert(description.clone()),
8533                "{path} repeats another page's description: {description:?}"
8534            );
8535            assert_eq!(
8536                meta(h, "property=\"og:url\"").as_deref(),
8537                Some(format!("https://feather-reader.com{path}").as_str()),
8538                "{path}"
8539            );
8540            assert!(
8541                !h.contains("name=\"robots\""),
8542                "{path} is public and must not be noindex: {h}"
8543            );
8544        }
8545    }
8546
8547    /// The share image is served from `/static` as a PNG of the dimensions the
8548    /// tags promise, well under the 1 MB card fetchers tolerate, and cacheable.
8549    #[tokio::test]
8550    async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8551        let landing = public_body(production_origin_state().await, "/").await;
8552        let h = head(&landing);
8553        let image = meta(h, "property=\"og:image\"").unwrap();
8554        let path = image.strip_prefix("https://feather-reader.com").unwrap();
8555        let width: u32 = meta(h, "property=\"og:image:width\"")
8556            .unwrap()
8557            .parse()
8558            .unwrap();
8559        let height: u32 = meta(h, "property=\"og:image:height\"")
8560            .unwrap()
8561            .parse()
8562            .unwrap();
8563        assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8564        assert_eq!(
8565            meta(h, "property=\"og:image:type\"").as_deref(),
8566            Some("image/png")
8567        );
8568        assert!(
8569            !meta(h, "property=\"og:image:alt\"")
8570                .unwrap_or_default()
8571                .is_empty(),
8572            "the image needs alt text"
8573        );
8574
8575        let resp = router(production_origin_state().await)
8576            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8577            .await
8578            .unwrap();
8579        assert_eq!(resp.status(), StatusCode::OK, "{path}");
8580        assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8581        assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8582        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8583            .await
8584            .expect("the image is under 1 MB");
8585        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8586        // IHDR: width and height, big-endian, at offsets 16 and 20.
8587        let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8588        assert_eq!(
8589            (be(16), be(20)),
8590            (width, height),
8591            "the PNG's own dimensions must match the tags"
8592        );
8593    }
8594
8595    /// A page that renders a session's private view carries the site's generic
8596    /// card — nothing from the view reaches `<head>` — and is `noindex`.
8597    #[tokio::test]
8598    async fn private_pages_keep_user_data_out_of_the_card() {
8599        for path in ["/", "/manage"] {
8600            let state = production_origin_state().await;
8601            let body = signed_in_body(state, path, "did:plc:admin").await;
8602            let h = head(&body);
8603            assert!(
8604                h.contains("<meta name=\"robots\" content=\"noindex\""),
8605                "{path}: a private view must be noindex: {h}"
8606            );
8607            assert_eq!(
8608                meta(h, "property=\"og:title\"").as_deref(),
8609                Some("FeatherReader — read, quietly"),
8610                "{path}: the card of a private view is the site's generic one"
8611            );
8612            assert_eq!(
8613                meta(h, "property=\"og:url\"").as_deref(),
8614                Some("https://feather-reader.com/"),
8615                "{path}: og:url of a private view is the front door, not the private path"
8616            );
8617            for private in ["reader.example", "did:plc:admin"] {
8618                assert!(
8619                    !h.contains(private),
8620                    "{path}: {private:?} must not reach <head>: {h}"
8621                );
8622            }
8623        }
8624    }
8625
8626    #[tokio::test]
8627    async fn beta_redeem_page_renders() {
8628        let state = test_state(&[]).await;
8629        let app = router(state);
8630        let resp = app
8631            .oneshot(
8632                Request::builder()
8633                    .uri("/beta/redeem")
8634                    .body(Body::empty())
8635                    .unwrap(),
8636            )
8637            .await
8638            .unwrap();
8639        assert_eq!(resp.status(), StatusCode::OK);
8640        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8641            .await
8642            .unwrap();
8643        let html = String::from_utf8(bytes.to_vec()).unwrap();
8644        assert!(html.contains("Invite code"));
8645        assert!(html.contains("/beta/redeem"));
8646    }
8647
8648    #[tokio::test]
8649    async fn rate_limit_returns_429_after_burst() {
8650        // Configure a trusted proxy header so the limiter keys on the forwarded
8651        // IP (the oneshot harness sets no ConnectInfo socket peer).
8652        let db = store::init_url("sqlite::memory:").await.unwrap();
8653        store::ensure_seed(&db, &[]).await.unwrap();
8654        let config = Config {
8655            cookie_secret: "test-cookie-secret-000".to_string(),
8656            beta_cap: 3,
8657            trusted_ip_header: Some("cf-connecting-ip".to_string()),
8658            ..Config::default()
8659        };
8660        let state = AppState::new(config, db).unwrap();
8661        let app = router(state);
8662        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
8663        // handler itself returns 200 (re-render) on a bad code; the limiter is
8664        // what eventually yields 429.
8665        let mut saw_429 = false;
8666        for _ in 0..(RATE_BURST as usize + 5) {
8667            let resp = app
8668                .clone()
8669                .oneshot(
8670                    Request::builder()
8671                        .method("POST")
8672                        .uri("/beta/redeem")
8673                        .header("content-type", "application/x-www-form-urlencoded")
8674                        .header("cf-connecting-ip", "203.0.113.200")
8675                        .body(Body::from("code=FEATHER-NOPENOPE"))
8676                        .unwrap(),
8677                )
8678                .await
8679                .unwrap();
8680            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8681                saw_429 = true;
8682                break;
8683            }
8684        }
8685        assert!(saw_429, "expected a 429 after exhausting the burst");
8686    }
8687
8688    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
8689    /// the middleware's comment cites this test as proof of.
8690    ///
8691    /// The previous version rotated the forged header and asserted that no
8692    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
8693    /// burst, so that assertion held whether the header was trusted or
8694    /// ignored — it passed in the vulnerable configuration too. And with no
8695    /// socket peer the limiter fails open, so nothing could have been keyed on
8696    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
8697    /// a DIFFERENT forged header, and the last must be 429: they all landed in
8698    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
8699    /// per request and never trips — which is exactly what the mutation does.
8700    #[tokio::test]
8701    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8702        let state = test_state(&[]).await;
8703        assert!(
8704            state.config.trusted_ip_header.is_none(),
8705            "no proxy header is trusted here"
8706        );
8707        let app = router(state);
8708        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8709        let mut saw_429 = false;
8710        for i in 0..(RATE_BURST as usize + 5) {
8711            let forged = format!("10.9.8.{}", i % 250);
8712            let resp = app
8713                .clone()
8714                .oneshot(
8715                    Request::builder()
8716                        .method("POST")
8717                        .uri("/beta/redeem")
8718                        .header("content-type", "application/x-www-form-urlencoded")
8719                        .header("x-forwarded-for", forged)
8720                        .extension(axum::extract::ConnectInfo(peer))
8721                        .body(Body::from("code=FEATHER-NOPENOPE"))
8722                        .unwrap(),
8723                )
8724                .await
8725                .unwrap();
8726            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8727                saw_429 = true;
8728                break;
8729            }
8730        }
8731        assert!(
8732            saw_429,
8733            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8734        );
8735    }
8736
8737    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
8738
8739    /// **A private feed is refused BEFORE it is fetched.** The add path's
8740    /// privacy gate had no test at all — `private_feeds_are_classified_private_
8741    /// across_providers` says "the add + OPML paths both gate on this
8742    /// classifier" and nothing checked either. The gate exists so a
8743    /// token-bearing URL never reaches the network; the assertion that
8744    /// matters is the server's hit count: zero.
8745    #[tokio::test]
8746    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8747        let did = "did:plc:privateadder";
8748        let state = test_state_with_caps(did, 0, 0).await;
8749        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8750        let port: u16 = base
8751            .trim_end_matches('/')
8752            .rsplit(':')
8753            .next()
8754            .unwrap()
8755            .parse()
8756            .unwrap();
8757        crate::net::test_host_override(
8758            "private-add.test",
8759            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8760        );
8761        let cookie = session_cookie(&state, did, None);
8762        let resp = router(state.clone())
8763            .oneshot(
8764                Request::builder()
8765                    .method("POST")
8766                    .uri("/subscriptions")
8767                    .header(header::COOKIE, cookie)
8768                    .header("content-type", "application/x-www-form-urlencoded")
8769                    .body(Body::from(format!(
8770                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8771                    )))
8772                    .unwrap(),
8773            )
8774            .await
8775            .unwrap();
8776        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8777        let loc = resp
8778            .headers()
8779            .get(header::LOCATION)
8780            .unwrap()
8781            .to_str()
8782            .unwrap();
8783        assert!(loc.contains("Private"), "not refused as private: {loc}");
8784        assert_eq!(
8785            hits.load(std::sync::atomic::Ordering::SeqCst),
8786            0,
8787            "the private feed was FETCHED before being refused"
8788        );
8789        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8790    }
8791
8792    /// **OPML import skips a private feed without storing or publishing it.**
8793    /// The import path does not fetch, so "never fetched" is not the signal
8794    /// here; "never stored, never written to the PDS" is. The batch write's
8795    /// bytes are captured and must not carry the URL.
8796    #[tokio::test]
8797    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8798        let did = "did:plc:renamer4";
8799        let (sidecar, bodies) = spawn_logging_sidecar().await;
8800        let state = test_state_with_sidecar(&[did], &sidecar).await;
8801        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8802        let opml = format!(
8803            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8804             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8805             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8806             </body></opml>"
8807        );
8808        let (ct, body) = opml_multipart(opml.as_bytes());
8809        let cookie = session_cookie(&state, did, None);
8810        let resp = router(state.clone())
8811            .oneshot(
8812                Request::builder()
8813                    .method("POST")
8814                    .uri("/opml")
8815                    .header(header::COOKIE, cookie)
8816                    .header("content-type", ct)
8817                    .body(Body::from(body))
8818                    .unwrap(),
8819            )
8820            .await
8821            .unwrap();
8822        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8823        let loc = resp
8824            .headers()
8825            .get(header::LOCATION)
8826            .unwrap()
8827            .to_str()
8828            .unwrap();
8829        assert!(
8830            loc.contains("skipped%20as%20private"),
8831            "not reported as skipped: {loc}"
8832        );
8833        assert!(store::get_feed_by_url(&state.db, tokened)
8834            .await
8835            .unwrap()
8836            .is_none());
8837        let sent = bodies.lock().unwrap().join("\n");
8838        assert!(
8839            sent.contains("public.example"),
8840            "the public feed was not written: {sent}"
8841        );
8842        assert!(
8843            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8844            "the secret was PUBLISHED to the PDS: {sent}"
8845        );
8846    }
8847
8848    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
8849    /// tested; the GET form starts the same handshake and had no test, so
8850    /// deleting its gate left the suite green.
8851    #[tokio::test]
8852    async fn get_login_without_a_seat_is_refused() {
8853        let state = test_state(&[]).await;
8854        let resp = router(state)
8855            .oneshot(
8856                Request::builder()
8857                    .method("GET")
8858                    .uri("/login?handle=alice.bsky.social")
8859                    .body(Body::empty())
8860                    .unwrap(),
8861            )
8862            .await
8863            .unwrap();
8864        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8865        assert_eq!(
8866            resp.headers().get(header::LOCATION).unwrap(),
8867            "/beta/redeem"
8868        );
8869    }
8870
8871    /// A sidecar fake that answers every request `ok` and records the PATH of
8872    /// each in arrival order, plus every body — for asserting what was sent,
8873    /// and in what order.
8874    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8875        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8876        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8877        let addr = listener.local_addr().unwrap();
8878        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8879        let sink = log.clone();
8880        tokio::spawn(async move {
8881            loop {
8882                let Ok((mut sock, _)) = listener.accept().await else {
8883                    break;
8884                };
8885                let mut raw: Vec<u8> = Vec::new();
8886                let mut chunk = [0u8; 4096];
8887                let text = loop {
8888                    let Ok(n) = sock.read(&mut chunk).await else {
8889                        break String::new();
8890                    };
8891                    if n == 0 {
8892                        break String::from_utf8_lossy(&raw).to_string();
8893                    }
8894                    raw.extend_from_slice(&chunk[..n]);
8895                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8896                        continue;
8897                    };
8898                    let (head, body) = raw.split_at(split + 4);
8899                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8900                        let (k, v) = l.split_once(':')?;
8901                        k.eq_ignore_ascii_case("content-length")
8902                            .then(|| v.trim().parse::<usize>().ok())?
8903                    });
8904                    if want.is_none_or(|w| body.len() >= w) {
8905                        break String::from_utf8_lossy(&raw).to_string();
8906                    }
8907                };
8908                let path = text
8909                    .lines()
8910                    .next()
8911                    .and_then(|l| l.split_whitespace().nth(1))
8912                    .unwrap_or("")
8913                    .to_string();
8914                let body_text = text
8915                    .split_once("\r\n\r\n")
8916                    .map(|(_, b)| b)
8917                    .unwrap_or("")
8918                    .to_string();
8919                sink.lock().unwrap().push(format!("{path} {body_text}"));
8920                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();
8921                let resp = format!(
8922                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8923                    body.len(),
8924                    body
8925                );
8926                let _ = sock.write_all(resp.as_bytes()).await;
8927                let _ = sock.flush().await;
8928            }
8929        });
8930        (format!("http://{addr}"), log)
8931    }
8932
8933    /// **The sign-out flush settles a split flush's landed prefix too.** It is
8934    /// `readstate::flush_did` under a timeout, so it shares the fix — pinned
8935    /// here so a sign-out path that grew its own flush would not silently lose
8936    /// it. Call 1 of 2 lands, call 2's connection drops: the landed cursors are
8937    /// created and clean, the rest stay dirty to park until the next sign-in.
8938    #[tokio::test]
8939    async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8940        use crate::readstate::tests as rs;
8941        for backend in [
8942            crate::metrics::Backend::Sidecar,
8943            crate::metrics::Backend::Rust,
8944        ] {
8945            let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8946            let state = rs::state_on(backend, &fake).await;
8947            for i in 0..250 {
8948                rs::mark_read(&state, i, "1").await;
8949            }
8950            fake.lock().unwrap().drop_call = Some(2);
8951
8952            flush_before_revoke(&state, rs::DID).await;
8953
8954            let order = rs::send_order(250);
8955            let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8956            for &i in landed {
8957                let c = rs::cursor(&state, i).await;
8958                assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8959            }
8960            for &i in rest {
8961                let c = rs::cursor(&state, i).await;
8962                assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8963            }
8964            assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8965        }
8966    }
8967
8968    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
8969    /// route.** The previous version of this test called
8970    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
8971    /// flush attempt; its doc claimed deleting the call from the handler
8972    /// "drops that to zero", which was false — the handler was never run.
8973    /// Deleting the call left the suite green: #117 regressing in full, with
8974    /// the test named after it still passing. Now `POST /logout` is driven and
8975    /// the sidecar's log must show a repo write BEFORE the revoke.
8976    #[tokio::test]
8977    async fn signing_out_flushes_before_it_revokes_through_the_route() {
8978        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8979        let (sidecar, log) = spawn_logging_sidecar().await;
8980        let state = test_state_with_sidecar(&[did], &sidecar).await;
8981        crate::store::upsert_cursor(
8982            &state.db,
8983            &crate::store::ReadCursor {
8984                did: did.to_string(),
8985                feed_url: "https://example.com/feed.xml".into(),
8986                read_through: None,
8987                read_ids: "[\"1\"]".into(),
8988                unread_ids: "[]".into(),
8989                dirty: true,
8990                pds_created: false,
8991                updated_at: "2026-09-13T21:22:40Z".into(),
8992            },
8993        )
8994        .await
8995        .unwrap();
8996        let cookie = session_cookie(&state, did, None);
8997        let resp = router(state.clone())
8998            .oneshot(
8999                Request::builder()
9000                    .method("POST")
9001                    .uri("/logout")
9002                    .header(header::COOKIE, cookie)
9003                    .body(Body::empty())
9004                    .unwrap(),
9005            )
9006            .await
9007            .unwrap();
9008        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9009
9010        let entries = log.lock().unwrap().clone();
9011        let flush = entries
9012            .iter()
9013            .position(|e| e.starts_with("/internal/repo "));
9014        let revoke = entries
9015            .iter()
9016            .position(|e| e.starts_with("/internal/revoke "));
9017        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
9018        assert!(
9019            flush.is_some(),
9020            "sign-out did not attempt a flush before revoking: {entries:?}"
9021        );
9022        assert!(
9023            flush < revoke,
9024            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
9025        );
9026    }
9027
9028    /// The policy, as a literal: the backstop the router calls "neutralises any
9029    /// XSS that slips past sanitization". `script-src 'self'` and no
9030    /// `'unsafe-inline'` on it are the two clauses that make it one.
9031    const EXPECTED_CSP: &str = "default-src 'self'; \
9032     script-src 'self'; \
9033     style-src 'self' 'unsafe-inline'; \
9034     img-src 'self' https: data:; \
9035     font-src 'self'; \
9036     connect-src 'self'; \
9037     form-action 'self'; \
9038     base-uri 'self'; \
9039     frame-ancestors 'none'; \
9040     object-src 'none'";
9041
9042    /// Build a `multipart/form-data` body carrying a single `file` field whose
9043    /// contents are `payload`, returning `(content_type, body_bytes)`.
9044    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
9045        let boundary = "----featherreadertestboundary";
9046        let mut body = Vec::new();
9047        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
9048        body.extend_from_slice(
9049            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
9050        );
9051        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
9052        body.extend_from_slice(payload);
9053        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
9054        (format!("multipart/form-data; boundary={boundary}"), body)
9055    }
9056
9057    #[tokio::test]
9058    async fn opml_import_oversize_upload_returns_413() {
9059        let state = test_state(&["did:plc:admin"]).await;
9060        let cookie = session_cookie(&state, "did:plc:admin", None);
9061        let app = router(state);
9062
9063        // A payload comfortably above the route cap.
9064        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
9065        let (content_type, body) = opml_multipart(&payload);
9066
9067        let resp = app
9068            .oneshot(
9069                Request::builder()
9070                    .method("POST")
9071                    .uri("/opml")
9072                    .header("content-type", content_type)
9073                    .header(header::COOKIE, cookie)
9074                    .body(Body::from(body))
9075                    .unwrap(),
9076            )
9077            .await
9078            .unwrap();
9079        assert_eq!(
9080            resp.status(),
9081            StatusCode::PAYLOAD_TOO_LARGE,
9082            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
9083        );
9084    }
9085
9086    /// **The route's own cap is what refuses this, not the framework's.**
9087    ///
9088    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
9089    /// the route's layer was a no-op — deleting it left every test green, and
9090    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
9091    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
9092    /// sits BETWEEN the two: over ours, under the framework's. Only the
9093    /// route's layer can refuse it — remove the layer and this payload is
9094    /// accepted, which is also what demonstrates the framework's default is
9095    /// the larger of the two.
9096    #[tokio::test]
9097    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
9098        let state = test_state(&["did:plc:admin"]).await;
9099        let cookie = session_cookie(&state, "did:plc:admin", None);
9100        let app = router(state);
9101
9102        // Between the two ceilings: the framework would accept this.
9103        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
9104        let (content_type, body) = opml_multipart(&payload);
9105
9106        let resp = app
9107            .oneshot(
9108                Request::builder()
9109                    .method("POST")
9110                    .uri("/opml")
9111                    .header("content-type", content_type)
9112                    .header(header::COOKIE, cookie)
9113                    .body(Body::from(body))
9114                    .unwrap(),
9115            )
9116            .await
9117            .unwrap();
9118        assert_eq!(
9119            resp.status(),
9120            StatusCode::PAYLOAD_TOO_LARGE,
9121            "a payload over the route's cap but under the framework's was accepted — \
9122             the route's own DefaultBodyLimit layer is not doing anything"
9123        );
9124    }
9125
9126    #[tokio::test]
9127    async fn opml_import_under_limit_upload_is_accepted() {
9128        let state = test_state(&["did:plc:admin"]).await;
9129        let cookie = session_cookie(&state, "did:plc:admin", None);
9130        let db = state.db.clone();
9131        let app = router(state);
9132
9133        // A small, valid OPML well under the cap: must be accepted (the handler
9134        // redirects to `/` or a flash), i.e. never 413.
9135        let opml = br#"<?xml version="1.0"?>
9136<opml version="2.0"><body>
9137  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
9138</body></opml>"#;
9139        let (content_type, body) = opml_multipart(opml);
9140
9141        let resp = app
9142            .oneshot(
9143                Request::builder()
9144                    .method("POST")
9145                    .uri("/opml")
9146                    .header("content-type", content_type)
9147                    .header(header::COOKIE, cookie)
9148                    .body(Body::from(body))
9149                    .unwrap(),
9150            )
9151            .await
9152            .unwrap();
9153        // **Assert it was ACCEPTED, not merely that it was not a 413.**
9154        //
9155        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
9156        // 500 satisfies — so making `import_opml` fail unconditionally left this
9157        // green. Three other OPML tests caught that mutation; the one whose name
9158        // promises to cover the under-cap case did not.
9159        assert_eq!(
9160            resp.status(),
9161            StatusCode::SEE_OTHER,
9162            "an under-cap OPML upload was not accepted (status {})",
9163            resp.status(),
9164        );
9165        // **303 alone is not acceptance.** `import_opml` redirects on several
9166        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
9167        // by a cap — so an import that stored nothing satisfied the status check.
9168        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
9169            .bind("https://example.com/feed.xml")
9170            .fetch_one(&db)
9171            .await
9172            .unwrap();
9173        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
9174        let location = resp
9175            .headers()
9176            .get(header::LOCATION)
9177            .and_then(|v| v.to_str().ok())
9178            .unwrap_or_default()
9179            .to_string();
9180        assert!(
9181            !location.starts_with("/login"),
9182            "the import bounced to login instead of being accepted: {location}",
9183        );
9184    }
9185
9186    #[tokio::test]
9187    async fn opml_import_logged_out_redirects_to_login() {
9188        // Logged-out callers are redirected before the body is consumed; assert
9189        // the auth short-circuit rather than a body-cap rejection.
9190        let state = test_state(&["did:plc:admin"]).await;
9191        let app = router(state);
9192
9193        let opml = b"<opml version=\"2.0\"><body></body></opml>";
9194        let (content_type, body) = opml_multipart(opml);
9195
9196        let resp = app
9197            .oneshot(
9198                Request::builder()
9199                    .method("POST")
9200                    .uri("/opml")
9201                    .header("content-type", content_type)
9202                    .body(Body::from(body))
9203                    .unwrap(),
9204            )
9205            .await
9206            .unwrap();
9207        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9208        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
9209    }
9210
9211    // -- delete-my-data (POST /account/delete) --------------------------------
9212
9213    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
9214    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
9215    /// channel) the DID it was asked to revoke. Enough to prove the delete
9216    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
9217    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
9218        use tokio::io::{AsyncReadExt, AsyncWriteExt};
9219        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9220        let addr = listener.local_addr().unwrap();
9221        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
9222        tokio::spawn(async move {
9223            let (mut sock, _) = listener.accept().await.unwrap();
9224            let mut buf = vec![0u8; 4096];
9225            let n = sock.read(&mut buf).await.unwrap();
9226            let req = String::from_utf8_lossy(&buf[..n]).to_string();
9227            // Pull the DID out of the JSON body (last line of the request).
9228            let did = req
9229                .split("\r\n\r\n")
9230                .nth(1)
9231                .and_then(|body| {
9232                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
9233                    v.get("did")?.as_str().map(str::to_string)
9234                })
9235                .unwrap_or_default();
9236            let is_revoke = req.starts_with("POST /internal/revoke");
9237            let body = serde_json::json!({
9238                "ok": true, "did": did, "revoked": true, "hadSession": true
9239            })
9240            .to_string();
9241            let resp = format!(
9242                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9243                body.len(),
9244                body
9245            );
9246            sock.write_all(resp.as_bytes()).await.unwrap();
9247            sock.flush().await.unwrap();
9248            let _ = tx.send(if is_revoke { did } else { String::new() });
9249        });
9250        (format!("http://{addr}"), rx)
9251    }
9252
9253    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
9254    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
9255        let defaults = Config::default();
9256        test_state_with_sidecar_and(
9257            allowed,
9258            sidecar_url,
9259            defaults.standard_site,
9260            defaults.max_feeds_global,
9261        )
9262        .await
9263    }
9264
9265    /// [`test_state_with_sidecar`] with the standard.site flag and the global
9266    /// feeds ceiling chosen — the two settings the at:// paths branch on.
9267    async fn test_state_with_sidecar_and(
9268        allowed: &[&str],
9269        sidecar_url: &str,
9270        standard_site: bool,
9271        max_feeds_global: i64,
9272    ) -> AppState {
9273        let db = store::init_url("sqlite::memory:").await.unwrap();
9274        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
9275        store::ensure_seed(&db, &dids).await.unwrap();
9276        let mut config = Config {
9277            allowed_dids: dids,
9278            cookie_secret: "test-cookie-secret-000".to_string(),
9279            beta_cap: 3,
9280            standard_site,
9281            max_feeds_global,
9282            ..Config::default()
9283        };
9284        config.sidecar.public_url = sidecar_url.to_string();
9285        config.sidecar.internal_url = sidecar_url.to_string();
9286        AppState::new(config, db).unwrap()
9287    }
9288
9289    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
9290    /// the sidecar revoke for that DID, and clears the session cookie.
9291    #[tokio::test]
9292    async fn account_delete_purges_rows_and_triggers_revoke() {
9293        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
9294        let did = "did:plc:leaver";
9295        let state = test_state_with_sidecar(&[], &sidecar_url).await;
9296
9297        // Seed the DID with local rows across the per-DID tables.
9298        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
9299            .await
9300            .unwrap();
9301        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
9302        store::mint_code(&state.db, did, 3600).await.unwrap();
9303        assert!(store::has_beta_access(&state.db, did).await.unwrap());
9304
9305        let cookie = session_cookie(&state, did, Some("leaver.example"));
9306        let app = router(state.clone());
9307
9308        let resp = app
9309            .oneshot(
9310                Request::builder()
9311                    .method("POST")
9312                    .uri("/account/delete")
9313                    .header(header::COOKIE, cookie)
9314                    .header("content-type", "application/x-www-form-urlencoded")
9315                    .body(Body::from("confirm=DELETE"))
9316                    .unwrap(),
9317            )
9318            .await
9319            .unwrap();
9320
9321        // Signed out: redirect to /login with the cookie cleared.
9322        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9323        assert!(resp
9324            .headers()
9325            .get(header::LOCATION)
9326            .unwrap()
9327            .to_str()
9328            .unwrap()
9329            .starts_with("/login"));
9330        let set_cookie = resp
9331            .headers()
9332            .get(header::SET_COOKIE)
9333            .unwrap()
9334            .to_str()
9335            .unwrap();
9336        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
9337
9338        // The sidecar revoke was called for exactly this DID.
9339        //
9340        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
9341        // that simply never called the sidecar — hung this test forever instead
9342        // of failing it: a wedged CI job rather than a red one, which is the
9343        // worse of the two signals because nobody reads it as a defect.
9344        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
9345            .await
9346            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
9347            .unwrap();
9348        assert_eq!(
9349            revoked_did, did,
9350            "sidecar revoke must fire for the caller DID"
9351        );
9352
9353        // Local rows are gone.
9354        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
9355        let codes: i64 =
9356            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
9357                .bind(did)
9358                .fetch_one(&state.db)
9359                .await
9360                .unwrap();
9361        assert_eq!(codes, 0);
9362    }
9363
9364    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
9365    /// nothing and bounces back to /manage.
9366    #[tokio::test]
9367    async fn account_delete_without_confirm_is_a_noop() {
9368        let did = "did:plc:staying";
9369        let state = test_state(&[]).await;
9370        store::grant_access(&state.db, did, None, "test", None)
9371            .await
9372            .unwrap();
9373        let cookie = session_cookie(&state, did, None);
9374        let app = router(state.clone());
9375
9376        let resp = app
9377            .oneshot(
9378                Request::builder()
9379                    .method("POST")
9380                    .uri("/account/delete")
9381                    .header(header::COOKIE, cookie)
9382                    .header("content-type", "application/x-www-form-urlencoded")
9383                    .body(Body::from("confirm=nope"))
9384                    .unwrap(),
9385            )
9386            .await
9387            .unwrap();
9388
9389        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9390        assert!(resp
9391            .headers()
9392            .get(header::LOCATION)
9393            .unwrap()
9394            .to_str()
9395            .unwrap()
9396            .starts_with("/manage"));
9397        // Nothing deleted.
9398        assert!(store::has_beta_access(&state.db, did).await.unwrap());
9399    }
9400
9401    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
9402    /// this harness — the default sidecar URL is not served), a DID must STILL
9403    /// be unable to read or mutate an entry in a feed it does not subscribe to.
9404    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
9405    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
9406    /// every cached feed.
9407    #[tokio::test]
9408    async fn pds_outage_does_not_widen_cross_did_access() {
9409        let did_a = "did:plc:aaaa";
9410        let state = test_state(&[]).await;
9411        store::grant_access(&state.db, did_a, None, "test", None)
9412            .await
9413            .unwrap();
9414
9415        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
9416        // lives in feed_b — the one A must never touch during the outage.
9417        let feed_a = store::upsert_feed(
9418            &state.db,
9419            &store::NewFeed {
9420                url: "https://a.example/feed.xml".to_string(),
9421                title: Some("A".to_string()),
9422                ..Default::default()
9423            },
9424        )
9425        .await
9426        .unwrap();
9427        let feed_b = store::upsert_feed(
9428            &state.db,
9429            &store::NewFeed {
9430                url: "https://b.example/feed.xml".to_string(),
9431                title: Some("B".to_string()),
9432                ..Default::default()
9433            },
9434        )
9435        .await
9436        .unwrap();
9437        store::insert_entries(
9438            &state.db,
9439            feed_b,
9440            &[store::NewEntry {
9441                guid: "b-1".to_string(),
9442                url: Some("https://b.example/1".to_string()),
9443                title: Some("B one".to_string()),
9444                published: Some("2026-07-11T00:00:00Z".to_string()),
9445                content_html: Some("<p>secret B body</p>".to_string()),
9446                ..Default::default()
9447            }],
9448            0,
9449        )
9450        .await
9451        .unwrap();
9452        // A subscribes ONLY to feed_a.
9453        store::replace_sub_refs(&state.db, did_a, &[feed_a])
9454            .await
9455            .unwrap();
9456        // Read B's entry id via a transient sub_ref, then drop it so only the
9457        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
9458        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
9459            .await
9460            .unwrap();
9461        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
9462            .await
9463            .unwrap()[0]
9464            .id;
9465        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
9466            .await
9467            .unwrap();
9468
9469        let cookie = session_cookie(&state, did_a, None);
9470        let app = router(state.clone());
9471
9472        // GET /entries/{b} as A → 404 even during the outage.
9473        let get_b = app
9474            .clone()
9475            .oneshot(
9476                Request::builder()
9477                    .method("GET")
9478                    .uri(format!("/entries/{b_entry_id}"))
9479                    .header(header::COOKIE, cookie.clone())
9480                    .body(Body::empty())
9481                    .unwrap(),
9482            )
9483            .await
9484            .unwrap();
9485        assert_eq!(
9486            get_b.status(),
9487            StatusCode::NOT_FOUND,
9488            "A must not read B's entry during a PDS outage"
9489        );
9490
9491        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
9492        let read_b = app
9493            .oneshot(
9494                Request::builder()
9495                    .method("POST")
9496                    .uri(format!("/entries/{b_entry_id}/read"))
9497                    .header(header::COOKIE, cookie)
9498                    .header("content-type", "application/x-www-form-urlencoded")
9499                    .body(Body::from("read=true"))
9500                    .unwrap(),
9501            )
9502            .await
9503            .unwrap();
9504        assert_eq!(
9505            read_b.status(),
9506            StatusCode::NOT_FOUND,
9507            "A must not mark B's entry read during a PDS outage"
9508        );
9509
9510        // The fallback must NOT have widened A's sub_ref to feed_b.
9511        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
9512            .bind(did_a)
9513            .fetch_all(&state.db)
9514            .await
9515            .unwrap();
9516        assert_eq!(
9517            a_feed_ids,
9518            vec![feed_a],
9519            "outage fallback must not add feeds A never subscribed to"
9520        );
9521        // And B's entry has zero read-state (A's attempt did not mutate).
9522        let es_count: i64 =
9523            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9524                .bind(did_a)
9525                .bind(b_entry_id)
9526                .fetch_one(&state.db)
9527                .await
9528                .unwrap();
9529        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9530    }
9531
9532    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
9533    /// nothing. The other arm is counted separately.**
9534    ///
9535    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
9536    /// error would make the metric noisy in exactly the case that is fine.
9537    ///
9538    /// But `revoke_everywhere` has TWO arms, and a review found that counting
9539    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
9540    /// revocation failed. For anyone who logged in before the cutover the sidecar
9541    /// store is the only one holding tokens, so the rust arm correctly says
9542    /// NoSession and the metric said nothing was wrong. Both arms are now
9543    /// recorded, distinguished by the backend column — so this test pins the
9544    /// BACKEND as well as the outcome.
9545    #[tokio::test]
9546    async fn a_logout_with_no_session_counts_as_success() {
9547        let did = "did:plc:aaaa";
9548        let state = test_state(&[]).await;
9549        assert!(
9550            state.oauth.is_some(),
9551            "meaningless without an oauth runtime; the revoke arm would be skipped",
9552        );
9553
9554        revoke_everywhere(&state, did).await;
9555        let rows = state.metrics.snapshot();
9556        let find = |b: crate::metrics::Backend| {
9557            rows.iter()
9558                .find(|r| r.op == "oauth_revoke" && r.backend == b)
9559                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9560        };
9561
9562        // Rust arm: nothing stored for this DID, so NoSession -> ok.
9563        let rust = find(crate::metrics::Backend::Rust);
9564        assert_eq!(
9565            rust.stats.err_count, 0,
9566            "NoSession was counted as a failure; logout is idempotent",
9567        );
9568        assert_eq!(rust.stats.ok_count, 1);
9569
9570        // Sidecar arm: unreachable in a test, so it must be recorded as an
9571        // ERROR under its own backend — not silently dropped, and not folded
9572        // into the rust row.
9573        let sidecar = find(crate::metrics::Backend::Sidecar);
9574        assert_eq!(
9575            sidecar.stats.err_count, 1,
9576            "a failed sidecar revoke was not counted",
9577        );
9578    }
9579
9580    /// **`Failed` must count as an error — the half the metric exists for.**
9581    ///
9582    /// A review found this unpinned: replacing the mapping with
9583    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
9584    /// asserted the `NoSession -> ok` half, so the branch that actually means
9585    /// "the PDS still holds tokens we asked it to drop" was untested.
9586    ///
9587    /// Driven through the same handler, with a session present but the PDS
9588    /// unreachable, so `sign_out_discovering` returns `Failed`.
9589    #[tokio::test]
9590    async fn a_failed_rust_revoke_counts_as_an_error() {
9591        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9592        let state = test_state(&[]).await;
9593        let runtime = state.oauth.as_deref().expect("oauth runtime");
9594        crate::oauth::store::put_session(
9595            &state.db,
9596            &runtime.codec,
9597            &crate::oauth::store::OAuthSession {
9598                sub: did.into(),
9599                issuer: "https://auth.invalid".into(),
9600                aud: "https://pds.invalid".into(),
9601                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9602                    .to_jwk_json()
9603                    .unwrap(),
9604                access_token: "at".into(),
9605                refresh_token: "rt".into(),
9606                token_type: "DPoP".into(),
9607                granted_scope: "atproto".into(),
9608                expires_at: Some(crate::store::now_unix() + 3600),
9609            },
9610        )
9611        .await
9612        .unwrap();
9613
9614        revoke_everywhere(&state, did).await;
9615
9616        let rows = state.metrics.snapshot();
9617        let rust = rows
9618            .iter()
9619            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9620            .expect("no rust oauth_revoke row");
9621        assert_eq!(
9622            rust.stats.err_count, 1,
9623            "an unreachable PDS must count as a revocation failure",
9624        );
9625        assert_eq!(rust.stats.ok_count, 0);
9626    }
9627
9628    /// **The `href` defence is now carried by the TYPE, not by remembering.**
9629    ///
9630    /// `EntryRow.link` used to be a `String`, and the guard was "call
9631    /// `net::safe_link` before assigning it". Deleting that call left all 679
9632    /// tests passing — a live XSS defence with nothing protecting it.
9633    ///
9634    /// `SafeLink` has no `From<String>` and no public member, so the only way to
9635    /// get foreign input into an `href` is `external`, which does the check
9636    /// itself. This test pins that constructor; the *wiring* is now pinned by
9637    /// the compiler, which is the part a test could never hold down.
9638    ///
9639    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
9640    /// so the template renders the row WITHOUT an anchor. Dropping the row
9641    /// instead would make the record unremovable, because the un-save button
9642    /// lives on it.
9643    #[test]
9644    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9645        for hostile in [
9646            "javascript:alert(1)",
9647            "JavaScript:alert(1)",
9648            "  javascript:alert(1)",
9649            "data:text/html;base64,PHNjcmlwdD4=",
9650            "vbscript:msgbox(1)",
9651            "file:///etc/passwd",
9652            // Protocol-relative: inherits the page's scheme, so it is an
9653            // off-site link wearing a same-site costume. Carried over from the
9654            // test this one replaces, which was its only unique input.
9655            "//evil.example/path",
9656        ] {
9657            let link = SafeLink::external(hostile);
9658            assert!(
9659                link.is_empty(),
9660                "{hostile:?} produced a non-empty href: {link}",
9661            );
9662            assert!(
9663                !link.to_string().to_ascii_lowercase().contains("script"),
9664                "{hostile:?} leaked into the rendered link",
9665            );
9666        }
9667
9668        // And the other direction: a check that rejects everything would satisfy
9669        // the loop above while breaking every real saved record.
9670        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9671            let link = SafeLink::external(good);
9672            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9673            assert_eq!(link.to_string(), good);
9674        }
9675    }
9676
9677    /// **The WIRING, not the helper — this is the one that catches the real
9678    /// mistake.**
9679    ///
9680    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
9681    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
9682    /// *calls* it, and a review proved that gap was live twice over: swapping
9683    /// `external` for the app-path constructor, and constructing the tuple
9684    /// directly, both restored the whole `javascript:` hole with every test
9685    /// green. The type now blocks both — `entry` takes an `i64`, and the field
9686    /// lives in another module — but the wiring deserves a test of its own
9687    /// rather than resting on the shape of a signature.
9688    ///
9689    /// Renders the actual row through the actual handler, from a record whose
9690    /// URL is hostile.
9691    #[tokio::test]
9692    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9693        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9694        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9695        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9696        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9697
9698        let resp = router(state)
9699            .oneshot(
9700                Request::builder()
9701                    .uri("/?view=starred")
9702                    .body(Body::empty())
9703                    .unwrap(),
9704            )
9705            .await
9706            .unwrap();
9707        assert_eq!(resp.status(), StatusCode::OK);
9708        let body = String::from_utf8(
9709            axum::body::to_bytes(resp.into_body(), usize::MAX)
9710                .await
9711                .unwrap()
9712                .to_vec(),
9713        )
9714        .unwrap();
9715
9716        // Not in an href, and not as the title either — the title falls back to
9717        // the URL for links we DO render, so both paths must withhold it.
9718        assert!(
9719            !body.to_ascii_lowercase().contains("javascript:"),
9720            "the hostile scheme reached the rendered page",
9721        );
9722        // But the row must survive: the un-save button lives on it, so dropping
9723        // the row would make the record unremovable from here.
9724        assert!(
9725            body.contains("unusable link"),
9726            "the row was dropped instead of rendering without an anchor",
9727        );
9728    }
9729
9730    /// **The reader view's two `href`s, through the actual handler.**
9731    ///
9732    /// The sibling above covers the LIST row. `entry.html` has its own pair of
9733    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
9734    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
9735    ///
9736    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
9737    /// this was never a live hole. But that guard is procedural and sits a long
9738    /// way from the `href`: it holds only as long as every future writer to
9739    /// `entries.url` remembers to go through `feed.rs`. This test does not
9740    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
9741    /// is precisely the state the ingest check cannot speak for.
9742    ///
9743    /// **Both directions, deliberately.** A fix that renders no link at all
9744    /// satisfies every negative assertion here, and would break every real
9745    /// entry. The second half is what makes the first half mean something.
9746    #[tokio::test]
9747    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9748        let did = "did:plc:readerhref";
9749        let state = test_state(&[]).await;
9750        store::grant_access(&state.db, did, None, "test", None)
9751            .await
9752            .unwrap();
9753        let feed = store::upsert_feed(
9754            &state.db,
9755            &store::NewFeed {
9756                url: "https://href.example/feed.xml".to_string(),
9757                title: Some("Href".to_string()),
9758                ..Default::default()
9759            },
9760        )
9761        .await
9762        .unwrap();
9763        // Straight into the column, bypassing `feed.rs` — the whole point.
9764        store::insert_entries(
9765            &state.db,
9766            feed,
9767            &[
9768                store::NewEntry {
9769                    guid: "hostile-1".to_string(),
9770                    url: Some("javascript:alert(1)".to_string()),
9771                    title: Some("Hostile entry".to_string()),
9772                    published: Some("2026-07-11T00:00:00Z".to_string()),
9773                    ..Default::default()
9774                },
9775                store::NewEntry {
9776                    guid: "benign-1".to_string(),
9777                    url: Some("https://href.example/post".to_string()),
9778                    title: Some("Benign entry".to_string()),
9779                    published: Some("2026-07-10T00:00:00Z".to_string()),
9780                    ..Default::default()
9781                },
9782            ],
9783            0,
9784        )
9785        .await
9786        .unwrap();
9787        store::replace_sub_refs(&state.db, did, &[feed])
9788            .await
9789            .unwrap();
9790        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9791        let id_of = |guid: &str| {
9792            rows.iter()
9793                .find(|r| r.guid == guid)
9794                .unwrap_or_else(|| panic!("{guid} was not inserted"))
9795                .id
9796        };
9797
9798        let cookie = session_cookie(&state, did, None);
9799        let app = router(state.clone());
9800
9801        let render = |id: i64| {
9802            let app = app.clone();
9803            let cookie = cookie.clone();
9804            async move {
9805                let resp = app
9806                    .oneshot(
9807                        Request::builder()
9808                            .method("GET")
9809                            .uri(format!("/entries/{id}"))
9810                            .header(header::COOKIE, cookie)
9811                            .body(Body::empty())
9812                            .unwrap(),
9813                    )
9814                    .await
9815                    .unwrap();
9816                assert_eq!(resp.status(), StatusCode::OK);
9817                String::from_utf8(
9818                    axum::body::to_bytes(resp.into_body(), usize::MAX)
9819                        .await
9820                        .unwrap()
9821                        .to_vec(),
9822                )
9823                .unwrap()
9824            }
9825        };
9826
9827        let hostile = render(id_of("hostile-1")).await;
9828        // The reader page for THIS entry actually rendered. Without this the
9829        // three negatives below are satisfied by an empty body.
9830        assert!(
9831            hostile.contains("Hostile entry"),
9832            "the reader did not render the entry: {hostile}",
9833        );
9834        assert!(
9835            !hostile.to_ascii_lowercase().contains("javascript:"),
9836            "the hostile scheme reached the reader page: {hostile}",
9837        );
9838        // Not merely escaped — the template took its no-link branch. Both
9839        // `href`s are gated on the same `Option`, so this covers the byline
9840        // link and the action-bar button together.
9841        assert!(
9842            !hostile.contains("actionbar-open"),
9843            "the action bar rendered an open-original link for a refused URL: {hostile}",
9844        );
9845        assert!(
9846            !hostile.contains("Original \u{2197}"),
9847            "the byline rendered an original link for a refused URL: {hostile}",
9848        );
9849
9850        // The other direction: a legitimate entry still links out, so "render
9851        // nothing" cannot pass as a fix.
9852        let benign = render(id_of("benign-1")).await;
9853        assert!(
9854            benign.contains("Benign entry"),
9855            "the reader did not render the benign entry: {benign}",
9856        );
9857        // BOTH `href`s, counted. The negatives above fire on the action bar
9858        // first, so without this the byline needle `Original \u{2197}` is never
9859        // once observed failing — a misspelled needle would pass forever.
9860        assert_eq!(
9861            benign
9862                .matches(r#"href="https://href.example/post""#)
9863                .count(),
9864            2,
9865            "entry.html has two `href`s for the entry URL — the byline link and \
9866             the action-bar button — and this render produced a different \
9867             number: {benign}",
9868        );
9869        assert!(
9870            benign.contains("actionbar-open"),
9871            "a legitimate entry lost its open-original button: {benign}",
9872        );
9873        assert!(
9874            benign.contains("Original \u{2197}"),
9875            "a legitimate entry lost its byline link: {benign}",
9876        );
9877    }
9878
9879    /// **The reader view's body, through the actual handler (#151).**
9880    ///
9881    /// `entry.html` used to emit `content_html` with `|safe`, trusting that
9882    /// `feed.rs` had run `ammonia` at ingest. This writes hostile markup into
9883    /// the column DIRECTLY — `store::insert_entries` does not sanitize — which
9884    /// is the state the ingest guard cannot speak for, and asserts that none of
9885    /// it is live on the page.
9886    ///
9887    /// **Both directions.** A fix that escapes the whole body (or drops it)
9888    /// passes every negative below and breaks every real article, so the same
9889    /// render must also carry the benign markup through as markup, and an
9890    /// already-clean body must come out byte-identical.
9891    #[tokio::test]
9892    async fn a_hostile_stored_body_renders_inert_on_the_reader_page() {
9893        let did = "did:plc:readerbody";
9894        let state = test_state(&[]).await;
9895        store::grant_access(&state.db, did, None, "test", None)
9896            .await
9897            .unwrap();
9898        let feed = store::upsert_feed(
9899            &state.db,
9900            &store::NewFeed {
9901                url: "https://body.example/feed.xml".to_string(),
9902                title: Some("Body".to_string()),
9903                ..Default::default()
9904            },
9905        )
9906        .await
9907        .unwrap();
9908        // Hostile attributes on tags the sanitizer keeps, and tags it removes.
9909        let hostile_body = concat!(
9910            "<p>kept <b>bold</b></p>",
9911            r#"<img src="x" onerror="alert(2)">"#,
9912            r#"<a href="javascript:alert(3)">click</a>"#,
9913            r#"<p onclick="alert(4)" style="color:red">tail</p>"#,
9914            "<script>alert(1)</script>",
9915            r#"<iframe src="https://evil.example/"></iframe>"#,
9916        );
9917        // Already-clean: the shape ingest stores. It must render unchanged.
9918        let clean_body = concat!(
9919            "<h2>Heading</h2>",
9920            r#"<p>Text with <a href="https://body.example/x" rel="noopener noreferrer">a link</a>, "#,
9921            "<em>emphasis</em> &amp; an entity, &lt;angle&gt; brackets.</p>",
9922            "<pre><code>if a &lt; b &amp;&amp; c { }</code></pre>",
9923            r#"<ul><li>one</li><li>two</li></ul><img src="https://body.example/i.png" alt="i">"#,
9924        );
9925        store::insert_entries(
9926            &state.db,
9927            feed,
9928            &[
9929                store::NewEntry {
9930                    guid: "hostile-body".to_string(),
9931                    title: Some("Hostile body".to_string()),
9932                    published: Some("2026-07-11T00:00:00Z".to_string()),
9933                    content_html: Some(hostile_body.to_string()),
9934                    ..Default::default()
9935                },
9936                store::NewEntry {
9937                    guid: "clean-body".to_string(),
9938                    title: Some("Clean body".to_string()),
9939                    published: Some("2026-07-10T00:00:00Z".to_string()),
9940                    content_html: Some(clean_body.to_string()),
9941                    ..Default::default()
9942                },
9943                // One byte over the bound ingest enforces on what it stores:
9944                // only a writer that skipped `feed.rs` can produce this row.
9945                store::NewEntry {
9946                    guid: "oversize-body".to_string(),
9947                    title: Some("Oversize body".to_string()),
9948                    published: Some("2026-07-09T00:00:00Z".to_string()),
9949                    content_html: Some(format!(
9950                        "<p>{}OVERSIZE</p>",
9951                        "a".repeat(crate::sanitized_html::MAX_RENDER_HTML_BYTES)
9952                    )),
9953                    ..Default::default()
9954                },
9955            ],
9956            0,
9957        )
9958        .await
9959        .unwrap();
9960        store::replace_sub_refs(&state.db, did, &[feed])
9961            .await
9962            .unwrap();
9963        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9964        let id_of = |guid: &str| {
9965            rows.iter()
9966                .find(|r| r.guid == guid)
9967                .unwrap_or_else(|| panic!("{guid} was not inserted"))
9968                .id
9969        };
9970
9971        let cookie = session_cookie(&state, did, None);
9972        let app = router(state.clone());
9973        // The article body only: `base.html` carries the app's own `<script>`
9974        // tags, which are not what this is about.
9975        let prose = |id: i64| {
9976            let app = app.clone();
9977            let cookie = cookie.clone();
9978            async move {
9979                let resp = app
9980                    .oneshot(
9981                        Request::builder()
9982                            .method("GET")
9983                            .uri(format!("/entries/{id}"))
9984                            .header(header::COOKIE, cookie)
9985                            .body(Body::empty())
9986                            .unwrap(),
9987                    )
9988                    .await
9989                    .unwrap();
9990                assert_eq!(resp.status(), StatusCode::OK);
9991                let page = String::from_utf8(
9992                    axum::body::to_bytes(resp.into_body(), usize::MAX)
9993                        .await
9994                        .unwrap()
9995                        .to_vec(),
9996                )
9997                .unwrap();
9998                let start = page
9999                    .find(r#"<div class="prose">"#)
10000                    .unwrap_or_else(|| panic!("no prose block: {page}"));
10001                let end = page[start..]
10002                    .find("</article>")
10003                    .map(|e| start + e)
10004                    .unwrap_or_else(|| panic!("no </article>: {page}"));
10005                page[start..end].to_string()
10006            }
10007        };
10008
10009        let hostile = prose(id_of("hostile-body")).await;
10010        let lower = hostile.to_ascii_lowercase();
10011        for needle in [
10012            "<script",
10013            "alert(1)",
10014            "onerror",
10015            "javascript:",
10016            "<iframe",
10017            "onclick",
10018        ] {
10019            assert!(
10020                !lower.contains(needle),
10021                "`{needle}` from a stored body reached the reader page: {hostile}",
10022            );
10023        }
10024        // Rendered as markup, not escaped: the benign parts survive as tags.
10025        assert!(
10026            hostile.contains("<p>kept <b>bold</b></p>"),
10027            "the benign markup did not render as markup: {hostile}",
10028        );
10029        assert!(
10030            hostile.contains(r#"<img src="x">"#)
10031                && hostile.contains(r#"<a rel="noopener noreferrer">click</a>"#),
10032            "the sanitizer's output did not reach the page: {hostile}",
10033        );
10034
10035        let clean = prose(id_of("clean-body")).await;
10036        assert!(
10037            clean.contains(clean_body),
10038            "an already-clean stored body did not render byte-identically: {clean}",
10039        );
10040        assert!(
10041            !clean.contains("body-too-large")
10042                && !clean.contains("body-unavailable")
10043                && !clean.contains("body-too-slow"),
10044            "a whole, ordinary body was shown as refused: {clean}",
10045        );
10046
10047        // Over the stored size cap: not given to the sanitizer, and the reader
10048        // is told why rather than shown a silently empty body.
10049        let over = prose(id_of("oversize-body")).await;
10050        assert!(
10051            !over.contains("OVERSIZE") && !over.contains(&"a".repeat(64)),
10052            "an over-size stored body was rendered: {}…",
10053            &over[..over.len().min(300)],
10054        );
10055        assert!(
10056            over.contains("body-too-large"),
10057            "an over-size body did not say so: {over}",
10058        );
10059    }
10060
10061    /// **Each of the renderer's four outcomes has its own branch in
10062    /// `entry.html`.** The handler test above drives the first two through the
10063    /// real path; `Unavailable` cannot be forced there without starving the
10064    /// process-wide permits that every other test shares, and `TooSlow` needs
10065    /// a body that took over 500 ms to clean, so the template is rendered
10066    /// directly for all four. `TooSlow` was missing here, so deleting its note
10067    /// from the template left the page silently empty with every test green
10068    /// (vacuous-test hunt of #273).
10069    #[test]
10070    fn the_reader_template_renders_each_body_outcome() {
10071        let config = Config::default();
10072        let user = CurrentUser {
10073            did: "did:plc:bodyoutcomes".to_string(),
10074            handle: None,
10075            sid: None,
10076        };
10077        let page = |content_html: Option<BodyRender>| {
10078            EntryTemplate {
10079                card: Card::private(&config),
10080                version: VERSION,
10081                repo_url: REPO_URL,
10082                kofi_url: KOFI_URL,
10083                nav: build_nav(&user, "unread", String::new(), vec![], vec![], false),
10084                id: 1,
10085                title: "T".to_string(),
10086                feed_title: "F".to_string(),
10087                author: None,
10088                published: String::new(),
10089                url: SafeLink::external_opt("https://orig.example/a"),
10090                content_html,
10091                read: false,
10092                starred: false,
10093                back_qs: String::new(),
10094                prev_id: None,
10095                next_id: None,
10096                oob: false,
10097            }
10098            .render()
10099            .unwrap()
10100        };
10101
10102        let html = page(Some(BodyRender::Html(
10103            crate::sanitized_html::SanitizedHtml::clean("<p>body <b>here</b></p>"),
10104        )));
10105        assert!(html.contains("<p>body <b>here</b></p>"), "{html}");
10106        assert!(
10107            !html.contains("body-too-large")
10108                && !html.contains("body-unavailable")
10109                && !html.contains("body-too-slow")
10110        );
10111
10112        let too_large = page(Some(BodyRender::TooLarge));
10113        assert!(too_large.contains("body-too-large"), "{too_large}");
10114        assert!(too_large.contains("too large to display"), "{too_large}");
10115        assert!(!too_large.contains("body-unavailable"));
10116
10117        let unavailable = page(Some(BodyRender::Unavailable));
10118        assert!(unavailable.contains("body-unavailable"), "{unavailable}");
10119        assert!(
10120            unavailable.contains("temporarily unavailable"),
10121            "{unavailable}"
10122        );
10123        assert!(!unavailable.contains("body-too-large"));
10124
10125        let too_slow = page(Some(BodyRender::TooSlow));
10126        assert!(too_slow.contains("body-too-slow"), "{too_slow}");
10127        assert!(too_slow.contains("too complex to display"), "{too_slow}");
10128        assert!(
10129            !too_slow.contains("body-too-large") && !too_slow.contains("body-unavailable"),
10130            "{too_slow}"
10131        );
10132
10133        let none = page(None);
10134        assert!(none.contains("has no stored content"), "{none}");
10135    }
10136
10137    /// **The outage fallback must not widen what the caller can READ — and the
10138    /// sibling test above can only see what it WRITES.**
10139    ///
10140    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
10141    /// on `entry_state`: the fallback's side effects. But the fail-open it names
10142    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
10143    /// leaks through the list it *hands back* — the sidebar and the reader render
10144    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
10145    /// perfectly honest and every existing assertion stays green.
10146    ///
10147    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
10148    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
10149    /// exact historical bug the fallback's comment describes — left **all 663
10150    /// tests passing**. Cross-tenant isolation is the one property this project
10151    /// cannot regress quietly, and nothing observed it.
10152    ///
10153    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
10154    /// user, and it deliberately does not look at `sub_ref` at all — that half is
10155    /// already covered above.
10156    #[tokio::test]
10157    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
10158        let did_a = "did:plc:aaaa";
10159        let state = test_state(&[]).await;
10160        store::grant_access(&state.db, did_a, None, "test", None)
10161            .await
10162            .unwrap();
10163
10164        let feed_a = store::upsert_feed(
10165            &state.db,
10166            &store::NewFeed {
10167                url: "https://a.example/feed.xml".to_string(),
10168                title: Some("A".to_string()),
10169                ..Default::default()
10170            },
10171        )
10172        .await
10173        .unwrap();
10174        let _feed_b = store::upsert_feed(
10175            &state.db,
10176            &store::NewFeed {
10177                url: "https://b.example/feed.xml".to_string(),
10178                title: Some("B".to_string()),
10179                ..Default::default()
10180            },
10181        )
10182        .await
10183        .unwrap();
10184        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
10185        // to nobody — exactly the row a whole-cache fallback would hand to A.
10186        store::replace_sub_refs(&state.db, did_a, &[feed_a])
10187            .await
10188            .unwrap();
10189
10190        // No sidecar and no PDS are reachable from a test, so
10191        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
10192        // that, rather than assuming it: if the repo ever starts succeeding here,
10193        // this test would silently stop exercising the fallback at all.
10194        assert!(
10195            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
10196            "this test is only meaningful on the outage path; the repo answered",
10197        );
10198
10199        let resolved = resolve_subscriptions(&state, did_a).await;
10200
10201        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
10202        assert_eq!(
10203            urls,
10204            vec!["https://a.example/feed.xml"],
10205            "the outage fallback must return the caller's OWN subscriptions only; \
10206             any other feed here is cross-tenant read access granted by an outage",
10207        );
10208    }
10209
10210    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
10211    /// seeding `did` a beta seat + session-capable state.
10212    async fn test_state_with_caps(
10213        did: &str,
10214        max_subs_per_did: i64,
10215        max_feeds_global: i64,
10216    ) -> AppState {
10217        let db = store::init_url("sqlite::memory:").await.unwrap();
10218        let config = Config {
10219            cookie_secret: "test-cookie-secret-000".to_string(),
10220            beta_cap: 100,
10221            max_subs_per_did,
10222            max_feeds_global,
10223            ..Config::default()
10224        };
10225        store::grant_access(&db, did, None, "test", None)
10226            .await
10227            .unwrap();
10228        AppState::new(config, db).unwrap()
10229    }
10230
10231    /// An OPML document with `n` distinct public feeds.
10232    fn opml_with_feeds(n: usize) -> String {
10233        let mut outlines = String::new();
10234        for i in 0..n {
10235            outlines.push_str(&format!(
10236                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
10237            ));
10238        }
10239        format!(
10240            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
10241        )
10242    }
10243
10244    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
10245    /// distinct new feeds than the shared cache can hold caches only up to the
10246    /// ceiling — the rest are trimmed. (Regression: the import loop previously
10247    /// bypassed `max_feeds_global` entirely.)
10248    #[tokio::test]
10249    async fn opml_import_enforces_global_feeds_ceiling() {
10250        let did = "did:plc:importer";
10251        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
10252        let state = test_state_with_caps(did, 0, 3).await;
10253        let cookie = session_cookie(&state, did, None);
10254        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10255        let app = router(state.clone());
10256
10257        let resp = app
10258            .oneshot(
10259                Request::builder()
10260                    .method("POST")
10261                    .uri("/opml")
10262                    .header(header::COOKIE, cookie)
10263                    .header("content-type", ct)
10264                    .body(Body::from(body))
10265                    .unwrap(),
10266            )
10267            .await
10268            .unwrap();
10269        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10270
10271        let feeds = store::count_feeds(&state.db).await.unwrap();
10272        assert!(
10273            feeds <= 3,
10274            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
10275        );
10276    }
10277
10278    /// POST an OPML of `n` feeds as `did` against a strict fake PDS behind the
10279    /// sidecar, and return the flash it redirected with plus the fake's log.
10280    async fn import_against_strict_pds(
10281        did: &str,
10282        n: usize,
10283        fail_call: Option<usize>,
10284    ) -> (String, crate::atproto::tests::ApplyWritesLog) {
10285        let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
10286        let state = test_state_with_sidecar(&[did], &sidecar).await;
10287        let cookie = session_cookie(&state, did, None);
10288        let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
10289        let resp = router(state)
10290            .oneshot(
10291                Request::builder()
10292                    .method("POST")
10293                    .uri("/opml")
10294                    .header(header::COOKIE, cookie)
10295                    .header("content-type", ct)
10296                    .body(Body::from(body))
10297                    .unwrap(),
10298            )
10299            .await
10300            .unwrap();
10301        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10302        let loc = resp.headers()[header::LOCATION].to_str().unwrap();
10303        let flash = url::Url::parse(&format!("http://x{loc}"))
10304            .unwrap()
10305            .query_pairs()
10306            .find(|(k, _)| k == "flash")
10307            .map(|(_, v)| v.into_owned())
10308            .unwrap_or_default();
10309        (flash, log)
10310    }
10311
10312    /// **An OPML import of more than 200 feeds succeeds** against a PDS that
10313    /// refuses more than 200 writes a call, as the reference PDS does. It used
10314    /// to go out as one `applyWrites` and fail outright, importing nothing.
10315    #[tokio::test]
10316    async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
10317        let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
10318        assert_eq!(flash, "Imported 450 feeds", "{flash}");
10319        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
10320    }
10321
10322    /// **A part-landed import says so.** Chunk 2 of 3 fails: chunk 1's 200
10323    /// feeds are in the reader's repo, and "nothing was imported" — what the
10324    /// handler said for any failure — would be false.
10325    #[tokio::test]
10326    async fn opml_import_that_part_lands_reports_what_landed() {
10327        let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
10328        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
10329        assert!(
10330            flash.contains("200 of 450"),
10331            "the landed count is not reported: {flash}"
10332        );
10333        assert!(
10334            !flash.contains("nothing was imported"),
10335            "200 feeds landed and the reader was told none did: {flash}"
10336        );
10337    }
10338
10339    /// A batch that failed on its first call still reports that nothing was
10340    /// imported — true, since nothing after a failed call is sent.
10341    #[tokio::test]
10342    async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
10343        let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
10344        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
10345        assert!(flash.contains("nothing was imported"), "{flash}");
10346    }
10347
10348    /// **A malformed `at://` on the add path is "not a kind of feed we take",
10349    /// not "private/paid".** The first gate was the privacy classifier, whose
10350    /// at:// arm fails closed as `Private` for anything not a well-formed
10351    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
10352    /// the private-feed flash and a "refused private/paid feed" log line. On
10353    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
10354    /// feed". Storability is decided first for an at:// input, with its own
10355    /// message.
10356    #[tokio::test]
10357    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
10358        let did = "did:plc:typoist";
10359        let state = test_state_with_caps(did, 0, 0).await;
10360        let cookie = session_cookie(&state, did, None);
10361        for input in [
10362            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
10363            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10364        ] {
10365            let resp = router(state.clone())
10366                .oneshot(
10367                    Request::builder()
10368                        .method("POST")
10369                        .uri("/subscriptions")
10370                        .header(header::COOKIE, cookie.clone())
10371                        .header("content-type", "application/x-www-form-urlencoded")
10372                        .body(Body::from(format!("url={input}")))
10373                        .unwrap(),
10374                )
10375                .await
10376                .unwrap();
10377            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10378            let loc = resp
10379                .headers()
10380                .get(header::LOCATION)
10381                .unwrap()
10382                .to_str()
10383                .unwrap();
10384            assert!(
10385                loc.contains("kind%20of%20feed"),
10386                "expected the unsupported-feed flash for {input}, got {loc}"
10387            );
10388            assert!(
10389                !loc.contains("Private"),
10390                "a storability refusal was reported as a privacy one for {input}: {loc}"
10391            );
10392        }
10393        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10394    }
10395
10396    /// **An OPML entry this instance cannot store is counted and reported, not
10397    /// silently dropped.** The storability `continue` incremented nothing,
10398    /// while the privacy branch beside it produced a user-visible label — so
10399    /// an OPML exported from a standard.site-enabled instance imported
10400    /// "successfully" with entries missing and no reason given. The reader is
10401    /// told how many, and why.
10402    #[tokio::test]
10403    async fn opml_import_reports_entries_this_instance_cannot_store() {
10404        let did = "did:plc:renamer4";
10405        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10406        let state = test_state_with_sidecar(&[did], &sidecar).await;
10407        assert!(!state.config.standard_site);
10408        let opml = format!(
10409            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10410             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10411             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10412             </body></opml>"
10413        );
10414        let (ct, body) = opml_multipart(opml.as_bytes());
10415        let cookie = session_cookie(&state, did, None);
10416        let resp = router(state.clone())
10417            .oneshot(
10418                Request::builder()
10419                    .method("POST")
10420                    .uri("/opml")
10421                    .header(header::COOKIE, cookie)
10422                    .header("content-type", ct)
10423                    .body(Body::from(body))
10424                    .unwrap(),
10425            )
10426            .await
10427            .unwrap();
10428        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10429        let loc = resp
10430            .headers()
10431            .get(header::LOCATION)
10432            .unwrap()
10433            .to_str()
10434            .unwrap();
10435        assert!(
10436            loc.contains("Imported%201%20feed"),
10437            "unexpected flash: {loc}"
10438        );
10439        assert!(
10440            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
10441            "the dropped entry was not reported: {loc}"
10442        );
10443        // Reported by count only: the at-URI itself is not echoed back.
10444        assert!(
10445            !loc.contains("site.standard.publication"),
10446            "the URI was echoed: {loc}"
10447        );
10448    }
10449
10450    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
10451    /// cap imports zero new feeds.
10452    #[tokio::test]
10453    async fn opml_import_enforces_per_did_cap() {
10454        let did = "did:plc:capped";
10455        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
10456        let state = test_state_with_caps(did, 2, 0).await;
10457        let existing_a = store::upsert_feed(
10458            &state.db,
10459            &store::NewFeed {
10460                url: "https://have-a.example/feed.xml".to_string(),
10461                ..Default::default()
10462            },
10463        )
10464        .await
10465        .unwrap();
10466        let existing_b = store::upsert_feed(
10467            &state.db,
10468            &store::NewFeed {
10469                url: "https://have-b.example/feed.xml".to_string(),
10470                ..Default::default()
10471            },
10472        )
10473        .await
10474        .unwrap();
10475        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
10476            .await
10477            .unwrap();
10478        let before = store::count_feeds(&state.db).await.unwrap();
10479
10480        let cookie = session_cookie(&state, did, None);
10481        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
10482        let app = router(state.clone());
10483        let resp = app
10484            .oneshot(
10485                Request::builder()
10486                    .method("POST")
10487                    .uri("/opml")
10488                    .header(header::COOKIE, cookie)
10489                    .header("content-type", ct)
10490                    .body(Body::from(body))
10491                    .unwrap(),
10492            )
10493            .await
10494            .unwrap();
10495        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10496        // Headroom was 0 → no new feeds imported into the shared cache.
10497        let after = store::count_feeds(&state.db).await.unwrap();
10498        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
10499    }
10500
10501    /// Single-add per-DID cap: a DID at its subscription cap is refused before
10502    /// any fetch, with the limit flash.
10503    #[tokio::test]
10504    async fn single_add_enforces_per_did_cap() {
10505        let did = "did:plc:subcapped";
10506        let state = test_state_with_caps(did, 1, 0).await;
10507        let f = store::upsert_feed(
10508            &state.db,
10509            &store::NewFeed {
10510                url: "https://have.example/feed.xml".to_string(),
10511                ..Default::default()
10512            },
10513        )
10514        .await
10515        .unwrap();
10516        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
10517        let cookie = session_cookie(&state, did, None);
10518        let app = router(state.clone());
10519        let resp = app
10520            .oneshot(
10521                Request::builder()
10522                    .method("POST")
10523                    .uri("/subscriptions")
10524                    .header(header::COOKIE, cookie)
10525                    .header("content-type", "application/x-www-form-urlencoded")
10526                    .body(Body::from("url=https://another.example/feed.xml"))
10527                    .unwrap(),
10528            )
10529            .await
10530            .unwrap();
10531        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10532        let loc = resp
10533            .headers()
10534            .get(header::LOCATION)
10535            .unwrap()
10536            .to_str()
10537            .unwrap();
10538        assert!(
10539            loc.contains("Subscription%20limit%20reached"),
10540            "expected sub-limit flash, got {loc}"
10541        );
10542    }
10543
10544    /// `GET /` renders at most one page of rows and offers a way to the rest.
10545    ///
10546    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
10547    /// `LIMIT`, article bodies included — and hand the lot to the template. With
10548    /// 250 entries that is the whole list in one response; with a real backlog on
10549    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
10550    /// is capped, the heading still reports the true total, and page 2 is
10551    /// reachable and disjoint.
10552    #[tokio::test]
10553    async fn the_reader_index_pages_instead_of_rendering_everything() {
10554        let did = "did:plc:pager";
10555        let state = test_state(&[]).await;
10556        store::grant_access(&state.db, did, None, "test", None)
10557            .await
10558            .unwrap();
10559        let feed = store::upsert_feed(
10560            &state.db,
10561            &store::NewFeed {
10562                url: "https://pager.example/feed.xml".to_string(),
10563                title: Some("Pager".to_string()),
10564                ..Default::default()
10565            },
10566        )
10567        .await
10568        .unwrap();
10569        let total = 250_usize;
10570        let entries: Vec<store::NewEntry> = (0..total)
10571            .map(|i| store::NewEntry {
10572                guid: format!("p-{i:04}"),
10573                url: Some(format!("https://pager.example/{i}")),
10574                title: Some(format!("Article {i:04}")),
10575                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
10576                content_html: Some("x".repeat(4_000)),
10577                ..Default::default()
10578            })
10579            .collect();
10580        store::insert_entries(&state.db, feed, &entries, 0)
10581            .await
10582            .unwrap();
10583        store::replace_sub_refs(&state.db, did, &[feed])
10584            .await
10585            .unwrap();
10586
10587        let cookie = session_cookie(&state, did, None);
10588        let app = router(state.clone());
10589        let get = |uri: &str| {
10590            let app = app.clone();
10591            let cookie = cookie.clone();
10592            let uri = uri.to_string();
10593            async move {
10594                let resp = app
10595                    .oneshot(
10596                        Request::builder()
10597                            .uri(uri)
10598                            .header(header::COOKIE, cookie)
10599                            .body(Body::empty())
10600                            .unwrap(),
10601                    )
10602                    .await
10603                    .unwrap();
10604                assert_eq!(resp.status(), StatusCode::OK);
10605                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
10606                    .await
10607                    .unwrap();
10608                String::from_utf8(bytes.to_vec()).unwrap()
10609            }
10610        };
10611
10612        let page1 = get("/").await;
10613        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
10614        // over-count: each row carries several (the link plus the read/star
10615        // forms).
10616        let rows1 = page1.matches("<li class=\"entry").count();
10617        assert!(
10618            rows1 <= ENTRIES_PER_PAGE as usize,
10619            "page 1 rendered {rows1} entry links; the list is unbounded"
10620        );
10621        assert!(
10622            rows1 > 0,
10623            "page 1 rendered nothing at all: the page bound swallowed the list"
10624        );
10625        // The count is the TRUE total, not the page size — otherwise paging
10626        // would quietly relabel a 250-entry backlog as a 100-entry one.
10627        assert!(
10628            page1.contains("250 entries"),
10629            "heading must report the full total, not the page"
10630        );
10631        assert!(
10632            page1.contains("page=2"),
10633            "no way to reach the rest of the list: {}",
10634            &page1[..page1.len().min(400)]
10635        );
10636        // The body never belongs in a list response.
10637        assert!(
10638            !page1.contains(&"x".repeat(4_000)),
10639            "the list response carried an article body"
10640        );
10641
10642        let page2 = get("/?page=2").await;
10643        assert!(
10644            page2.matches("<li class=\"entry").count() > 0,
10645            "page 2 rendered no rows at all"
10646        );
10647        assert!(
10648            page2.contains("page=1") || page2.contains("Newer"),
10649            "page 2 offers no way back"
10650        );
10651        // Disjoint: an article on page 1 must not reappear on page 2.
10652        let first_title = (0..total)
10653            .map(|i| format!("Article {i:04}"))
10654            .find(|t| page1.contains(t))
10655            .expect("page 1 shows at least one titled article");
10656        assert!(
10657            !page2.contains(&first_title),
10658            "{first_title} appears on both pages"
10659        );
10660
10661        // A page past the end must not be a dead end. The empty state renders
10662        // instead of the pager, so an out-of-range page would leave a reader
10663        // with no link back — reachable by typing a number, and reachable
10664        // WITHOUT typing anything by paging to the end and then marking entries
10665        // read, which shrinks the list under the URL already in the address bar.
10666        let past_end = get("/?page=999").await;
10667        assert!(
10668            past_end.matches("<li class=\"entry").count() > 0,
10669            "an out-of-range page rendered nothing and offered no way back"
10670        );
10671        assert!(
10672            past_end.contains("page=2"),
10673            "the clamped page offers no pager"
10674        );
10675    }
10676
10677    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
10678    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
10679    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
10680    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
10681    /// view (no reader header) instead swaps the row. This guards the reader OOB
10682    /// toggle wiring, which had no test.
10683    #[tokio::test]
10684    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
10685        let did = "did:plc:reader";
10686        let state = test_state(&[]).await;
10687        store::grant_access(&state.db, did, None, "test", None)
10688            .await
10689            .unwrap();
10690        let feed = store::upsert_feed(
10691            &state.db,
10692            &store::NewFeed {
10693                url: "https://reader.example/feed.xml".to_string(),
10694                title: Some("Reader".to_string()),
10695                ..Default::default()
10696            },
10697        )
10698        .await
10699        .unwrap();
10700        store::insert_entries(
10701            &state.db,
10702            feed,
10703            &[store::NewEntry {
10704                guid: "r-1".to_string(),
10705                url: Some("https://reader.example/1".to_string()),
10706                title: Some("Article".to_string()),
10707                published: Some("2026-07-11T00:00:00Z".to_string()),
10708                content_html: Some("<p>body</p>".to_string()),
10709                ..Default::default()
10710            }],
10711            0,
10712        )
10713        .await
10714        .unwrap();
10715        store::replace_sub_refs(&state.db, did, &[feed])
10716            .await
10717            .unwrap();
10718        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10719
10720        let cookie = session_cookie(&state, did, None);
10721        let app = router(state.clone());
10722
10723        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
10724        let resp = app
10725            .clone()
10726            .oneshot(
10727                Request::builder()
10728                    .method("POST")
10729                    .uri(format!("/entries/{entry_id}/read"))
10730                    .header(header::COOKIE, cookie.clone())
10731                    .header("HX-Request", "true")
10732                    .header("X-FR-Reader", "1")
10733                    .header("content-type", "application/x-www-form-urlencoded")
10734                    .body(Body::from("read=true"))
10735                    .unwrap(),
10736            )
10737            .await
10738            .unwrap();
10739        assert_eq!(resp.status(), StatusCode::OK);
10740        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10741            .await
10742            .unwrap();
10743        let html = String::from_utf8(bytes.to_vec()).unwrap();
10744        assert!(
10745            html.contains("hx-swap-oob=\"outerHTML\""),
10746            "reader response must be an OOB swap: {html}"
10747        );
10748        assert!(
10749            html.contains(r#"id="entry-actionbar""#),
10750            "reader response must be the action-bar fragment: {html}"
10751        );
10752        // Now READ: the read button reflects it (aria-pressed=true) and the
10753        // hidden value flips to `false` so the next tap marks it UNREAD.
10754        assert!(
10755            html.contains(r#"aria-pressed="true""#),
10756            "read button must show pressed after marking read: {html}"
10757        );
10758        assert!(
10759            html.contains(r#"name="read" value="false""#),
10760            "hidden read value must flip to false so a second tap reverses: {html}"
10761        );
10762
10763        // A second reader mark-read (submitting the flipped `read=false`) marks
10764        // it UNREAD again — the toggle reverses.
10765        let resp2 = app
10766            .oneshot(
10767                Request::builder()
10768                    .method("POST")
10769                    .uri(format!("/entries/{entry_id}/read"))
10770                    .header(header::COOKIE, cookie)
10771                    .header("HX-Request", "true")
10772                    .header("X-FR-Reader", "1")
10773                    .header("content-type", "application/x-www-form-urlencoded")
10774                    .body(Body::from("read=false"))
10775                    .unwrap(),
10776            )
10777            .await
10778            .unwrap();
10779        assert_eq!(resp2.status(), StatusCode::OK);
10780        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10781            .await
10782            .unwrap();
10783        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10784        assert!(
10785            html2.contains(r#"aria-pressed="false""#),
10786            "read button must show un-pressed after reversing: {html2}"
10787        );
10788        assert!(
10789            html2.contains(r#"name="read" value="true""#),
10790            "hidden read value must flip back to true: {html2}"
10791        );
10792    }
10793
10794    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
10795    /// action-bar — the counterpart to the reader-OOB test above.
10796    #[tokio::test]
10797    async fn list_mark_read_returns_row_not_oob_actionbar() {
10798        let did = "did:plc:listv";
10799        let state = test_state(&[]).await;
10800        store::grant_access(&state.db, did, None, "test", None)
10801            .await
10802            .unwrap();
10803        let feed = store::upsert_feed(
10804            &state.db,
10805            &store::NewFeed {
10806                url: "https://list.example/feed.xml".to_string(),
10807                title: Some("List".to_string()),
10808                ..Default::default()
10809            },
10810        )
10811        .await
10812        .unwrap();
10813        store::insert_entries(
10814            &state.db,
10815            feed,
10816            &[store::NewEntry {
10817                guid: "l-1".to_string(),
10818                url: Some("https://list.example/1".to_string()),
10819                title: Some("Article".to_string()),
10820                published: Some("2026-07-11T00:00:00Z".to_string()),
10821                ..Default::default()
10822            }],
10823            0,
10824        )
10825        .await
10826        .unwrap();
10827        store::replace_sub_refs(&state.db, did, &[feed])
10828            .await
10829            .unwrap();
10830        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10831
10832        let cookie = session_cookie(&state, did, None);
10833        let app = router(state.clone());
10834
10835        let resp = app
10836            .oneshot(
10837                Request::builder()
10838                    .method("POST")
10839                    .uri(format!("/entries/{entry_id}/read"))
10840                    .header(header::COOKIE, cookie)
10841                    .header("HX-Request", "true")
10842                    .header("content-type", "application/x-www-form-urlencoded")
10843                    .body(Body::from("read=true"))
10844                    .unwrap(),
10845            )
10846            .await
10847            .unwrap();
10848        assert_eq!(resp.status(), StatusCode::OK);
10849        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10850            .await
10851            .unwrap();
10852        let html = String::from_utf8(bytes.to_vec()).unwrap();
10853        assert!(
10854            !html.contains("hx-swap-oob"),
10855            "list-view response must NOT be an OOB swap: {html}"
10856        );
10857        // **And it must actually BE the row.** The assertion above is satisfied
10858        // by an empty body, or by any response that simply omits the attribute —
10859        // so on its own it pins half a property and the name promises the other
10860        // half.
10861        assert!(
10862            html.contains(&format!("/entries/{entry_id}")),
10863            "the response is not the row for this entry: {html}",
10864        );
10865        assert!(
10866            html.contains("Article"),
10867            "the row rendered without its title: {html}",
10868        );
10869        // **The row comes back carrying read state. That is all this proves.**
10870        //
10871        // It does NOT prove the state was persisted: the handler renders
10872        // `Some(read)` from the form value, so making `mark_read` roll back
10873        // instead of commit fails 11 store tests and leaves this one green.
10874        //
10875        // It does not prove the OVERRIDE either, which an earlier version of
10876        // this comment claimed. Verified: changing the call site to
10877        // `build_entry_row(pool, &did, id, None)` — deleting the override
10878        // wholesale — keeps the whole suite green, because `mark_read` has
10879        // already persisted the same value two lines earlier, so reading it back
10880        // from the database produces an identical row.
10881        //
10882        // Distinguishing the two needs a case where the override and the stored
10883        // state DISAGREE, which this handler never produces: it writes the value
10884        // it then renders. Left as a known gap rather than described as covered.
10885        assert!(
10886            html.contains("is-read"),
10887            "the row came back without the read state it was just given: {html}",
10888        );
10889    }
10890
10891    // -----------------------------------------------------------------------
10892    // Rename parity (POST /subscriptions/{rkey}/rename)
10893    // -----------------------------------------------------------------------
10894
10895    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
10896    ///
10897    /// The add path gates the URL the user *typed*; the URL it *stores* is
10898    /// whatever `resolve_feed_url` returns, which for an HTML page is a
10899    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
10900    /// that: `discover_feed` yields only http(s), and the add path re-checks
10901    /// storability on the resolved URL. This test pins the DISJUNCTION —
10902    /// each layer alone holds it, both removed fails it — driven through the
10903    /// real route against a real local server.
10904    ///
10905    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
10906    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
10907    /// form: once storage became DID-only the privacy classifier refused it
10908    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
10909    /// — the colons in the DID), so `discover_feed` drops it before either
10910    /// layer exists. An at:// link cannot come out of autodiscovery under
10911    /// ANY mutation of the layers, so no test through this route can pin
10912    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
10913    /// structure and pinned where it lives: `discover_skips_a_non_http_
10914    /// alternate` and the storability tests in `feed.rs`.
10915    #[tokio::test]
10916    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10917        let did = "did:plc:autodiscovered";
10918        // Access granted, both caps disabled — the only gates left are the
10919        // two under test.
10920        let state = test_state_with_caps(did, 0, 0).await;
10921
10922        let page = r#"<!doctype html><html><head><title>Blog</title>
10923            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10924            </head><body>hi</body></html>"#;
10925        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10926        let port: u16 = base
10927            .trim_end_matches('/')
10928            .rsplit(':')
10929            .next()
10930            .unwrap()
10931            .parse()
10932            .unwrap();
10933        crate::net::test_host_override(
10934            "autodiscover-ftp.test",
10935            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10936        );
10937
10938        let cookie = session_cookie(&state, did, None);
10939        let resp = router(state.clone())
10940            .oneshot(
10941                Request::builder()
10942                    .method("POST")
10943                    .uri("/subscriptions")
10944                    .header(header::COOKIE, cookie)
10945                    .header("content-type", "application/x-www-form-urlencoded")
10946                    .body(Body::from(format!(
10947                        "url=http://autodiscover-ftp.test:{port}/"
10948                    )))
10949                    .unwrap(),
10950            )
10951            .await
10952            .unwrap();
10953        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10954        let loc = resp
10955            .headers()
10956            .get(header::LOCATION)
10957            .unwrap()
10958            .to_str()
10959            .unwrap();
10960        assert_ne!(loc, "/login", "the test never reached the add path");
10961        assert_ne!(loc, "/", "the subscribe succeeded");
10962
10963        assert_eq!(
10964            store::count_feeds(&state.db).await.unwrap(),
10965            0,
10966            "a non-http(s) URL from autodiscovery was stored"
10967        );
10968        assert_eq!(
10969            store::count_subscriptions_for_did(&state.db, did)
10970                .await
10971                .unwrap(),
10972            0
10973        );
10974    }
10975
10976    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
10977    /// its global ceiling must be refused (capacity flash) and must NOT insert a
10978    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
10979    /// rename loop can't inflate the shared cache past the cap.
10980    #[tokio::test]
10981    async fn rename_to_new_url_refused_at_global_feeds_cap() {
10982        let did = "did:plc:renamer4";
10983        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10984        // Global cap 1; pre-fill it with one feed so headroom is 0.
10985        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10986        store::upsert_feed(
10987            &state.db,
10988            &store::NewFeed {
10989                url: "https://existing.example/feed.xml".to_string(),
10990                ..Default::default()
10991            },
10992        )
10993        .await
10994        .unwrap();
10995        let before = store::count_feeds(&state.db).await.unwrap();
10996        assert_eq!(before, 1);
10997
10998        let cookie = session_cookie(&state, did, None);
10999        let resp = router(state.clone())
11000            .oneshot(
11001                Request::builder()
11002                    .method("POST")
11003                    .uri("/subscriptions/rk-keep/rename")
11004                    .header(header::COOKIE, cookie)
11005                    .header("content-type", "application/x-www-form-urlencoded")
11006                    // A URL not in the cache → would be a NEW feeds row.
11007                    .body(Body::from(
11008                        "url=https://brand-new.example/feed.xml&title=Renamed",
11009                    ))
11010                    .unwrap(),
11011            )
11012            .await
11013            .unwrap();
11014        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11015        let loc = resp
11016            .headers()
11017            .get(header::LOCATION)
11018            .unwrap()
11019            .to_str()
11020            .unwrap();
11021        assert!(
11022            loc.contains("feed%20capacity"),
11023            "expected the feed-capacity flash, got {loc}"
11024        );
11025        // No new feeds row was inserted, and nothing reached the PDS.
11026        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
11027        assert!(
11028            puts.lock().unwrap().is_empty(),
11029            "a refused repoint reached the PDS"
11030        );
11031    }
11032
11033    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
11034    /// global cap (only new URLs are gated) — the other half of the guard.
11035    ///
11036    /// On the sidecar fake, so "allowed" means the put actually happened: the
11037    /// earlier harness had no sidecar, and this passed on a "could not reach
11038    /// your PDS" flash that merely was not the capacity one.
11039    #[tokio::test]
11040    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
11041        let did = "did:plc:renamer4";
11042        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11043        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11044        store::upsert_feed(
11045            &state.db,
11046            &store::NewFeed {
11047                url: "https://existing.example/feed.xml".to_string(),
11048                ..Default::default()
11049            },
11050        )
11051        .await
11052        .unwrap();
11053        let before = store::count_feeds(&state.db).await.unwrap();
11054
11055        let cookie = session_cookie(&state, did, None);
11056        let resp = router(state.clone())
11057            .oneshot(
11058                Request::builder()
11059                    .method("POST")
11060                    .uri("/subscriptions/rk-keep/rename")
11061                    .header(header::COOKIE, cookie)
11062                    .header("content-type", "application/x-www-form-urlencoded")
11063                    .body(Body::from(
11064                        "url=https://existing.example/feed.xml&title=Retitled",
11065                    ))
11066                    .unwrap(),
11067            )
11068            .await
11069            .unwrap();
11070        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11071        let loc = resp
11072            .headers()
11073            .get(header::LOCATION)
11074            .unwrap()
11075            .to_str()
11076            .unwrap();
11077        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
11078        assert_eq!(
11079            puts.lock().unwrap().len(),
11080            1,
11081            "the repoint did not reach the PDS"
11082        );
11083        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
11084    }
11085
11086    /// A rename with a blank URL writes nothing anywhere.
11087    #[tokio::test]
11088    async fn rename_with_blank_url_writes_nothing() {
11089        let did = "did:plc:renamer3";
11090        let state = test_state_with_caps(did, 0, 0).await;
11091        let before = store::count_feeds(&state.db).await.unwrap();
11092        assert_eq!(before, 0);
11093
11094        let cookie = session_cookie(&state, did, None);
11095        let app = router(state.clone());
11096        let resp = app
11097            .oneshot(
11098                Request::builder()
11099                    .method("POST")
11100                    .uri("/subscriptions/rkey123/rename")
11101                    .header(header::COOKIE, cookie)
11102                    .header("content-type", "application/x-www-form-urlencoded")
11103                    // Whitespace-only URL trims to empty.
11104                    .body(Body::from("url=%20%20&title=Nope"))
11105                    .unwrap(),
11106            )
11107            .await
11108            .unwrap();
11109        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11110        assert_eq!(
11111            resp.headers()
11112                .get(header::LOCATION)
11113                .unwrap()
11114                .to_str()
11115                .unwrap(),
11116            "/",
11117        );
11118        // Nothing was cached.
11119        assert_eq!(
11120            store::count_feeds(&state.db).await.unwrap(),
11121            0,
11122            "blank-URL rename wrote a junk feeds row"
11123        );
11124    }
11125
11126    /// A sidecar mock that serves ONE existing subscription record and captures
11127    /// every `put` body a rename produces.
11128    ///
11129    /// **Reads to `content-length` rather than taking one `read`.** A single
11130    /// read gets whatever one segment carried; if the head and body land
11131    /// separately the capture holds no record and every field assertion below
11132    /// passes for the wrong reason. Each captured body must also mention the
11133    /// collection, so an empty capture fails loudly instead of quietly.
11134    async fn spawn_rename_sidecar(
11135        existing: serde_json::Value,
11136    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
11137        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
11138        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11139        let addr = listener.local_addr().unwrap();
11140        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
11141        let sink = puts.clone();
11142        tokio::spawn(async move {
11143            loop {
11144                let Ok((mut sock, _)) = listener.accept().await else {
11145                    break;
11146                };
11147                let mut raw: Vec<u8> = Vec::new();
11148                let mut chunk = [0u8; 4096];
11149                let body_text = loop {
11150                    let Ok(n) = sock.read(&mut chunk).await else {
11151                        break String::new();
11152                    };
11153                    if n == 0 {
11154                        break String::from_utf8_lossy(&raw).to_string();
11155                    }
11156                    raw.extend_from_slice(&chunk[..n]);
11157                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
11158                        continue;
11159                    };
11160                    let (head, body) = raw.split_at(split + 4);
11161                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
11162                        let (k, v) = l.split_once(':')?;
11163                        k.eq_ignore_ascii_case("content-length")
11164                            .then(|| v.trim().parse::<usize>().ok())?
11165                    });
11166                    if want.is_none_or(|want| body.len() >= want) {
11167                        break String::from_utf8_lossy(body).to_string();
11168                    }
11169                };
11170
11171                // `"action":"put"` is the rename write; anything else is the read.
11172                let is_put = body_text.contains("\"action\":\"put\"");
11173                let data = if is_put {
11174                    sink.lock().unwrap().push(body_text.clone());
11175                    serde_json::json!({
11176                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
11177                        "cid": "bafyreiafter"
11178                    })
11179                } else {
11180                    serde_json::json!({ "records": [existing.clone()] })
11181                };
11182                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
11183                let resp = format!(
11184                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11185                    body.len(),
11186                    body
11187                );
11188                let _ = sock.write_all(resp.as_bytes()).await;
11189                let _ = sock.flush().await;
11190            }
11191        });
11192        (format!("http://{addr}"), puts)
11193    }
11194
11195    /// The existing record a rename must not destroy.
11196    fn seeded_subscription() -> serde_json::Value {
11197        serde_json::json!({
11198            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
11199            "cid": "bafyreibefore",
11200            "value": {
11201                "$type": "community.lexicon.rss.subscription",
11202                "url": "https://example.com/feed.xml",
11203                "title": "Old title",
11204                "siteUrl": "https://example.com/blog",
11205                "fetchHint": "hourly",
11206                "private": false,
11207                "createdAt": "2024-03-01T00:00:00.000Z"
11208            }
11209        })
11210    }
11211
11212    /// An existing standard.site subscription, as the 19 in production are:
11213    /// written before this reader refused the scheme, still in the repo.
11214    fn seeded_at_uri_subscription() -> serde_json::Value {
11215        seeded_subscription_with_url(AT_URI_SUB)
11216    }
11217    /// An existing subscription record at `rk-keep` with the given URL.
11218    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
11219        serde_json::json!({
11220            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
11221            "cid": "bafyreibefore",
11222            "value": {
11223                "$type": "community.lexicon.rss.subscription",
11224                "url": url,
11225                "title": "Old title",
11226                "private": false,
11227                "createdAt": "2024-03-01T00:00:00.000Z"
11228            }
11229        })
11230    }
11231    const AT_URI_SUB: &str =
11232        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
11233    const AT_URI_SUB_ENC: &str =
11234        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
11235
11236    /// **Retitling an existing `at://` subscription must work with the flag off.**
11237    ///
11238    /// The storability guard was placed before the repo lookup, so it refused
11239    /// any rename whose URL is an at-URI — including a pure title or folder
11240    /// change on a record that already exists. On main that rename succeeded;
11241    /// the 19 production records would have become un-editable. The flag gates
11242    /// what may be STORED in the cache, not whether a reader may edit their own
11243    /// record: the PDS write goes through, the cache row is simply not created.
11244    #[tokio::test]
11245    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
11246        let did = "did:plc:renamer5";
11247        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11248        let state = test_state_with_sidecar(&[did], &sidecar).await;
11249        assert!(
11250            !state.config.standard_site,
11251            "the flag must be off for this test"
11252        );
11253        let cookie = session_cookie(&state, did, None);
11254        let resp = router(state.clone())
11255            .oneshot(
11256                Request::builder()
11257                    .method("POST")
11258                    .uri("/subscriptions/rk-keep/rename")
11259                    .header(header::COOKIE, cookie)
11260                    .header("content-type", "application/x-www-form-urlencoded")
11261                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
11262                    .unwrap(),
11263            )
11264            .await
11265            .unwrap();
11266        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11267        let loc = resp
11268            .headers()
11269            .get(header::LOCATION)
11270            .unwrap()
11271            .to_str()
11272            .unwrap();
11273        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11274
11275        let bodies = puts.lock().unwrap().clone();
11276        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11277        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11278        assert_eq!(
11279            sent["record"]["title"], "New title",
11280            "the rename did not apply"
11281        );
11282        assert_eq!(
11283            sent["record"]["url"], AT_URI_SUB,
11284            "the rename changed the URL"
11285        );
11286
11287        // The flag still means what it says for the CACHE: no at:// row.
11288        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11289        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
11290    }
11291
11292    /// **Repointing a subscription AT an `at://` URI is still refused with the
11293    /// flag off** — the half of the guard that has to survive the fix above.
11294    /// Nothing reaches the PDS and nothing reaches the cache.
11295    #[tokio::test]
11296    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
11297        let did = "did:plc:renamer4";
11298        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11299        let state = test_state_with_sidecar(&[did], &sidecar).await;
11300        let cookie = session_cookie(&state, did, None);
11301        let resp = router(state.clone())
11302            .oneshot(
11303                Request::builder()
11304                    .method("POST")
11305                    .uri("/subscriptions/rk-keep/rename")
11306                    .header(header::COOKIE, cookie)
11307                    .header("content-type", "application/x-www-form-urlencoded")
11308                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11309                    .unwrap(),
11310            )
11311            .await
11312            .unwrap();
11313        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11314        let loc = resp
11315            .headers()
11316            .get(header::LOCATION)
11317            .unwrap()
11318            .to_str()
11319            .unwrap();
11320        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
11321        assert!(
11322            !loc.contains("Private"),
11323            "a storability refusal was reported as a privacy one: {loc}"
11324        );
11325        assert!(
11326            puts.lock().unwrap().is_empty(),
11327            "the repoint reached the PDS"
11328        );
11329        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
11330        assert_eq!(cached, 0);
11331    }
11332
11333    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
11334    /// redirect location.
11335    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
11336        let cookie = session_cookie(state, did, None);
11337        let resp = router(state.clone())
11338            .oneshot(
11339                Request::builder()
11340                    .method("POST")
11341                    .uri("/subscriptions/rk-keep/rename")
11342                    .header(header::COOKIE, cookie)
11343                    .header("content-type", "application/x-www-form-urlencoded")
11344                    .body(Body::from(format!("url={url_enc}&title=New+title")))
11345                    .unwrap(),
11346            )
11347            .await
11348            .unwrap();
11349        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11350        resp.headers()
11351            .get(header::LOCATION)
11352            .unwrap()
11353            .to_str()
11354            .unwrap()
11355            .to_string()
11356    }
11357
11358    /// **The privacy gate has the same ordering bug the storable gate had.**
11359    ///
11360    /// Another client can write a subscription whose URL is an at-URI that is
11361    /// not a well-formed publication URI at all — a feed generator, say. On
11362    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
11363    /// the classifier reads as `Public`). The narrowed at:// arm now fails
11364    /// closed as `Private` for it, and the gate ran before `url_changed` was
11365    /// known — so the record became un-editable, with a flash claiming it "was
11366    /// not saved or sent anywhere". Both gates now apply to a repoint only.
11367    #[tokio::test]
11368    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
11369        let did = "did:plc:renamer5";
11370        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
11371        let other_enc =
11372            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
11373        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
11374        let state = test_state_with_sidecar(&[did], &sidecar).await;
11375        let loc = retitle_unchanged(&state, did, other_enc).await;
11376        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11377        let bodies = puts.lock().unwrap().clone();
11378        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11379        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11380        assert_eq!(sent["record"]["title"], "New title");
11381        assert_eq!(sent["record"]["url"], other);
11382    }
11383
11384    /// **A repoint to a secret-bearing URL is still refused** — the half of
11385    /// the privacy gate that has to survive moving it behind `url_changed`.
11386    /// Found by mutation: with the gate deleted outright, nothing failed.
11387    #[tokio::test]
11388    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
11389        let did = "did:plc:renamer4";
11390        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11391        let state = test_state_with_sidecar(&[did], &sidecar).await;
11392        let cookie = session_cookie(&state, did, None);
11393        let resp = router(state.clone())
11394            .oneshot(
11395                Request::builder()
11396                    .method("POST")
11397                    .uri("/subscriptions/rk-keep/rename")
11398                    .header(header::COOKIE, cookie)
11399                    .header("content-type", "application/x-www-form-urlencoded")
11400                    .body(Body::from(
11401                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
11402                    ))
11403                    .unwrap(),
11404            )
11405            .await
11406            .unwrap();
11407        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11408        let loc = resp
11409            .headers()
11410            .get(header::LOCATION)
11411            .unwrap()
11412            .to_str()
11413            .unwrap();
11414        assert!(
11415            loc.contains("Private"),
11416            "the private repoint was not refused: {loc}"
11417        );
11418        assert!(
11419            puts.lock().unwrap().is_empty(),
11420            "a secret-bearing URL reached the PDS"
11421        );
11422        // The repo's fixture token: opaque enough for the classifier, not a real
11423        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
11424        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
11425        assert!(store::get_feed_by_url(&state.db, leaked)
11426            .await
11427            .unwrap()
11428            .is_none());
11429    }
11430
11431    /// **A retitle of a never-cached at:// subscription is not "at feed
11432    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
11433    /// and an at:// record is never cached with the flag off — so at capacity,
11434    /// a pure retitle was refused for a row the handler would not insert. The
11435    /// check now runs once `url_changed` is known and only for a repoint.
11436    #[tokio::test]
11437    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
11438        let did = "did:plc:renamer5";
11439        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11440        // Ceiling 1, and one real feed already fills it.
11441        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11442        store::upsert_feed(
11443            &state.db,
11444            &store::NewFeed {
11445                url: "https://filler.example/feed.xml".to_string(),
11446                ..Default::default()
11447            },
11448        )
11449        .await
11450        .unwrap();
11451        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11452        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11453        assert_eq!(
11454            puts.lock().unwrap().len(),
11455            1,
11456            "the retitle did not reach the PDS"
11457        );
11458        assert_eq!(
11459            store::count_feeds(&state.db).await.unwrap(),
11460            1,
11461            "a row was inserted"
11462        );
11463    }
11464
11465    /// POST `/subscriptions` with `url`, returning the redirect target.
11466    async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
11467        let cookie = session_cookie(state, did, None);
11468        let resp = router(state.clone())
11469            .oneshot(
11470                Request::builder()
11471                    .method("POST")
11472                    .uri("/subscriptions")
11473                    .header(header::COOKIE, cookie)
11474                    .header("content-type", "application/x-www-form-urlencoded")
11475                    .body(Body::from(format!("url={url_enc}")))
11476                    .unwrap(),
11477            )
11478            .await
11479            .unwrap();
11480        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11481        resp.headers()
11482            .get(header::LOCATION)
11483            .unwrap()
11484            .to_str()
11485            .unwrap()
11486            .to_string()
11487    }
11488
11489    /// A `com.atproto.identity.resolveHandle` that answers `did` for anything.
11490    async fn serve_resolver(did: &str) -> String {
11491        let base = crate::net::tests::serve_body(
11492            serde_json::json!({ "did": did }).to_string().into_bytes(),
11493        )
11494        .await;
11495        let port: u16 = base
11496            .trim_end_matches('/')
11497            .rsplit(':')
11498            .next()
11499            .unwrap()
11500            .parse()
11501            .unwrap();
11502        let host = format!("resolver-{port}.test");
11503        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11504        format!("http://{host}:{port}")
11505    }
11506
11507    fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
11508        let mut config = (*state.config).clone();
11509        f(&mut config);
11510        state.config = std::sync::Arc::new(config);
11511        state
11512    }
11513
11514    /// **0.4.0 step 3: with the flag ON, a well-formed at:// paste is
11515    /// subscribed.** It was refused as unsupported while nothing could read a
11516    /// publication; the poller reads them now. Stored in DID form, as a
11517    /// `publication`, and written to the reader's PDS like any subscription.
11518    #[tokio::test]
11519    async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
11520        let did = "did:plc:renamer5";
11521        let (sidecar, log) = spawn_logging_sidecar().await;
11522        let state = with_config(
11523            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11524            |c| {
11525                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11526            },
11527        );
11528        let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
11529        assert_eq!(loc, "/", "the paste was refused: {loc}");
11530        let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
11531            .await
11532            .unwrap()
11533            .expect("no feed row");
11534        assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
11535        let sent = log.lock().unwrap().join("\n");
11536        assert!(
11537            sent.contains(AT_URI_SUB),
11538            "the subscription was not written to the PDS: {sent}"
11539        );
11540    }
11541
11542    /// **A0 through the form** — the 0.4.0 exit test, end to end: a reader
11543    /// pastes a publication, it is stored and written to their PDS, and the
11544    /// first poll — the one subscribing runs at once — stores its documents.
11545    #[tokio::test]
11546    async fn a0_subscribing_from_the_form_delivers_entries() {
11547        let did = "did:plc:renamer5";
11548        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11549        let site = AT_URI_SUB;
11550        let (plc, _) = crate::standard_site::tests::serve_repo(
11551            author,
11552            vec![
11553                (
11554                    lexicon::nsid::STANDARD_PUBLICATION,
11555                    "3lab2c4d5e6f7g8h",
11556                    serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
11557                ),
11558                (
11559                    lexicon::nsid::STANDARD_DOCUMENT,
11560                    "3l2a0frmaaa2a",
11561                    serde_json::json!({ "title": "From the form", "path": "/f",
11562                        "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
11563                ),
11564            ],
11565        )
11566        .await;
11567        let (sidecar, _log) = spawn_logging_sidecar().await;
11568        let state = with_config(
11569            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11570            |c| {
11571                c.oauth.plc_directory = plc;
11572            },
11573        );
11574        assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
11575        let row = store::get_feed_by_url(&state.db, site)
11576            .await
11577            .unwrap()
11578            .unwrap();
11579        let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
11580            .bind(row.id)
11581            .fetch_all(&state.db)
11582            .await
11583            .unwrap();
11584        assert_eq!(
11585            titles,
11586            vec!["From the form".to_string()],
11587            "the first poll stored nothing"
11588        );
11589        assert_eq!(row.title.as_deref(), Some("A0 Journal"));
11590    }
11591
11592    /// A handle-form paste is resolved to the DID before it is stored: a
11593    /// handle is a mutable name, and `feeds.url` is keyed on identity.
11594    #[tokio::test]
11595    async fn a_handle_form_paste_is_stored_by_its_did() {
11596        let did = "did:plc:renamer5";
11597        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
11598        let (sidecar, _log) = spawn_logging_sidecar().await;
11599        let resolver = serve_resolver(author).await;
11600        let state = with_config(
11601            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11602            |c| {
11603                c.resolver_base = resolver;
11604                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11605            },
11606        );
11607        let loc = subscribe(
11608            &state,
11609            did,
11610            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11611        )
11612        .await;
11613        assert_eq!(loc, "/", "the paste was refused: {loc}");
11614        assert!(
11615            store::get_feed_by_url(&state.db, AT_URI_SUB)
11616                .await
11617                .unwrap()
11618                .is_some(),
11619            "not stored by its DID"
11620        );
11621        assert_eq!(
11622            store::count_feeds(&state.db).await.unwrap(),
11623            1,
11624            "the handle form was stored too"
11625        );
11626    }
11627
11628    /// A resolver answering `did` that counts how often it was asked.
11629    async fn serve_counting_resolver(
11630        did: &str,
11631    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
11632        let (base, hits) = crate::net::tests::serve_body_counted(
11633            serde_json::json!({ "did": did }).to_string().into_bytes(),
11634        )
11635        .await;
11636        let port: u16 = base
11637            .trim_end_matches('/')
11638            .rsplit(':')
11639            .next()
11640            .unwrap()
11641            .parse()
11642            .unwrap();
11643        let host = format!("counting-resolver-{port}.test");
11644        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
11645        (format!("http://{host}:{port}"), hits)
11646    }
11647
11648    /// Review of #230: the per-DID cap is documented as checked "BEFORE any
11649    /// fetch/resolve so an over-cap account can't even trigger an outbound
11650    /// request" — a handle paste resolved the handle first.
11651    #[tokio::test]
11652    async fn an_over_cap_handle_paste_makes_no_outbound_request() {
11653        let did = "did:plc:renamer5";
11654        let (sidecar, _log) = spawn_logging_sidecar().await;
11655        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
11656        let state = with_config(
11657            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11658            |c| {
11659                c.resolver_base = resolver;
11660                c.max_subs_per_did = 1;
11661            },
11662        );
11663        let feed_id = store::upsert_feed(
11664            &state.db,
11665            &store::NewFeed {
11666                url: "https://already.example/feed.xml".into(),
11667                ..Default::default()
11668            },
11669        )
11670        .await
11671        .unwrap();
11672        store::replace_sub_refs(&state.db, did, &[feed_id])
11673            .await
11674            .unwrap();
11675        let loc = subscribe(
11676            &state,
11677            did,
11678            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11679        )
11680        .await;
11681        assert!(
11682            loc.contains("Subscription%20limit"),
11683            "expected the cap flash: {loc}"
11684        );
11685        assert_eq!(
11686            hits.load(std::sync::atomic::Ordering::SeqCst),
11687            0,
11688            "an over-cap paste resolved a handle"
11689        );
11690    }
11691
11692    /// Review of #230: an authority that is neither a valid DID nor a valid
11693    /// handle — `did:plc:TOOSHORT`, an uppercase DID — went to the resolver as
11694    /// a "handle". It is unsupported, and asks nobody anything.
11695    #[tokio::test]
11696    async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
11697        let did = "did:plc:renamer5";
11698        let (sidecar, _log) = spawn_logging_sidecar().await;
11699        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
11700        let state = with_config(
11701            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11702            |c| {
11703                c.resolver_base = resolver;
11704            },
11705        );
11706        for authority in [
11707            "did%3Aplc%3ATOOSHORT",
11708            "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
11709            "bad%0Ahandle.example",
11710        ] {
11711            let loc = subscribe(
11712                &state,
11713                did,
11714                &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
11715            )
11716            .await;
11717            assert!(
11718                loc.contains("kind%20of%20feed"),
11719                "{authority}: expected the unsupported flash: {loc}"
11720            );
11721        }
11722        assert_eq!(
11723            hits.load(std::sync::atomic::Ordering::SeqCst),
11724            0,
11725            "a malformed authority reached the resolver"
11726        );
11727        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11728    }
11729
11730    /// A handle that does not resolve is refused, and nothing is stored.
11731    #[tokio::test]
11732    async fn an_unresolvable_handle_paste_is_refused() {
11733        let did = "did:plc:renamer5";
11734        let (sidecar, _log) = spawn_logging_sidecar().await;
11735        let state = with_config(
11736            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11737            |c| {
11738                c.resolver_base = "http://resolver.nowhere.invalid".into();
11739            },
11740        );
11741        let loc = subscribe(
11742            &state,
11743            did,
11744            "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11745        )
11746        .await;
11747        assert!(
11748            loc.contains("resolve%20the%20handle"),
11749            "expected the unresolvable-handle flash: {loc}"
11750        );
11751        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11752    }
11753
11754    /// An at:// URI that is not a publication is refused, flag on or off.
11755    #[tokio::test]
11756    async fn a_non_publication_at_uri_paste_is_refused() {
11757        let did = "did:plc:renamer5";
11758        let (sidecar, _log) = spawn_logging_sidecar().await;
11759        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11760        let loc = subscribe(
11761            &state,
11762            did,
11763            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
11764        )
11765        .await;
11766        assert!(
11767            loc.contains("kind%20of%20feed"),
11768            "expected the unsupported flash: {loc}"
11769        );
11770        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
11771    }
11772
11773    /// A mixed-case scheme is canonicalised at input, not refused and not
11774    /// stored as a second spelling of the same publication.
11775    #[tokio::test]
11776    async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
11777        let did = "did:plc:renamer5";
11778        let (sidecar, _log) = spawn_logging_sidecar().await;
11779        let state = with_config(
11780            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11781            |c| {
11782                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11783            },
11784        );
11785        let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11786        assert_eq!(loc, "/", "the paste was refused: {loc}");
11787        assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11788            .await
11789            .unwrap()
11790            .is_some());
11791    }
11792
11793    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
11794    /// path that is meant to work today, asserted with the flag actually on.
11795    #[tokio::test]
11796    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11797        let did = "did:plc:renamer5";
11798        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11799        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11800        let opml = format!(
11801            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11802             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11803             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11804             </body></opml>"
11805        );
11806        let (ct, body) = opml_multipart(opml.as_bytes());
11807        let cookie = session_cookie(&state, did, None);
11808        let resp = router(state.clone())
11809            .oneshot(
11810                Request::builder()
11811                    .method("POST")
11812                    .uri("/opml")
11813                    .header(header::COOKIE, cookie)
11814                    .header("content-type", ct)
11815                    .body(Body::from(body))
11816                    .unwrap(),
11817            )
11818            .await
11819            .unwrap();
11820        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11821        let loc = resp
11822            .headers()
11823            .get(header::LOCATION)
11824            .unwrap()
11825            .to_str()
11826            .unwrap();
11827        assert!(
11828            loc.contains("Imported%202%20feeds"),
11829            "unexpected flash: {loc}"
11830        );
11831        assert!(
11832            !loc.contains("skipped"),
11833            "the at:// entry was skipped with the flag on: {loc}"
11834        );
11835        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11836        assert!(
11837            stored.is_some(),
11838            "the at:// entry was not stored with the flag on"
11839        );
11840    }
11841
11842    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
11843    /// gate behind `url_changed` was right for the PDS write — the record is
11844    /// the reader's — but the cache write was gated only on `storable`, which
11845    /// any http(s) URL is. So a retitle of a record another client wrote with
11846    /// a tokened feed URL inserted that URL into the shared `feeds` table,
11847    /// where the poller would fail it every cycle and print it on the admin
11848    /// page. main refused the whole rename; this keeps the record editable and
11849    /// the cache clean, as `resolve_subscriptions` already does for the same
11850    /// record.
11851    #[tokio::test]
11852    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11853        let did = "did:plc:renamer5";
11854        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11855        let tokened_enc =
11856            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11857        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11858        let state = test_state_with_sidecar(&[did], &sidecar).await;
11859        let loc = retitle_unchanged(&state, did, tokened_enc).await;
11860        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11861        assert_eq!(
11862            puts.lock().unwrap().len(),
11863            1,
11864            "the retitle did not reach the PDS"
11865        );
11866        assert!(
11867            store::get_feed_by_url(&state.db, tokened)
11868                .await
11869                .unwrap()
11870                .is_none(),
11871            "a secret-bearing URL was written to the shared cache by a retitle"
11872        );
11873    }
11874
11875    /// **On a repoint, storability is decided before privacy and capacity** —
11876    /// the same ordering the add path got. A malformed at:// target drew the
11877    /// private/paid flash, and at capacity a well-formed one drew "try again
11878    /// later" for a URL that can never be accepted with the flag off.
11879    #[tokio::test]
11880    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11881        let did = "did:plc:renamer4";
11882        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11883        let state = test_state_with_sidecar(&[did], &sidecar).await;
11884        let cookie = session_cookie(&state, did, None);
11885        let resp = router(state.clone())
11886            .oneshot(
11887                Request::builder()
11888                    .method("POST")
11889                    .uri("/subscriptions/rk-keep/rename")
11890                    .header(header::COOKIE, cookie)
11891                    .header("content-type", "application/x-www-form-urlencoded")
11892                    .body(Body::from(
11893                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11894                    ))
11895                    .unwrap(),
11896            )
11897            .await
11898            .unwrap();
11899        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11900        let loc = resp
11901            .headers()
11902            .get(header::LOCATION)
11903            .unwrap()
11904            .to_str()
11905            .unwrap();
11906        assert!(
11907            loc.contains("kind%20of%20feed"),
11908            "expected the unsupported flash: {loc}"
11909        );
11910        assert!(
11911            !loc.contains("Private"),
11912            "a typo was reported as a paid feed: {loc}"
11913        );
11914        assert!(puts.lock().unwrap().is_empty());
11915    }
11916
11917    #[tokio::test]
11918    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11919        let did = "did:plc:renamer4";
11920        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11921        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11922        store::upsert_feed(
11923            &state.db,
11924            &store::NewFeed {
11925                url: "https://filler.example/feed.xml".to_string(),
11926                ..Default::default()
11927            },
11928        )
11929        .await
11930        .unwrap();
11931        let cookie = session_cookie(&state, did, None);
11932        let resp = router(state.clone())
11933            .oneshot(
11934                Request::builder()
11935                    .method("POST")
11936                    .uri("/subscriptions/rk-keep/rename")
11937                    .header(header::COOKIE, cookie)
11938                    .header("content-type", "application/x-www-form-urlencoded")
11939                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11940                    .unwrap(),
11941            )
11942            .await
11943            .unwrap();
11944        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11945        let loc = resp
11946            .headers()
11947            .get(header::LOCATION)
11948            .unwrap()
11949            .to_str()
11950            .unwrap();
11951        assert!(
11952            loc.contains("kind%20of%20feed"),
11953            "expected the unsupported flash: {loc}"
11954        );
11955        assert!(
11956            !loc.contains("capacity"),
11957            "an unacceptable URL was reported as a capacity problem: {loc}"
11958        );
11959        assert!(puts.lock().unwrap().is_empty());
11960    }
11961
11962    /// **`url_changed` compares like for like.** The form value is trimmed;
11963    /// the record's URL was compared raw, so a record another client wrote
11964    /// with a trailing space read as a repoint on every retitle and re-armed
11965    /// every gate — including the one that made an at:// record un-editable.
11966    #[tokio::test]
11967    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11968        let did = "did:plc:renamer5";
11969        let padded = format!("{AT_URI_SUB} ");
11970        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11971        let state = test_state_with_sidecar(&[did], &sidecar).await;
11972        // The manage row posts the record's URL verbatim, padding included.
11973        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11974        assert_eq!(
11975            loc, "/",
11976            "the retitle was treated as a repoint and refused: {loc}"
11977        );
11978        let bodies = puts.lock().unwrap().clone();
11979        assert_eq!(bodies.len(), 1);
11980        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11981        assert_eq!(
11982            sent["record"]["url"], AT_URI_SUB,
11983            "the padding was not normalised away"
11984        );
11985    }
11986
11987    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
11988    /// only, so the trailing upsert must not create a row for an unchanged URL
11989    /// that has none — with the flag on and the cache full, each retitle of a
11990    /// never-cached at:// record was a row past the cap. An existing row still
11991    /// gets its title kept in step.
11992    #[tokio::test]
11993    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11994        let did = "did:plc:renamer5";
11995        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11996        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11997        store::upsert_feed(
11998            &state.db,
11999            &store::NewFeed {
12000                url: "https://filler.example/feed.xml".to_string(),
12001                ..Default::default()
12002            },
12003        )
12004        .await
12005        .unwrap();
12006        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
12007        assert_eq!(loc, "/", "the retitle was refused: {loc}");
12008        assert_eq!(puts.lock().unwrap().len(), 1);
12009        assert_eq!(
12010            store::count_feeds(&state.db).await.unwrap(),
12011            1,
12012            "a retitle inserted a cache row past the ceiling"
12013        );
12014    }
12015
12016    /// **The add path's at:// pre-check is about the MESSAGE, so it is
12017    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
12018    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
12019    /// tripped the secret heuristic on the rkey — the private/paid flash the
12020    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
12021    /// touch it.
12022    #[tokio::test]
12023    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
12024        let did = "did:plc:typoist";
12025        let state = test_state_with_caps(did, 0, 0).await;
12026        let cookie = session_cookie(&state, did, None);
12027        let resp = router(state.clone())
12028            .oneshot(
12029                Request::builder()
12030                    .method("POST")
12031                    .uri("/subscriptions")
12032                    .header(header::COOKIE, cookie)
12033                    .header("content-type", "application/x-www-form-urlencoded")
12034                    .body(Body::from(
12035                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
12036                    ))
12037                    .unwrap(),
12038            )
12039            .await
12040            .unwrap();
12041        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12042        let loc = resp
12043            .headers()
12044            .get(header::LOCATION)
12045            .unwrap()
12046            .to_str()
12047            .unwrap();
12048        assert!(
12049            loc.contains("kind%20of%20feed"),
12050            "expected the unsupported flash: {loc}"
12051        );
12052        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
12053    }
12054
12055    // -- #149: a rename that races another client's write -------------------
12056
12057    /// One subscription record behind a fake repo that ENFORCES `swapRecord`
12058    /// the way the reference PDS does: a put naming a CID the record is no
12059    /// longer at is refused `400 InvalidSwap`; a put with no swap always lands.
12060    #[derive(Default)]
12061    struct SwapRepo {
12062        /// The record's current value.
12063        value: serde_json::Value,
12064        /// Bumped on every write, so each version has its own CID.
12065        version: u32,
12066        /// Every put request body received, in order, landed or not.
12067        puts: Vec<serde_json::Value>,
12068        /// Another client's write, landed the moment our FIRST put arrives —
12069        /// i.e. between our read and our write.
12070        concurrent: Option<serde_json::Value>,
12071        /// Refuse every put that carries a swap, whatever CID it names.
12072        refuse_every_swap: bool,
12073        /// Refuse every put with this (status, error) — a non-swap failure.
12074        fail_puts: Option<(u16, &'static str)>,
12075        /// The collection the record lives in; the subscription one when unset.
12076        collection: Option<&'static str>,
12077        /// The record is not in the repo: the listing comes back empty.
12078        missing: bool,
12079        /// Every listing fails `502`.
12080        fail_list: bool,
12081    }
12082
12083    impl SwapRepo {
12084        fn cid(&self) -> String {
12085            format!("bafyreiversion{}", self.version)
12086        }
12087
12088        fn nsid(&self) -> &'static str {
12089            self.collection
12090                .unwrap_or(crate::lexicon::nsid::SUBSCRIPTION)
12091        }
12092
12093        fn page(&self) -> serde_json::Value {
12094            if self.missing {
12095                return serde_json::json!({ "records": [] });
12096            }
12097            serde_json::json!({ "records": [{
12098                "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
12099                "cid": self.cid(),
12100                "value": self.value,
12101            }] })
12102        }
12103
12104        /// A put: `Ok(strong ref)` or `Err((status, error name))`.
12105        fn put(&mut self, body: &serde_json::Value) -> Result<serde_json::Value, (u16, String)> {
12106            self.puts.push(body.clone());
12107            if let Some(theirs) = self.concurrent.take() {
12108                self.value = theirs;
12109                self.version += 1;
12110            }
12111            if let Some((status, error)) = self.fail_puts {
12112                return Err((status, error.to_string()));
12113            }
12114            if let Some(swap) = body.get("swapRecord").and_then(|v| v.as_str()) {
12115                if self.refuse_every_swap || swap != self.cid() {
12116                    return Err((400, "InvalidSwap".to_string()));
12117                }
12118            }
12119            self.value = body["record"].clone();
12120            self.version += 1;
12121            Ok(serde_json::json!({
12122                "uri": format!("at://{RACE_DID}/{}/rk-keep", self.nsid()),
12123                "cid": self.cid(),
12124            }))
12125        }
12126    }
12127
12128    const RACE_DID: &str = "did:plc:racer149";
12129
12130    /// Serve `repo` as both a sidecar (`/internal/repo`) and a PDS (`/xrpc/*`),
12131    /// so one fixture drives either backend. Returns the sidecar base URL and
12132    /// the PDS audience a Rust-backend session should carry.
12133    async fn serve_swap_repo(repo: std::sync::Arc<std::sync::Mutex<SwapRepo>>) -> (String, String) {
12134        use axum::response::IntoResponse as _;
12135        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12136        let addr = listener.local_addr().unwrap();
12137        let host = format!("pds-{}.race.test", addr.port());
12138        crate::net::test_host_override(&host, addr);
12139        let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
12140            let repo = std::sync::Arc::clone(&repo);
12141            async move {
12142                let (parts, body) = req.into_parts();
12143                let raw = axum::body::to_bytes(body, usize::MAX).await.unwrap();
12144                let body: serde_json::Value =
12145                    serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null);
12146                let reply = |status: u16, body: serde_json::Value| {
12147                    (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
12148                };
12149                let mut repo = repo.lock().unwrap();
12150                match (parts.uri.path(), body["action"].as_str()) {
12151                    ("/internal/repo", Some("list")) if repo.fail_list => reply(
12152                        502,
12153                        serde_json::json!({
12154                            "ok": false, "error": "UpstreamFailure", "message": "down", "status": 502,
12155                        }),
12156                    ),
12157                    ("/internal/repo", Some("list")) => {
12158                        reply(200, serde_json::json!({ "ok": true, "data": repo.page() }))
12159                    }
12160                    ("/internal/repo", Some("put")) => match repo.put(&body) {
12161                        Ok(data) => reply(200, serde_json::json!({ "ok": true, "data": data })),
12162                        Err((status, error)) => reply(
12163                            status,
12164                            serde_json::json!({
12165                                "ok": false, "error": error, "message": "refused", "status": status,
12166                            }),
12167                        ),
12168                    },
12169                    ("/xrpc/com.atproto.repo.listRecords", _) if repo.fail_list => reply(
12170                        502,
12171                        serde_json::json!({ "error": "UpstreamFailure", "message": "down" }),
12172                    ),
12173                    ("/xrpc/com.atproto.repo.listRecords", _) => reply(200, repo.page()),
12174                    ("/xrpc/com.atproto.repo.putRecord", _) => match repo.put(&body) {
12175                        Ok(data) => reply(200, data),
12176                        Err((status, error)) => reply(
12177                            status,
12178                            serde_json::json!({ "error": error, "message": "refused" }),
12179                        ),
12180                    },
12181                    other => panic!("unexpected request {other:?}"),
12182                }
12183            }
12184        });
12185        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
12186        (
12187            format!("http://{addr}"),
12188            format!("http://{host}:{}", addr.port()),
12189        )
12190    }
12191
12192    /// An `AppState` on `backend`, pointed at `repo` — the sidecar through its
12193    /// internal URL, the Rust client through a live OAuth session whose `aud`
12194    /// is the fake.
12195    async fn race_state(
12196        backend: crate::metrics::Backend,
12197        repo: &std::sync::Arc<std::sync::Mutex<SwapRepo>>,
12198    ) -> AppState {
12199        let (sidecar, aud) = serve_swap_repo(std::sync::Arc::clone(repo)).await;
12200        let db = store::init_url("sqlite::memory:").await.unwrap();
12201        store::ensure_seed(&db, &[RACE_DID.to_string()])
12202            .await
12203            .unwrap();
12204        let mut config = Config {
12205            allowed_dids: vec![RACE_DID.to_string()],
12206            cookie_secret: "test-cookie-secret-000".to_string(),
12207            beta_cap: 3,
12208            repo_backend: backend,
12209            oauth: crate::config::OauthConfig {
12210                // Per test, never the relative default — see `repo::tests`.
12211                key_path: std::env::temp_dir().join(format!(
12212                    "fr-race-oauth-key-{}-{:p}.json",
12213                    std::process::id(),
12214                    &db as *const _
12215                )),
12216                encryption_key: Some("a".repeat(43)),
12217                ..crate::config::OauthConfig::default()
12218            },
12219            ..Config::default()
12220        };
12221        config.sidecar.public_url = sidecar.clone();
12222        config.sidecar.internal_url = sidecar;
12223        let state = AppState::new(config, db).unwrap();
12224        if backend == crate::metrics::Backend::Rust {
12225            let runtime = state.oauth.as_deref().expect("oauth runtime");
12226            crate::oauth::store::put_session(
12227                &state.db,
12228                &runtime.codec,
12229                &crate::oauth::store::OAuthSession {
12230                    sub: RACE_DID.into(),
12231                    issuer: "https://auth.invalid".into(),
12232                    aud,
12233                    dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
12234                        .to_jwk_json()
12235                        .unwrap(),
12236                    access_token: "at".into(),
12237                    refresh_token: "rt".into(),
12238                    token_type: "DPoP".into(),
12239                    granted_scope: "atproto".into(),
12240                    expires_at: Some(store::now_unix() + 3600),
12241                },
12242            )
12243            .await
12244            .unwrap();
12245        }
12246        state
12247    }
12248
12249    /// The record before anyone touches it — the seeded one, as a value.
12250    fn race_seed() -> serde_json::Value {
12251        seeded_subscription()["value"].clone()
12252    }
12253
12254    /// Post the manage row's rename (url unchanged, a new title and folder) and
12255    /// return the redirect location.
12256    async fn post_race_rename(state: &AppState) -> String {
12257        post_race_rename_body(
12258            state,
12259            "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
12260        )
12261        .await
12262    }
12263
12264    /// Post `body` as the rename of `rk-keep`; returns the redirect location.
12265    async fn post_race_rename_body(state: &AppState, body: &str) -> String {
12266        let cookie = session_cookie(state, RACE_DID, None);
12267        let resp = router(state.clone())
12268            .oneshot(
12269                Request::builder()
12270                    .method("POST")
12271                    .uri("/subscriptions/rk-keep/rename")
12272                    .header(header::COOKIE, cookie)
12273                    .header("content-type", "application/x-www-form-urlencoded")
12274                    .body(Body::from(body.to_string()))
12275                    .unwrap(),
12276            )
12277            .await
12278            .unwrap();
12279        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12280        resp.headers()
12281            .get(header::LOCATION)
12282            .unwrap()
12283            .to_str()
12284            .unwrap()
12285            .to_string()
12286    }
12287
12288    const RACE_BACKENDS: [crate::metrics::Backend; 2] = [
12289        crate::metrics::Backend::Sidecar,
12290        crate::metrics::Backend::Rust,
12291    ];
12292
12293    /// **The key test of #149: a rename that loses a race keeps the other
12294    /// client's change AND lands its own.**
12295    ///
12296    /// The fake lands another client's edit (a new `siteUrl` and `fetchHint`)
12297    /// between the handler's read and its write. The write names the CID it
12298    /// read, so the PDS refuses it; the handler re-reads, re-applies the form's
12299    /// fields to the FRESH record, and writes again under the new CID.
12300    ///
12301    /// With `swapRecord` dropped anywhere on the way out, the first put lands
12302    /// unconditionally and the other client's edit is gone — which is what
12303    /// the final-record assertions catch. Run on both backends: production is
12304    /// on `rust`, and a backend whose put ignores the swap is the exact gap.
12305    #[tokio::test]
12306    async fn a_rename_that_loses_a_race_keeps_the_concurrent_edit_and_lands() {
12307        for backend in RACE_BACKENDS {
12308            let mut theirs = race_seed();
12309            theirs["siteUrl"] = serde_json::json!("https://elsewhere.example/blog");
12310            theirs["fetchHint"] = serde_json::json!("daily");
12311            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12312                value: race_seed(),
12313                concurrent: Some(theirs),
12314                ..SwapRepo::default()
12315            }));
12316            let state = race_state(backend, &repo).await;
12317
12318            let loc = post_race_rename(&state).await;
12319
12320            let repo = repo.lock().unwrap();
12321            assert_eq!(
12322                loc, "/",
12323                "{backend:?}: a rename that converged was not reported as done"
12324            );
12325            assert_eq!(
12326                repo.puts.len(),
12327                2,
12328                "{backend:?}: expected the refused put and one retry: {:?}",
12329                repo.puts
12330            );
12331            assert_eq!(
12332                repo.puts[0]["swapRecord"], "bafyreiversion0",
12333                "{backend:?}: the first put did not name the CID it read: {}",
12334                repo.puts[0]
12335            );
12336            assert_eq!(
12337                repo.puts[1]["swapRecord"], "bafyreiversion1",
12338                "{backend:?}: the retry did not name the RE-READ CID: {}",
12339                repo.puts[1]
12340            );
12341            let landed = &repo.value;
12342            // The reader's change landed...
12343            assert_eq!(landed["title"], "New title", "{backend:?}: {landed}");
12344            assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12345            // ...on top of the other client's, not over it.
12346            assert_eq!(
12347                landed["siteUrl"], "https://elsewhere.example/blog",
12348                "{backend:?}: the concurrent edit was lost: {landed}"
12349            );
12350            assert_eq!(
12351                landed["fetchHint"], "daily",
12352                "{backend:?}: the concurrent edit was lost: {landed}"
12353            );
12354            // And #147's preservation still holds on the retried record.
12355            assert_eq!(
12356                landed["createdAt"], "2024-03-01T00:00:00.000Z",
12357                "{backend:?}: {landed}"
12358            );
12359        }
12360    }
12361
12362    /// **A rename the PDS refuses on every attempt is reported as a conflict,
12363    /// never as done — and is not retried forever.** One retry, so at most two
12364    /// puts; then the reader is told the subscription changed elsewhere.
12365    #[tokio::test]
12366    async fn a_rename_refused_on_every_swap_reports_the_conflict() {
12367        for backend in RACE_BACKENDS {
12368            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12369                value: race_seed(),
12370                refuse_every_swap: true,
12371                ..SwapRepo::default()
12372            }));
12373            let state = race_state(backend, &repo).await;
12374
12375            let loc = post_race_rename(&state).await;
12376
12377            let repo = repo.lock().unwrap();
12378            assert_ne!(loc, "/", "{backend:?}: a refused rename reported success");
12379            assert!(
12380                loc.contains("changed%20elsewhere"),
12381                "{backend:?}: expected the conflict flash, got {loc}"
12382            );
12383            assert!(
12384                (1..=2).contains(&repo.puts.len()),
12385                "{backend:?}: expected at most two put attempts, got {}",
12386                repo.puts.len()
12387            );
12388            assert_eq!(
12389                repo.value,
12390                race_seed(),
12391                "{backend:?}: the record changed though every put was refused"
12392            );
12393        }
12394    }
12395
12396    /// **A put refused for any OTHER reason is not retried**, and keeps the
12397    /// message it had: a re-read cannot fix a rejected record or an outage,
12398    /// and calling it a conflict would send the reader looking for an edit
12399    /// nobody made.
12400    #[tokio::test]
12401    async fn a_rename_refused_for_another_reason_is_not_retried() {
12402        for backend in RACE_BACKENDS {
12403            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12404                value: race_seed(),
12405                fail_puts: Some((400, "InvalidRequest")),
12406                ..SwapRepo::default()
12407            }));
12408            let state = race_state(backend, &repo).await;
12409
12410            let loc = post_race_rename(&state).await;
12411
12412            let repo = repo.lock().unwrap();
12413            assert_eq!(
12414                repo.puts.len(),
12415                1,
12416                "{backend:?}: a non-swap refusal was retried"
12417            );
12418            assert!(
12419                loc.contains("Could%20not%20save"),
12420                "{backend:?}: expected the save-failed flash, got {loc}"
12421            );
12422            assert!(
12423                !loc.contains("changed%20elsewhere"),
12424                "{backend:?}: a non-swap refusal was reported as a conflict: {loc}"
12425            );
12426        }
12427    }
12428
12429    /// What the manage row posts for a reader who changed only the title: the
12430    /// url and folder as they were, plus the `seen_*` values the inputs were
12431    /// pre-filled with.
12432    const SEEN_SEED: &str = "seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12433                             &seen_title=Old+title&seen_folder=";
12434
12435    /// **A retry merges; it does not replay the whole form.** Another client
12436    /// repoints the record (A -> B, with B's own siteUrl and fetchHint) while
12437    /// the reader only retitles it. The form still carries URL A — it is a
12438    /// hidden input — so replaying it on the fresh record "repointed" back to
12439    /// A, cleared the other client's siteUrl and fetchHint, and reported
12440    /// success. The reader changed the title and nothing else, so the title is
12441    /// all that may move. Run with and without the `seen_*` inputs: without
12442    /// them the base is the handler's first read.
12443    #[tokio::test]
12444    async fn a_retry_keeps_a_concurrent_repoint_the_reader_did_not_make() {
12445        for backend in RACE_BACKENDS {
12446            for with_seen in [true, false] {
12447                let mut theirs = race_seed();
12448                theirs["url"] = serde_json::json!("https://moved.example/feed.xml");
12449                theirs["siteUrl"] = serde_json::json!("https://moved.example/");
12450                theirs["fetchHint"] = serde_json::json!("daily");
12451                let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12452                    value: race_seed(),
12453                    concurrent: Some(theirs),
12454                    ..SwapRepo::default()
12455                }));
12456                let state = race_state(backend, &repo).await;
12457                let mut body =
12458                    "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title".to_string();
12459                if with_seen {
12460                    body.push_str(
12461                        "&seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml&seen_title=Old+title",
12462                    );
12463                }
12464
12465                let loc = post_race_rename_body(&state, &body).await;
12466
12467                let repo = repo.lock().unwrap();
12468                let ctx = format!("{backend:?} seen={with_seen}");
12469                assert_eq!(loc, "/", "{ctx}: the rename did not land: {loc}");
12470                assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12471                let landed = &repo.value;
12472                assert_eq!(landed["title"], "New title", "{ctx}: {landed}");
12473                assert_eq!(
12474                    landed["url"], "https://moved.example/feed.xml",
12475                    "{ctx}: the retry repointed the record back to the stale URL: {landed}"
12476                );
12477                assert_eq!(
12478                    landed["siteUrl"], "https://moved.example/",
12479                    "{ctx}: {landed}"
12480                );
12481                assert_eq!(landed["fetchHint"], "daily", "{ctx}: {landed}");
12482            }
12483        }
12484    }
12485
12486    /// The other client retitles; the reader only moves the folder. Their
12487    /// title is kept and the folder applied — the reader's stale copy of the
12488    /// title (posted because the input is always submitted) is not a change.
12489    #[tokio::test]
12490    async fn a_retry_keeps_a_concurrent_retitle_when_the_reader_only_moved_it() {
12491        for backend in RACE_BACKENDS {
12492            let mut theirs = race_seed();
12493            theirs["title"] = serde_json::json!("Their title");
12494            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12495                value: race_seed(),
12496                concurrent: Some(theirs),
12497                ..SwapRepo::default()
12498            }));
12499            let state = race_state(backend, &repo).await;
12500
12501            let loc = post_race_rename_body(
12502                &state,
12503                &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Old+title&folder=Tech&{SEEN_SEED}"),
12504            )
12505            .await;
12506
12507            let repo = repo.lock().unwrap();
12508            assert_eq!(loc, "/", "{backend:?}: {loc}");
12509            let landed = &repo.value;
12510            assert_eq!(
12511                landed["title"], "Their title",
12512                "{backend:?}: the reader's untouched title overwrote the other client's: {landed}"
12513            );
12514            assert_eq!(landed["folder"], "Tech", "{backend:?}: {landed}");
12515        }
12516    }
12517
12518    /// **Both changed the same field: a conflict, and nothing is written.**
12519    /// Neither edit can be chosen for the reader, so they are told, and the
12520    /// other client's title stays.
12521    #[tokio::test]
12522    async fn both_retitling_is_a_conflict_that_writes_nothing() {
12523        for backend in RACE_BACKENDS {
12524            let mut theirs = race_seed();
12525            theirs["title"] = serde_json::json!("Their title");
12526            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12527                value: race_seed(),
12528                concurrent: Some(theirs.clone()),
12529                ..SwapRepo::default()
12530            }));
12531            let state = race_state(backend, &repo).await;
12532
12533            let loc = post_race_rename_body(
12534                &state,
12535                &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12536            )
12537            .await;
12538
12539            let repo = repo.lock().unwrap();
12540            assert!(
12541                loc.contains("changed%20elsewhere"),
12542                "{backend:?}: expected the conflict flash, got {loc}"
12543            );
12544            assert_eq!(
12545                repo.puts.len(),
12546                1,
12547                "{backend:?}: a conflicting retry was written: {:?}",
12548                repo.puts
12549            );
12550            assert_eq!(
12551                repo.value, theirs,
12552                "{backend:?}: their title was overwritten"
12553            );
12554        }
12555    }
12556
12557    /// **The page-load window: an edit that landed BEFORE the handler's first
12558    /// read.** The manage page showed URL A; another client repointed to B
12559    /// before the reader pressed Save, so the first read already sees B and
12560    /// no swap fails. The `seen_url` the page was rendered with is what says
12561    /// the reader never touched the URL — without it, the stale hidden `url`
12562    /// reads as a repoint back to A.
12563    #[tokio::test]
12564    async fn a_repoint_before_the_first_read_is_kept_when_the_reader_only_retitled() {
12565        for backend in RACE_BACKENDS {
12566            let mut moved = race_seed();
12567            moved["url"] = serde_json::json!("https://moved.example/feed.xml");
12568            moved["siteUrl"] = serde_json::json!("https://moved.example/");
12569            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12570                value: moved,
12571                ..SwapRepo::default()
12572            }));
12573            let state = race_state(backend, &repo).await;
12574
12575            let loc = post_race_rename_body(
12576                &state,
12577                &format!("url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&{SEEN_SEED}"),
12578            )
12579            .await;
12580
12581            let repo = repo.lock().unwrap();
12582            assert_eq!(loc, "/", "{backend:?}: {loc}");
12583            assert_eq!(repo.puts.len(), 1, "{backend:?}");
12584            let landed = &repo.value;
12585            assert_eq!(
12586                landed["url"], "https://moved.example/feed.xml",
12587                "{backend:?}: the stale hidden url repointed the record: {landed}"
12588            );
12589            assert_eq!(landed["siteUrl"], "https://moved.example/", "{backend:?}");
12590            assert_eq!(landed["title"], "New title", "{backend:?}");
12591        }
12592    }
12593
12594    /// **The cache follows the PDS, never leads it.** A rename that did not
12595    /// land — every swap refused, or the put failed for another reason — must
12596    /// leave the local `feeds` cache as it was: no row for a repoint's new URL
12597    /// (the poller would fetch a feed nobody subscribes to), and no new title
12598    /// on the existing row. One that landed updates it as before.
12599    #[tokio::test]
12600    async fn a_rename_that_did_not_land_leaves_the_cache_alone() {
12601        const NEW_URL: &str = "https://other.example/feed.xml";
12602        const OLD_URL: &str = "https://example.com/feed.xml";
12603        // (refuse every swap, fail every put, expect the write to land)
12604        for (refuse_every_swap, fail_puts, lands) in [
12605            (true, None, false),
12606            (false, Some((400, "InvalidRequest")), false),
12607            (false, Some((502, "UpstreamFailure")), false),
12608            (false, None, true),
12609        ] {
12610            for backend in RACE_BACKENDS {
12611                let ctx = format!("{backend:?} refuse={refuse_every_swap} fail={fail_puts:?}");
12612                for repoint in [false, true] {
12613                    let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12614                        value: race_seed(),
12615                        refuse_every_swap,
12616                        fail_puts,
12617                        ..SwapRepo::default()
12618                    }));
12619                    let state = race_state(backend, &repo).await;
12620                    store::upsert_feed(
12621                        &state.db,
12622                        &store::NewFeed {
12623                            url: OLD_URL.to_string(),
12624                            title: Some("Cached title".to_string()),
12625                            ..Default::default()
12626                        },
12627                    )
12628                    .await
12629                    .unwrap();
12630                    let url = if repoint { NEW_URL } else { OLD_URL };
12631                    let body = format!("url={}&title=New+title&{SEEN_SEED}", qenc(url));
12632
12633                    let loc = post_race_rename_body(&state, &body).await;
12634
12635                    let new_row = store::get_feed_by_url(&state.db, NEW_URL).await.unwrap();
12636                    let old_row = store::get_feed_by_url(&state.db, OLD_URL)
12637                        .await
12638                        .unwrap()
12639                        .expect("the old row");
12640                    let ctx = format!("{ctx} repoint={repoint} -> {loc}");
12641                    if lands {
12642                        assert_eq!(loc, "/", "{ctx}");
12643                        if repoint {
12644                            assert!(
12645                                new_row.is_some(),
12646                                "{ctx}: a landed repoint got no cache row"
12647                            );
12648                        } else {
12649                            assert_eq!(old_row.title.as_deref(), Some("New title"), "{ctx}");
12650                        }
12651                    } else {
12652                        assert_ne!(loc, "/", "{ctx}");
12653                        assert!(
12654                            new_row.is_none(),
12655                            "{ctx}: a repoint that did not land left a feeds row for its URL"
12656                        );
12657                        assert_eq!(
12658                            old_row.title.as_deref(),
12659                            Some("Cached title"),
12660                            "{ctx}: a rename that did not land changed the cached title"
12661                        );
12662                    }
12663                }
12664            }
12665        }
12666    }
12667
12668    /// **A page with no folder dropdown does not un-folder.** The select (and
12669    /// its `seen_folder`) render only when the reader has folders the page
12670    /// could list — none, or a failed folder listing, and neither is posted.
12671    /// That is "the reader never saw a folder", not "the reader chose none":
12672    /// a retitle from such a page used to un-folder the subscription, and on
12673    /// a retry could report a conflict on a field the reader never saw.
12674    #[tokio::test]
12675    async fn a_rename_from_a_page_without_a_folder_select_keeps_the_folder() {
12676        for backend in RACE_BACKENDS {
12677            for raced in [false, true] {
12678                let mut seed = race_seed();
12679                seed["folder"] = serde_json::json!("at://did:plc:racer149/folder/kept");
12680                let concurrent = raced.then(|| {
12681                    let mut theirs = seed.clone();
12682                    theirs["folder"] = serde_json::json!("at://did:plc:racer149/folder/theirs");
12683                    theirs
12684                });
12685                let want_folder = concurrent
12686                    .as_ref()
12687                    .map_or(seed["folder"].clone(), |t| t["folder"].clone());
12688                let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12689                    value: seed,
12690                    concurrent,
12691                    ..SwapRepo::default()
12692                }));
12693                let state = race_state(backend, &repo).await;
12694
12695                // Exactly what the manage row posts with no folder select.
12696                let loc = post_race_rename_body(
12697                    &state,
12698                    "url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12699                     &seen_url=https%3A%2F%2Fexample.com%2Ffeed.xml\
12700                     &seen_title=Old+title&title=New+title",
12701                )
12702                .await;
12703
12704                let repo = repo.lock().unwrap();
12705                let ctx = format!("{backend:?} raced={raced}");
12706                assert_eq!(loc, "/", "{ctx}: {loc}");
12707                assert_eq!(repo.value["title"], "New title", "{ctx}");
12708                assert_eq!(
12709                    repo.value["folder"], want_folder,
12710                    "{ctx}: a page that never showed a folder changed it: {}",
12711                    repo.value
12712                );
12713            }
12714        }
12715    }
12716
12717    /// **A double-clicked Save is not a conflict.** Both POSTs read the same
12718    /// CID; the first lands; the second's swap fails, and its re-read finds
12719    /// the record already saying exactly what the reader asked for. That is
12720    /// success, with nothing left to write — not "nothing was renamed".
12721    #[tokio::test]
12722    async fn a_double_submitted_rename_reports_success_and_writes_once() {
12723        for backend in RACE_BACKENDS {
12724            // The first submission's write, landing between the second's read
12725            // and its put.
12726            let mut first = race_seed();
12727            first["title"] = serde_json::json!("New title");
12728            first["folder"] = serde_json::json!("Tech");
12729            let repo = std::sync::Arc::new(std::sync::Mutex::new(SwapRepo {
12730                value: race_seed(),
12731                concurrent: Some(first.clone()),
12732                ..SwapRepo::default()
12733            }));
12734            let state = race_state(backend, &repo).await;
12735
12736            let loc = post_race_rename_body(
12737                &state,
12738                &format!(
12739                    "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech&{SEEN_SEED}"
12740                ),
12741            )
12742            .await;
12743
12744            let repo = repo.lock().unwrap();
12745            assert_eq!(
12746                loc, "/",
12747                "{backend:?}: a save that landed was reported as a conflict: {loc}"
12748            );
12749            assert_eq!(
12750                repo.puts.len(),
12751                1,
12752                "{backend:?}: only the refused put; the re-read has nothing left to write: {:?}",
12753                repo.puts
12754            );
12755            assert_eq!(repo.value, first, "{backend:?}");
12756        }
12757    }
12758
12759    // -- #268: renaming a folder edits the record, it does not replace it ----
12760
12761    /// A folder record as another `community.lexicon.rss` client might have
12762    /// left it: a sort position, an old `createdAt`, and a field this build
12763    /// does not know.
12764    fn folder_seed() -> serde_json::Value {
12765        serde_json::json!({
12766            "$type": crate::lexicon::nsid::FOLDER,
12767            "name": "Old name",
12768            "position": 3,
12769            "createdAt": "2024-01-01T00:00:00.000Z",
12770            "color": "#abc",
12771        })
12772    }
12773
12774    /// A [`SwapRepo`] holding `value` as the folder `rk-keep`.
12775    fn folder_repo(value: serde_json::Value) -> SwapRepo {
12776        SwapRepo {
12777            value,
12778            collection: Some(crate::lexicon::nsid::FOLDER),
12779            ..SwapRepo::default()
12780        }
12781    }
12782
12783    /// Post `body` as the rename of folder `rk-keep`; returns the redirect.
12784    async fn post_folder_rename(state: &AppState, body: &str) -> String {
12785        let cookie = session_cookie(state, RACE_DID, None);
12786        let resp = router(state.clone())
12787            .oneshot(
12788                Request::builder()
12789                    .method("POST")
12790                    .uri("/folders/rk-keep/rename")
12791                    .header(header::COOKIE, cookie)
12792                    .header("content-type", "application/x-www-form-urlencoded")
12793                    .body(Body::from(body.to_string()))
12794                    .unwrap(),
12795            )
12796            .await
12797            .unwrap();
12798        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
12799        resp.headers()
12800            .get(header::LOCATION)
12801            .unwrap()
12802            .to_str()
12803            .unwrap()
12804            .to_string()
12805    }
12806
12807    /// `value` with its name set to `name`.
12808    fn renamed(mut value: serde_json::Value, name: &str) -> serde_json::Value {
12809        value["name"] = serde_json::json!(name);
12810        value
12811    }
12812
12813    /// **The key test of #268: a rename changes the name and nothing else.**
12814    /// The handler used to put `Folder::new(name, now)` over the record, which
12815    /// reset `position`, replaced `createdAt` with the rename time and dropped
12816    /// every field another client had added. The exact body put is asserted,
12817    /// so any field lost or invented on the way fails it — on both backends.
12818    #[tokio::test]
12819    async fn renaming_a_folder_changes_only_its_name() {
12820        for backend in RACE_BACKENDS {
12821            let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(folder_seed())));
12822            let state = race_state(backend, &repo).await;
12823
12824            let loc = post_folder_rename(&state, "name=New+name").await;
12825
12826            let repo = repo.lock().unwrap();
12827            assert_eq!(
12828                loc, "/",
12829                "{backend:?}: a landed rename was not reported as done"
12830            );
12831            assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
12832            assert_eq!(
12833                repo.puts[0]["record"],
12834                renamed(folder_seed(), "New name"),
12835                "{backend:?}: the put did not keep the record whole: {}",
12836                repo.puts[0]
12837            );
12838            assert_eq!(
12839                repo.puts[0]["collection"],
12840                crate::lexicon::nsid::FOLDER,
12841                "{backend:?}"
12842            );
12843            assert_eq!(
12844                repo.puts[0]["swapRecord"], "bafyreiversion0",
12845                "{backend:?}: the put did not name the CID it read: {}",
12846                repo.puts[0]
12847            );
12848        }
12849    }
12850
12851    /// **A rename that loses a race keeps the other client's change and
12852    /// lands.** Another client moves the folder (and adds a field) between the
12853    /// read and the write; the swap is refused, the handler re-reads and
12854    /// renames the FRESH record.
12855    #[tokio::test]
12856    async fn a_folder_rename_that_loses_a_race_keeps_the_concurrent_edit() {
12857        for backend in RACE_BACKENDS {
12858            for with_seen in [true, false] {
12859                let mut theirs = folder_seed();
12860                theirs["position"] = serde_json::json!(7);
12861                theirs["icon"] = serde_json::json!("star");
12862                let mut fake = folder_repo(folder_seed());
12863                fake.concurrent = Some(theirs.clone());
12864                let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12865                let state = race_state(backend, &repo).await;
12866
12867                let body = if with_seen {
12868                    "name=New+name&seen_name=Old+name"
12869                } else {
12870                    "name=New+name"
12871                };
12872                let loc = post_folder_rename(&state, body).await;
12873
12874                let repo = repo.lock().unwrap();
12875                let ctx = format!("{backend:?} with_seen={with_seen}");
12876                assert_eq!(
12877                    loc, "/",
12878                    "{ctx}: a converged rename was not reported as done"
12879                );
12880                assert_eq!(repo.puts.len(), 2, "{ctx}: {:?}", repo.puts);
12881                assert_eq!(repo.puts[0]["swapRecord"], "bafyreiversion0", "{ctx}");
12882                assert_eq!(
12883                    repo.puts[1]["swapRecord"], "bafyreiversion1",
12884                    "{ctx}: the retry did not name the RE-READ CID"
12885                );
12886                assert_eq!(
12887                    repo.value,
12888                    renamed(theirs, "New name"),
12889                    "{ctx}: the concurrent edit was lost"
12890                );
12891            }
12892        }
12893    }
12894
12895    /// **Both renaming the folder, differently, is a conflict that writes
12896    /// nothing** — the reader is told, and the other client's name stands.
12897    /// With and without `seen_name`: without it, the first read is the
12898    /// ancestor.
12899    #[tokio::test]
12900    async fn both_renaming_a_folder_differently_is_a_conflict() {
12901        for backend in RACE_BACKENDS {
12902            for body in ["name=New+name&seen_name=Old+name", "name=New+name"] {
12903                let theirs = renamed(folder_seed(), "Their name");
12904                let mut fake = folder_repo(folder_seed());
12905                fake.concurrent = Some(theirs.clone());
12906                let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12907                let state = race_state(backend, &repo).await;
12908
12909                let loc = post_folder_rename(&state, body).await;
12910
12911                let repo = repo.lock().unwrap();
12912                assert!(
12913                    loc.contains("changed%20elsewhere"),
12914                    "{backend:?} {body}: expected the conflict flash, got {loc}"
12915                );
12916                assert_eq!(
12917                    repo.puts.len(),
12918                    1,
12919                    "{backend:?} {body}: only the refused put: {:?}",
12920                    repo.puts
12921                );
12922                assert_eq!(
12923                    repo.value, theirs,
12924                    "{backend:?} {body}: the other client's name was overwritten"
12925                );
12926            }
12927        }
12928    }
12929
12930    /// **Both renaming it to the SAME name is agreement** — a double-submitted
12931    /// Save whose first request landed. Success, and no second write.
12932    #[tokio::test]
12933    async fn both_renaming_a_folder_the_same_is_success_without_a_write() {
12934        for backend in RACE_BACKENDS {
12935            let theirs = renamed(folder_seed(), "New name");
12936            let mut fake = folder_repo(folder_seed());
12937            fake.concurrent = Some(theirs.clone());
12938            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12939            let state = race_state(backend, &repo).await;
12940
12941            let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
12942
12943            let repo = repo.lock().unwrap();
12944            assert_eq!(
12945                loc, "/",
12946                "{backend:?}: agreement reported as a failure: {loc}"
12947            );
12948            assert_eq!(repo.puts.len(), 1, "{backend:?}: {:?}", repo.puts);
12949            assert_eq!(repo.value, theirs, "{backend:?}");
12950        }
12951    }
12952
12953    /// **A folder that keeps moving is a conflict after one retry**, never
12954    /// reported as renamed and never retried forever.
12955    #[tokio::test]
12956    async fn a_folder_rename_refused_on_every_swap_reports_the_conflict() {
12957        for backend in RACE_BACKENDS {
12958            let mut fake = folder_repo(folder_seed());
12959            fake.refuse_every_swap = true;
12960            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12961            let state = race_state(backend, &repo).await;
12962
12963            let loc = post_folder_rename(&state, "name=New+name").await;
12964
12965            let repo = repo.lock().unwrap();
12966            assert!(
12967                loc.contains("changed%20elsewhere"),
12968                "{backend:?}: expected the conflict flash, got {loc}"
12969            );
12970            assert_eq!(repo.puts.len(), 2, "{backend:?}: one try and one retry");
12971            assert_eq!(repo.value, folder_seed(), "{backend:?}");
12972        }
12973    }
12974
12975    /// **A failed rename tells the reader**, instead of redirecting as if it
12976    /// had worked — and a failure a re-read cannot fix is not retried.
12977    #[tokio::test]
12978    async fn a_failed_folder_rename_shows_an_error() {
12979        for backend in RACE_BACKENDS {
12980            let mut fake = folder_repo(folder_seed());
12981            fake.fail_puts = Some((502, "UpstreamFailure"));
12982            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
12983            let state = race_state(backend, &repo).await;
12984
12985            let loc = post_folder_rename(&state, "name=New+name").await;
12986
12987            let repo = repo.lock().unwrap();
12988            assert_ne!(loc, "/", "{backend:?}: a failed rename reported success");
12989            assert!(
12990                loc.contains("Could%20not%20save"),
12991                "{backend:?}: expected the save-failed flash, got {loc}"
12992            );
12993            assert!(!loc.contains("changed%20elsewhere"), "{backend:?}: {loc}");
12994            assert_eq!(
12995                repo.puts.len(),
12996                1,
12997                "{backend:?}: a non-swap failure was retried"
12998            );
12999        }
13000    }
13001
13002    /// **A folder deleted elsewhere is not recreated.** A put at a missing
13003    /// rkey creates the record, which a rename must not do.
13004    #[tokio::test]
13005    async fn renaming_a_folder_that_no_longer_exists_writes_nothing() {
13006        for backend in RACE_BACKENDS {
13007            let mut fake = folder_repo(folder_seed());
13008            fake.missing = true;
13009            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13010            let state = race_state(backend, &repo).await;
13011
13012            let loc = post_folder_rename(&state, "name=New+name").await;
13013
13014            let repo = repo.lock().unwrap();
13015            assert!(
13016                loc.contains("no%20longer%20exists"),
13017                "{backend:?}: expected the missing-folder flash, got {loc}"
13018            );
13019            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13020        }
13021    }
13022
13023    /// **A folder that cannot be read is not renamed** — rebuilding it from
13024    /// the form instead is the record loss this read exists to prevent.
13025    #[tokio::test]
13026    async fn a_folder_rename_whose_read_fails_writes_nothing() {
13027        for backend in RACE_BACKENDS {
13028            let mut fake = folder_repo(folder_seed());
13029            fake.fail_list = true;
13030            let repo = std::sync::Arc::new(std::sync::Mutex::new(fake));
13031            let state = race_state(backend, &repo).await;
13032
13033            let loc = post_folder_rename(&state, "name=New+name").await;
13034
13035            let repo = repo.lock().unwrap();
13036            assert!(
13037                loc.contains("Could%20not%20reach"),
13038                "{backend:?}: expected the read-failed flash, got {loc}"
13039            );
13040            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13041        }
13042    }
13043
13044    /// **`seen_name` closes the page-load window.** Another client renamed the
13045    /// folder after the page was rendered but before the handler read it, so
13046    /// no swap fails; the form still says what the reader saw, and their
13047    /// different rename is a conflict rather than a silent overwrite.
13048    #[tokio::test]
13049    async fn a_rename_elsewhere_after_page_load_is_a_conflict_with_seen_name() {
13050        for backend in RACE_BACKENDS {
13051            let theirs = renamed(folder_seed(), "Their name");
13052            let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
13053            let state = race_state(backend, &repo).await;
13054
13055            let loc = post_folder_rename(&state, "name=New+name&seen_name=Old+name").await;
13056
13057            let repo = repo.lock().unwrap();
13058            assert!(
13059                loc.contains("changed%20elsewhere"),
13060                "{backend:?}: expected the conflict flash, got {loc}"
13061            );
13062            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13063            assert_eq!(repo.value, theirs, "{backend:?}");
13064        }
13065    }
13066
13067    /// A form whose name is the one it showed changes nothing: no write, and
13068    /// a rename another client made since stands.
13069    #[tokio::test]
13070    async fn an_unchanged_folder_name_writes_nothing() {
13071        for backend in RACE_BACKENDS {
13072            let theirs = renamed(folder_seed(), "Their name");
13073            let repo = std::sync::Arc::new(std::sync::Mutex::new(folder_repo(theirs.clone())));
13074            let state = race_state(backend, &repo).await;
13075
13076            let loc = post_folder_rename(&state, "name=Old+name&seen_name=Old+name").await;
13077
13078            let repo = repo.lock().unwrap();
13079            assert_eq!(loc, "/", "{backend:?}");
13080            assert!(repo.puts.is_empty(), "{backend:?}: {:?}", repo.puts);
13081            assert_eq!(repo.value, theirs, "{backend:?}");
13082        }
13083    }
13084
13085    /// The folder merge, case by case (#268).
13086    #[test]
13087    fn merge_folder_rename_is_a_three_way_merge_on_the_name() {
13088        let base = Folder::new("Old", "2024-01-01T00:00:00.000Z");
13089        let with = |name: &str| {
13090            let mut f = base.clone();
13091            f.name = name.to_string();
13092            f.position = Some(3);
13093            f.extra
13094                .insert("color".to_string(), serde_json::json!("#abc"));
13095            f
13096        };
13097        // The reader left the name as shown: nothing to write.
13098        assert_eq!(
13099            merge_folder_rename("Old", Some("Old"), &base, with("Other")),
13100            Ok(FolderMerge::Unchanged)
13101        );
13102        // Only the reader changed it: the FRESH record, renamed.
13103        assert_eq!(
13104            merge_folder_rename("New", Some("Old"), &base, with("Old")),
13105            Ok(FolderMerge::Write(with("New")))
13106        );
13107        // Fresh already holds the reader's name: agreement.
13108        assert_eq!(
13109            merge_folder_rename("New", Some("Old"), &base, with("New")),
13110            Ok(FolderMerge::AlreadySaved)
13111        );
13112        // Both changed it, differently: a conflict.
13113        assert_eq!(
13114            merge_folder_rename("New", Some("Old"), &base, with("Other")),
13115            Err(RenameConflict("name"))
13116        );
13117        // Without seen_name the first read is the ancestor.
13118        assert_eq!(
13119            merge_folder_rename("New", None, &with("Other"), with("Other")),
13120            Ok(FolderMerge::Write(with("New")))
13121        );
13122        assert_eq!(
13123            merge_folder_rename("New", None, &base, with("Other")),
13124            Err(RenameConflict("name"))
13125        );
13126        // Padding is not a change.
13127        assert_eq!(
13128            merge_folder_rename(" Old ", Some("Old "), &base, with("Other")),
13129            Ok(FolderMerge::Unchanged)
13130        );
13131        assert_eq!(
13132            merge_folder_rename("New ", Some("Old"), &base, with(" Old ")),
13133            Ok(FolderMerge::Write(with("New")))
13134        );
13135    }
13136
13137    /// Both sides changing a field to the SAME value is agreement, not a
13138    /// conflict — for every field the merge handles. Alongside a field still
13139    /// to apply, the write goes ahead with it; alone, there is nothing to
13140    /// write and the save is already done.
13141    #[test]
13142    fn the_same_change_on_both_sides_is_not_a_conflict() {
13143        let base = merge_base();
13144        let url = "https://a.example/feed.xml";
13145        let new_url = "https://c.example/feed.xml";
13146
13147        // title: both "New".
13148        let mut fresh = base.clone();
13149        fresh.title = Some("New".to_string());
13150        let merged = merge_rename(&merge_form(url, "New", Some("at://f/old")), &base, fresh)
13151            .expect("same title is no conflict");
13152        assert!(merged.already_saved, "nothing left to write");
13153
13154        // folder: both moved to the same folder, while the reader also retitles.
13155        let mut fresh = base.clone();
13156        fresh.folder = Some("at://f/new".to_string());
13157        let merged = merge_rename(&merge_form(url, "Mine", Some("at://f/new")), &base, fresh)
13158            .expect("same folder is no conflict");
13159        assert!(!merged.already_saved, "the title is still to write");
13160        assert_eq!(merged.sub.title.as_deref(), Some("Mine"));
13161        assert_eq!(merged.sub.folder.as_deref(), Some("at://f/new"));
13162
13163        // url: both repointed to the same URL. Not a repoint by THIS write, so
13164        // the fresh record's siteUrl (which may be for the new feed) stays.
13165        let mut fresh = base.clone();
13166        fresh.url = new_url.to_string();
13167        fresh.site_url = Some("https://c.example/".to_string());
13168        let merged = merge_rename(
13169            &merge_form(new_url, "Old", Some("at://f/old")),
13170            &base,
13171            fresh,
13172        )
13173        .expect("same url is no conflict");
13174        assert!(merged.already_saved);
13175        assert!(!merged.repoint);
13176        assert_eq!(merged.sub.site_url.as_deref(), Some("https://c.example/"));
13177
13178        // siteUrl: both set it the same.
13179        let mut fresh = base.clone();
13180        fresh.site_url = Some("https://same.example/".to_string());
13181        let mut form = merge_form(url, "Old", Some("at://f/old"));
13182        form.site_url = Some("https://same.example/".to_string());
13183        let merged = merge_rename(&form, &base, fresh).expect("same siteUrl is no conflict");
13184        assert!(merged.already_saved);
13185
13186        // A form with no edits at all is NOT "already saved": it writes, as
13187        // it always has.
13188        let merged = merge_rename(
13189            &merge_form(url, "Old", Some("at://f/old")),
13190            &base,
13191            base.clone(),
13192        )
13193        .unwrap();
13194        assert!(!merged.already_saved);
13195    }
13196
13197    fn merge_form(url: &str, title: &str, folder: Option<&str>) -> RenameSubForm {
13198        RenameSubForm {
13199            url: url.to_string(),
13200            title: Some(title.to_string()),
13201            site_url: None,
13202            folder: folder.map(str::to_string),
13203            seen_url: None,
13204            seen_title: None,
13205            seen_folder: None,
13206        }
13207    }
13208
13209    fn merge_base() -> Subscription {
13210        let mut s = Subscription::new("https://a.example/feed.xml", "2024-03-01T00:00:00.000Z");
13211        s.title = Some("Old".to_string());
13212        s.folder = Some("at://f/old".to_string());
13213        s.site_url = Some("https://a.example/".to_string());
13214        s
13215    }
13216
13217    /// The merge, field by field, for the branches the handler tests do not
13218    /// each reach: every field the reader changed that someone else also
13219    /// changed is a conflict; every field only one side changed merges.
13220    #[test]
13221    fn merge_rename_is_a_three_way_merge_per_field() {
13222        let base = merge_base();
13223        let url = "https://a.example/feed.xml";
13224
13225        // Folder: both moved it -> conflict; only the reader -> applied.
13226        let mut theirs = base.clone();
13227        theirs.folder = Some("at://f/theirs".to_string());
13228        assert_eq!(
13229            merge_rename(
13230                &merge_form(url, "Old", Some("at://f/mine")),
13231                &base,
13232                theirs.clone()
13233            ),
13234            Err(RenameConflict("folder"))
13235        );
13236        let merged = merge_rename(
13237            &merge_form(url, "Old", Some("at://f/mine")),
13238            &base,
13239            base.clone(),
13240        )
13241        .unwrap();
13242        assert_eq!(merged.sub.folder.as_deref(), Some("at://f/mine"));
13243        assert!(!merged.repoint);
13244        // Only they moved it: theirs stands.
13245        let merged =
13246            merge_rename(&merge_form(url, "Old", Some("at://f/old")), &base, theirs).unwrap();
13247        assert_eq!(merged.sub.folder.as_deref(), Some("at://f/theirs"));
13248
13249        // URL: both repointed -> conflict.
13250        let mut moved = base.clone();
13251        moved.url = "https://b.example/feed.xml".to_string();
13252        assert_eq!(
13253            merge_rename(
13254                &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
13255                &base,
13256                moved
13257            ),
13258            Err(RenameConflict("url"))
13259        );
13260
13261        // siteUrl: a posted value both sides changed -> conflict.
13262        let mut resited = base.clone();
13263        resited.site_url = Some("https://theirs.example/".to_string());
13264        let mut form = merge_form(url, "Old", Some("at://f/old"));
13265        form.site_url = Some("https://mine.example/".to_string());
13266        assert_eq!(
13267            merge_rename(&form, &base, resited),
13268            Err(RenameConflict("siteUrl"))
13269        );
13270
13271        // A reader's repoint drops the old feed's properties.
13272        let merged = merge_rename(
13273            &merge_form("https://c.example/feed.xml", "Old", Some("at://f/old")),
13274            &base,
13275            base.clone(),
13276        )
13277        .unwrap();
13278        assert!(merged.repoint);
13279        assert_eq!(merged.sub.url, "https://c.example/feed.xml");
13280        assert_eq!(merged.sub.site_url, None);
13281
13282        // seen_* wins over base for "did the reader change it": the input was
13283        // pre-filled with a display title, and posting it back is no edit.
13284        let mut untitled = base.clone();
13285        untitled.title = None;
13286        let mut form = merge_form(url, "A display fallback", Some("at://f/old"));
13287        form.seen_title = Some("A display fallback".to_string());
13288        let merged = merge_rename(&form, &untitled, untitled.clone()).unwrap();
13289        assert_eq!(
13290            merged.sub.title, None,
13291            "an untouched display title was written"
13292        );
13293    }
13294
13295    /// **A rename must not destroy the fields the form never carries.**
13296    ///
13297    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
13298    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
13299    /// every field absent from `templates/manage_row.html` (which posts only
13300    /// `url`, `title`, `folder`) was written back as its default:
13301    ///
13302    /// | field | before | after |
13303    /// |---|---|---|
13304    /// | `siteUrl` | whatever the feed advertised | gone |
13305    /// | `fetchHint` | as set | gone |
13306    /// | `private` | as set | gone |
13307    /// | `createdAt` | original subscribe time | reset to now |
13308    ///
13309    /// `createdAt` is the worst of the four: it is the sort key for "when did I
13310    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
13311    /// tells the reader it moved.
13312    ///
13313    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
13314    /// in the test — the record only becomes wrong on the way out, so checking
13315    /// the value we passed in would pass just as happily with the fix removed.
13316    #[tokio::test]
13317    async fn renaming_preserves_the_fields_the_form_never_carries() {
13318        let did = "did:plc:renamer4";
13319        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13320        let state = test_state_with_sidecar(&[did], &sidecar).await;
13321        let cookie = session_cookie(&state, did, None);
13322
13323        let resp = router(state.clone())
13324            .oneshot(
13325                Request::builder()
13326                    .method("POST")
13327                    .uri("/subscriptions/rk-keep/rename")
13328                    .header(header::COOKIE, cookie)
13329                    .header("content-type", "application/x-www-form-urlencoded")
13330                    // Exactly what the manage row posts: url, title, folder.
13331                    .body(Body::from(
13332                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
13333                    ))
13334                    .unwrap(),
13335            )
13336            .await
13337            .unwrap();
13338        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13339
13340        let bodies = puts.lock().unwrap().clone();
13341        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13342        let body = &bodies[0];
13343        // Anchors the negative assertions: an empty capture would satisfy them.
13344        assert!(
13345            body.contains("community.lexicon.rss.subscription"),
13346            "captured no usable put body: {body:?}"
13347        );
13348
13349        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
13350        let record = &sent["record"];
13351
13352        // What the form DID carry must be applied.
13353        assert_eq!(record["title"], "New title", "the rename did not apply");
13354        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
13355
13356        // What the form did NOT carry must survive.
13357        assert_eq!(
13358            record["createdAt"], "2024-03-01T00:00:00.000Z",
13359            "the rename reset createdAt — the reader's subscribe time is gone \
13360             from their own repo, and nothing told them"
13361        );
13362        assert_eq!(
13363            record["siteUrl"], "https://example.com/blog",
13364            "the rename erased siteUrl"
13365        );
13366        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
13367        assert_eq!(record["private"], false, "the rename erased private");
13368    }
13369
13370    /// **Repointing at a different feed drops that feed's properties, but not
13371    /// the subscription's.**
13372    ///
13373    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
13374    /// so carrying them onto a different URL would leave a site link for the old
13375    /// feed hanging off the new one. `createdAt` and `private` are properties of
13376    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
13377    /// subscribed, whatever the URL was later corrected to.
13378    #[tokio::test]
13379    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
13380        let did = "did:plc:renamer4";
13381        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13382        let state = test_state_with_sidecar(&[did], &sidecar).await;
13383        let cookie = session_cookie(&state, did, None);
13384
13385        let resp = router(state.clone())
13386            .oneshot(
13387                Request::builder()
13388                    .method("POST")
13389                    .uri("/subscriptions/rk-keep/rename")
13390                    .header(header::COOKIE, cookie)
13391                    .header("content-type", "application/x-www-form-urlencoded")
13392                    // A DIFFERENT feed URL from the seeded record.
13393                    .body(Body::from(
13394                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
13395                    ))
13396                    .unwrap(),
13397            )
13398            .await
13399            .unwrap();
13400        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13401
13402        let bodies = puts.lock().unwrap().clone();
13403        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13404        assert!(
13405            bodies[0].contains("community.lexicon.rss.subscription"),
13406            "captured no usable put body: {:?}",
13407            bodies[0]
13408        );
13409        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13410        let record = &sent["record"];
13411
13412        assert_eq!(record["url"], "https://other.example/feed.xml");
13413        // The old feed's properties are gone rather than misattributed.
13414        assert!(
13415            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
13416            "the old feed's site link followed the subscription to a new feed: {record}"
13417        );
13418        assert!(
13419            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
13420            "the old feed's fetch hint followed the subscription to a new feed: {record}"
13421        );
13422        // The subscription's own properties survive.
13423        assert_eq!(
13424            record["createdAt"], "2024-03-01T00:00:00.000Z",
13425            "a repoint is still not a new subscription; createdAt must not move"
13426        );
13427        assert_eq!(record["private"], false, "the repoint erased private");
13428    }
13429
13430    /// **A rename against an rkey that is not in the repo writes NOTHING.**
13431    ///
13432    /// `update_subscription` is a `putRecord`, which CREATES the record when the
13433    /// rkey does not exist — with whatever `createdAt` we hand it. So without
13434    /// this refusal a rename against a stale or wrong rkey manufactures a
13435    /// subscription dated today, which is the bug this whole change exists to
13436    /// fix, arriving by a different door.
13437    ///
13438    /// The guard was untested when first written: removing it left all 733 tests
13439    /// green. An untested guard against the exact defect being fixed is how the
13440    /// two previous rounds of this problem got through.
13441    #[tokio::test]
13442    async fn renaming_an_unknown_rkey_writes_nothing() {
13443        let did = "did:plc:renamer4";
13444        // The sidecar serves exactly one record, at rkey `rk-keep`.
13445        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13446        let state = test_state_with_sidecar(&[did], &sidecar).await;
13447        let cookie = session_cookie(&state, did, None);
13448
13449        let resp = router(state.clone())
13450            .oneshot(
13451                Request::builder()
13452                    .method("POST")
13453                    // ...and this is not it.
13454                    .uri("/subscriptions/rk-does-not-exist/rename")
13455                    .header(header::COOKIE, cookie)
13456                    .header("content-type", "application/x-www-form-urlencoded")
13457                    .body(Body::from(
13458                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
13459                    ))
13460                    .unwrap(),
13461            )
13462            .await
13463            .unwrap();
13464
13465        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13466        let loc = resp
13467            .headers()
13468            .get(header::LOCATION)
13469            .unwrap()
13470            .to_str()
13471            .unwrap();
13472        assert!(
13473            loc.contains("flash="),
13474            "an unknown rkey redirected as though the rename had worked: {loc}"
13475        );
13476        assert!(
13477            puts.lock().unwrap().is_empty(),
13478            "a rename against an unknown rkey wrote a record — putRecord would \
13479             CREATE it, dated today: {:?}",
13480            puts.lock().unwrap()
13481        );
13482    }
13483
13484    /// **A `site_url` the client actually sends is applied, not dropped.**
13485    ///
13486    /// `templates/manage_row.html` does not post this field, so it is tempting
13487    /// to read the arm that handles it as dead code. It is not:
13488    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
13489    /// today. Discarding the value instead of applying it left all 733 tests
13490    /// green.
13491    ///
13492    /// The value is scheme-checked on the way out by the repo-boundary vet, so
13493    /// this is a coverage gap rather than an exposure — but an untested path
13494    /// that writes a URL into the reader's PDS should not stay untested.
13495    #[tokio::test]
13496    async fn a_client_supplied_site_url_reaches_the_record() {
13497        let did = "did:plc:renamer4";
13498        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
13499        let state = test_state_with_sidecar(&[did], &sidecar).await;
13500        let cookie = session_cookie(&state, did, None);
13501
13502        let resp = router(state.clone())
13503            .oneshot(
13504                Request::builder()
13505                    .method("POST")
13506                    .uri("/subscriptions/rk-keep/rename")
13507                    .header(header::COOKIE, cookie)
13508                    .header("content-type", "application/x-www-form-urlencoded")
13509                    // Same feed URL, but carrying a site_url the manage row
13510                    // never sends.
13511                    .body(Body::from(
13512                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
13513                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
13514                    ))
13515                    .unwrap(),
13516            )
13517            .await
13518            .unwrap();
13519        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13520
13521        let bodies = puts.lock().unwrap().clone();
13522        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
13523        assert!(
13524            bodies[0].contains("community.lexicon.rss.subscription"),
13525            "captured no usable put body: {:?}",
13526            bodies[0]
13527        );
13528        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
13529        assert_eq!(
13530            sent["record"]["siteUrl"], "https://typed.example/site",
13531            "the client's siteUrl was dropped; the seeded record's survived instead"
13532        );
13533    }
13534
13535    /// **A rename whose read fails writes NOTHING.**
13536    ///
13537    /// This is the property most easily lost when someone later touches this
13538    /// handler: falling back to `Subscription::new` on a read error looks like
13539    /// graceful degradation and is in fact the original bug, reinstated on
13540    /// exactly the path where it is hardest to notice. The reader must be told
13541    /// instead.
13542    #[tokio::test]
13543    async fn a_rename_whose_read_fails_writes_nothing() {
13544        let did = "did:plc:renamer5";
13545        // A port that accepts nothing: the read cannot succeed.
13546        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13547        let dead = format!("http://{}", listener.local_addr().unwrap());
13548        drop(listener);
13549
13550        let state = test_state_with_sidecar(&[did], &dead).await;
13551        let cookie = session_cookie(&state, did, None);
13552        let before = store::count_feeds(&state.db).await.unwrap();
13553
13554        let resp = router(state.clone())
13555            .oneshot(
13556                Request::builder()
13557                    .method("POST")
13558                    .uri("/subscriptions/rk-keep/rename")
13559                    .header(header::COOKIE, cookie)
13560                    .header("content-type", "application/x-www-form-urlencoded")
13561                    .body(Body::from(
13562                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
13563                    ))
13564                    .unwrap(),
13565            )
13566            .await
13567            .unwrap();
13568
13569        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
13570        let loc = resp
13571            .headers()
13572            .get(header::LOCATION)
13573            .unwrap()
13574            .to_str()
13575            .unwrap();
13576        assert!(
13577            loc.contains("flash="),
13578            "a failed read redirected as though the rename had worked: {loc}"
13579        );
13580        assert_eq!(
13581            store::count_feeds(&state.db).await.unwrap(),
13582            before,
13583            "a rename that could not read the record still wrote to the cache"
13584        );
13585    }
13586
13587    /// Folder pre-selection regression: the manage rename row must mark the
13588    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
13589    /// re-submits the current folder instead of silently un-foldering the feed.
13590    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
13591    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
13592    #[test]
13593    fn manage_rename_row_preselects_current_folder() {
13594        let nav = Nav {
13595            handle: "@reader.example".to_string(),
13596            avatar: "RE".to_string(),
13597            view: "unread".to_string(),
13598            scope_qs: String::new(),
13599            folders: Vec::new(),
13600            loose_feeds: Vec::new(),
13601            manage_active: true,
13602        };
13603        let folder_options = vec![
13604            FolderOption {
13605                uri: "at://did:plc:x/app.folder/work".to_string(),
13606                name: "Work".to_string(),
13607            },
13608            FolderOption {
13609                uri: "at://did:plc:x/app.folder/fun".to_string(),
13610                name: "Fun".to_string(),
13611            },
13612        ];
13613        // A foldered feed (in "Work") and a loose feed (no folder), each with a
13614        // non-empty rkey so the rename form renders.
13615        let foldered = FeedView {
13616            rkey: "sub-foldered".to_string(),
13617            url: "https://work.example/feed.xml".to_string(),
13618            title: "Work Feed".to_string(),
13619            unread: 0,
13620            selected: false,
13621            folder: Some("at://did:plc:x/app.folder/work".to_string()),
13622        };
13623        let loose = FeedView {
13624            rkey: "sub-loose".to_string(),
13625            url: "https://loose.example/feed.xml".to_string(),
13626            title: "Loose Feed".to_string(),
13627            unread: 0,
13628            selected: false,
13629            folder: None,
13630        };
13631        let tmpl = ManageTemplate {
13632            card: Card::private(&Config::default()),
13633            version: VERSION,
13634            repo_url: REPO_URL,
13635            kofi_url: KOFI_URL,
13636            flash: String::new(),
13637            alert: String::new(),
13638            nav,
13639            folder_options,
13640            folders: vec![FolderView {
13641                rkey: "folder-work".to_string(),
13642                uri: "at://did:plc:x/app.folder/work".to_string(),
13643                name: "Work".to_string(),
13644                feeds: vec![foldered],
13645                selected: false,
13646            }],
13647            loose_feeds: vec![loose],
13648            standard_site: false,
13649        };
13650        let html = tmpl.render().unwrap();
13651
13652        // The foldered feed's "Work" option is pre-selected.
13653        assert!(
13654            html.contains(
13655                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
13656            ),
13657            "foldered feed must pre-select its current folder: {html}"
13658        );
13659        // The loose feed's "No folder" option is pre-selected (appears for the
13660        // loose row, which has folder=None).
13661        assert!(
13662            html.contains(r#"<option value="" selected>No folder</option>"#),
13663            "loose feed must pre-select 'No folder': {html}"
13664        );
13665
13666        // #149: the values each input was pre-filled with ride along, so the
13667        // handler can tell what the reader changed from what they merely saw.
13668        for want in [
13669            r#"<input type="hidden" name="seen_url" value="https://work.example/feed.xml" />"#,
13670            r#"<input type="hidden" name="seen_title" value="Work Feed" />"#,
13671            r#"<input type="hidden" name="seen_folder" value="at://did:plc:x/app.folder/work" />"#,
13672            r#"<input type="hidden" name="seen_folder" value="" />"#,
13673            // #268: the folder rename form says which name it showed.
13674            r#"<input type="hidden" name="seen_name" value="Work" />"#,
13675        ] {
13676            assert!(html.contains(want), "missing {want}: {html}");
13677        }
13678    }
13679
13680    /// **The public stats page carries no user data.**
13681    ///
13682    /// It is reachable by anyone, so the thing worth pinning is what it does
13683    /// NOT say: nothing about how many people use the instance, nothing about
13684    /// which feeds fail, nothing about who reads what.
13685    #[tokio::test]
13686    async fn the_public_stats_page_exposes_no_user_data() {
13687        let state = test_state(&[]).await;
13688        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
13689            .await
13690            .unwrap();
13691
13692        let resp = router(state)
13693            .oneshot(
13694                Request::builder()
13695                    .uri("/stats")
13696                    .body(Body::empty())
13697                    .unwrap(),
13698            )
13699            .await
13700            .unwrap();
13701        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
13702
13703        let body = String::from_utf8(
13704            axum::body::to_bytes(resp.into_body(), usize::MAX)
13705                .await
13706                .unwrap()
13707                .to_vec(),
13708        )
13709        .unwrap();
13710
13711        // Structural checks, not word checks. The page's own prose says it
13712        // publishes no error rates, so searching for that PHRASE finds the
13713        // disclaimer rather than a leak — the first version of this test failed
13714        // on exactly that. What matters is whether identifiers or the
13715        // admin-only figures are present.
13716        assert!(
13717            !body.contains("did:"),
13718            "the public stats page leaked an identifier"
13719        );
13720        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
13721            assert!(
13722                !body.contains(admin_only),
13723                "the public page is showing the admin metrics column {admin_only:?}"
13724            );
13725        }
13726        // And it does render the aggregate it exists for.
13727        assert!(body.contains("Feeds tracked"));
13728        assert!(body.contains("Waiting to be polled"));
13729    }
13730
13731    /// **The two states that stop feeds updating must be visible.**
13732    ///
13733    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
13734    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
13735    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
13736    /// the backlog and makes the page read healthier. That inversion is what this
13737    /// test pins: a broken feed must raise a number, not lower one.
13738    #[tokio::test]
13739    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
13740        let state = test_state(&[]).await;
13741        // Three feeds: one healthy, one flaky, one long dead.
13742        for (url, errors) in [
13743            ("https://ok.example/f.xml", 0),
13744            ("https://flaky.example/f.xml", 2),
13745            ("https://dead.example/f.xml", 9),
13746        ] {
13747            store::upsert_feed(
13748                &state.db,
13749                &store::NewFeed {
13750                    url: url.to_string(),
13751                    // Pushed forward, exactly as backoff does — so none of these
13752                    // are counted as `overdue`.
13753                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
13754                    ..Default::default()
13755                },
13756            )
13757            .await
13758            .unwrap();
13759            for _ in 0..errors {
13760                store::bump_feed_errors(
13761                    &state.db,
13762                    url,
13763                    feed::FailureKind::Fetch,
13764                    "connection refused",
13765                )
13766                .await
13767                .unwrap();
13768            }
13769        }
13770
13771        let render_stats = |state: AppState| async move {
13772            let resp = router(state)
13773                .oneshot(
13774                    Request::builder()
13775                        .uri("/stats")
13776                        .body(Body::empty())
13777                        .unwrap(),
13778                )
13779                .await
13780                .unwrap();
13781            assert_eq!(resp.status(), StatusCode::OK);
13782            String::from_utf8(
13783                axum::body::to_bytes(resp.into_body(), usize::MAX)
13784                    .await
13785                    .unwrap()
13786                    .to_vec(),
13787            )
13788            .unwrap()
13789        };
13790
13791        // **The fixture must actually be RUNNING, or this test measures nothing.**
13792        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
13793        // checks that BEFORE the watermark — so without these two lines every
13794        // render below reports "off" and the watermark can never surface. The
13795        // assertions still passed, for reasons unrelated to what they name: see
13796        // the two comments below.
13797        state.runtime_health.set_schedulers_enabled(true);
13798        state
13799            .runtime_health
13800            .poll_tick_completed(crate::store::now_unix());
13801
13802        let body = render_stats(state.clone()).await;
13803        assert!(
13804            body.contains("Failing"),
13805            "backoff is still invisible on the public page"
13806        );
13807        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
13808        // value rather than on surrounding whitespace, so re-indenting the
13809        // template cannot break this.
13810        assert!(
13811            body.contains("2, 1 badly"),
13812            "expected '2, 1 badly' in the failing row; got:\n{}",
13813            body.split("Failing")
13814                .nth(1)
13815                .unwrap_or("")
13816                .chars()
13817                .take(300)
13818                .collect::<String>()
13819        );
13820        // Not paused, and the backlog is genuinely empty — which is exactly the
13821        // reading that used to be indistinguishable from healthy.
13822        //
13823        // **Asserted by EXCLUDING the other states, not by matching "running".**
13824        // The `off` row reads "the poller is not running on this instance", which
13825        // contains "running" — so the bare substring passed while the page was
13826        // reporting the exact opposite of what this line claims to check.
13827        assert!(
13828            !body.contains("the poller is not running")
13829                && !body.contains("the cache is at its size limit")
13830                && !body.contains("has not completed a round"),
13831            "expected the running state; the page reported a stopped one",
13832        );
13833
13834        // Now trip the watermark. Nothing in the database changes; only the
13835        // recorded runtime state does — which is the whole reason it needed a
13836        // home outside the log stream.
13837        state.runtime_health.set_watermark(true);
13838        let paused = render_stats(state.clone()).await;
13839        // Matched on the paused row's OWN sentence. The bare word "paused" also
13840        // appeared in the page's explanatory prose, so this assertion passed
13841        // whether or not the row rendered — and trimming that prose is what
13842        // exposed it. This phrase exists only inside the `paused` branch.
13843        assert!(
13844            paused.contains("the cache is at its size limit"),
13845            "a watermark pause is still invisible on the public page"
13846        );
13847
13848        // Still no identifiers: these are counts, not feeds.
13849        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
13850            assert!(
13851                !paused.contains(leak),
13852                "the public page leaked {leak:?} while reporting failures"
13853            );
13854        }
13855    }
13856
13857    /// **`/admin/metrics` is gated, and nothing checked that it was.**
13858    ///
13859    /// Deleting the `admin_seed_dids` check left the entire suite green. That
13860    /// was survivable while the page held only aggregate timings; it is not now,
13861    /// because this branch puts **per-feed URLs and remote error text** behind
13862    /// that gate. A guarantee nothing checks is a comment, and this one is now
13863    /// the only thing standing between a signed-in stranger and the operational
13864    /// picture the handler's own doc says is not public.
13865    ///
13866    /// All three doors: no session, a session that is not an admin, and the
13867    /// admin itself.
13868    #[tokio::test]
13869    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
13870        let admin = "did:plc:adminseed";
13871        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
13872        // IS that list — deliberately, per its doc: "the same people I trust on
13873        // this instance". Production sets it to the bootstrap DID alone.
13874        //
13875        // A genuine non-admin is therefore someone holding a beta seat granted
13876        // by an invite, not by the allow-list. Seeding both would have made
13877        // both admins and quietly turned the 403 assertion below into a test of
13878        // nothing — which is exactly what the first draft of this did.
13879        let state = test_state(&[admin]).await;
13880        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
13881            .await
13882            .unwrap();
13883        let url = "https://broken.example/f.xml";
13884        store::upsert_feed(
13885            &state.db,
13886            &store::NewFeed {
13887                url: url.to_string(),
13888                ..Default::default()
13889            },
13890        )
13891        .await
13892        .unwrap();
13893        store::bump_feed_errors(
13894            &state.db,
13895            url,
13896            feed::FailureKind::Fetch,
13897            "SENTINEL_ADMIN_ONLY",
13898        )
13899        .await
13900        .unwrap();
13901
13902        let get = |state: AppState, cookie: Option<String>| async move {
13903            let mut req = Request::builder().uri("/admin/metrics");
13904            if let Some(c) = cookie {
13905                req = req.header(header::COOKIE, c);
13906            }
13907            let resp = router(state)
13908                .oneshot(req.body(Body::empty()).unwrap())
13909                .await
13910                .unwrap();
13911            let status = resp.status();
13912            let body = String::from_utf8(
13913                axum::body::to_bytes(resp.into_body(), usize::MAX)
13914                    .await
13915                    .unwrap()
13916                    .to_vec(),
13917            )
13918            .unwrap();
13919            (status, body)
13920        };
13921
13922        // No session at all.
13923        let (status, body) = get(state.clone(), None).await;
13924        assert_eq!(status, StatusCode::UNAUTHORIZED);
13925        assert!(
13926            !body.contains("SENTINEL_ADMIN_ONLY"),
13927            "leaked to anonymous: {body}"
13928        );
13929
13930        // A real, signed-in user who is not an admin.
13931        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
13932        let (status, body) = get(state.clone(), Some(ordinary)).await;
13933        assert_eq!(
13934            status,
13935            StatusCode::FORBIDDEN,
13936            "a non-admin session was let in"
13937        );
13938        assert!(
13939            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
13940            "leaked to a non-admin: {body}",
13941        );
13942
13943        // The admin does get it — otherwise the two refusals above are
13944        // satisfied by the endpoint being broken for everyone.
13945        let admin_cookie = session_cookie(&state, admin, None);
13946        let (status, body) = get(state, Some(admin_cookie)).await;
13947        assert_eq!(status, StatusCode::OK);
13948        assert!(
13949            body.contains("SENTINEL_ADMIN_ONLY"),
13950            "admin cannot see it: {body}"
13951        );
13952    }
13953
13954    /// **The cause a public count cannot carry belongs on the admin page.**
13955    ///
13956    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
13957    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
13958    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
13959    /// have separated "sixty dead publishers" from "one bug here", which is the
13960    /// case it was justified by.
13961    ///
13962    /// The answer is not a finer public vocabulary — `/stats` promises never
13963    /// which feed and never whose, and a bucket per error string would break
13964    /// that. It is to put the detail where per-feed data is already allowed.
13965    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
13966    /// operational picture.
13967    ///
13968    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
13969    /// public one.
13970    #[tokio::test]
13971    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
13972        let admin = "did:plc:adminseed";
13973        let state = test_state(&[admin]).await;
13974        let url = "https://broken.example/f.xml";
13975        store::upsert_feed(
13976            &state.db,
13977            &store::NewFeed {
13978                url: url.to_string(),
13979                ..Default::default()
13980            },
13981        )
13982        .await
13983        .unwrap();
13984        store::bump_feed_errors(
13985            &state.db,
13986            url,
13987            feed::FailureKind::Fetch,
13988            "SENTINEL_REDIRECT_NO_LOCATION",
13989        )
13990        .await
13991        .unwrap();
13992
13993        let cookie = session_cookie(&state, admin, None);
13994        let resp = router(state.clone())
13995            .oneshot(
13996                Request::builder()
13997                    .uri("/admin/metrics")
13998                    .header(header::COOKIE, cookie)
13999                    .body(Body::empty())
14000                    .unwrap(),
14001            )
14002            .await
14003            .unwrap();
14004        assert_eq!(resp.status(), StatusCode::OK);
14005        let admin_body = String::from_utf8(
14006            axum::body::to_bytes(resp.into_body(), usize::MAX)
14007                .await
14008                .unwrap()
14009                .to_vec(),
14010        )
14011        .unwrap();
14012        assert!(
14013            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
14014            "the admin page does not carry the failure detail: {admin_body}",
14015        );
14016        assert!(
14017            admin_body.contains("broken.example"),
14018            "the admin page does not name the failing feed: {admin_body}",
14019        );
14020
14021        // The public page still carries neither.
14022        let resp = router(state)
14023            .oneshot(
14024                Request::builder()
14025                    .uri("/stats")
14026                    .body(Body::empty())
14027                    .unwrap(),
14028            )
14029            .await
14030            .unwrap();
14031        let public = String::from_utf8(
14032            axum::body::to_bytes(resp.into_body(), usize::MAX)
14033                .await
14034                .unwrap()
14035                .to_vec(),
14036        )
14037        .unwrap();
14038        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
14039            assert!(
14040                !public.contains(secret),
14041                "{secret:?} reached the PUBLIC stats page: {public}",
14042            );
14043        }
14044    }
14045
14046    /// **A direct poll must settle the error columns, like the scheduler does.**
14047    ///
14048    /// `add_subscription` polls through `feed::poll_feed` rather than the
14049    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
14050    /// touches `consecutive_errors` — that is the scheduler's job, and this path
14051    /// is not the scheduler.
14052    ///
14053    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
14054    /// its old count and its old cause: the public page went on reporting it
14055    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
14056    /// the stale backoff horizon lasted — up to 24h — while the reader was
14057    /// demonstrably fetching it.
14058    #[tokio::test]
14059    async fn a_successful_direct_poll_clears_a_stale_failure() {
14060        let state = test_state(&[]).await;
14061        let url = "https://recovered.example/f.xml";
14062        store::upsert_feed(
14063            &state.db,
14064            &store::NewFeed {
14065                url: url.to_string(),
14066                ..Default::default()
14067            },
14068        )
14069        .await
14070        .unwrap();
14071        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
14072            .await
14073            .unwrap();
14074        // Park it on a stale backoff horizon, as a real failing feed would be.
14075        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
14076            .bind(url)
14077            .execute(&state.db)
14078            .await
14079            .unwrap();
14080
14081        // The publisher is fixed: a successful poll happens on this path.
14082        feed::settle_poll(
14083            &state.db,
14084            url,
14085            &feed::PollOutcome::NotModified,
14086            state.config.poll_interval,
14087        )
14088        .await;
14089
14090        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
14091            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
14092        )
14093        .bind(url)
14094        .fetch_one(&state.db)
14095        .await
14096        .unwrap();
14097        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
14098        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
14099        // **The half the first fix missed.** Clearing the count fixed the
14100        // REPORTING; the feed stayed parked until 2099. A working feed must be
14101        // rescheduled on its normal cadence, not left on the failure horizon.
14102        let next = row.2.expect("next_poll was cleared to NULL");
14103        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
14104        // backoff. A mutation that reschedules successes with backoff_for(1)
14105        // (5 min) also moves it off 2099, so the interval is asserted.
14106        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
14107        let delta = parsed
14108            .signed_duration_since(chrono::Utc::now())
14109            .num_seconds();
14110        let cadence = state.config.poll_interval.as_secs() as i64;
14111        assert!(
14112            (cadence - 60..=cadence + 60).contains(&delta),
14113            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
14114        );
14115    }
14116
14117    /// The mirror case: a first poll that FAILS must be visible at all.
14118    ///
14119    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
14120    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
14121    /// with a NULL cause — invisible to the page built to count exactly that.
14122    #[tokio::test]
14123    async fn a_failing_direct_poll_is_recorded() {
14124        let state = test_state(&[]).await;
14125        let url = "https://born-broken.example/f.xml";
14126        store::upsert_feed(
14127            &state.db,
14128            &store::NewFeed {
14129                url: url.to_string(),
14130                ..Default::default()
14131            },
14132        )
14133        .await
14134        .unwrap();
14135
14136        feed::settle_poll(
14137            &state.db,
14138            url,
14139            &feed::PollOutcome::Failed {
14140                backoff: std::time::Duration::from_secs(300),
14141                kind: feed::FailureKind::Parse,
14142                detail: "SENTINEL_BORN_BROKEN".to_string(),
14143            },
14144            state.config.poll_interval,
14145        )
14146        .await;
14147
14148        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
14149            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
14150        )
14151        .bind(url)
14152        .fetch_one(&state.db)
14153        .await
14154        .unwrap();
14155        assert_eq!(row.0, 1, "a failed first poll was not counted");
14156        assert_eq!(
14157            row.1.as_deref(),
14158            Some("parse"),
14159            "its cause was not recorded"
14160        );
14161        // And it is BACKED OFF on the schedule the scheduler would use — not
14162        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
14163        // on the very next tick.
14164        let next = row.2.expect("a failed direct poll left next_poll NULL");
14165        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
14166        let delta = parsed
14167            .signed_duration_since(chrono::Utc::now())
14168            .num_seconds();
14169        assert!(
14170            (240..=360).contains(&delta),
14171            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
14172        );
14173    }
14174
14175    /// **The breakdown must sum to the Failing figure above it.**
14176    ///
14177    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
14178    /// `consecutive_errors > 0`. On a migrated database every row that was
14179    /// already failing has a NULL kind — correctly, it was never recorded — so
14180    /// the two do not reconcile and the page shows "70 failing" beside "3
14181    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
14182    /// entirely while the prose still promises a breakdown.
14183    ///
14184    /// An explicit `unknown` bucket is the honest shape: the page says how many
14185    /// it cannot explain rather than omitting them.
14186    #[tokio::test]
14187    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
14188        let state = test_state(&[]).await;
14189        // Two legacy rows: failing, with no recorded cause.
14190        for url in [
14191            "https://legacy1.example/f.xml",
14192            "https://legacy2.example/f.xml",
14193        ] {
14194            store::upsert_feed(
14195                &state.db,
14196                &store::NewFeed {
14197                    url: url.to_string(),
14198                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14199                    ..Default::default()
14200                },
14201            )
14202            .await
14203            .unwrap();
14204            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
14205                .bind(url)
14206                .execute(&state.db)
14207                .await
14208                .unwrap();
14209        }
14210        // One row with a recorded cause.
14211        store::upsert_feed(
14212            &state.db,
14213            &store::NewFeed {
14214                url: "https://known.example/f.xml".to_string(),
14215                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14216                ..Default::default()
14217            },
14218        )
14219        .await
14220        .unwrap();
14221        store::bump_feed_errors(
14222            &state.db,
14223            "https://known.example/f.xml",
14224            feed::FailureKind::Status,
14225            "SENTINEL",
14226        )
14227        .await
14228        .unwrap();
14229
14230        let now = chrono::Utc::now();
14231        let health = store::poll_health(
14232            &state.db,
14233            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14234            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14235        )
14236        .await
14237        .unwrap();
14238        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
14239        assert_eq!(
14240            counted, health.in_backoff,
14241            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
14242            health.in_backoff, health.failure_kinds,
14243        );
14244        assert!(
14245            health
14246                .failure_kinds
14247                .iter()
14248                .any(|(k, n)| k == "unknown" && *n == 2),
14249            "no unknown bucket for the legacy rows: {:?}",
14250            health.failure_kinds,
14251        );
14252    }
14253
14254    /// **The breakdown is ordered by count, and the assertion can see it.**
14255    ///
14256    /// The first version of this asserted with three `contains` calls, which
14257    /// cannot observe order — deleting `ORDER BY` from the query passed.
14258    #[tokio::test]
14259    async fn the_failure_breakdown_is_ordered_by_count() {
14260        let state = test_state(&[]).await;
14261        for (url, kind, n) in [
14262            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
14263            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
14264            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
14265            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
14266            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
14267            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
14268        ] {
14269            store::upsert_feed(
14270                &state.db,
14271                &store::NewFeed {
14272                    url: url.to_string(),
14273                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14274                    ..Default::default()
14275                },
14276            )
14277            .await
14278            .unwrap();
14279            for _ in 0..n {
14280                store::bump_feed_errors(&state.db, url, kind, "d")
14281                    .await
14282                    .unwrap();
14283            }
14284        }
14285        let now = chrono::Utc::now();
14286        let health = store::poll_health(
14287            &state.db,
14288            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14289            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
14290        )
14291        .await
14292        .unwrap();
14293        let labels: Vec<&str> = health
14294            .failure_kinds
14295            .iter()
14296            .map(|(k, _)| k.as_str())
14297            .collect();
14298        assert_eq!(
14299            labels,
14300            ["fetch", "status", "parse"],
14301            "not ordered by count, descending: {:?}",
14302            health.failure_kinds,
14303        );
14304    }
14305
14306    /// **Failing feeds are grouped by CAUSE, and still never named.**
14307    ///
14308    /// `badly_broken` could say that sixty feeds were failing and not whether
14309    /// that was sixty dead publishers or one bug here. It was the latter — #159,
14310    /// a `304 Not Modified` read as a malformed redirect — and the page could
14311    /// not say so, which is most of why it went unexamined.
14312    ///
14313    /// The second half of this test is the constraint that shapes the first:
14314    /// `/stats` is public and promises machines-not-people, *never which feed
14315    /// and never whose*. A histogram of causes keeps that promise; a list of
14316    /// failing URLs would break it, and is the obvious way to build this.
14317    #[tokio::test]
14318    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
14319        let state = test_state(&[]).await;
14320        for (url, kind, detail, errors) in [
14321            // Detail strings are distinctive SENTINELS, not plausible English.
14322            // A first pass used "not a feed", which the page's own explanation
14323            // of the `parse` kind contains verbatim — the privacy assertion
14324            // fired on static copy rather than on a leak. A sentinel cannot
14325            // collide with prose.
14326            (
14327                "https://a.example/f.xml",
14328                feed::FailureKind::Fetch,
14329                "SENTINEL_CONNREFUSED",
14330                3,
14331            ),
14332            (
14333                "https://b.example/f.xml",
14334                feed::FailureKind::Fetch,
14335                "SENTINEL_DNSFAIL",
14336                2,
14337            ),
14338            (
14339                "https://c.example/f.xml",
14340                feed::FailureKind::Status,
14341                "SENTINEL_404",
14342                1,
14343            ),
14344            (
14345                "https://d.example/f.xml",
14346                feed::FailureKind::Parse,
14347                "SENTINEL_UNPARSEABLE",
14348                1,
14349            ),
14350        ] {
14351            store::upsert_feed(
14352                &state.db,
14353                &store::NewFeed {
14354                    url: url.to_string(),
14355                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
14356                    ..Default::default()
14357                },
14358            )
14359            .await
14360            .unwrap();
14361            for _ in 0..errors {
14362                store::bump_feed_errors(&state.db, url, kind, detail)
14363                    .await
14364                    .unwrap();
14365            }
14366        }
14367
14368        let resp = router(state.clone())
14369            .oneshot(
14370                Request::builder()
14371                    .uri("/stats")
14372                    .body(Body::empty())
14373                    .unwrap(),
14374            )
14375            .await
14376            .unwrap();
14377        assert_eq!(resp.status(), StatusCode::OK);
14378        let body = String::from_utf8(
14379            axum::body::to_bytes(resp.into_body(), usize::MAX)
14380                .await
14381                .unwrap()
14382                .to_vec(),
14383        )
14384        .unwrap();
14385
14386        // Descending by count: two fetch, then one each, tie-broken by name.
14387        assert!(
14388            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
14389            "the cause histogram did not render: {body}",
14390        );
14391
14392        // **The privacy half.** No feed URL, host, or error detail reaches the
14393        // public page — only counts by kind.
14394        for secret in [
14395            "a.example",
14396            "b.example",
14397            "c.example",
14398            "d.example",
14399            "SENTINEL_CONNREFUSED",
14400            "SENTINEL_DNSFAIL",
14401            "SENTINEL_404",
14402            "SENTINEL_UNPARSEABLE",
14403        ] {
14404            assert!(
14405                !body.contains(secret),
14406                "{secret:?} reached the PUBLIC stats page: {body}",
14407            );
14408        }
14409    }
14410
14411    /// `/health` must prove the process can reach its database, and must report
14412    /// the loop state without letting it change the status code.
14413    #[tokio::test]
14414    async fn health_checks_the_database_and_reports_the_loops() {
14415        let state = test_state(&[]).await;
14416        let body_of = |state: AppState| async move {
14417            let resp = router(state)
14418                .oneshot(
14419                    Request::builder()
14420                        .uri("/health")
14421                        .body(Body::empty())
14422                        .unwrap(),
14423                )
14424                .await
14425                .unwrap();
14426            let status = resp.status();
14427            let body = String::from_utf8(
14428                axum::body::to_bytes(resp.into_body(), usize::MAX)
14429                    .await
14430                    .unwrap()
14431                    .to_vec(),
14432            )
14433            .unwrap();
14434            (status, body)
14435        };
14436
14437        // The boot stamp is what `main` sets; the router alone does not, so this
14438        // starts "unknown" and the uptime branch below drives it explicitly.
14439        state
14440            .runtime_health
14441            .set_started_at(chrono::Utc::now().timestamp());
14442
14443        let (status, body) = body_of(state.clone()).await;
14444        assert_eq!(status, StatusCode::OK);
14445        assert!(
14446            body.contains("db: ok"),
14447            "health did not probe the DB: {body}"
14448        );
14449        assert!(
14450            body.contains("uptime:"),
14451            "no uptime — the first thing anyone asks about a container that may \
14452             be restarting: {body}"
14453        );
14454        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
14455        assert!(body.contains("polling-paused: no"), "{body}");
14456        assert!(body.contains("backend:"), "{body}");
14457        assert!(body.contains("oauth-runtime:"), "{body}");
14458
14459        // A watermark pause is REPORTED but must not fail the check. A failed
14460        // check DEREGISTERS this machine from the proxy — and it is the only
14461        // machine — so it would turn "feeds are behind" into "the site is down"
14462        // for as long as the disk stays full.
14463        state.runtime_health.set_watermark(true);
14464        state.runtime_health.set_schedulers_enabled(true);
14465        let (status, body) = body_of(state.clone()).await;
14466        assert_eq!(
14467            status,
14468            StatusCode::OK,
14469            "a watermark pause must not fail the liveness check: {body}"
14470        );
14471        assert!(body.contains("polling-paused: yes"), "{body}");
14472        // Schedulers on but no tick yet — and that must not read as "0s ago",
14473        // which is the healthiest possible answer to an unanswered question.
14474        assert!(
14475            body.contains("poller: not-yet-ticked"),
14476            "a never-ticked poller must say so: {body}"
14477        );
14478
14479        // A stale heartbeat is likewise reported, not fatal.
14480        let stale_after = health_tick_stale_secs(configured_poll_tick());
14481        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
14482        state.runtime_health.poll_tick_completed(long_ago);
14483        let (status, body) = body_of(state.clone()).await;
14484        assert_eq!(
14485            status,
14486            StatusCode::OK,
14487            "a stale poller must not 503: {body}"
14488        );
14489        assert!(body.contains("poller: stale"), "{body}");
14490
14491        // **A poller that has never ticked stops being benign.**
14492        //
14493        // In a crash loop with 30 s+ boot cycles the poller never reaches its
14494        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
14495        // could not detect the one failure mode the startup delays were added
14496        // for. It is read against uptime now.
14497        state.runtime_health.poll_tick_completed(0); // reset to "never"
14498        state
14499            .runtime_health
14500            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
14501        let (status, body) = body_of(state.clone()).await;
14502        assert_eq!(status, StatusCode::OK);
14503        assert!(
14504            body.contains("poller: stale never-ticked"),
14505            "a poller that never ticked long after boot still reads as benign: {body}"
14506        );
14507
14508        // A closed pool is a real outage: nothing can be served, and a restart is
14509        // the correct response. THIS is what the status code is for.
14510        state.db.close().await;
14511        let (status, body) = body_of(state.clone()).await;
14512        assert_eq!(
14513            status,
14514            StatusCode::SERVICE_UNAVAILABLE,
14515            "an unreachable database must fail the check: {body}"
14516        );
14517        assert!(body.starts_with("FAIL"), "{body}");
14518        // Coarse, not the raw sqlx error: an unauthenticated caller learning
14519        // exactly which failure it hit is an attack-progress oracle, and this
14520        // endpoint is exempt from the origin lock.
14521        assert!(
14522            !body.contains("PoolClosed") && !body.contains("sqlx"),
14523            "health leaked the raw database error to an unauthenticated caller: {body}"
14524        );
14525    }
14526
14527    /// The staleness threshold must track the configured tick.
14528    ///
14529    /// Hardcoded at 15 minutes, an operator who raised
14530    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
14531    /// in the body the deployment docs tell them to alert on.
14532    #[test]
14533    fn the_stale_threshold_follows_the_poll_tick() {
14534        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
14535        // alerting that early would fire on any brief hiccup.
14536        assert_eq!(
14537            health_tick_stale_secs(Duration::from_secs(60)),
14538            HEALTH_TICK_STALE_FLOOR_SECS
14539        );
14540        // A slow tick raises it, so a legitimately-configured loop is never
14541        // permanently "stale".
14542        let slow = Duration::from_secs(30 * 60);
14543        assert!(
14544            health_tick_stale_secs(slow) > slow.as_secs() as i64,
14545            "a 30-minute tick must not be stale after one interval"
14546        );
14547        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
14548        // And it cannot overflow into nonsense on an absurd value.
14549        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
14550    }
14551
14552    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
14553    ///
14554    /// `polling_paused` alone rendered "running" for three different states,
14555    /// including the two where nothing polls at all — on the page added to
14556    /// answer exactly that question.
14557    #[tokio::test]
14558    async fn stats_does_not_call_a_stopped_poller_running() {
14559        let state = test_state(&[]).await;
14560        let render = |state: AppState| async move {
14561            let resp = router(state)
14562                .oneshot(
14563                    Request::builder()
14564                        .uri("/stats")
14565                        .body(Body::empty())
14566                        .unwrap(),
14567                )
14568                .await
14569                .unwrap();
14570            assert_eq!(resp.status(), StatusCode::OK);
14571            String::from_utf8(
14572                axum::body::to_bytes(resp.into_body(), usize::MAX)
14573                    .await
14574                    .unwrap()
14575                    .to_vec(),
14576            )
14577            .unwrap()
14578        };
14579
14580        // Schedulers never started: not "running".
14581        let body = render(state.clone()).await;
14582        assert!(
14583            body.contains("the poller is not running on this instance"),
14584            "a disabled poller renders as healthy"
14585        );
14586
14587        // Started, but no tick has finished yet.
14588        state.runtime_health.set_schedulers_enabled(true);
14589        let body = render(state.clone()).await;
14590        assert!(
14591            body.contains("no poll has finished since this instance booted"),
14592            "a poller that has not ticked renders as healthy"
14593        );
14594
14595        // Ticking: running.
14596        state
14597            .runtime_health
14598            .poll_tick_completed(chrono::Utc::now().timestamp());
14599        let body = render(state.clone()).await;
14600        assert!(
14601            body.contains("running"),
14602            "a healthy poller must read as running"
14603        );
14604
14605        // Paused at the watermark still wins over "running".
14606        state.runtime_health.set_watermark(true);
14607        let body = render(state.clone()).await;
14608        assert!(
14609            body.contains("the cache is at its size limit"),
14610            "a watermark pause is hidden once the poller is ticking"
14611        );
14612    }
14613
14614    /// **Ingest starved of sanitize permits shows on `/stats`** (review of
14615    /// #274). Four hostile feeds can hold every permit; every other poll then
14616    /// defers, storing nothing and filing nothing against its feed, so the
14617    /// failing-feeds rows stay clean while nothing updates. This row is how
14618    /// anyone sees it: absent until a deferral, then the count, and while no
14619    /// permit has come free, "stalled" and since when.
14620    #[tokio::test]
14621    async fn stats_shows_ingest_starved_of_sanitize_permits() {
14622        let mut state = test_state(&[]).await;
14623        let starvation: &'static crate::feed::Starvation =
14624            Box::leak(Box::new(crate::feed::Starvation::new()));
14625        state.sanitize_starvation = starvation;
14626        let render = |state: AppState| async move {
14627            let resp = router(state)
14628                .oneshot(
14629                    Request::builder()
14630                        .uri("/stats")
14631                        .body(Body::empty())
14632                        .unwrap(),
14633                )
14634                .await
14635                .unwrap();
14636            assert_eq!(resp.status(), StatusCode::OK);
14637            String::from_utf8(
14638                axum::body::to_bytes(resp.into_body(), usize::MAX)
14639                    .await
14640                    .unwrap()
14641                    .to_vec(),
14642            )
14643            .unwrap()
14644        };
14645
14646        let body = render(state.clone()).await;
14647        assert!(
14648            !body.contains("no sanitize capacity"),
14649            "the row shows on an instance that never deferred"
14650        );
14651
14652        let ten_min_ago = chrono::Utc::now().timestamp() - 600;
14653        starvation.record_no_permit(ten_min_ago);
14654        starvation.record_no_permit(ten_min_ago + 1);
14655        starvation.record_no_permit(ten_min_ago + 2);
14656        let body = render(state.clone()).await;
14657        assert!(body.contains("3 polls deferred since boot"), "{body}");
14658        assert!(
14659            body.contains(
14660                "<strong>stalled</strong> — no feed body has been sanitized since 10m ago"
14661            ),
14662            "a starved instance does not read as stalled"
14663        );
14664
14665        starvation.record_permit();
14666        let body = render(state).await;
14667        assert!(body.contains("3 polls deferred since boot"));
14668        assert!(
14669            !body.contains("<strong>stalled</strong> — no feed body"),
14670            "a permit came free but /stats still reads stalled"
14671        );
14672    }
14673
14674    /// **An UNMEASURED database must not fail the check.**
14675    ///
14676    /// `/health` is the one path exempt from the Cloudflare origin lock and
14677    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
14678    /// drop WITHOUT recording a verdict — so a cancelled request (a client
14679    /// disconnect is enough) leaves the verdict at "none", and a concurrent
14680    /// caller reads it. Treating that as a failure turned an unauthenticated
14681    /// request into a lever on the only signal the platform acts on. The
14682    /// previous version of this code had the opposite bug and reported `ok` for
14683    /// a database nothing had read; "unknown" is neither.
14684    #[tokio::test]
14685    async fn health_reports_an_unmeasured_database_without_failing() {
14686        use crate::runtime_health::DbProbe;
14687        let state = test_state(&[]).await;
14688
14689        // Hold the probe claim, exactly as an in-flight request would, and never
14690        // record a verdict — the cancelled-request state.
14691        let held = state
14692            .runtime_health
14693            .begin_db_probe()
14694            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
14695
14696        let resp = router(state.clone())
14697            .oneshot(
14698                Request::builder()
14699                    .uri("/health")
14700                    .body(Body::empty())
14701                    .unwrap(),
14702            )
14703            .await
14704            .unwrap();
14705        let status = resp.status();
14706        let body = String::from_utf8(
14707            axum::body::to_bytes(resp.into_body(), usize::MAX)
14708                .await
14709                .unwrap()
14710                .to_vec(),
14711        )
14712        .unwrap();
14713        drop(held);
14714
14715        assert_eq!(
14716            status,
14717            StatusCode::OK,
14718            "an unmeasured database failed the check, which an unauthenticated \
14719             caller can cause on demand: {body}"
14720        );
14721        assert!(
14722            body.contains("db: unknown"),
14723            "the unmeasured state must still be REPORTED: {body}"
14724        );
14725        assert!(!body.starts_with("FAIL"), "{body}");
14726        // **And it must not read as `ok` either.** `fly.toml` tells operators to
14727        // alert on the BODY for everything the status code ignores, so a first
14728        // line identical to the healthy one makes a monitor keying on `^ok` read
14729        // green in exactly the state this enum exists to surface.
14730        assert!(
14731            !body.starts_with("ok"),
14732            "the unmeasured state is indistinguishable from healthy to a \
14733             body-matching monitor: {body}"
14734        );
14735        assert!(body.starts_with("unknown"), "{body}");
14736
14737        // **A BORROWED failure must 503 too.**
14738        //
14739        // This previously recorded `Failed` and then closed the pool — but
14740        // `record` consumes the guard and releases the claim, so the request won
14741        // it, ran a live probe against the closed pool, and failed on its own.
14742        // The 503 passed for the wrong reason and the borrow path — the whole
14743        // point of the three-state enum on the read side — had no coverage.
14744        //
14745        // Holding the claim forces the borrow, so the recorded verdict is what
14746        // gets reported.
14747        let held = state
14748            .runtime_health
14749            .begin_db_probe()
14750            .unwrap_or_else(|_| panic!("claim"));
14751        state
14752            .runtime_health
14753            .record_for_test(DbProbe::Failed("unavailable".to_string()));
14754        let resp = router(state.clone())
14755            .oneshot(
14756                Request::builder()
14757                    .uri("/health")
14758                    .body(Body::empty())
14759                    .unwrap(),
14760            )
14761            .await
14762            .unwrap();
14763        let status = resp.status();
14764        let body = String::from_utf8(
14765            axum::body::to_bytes(resp.into_body(), usize::MAX)
14766                .await
14767                .unwrap()
14768                .to_vec(),
14769        )
14770        .unwrap();
14771        drop(held);
14772        assert_eq!(
14773            status,
14774            StatusCode::SERVICE_UNAVAILABLE,
14775            "a BORROWED failure verdict must fail the check, not just a freshly \
14776             measured one: {body}"
14777        );
14778        assert!(body.starts_with("FAIL"), "{body}");
14779
14780        state.db.close().await;
14781        let resp = router(state.clone())
14782            .oneshot(
14783                Request::builder()
14784                    .uri("/health")
14785                    .body(Body::empty())
14786                    .unwrap(),
14787            )
14788            .await
14789            .unwrap();
14790        assert_eq!(
14791            resp.status(),
14792            StatusCode::SERVICE_UNAVAILABLE,
14793            "a measured database failure must still fail the check"
14794        );
14795    }
14796
14797    /// **A disconnected client must not be able to cancel the probe.**
14798    ///
14799    /// Axum drops the handler future when a caller goes away. With the probe
14800    /// inline that dropped it mid-flight and released the claim WITHOUT
14801    /// recording a verdict — which let an unauthenticated caller manufacture the
14802    /// no-verdict state on demand and freeze what every other caller, including
14803    /// Fly's own check, reads. The probe runs detached now, so the verdict is
14804    /// recorded whatever happens to the request that started it.
14805    #[tokio::test]
14806    async fn an_abandoned_request_still_records_its_probe() {
14807        use crate::runtime_health::DbProbe;
14808        let state = test_state(&[]).await;
14809        let rh = state.runtime_health.clone();
14810
14811        // Drive /health and abandon it immediately — the disconnect case.
14812        let app = router(state.clone());
14813        let fut = app.oneshot(
14814            Request::builder()
14815                .uri("/health")
14816                .body(Body::empty())
14817                .unwrap(),
14818        );
14819        let handle = tokio::spawn(fut);
14820        handle.abort();
14821        let _ = handle.await;
14822
14823        // The detached probe still completes and publishes a verdict, so the
14824        // claim is free and the next caller gets a MEASURED answer.
14825        for _ in 0..50 {
14826            if rh.begin_db_probe().is_ok() {
14827                break;
14828            }
14829            tokio::time::sleep(Duration::from_millis(20)).await;
14830        }
14831        let resp = router(state.clone())
14832            .oneshot(
14833                Request::builder()
14834                    .uri("/health")
14835                    .body(Body::empty())
14836                    .unwrap(),
14837            )
14838            .await
14839            .unwrap();
14840        let body = String::from_utf8(
14841            axum::body::to_bytes(resp.into_body(), usize::MAX)
14842                .await
14843                .unwrap()
14844                .to_vec(),
14845        )
14846        .unwrap();
14847        assert!(
14848            body.contains("db: ok"),
14849            "after an abandoned request the next caller still reads an \
14850             unmeasured database — the probe was cancelled with it: {body}"
14851        );
14852        // Sanity: the type still distinguishes the three states.
14853        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
14854    }
14855
14856    /// **The probe must read a real page.**
14857    ///
14858    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
14859    /// it never touches a b-tree and returns success against a corrupted
14860    /// database. Asserted by asking SQLite what the statement actually compiles
14861    /// to, so it survives someone "simplifying" the query later.
14862    #[tokio::test]
14863    async fn the_health_probe_opens_a_real_table() {
14864        use sqlx::Row;
14865        let state = test_state(&[]).await;
14866        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
14867        let opcodes = |sql: &'static str| {
14868            let db = state.db.clone();
14869            async move {
14870                sqlx::query(sql)
14871                    .fetch_all(&db)
14872                    .await
14873                    .unwrap()
14874                    .into_iter()
14875                    .map(|r| r.get::<String, _>("opcode"))
14876                    .collect::<Vec<String>>()
14877            }
14878        };
14879
14880        // The statement `health_db_probe` really runs — it is the sole path, so
14881        // there is no second string for the handler to use instead.
14882        let explain: &'static str =
14883            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
14884        let probe = opcodes(explain).await;
14885        // And the probe itself works against a real schema.
14886        assert!(
14887            health_db_probe(&state.db).await.is_ok(),
14888            "the probe does not run against the real schema",
14889        );
14890        assert!(
14891            probe.iter().any(|op| op == "OpenRead"),
14892            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
14893        );
14894        // And the bare form genuinely does not, which is the whole point.
14895        let bare = opcodes("EXPLAIN SELECT 1").await;
14896        assert!(
14897            !bare.iter().any(|op| op == "OpenRead"),
14898            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
14899        );
14900    }
14901
14902    /// A fresh instance says "never", not "0" — which would read as "polled
14903    /// just now", the opposite of the truth.
14904    #[test]
14905    fn an_instance_that_has_never_polled_says_so() {
14906        assert_eq!(humanise_ago(None), "never");
14907        assert_eq!(humanise_ago(Some(0)), "0s ago");
14908        assert_eq!(humanise_ago(Some(59)), "59s ago");
14909        assert_eq!(humanise_ago(Some(60)), "1m ago");
14910        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
14911        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
14912    }
14913
14914    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
14915    /// record, and anything else with an empty list. Serves repeatedly.
14916    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
14917        use tokio::io::{AsyncReadExt, AsyncWriteExt};
14918        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14919        let addr = listener.local_addr().unwrap();
14920        let (url, title) = (saved_url.to_string(), saved_title.to_string());
14921        tokio::spawn(async move {
14922            loop {
14923                let Ok((mut sock, _)) = listener.accept().await else {
14924                    break;
14925                };
14926                let mut buf = vec![0u8; 8192];
14927                let Ok(n) = sock.read(&mut buf).await else {
14928                    continue;
14929                };
14930                let req = String::from_utf8_lossy(&buf[..n]).to_string();
14931                let wants_saved = req.contains("community.lexicon.rss.saved");
14932                let records = if wants_saved {
14933                    serde_json::json!([{
14934                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
14935                        "cid": "bafy",
14936                        "value": {
14937                            "$type": "community.lexicon.rss.saved",
14938                            "url": url,
14939                            "title": title,
14940                            "createdAt": "2026-01-01T00:00:00Z"
14941                        }
14942                    }])
14943                } else {
14944                    serde_json::json!([])
14945                };
14946                let body = serde_json::json!({
14947                    "ok": true, "data": { "records": records }
14948                })
14949                .to_string();
14950                let resp = format!(
14951                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
14952                    body.len(), body
14953                );
14954                let _ = sock.write_all(resp.as_bytes()).await;
14955                let _ = sock.flush().await;
14956            }
14957        });
14958        format!("http://{addr}")
14959    }
14960
14961    /// A sidecar mock serving `n` distinct saved records, none of them cached
14962    /// locally — the shape that exercises the uncached-row append.
14963    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
14964        let feed = subscribed_feed.to_string();
14965        use tokio::io::{AsyncReadExt, AsyncWriteExt};
14966        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
14967        let addr = listener.local_addr().unwrap();
14968        tokio::spawn(async move {
14969            loop {
14970                let Ok((mut sock, _)) = listener.accept().await else {
14971                    break;
14972                };
14973                let mut buf = vec![0u8; 8192];
14974                let Ok(read) = sock.read(&mut buf).await else {
14975                    continue;
14976                };
14977                let req = String::from_utf8_lossy(&buf[..read]).to_string();
14978                let records = if req.contains("community.lexicon.rss.saved") {
14979                    serde_json::Value::Array(
14980                        (0..n)
14981                            .map(|i| {
14982                                serde_json::json!({
14983                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
14984                                    "cid": "bafy",
14985                                    "value": {
14986                                        "$type": "community.lexicon.rss.saved",
14987                                        "url": format!("https://elsewhere.example/{i}"),
14988                                        "title": format!("Elsewhere {i}"),
14989                                        "createdAt": "2026-01-01T00:00:00Z"
14990                                    }
14991                                })
14992                            })
14993                            .collect(),
14994                    )
14995                } else if req.contains("community.lexicon.rss.subscription") {
14996                    // Without this the handler's `sync_sub_refs` would REPLACE
14997                    // sub_ref with an empty set on every render, and every
14998                    // sub_ref-scoped read — including the cached starred list
14999                    // this test is about — would come back empty.
15000                    serde_json::json!([{
15001                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
15002                        "cid": "bafy",
15003                        "value": {
15004                            "$type": "community.lexicon.rss.subscription",
15005                            "url": feed,
15006                            "createdAt": "2026-01-01T00:00:00Z"
15007                        }
15008                    }])
15009                } else {
15010                    serde_json::json!([])
15011                };
15012                let body =
15013                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15014                let resp = format!(
15015                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15016                    body.len(), body
15017                );
15018                let _ = sock.write_all(resp.as_bytes()).await;
15019                let _ = sock.flush().await;
15020            }
15021        });
15022        format!("http://{addr}")
15023    }
15024
15025    /// **The pager must not advertise a page the clamp cannot reach.**
15026    ///
15027    /// The page clamp is computed from the CACHED total; the uncached PDS rows
15028    /// are appended to the last page rather than paged. Inflating `total` with
15029    /// them made `page_count` and the "Older →" link point one page past the end:
15030    /// requesting it clamped straight back, re-rendered the same last page, and
15031    /// still offered the link. An infinite "next" that never advances.
15032    #[tokio::test]
15033    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
15034        let did = "did:plc:pagerloop";
15035        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
15036        let state = test_state_with_sidecar(&[], &sidecar).await;
15037        store::grant_access(&state.db, did, None, "test", None)
15038            .await
15039            .unwrap();
15040        let feed = store::upsert_feed(
15041            &state.db,
15042            &store::NewFeed {
15043                url: "https://loop.example/feed.xml".to_string(),
15044                title: Some("Loop".to_string()),
15045                ..Default::default()
15046            },
15047        )
15048        .await
15049        .unwrap();
15050        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
15051        // and the old arithmetic reported a fourth page.
15052        let entries: Vec<store::NewEntry> = (0..250)
15053            .map(|i| store::NewEntry {
15054                guid: format!("s-{i:04}"),
15055                url: Some(format!("https://loop.example/{i}")),
15056                title: Some(format!("Starred {i:04}")),
15057                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
15058                ..Default::default()
15059            })
15060            .collect();
15061        store::insert_entries(&state.db, feed, &entries, 0)
15062            .await
15063            .unwrap();
15064        store::replace_sub_refs(&state.db, did, &[feed])
15065            .await
15066            .unwrap();
15067        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
15068            .await
15069            .unwrap()
15070        {
15071            store::mark_starred(&state.db, did, row.id, true)
15072                .await
15073                .unwrap();
15074        }
15075
15076        let cookie = session_cookie(&state, did, None);
15077        let app = router(state.clone());
15078        let get = |uri: &str| {
15079            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
15080            async move {
15081                let resp = app
15082                    .oneshot(
15083                        Request::builder()
15084                            .uri(uri)
15085                            .header(header::COOKIE, cookie)
15086                            .body(Body::empty())
15087                            .unwrap(),
15088                    )
15089                    .await
15090                    .unwrap();
15091                assert_eq!(resp.status(), StatusCode::OK);
15092                String::from_utf8(
15093                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
15094                        .await
15095                        .unwrap()
15096                        .to_vec(),
15097                )
15098                .unwrap()
15099            }
15100        };
15101
15102        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
15103        // clamp must agree on that, and EVERY page it offers must have content —
15104        // the original bug advertised a fourth page that clamped back to the
15105        // third and re-rendered it, still offering the link.
15106        let p3 = get("/?view=starred&page=3").await;
15107        assert!(
15108            p3.contains("Page 3 of 4"),
15109            "the pager and the clamp disagree on the total: {}",
15110            p3.split("pager-pos")
15111                .nth(1)
15112                .unwrap_or("")
15113                .chars()
15114                .take(120)
15115                .collect::<String>()
15116        );
15117        // Page 3 is the boundary: the last 50 cached rows, then the first 50
15118        // uncached ones.
15119        assert!(
15120            p3.contains("Elsewhere 0"),
15121            "page 3 should start the uncached run"
15122        );
15123        assert_eq!(
15124            p3.matches("<li class=\"entry").count(),
15125            ENTRIES_PER_PAGE as usize,
15126            "the boundary page is not full"
15127        );
15128
15129        // **The heading, which the previous round broke by deleting this.**
15130        //
15131        // `total` includes the uncached records, so the parenthetical is a
15132        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
15133        // The version that said "plus N" double counted once `total` started
15134        // including them, and N had become page-local in the same commit while
15135        // the template stayed put. It shipped because this assertion was deleted
15136        // rather than updated.
15137        {
15138            let body = &p3;
15139            assert!(
15140                body.contains("330 entries"),
15141                "the heading must count the whole sequence: {}",
15142                body.split("content-count")
15143                    .nth(1)
15144                    .unwrap_or("")
15145                    .chars()
15146                    .take(120)
15147                    .collect::<String>()
15148            );
15149            assert!(
15150                body.contains("(80 saved elsewhere)"),
15151                "the heading must say how many of the total the cache cannot show, \
15152                 as a whole-list figure and not a per-page one: {}",
15153                body.split("content-count")
15154                    .nth(1)
15155                    .unwrap_or("")
15156                    .chars()
15157                    .take(120)
15158                    .collect::<String>()
15159            );
15160            assert!(
15161                !body.contains("plus 50") && !body.contains("plus 80"),
15162                "the heading is adding the uncached rows to a total that already \
15163                 includes them"
15164            );
15165        }
15166
15167        let p4 = get("/?view=starred&page=4").await;
15168        assert!(
15169            p4.contains("Page 4 of 4"),
15170            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
15171        );
15172        assert_eq!(
15173            p4.matches("<li class=\"entry").count(),
15174            30,
15175            "page 4 should hold the remaining 30 uncached records"
15176        );
15177        assert!(
15178            p4.contains("Elsewhere 79"),
15179            "the LAST saved record is unreachable — it can only be removed from here"
15180        );
15181
15182        // No uncached record appears on two pages.
15183        assert!(
15184            !p4.contains("Elsewhere 0"),
15185            "an uncached record was rendered on more than one page"
15186        );
15187        // Page 1 is all cached — and still reports the same whole-list heading,
15188        // because the parenthetical describes the LIST, not the page.
15189        let first = get("/?view=starred").await;
15190        assert!(
15191            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
15192            "the heading changed between pages; it describes the list, not the page"
15193        );
15194        assert!(
15195            !first.contains("Elsewhere "),
15196            "uncached saved records leaked onto the first page"
15197        );
15198    }
15199
15200    /// **A saved record whose article is not cached here is still shown.**
15201    ///
15202    /// The starred view is built from local `entries`, so before this a record
15203    /// starred in ANOTHER atproto reader — the portability the shared lexicon
15204    /// exists for — was simply invisible. It now renders from the PDS record,
15205    /// visually distinct, linking straight out.
15206    #[tokio::test]
15207    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
15208        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
15209        let sidecar =
15210            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
15211        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
15212        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
15213
15214        let resp = router(state)
15215            .oneshot(
15216                Request::builder()
15217                    .uri("/?view=starred")
15218                    .body(Body::empty())
15219                    .unwrap(),
15220            )
15221            .await
15222            .unwrap();
15223        assert_eq!(resp.status(), StatusCode::OK);
15224        let body = String::from_utf8(
15225            axum::body::to_bytes(resp.into_body(), usize::MAX)
15226                .await
15227                .unwrap()
15228                .to_vec(),
15229        )
15230        .unwrap();
15231
15232        assert!(
15233            body.contains("Starred elsewhere"),
15234            "the saved record was not rendered at all"
15235        );
15236        assert!(
15237            body.contains("entry-uncached"),
15238            "it was not marked as uncached, so it looks like a normal entry"
15239        );
15240        assert!(
15241            body.contains("https://elsewhere.example/article"),
15242            "the row must link straight to the article"
15243        );
15244        assert!(
15245            !body.contains("/entries/0/"),
15246            "an uncached row must not offer entry actions against a nonexistent id"
15247        );
15248    }
15249
15250    /// **A PDS `createdAt` must not be able to panic the starred view.**
15251    ///
15252    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
15253    /// timestamp the feed parser produced; the saved-record path passes a bare
15254    /// string off a PDS record, written by whatever client the reader used. A
15255    /// multi-byte value panicked the handler, and with no catch-panic layer the
15256    /// view stayed down until the record was removed — from that same view.
15257    #[test]
15258    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
15259        for hostile in [
15260            "日本語日本語日本",
15261            "é",
15262            "",
15263            "2026",
15264            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
15265        ] {
15266            let out = display_date(Some(hostile));
15267            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
15268        }
15269        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
15270        assert_eq!(display_date(None), "");
15271    }
15272
15273    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
15274    /// its neighbours are limited. It was added as a route and not added here.
15275    #[test]
15276    fn the_unsave_route_is_rate_limited() {
15277        use axum::http::Method;
15278        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
15279        // And the neighbours still are.
15280        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
15281    }
15282
15283    /// **The probe detects a broken database — asserted through `/health`
15284    /// itself, not through a string.**
15285    ///
15286    /// A named constant did not bind the handler: it stayed free to call
15287    /// `query_scalar` with a different literal, so degrading the real probe to
15288    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
15289    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
15290    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
15291    #[tokio::test]
15292    async fn health_reports_a_broken_database() {
15293        let state = test_state(&[]).await;
15294        // Sanity: healthy first, so the assertion below is about the damage.
15295        assert!(
15296            health_db_probe(&state.db).await.is_ok(),
15297            "the fixture was not healthy to begin with",
15298        );
15299
15300        sqlx::query("DROP TABLE feeds")
15301            .execute(&state.db)
15302            .await
15303            .unwrap();
15304
15305        assert!(
15306            health_db_probe(&state.db).await.is_err(),
15307            "the probe reported success against a database missing the table it \
15308             claims to read; `SELECT 1` would do exactly this",
15309        );
15310
15311        let resp = router(state)
15312            .oneshot(
15313                Request::builder()
15314                    .uri("/health")
15315                    .body(Body::empty())
15316                    .unwrap(),
15317            )
15318            .await
15319            .unwrap();
15320        let body = String::from_utf8(
15321            axum::body::to_bytes(resp.into_body(), usize::MAX)
15322                .await
15323                .unwrap()
15324                .to_vec(),
15325        )
15326        .unwrap();
15327        // The documented contract: the FIRST token is the state.
15328        assert!(
15329            body.starts_with("FAIL"),
15330            "/health did not report FAIL for a broken database: {body}",
15331        );
15332        assert!(
15333            !body.contains("db: ok"),
15334            "/health still called the database ok: {body}",
15335        );
15336    }
15337
15338    /// A sidecar mock for the OPML export: serves one subscription and one
15339    /// folder, except for the collection named in `fail_on`, which answers
15340    /// `500` — the shape a refused (short or unreadable) walk takes at this
15341    /// boundary.
15342    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
15343        use tokio::io::{AsyncReadExt, AsyncWriteExt};
15344        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15345        let addr = listener.local_addr().unwrap();
15346        tokio::spawn(async move {
15347            loop {
15348                let Ok((mut sock, _)) = listener.accept().await else {
15349                    break;
15350                };
15351                let mut buf = vec![0u8; 8192];
15352                let Ok(n) = sock.read(&mut buf).await else {
15353                    continue;
15354                };
15355                let req = String::from_utf8_lossy(&buf[..n]).to_string();
15356                let wants = |c: &str| req.contains(c);
15357                if fail_on.is_some_and(wants) {
15358                    let body = r#"{"ok":false,"error":"ShortList"}"#;
15359                    let resp = format!(
15360                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15361                        body.len(),
15362                        body
15363                    );
15364                    let _ = sock.write_all(resp.as_bytes()).await;
15365                    let _ = sock.flush().await;
15366                    continue;
15367                }
15368                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
15369                    serde_json::json!([{
15370                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
15371                        "cid": "bafy",
15372                        "value": {
15373                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
15374                            "url": "https://kept.example/feed.xml",
15375                            "title": "Kept",
15376                            // Inside the folder, so the healthy export has to
15377                            // carry BOTH walks' results: an exporter that lost
15378                            // the folder list would flatten this outline out of
15379                            // its group with nothing else changing.
15380                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15381                            "createdAt": "2026-01-01T00:00:00Z"
15382                        }
15383                    }])
15384                } else if wants(crate::lexicon::nsid::FOLDER) {
15385                    serde_json::json!([{
15386                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
15387                        "cid": "bafy",
15388                        "value": {
15389                            "$type": crate::lexicon::nsid::FOLDER,
15390                            "name": "Kept folder",
15391                            "createdAt": "2026-01-01T00:00:00Z"
15392                        }
15393                    }])
15394                } else {
15395                    serde_json::json!([])
15396                };
15397                let body =
15398                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
15399                let resp = format!(
15400                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15401                    body.len(),
15402                    body
15403                );
15404                let _ = sock.write_all(resp.as_bytes()).await;
15405                let _ = sock.flush().await;
15406            }
15407        });
15408        format!("http://{addr}")
15409    }
15410
15411    /// A sidecar whose every `listRecords` page carries one good record and
15412    /// one with no `uri` — the #177 shape — for any collection.
15413    async fn spawn_malformed_sidecar() -> String {
15414        use tokio::io::{AsyncReadExt, AsyncWriteExt};
15415        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
15416        let addr = listener.local_addr().unwrap();
15417        tokio::spawn(async move {
15418            loop {
15419                let Ok((mut sock, _)) = listener.accept().await else {
15420                    break;
15421                };
15422                let mut buf = vec![0u8; 8192];
15423                let _ = sock.read(&mut buf).await;
15424                let body = serde_json::json!({ "ok": true, "data": { "records": [
15425                    { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
15426                    { "cid": "bafy", "value": {} },
15427                ]}})
15428                .to_string();
15429                let resp = format!(
15430                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15431                    body.len(),
15432                    body
15433                );
15434                let _ = sock.write_all(resp.as_bytes()).await;
15435                let _ = sock.flush().await;
15436            }
15437        });
15438        format!("http://{addr}")
15439    }
15440
15441    async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
15442        let cookie = session_cookie(&state, did, None);
15443        let resp = router(state)
15444            .oneshot(
15445                Request::builder()
15446                    .uri(uri)
15447                    .header(header::COOKIE, cookie)
15448                    .body(Body::empty())
15449                    .unwrap(),
15450            )
15451            .await
15452            .unwrap();
15453        let status = resp.status();
15454        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
15455            .await
15456            .unwrap();
15457        (status, String::from_utf8_lossy(&body).to_string())
15458    }
15459
15460    /// **0.4.0 step 4: a publication document with neither summary field
15461    /// renders as a title, a date and a link** — 8% of measured documents
15462    /// (37 of 449) carry neither `description` nor `textContent`. That is what
15463    /// an RSS reader shows for a title-only feed, not an error, in the list and
15464    /// on the article page alike.
15465    #[tokio::test]
15466    async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
15467        let did = "did:plc:displayer";
15468        let state = test_state(&[did]).await;
15469        let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
15470        let feed_id = store::upsert_feed(
15471            &state.db,
15472            &store::NewFeed {
15473                url: url.into(),
15474                title: Some("Quiet Journal".into()),
15475                ..Default::default()
15476            },
15477        )
15478        .await
15479        .unwrap();
15480        store::replace_sub_refs(&state.db, did, &[feed_id])
15481            .await
15482            .unwrap();
15483        store::insert_entries(
15484            &state.db,
15485            feed_id,
15486            &[store::NewEntry {
15487                guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
15488                    .into(),
15489                url: Some("https://quiet.example/no-summary".into()),
15490                title: Some("A title-only article".into()),
15491                published: Some("2026-07-11T00:00:00Z".into()),
15492                content_html: None,
15493                ..Default::default()
15494            }],
15495            0,
15496        )
15497        .await
15498        .unwrap();
15499        let (status, list) = page_body(state.clone(), did, "/?view=all").await;
15500        assert_eq!(status, StatusCode::OK);
15501        assert!(
15502            list.contains("A title-only article"),
15503            "the entry is missing from the list"
15504        );
15505
15506        let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
15507            .bind(feed_id)
15508            .fetch_one(&state.db)
15509            .await
15510            .unwrap();
15511        let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
15512        assert_eq!(
15513            status,
15514            StatusCode::OK,
15515            "the article page failed for an entry with no body"
15516        );
15517        assert!(page.contains("A title-only article"));
15518        assert!(
15519            page.contains("https://quiet.example/no-summary"),
15520            "no link to the original"
15521        );
15522        assert!(
15523            page.contains(r#"<time datetime=""#),
15524            "no date on the article page"
15525        );
15526    }
15527
15528    /// **#177: a malformed record in the reader's own repo is refused, and the
15529    /// reader is told.** Refusing keeps `replace_sub_refs` from dropping the
15530    /// subscription that record was; telling them keeps the stale list from
15531    /// looking like the real one. Both the reading page and the manage page.
15532    #[tokio::test]
15533    async fn a_malformed_subscription_record_raises_an_alert() {
15534        let did = "did:plc:alerted";
15535        for page in ["/", "/manage"] {
15536            let sidecar = spawn_malformed_sidecar().await;
15537            let state = test_state_with_sidecar(&[did], &sidecar).await;
15538            let (status, body) = page_body(state, did, page).await;
15539            assert_eq!(status, StatusCode::OK, "{page} did not render");
15540            assert!(
15541                body.contains(r#"role="alert""#) && body.contains("could not be read"),
15542                "{page} rendered no alert for a refused subscription list"
15543            );
15544            assert!(
15545                body.contains("1 record(s) in your subscription list"),
15546                "{page} gave the generic alert, not the malformed-record one"
15547            );
15548        }
15549    }
15550
15551    /// The control: a healthy listing raises no alert.
15552    #[tokio::test]
15553    async fn a_healthy_subscription_listing_raises_no_alert() {
15554        let did = "did:plc:exporter";
15555        let sidecar = spawn_export_sidecar(None).await;
15556        let state = test_state_with_sidecar(&[did], &sidecar).await;
15557        let (status, body) = page_body(state, did, "/").await;
15558        assert_eq!(status, StatusCode::OK);
15559        assert!(
15560            !body.contains("could not be read"),
15561            "a healthy listing raised an alert"
15562        );
15563    }
15564
15565    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
15566    async fn export_opml_response(
15567        fail_on: Option<&'static str>,
15568    ) -> (StatusCode, HeaderMap, String) {
15569        let did = "did:plc:exporter";
15570        let sidecar = spawn_export_sidecar(fail_on).await;
15571        let state = test_state_with_sidecar(&[did], &sidecar).await;
15572        let cookie = session_cookie(&state, did, None);
15573        let resp = router(state)
15574            .oneshot(
15575                Request::builder()
15576                    .uri("/opml/export")
15577                    .header(header::COOKIE, cookie)
15578                    .body(Body::empty())
15579                    .unwrap(),
15580            )
15581            .await
15582            .unwrap();
15583        let status = resp.status();
15584        let headers = resp.headers().clone();
15585        let body = String::from_utf8_lossy(
15586            &axum::body::to_bytes(resp.into_body(), usize::MAX)
15587                .await
15588                .unwrap(),
15589        )
15590        .to_string();
15591        (status, headers, body)
15592    }
15593
15594    /// **An empty export is worse than no export, and this is the caller that
15595    /// used to produce one.**
15596    ///
15597    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
15598    /// truncated walk refuses instead of returning a short list, that turned the
15599    /// refusal into `200 OK` carrying a zero-feed
15600    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
15601    /// the moment a locked-out reader reached for one, and the changelog points
15602    /// them at this route as the recovery path.
15603    ///
15604    /// Asserts the three things a reader can actually observe: no success status,
15605    /// no download offered, and no OPML document in the body.
15606    #[tokio::test]
15607    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
15608        let (status, headers, body) =
15609            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
15610
15611        assert_ne!(
15612            status,
15613            StatusCode::OK,
15614            "a failed subscription walk answered 200: {body}",
15615        );
15616        assert!(
15617            !headers.contains_key(header::CONTENT_DISPOSITION),
15618            "a failed subscription walk still offered a download: {headers:?}",
15619        );
15620        assert!(
15621            !body.contains("<opml"),
15622            "a failed subscription walk still served an OPML document: {body}",
15623        );
15624    }
15625
15626    /// The folders half of the same hole. The two walks are separate calls, and
15627    /// fixing only the first leaves an export that silently loses every folder —
15628    /// a flat list that reimports as one, with no sign anything was lost.
15629    #[tokio::test]
15630    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
15631        let (status, headers, body) =
15632            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
15633
15634        assert_ne!(
15635            status,
15636            StatusCode::OK,
15637            "a failed folder walk answered 200: {body}",
15638        );
15639        assert!(
15640            !headers.contains_key(header::CONTENT_DISPOSITION),
15641            "a failed folder walk still offered a download: {headers:?}",
15642        );
15643        assert!(
15644            !body.contains("<opml"),
15645            "a failed folder walk still served an OPML document: {body}",
15646        );
15647    }
15648
15649    /// The other direction, without which "refuse everything" would pass both
15650    /// tests above: a healthy read still serves the file, with the feed in it.
15651    #[tokio::test]
15652    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
15653        let (status, headers, body) = export_opml_response(None).await;
15654
15655        assert_eq!(
15656            status,
15657            StatusCode::OK,
15658            "a healthy export did not answer 200"
15659        );
15660        assert_eq!(
15661            headers
15662                .get(header::CONTENT_DISPOSITION)
15663                .and_then(|v| v.to_str().ok()),
15664            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
15665            "a healthy export did not offer the download",
15666        );
15667        assert!(
15668            body.contains("https://kept.example/feed.xml"),
15669            "the exported OPML lost the subscription: {body}",
15670        );
15671        assert!(
15672            body.contains("Kept folder"),
15673            "the exported OPML lost the folder: {body}",
15674        );
15675    }
15676}