Skip to main content

feather_reader/
web.rs

1//! The axum web layer — server-rendered HTML + a dash of htmx, **no SPA**.
2//!
3//! This module owns the HTTP surface: [`router`] builds an [`axum::Router`] over
4//! the shared [`AppState`], wiring the store, feed, atproto, and config seams into
5//! a small set of typography-first, dark-mode-ready views rendered with
6//! [`askama`] templates (under `templates/`). Progressive enhancement is a single
7//! vendored `htmx` script plus a tiny keyboard handler (`static/keyboard.js`);
8//! every interaction also works as a plain HTML form POST, so the reader is fully
9//! usable with JavaScript disabled.
10//!
11//! ## HTTP surface
12//!
13//! * `GET  /health` — liveness + version, as `text/plain`.
14//! * `GET  /` — the reader: a folders/feeds sidebar (from the PDS records layer)
15//!   plus the main article list. Query params pick the scope (`?feed=…` /
16//!   `?folder=…` / all) and the view (`?view=unread|all|starred`).
17//! * `GET  /entries/{id}` — the clean, distraction-free reader for one entry,
18//!   with prev/next within the current list.
19//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
20//! * `POST /entries/{id}/star` — star/unstar; writes a
21//!   `community.lexicon.rss.saved` record to the user's PDS.
22//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
23//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
24//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
25//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
26//! * `POST /folders` — create a folder record.
27//! * `POST /folders/{rkey}/rename` — rename a folder record.
28//! * `POST /folders/{rkey}/delete` — delete a folder record.
29//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
30//!   subscription records in the PDS.
31//! * `GET  /opml/export` — OPML export (records → a downloadable document).
32//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
33//!   OAuth sign-in flow (routed through the sidecar).
34//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
35//!   reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
36//!   (sets the reserving cookie → `/login`).
37//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
38//!   code + token/url for the bot to post. Cap-aware (409 when full).
39//!
40//! ## Identity — a cookie-resolved atproto session
41//!
42//! Per-request identity comes from a **signed session cookie** (`fr_session`)
43//! keyed by the logged-in DID, set by [`oauth_callback`] and read by
44//! [`current_session`] / [`current_did`]. For local runs without the sidecar,
45//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
46//! All PDS writes route through the [`crate::atproto::SidecarClient`]; a live-PDS
47//! write needs a real OAuth session, but the full write path is built and unit-
48//! tested to the sidecar boundary.
49
50use std::collections::HashMap;
51use std::net::IpAddr;
52use std::sync::Mutex;
53use std::time::{Duration, Instant};
54
55use askama::Template;
56use axum::{
57    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
58    http::{header, HeaderMap, StatusCode},
59    middleware::{self, Next},
60    response::{Html, IntoResponse, Redirect, Response},
61    routing::{get, post},
62    Form, Router,
63};
64use serde::Deserialize;
65use std::net::SocketAddr;
66use tower_http::services::{ServeDir, ServeFile};
67use tower_http::set_header::SetResponseHeaderLayer;
68use tower_http::trace::TraceLayer;
69use tracing::{info, warn};
70
71use crate::config::Config;
72use crate::lexicon::{self, Folder, Saved, Subscription};
73use crate::{feed, store, AppState, Session, VERSION};
74
75// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
76// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
77// here via an explicit path so the reader's OPML routes can use the canonical
78// `parse_opml` / `to_opml` without duplicating that logic.
79#[path = "opml.rs"]
80mod opml;
81
82/// The name of the signed session cookie.
83const SESSION_COOKIE: &str = "fr_session";
84
85/// The name of the short-lived signed **invite** cookie.
86///
87/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
88/// OAuth callback. It reserves *intent* to redeem a specific code before the
89/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
90/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
91/// signed with the same key as the session cookie.
92const INVITE_COOKIE: &str = "fr_invite";
93
94/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
95/// Short enough that a reserved-but-unclaimed seat frees quickly.
96const INVITE_TTL_SECS: i64 = 1800;
97
98/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
99/// sign-in pitch, and `/about`.
100const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
101
102/// The tip / support link (cloud plan public-experiment UI).
103const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
104
105/// The published crate on crates.io — surfaced on the signed-out landing page.
106const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
107
108/// The Content-Security-Policy applied to every response.
109///
110/// Tuned to keep the app fully working while neutralising injected script:
111/// * `default-src 'self'` — same-origin baseline.
112/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
113///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
114///   `javascript:` href (F4) cannot execute. (The design's templates carry no
115///   inline event handlers — every control is wired in `keyboard.js`.)
116/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
117///   inline styles htmx toggles for its request indicators.
118/// * `img-src 'self' https: data:` — feed content routinely embeds remote
119///   images; allow https + data URIs but not other schemes.
120/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
121///   down form posts, `<base>` hijacking, and clickjacking.
122/// * `object-src 'none'` — no plugins.
123const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
124     script-src 'self'; \
125     style-src 'self' 'unsafe-inline'; \
126     img-src 'self' https: data:; \
127     font-src 'self'; \
128     connect-src 'self'; \
129     form-action 'self'; \
130     base-uri 'self'; \
131     frame-ancestors 'none'; \
132     object-src 'none'";
133
134/// The resolved identity for the current request.
135///
136/// `did` is the primary key for all per-user local state; `handle` is display
137/// only; `sid` is the opaque server-side session id the cookie carried (needed
138/// so logout can revoke exactly this session). Sourced from the signed cookie
139/// (real login) or, if none, the configured dev DID fallback.
140#[derive(Clone, Debug)]
141struct CurrentUser {
142    did: String,
143    handle: Option<String>,
144    /// The opaque session id, if this identity came from a real cookie session
145    /// (absent for the dev-DID fallback, which has no server-side session row).
146    sid: Option<String>,
147}
148
149/// Resolve the current request's session from the signed cookie, falling back to
150/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
151///
152/// The cookie carries an opaque server-minted session id (not the DID). We
153/// verify its HMAC, look the id up in the registry, and — crucially —
154/// **re-check the DID against the closed-beta gate on every request**
155/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
156/// DID's beta seat takes effect immediately for already-issued cookies. (The
157/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
158/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
159async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
160    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
161        if let Some(session) = state.sessions.get(&sid) {
162            if store::has_beta_access(&state.db, &session.did)
163                .await
164                .unwrap_or(false)
165            {
166                return Some(CurrentUser {
167                    did: session.did,
168                    handle: session.handle,
169                    sid: Some(sid),
170                });
171            }
172            // DID no longer holds a beta seat: treat as logged out (and drop the
173            // stale server-side session so the dead cookie can't linger).
174            state.sessions.remove(&sid);
175        }
176    }
177    // No valid cookie: dev fallback only if explicitly configured *and* still
178    // inside the beta gate (seeded via ensure_seed / a redeemed code).
179    if let Some(did) = state.config.dev_did.clone() {
180        if store::has_beta_access(&state.db, &did)
181            .await
182            .unwrap_or(false)
183        {
184            return Some(CurrentUser {
185                did,
186                handle: None,
187                sid: None,
188            });
189        }
190    }
191    None
192}
193
194/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
195async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
196    current_session(state, headers).await.map(|u| u.did)
197}
198
199/// Build the application router over shared [`AppState`].
200///
201/// Wires the reader routes, the health check, and the `/static` asset mount
202/// (the stylesheet, vendored htmx, and the keyboard handler, served from
203/// `static/` via [`ServeDir`]). A [`TraceLayer`] gives per-request tracing.
204pub fn router(state: AppState) -> Router {
205    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
206    // and the write endpoints). One instance is cloned into the state closure of
207    // the `rate_limit` middleware.
208    let limiter = RateLimiter::shared();
209    // The trusted client-IP source for the limiter (a proxy header the operator
210    // controls, or the socket peer when unset). Bundled with the limiter so the
211    // middleware derives a spoof-resistant IP.
212    let rl_state = RateLimitState {
213        limiter,
214        trusted_header: state.config.trusted_ip_header.clone(),
215    };
216
217    Router::new()
218        .route("/health", get(health))
219        .route("/about", get(about))
220        .route("/manage", get(manage))
221        .route("/", get(index))
222        .route("/entries/{id}", get(entry_view))
223        .route("/entries/{id}/read", post(mark_read))
224        .route("/entries/{id}/star", post(toggle_star))
225        .route("/read-all", post(mark_all_read))
226        .route("/subscriptions", post(add_subscription))
227        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
228        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
229        .route("/folders", post(create_folder))
230        .route("/folders/{rkey}/rename", post(rename_folder))
231        .route("/folders/{rkey}/delete", post(delete_folder))
232        // OPML import takes untrusted uploads: cap the body so a huge upload
233        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
234        .route(
235            "/opml",
236            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
237        )
238        .route("/opml/export", get(export_opml))
239        .route("/login", get(login_form).post(login_submit))
240        .route(
241            "/beta/redeem",
242            get(beta_redeem_form).post(beta_redeem_submit),
243        )
244        // The follow→invite bot's claim link: a public skeet points a new
245        // follower here with an opaque token that reserves a pre-minted code.
246        .route("/claim", get(claim))
247        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
248        // code + returns its token/url for the bot to post.
249        .route("/bot/claims", post(bot_mint_claim))
250        .route("/admin/invites", post(admin_mint_invites))
251        .route("/account/delete", post(account_delete))
252        .route("/oauth/callback", get(oauth_callback))
253        .route("/logout", post(logout))
254        .nest_service("/static", ServeDir::new("static"))
255        // Browsers (and some feed clients) request /favicon.ico at the root
256        // regardless of the <link rel="icon"> tags; serve the same icon that
257        // lives under /static so the bare path stops 404-ing.
258        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
259        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
260        // logged-out landing + static assets, `no-store` on anything that
261        // rendered a session's private view. Runs *inside* the security layers so
262        // the CSP/nosniff/frame headers are untouched.
263        .layer(middleware::from_fn(cache_control))
264        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
265        // as a middleware so it sees the matched path + the peer IP.
266        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
267        .layer(TraceLayer::new_for_http())
268        // Baseline security headers on *every* response (F4). The CSP is the
269        // backstop that neutralises any XSS that slips past sanitization; the
270        // others harden sniffing, framing, and referrer leakage.
271        .layer(static_header_layer(
272            "content-security-policy",
273            CONTENT_SECURITY_POLICY,
274        ))
275        .layer(static_header_layer("x-content-type-options", "nosniff"))
276        .layer(static_header_layer(
277            "referrer-policy",
278            "strict-origin-when-cross-origin",
279        ))
280        .layer(static_header_layer("x-frame-options", "DENY"))
281        .with_state(state)
282}
283
284/// Body-size ceiling for the OPML import upload (~2 MiB). Large enough for any
285/// realistic subscription list, small enough to make an OOM upload impossible.
286const OPML_BODY_LIMIT: usize = 2 * 1024 * 1024;
287
288/// A response-header layer that sets `name: value` on every response, overriding
289/// any existing header of that name. `name`/`value` must be valid static header
290/// tokens (they are, for our fixed security headers).
291fn static_header_layer(
292    name: &'static str,
293    value: &'static str,
294) -> SetResponseHeaderLayer<header::HeaderValue> {
295    SetResponseHeaderLayer::overriding(
296        header::HeaderName::from_static(name),
297        header::HeaderValue::from_static(value),
298    )
299}
300
301// ---------------------------------------------------------------------------
302// Per-IP rate limiting (token bucket, self-contained — no extra crate)
303// ---------------------------------------------------------------------------
304
305/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
306/// kick-off, the invite redeem, the mutating write endpoints, and mark-read/star
307/// /mark-all. Read-only navigation is intentionally *not* limited.
308fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
309    use axum::http::Method;
310    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
311    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
312    // like the other abuse-prone entry points — not just `/login`.
313    if method != Method::POST && !(method == Method::GET && (path == "/login" || path == "/claim"))
314    {
315        return false;
316    }
317    match path {
318        "/login" | "/claim" | "/beta/redeem" | "/subscriptions" | "/opml" | "/read-all"
319        | "/admin/invites" | "/bot/claims" | "/account/delete" | "/folders" => true,
320        // Every per-record subscription/folder mutation (delete/rename) and the
321        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
322        p => {
323            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
324                || p.starts_with("/subscriptions/")
325                || p.starts_with("/folders/")
326        }
327    }
328}
329
330/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
331/// client-IP header (if any). Cloned into every request; both fields are cheap.
332#[derive(Clone)]
333struct RateLimitState {
334    limiter: RateLimiter,
335    /// The lowercased proxy header the operator trusts for the client IP, or
336    /// `None` to trust only the socket peer. See [`client_ip`].
337    trusted_header: Option<String>,
338}
339
340/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
341/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
342/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
343/// dependency → no network fetch at build, deterministic offline CI).
344#[derive(Clone)]
345struct RateLimiter {
346    inner: std::sync::Arc<Mutex<HashMap<IpAddr, Bucket>>>,
347}
348
349/// One IP's token bucket: a fractional token count + the last-refill instant.
350struct Bucket {
351    tokens: f64,
352    last: Instant,
353}
354
355/// Burst capacity per IP — how many requests can arrive back-to-back.
356const RATE_BURST: f64 = 20.0;
357/// Steady-state refill rate (tokens/sec) once the burst is spent.
358const RATE_REFILL_PER_SEC: f64 = 1.0;
359/// Evict idle buckets older than this so the map can't grow unbounded.
360const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
361
362impl RateLimiter {
363    /// A fresh, shared limiter (cloned into the middleware state).
364    fn shared() -> Self {
365        Self {
366            inner: std::sync::Arc::new(Mutex::new(HashMap::new())),
367        }
368    }
369
370    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
371    /// bucket is empty (→ 429).
372    fn check(&self, ip: IpAddr) -> bool {
373        let now = Instant::now();
374        let mut map = match self.inner.lock() {
375            Ok(m) => m,
376            // A poisoned lock shouldn't take the site down — fail open.
377            Err(p) => p.into_inner(),
378        };
379        // Opportunistic eviction of long-idle buckets (cheap, amortised).
380        map.retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
381
382        let bucket = map.entry(ip).or_insert(Bucket {
383            tokens: RATE_BURST,
384            last: now,
385        });
386        let elapsed = now.duration_since(bucket.last).as_secs_f64();
387        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
388        bucket.last = now;
389        if bucket.tokens >= 1.0 {
390            bucket.tokens -= 1.0;
391            true
392        } else {
393            false
394        }
395    }
396}
397
398/// The **trusted** client IP for a request.
399///
400/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
401/// is fully bypassable — the left-most value is attacker-supplied (any client
402/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
403/// bucket and the per-IP limit never bites. We therefore derive the IP only from
404/// a source the operator controls:
405///
406/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
407///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
408///   set by the proxy we run in front and overwrites any client-supplied copy.
409///   We take the LAST value if the header happens to be a comma list (the hop
410///   the trusted proxy appended), which is also the correct read for a
411///   right-most-`X-Forwarded-For` deployment where the operator points
412///   `trusted_header` at `x-forwarded-for`.
413/// * Otherwise we ignore all forwarding headers and use the socket peer
414///   (`ConnectInfo`) — correct for a direct bind with no proxy.
415///
416/// Returns `None` only when neither source yields a parseable IP (the limiter
417/// then fails open for that one request).
418fn client_ip(
419    headers: &HeaderMap,
420    conn: Option<&SocketAddr>,
421    trusted_header: Option<&str>,
422) -> Option<IpAddr> {
423    if let Some(name) = trusted_header {
424        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
425            // Right-most hop is the one the trusted proxy appended; earlier
426            // entries may be client-forged, so never trust the left-most.
427            if let Some(last) = raw.split(',').next_back() {
428                if let Ok(ip) = last.trim().parse::<IpAddr>() {
429                    return Some(ip);
430                }
431            }
432        }
433        // Trusted header absent/unparseable → fall through to the socket peer.
434    }
435    conn.map(|s| s.ip())
436}
437
438/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
439/// empty; every other request (and every non-guarded path) passes through. The
440/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
441/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
442async fn rate_limit(
443    State(rl): State<RateLimitState>,
444    req: axum::extract::Request,
445    next: Next,
446) -> Response {
447    let path = req.uri().path().to_string();
448    let method = req.method().clone();
449    if is_rate_limited_path(&path, &method) {
450        let conn = req
451            .extensions()
452            .get::<ConnectInfo<SocketAddr>>()
453            .map(|c| c.0);
454        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
455        // Deliberately fail OPEN when no client IP is derivable (no trusted
456        // header / no socket peer): there is no per-IP key to enforce, and a
457        // blanket 429 would self-DoS every guarded path (incl. /login). This is
458        // safe precisely because we never key on an attacker-forged XFF — see
459        // `rate_limit_ignores_spoofed_xff_rotation`.
460        if let Some(ip) = ip {
461            if !rl.limiter.check(ip) {
462                warn!(%ip, %path, "rate limit exceeded");
463                return (
464                    StatusCode::TOO_MANY_REQUESTS,
465                    [(header::RETRY_AFTER, "1")],
466                    "rate limit exceeded\n",
467                )
468                    .into_response();
469            }
470        }
471    }
472    next.run(req).await
473}
474
475// ---------------------------------------------------------------------------
476// Cache-Control (viral / CDN vs. private authenticated views)
477// ---------------------------------------------------------------------------
478
479/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
480/// logged-out surfaces (the `/login` landing without a handle, `/about`, and the
481/// `/static/*` assets) and `no-store` on the authenticated app pages, so a CDN /
482/// browser can hold the viral landing while never caching a signed-in user's
483/// private view. Never overrides a handler that already set Cache-Control.
484async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
485    let path = req.uri().path().to_string();
486    // The logged-out landing is only cacheable when it's the bare form — a
487    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
488    let is_login_landing = path == "/login"
489        && req.method() == axum::http::Method::GET
490        && !req.uri().query().unwrap_or("").contains("handle=");
491    let public = is_login_landing || path == "/about" || path.starts_with("/static/");
492
493    let mut resp = next.run(req).await;
494    if resp.headers().contains_key(header::CACHE_CONTROL) {
495        return resp;
496    }
497    let value = if public {
498        "public, max-age=300"
499    } else {
500        "no-store"
501    };
502    if let Ok(hv) = header::HeaderValue::from_str(value) {
503        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
504    }
505    resp
506}
507
508// ---------------------------------------------------------------------------
509// Health
510// ---------------------------------------------------------------------------
511
512/// `GET /health` — a cheap liveness probe returning `200 ok` + the crate version.
513async fn health() -> impl IntoResponse {
514    (StatusCode::OK, format!("ok featherreader/{VERSION}\n"))
515}
516
517/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
518/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link. A
519/// static render; readable whether or not a session exists.
520async fn about() -> Response {
521    render(&AboutTemplate {
522        version: VERSION,
523        repo_url: REPO_URL,
524        kofi_url: KOFI_URL,
525    })
526}
527
528// ---------------------------------------------------------------------------
529// View models
530// ---------------------------------------------------------------------------
531
532/// A feed as shown in the sidebar (title + its unread count + a stable scope key
533/// and the PDS subscription rkey for management actions).
534struct FeedView {
535    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
536    rkey: String,
537    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
538    url: String,
539    title: String,
540    unread: i64,
541    /// Whether this feed is the currently-selected scope.
542    selected: bool,
543    /// The feed's current folder `at://` URI (from its subscription record), or
544    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
545    /// rename row so an untouched folder dropdown does not silently un-folder the
546    /// feed on save.
547    folder: Option<String>,
548}
549
550/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
551struct FolderView {
552    /// PDS folder rkey — addresses the record for rename/delete.
553    rkey: String,
554    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
555    uri: String,
556    name: String,
557    feeds: Vec<FeedView>,
558    /// Whether this folder is the currently-selected scope.
559    selected: bool,
560}
561
562/// One entry as shown in the article list / after an htmx swap.
563struct EntryRow {
564    id: i64,
565    title: String,
566    feed_title: String,
567    published: String,
568    read: bool,
569    starred: bool,
570    /// The reader link href, already carrying the scope/view query so opening an
571    /// entry and paging back stays within the list it came from.
572    link: String,
573}
574
575/// A folder as an option in the "move feed to folder" select.
576struct FolderOption {
577    uri: String,
578    name: String,
579}
580
581/// The shared navigation "rail" model: the same DOM element is the
582/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
583/// manage) renders it from this one struct. Feed management lives on `/manage`,
584/// not here — the rail is navigation only.
585struct Nav {
586    /// `@handle` for the identity chip (falls back to the DID's tail).
587    handle: String,
588    /// Two-letter avatar initials for the identity chip.
589    avatar: String,
590    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
591    view: String,
592    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
593    /// empty for the unscoped "everything" views.
594    scope_qs: String,
595    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
596    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
597    folders: Vec<FolderView>,
598    loose_feeds: Vec<FeedView>,
599    /// Whether the "Manage feeds" rail tool is the current page.
600    manage_active: bool,
601}
602
603/// The reader index (`GET /`).
604#[derive(Template)]
605#[template(path = "index.html")]
606struct IndexTemplate {
607    version: &'static str,
608    repo_url: &'static str,
609    kofi_url: &'static str,
610    flash: String,
611    /// The shared rail (drawer + desktop sidebar) navigation model.
612    nav: Nav,
613    /// The article list for the selected scope + view.
614    entries: Vec<EntryRow>,
615    /// The list heading (the selected view/feed/folder name).
616    heading: String,
617    /// Whether a feed scope is active (enables per-feed mark-all-read).
618    feed_scope: Option<String>,
619}
620
621/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
622#[derive(Template)]
623#[template(path = "manage.html")]
624struct ManageTemplate {
625    version: &'static str,
626    repo_url: &'static str,
627    kofi_url: &'static str,
628    flash: String,
629    nav: Nav,
630    /// All folders as move-targets for the subscribe folder select.
631    folder_options: Vec<FolderOption>,
632    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
633    folders: Vec<FolderView>,
634    loose_feeds: Vec<FeedView>,
635}
636
637/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link.
638#[derive(Template)]
639#[template(path = "about.html")]
640struct AboutTemplate {
641    version: &'static str,
642    repo_url: &'static str,
643    kofi_url: &'static str,
644}
645
646/// The signed-out landing page (`GET /` with no session) — the public front
647/// door at feather-reader.com. A static render, no session required.
648#[derive(Template)]
649#[template(path = "landing.html")]
650struct LandingTemplate {
651    version: &'static str,
652    repo_url: &'static str,
653    crates_url: &'static str,
654    kofi_url: &'static str,
655}
656
657/// The single-entry reader view (`GET /entries/:id`).
658#[derive(Template)]
659#[template(path = "entry.html")]
660struct EntryTemplate {
661    version: &'static str,
662    repo_url: &'static str,
663    kofi_url: &'static str,
664    nav: Nav,
665    id: i64,
666    title: String,
667    feed_title: String,
668    author: Option<String>,
669    published: String,
670    url: Option<String>,
671    content_html: Option<String>,
672    read: bool,
673    starred: bool,
674    /// The query string to carry the reading context back to the list.
675    back_qs: String,
676    /// Prev/next entry ids within the current list, for keyboard/paging nav.
677    prev_id: Option<i64>,
678    next_id: Option<i64>,
679    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
680    oob: bool,
681}
682
683/// The htmx swap fragment for a single entry row (`entry_row.html`).
684#[derive(Template)]
685#[template(path = "entry_row.html")]
686struct EntryRowTemplate {
687    e: EntryRow,
688}
689
690/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
691/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
692/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
693/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
694#[derive(Template)]
695#[template(path = "entry_actionbar.html")]
696struct EntryActionBarTemplate {
697    id: i64,
698    read: bool,
699    starred: bool,
700    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
701    oob: bool,
702}
703
704/// The login stub (`GET /login`).
705#[derive(Template)]
706#[template(path = "login.html")]
707struct LoginTemplate {
708    repo_url: &'static str,
709    error: String,
710    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
711    /// distinct from `error`. Empty renders nothing.
712    flash: String,
713}
714
715/// The closed-beta invite-redeem page (`GET /beta/redeem`).
716#[derive(Template)]
717#[template(path = "beta_redeem.html")]
718struct BetaRedeemTemplate {
719    repo_url: &'static str,
720    error: String,
721    /// When true the seat cap is full: hide the form and show the "capacity
722    /// full — try self-hosting" message instead.
723    capacity_full: bool,
724}
725
726// ---------------------------------------------------------------------------
727// Rendering + error helpers
728// ---------------------------------------------------------------------------
729
730/// Render an askama template into an HTML response, mapping a render failure to
731/// a `500` rather than panicking (no `unwrap` in the request path).
732fn render<T: Template>(tmpl: &T) -> Response {
733    match tmpl.render() {
734        Ok(body) => Html(body).into_response(),
735        Err(err) => {
736            warn!(%err, "template render failed");
737            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
738        }
739    }
740}
741
742/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
743/// still return an `impl IntoResponse`. Renders as a `500` with a short message
744/// by default; a handler may override the status (e.g. `413` for an over-cap
745/// upload) via [`WebError::with_status`].
746struct WebError {
747    err: anyhow::Error,
748    status: StatusCode,
749}
750
751impl<E: Into<anyhow::Error>> From<E> for WebError {
752    fn from(err: E) -> Self {
753        WebError {
754            err: err.into(),
755            status: StatusCode::INTERNAL_SERVER_ERROR,
756        }
757    }
758}
759
760impl WebError {
761    /// Attach an explicit HTTP status to render instead of the default `500`.
762    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
763        WebError {
764            err: err.into(),
765            status,
766        }
767    }
768}
769
770impl IntoResponse for WebError {
771    fn into_response(self) -> Response {
772        warn!(error = %self.err, status = %self.status, "request failed");
773        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
774            "internal error"
775        } else {
776            self.status.canonical_reason().unwrap_or("error")
777        };
778        (self.status, body).into_response()
779    }
780}
781
782/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
783/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
784/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
785/// `400`. Either way this avoids collapsing the failure into a generic `500`.
786fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
787    let status = err.status();
788    WebError::with_status(err, status)
789}
790
791/// A short, human display of a feed/site title for the sidebar/list, falling
792/// back to the host of a URL and finally to the raw string.
793fn display_title(title: Option<&str>, url: &str) -> String {
794    if let Some(t) = title {
795        let t = t.trim();
796        if !t.is_empty() {
797            return t.to_string();
798        }
799    }
800    url::Url::parse(url)
801        .ok()
802        .and_then(|u| u.host_str().map(str::to_string))
803        .unwrap_or_else(|| url.to_string())
804}
805
806/// A display `@handle` for the identity chip: the stored handle if present,
807/// else the tail of the DID so the chip is never empty.
808fn display_handle(handle: Option<&str>, did: &str) -> String {
809    match handle {
810        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
811        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
812    }
813}
814
815/// Two-letter, lowercase avatar initials from a handle/DID.
816fn avatar_initials(handle: Option<&str>, did: &str) -> String {
817    let source = handle
818        .map(|h| h.trim().trim_start_matches('@'))
819        .filter(|h| !h.is_empty())
820        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
821    let letters: String = source
822        .chars()
823        .filter(|c| c.is_alphanumeric())
824        .take(2)
825        .collect::<String>()
826        .to_lowercase();
827    if letters.is_empty() {
828        "fr".to_string()
829    } else {
830        letters
831    }
832}
833
834/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
835/// low-noise display. Falls back to the raw string if it doesn't look like one.
836fn display_date(published: Option<&str>) -> String {
837    match published {
838        Some(p) if p.len() >= 10 => p[..10].to_string(),
839        Some(p) => p.to_string(),
840        None => String::new(),
841    }
842}
843
844/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
845/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
846/// a bare value, and this keeps the scope-preserving links honest.
847fn qenc(s: &str) -> String {
848    let mut out = String::with_capacity(s.len() * 3);
849    for b in s.bytes() {
850        match b {
851            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
852                out.push(b as char)
853            }
854            _ => out.push_str(&format!("%{b:02X}")),
855        }
856    }
857    out
858}
859
860// ---------------------------------------------------------------------------
861// Reader: index
862// ---------------------------------------------------------------------------
863
864/// Query for `GET /` — the scope + view selector.
865#[derive(Debug, Deserialize, Default)]
866struct IndexQuery {
867    /// Filter to a single feed by its canonical URL.
868    #[serde(default)]
869    feed: Option<String>,
870    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
871    #[serde(default)]
872    folder: Option<String>,
873    /// `unread` (default) | `all` | `starred`.
874    #[serde(default)]
875    view: Option<String>,
876    /// Optional flash message (e.g. after an action redirect).
877    #[serde(default)]
878    flash: Option<String>,
879}
880
881/// A subscription resolved against the local cache: the PDS record + its
882/// (possibly-missing) cached feed row.
883struct ResolvedSub {
884    rkey: String,
885    sub: Subscription,
886    feed: Option<store::Feed>,
887}
888
889/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
890/// local cache row so unread counts work, and return them resolved. Best-effort
891/// on the sidecar: a failure falls back to the local cache alone.
892async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
893    let pool = &state.db;
894    let subs = match state.sidecar.list_subscriptions_sorted(did).await {
895        Ok(s) => s,
896        Err(err) => {
897            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
898            // Fail CLOSED: the PDS is the source of truth for what this DID
899            // follows. When it is unreachable we must NOT widen the caller's
900            // authorization surface. Serve from the DID's OWN last-known
901            // `sub_ref` projection (its own feeds, possibly stale) and leave
902            // `sub_ref` untouched — never synthesize from every cached feed,
903            // which would grant cross-tenant read+mutate during any outage.
904            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_default();
905            return feeds
906                .into_iter()
907                .map(|f| ResolvedSub {
908                    rkey: String::new(),
909                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
910                    feed: Some(f),
911                })
912                .collect();
913        }
914    };
915
916    let mut out = Vec::with_capacity(subs.len());
917    for (rkey, sub) in subs {
918        let feed = match store::get_feed_by_url(pool, &sub.url).await {
919            Ok(Some(f)) => Some(f),
920            Ok(None) => {
921                // Upsert a cache row so the sidebar reflects the real follow-list.
922                let _ = store::upsert_feed(
923                    pool,
924                    &store::NewFeed {
925                        url: sub.url.clone(),
926                        title: sub.title.clone(),
927                        site_url: sub.site_url.clone(),
928                        ..Default::default()
929                    },
930                )
931                .await;
932                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
933            }
934            Err(err) => {
935                warn!(%err, url = %sub.url, "get_feed_by_url failed");
936                None
937            }
938        };
939        out.push(ResolvedSub { rkey, sub, feed });
940    }
941    // Mirror the caller's resolved subscription set into `sub_ref`, so every
942    // scoped entry/feed read + read/star mutation authorizes against exactly
943    // the feeds this DID follows right now. This is THE per-DID isolation hook.
944    sync_sub_refs(pool, did, &out).await;
945    out
946}
947
948/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
949/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
950/// fail closed / show fewer rows), never leaks another user's entries.
951async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
952    let feed_ids: Vec<i64> = subs
953        .iter()
954        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
955        .collect();
956    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
957        warn!(%err, %did, "failed to sync sub_ref projection");
958    }
959}
960
961/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
962/// records layer) and the article list for the selected scope + view.
963async fn index(
964    State(state): State<AppState>,
965    headers: HeaderMap,
966    Query(q): Query<IndexQuery>,
967) -> Result<Response, WebError> {
968    let user = match current_session(&state, &headers).await {
969        Some(u) => u,
970        // Signed out: serve the public landing page rather than bouncing to
971        // /login. /login remains the entry point for the actual OAuth sign-in.
972        None => {
973            return Ok(render(&LandingTemplate {
974                version: VERSION,
975                repo_url: REPO_URL,
976                crates_url: CRATES_URL,
977                kofi_url: KOFI_URL,
978            }))
979        }
980    };
981    let did = user.did.clone();
982    let pool = &state.db;
983
984    let subs = resolve_subscriptions(&state, &did).await;
985
986    // Per-DID working sets, once.
987    let unread = store::get_unread_for_did(pool, &did).await?;
988    let starred = store::get_starred_for_did(pool, &did).await?;
989    let starred_ids: std::collections::HashSet<i64> = starred.iter().map(|e| e.id).collect();
990
991    // View: unread (default) | all | starred.
992    let view = match q.view.as_deref() {
993        Some("all") => "all",
994        Some("starred") => "starred",
995        _ => "unread",
996    }
997    .to_string();
998
999    // Which feed URLs are in scope?
1000    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1001
1002    // Resolve feed_id → url once for row rendering + scope filtering.
1003    let feed_url_by_id = |id: i64| -> Option<String> {
1004        subs.iter()
1005            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1006            .map(|s| s.sub.url.clone())
1007    };
1008    let feed_title_by_id = |id: i64| -> String {
1009        subs.iter()
1010            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1011            .map(|s| {
1012                display_title(
1013                    s.sub
1014                        .title
1015                        .as_deref()
1016                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1017                    &s.sub.url,
1018                )
1019            })
1020            .unwrap_or_default()
1021    };
1022
1023    let in_scope = |feed_id: i64| -> bool {
1024        match &scope_urls {
1025            None => true,
1026            Some(urls) => feed_url_by_id(feed_id)
1027                .map(|u| urls.contains(&u))
1028                .unwrap_or(false),
1029        }
1030    };
1031
1032    // The source list for the chosen view.
1033    let source = match view.as_str() {
1034        "all" => {
1035            // All entries across in-scope feeds, newest first.
1036            let mut all = Vec::new();
1037            for s in &subs {
1038                if let Some(f) = &s.feed {
1039                    if in_scope(f.id) {
1040                        let mut es = store::entries_for_feed(pool, &did, f.id)
1041                            .await
1042                            .unwrap_or_default();
1043                        all.append(&mut es);
1044                    }
1045                }
1046            }
1047            all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
1048            all
1049        }
1050        "starred" => starred
1051            .iter()
1052            .filter(|e| in_scope(e.feed_id))
1053            .cloned()
1054            .collect(),
1055        _ => unread
1056            .iter()
1057            .filter(|e| in_scope(e.feed_id))
1058            .cloned()
1059            .collect(),
1060    };
1061
1062    // The scope/view suffix carried onto every entry link (built once).
1063    let entry_scope_qs = {
1064        let mut parts = Vec::new();
1065        if let Some(f) = q.feed.as_deref() {
1066            parts.push(format!("feed={}", qenc(f)));
1067        }
1068        if let Some(f) = q.folder.as_deref() {
1069            parts.push(format!("folder={}", qenc(f)));
1070        }
1071        if view != "unread" {
1072            parts.push(format!("view={}", qenc(&view)));
1073        }
1074        parts.join("&")
1075    };
1076    let entry_link = |id: i64| -> String {
1077        if entry_scope_qs.is_empty() {
1078            format!("/entries/{id}")
1079        } else {
1080            format!("/entries/{id}?{entry_scope_qs}")
1081        }
1082    };
1083
1084    let entries: Vec<EntryRow> = source
1085        .iter()
1086        .map(|e| EntryRow {
1087            id: e.id,
1088            title: e
1089                .title
1090                .clone()
1091                .filter(|t| !t.trim().is_empty())
1092                .unwrap_or_else(|| "(untitled)".to_string()),
1093            feed_title: feed_title_by_id(e.feed_id),
1094            published: display_date(e.published.as_deref()),
1095            read: view != "unread" && !unread.iter().any(|u| u.id == e.id),
1096            starred: starred_ids.contains(&e.id),
1097            link: entry_link(e.id),
1098        })
1099        .collect();
1100
1101    let selected_feed = q.feed.as_deref();
1102    let selected_folder = q.folder.as_deref();
1103
1104    // Build the shared sidebar (folders + loose feeds, with unread counts).
1105    let (folder_views, loose_feeds, _folder_options) =
1106        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
1107
1108    // Heading + scope query-string suffix.
1109    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
1110        let name = subs
1111            .iter()
1112            .find(|s| s.sub.url == feed_url)
1113            .map(|s| {
1114                display_title(
1115                    s.sub
1116                        .title
1117                        .as_deref()
1118                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1119                    &s.sub.url,
1120                )
1121            })
1122            .unwrap_or_else(|| display_title(None, feed_url));
1123        (name, format!("feed={}", qenc(feed_url)))
1124    } else if let Some(folder_uri) = selected_folder {
1125        let name = folder_views
1126            .iter()
1127            .find(|f| f.uri == folder_uri)
1128            .map(|f| f.name.clone())
1129            .unwrap_or_else(|| "Folder".to_string());
1130        (name, format!("folder={}", qenc(folder_uri)))
1131    } else {
1132        let h = match view.as_str() {
1133            "all" => "All",
1134            "starred" => "Starred",
1135            _ => "Unread",
1136        };
1137        (h.to_string(), String::new())
1138    };
1139
1140    let feed_scope = selected_feed.map(str::to_string);
1141    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
1142
1143    let tmpl = IndexTemplate {
1144        version: VERSION,
1145        repo_url: REPO_URL,
1146        kofi_url: KOFI_URL,
1147        flash: q.flash.unwrap_or_default(),
1148        nav,
1149        entries,
1150        heading,
1151        feed_scope,
1152    };
1153    Ok(render(&tmpl))
1154}
1155
1156/// Query for `GET /manage` — carries an optional flash after an action redirect.
1157#[derive(Debug, Deserialize, Default)]
1158struct ManageQuery {
1159    #[serde(default)]
1160    flash: Option<String>,
1161}
1162
1163/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
1164/// / your-feeds / OPML surfaces; the forms POST to the existing routes
1165/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
1166/// mutation logic of its own.
1167async fn manage(
1168    State(state): State<AppState>,
1169    headers: HeaderMap,
1170    Query(q): Query<ManageQuery>,
1171) -> Result<Response, WebError> {
1172    let user = match current_session(&state, &headers).await {
1173        Some(u) => u,
1174        None => return Ok(Redirect::to("/login").into_response()),
1175    };
1176    let did = user.did.clone();
1177
1178    let subs = resolve_subscriptions(&state, &did).await;
1179    let (folder_views, loose_feeds, folder_options) =
1180        build_sidebar(&state, &did, &subs, None, None).await;
1181
1182    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
1183    let nav = build_nav(
1184        &user,
1185        "unread",
1186        String::new(),
1187        folder_views.iter().map(clone_folder_view).collect(),
1188        loose_feeds.iter().map(clone_feed_view).collect(),
1189        true,
1190    );
1191
1192    let tmpl = ManageTemplate {
1193        version: VERSION,
1194        repo_url: REPO_URL,
1195        kofi_url: KOFI_URL,
1196        flash: q.flash.unwrap_or_default(),
1197        nav,
1198        folder_options,
1199        folders: folder_views,
1200        loose_feeds,
1201    };
1202    Ok(render(&tmpl))
1203}
1204
1205/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
1206/// (`Nav`) and the page body without an extra DB round-trip.
1207fn clone_feed_view(f: &FeedView) -> FeedView {
1208    FeedView {
1209        rkey: f.rkey.clone(),
1210        url: f.url.clone(),
1211        title: f.title.clone(),
1212        unread: f.unread,
1213        selected: f.selected,
1214        folder: f.folder.clone(),
1215    }
1216}
1217
1218fn clone_folder_view(f: &FolderView) -> FolderView {
1219    FolderView {
1220        rkey: f.rkey.clone(),
1221        uri: f.uri.clone(),
1222        name: f.name.clone(),
1223        feeds: f.feeds.iter().map(clone_feed_view).collect(),
1224        selected: f.selected,
1225    }
1226}
1227
1228/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
1229/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
1230/// unscoped "everything" view. A folder scope takes the feed scope when both are
1231/// somehow present (feed wins, matching the query precedence elsewhere).
1232fn scope_urls_for(
1233    subs: &[ResolvedSub],
1234    feed: Option<&str>,
1235    folder: Option<&str>,
1236) -> Option<Vec<String>> {
1237    if let Some(feed_url) = feed {
1238        Some(vec![feed_url.to_string()])
1239    } else {
1240        folder.map(|folder_uri| {
1241            subs.iter()
1242                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
1243                .map(|s| s.sub.url.clone())
1244                .collect()
1245        })
1246    }
1247}
1248
1249/// The `at://` URI for a folder record given the owner DID + rkey.
1250fn folder_uri(did: &str, rkey: &str) -> String {
1251    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
1252}
1253
1254/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
1255/// DID — the shared source for both the reader index and the rail on every
1256/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
1257async fn build_sidebar(
1258    state: &AppState,
1259    did: &str,
1260    subs: &[ResolvedSub],
1261    selected_feed: Option<&str>,
1262    selected_folder: Option<&str>,
1263) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
1264    let pool = &state.db;
1265    let unread = store::get_unread_for_did(pool, did)
1266        .await
1267        .unwrap_or_default();
1268    let folders = state
1269        .sidecar
1270        .list_folders_sorted(did)
1271        .await
1272        .unwrap_or_default();
1273
1274    let unread_count = |feed_id: Option<i64>| -> i64 {
1275        match feed_id {
1276            Some(id) => unread.iter().filter(|e| e.feed_id == id).count() as i64,
1277            None => 0,
1278        }
1279    };
1280    let mk_feed_view = |s: &ResolvedSub| FeedView {
1281        rkey: s.rkey.clone(),
1282        url: s.sub.url.clone(),
1283        title: display_title(
1284            s.sub
1285                .title
1286                .as_deref()
1287                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1288            &s.sub.url,
1289        ),
1290        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
1291        selected: selected_feed == Some(s.sub.url.as_str()),
1292        folder: s.sub.folder.clone(),
1293    };
1294
1295    let mut folder_views = Vec::with_capacity(folders.len());
1296    for (rkey, folder) in &folders {
1297        let uri = folder_uri(did, rkey);
1298        let feeds: Vec<FeedView> = subs
1299            .iter()
1300            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
1301            .map(mk_feed_view)
1302            .collect();
1303        folder_views.push(FolderView {
1304            rkey: rkey.clone(),
1305            uri: uri.clone(),
1306            name: folder.name.clone(),
1307            feeds,
1308            selected: selected_folder == Some(uri.as_str()),
1309        });
1310    }
1311
1312    let known_uris: std::collections::HashSet<String> =
1313        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
1314    let loose_feeds: Vec<FeedView> = subs
1315        .iter()
1316        .filter(|s| {
1317            s.sub
1318                .folder
1319                .as_deref()
1320                .map(|f| !known_uris.contains(f))
1321                .unwrap_or(true)
1322        })
1323        .map(mk_feed_view)
1324        .collect();
1325
1326    let folder_options: Vec<FolderOption> = folders
1327        .iter()
1328        .map(|(rkey, folder)| FolderOption {
1329            name: folder.name.clone(),
1330            uri: folder_uri(did, rkey),
1331        })
1332        .collect();
1333
1334    (folder_views, loose_feeds, folder_options)
1335}
1336
1337/// Assemble the shared rail [`Nav`] for a chrome page.
1338fn build_nav(
1339    user: &CurrentUser,
1340    view: &str,
1341    scope_qs: String,
1342    folders: Vec<FolderView>,
1343    loose_feeds: Vec<FeedView>,
1344    manage_active: bool,
1345) -> Nav {
1346    Nav {
1347        handle: display_handle(user.handle.as_deref(), &user.did),
1348        avatar: avatar_initials(user.handle.as_deref(), &user.did),
1349        view: view.to_string(),
1350        scope_qs,
1351        folders,
1352        loose_feeds,
1353        manage_active,
1354    }
1355}
1356
1357// ---------------------------------------------------------------------------
1358// Reader: single entry
1359// ---------------------------------------------------------------------------
1360
1361/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
1362/// prev/next and "back" stay within the list the reader came from.
1363#[derive(Debug, Deserialize, Default)]
1364struct EntryQuery {
1365    #[serde(default)]
1366    feed: Option<String>,
1367    #[serde(default)]
1368    folder: Option<String>,
1369    #[serde(default)]
1370    view: Option<String>,
1371}
1372
1373/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
1374/// within the current reading list.
1375async fn entry_view(
1376    State(state): State<AppState>,
1377    headers: HeaderMap,
1378    Path(id): Path<i64>,
1379    Query(q): Query<EntryQuery>,
1380) -> Result<Response, WebError> {
1381    let user = match current_session(&state, &headers).await {
1382        Some(u) => u,
1383        None => return Ok(Redirect::to("/login").into_response()),
1384    };
1385    let did = user.did.clone();
1386    let pool = &state.db;
1387
1388    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
1389    // the per-DID entry gate below authorizes against the caller's current PDS
1390    // subscription set (not another user's cached feeds).
1391    let subs = resolve_subscriptions(&state, &did).await;
1392
1393    let entry = match get_entry_by_id(pool, &did, id).await? {
1394        Some(e) => e,
1395        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1396    };
1397
1398    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
1399
1400    let read = entry_is_read(pool, &did, id).await?;
1401    let starred = entry_is_starred(pool, &did, id).await?;
1402
1403    // Reconstruct the current list to compute prev/next, so paging in the reader
1404    // matches what the list showed.
1405    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
1406
1407    let back_qs = scope_query(&q);
1408
1409    let (folder_views, loose_feeds, _) =
1410        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
1411    let nav_view = match q.view.as_deref() {
1412        Some("all") => "all",
1413        Some("starred") => "starred",
1414        _ => "unread",
1415    };
1416    let nav = build_nav(
1417        &user,
1418        nav_view,
1419        back_qs.clone(),
1420        folder_views,
1421        loose_feeds,
1422        false,
1423    );
1424
1425    let tmpl = EntryTemplate {
1426        version: VERSION,
1427        repo_url: REPO_URL,
1428        kofi_url: KOFI_URL,
1429        nav,
1430        id: entry.id,
1431        title: entry
1432            .title
1433            .clone()
1434            .filter(|t| !t.trim().is_empty())
1435            .unwrap_or_else(|| "(untitled)".to_string()),
1436        feed_title,
1437        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
1438        published: display_date(entry.published.as_deref()),
1439        url: entry.url.clone().filter(|u| !u.trim().is_empty()),
1440        content_html: entry.content_html.clone(),
1441        read,
1442        starred,
1443        back_qs,
1444        prev_id,
1445        next_id,
1446        oob: false,
1447    };
1448    Ok(render(&tmpl))
1449}
1450
1451/// Compute the prev/next entry ids around `current` within the reader's current
1452/// scope + view, so the reader view can offer keyboard/paging navigation.
1453async fn neighbors_in_scope(
1454    state: &AppState,
1455    did: &str,
1456    q: &EntryQuery,
1457    current: i64,
1458) -> (Option<i64>, Option<i64>) {
1459    let idx_q = IndexQuery {
1460        feed: q.feed.clone(),
1461        folder: q.folder.clone(),
1462        view: q.view.clone(),
1463        flash: None,
1464    };
1465    let ids = list_entry_ids(state, did, &idx_q).await;
1466    let pos = ids.iter().position(|&x| x == current);
1467    match pos {
1468        Some(p) => {
1469            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
1470            let next = ids.get(p + 1).copied();
1471            (prev, next)
1472        }
1473        None => (None, None),
1474    }
1475}
1476
1477/// The ordered entry ids for a scope + view — the same ordering `index` renders,
1478/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
1479async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
1480    let pool = &state.db;
1481    let subs = resolve_subscriptions(state, did).await;
1482
1483    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1484    let feed_url_by_id = |id: i64| -> Option<String> {
1485        subs.iter()
1486            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1487            .map(|s| s.sub.url.clone())
1488    };
1489    let in_scope = |feed_id: i64| -> bool {
1490        match &scope_urls {
1491            None => true,
1492            Some(urls) => feed_url_by_id(feed_id)
1493                .map(|u| urls.contains(&u))
1494                .unwrap_or(false),
1495        }
1496    };
1497
1498    let view = q.view.as_deref().unwrap_or("unread");
1499    let entries = match view {
1500        "all" => {
1501            let mut all = Vec::new();
1502            for s in &subs {
1503                if let Some(f) = &s.feed {
1504                    if in_scope(f.id) {
1505                        let mut es = store::entries_for_feed(pool, did, f.id)
1506                            .await
1507                            .unwrap_or_default();
1508                        all.append(&mut es);
1509                    }
1510                }
1511            }
1512            all.sort_by(|a, b| b.published.cmp(&a.published).then(b.id.cmp(&a.id)));
1513            all
1514        }
1515        "starred" => store::get_starred_for_did(pool, did)
1516            .await
1517            .unwrap_or_default()
1518            .into_iter()
1519            .filter(|e| in_scope(e.feed_id))
1520            .collect(),
1521        _ => store::get_unread_for_did(pool, did)
1522            .await
1523            .unwrap_or_default()
1524            .into_iter()
1525            .filter(|e| in_scope(e.feed_id))
1526            .collect(),
1527    };
1528    entries.into_iter().map(|e| e.id).collect()
1529}
1530
1531/// Build a `?…` query string that preserves the reading scope + view for links.
1532fn scope_query(q: &EntryQuery) -> String {
1533    let mut parts = Vec::new();
1534    if let Some(f) = q.feed.as_deref() {
1535        parts.push(format!("feed={}", qenc(f)));
1536    }
1537    if let Some(f) = q.folder.as_deref() {
1538        parts.push(format!("folder={}", qenc(f)));
1539    }
1540    if let Some(v) = q.view.as_deref() {
1541        if v != "unread" {
1542            parts.push(format!("view={}", qenc(v)));
1543        }
1544    }
1545    parts.join("&")
1546}
1547
1548// ---------------------------------------------------------------------------
1549// Mark read / unread
1550// ---------------------------------------------------------------------------
1551
1552/// Form body for `POST /entries/:id/read`.
1553#[derive(Debug, Deserialize)]
1554struct ReadForm {
1555    #[serde(default)]
1556    read: Option<String>,
1557}
1558
1559/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
1560async fn mark_read(
1561    State(state): State<AppState>,
1562    Path(id): Path<i64>,
1563    headers: HeaderMap,
1564    Form(form): Form<ReadForm>,
1565) -> Result<Response, WebError> {
1566    let did = match current_did(&state, &headers).await {
1567        Some(d) => d,
1568        None => return Ok(Redirect::to("/login").into_response()),
1569    };
1570    let pool = &state.db;
1571
1572    let read = matches!(
1573        form.read.as_deref(),
1574        Some("true") | Some("1") | Some("on") | None
1575    );
1576
1577    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
1578    // mutation: `mark_read` only writes when `did` subscribes to the entry's
1579    // feed. A non-subscriber gets a 404, never a mutation of someone else's
1580    // (or the shared cache's) state.
1581    resolve_subscriptions(&state, &did).await;
1582    if !store::mark_read(pool, &did, id, read).await? {
1583        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1584    }
1585
1586    if !is_htmx(&headers) {
1587        return Ok(Redirect::to("/").into_response());
1588    }
1589
1590    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
1591    // in the DOM), so its button's hidden value + aria-pressed update in place
1592    // and a second keypress can reverse the toggle. The list view swaps the row.
1593    if is_reader_request(&headers) {
1594        let starred = entry_is_starred(pool, &did, id).await?;
1595        return Ok(render(&EntryActionBarTemplate {
1596            id,
1597            read,
1598            starred,
1599            oob: true,
1600        }));
1601    }
1602
1603    let row = build_entry_row(pool, &did, id, Some(read)).await?;
1604    match row {
1605        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1606        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1607    }
1608}
1609
1610// ---------------------------------------------------------------------------
1611// Star / save
1612// ---------------------------------------------------------------------------
1613
1614/// Form body for `POST /entries/:id/star`.
1615#[derive(Debug, Deserialize)]
1616struct StarForm {
1617    #[serde(default)]
1618    starred: Option<String>,
1619}
1620
1621/// `POST /entries/:id/star` — star/unstar an entry.
1622///
1623/// Sets the local `starred` bit (fast working copy) and writes/removes a
1624/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
1625/// owning). The PDS write is best-effort — the local star still lands.
1626async fn toggle_star(
1627    State(state): State<AppState>,
1628    Path(id): Path<i64>,
1629    headers: HeaderMap,
1630    Form(form): Form<StarForm>,
1631) -> Result<Response, WebError> {
1632    let did = match current_did(&state, &headers).await {
1633        Some(d) => d,
1634        None => return Ok(Redirect::to("/login").into_response()),
1635    };
1636    let pool = &state.db;
1637
1638    let starred = matches!(
1639        form.starred.as_deref(),
1640        Some("true") | Some("1") | Some("on") | None
1641    );
1642
1643    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
1644    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
1645    // feed. A non-subscriber gets a 404, never a mutation.
1646    resolve_subscriptions(&state, &did).await;
1647    if !store::mark_starred(pool, &did, id, starred).await? {
1648        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
1649    }
1650
1651    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
1652    // to the caller's subscriptions, so this only ever acts on the caller's feed.
1653    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
1654        let entry_url = entry.url.clone().unwrap_or_default();
1655        if !entry_url.is_empty() {
1656            if starred {
1657                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
1658                saved.title = entry.title.clone();
1659                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
1660                saved.entry_id = Some(entry.guid.clone());
1661                match state.sidecar.add_saved(&did, &saved).await {
1662                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
1663                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
1664                }
1665            } else {
1666                // Un-star: find and delete the matching saved record by URL.
1667                match state.sidecar.list_saved(&did).await {
1668                    Ok(records) => {
1669                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
1670                            if let Err(err) = state.sidecar.remove_saved(&did, rkey).await {
1671                                warn!(%err, %did, %rkey, "PDS saved delete failed");
1672                            }
1673                        }
1674                    }
1675                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
1676                }
1677            }
1678        }
1679    }
1680
1681    if !is_htmx(&headers) {
1682        return Ok(Redirect::to("/").into_response());
1683    }
1684
1685    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
1686    if is_reader_request(&headers) {
1687        let read = entry_is_read(pool, &did, id).await?;
1688        return Ok(render(&EntryActionBarTemplate {
1689            id,
1690            read,
1691            starred,
1692            oob: true,
1693        }));
1694    }
1695
1696    let row = build_entry_row(pool, &did, id, None).await?;
1697    match row {
1698        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
1699        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
1700    }
1701}
1702
1703/// The feed URL for a cached feed id, if the row exists.
1704async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
1705    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
1706        .bind(feed_id)
1707        .fetch_optional(pool)
1708        .await
1709        .ok()
1710        .flatten()
1711}
1712
1713// ---------------------------------------------------------------------------
1714// Mark-all-read
1715// ---------------------------------------------------------------------------
1716
1717/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
1718/// absent means mark everything read.
1719#[derive(Debug, Deserialize, Default)]
1720struct ReadAllQuery {
1721    #[serde(default)]
1722    feed: Option<String>,
1723}
1724
1725/// `POST /read-all` — mark every entry read for the current DID, optionally
1726/// scoped to one feed (mark-all-read per feed or globally).
1727async fn mark_all_read(
1728    State(state): State<AppState>,
1729    headers: HeaderMap,
1730    Query(q): Query<ReadAllQuery>,
1731) -> Result<Response, WebError> {
1732    let did = match current_did(&state, &headers).await {
1733        Some(d) => d,
1734        None => return Ok(Redirect::to("/login").into_response()),
1735    };
1736    let pool = &state.db;
1737
1738    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
1739    // only ever touch feeds this DID actually subscribes to.
1740    resolve_subscriptions(&state, &did).await;
1741
1742    if let Some(feed_url) = q.feed.as_deref() {
1743        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
1744            store::mark_feed_read(pool, &did, feed.id, true).await?;
1745        }
1746        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
1747    }
1748
1749    // Global: mark every subscribed feed read. Fan out over the DID's feeds
1750    // (bounded by the per-DID subscription cap) using the batched per-feed path,
1751    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
1752    // state, but O(feeds) statements instead of O(unread entries).
1753    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
1754        store::mark_feed_read(pool, &did, feed_id, true).await?;
1755    }
1756    Ok(Redirect::to("/").into_response())
1757}
1758
1759// ---------------------------------------------------------------------------
1760// Subscribe by URL
1761// ---------------------------------------------------------------------------
1762
1763/// Refusal message shown when a private/paid feed is submitted. FeatherReader
1764/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
1765/// only for now — a private feed's secret URL is never saved, fetched, or sent
1766/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
1767/// and the boot-smoke can assert on it.
1768const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
1769    FeatherReader stores your subscriptions in your public PDS, so it supports public \
1770    feeds for now — private-feed support arrives when atproto's private data \
1771    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
1772
1773/// Form body for `POST /subscriptions`.
1774#[derive(Debug, Deserialize)]
1775struct SubscribeForm {
1776    url: String,
1777    /// Optional folder `at://` URI to file the new feed under.
1778    #[serde(default)]
1779    folder: Option<String>,
1780}
1781
1782/// `POST /subscriptions` — subscribe by URL.
1783async fn add_subscription(
1784    State(state): State<AppState>,
1785    headers: HeaderMap,
1786    Form(form): Form<SubscribeForm>,
1787) -> Result<Response, WebError> {
1788    let did = match current_did(&state, &headers).await {
1789        Some(d) => d,
1790        None => return Ok(Redirect::to("/login").into_response()),
1791    };
1792    let pool = &state.db;
1793    let input = form.url.trim().to_string();
1794    if input.is_empty() {
1795        return Ok(Redirect::to("/").into_response());
1796    }
1797
1798    // Block private/paid feeds BEFORE any fetch/resolve so a secret-bearing URL is
1799    // never even requested. Public feeds only until atproto permissioned data
1800    // ships; there is no override and nothing is stored or written.
1801    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
1802        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
1803        return Ok(
1804            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1805        );
1806    }
1807
1808    // Per-DID subscription cap: bound one account's storage/poller footprint on
1809    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
1810    // can't even trigger an outbound request. `<= 0` disables the cap.
1811    let cap = state.config.max_subs_per_did;
1812    if cap > 0 {
1813        match store::count_subscriptions_for_did(pool, &did).await {
1814            Ok(n) if n >= cap => {
1815                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
1816                return Ok(Redirect::to(&format!(
1817                    "/?flash={}",
1818                    qenc(&format!(
1819                        "Subscription limit reached ({cap}). Remove a feed before adding another."
1820                    ))
1821                ))
1822                .into_response());
1823            }
1824            Ok(_) => {}
1825            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
1826        }
1827    }
1828
1829    let feed_url = match resolve_feed_url(&state.config, &input).await {
1830        Ok(u) => u,
1831        Err(err) => {
1832            warn!(%err, url = %input, "could not resolve a feed from the given URL");
1833            return Ok(Redirect::to(&format!(
1834                "/?flash={}",
1835                qenc("Couldn't find a feed at that URL")
1836            ))
1837            .into_response());
1838        }
1839    };
1840
1841    // Defensive: resolution may have discovered a feed URL that itself carries a
1842    // secret (e.g. a public site page linking a tokened feed). Re-check the
1843    // resolved URL and refuse before storing/writing anything.
1844    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1845        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
1846        return Ok(
1847            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1848        );
1849    }
1850
1851    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
1852    // cache is full (an existing/duplicate feed URL is always fine — it adds no
1853    // row). Bounds total cache size across all users on the box. `<= 0` disables.
1854    let feeds_cap = state.config.max_feeds_global;
1855    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
1856        match store::count_feeds(pool).await {
1857            Ok(n) if n >= feeds_cap => {
1858                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
1859                return Ok(Redirect::to(&format!(
1860                    "/?flash={}",
1861                    qenc(
1862                        "This instance is at its feed capacity right now. Please try again later."
1863                    )
1864                ))
1865                .into_response());
1866            }
1867            Ok(_) => {}
1868            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1869        }
1870    }
1871
1872    store::upsert_feed(
1873        pool,
1874        &store::NewFeed {
1875            url: feed_url.clone(),
1876            ..Default::default()
1877        },
1878    )
1879    .await?;
1880
1881    if let Ok(client) = feed::build_client() {
1882        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
1883            match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
1884            {
1885                Ok(outcome) => info!(feed = %feed_url, ?outcome, "polled new subscription"),
1886                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
1887            }
1888        }
1889    }
1890
1891    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
1892    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
1893        sub.title = feed_row.title.clone();
1894        sub.site_url = feed_row.site_url.clone();
1895    }
1896    sub.folder = form
1897        .folder
1898        .map(|f| f.trim().to_string())
1899        .filter(|f| !f.is_empty());
1900
1901    match state.sidecar.add_subscription(&did, &sub).await {
1902        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
1903        Err(err) => {
1904            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
1905        }
1906    }
1907
1908    Ok(Redirect::to("/").into_response())
1909}
1910
1911/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
1912async fn delete_subscription(
1913    State(state): State<AppState>,
1914    headers: HeaderMap,
1915    Path(rkey): Path<String>,
1916) -> Result<Response, WebError> {
1917    let did = match current_did(&state, &headers).await {
1918        Some(d) => d,
1919        None => return Ok(Redirect::to("/login").into_response()),
1920    };
1921    match state.sidecar.remove_subscription(&did, &rkey).await {
1922        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
1923        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
1924    }
1925    Ok(Redirect::to("/").into_response())
1926}
1927
1928/// Form body for `POST /subscriptions/:rkey/rename`.
1929#[derive(Debug, Deserialize)]
1930struct RenameSubForm {
1931    url: String,
1932    #[serde(default)]
1933    title: Option<String>,
1934    #[serde(default)]
1935    site_url: Option<String>,
1936    #[serde(default)]
1937    folder: Option<String>,
1938}
1939
1940/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
1941/// folder, rewriting the whole subscription record via `putRecord`.
1942async fn rename_subscription(
1943    State(state): State<AppState>,
1944    headers: HeaderMap,
1945    Path(rkey): Path<String>,
1946    Form(form): Form<RenameSubForm>,
1947) -> Result<Response, WebError> {
1948    let did = match current_did(&state, &headers).await {
1949        Some(d) => d,
1950        None => return Ok(Redirect::to("/login").into_response()),
1951    };
1952    let feed_url = form.url.trim().to_string();
1953
1954    // Reject an empty/blank resolved URL — a rename with no usable URL must not
1955    // write a junk row to the cache or a malformed subscription record to the
1956    // PDS (add_subscription refuses an empty input the same way).
1957    if feed_url.is_empty() {
1958        return Ok(Redirect::to("/").into_response());
1959    }
1960
1961    // Block private/paid feeds on rename too. `url` is attacker-controllable, and
1962    // rename both upserts it to the local cache AND rewrites the PDS subscription
1963    // record (a public `putRecord`), so without this guard a crafted rename could
1964    // land a secret-bearing URL in the public PDS — the exact leak the add and
1965    // OPML paths already prevent. Refuse before touching either store.
1966    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
1967        info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
1968        return Ok(
1969            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
1970        );
1971    }
1972
1973    // Global feeds ceiling parity with add_subscription: a rename can point at a
1974    // brand-new feed URL (not just retitle an existing one), which would insert a
1975    // NEW `feeds` row. Refuse that when the shared cache is at capacity (an
1976    // existing/duplicate URL adds no row and is always fine). `<= 0` disables.
1977    let feeds_cap = state.config.max_feeds_global;
1978    if feeds_cap > 0
1979        && store::get_feed_by_url(&state.db, &feed_url)
1980            .await?
1981            .is_none()
1982    {
1983        match store::count_feeds(&state.db).await {
1984            Ok(n) if n >= feeds_cap => {
1985                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
1986                return Ok(Redirect::to(&format!(
1987                    "/?flash={}",
1988                    qenc(
1989                        "This instance is at its feed capacity right now. Please try again later."
1990                    )
1991                ))
1992                .into_response());
1993            }
1994            Ok(_) => {}
1995            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
1996        }
1997    }
1998
1999    let mut sub = Subscription::new(feed_url, now_rfc3339());
2000    sub.title = form
2001        .title
2002        .map(|t| t.trim().to_string())
2003        .filter(|t| !t.is_empty());
2004    sub.site_url = form
2005        .site_url
2006        .map(|t| t.trim().to_string())
2007        .filter(|t| !t.is_empty());
2008    sub.folder = form
2009        .folder
2010        .map(|f| f.trim().to_string())
2011        .filter(|f| !f.is_empty());
2012
2013    // Keep the local cache title in step for the loose-feed fallback path.
2014    let _ = store::upsert_feed(
2015        &state.db,
2016        &store::NewFeed {
2017            url: sub.url.clone(),
2018            title: sub.title.clone(),
2019            site_url: sub.site_url.clone(),
2020            ..Default::default()
2021        },
2022    )
2023    .await;
2024
2025    match state.sidecar.update_subscription(&did, &rkey, &sub).await {
2026        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription"),
2027        Err(err) => warn!(%err, %did, %rkey, "PDS subscription update failed"),
2028    }
2029    Ok(Redirect::to("/").into_response())
2030}
2031
2032// ---------------------------------------------------------------------------
2033// Folders
2034// ---------------------------------------------------------------------------
2035
2036/// Form body for `POST /folders`.
2037#[derive(Debug, Deserialize)]
2038struct FolderForm {
2039    name: String,
2040}
2041
2042/// `POST /folders` — create a folder record.
2043async fn create_folder(
2044    State(state): State<AppState>,
2045    headers: HeaderMap,
2046    Form(form): Form<FolderForm>,
2047) -> Result<Response, WebError> {
2048    let did = match current_did(&state, &headers).await {
2049        Some(d) => d,
2050        None => return Ok(Redirect::to("/login").into_response()),
2051    };
2052    let name = form.name.trim();
2053    if name.is_empty() {
2054        return Ok(Redirect::to("/").into_response());
2055    }
2056    let folder = Folder::new(name.to_string(), now_rfc3339());
2057    match state.sidecar.add_folder(&did, &folder).await {
2058        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
2059        Err(err) => warn!(%err, %did, "PDS folder create failed"),
2060    }
2061    Ok(Redirect::to("/").into_response())
2062}
2063
2064/// `POST /folders/:rkey/rename` — rename a folder record.
2065async fn rename_folder(
2066    State(state): State<AppState>,
2067    headers: HeaderMap,
2068    Path(rkey): Path<String>,
2069    Form(form): Form<FolderForm>,
2070) -> Result<Response, WebError> {
2071    let did = match current_did(&state, &headers).await {
2072        Some(d) => d,
2073        None => return Ok(Redirect::to("/login").into_response()),
2074    };
2075    let name = form.name.trim();
2076    if name.is_empty() {
2077        return Ok(Redirect::to("/").into_response());
2078    }
2079    let folder = Folder::new(name.to_string(), now_rfc3339());
2080    match state.sidecar.rename_folder(&did, &rkey, &folder).await {
2081        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
2082        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
2083    }
2084    Ok(Redirect::to("/").into_response())
2085}
2086
2087/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
2088/// simply become un-foldered).
2089async fn delete_folder(
2090    State(state): State<AppState>,
2091    headers: HeaderMap,
2092    Path(rkey): Path<String>,
2093) -> Result<Response, WebError> {
2094    let did = match current_did(&state, &headers).await {
2095        Some(d) => d,
2096        None => return Ok(Redirect::to("/login").into_response()),
2097    };
2098    match state.sidecar.remove_folder(&did, &rkey).await {
2099        Ok(()) => info!(%did, %rkey, "deleted folder record"),
2100        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
2101    }
2102    Ok(Redirect::to("/").into_response())
2103}
2104
2105/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
2106/// feed document we take it as-is; if it yields an HTML page we run
2107/// autodiscovery over its `<link rel="alternate">` tags.
2108async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
2109    let parsed =
2110        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
2111
2112    let client = feed::build_client()?;
2113    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
2114    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
2115    // loopback / private hosts.
2116    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
2117    let final_url = resp.url().clone();
2118    let content_type = resp
2119        .headers()
2120        .get(axum::http::header::CONTENT_TYPE)
2121        .and_then(|v| v.to_str().ok())
2122        .unwrap_or("")
2123        .to_ascii_lowercase();
2124    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
2125    // gzip strips it, and this response is reflected into the UI.
2126    let raw = crate::net::read_capped(resp).await?;
2127    let body = String::from_utf8_lossy(&raw).into_owned();
2128
2129    let looks_like_feed = content_type.contains("xml")
2130        || content_type.contains("rss")
2131        || content_type.contains("atom")
2132        || content_type.contains("application/feed+json")
2133        || {
2134            let head = body.trim_start();
2135            head.starts_with("<?xml")
2136                || head.starts_with("<rss")
2137                || head.starts_with("<feed")
2138                || head.contains("<rss")
2139                || head.contains("<feed")
2140        };
2141    if looks_like_feed {
2142        return Ok(final_url.to_string());
2143    }
2144
2145    match feed::discover_feed(&body, Some(&final_url)) {
2146        Some(u) => Ok(u.to_string()),
2147        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
2148    }
2149}
2150
2151// ---------------------------------------------------------------------------
2152// Login (atproto OAuth via the sidecar)
2153// ---------------------------------------------------------------------------
2154
2155/// Query for `GET /login`.
2156#[derive(Debug, Deserialize, Default)]
2157struct LoginQuery {
2158    #[serde(default)]
2159    handle: Option<String>,
2160    #[serde(default)]
2161    error: Option<String>,
2162    #[serde(default)]
2163    flash: Option<String>,
2164}
2165
2166/// `GET /login` — start the atproto OAuth flow, or render the handle form.
2167///
2168/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
2169/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
2170/// session cookie *or* the submitted handle resolving to a seated DID) or a
2171/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
2172/// form (no handle) always renders.
2173async fn login_form(
2174    State(state): State<AppState>,
2175    headers: HeaderMap,
2176    Query(q): Query<LoginQuery>,
2177) -> Response {
2178    if let Some(handle) = q
2179        .handle
2180        .map(|h| h.trim().to_string())
2181        .filter(|h| !h.is_empty())
2182    {
2183        if !may_start_oauth(&state, &headers, &handle).await {
2184            return Redirect::to("/beta/redeem").into_response();
2185        }
2186        return start_oauth(&state, &handle);
2187    }
2188    render(&LoginTemplate {
2189        repo_url: REPO_URL,
2190        error: q.error.unwrap_or_default(),
2191        flash: q.flash.unwrap_or_default(),
2192    })
2193}
2194
2195/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
2196/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
2197async fn login_submit(
2198    State(state): State<AppState>,
2199    headers: HeaderMap,
2200    Form(form): Form<LoginForm>,
2201) -> Response {
2202    let handle = form.handle.trim();
2203    if handle.is_empty() {
2204        return login_error("Enter your atproto handle.");
2205    }
2206    if !may_start_oauth(&state, &headers, handle).await {
2207        return Redirect::to("/beta/redeem").into_response();
2208    }
2209    start_oauth(&state, handle)
2210}
2211
2212/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
2213/// admits, in order of cost:
2214///
2215/// 1. an existing beta member's cookie session whose DID already holds a seat;
2216/// 2. a fresh visitor carrying a valid reserving invite cookie;
2217/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
2218///    already holds a seat — this honors the **seeded admin's first login** on a
2219///    fresh deploy (and any returning member who cleared cookies) without a
2220///    session cookie or an invite code.
2221///
2222/// The cookie/invite fast paths run FIRST and short-circuit, so the network
2223/// handle→DID resolution is only attempted when neither applies. It fails
2224/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
2225/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
2226/// This keeps the anti-abuse intent — a rando now pays a cheap handle
2227/// resolution instead of a burned sidecar handshake (and `/login` is already in
2228/// the rate-limited path set).
2229async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
2230    // The production resolver is the app's existing atproto handle→DID path,
2231    // routed through the SSRF guard. Resolution is injected so tests can exercise
2232    // the gate without a live network call (the guard forbids loopback mocks).
2233    may_start_oauth_with(state, headers, handle, |h| async move {
2234        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
2235            .await
2236            .ok()
2237    })
2238    .await
2239}
2240
2241/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
2242/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
2243/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
2244/// only called when neither admits — keeping the network round-trip off the hot
2245/// path and preserving the fail-closed contract on resolution failure.
2246async fn may_start_oauth_with<F, Fut>(
2247    state: &AppState,
2248    headers: &HeaderMap,
2249    handle: &str,
2250    resolve: F,
2251) -> bool
2252where
2253    F: FnOnce(String) -> Fut,
2254    Fut: std::future::Future<Output = Option<String>>,
2255{
2256    // 1. An already-beta'd session may re-auth freely.
2257    if let Some(did) = current_did(state, headers).await {
2258        if store::has_beta_access(&state.db, &did)
2259            .await
2260            .unwrap_or(false)
2261        {
2262            return true;
2263        }
2264    }
2265    // 2. A valid reserving invite cookie.
2266    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
2267        return true;
2268    }
2269    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
2270    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
2271    //    on any resolution error or unresolvable/malformed handle.
2272    match resolve(handle.to_string()).await {
2273        Some(did) => store::has_beta_access(&state.db, &did)
2274            .await
2275            .unwrap_or(false),
2276        None => {
2277            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
2278            false
2279        }
2280    }
2281}
2282
2283/// Redirect the browser to the sidecar's public `/login` for `handle`.
2284fn start_oauth(state: &AppState, handle: &str) -> Response {
2285    let url = state.sidecar.login_url(handle, None);
2286    info!(%handle, "redirecting to OAuth sidecar login");
2287    Redirect::to(&url).into_response()
2288}
2289
2290/// Form body for `POST /login`.
2291#[derive(Debug, Deserialize)]
2292struct LoginForm {
2293    handle: String,
2294}
2295
2296/// Query for `GET /oauth/callback`.
2297#[derive(Debug, Deserialize, Default)]
2298struct CallbackQuery {
2299    #[serde(default)]
2300    session_id: Option<String>,
2301    #[serde(default)]
2302    error: Option<String>,
2303    #[serde(default)]
2304    error_description: Option<String>,
2305}
2306
2307/// `GET /oauth/callback` — establish the cookie session.
2308///
2309/// **Invite gate:** the verified DID must hold beta access. If it already does
2310/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
2311/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
2312/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
2313async fn oauth_callback(
2314    State(state): State<AppState>,
2315    headers: HeaderMap,
2316    Query(q): Query<CallbackQuery>,
2317) -> Response {
2318    if let Some(err) = q.error {
2319        let desc = q.error_description.unwrap_or_default();
2320        warn!(error = %err, desc = %desc, "OAuth callback returned an error");
2321        return login_error(&format!("Login failed: {err}"));
2322    }
2323
2324    let session_id = match q.session_id {
2325        Some(s) if !s.is_empty() => s,
2326        _ => return login_error("Login failed: the callback carried no session."),
2327    };
2328
2329    let session = match state.sidecar.resolve_session(&session_id).await {
2330        Ok(Some(s)) => s,
2331        Ok(None) => {
2332            warn!("OAuth callback session_id did not resolve (expired/unknown)");
2333            return login_error("Login session expired — please try again.");
2334        }
2335        Err(err) => {
2336            warn!(%err, "failed to resolve OAuth session via the sidecar");
2337            return login_error("Login failed talking to the auth service.");
2338        }
2339    };
2340
2341    // Bind the verified DID to the invite gate. Returns a response only on the
2342    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
2343    let mut clear_invite = false;
2344    if !store::has_beta_access(&state.db, &session.did)
2345        .await
2346        .unwrap_or(false)
2347    {
2348        // Not yet a member: consume the reserved invite code, if any.
2349        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
2350            Some(c) => c,
2351            None => {
2352                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
2353                return Redirect::to("/beta/redeem").into_response();
2354            }
2355        };
2356        match store::redeem_code(
2357            &state.db,
2358            &code,
2359            &session.did,
2360            session.handle.as_deref(),
2361            state.config.beta_cap,
2362        )
2363        .await
2364        {
2365            Ok(Ok(())) => {
2366                clear_invite = true;
2367                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
2368            }
2369            Ok(Err(policy)) => {
2370                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
2371                let mut resp = redeem_bounce(&policy).into_response();
2372                // The reservation is spent/invalid — drop the stale invite cookie.
2373                clear_invite_cookie(&mut resp);
2374                return resp;
2375            }
2376            Err(err) => {
2377                warn!(%err, did = %session.did, "invite redeem infra error at callback");
2378                return login_error("Login failed while confirming your invite.");
2379            }
2380        }
2381    }
2382
2383    // Mint an opaque, random server-side session id and store the identity under
2384    // it; the cookie carries the (HMAC-signed) sid, never the DID.
2385    let sid = state.sessions.create(Session {
2386        did: session.did.clone(),
2387        handle: session.handle.clone(),
2388    });
2389    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
2390    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
2391
2392    let mut resp = Redirect::to("/").into_response();
2393    set_cookie(&mut resp, &cookie);
2394    if clear_invite {
2395        clear_invite_cookie(&mut resp);
2396    }
2397    resp
2398}
2399
2400/// `POST /logout` — end the session everywhere, not just in this browser.
2401///
2402/// Clearing the cookie only stops *this* device from presenting the session;
2403/// the sidecar still holds live OAuth tokens for the DID. So logout now also
2404/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
2405/// access tokens at the PDS and drops the sidecar's session rows. The local
2406/// registry entry is dropped and the cookie cleared regardless of whether the
2407/// revoke round-trip succeeds (best-effort — a network blip must not trap the
2408/// user in a half-logged-out state).
2409async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
2410    if let Some(user) = current_session(&state, &headers).await {
2411        // Only a real cookie session (`sid` present) has sidecar-held tokens to
2412        // revoke; the dev-DID fallback never handshook the sidecar.
2413        if let Some(sid) = user.sid {
2414            state.sessions.remove(&sid);
2415            match state.sidecar.revoke_session(&user.did).await {
2416                Ok(res) => {
2417                    info!(did = %user.did, revoked = res.revoked, "logout: sidecar session revoked");
2418                }
2419                Err(err) => {
2420                    warn!(did = %user.did, %err, "logout: sidecar revoke failed; clearing cookie anyway");
2421                }
2422            }
2423        }
2424    }
2425    let mut resp = Redirect::to("/login").into_response();
2426    set_cookie(
2427        &mut resp,
2428        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2429    );
2430    resp
2431}
2432
2433/// Form body for `POST /account/delete` — the confirm-gate. The user must type
2434/// `DELETE` into this field for the purge to run.
2435#[derive(Debug, Deserialize)]
2436struct DeleteAccountForm {
2437    #[serde(default)]
2438    confirm: String,
2439}
2440
2441/// The literal a user must type to confirm the destructive delete.
2442const DELETE_CONFIRM_PHRASE: &str = "DELETE";
2443
2444/// `POST /account/delete` (authed) — the "delete my data" endpoint.
2445///
2446/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
2447/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
2448///   1. purges **every** local row owned by the caller DID (`entry_state`,
2449///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
2450///      DID created) via [`store::purge_did_data`], then
2451///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
2452///      revoked at the PDS and the sidecar's session rows are dropped, then
2453///   3. drops the in-memory session and clears the cookie, signing the user out.
2454///
2455/// The subscription/folder/saved *records* in the user's own PDS are
2456/// intentionally left alone — they are the user's data on their own server; the
2457/// `/about` copy and this page's UI both say so, and export stays available.
2458async fn account_delete(
2459    State(state): State<AppState>,
2460    headers: HeaderMap,
2461    Form(form): Form<DeleteAccountForm>,
2462) -> Result<Response, WebError> {
2463    let user = match current_session(&state, &headers).await {
2464        Some(u) => u,
2465        None => return Ok(Redirect::to("/login").into_response()),
2466    };
2467    let did = user.did.clone();
2468
2469    // Confirm-gate: require the exact typed phrase before doing anything.
2470    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
2471        return Ok(Redirect::to(&format!(
2472            "/manage?flash={}",
2473            qenc("Type DELETE to confirm — nothing was deleted.")
2474        ))
2475        .into_response());
2476    }
2477
2478    // 1. Purge every local row this DID owns (single transaction).
2479    let counts = store::purge_did_data(&state.db, &did).await?;
2480    info!(
2481        %did,
2482        total = counts.total(),
2483        entry_state = counts.entry_state,
2484        read_cursor = counts.read_cursor,
2485        sub_ref = counts.sub_ref,
2486        beta_access = counts.beta_access,
2487        invite_codes = counts.invite_codes,
2488        "account/delete: local rows purged"
2489    );
2490
2491    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
2492    //    rows are already gone; a network blip must not block the sign-out).
2493    match state.sidecar.revoke_session(&did).await {
2494        Ok(res) => info!(%did, revoked = res.revoked, "account/delete: sidecar session revoked"),
2495        Err(err) => {
2496            warn!(%did, %err, "account/delete: sidecar revoke failed; local data already purged")
2497        }
2498    }
2499
2500    // 3. Drop the in-memory session and clear the cookie: sign the user out.
2501    if let Some(sid) = user.sid {
2502        state.sessions.remove(&sid);
2503    }
2504    let mut resp = Redirect::to(&format!(
2505        "/login?flash={}",
2506        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
2507    ))
2508    .into_response();
2509    set_cookie(
2510        &mut resp,
2511        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
2512    );
2513    Ok(resp)
2514}
2515
2516/// Re-render the login form with an error banner.
2517fn login_error(msg: &str) -> Response {
2518    render(&LoginTemplate {
2519        repo_url: REPO_URL,
2520        error: msg.to_string(),
2521        flash: String::new(),
2522    })
2523}
2524
2525// ---------------------------------------------------------------------------
2526// Closed-beta invite gate (self-serve redeem + admin mint)
2527// ---------------------------------------------------------------------------
2528
2529/// Form body for `POST /beta/redeem`.
2530#[derive(Debug, Deserialize)]
2531struct RedeemForm {
2532    code: String,
2533}
2534
2535/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
2536/// already full we render the "capacity full" variant (no form).
2537async fn beta_redeem_form(State(state): State<AppState>) -> Response {
2538    let full = store::count_beta_access(&state.db)
2539        .await
2540        .map(|n| n >= state.config.beta_cap)
2541        .unwrap_or(false);
2542    render(&BetaRedeemTemplate {
2543        repo_url: REPO_URL,
2544        error: String::new(),
2545        capacity_full: full,
2546    })
2547}
2548
2549/// `POST /beta/redeem` — the **pre-handshake** reservation.
2550///
2551/// Validates the pasted code is *redeemable right now* (exists, active,
2552/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
2553/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
2554/// reserving intent to redeem this code, then sends the visitor to `/login`. The
2555/// OAuth callback later binds the verified DID and atomically consumes the code
2556/// (`store::redeem_code`). This ordering means a non-invited visitor can never
2557/// start OAuth (and burn a sidecar handshake).
2558async fn beta_redeem_submit(
2559    State(state): State<AppState>,
2560    Form(form): Form<RedeemForm>,
2561) -> Response {
2562    let code = form.code.trim().to_uppercase();
2563    if code.is_empty() {
2564        return render(&BetaRedeemTemplate {
2565            repo_url: REPO_URL,
2566            error: "Enter your invite code.".to_string(),
2567            capacity_full: false,
2568        });
2569    }
2570
2571    match preflight_code(&state, &code).await {
2572        Ok(()) => {
2573            let cookie = sign_invite(&code, &state.config.cookie_secret);
2574            let mut resp = Redirect::to("/login").into_response();
2575            set_cookie(&mut resp, &cookie);
2576            info!("invite code preflight OK; reserving intent + redirecting to /login");
2577            resp
2578        }
2579        Err(policy) => {
2580            warn!(?policy, "invite code preflight rejected");
2581            redeem_bounce(&policy)
2582        }
2583    }
2584}
2585
2586/// Read-only preflight of an invite code for the pre-handshake reservation:
2587/// verify it exists, is active, is not past `expires_at`, and that a seat is
2588/// free — mirroring the checks `store::redeem_code` will re-run atomically at
2589/// callback time. Does NOT consume the code or grant a seat. Returns the same
2590/// typed [`store::RedeemError`] variants so the two paths share one message map.
2591async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
2592    // Cap check first: a clear "capacity full" beats "code invalid" when both.
2593    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
2594    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
2595    // still backstops the real cap inside its tx, so this is a consistency /
2596    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
2597    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
2598    // that might overrun the cap.
2599    let count = match store::count_beta_access(&state.db).await {
2600        Ok(n) => n,
2601        Err(err) => {
2602            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
2603            return Err(store::RedeemError::CapacityFull);
2604        }
2605    };
2606    if count >= state.config.beta_cap {
2607        return Err(store::RedeemError::CapacityFull);
2608    }
2609    // Look up the code's current status + expiry (read-only).
2610    let row = sqlx::query_as::<_, (String, i64)>(
2611        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
2612    )
2613    .bind(code)
2614    .fetch_optional(&state.db)
2615    .await
2616    .ok()
2617    .flatten();
2618    let (status, expires_at) = match row {
2619        Some(r) => r,
2620        None => return Err(store::RedeemError::NotFound),
2621    };
2622    let now = chrono::Utc::now().timestamp();
2623    match status.as_str() {
2624        "active" if expires_at >= now => Ok(()),
2625        "active" => Err(store::RedeemError::Expired),
2626        "expired" => Err(store::RedeemError::Expired),
2627        // "redeemed" or anything else non-active.
2628        _ => Err(store::RedeemError::AlreadyRedeemed),
2629    }
2630}
2631
2632/// Map a [`store::RedeemError`] to the invite page with the right message. Used
2633/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
2634fn redeem_bounce(policy: &store::RedeemError) -> Response {
2635    use store::RedeemError::*;
2636    let (msg, capacity_full) = match policy {
2637        NotFound => ("That invite code isn't valid.", false),
2638        Expired => ("That invite code has expired.", false),
2639        AlreadyRedeemed => ("That invite code has already been used.", false),
2640        CapacityFull => ("", true),
2641    };
2642    render(&BetaRedeemTemplate {
2643        repo_url: REPO_URL,
2644        error: msg.to_string(),
2645        capacity_full,
2646    })
2647}
2648
2649/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
2650#[derive(Debug, Deserialize, Default)]
2651struct MintQuery {
2652    #[serde(default)]
2653    n: Option<u32>,
2654}
2655
2656/// `POST /admin/invites?n=N` — mint N invite codes.
2657///
2658/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
2659/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
2660/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
2661async fn admin_mint_invites(
2662    State(state): State<AppState>,
2663    headers: HeaderMap,
2664    Query(q): Query<MintQuery>,
2665) -> Response {
2666    // Require a real, current session (not just a DID string) whose DID is an
2667    // admin-seed DID. `current_did` already re-checks the beta gate.
2668    let did = match current_did(&state, &headers).await {
2669        Some(d) => d,
2670        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
2671    };
2672    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
2673        warn!(%did, "admin mint denied: not an admin-seed DID");
2674        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
2675    }
2676
2677    let n = q.n.unwrap_or(1).clamp(1, 100);
2678    let mut codes = Vec::with_capacity(n as usize);
2679    for _ in 0..n {
2680        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
2681            Ok(code) => codes.push(code),
2682            Err(err) => {
2683                warn!(%err, %did, "admin mint_code failed");
2684                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2685            }
2686        }
2687    }
2688    info!(%did, count = codes.len(), "admin minted invite codes");
2689    let mut body = codes.join("\n");
2690    body.push('\n');
2691    (StatusCode::OK, body).into_response()
2692}
2693
2694// ---------------------------------------------------------------------------
2695// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
2696// ---------------------------------------------------------------------------
2697
2698/// Query for `GET /claim`.
2699#[derive(Debug, Deserialize)]
2700struct ClaimQuery {
2701    /// The opaque claim token from the bot's public follow-back skeet.
2702    t: Option<String>,
2703}
2704
2705/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
2706///
2707/// The follow→invite bot posts a public skeet mentioning a new follower with a
2708/// link here. The token wraps a pre-minted invite code (never the raw code — see
2709/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
2710/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
2711/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
2712/// callback atomically consumes the code (`store::redeem_code`) — the same
2713/// machinery as a pasted code. On any failure it bounces to the invite page with
2714/// the matching message.
2715///
2716/// Single-use / grabbability: a token in a public URL is grabbable. The code it
2717/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
2718/// here rejects an already-used / expired / capacity-full code before reserving,
2719/// so a replayed link past the first successful claim is refused. The residual
2720/// window is the same as any pasted invite code: whoever completes OAuth *first*
2721/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
2722/// blunts brute-force enumeration.
2723async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
2724    let token = match q.t {
2725        Some(t) if !t.is_empty() => t,
2726        _ => {
2727            warn!("claim link with no token");
2728            return redeem_bounce(&store::RedeemError::NotFound);
2729        }
2730    };
2731
2732    // Unwrap the token → the invite code it reserves. A tampered/forged token
2733    // yields nothing → treat as an invalid code (don't leak whether it parsed).
2734    let code = match claim_token_code(&token, &state.config.cookie_secret) {
2735        Some(c) => c,
2736        None => {
2737            warn!("claim token invalid (bad signature / malformed)");
2738            return redeem_bounce(&store::RedeemError::NotFound);
2739        }
2740    };
2741
2742    // Re-run the same preflight as the pasted-code path: exists, active,
2743    // unexpired, seat free. This is what makes a replayed link past first-claim
2744    // (or past cap) fail cleanly.
2745    match preflight_code(&state, &code).await {
2746        Ok(()) => {
2747            let cookie = sign_invite(&code, &state.config.cookie_secret);
2748            let mut resp = Redirect::to("/login").into_response();
2749            set_cookie(&mut resp, &cookie);
2750            info!("claim token preflight OK; reserving intent + redirecting to /login");
2751            resp
2752        }
2753        Err(policy) => {
2754            warn!(?policy, "claim token preflight rejected");
2755            redeem_bounce(&policy)
2756        }
2757    }
2758}
2759
2760/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
2761///
2762/// Passing the follower DID makes the APP the authoritative deduper: the app can
2763/// short-circuit a DID that already holds a seat, and return the SAME code for a
2764/// DID that already has an outstanding claim — so a bot-host state loss cannot
2765/// re-mint or re-post per follower. Handle is advisory (logs only).
2766#[derive(Debug, Default, Deserialize)]
2767struct BotClaimRequest {
2768    /// The follower's DID (the idempotency key). Optional for backward-compat: an
2769    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
2770    #[serde(default)]
2771    did: Option<String>,
2772    /// The follower's handle (advisory; recorded for operator logs only).
2773    #[serde(default)]
2774    #[allow(dead_code)]
2775    handle: Option<String>,
2776}
2777
2778/// The JSON body `POST /bot/claims` returns on success.
2779#[derive(Debug, serde::Serialize)]
2780struct BotClaimResponse {
2781    /// Server-side dedupe outcome, so the bot knows whether to post:
2782    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
2783    /// already had an outstanding claim; the SAME code/token/url is returned, so an
2784    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
2785    /// beta access; code/token/url are empty and the bot should post NOTHING).
2786    status: &'static str,
2787    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
2788    /// store. NEVER post this publicly; post the `url` instead. Empty when
2789    /// `already_seated`.
2790    code: String,
2791    /// The opaque claim token (the code wrapped + signed). Empty when
2792    /// `already_seated`.
2793    token: String,
2794    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
2795    /// Empty when `already_seated`.
2796    url: String,
2797}
2798
2799/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
2800///
2801/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
2802/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
2803/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
2804/// (503), so a bare/dev instance never exposes an unauthenticated mint.
2805///
2806/// Server-side DID idempotency (the authoritative dedupe backstop): the request
2807/// body carries the follower `did`. The app — not the bot's local SQLite — is the
2808/// source of truth, so a bot-host state loss cannot re-mint or re-post per
2809/// follower:
2810///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
2811///     code/url; the bot marks it handled and posts NOTHING);
2812///   * DID already has an outstanding active claim → `200 {status:"existing"}`
2813///     returning the SAME code/token/url (idempotent — never a second mint);
2814///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
2815///
2816/// Cap accounting: the bot must not promise more claims than seats remain, so
2817/// this refuses with `409 Conflict {"error":"full"}` when
2818/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
2819/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
2820/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
2821/// minting past the cap.
2822///
2823/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
2824/// default 14d — the admin browser flow's 30-min TTL would expire before the
2825/// follower taps an async-delivered link).
2826async fn bot_mint_claim(
2827    State(state): State<AppState>,
2828    headers: HeaderMap,
2829    body: axum::body::Bytes,
2830) -> Response {
2831    // 1. The endpoint is OFF unless a bot secret is configured.
2832    let bot_secret = match state.config.bot_secret.as_deref() {
2833        Some(s) => s,
2834        None => {
2835            warn!(
2836                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
2837            );
2838            return (
2839                StatusCode::SERVICE_UNAVAILABLE,
2840                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
2841            )
2842                .into_response();
2843        }
2844    };
2845
2846    // 2. Constant-time bearer check on the X-Bot-Secret header.
2847    let presented = headers
2848        .get("x-bot-secret")
2849        .and_then(|v| v.to_str().ok())
2850        .unwrap_or("");
2851    if !bot_secret_matches(presented, bot_secret) {
2852        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
2853        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
2854    }
2855
2856    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
2857    // (legacy caller) parses to an all-None request; a malformed body is a 400.
2858    let req: BotClaimRequest = if body.is_empty() {
2859        BotClaimRequest::default()
2860    } else {
2861        match serde_json::from_slice(&body) {
2862            Ok(r) => r,
2863            Err(err) => {
2864                warn!(%err, "POST /bot/claims: bad JSON body");
2865                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
2866            }
2867        }
2868    };
2869    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
2870
2871    // 3. Server-side DID idempotency (only when a DID was supplied):
2872    if let Some(did) = follower_did {
2873        // 3a. Already seated → tell the bot to post nothing.
2874        match store::has_beta_access(&state.db, did).await {
2875            Ok(true) => {
2876                info!("bot mint: DID already holds beta access; already_seated");
2877                return bot_claim_json(BotClaimResponse {
2878                    status: "already_seated",
2879                    code: String::new(),
2880                    token: String::new(),
2881                    url: String::new(),
2882                });
2883            }
2884            Ok(false) => {}
2885            Err(err) => {
2886                // Fail closed: a DB error must not fall through to a fresh mint.
2887                warn!(%err, "bot mint: has_beta_access failed");
2888                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
2889            }
2890        }
2891        // 3b. Outstanding active claim for this DID → return the SAME code (no
2892        // second mint). This is what survives a bot-host state loss.
2893        match store::find_active_code_for_did(&state.db, did).await {
2894            Ok(Some(code)) => {
2895                info!("bot mint: existing outstanding claim for DID; returning same code");
2896                let token = sign_claim_token(&code, &state.config.cookie_secret);
2897                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
2898                return bot_claim_json(BotClaimResponse {
2899                    status: "existing",
2900                    code,
2901                    token,
2902                    url,
2903                });
2904            }
2905            Ok(None) => {}
2906            Err(err) => {
2907                warn!(%err, "bot mint: find_active_code_for_did failed");
2908                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
2909            }
2910        }
2911    }
2912
2913    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
2914    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
2915    let granted = match store::count_beta_access(&state.db).await {
2916        Ok(n) => n,
2917        Err(err) => {
2918            warn!(%err, "bot mint: count_beta_access failed; failing closed");
2919            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
2920        }
2921    };
2922    let outstanding = match store::count_active_codes(&state.db).await {
2923        Ok(n) => n,
2924        Err(err) => {
2925            warn!(%err, "bot mint: count_active_codes failed; failing closed");
2926            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
2927        }
2928    };
2929    if granted + outstanding >= state.config.beta_cap {
2930        info!(
2931            granted,
2932            outstanding,
2933            cap = state.config.beta_cap,
2934            "bot mint refused: at capacity"
2935        );
2936        return (
2937            StatusCode::CONFLICT,
2938            [(header::CONTENT_TYPE, "application/json")],
2939            "{\"error\":\"full\"}\n",
2940        )
2941            .into_response();
2942    }
2943
2944    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
2945    //    so a re-request for the same DID returns THIS code idempotently.
2946    let bot_did = state
2947        .config
2948        .admin_seed_dids()
2949        .first()
2950        .cloned()
2951        .unwrap_or_else(|| "did:bot:featherreader".to_string());
2952    let minted = match follower_did {
2953        Some(did) => {
2954            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
2955        }
2956        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
2957    };
2958    let code = match minted {
2959        Ok(c) => c,
2960        // S4: the dedupe check (3b) and this mint are separate statements, so two
2961        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
2962        // The partial unique index `idx_invite_codes_intended_active` makes the
2963        // loser's INSERT fail (only one active row per intended DID), which
2964        // surfaces here as a conflict. Recover by returning the winner's existing
2965        // code (same shape as the 3b idempotent path) instead of a 500.
2966        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
2967            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
2968                Ok(Some(code)) => {
2969                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
2970                    let token = sign_claim_token(&code, &state.config.cookie_secret);
2971                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
2972                    return bot_claim_json(BotClaimResponse {
2973                        status: "existing",
2974                        code,
2975                        token,
2976                        url,
2977                    });
2978                }
2979                // The winner's row vanished between the conflict and this lookup
2980                // (redeemed/expired/purged in the gap) — nothing to hand back.
2981                // Fail closed rather than silently mint past the just-hit guard.
2982                Ok(None) => {
2983                    warn!("bot mint: conflict but no active code found on recovery");
2984                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2985                }
2986                Err(err) => {
2987                    warn!(%err, "bot mint: recovery lookup after conflict failed");
2988                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2989                }
2990            }
2991        }
2992        Err(err) => {
2993            warn!(%err, "bot mint_code failed");
2994            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
2995        }
2996    };
2997    let token = sign_claim_token(&code, &state.config.cookie_secret);
2998    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
2999    info!("bot minted a claim code + token");
3000
3001    bot_claim_json(BotClaimResponse {
3002        status: "minted",
3003        code,
3004        token,
3005        url,
3006    })
3007}
3008
3009/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
3010/// `500` if serialization somehow fails).
3011fn bot_claim_json(resp: BotClaimResponse) -> Response {
3012    match serde_json::to_string(&resp) {
3013        Ok(body) => (
3014            StatusCode::OK,
3015            [(header::CONTENT_TYPE, "application/json")],
3016            body,
3017        )
3018            .into_response(),
3019        Err(err) => {
3020            warn!(%err, "serializing bot claim response failed");
3021            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
3022        }
3023    }
3024}
3025
3026/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
3027/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
3028/// by the HMAC checks so there is one comparator to audit; a length mismatch
3029/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
3030fn bot_secret_matches(presented: &str, expected: &str) -> bool {
3031    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
3032}
3033
3034// ---------------------------------------------------------------------------
3035// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
3036// ---------------------------------------------------------------------------
3037
3038/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
3039/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
3040/// itself (base64url) rather than an opaque sid, since the code IS the reserved
3041/// intent the callback consumes.
3042fn sign_invite(code: &str, secret: &str) -> String {
3043    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
3044}
3045
3046/// Verify + read the reserved invite code out of the request's invite cookie
3047/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
3048/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
3049/// authority on the code's live status.
3050fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
3051    cookie::verify_value(headers, INVITE_COOKIE, secret)
3052}
3053
3054/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
3055/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
3056/// cookie value and vice-versa.
3057const CLAIM_TOKEN_LABEL: &str = "claim-token";
3058
3059/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
3060/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
3061///
3062/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
3063/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
3064/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
3065/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
3066/// code won't verify), the wrapped code is single-use (redeem flips
3067/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
3068/// token one self-contained string needing no server-side token table; it does
3069/// NOT hide the code.
3070fn sign_claim_token(code: &str, secret: &str) -> String {
3071    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
3072}
3073
3074/// Verify a claim token and return the invite code it wraps (`None` on a tampered
3075/// / forged / malformed token). The code's live status (active/unexpired/seat
3076/// free) is re-checked by `preflight_code`; this only proves the token was minted
3077/// by this instance.
3078fn claim_token_code(token: &str, secret: &str) -> Option<String> {
3079    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
3080}
3081
3082/// Clear the invite cookie on a response (after a successful bind, or when the
3083/// reservation turned out to be stale).
3084fn clear_invite_cookie(resp: &mut Response) {
3085    set_cookie(
3086        resp,
3087        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3088    );
3089}
3090
3091// ---------------------------------------------------------------------------
3092// OPML import + export
3093// ---------------------------------------------------------------------------
3094
3095/// `POST /opml` — import subscriptions from an OPML document.
3096///
3097/// Accepts either a multipart file upload (field `file`) or a pasted textarea
3098/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
3099/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
3100/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
3101/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
3102/// they show immediately; polling is left to the background poller.
3103async fn import_opml(
3104    State(state): State<AppState>,
3105    headers: HeaderMap,
3106    mut multipart: Multipart,
3107) -> Result<Response, WebError> {
3108    let did = match current_did(&state, &headers).await {
3109        Some(d) => d,
3110        None => return Ok(Redirect::to("/login").into_response()),
3111    };
3112    let pool = &state.db;
3113
3114    // Collect the OPML text from whichever field carried it. Multipart errors
3115    // are mapped to their axum-native response so that an over-cap upload (the
3116    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
3117    // `413 Payload Too Large` rather than being swallowed by the blanket
3118    // `WebError` → `500` conversion.
3119    let mut opml_text = String::new();
3120    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
3121        let name = field.name().unwrap_or("").to_string();
3122        if name == "opml" || name == "file" {
3123            let bytes = field.bytes().await.map_err(multipart_response)?;
3124            if !bytes.is_empty() {
3125                opml_text = String::from_utf8_lossy(&bytes).into_owned();
3126                if name == "file" {
3127                    break;
3128                }
3129            }
3130        }
3131    }
3132
3133    let feeds = opml::parse_opml(&opml_text).unwrap_or_default();
3134    if feeds.is_empty() {
3135        info!(%did, "OPML import found no feeds");
3136        return Ok(
3137            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
3138                .into_response(),
3139        );
3140    }
3141
3142    // Create any named folders first, mapping folder name → at:// URI so
3143    // subscriptions can reference them.
3144    let now = now_rfc3339();
3145    let mut folder_uris: std::collections::HashMap<String, String> =
3146        std::collections::HashMap::new();
3147    // Reuse existing folders where the name already exists.
3148    if let Ok(existing) = state.sidecar.list_folders_sorted(&did).await {
3149        for (rkey, folder) in existing {
3150            folder_uris
3151                .entry(folder.name.clone())
3152                .or_insert_with(|| folder_uri(&did, &rkey));
3153        }
3154    }
3155    let mut wanted_folders: Vec<String> = feeds
3156        .iter()
3157        .filter_map(|f| f.folder.clone())
3158        .filter(|n| !n.is_empty())
3159        .collect();
3160    wanted_folders.sort();
3161    wanted_folders.dedup();
3162    for name in wanted_folders {
3163        if folder_uris.contains_key(&name) {
3164            continue;
3165        }
3166        let folder = Folder::new(name.clone(), now.clone());
3167        match state.sidecar.add_folder(&did, &folder).await {
3168            Ok(rkey) => {
3169                folder_uris.insert(name, folder_uri(&did, &rkey));
3170            }
3171            Err(err) => warn!(%err, %did, "OPML folder create failed"),
3172        }
3173    }
3174
3175    // Build one subscription record per PUBLIC feed + upsert the local cache row.
3176    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
3177    // and reported back to the user — the same public-feeds-only stance as the
3178    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
3179    // token onto the public network either.
3180    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
3181    // the remaining headroom (cap − existing) once; public feeds beyond it are
3182    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
3183    let sub_cap = state.config.max_subs_per_did;
3184    let mut headroom: Option<i64> = if sub_cap > 0 {
3185        let existing = store::count_subscriptions_for_did(pool, &did)
3186            .await
3187            .unwrap_or(0);
3188        Some((sub_cap - existing).max(0))
3189    } else {
3190        None
3191    };
3192    let mut trimmed_over_cap: usize = 0;
3193
3194    // Global feeds ceiling: an OPML import must not blow past the shared cache
3195    // ceiling any more than the single-add path may. Seed the remaining global
3196    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
3197    // not already cached) consumes it. Existing/duplicate URLs add no row and
3198    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
3199    // `<= 0` disables the ceiling.
3200    let feeds_cap = state.config.max_feeds_global;
3201    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
3202        let existing = store::count_feeds(pool).await.unwrap_or(0);
3203        Some((feeds_cap - existing).max(0))
3204    } else {
3205        None
3206    };
3207    let mut trimmed_over_global: usize = 0;
3208
3209    let mut subs = Vec::with_capacity(feeds.len());
3210    let mut skipped_private: Vec<String> = Vec::new();
3211    for f in &feeds {
3212        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
3213            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
3214            // Report by title where we have one, else the (public-safe) host.
3215            let label = f
3216                .title
3217                .clone()
3218                .filter(|t| !t.trim().is_empty())
3219                .unwrap_or_else(|| private_feed_label(&f.feed_url));
3220            skipped_private.push(label);
3221            continue;
3222        }
3223
3224        // Over-cap: stop importing once headroom is exhausted (count the rest so
3225        // we can tell the user how many were dropped).
3226        if let Some(h) = headroom.as_mut() {
3227            if *h <= 0 {
3228                trimmed_over_cap += 1;
3229                continue;
3230            }
3231        }
3232
3233        // Global ceiling: a brand-new feed URL consumes global headroom. Once
3234        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
3235        // free — they add no row). Checked before decrementing the per-DID
3236        // headroom so a dropped feed doesn't burn the caller's own quota.
3237        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
3238            Ok(existing) => existing.is_none(),
3239            // On a lookup error, treat as existing (don't consume global
3240            // headroom) but still allow the upsert to proceed.
3241            Err(err) => {
3242                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
3243                false
3244            }
3245        };
3246        if is_new {
3247            if let Some(g) = global_headroom.as_mut() {
3248                if *g <= 0 {
3249                    trimmed_over_global += 1;
3250                    continue;
3251                }
3252                *g -= 1;
3253            }
3254        }
3255
3256        // Passed both caps: consume the per-DID headroom now that the feed is
3257        // actually being imported.
3258        if let Some(h) = headroom.as_mut() {
3259            *h -= 1;
3260        }
3261
3262        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
3263        sub.title = f.title.clone();
3264        sub.site_url = f.site_url.clone();
3265        sub.folder = f
3266            .folder
3267            .as_ref()
3268            .and_then(|name| folder_uris.get(name).cloned());
3269        subs.push(sub);
3270        let _ = store::upsert_feed(
3271            pool,
3272            &store::NewFeed {
3273                url: f.feed_url.clone(),
3274                title: f.title.clone(),
3275                site_url: f.site_url.clone(),
3276                ..Default::default()
3277            },
3278        )
3279        .await;
3280    }
3281
3282    match state.sidecar.add_subscriptions_bulk(&did, &subs).await {
3283        Ok(rkeys) => {
3284            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)")
3285        }
3286        Err(err) => warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)"),
3287    }
3288
3289    // Report the import count, plus any private/paid feeds skipped as unsupported.
3290    let mut flash = format!("Imported {} feeds", subs.len());
3291    if trimmed_over_cap > 0 {
3292        flash.push_str(&format!(
3293            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
3294        ));
3295    }
3296    if trimmed_over_global > 0 {
3297        flash.push_str(&format!(
3298            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
3299        ));
3300    }
3301    if !skipped_private.is_empty() {
3302        flash.push_str(&format!(
3303            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
3304            skipped_private.len(),
3305            skipped_private.join(", ")
3306        ));
3307    }
3308    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
3309}
3310
3311/// A public-safe label for a skipped private feed when it has no title: just the
3312/// host, so we never echo the secret-bearing path/query back to the user.
3313fn private_feed_label(url: &str) -> String {
3314    url::Url::parse(url)
3315        .ok()
3316        .and_then(|u| u.host_str().map(str::to_string))
3317        .unwrap_or_else(|| "a private feed".to_string())
3318}
3319
3320/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
3321async fn export_opml(
3322    State(state): State<AppState>,
3323    headers: HeaderMap,
3324) -> Result<Response, WebError> {
3325    let did = match current_did(&state, &headers).await {
3326        Some(d) => d,
3327        None => return Ok(Redirect::to("/login").into_response()),
3328    };
3329
3330    let subs = state
3331        .sidecar
3332        .list_subscriptions_sorted(&did)
3333        .await
3334        .unwrap_or_default();
3335    let folders = state
3336        .sidecar
3337        .list_folders_sorted(&did)
3338        .await
3339        .unwrap_or_default();
3340    // The exporter matches a subscription's `folder` at-uri against the folder's
3341    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
3342    let folder_pairs: Vec<(String, Folder)> = folders
3343        .into_iter()
3344        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
3345        .collect();
3346
3347    let body = opml::to_opml(&subs, &folder_pairs);
3348    let mut resp = (StatusCode::OK, body).into_response();
3349    resp.headers_mut().insert(
3350        header::CONTENT_TYPE,
3351        "text/x-opml; charset=utf-8".parse().unwrap(),
3352    );
3353    resp.headers_mut().insert(
3354        header::CONTENT_DISPOSITION,
3355        "attachment; filename=\"featherreader-subscriptions.opml\""
3356            .parse()
3357            .unwrap(),
3358    );
3359    Ok(resp)
3360}
3361
3362// ---------------------------------------------------------------------------
3363// Signed session cookie (HMAC-SHA256, dependency-free)
3364// ---------------------------------------------------------------------------
3365
3366/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
3367fn set_cookie(resp: &mut Response, cookie: &str) {
3368    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
3369        resp.headers_mut()
3370            .append(axum::http::header::SET_COOKIE, value);
3371    }
3372}
3373
3374/// Whether the request came from htmx (the `HX-Request` header).
3375fn is_htmx(headers: &HeaderMap) -> bool {
3376    headers
3377        .get("HX-Request")
3378        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
3379}
3380
3381/// Whether a mark-read / star request originated from the single-entry READER
3382/// (as opposed to the list view). The reader's forms tag themselves with
3383/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
3384/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
3385/// isn't in the DOM), the list gets the row (`entry_row.html`).
3386fn is_reader_request(headers: &HeaderMap) -> bool {
3387    headers
3388        .get("X-FR-Reader")
3389        .is_some_and(|v| v.as_bytes() == b"1")
3390}
3391
3392/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
3393/// server-minted **session id** (never the DID — so the cookie can't be forged
3394/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
3395/// server-side session id).
3396mod cookie {
3397    use super::{HeaderMap, SESSION_COOKIE};
3398
3399    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
3400    pub fn sign_session(sid: &str, secret: &str) -> String {
3401        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
3402    }
3403
3404    /// Verify the request's session cookie and return the session id it carries.
3405    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
3406        verify_value(headers, SESSION_COOKIE, secret)
3407    }
3408
3409    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
3410    /// value`), so a signature minted for one cookie can't verify under another —
3411    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
3412    /// The NUL separator can't appear in a cookie name, so the encoding is
3413    /// unambiguous.
3414    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
3415        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
3416        msg.extend_from_slice(name.as_bytes());
3417        msg.push(0);
3418        msg.extend_from_slice(value.as_bytes());
3419        msg
3420    }
3421
3422    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
3423    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
3424    /// generic form behind both the session cookie and the short-lived invite
3425    /// cookie; domain-separating by name keeps a signature valid only for the
3426    /// cookie it was minted for.
3427    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
3428        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
3429        let b64 = b64url_encode(value.as_bytes());
3430        format!(
3431            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
3432        )
3433    }
3434
3435    /// Verify + read a value out of the named signed cookie (`None` on absent /
3436    /// tampered / forged / cross-cookie). The generic form behind both readers.
3437    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
3438        let raw = cookie_value(headers, name)?;
3439        let (b64, sig) = raw.split_once('.')?;
3440        let bytes = b64url_decode(b64)?;
3441        let value = String::from_utf8(bytes).ok()?;
3442        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
3443        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
3444            Some(value)
3445        } else {
3446            None
3447        }
3448    }
3449
3450    /// Sign an arbitrary `value` into an opaque, URL-safe token string
3451    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
3452    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
3453    /// URL query param (the bot's claim link). `label` domain-separates it from
3454    /// the cookies so a token can't be replayed as a cookie value.
3455    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
3456        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
3457        let b64 = b64url_encode(value.as_bytes());
3458        format!("{b64}.{sig}")
3459    }
3460
3461    /// Verify a token minted by [`sign_token`] and return the wrapped value
3462    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
3463    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
3464        let (b64, sig) = token.split_once('.')?;
3465        let bytes = b64url_decode(b64)?;
3466        let value = String::from_utf8(bytes).ok()?;
3467        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
3468        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
3469            Some(value)
3470        } else {
3471            None
3472        }
3473    }
3474
3475    /// Pull one cookie value out of the `Cookie` request header.
3476    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
3477        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
3478        for part in header.split(';') {
3479            let part = part.trim();
3480            if let Some((k, v)) = part.split_once('=') {
3481                if k == name {
3482                    return Some(v.to_string());
3483                }
3484            }
3485        }
3486        None
3487    }
3488
3489    /// Constant-time byte comparison (avoid signature-timing leaks). Public
3490    /// within the module so the bot-secret bearer check reuses the exact same
3491    /// comparator as the cookie/token HMAC checks (one implementation to audit).
3492    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
3493        if a.len() != b.len() {
3494            return false;
3495        }
3496        let mut diff = 0u8;
3497        for (x, y) in a.iter().zip(b.iter()) {
3498            diff |= x ^ y;
3499        }
3500        diff == 0
3501    }
3502
3503    // -- URL-safe base64 (no padding), std-only --------------------------------
3504
3505    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
3506
3507    fn b64url_encode(input: &[u8]) -> String {
3508        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
3509        for chunk in input.chunks(3) {
3510            let b = [
3511                chunk[0],
3512                *chunk.get(1).unwrap_or(&0),
3513                *chunk.get(2).unwrap_or(&0),
3514            ];
3515            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
3516            out.push(B64[((n >> 18) & 63) as usize] as char);
3517            out.push(B64[((n >> 12) & 63) as usize] as char);
3518            if chunk.len() > 1 {
3519                out.push(B64[((n >> 6) & 63) as usize] as char);
3520            }
3521            if chunk.len() > 2 {
3522                out.push(B64[(n & 63) as usize] as char);
3523            }
3524        }
3525        out
3526    }
3527
3528    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
3529        fn val(c: u8) -> Option<u32> {
3530            match c {
3531                b'A'..=b'Z' => Some((c - b'A') as u32),
3532                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
3533                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
3534                b'-' => Some(62),
3535                b'_' => Some(63),
3536                _ => None,
3537            }
3538        }
3539        let bytes = input.as_bytes();
3540        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
3541        for chunk in bytes.chunks(4) {
3542            let mut n = 0u32;
3543            let mut valid = 0;
3544            for (i, &c) in chunk.iter().enumerate() {
3545                n |= val(c)? << (18 - 6 * i);
3546                valid += 1;
3547            }
3548            out.push((n >> 16) as u8);
3549            if valid > 2 {
3550                out.push((n >> 8) as u8);
3551            }
3552            if valid > 3 {
3553                out.push(n as u8);
3554            }
3555        }
3556        Some(out)
3557    }
3558
3559    // -- HMAC-SHA256, std-only -------------------------------------------------
3560
3561    /// HMAC-SHA256(key, msg) as lowercase hex.
3562    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
3563        const BLOCK: usize = 64;
3564        let mut k = [0u8; BLOCK];
3565        if key.len() > BLOCK {
3566            let d = sha256(key);
3567            k[..32].copy_from_slice(&d);
3568        } else {
3569            k[..key.len()].copy_from_slice(key);
3570        }
3571        let mut ipad = [0x36u8; BLOCK];
3572        let mut opad = [0x5cu8; BLOCK];
3573        for i in 0..BLOCK {
3574            ipad[i] ^= k[i];
3575            opad[i] ^= k[i];
3576        }
3577        let mut inner = Vec::with_capacity(BLOCK + msg.len());
3578        inner.extend_from_slice(&ipad);
3579        inner.extend_from_slice(msg);
3580        let inner_hash = sha256(&inner);
3581        let mut outer = Vec::with_capacity(BLOCK + 32);
3582        outer.extend_from_slice(&opad);
3583        outer.extend_from_slice(&inner_hash);
3584        let mac = sha256(&outer);
3585        let mut hex = String::with_capacity(64);
3586        for b in mac {
3587            hex.push_str(&format!("{b:02x}"));
3588        }
3589        hex
3590    }
3591
3592    /// SHA-256 (FIPS 180-4), std-only.
3593    fn sha256(data: &[u8]) -> [u8; 32] {
3594        const K: [u32; 64] = [
3595            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
3596            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
3597            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
3598            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
3599            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
3600            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
3601            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
3602            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
3603            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
3604            0xc67178f2,
3605        ];
3606        let mut h: [u32; 8] = [
3607            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
3608            0x5be0cd19,
3609        ];
3610
3611        let bit_len = (data.len() as u64) * 8;
3612        let mut msg = data.to_vec();
3613        msg.push(0x80);
3614        while msg.len() % 64 != 56 {
3615            msg.push(0);
3616        }
3617        msg.extend_from_slice(&bit_len.to_be_bytes());
3618
3619        for block in msg.chunks(64) {
3620            let mut w = [0u32; 64];
3621            for i in 0..16 {
3622                w[i] = u32::from_be_bytes([
3623                    block[i * 4],
3624                    block[i * 4 + 1],
3625                    block[i * 4 + 2],
3626                    block[i * 4 + 3],
3627                ]);
3628            }
3629            for i in 16..64 {
3630                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
3631                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
3632                w[i] = w[i - 16]
3633                    .wrapping_add(s0)
3634                    .wrapping_add(w[i - 7])
3635                    .wrapping_add(s1);
3636            }
3637            let mut a = h;
3638            for i in 0..64 {
3639                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
3640                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
3641                let t1 = a[7]
3642                    .wrapping_add(s1)
3643                    .wrapping_add(ch)
3644                    .wrapping_add(K[i])
3645                    .wrapping_add(w[i]);
3646                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
3647                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
3648                let t2 = s0.wrapping_add(maj);
3649                a[7] = a[6];
3650                a[6] = a[5];
3651                a[5] = a[4];
3652                a[4] = a[3].wrapping_add(t1);
3653                a[3] = a[2];
3654                a[2] = a[1];
3655                a[1] = a[0];
3656                a[0] = t1.wrapping_add(t2);
3657            }
3658            for i in 0..8 {
3659                h[i] = h[i].wrapping_add(a[i]);
3660            }
3661        }
3662
3663        let mut out = [0u8; 32];
3664        for (i, word) in h.iter().enumerate() {
3665            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
3666        }
3667        out
3668    }
3669
3670    #[cfg(test)]
3671    mod tests {
3672        use super::*;
3673
3674        #[test]
3675        fn sha256_known_vector() {
3676            let d = sha256(b"abc");
3677            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
3678            assert_eq!(
3679                hex,
3680                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
3681            );
3682        }
3683
3684        #[test]
3685        fn hmac_known_vector() {
3686            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
3687            assert_eq!(
3688                mac,
3689                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
3690            );
3691        }
3692
3693        #[test]
3694        fn sign_verify_round_trips() {
3695            let secret = "test-secret";
3696            let sid = "9f2c-opaque-session-id";
3697            let cookie = sign_session(sid, secret);
3698            let pair = cookie.split(';').next().unwrap().to_string();
3699            let mut headers = HeaderMap::new();
3700            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
3701            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
3702            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
3703            assert!(verify_session(&headers, "other-secret").is_none());
3704        }
3705
3706        #[test]
3707        fn forged_and_tampered_cookies_are_rejected() {
3708            let secret = "test-secret";
3709
3710            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
3711            //    the secret, so an arbitrary signature must not verify.
3712            let forged = format!(
3713                "{SESSION_COOKIE}={}.{}",
3714                b64url_encode(b"attacker-chosen-sid"),
3715                "deadbeef".repeat(8) // 64 hex chars, wrong sig
3716            );
3717            let mut headers = HeaderMap::new();
3718            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
3719            assert!(verify_session(&headers, secret).is_none());
3720
3721            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
3722            //    keeping the original signature — must not verify.
3723            let cookie = sign_session("real-sid", secret);
3724            let pair = cookie.split(';').next().unwrap();
3725            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
3726            let tampered = format!(
3727                "{SESSION_COOKIE}={}.{}",
3728                b64url_encode(b"different-sid"),
3729                sig
3730            );
3731            let mut headers2 = HeaderMap::new();
3732            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
3733            assert!(verify_session(&headers2, secret).is_none());
3734        }
3735
3736        #[test]
3737        fn b64url_round_trips() {
3738            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
3739                let enc = b64url_encode(s.as_bytes());
3740                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
3741            }
3742        }
3743    }
3744}
3745
3746// ---------------------------------------------------------------------------
3747// Small store helpers local to the web layer
3748// ---------------------------------------------------------------------------
3749
3750/// Fetch a single cached entry by id.
3751/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
3752///
3753/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
3754/// `did` does not subscribe to its feed. This is the per-DID read gate for the
3755/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
3756/// deduped by URL, but no DID can read another DID's cached article.
3757async fn get_entry_by_id(
3758    pool: &store::Pool,
3759    did: &str,
3760    id: i64,
3761) -> anyhow::Result<Option<store::Entry>> {
3762    let entry = sqlx::query_as::<_, store::Entry>(
3763        r#"
3764        SELECT e.* FROM entries e
3765        WHERE e.id = ?2
3766          AND EXISTS (
3767              SELECT 1 FROM sub_ref sr
3768              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3769          )
3770        "#,
3771    )
3772    .bind(did)
3773    .bind(id)
3774    .fetch_optional(pool)
3775    .await?;
3776    Ok(entry)
3777}
3778
3779/// Whether `entry_id` is marked read for `did` (absent state row = unread).
3780async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3781    let read: Option<bool> =
3782        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3783            .bind(did)
3784            .bind(entry_id)
3785            .fetch_optional(pool)
3786            .await?
3787            .flatten();
3788    Ok(read.unwrap_or(false))
3789}
3790
3791/// Whether `entry_id` is starred for `did` (absent state row = not starred).
3792async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
3793    let starred: Option<bool> =
3794        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
3795            .bind(did)
3796            .bind(entry_id)
3797            .fetch_optional(pool)
3798            .await?
3799            .flatten();
3800    Ok(starred.unwrap_or(false))
3801}
3802
3803/// Feed display title for one entry's feed id (via a single lookup).
3804async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
3805    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
3806        .bind(feed_id)
3807        .fetch_optional(pool)
3808        .await
3809    {
3810        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
3811        _ => String::new(),
3812    }
3813}
3814
3815/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
3816/// be forced (mark-read path) or looked up (`None` — star path).
3817async fn build_entry_row(
3818    pool: &store::Pool,
3819    did: &str,
3820    id: i64,
3821    read: Option<bool>,
3822) -> anyhow::Result<Option<EntryRow>> {
3823    let entry = match get_entry_by_id(pool, did, id).await? {
3824        Some(e) => e,
3825        None => return Ok(None),
3826    };
3827    let read = match read {
3828        Some(r) => r,
3829        None => entry_is_read(pool, did, id).await?,
3830    };
3831    let starred = entry_is_starred(pool, did, id).await?;
3832    Ok(Some(EntryRow {
3833        id: entry.id,
3834        title: entry
3835            .title
3836            .clone()
3837            .filter(|t| !t.trim().is_empty())
3838            .unwrap_or_else(|| "(untitled)".to_string()),
3839        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
3840        published: display_date(entry.published.as_deref()),
3841        read,
3842        starred,
3843        link: format!("/entries/{id}"),
3844    }))
3845}
3846
3847/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
3848fn now_rfc3339() -> String {
3849    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3850}
3851
3852#[cfg(test)]
3853mod tests {
3854    use super::*;
3855
3856    #[test]
3857    fn qenc_encodes_reserved() {
3858        assert_eq!(qenc("a b"), "a%20b");
3859        assert_eq!(
3860            qenc("https://example.com/feed.xml"),
3861            "https%3A%2F%2Fexample.com%2Ffeed.xml"
3862        );
3863        assert_eq!(
3864            qenc("at://did:plc:x/c/r"),
3865            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
3866        );
3867        // Unreserved chars pass through untouched.
3868        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
3869    }
3870
3871    #[test]
3872    fn folder_uri_shape() {
3873        assert_eq!(
3874            folder_uri("did:plc:abc", "3kfolder"),
3875            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
3876        );
3877    }
3878
3879    // -- public-feeds-only: private/paid feeds are refused --------------------
3880
3881    #[test]
3882    fn private_feeds_are_classified_private_across_providers() {
3883        // The add + OPML paths both gate on this classifier; assert it flags a
3884        // spread of paid providers (newsletters + private podcasts) and the
3885        // generic credential-in-URL shapes.
3886        for url in [
3887            "https://author.substack.com/feed/private/deadbeefcafe1234",
3888            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
3889            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
3890            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
3891            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
3892            "https://user:pass@example.com/feed",
3893        ] {
3894            assert!(
3895                feed::classify_feed_privacy(url).is_private(),
3896                "expected private: {url}"
3897            );
3898        }
3899    }
3900
3901    #[test]
3902    fn public_feeds_stay_public() {
3903        for url in [
3904            "https://author.substack.com/feed",
3905            "https://wordpress.example.com/feed/",
3906            "https://example.com/rss.xml",
3907            "https://example.org/atom.xml",
3908            // YouTube channel/playlist RSS is fully public — must not false-block.
3909            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
3910            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
3911        ] {
3912            assert!(
3913                !feed::classify_feed_privacy(url).is_private(),
3914                "expected public: {url}"
3915            );
3916        }
3917    }
3918
3919    #[test]
3920    fn private_feed_label_is_public_safe_host_only() {
3921        // The OPML skip report must never echo the secret path/query, only the host.
3922        let label =
3923            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
3924        assert_eq!(label, "author.substack.com");
3925        assert!(!label.contains("deadbeefcafe1234token"));
3926        assert!(!label.contains("/private/"));
3927        // An unparseable URL degrades to a generic label.
3928        assert_eq!(private_feed_label("not a url"), "a private feed");
3929    }
3930
3931    #[test]
3932    fn refusal_message_promises_nothing_stored() {
3933        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
3934        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
3935    }
3936
3937    #[test]
3938    fn scope_query_preserves_context() {
3939        let q = EntryQuery {
3940            feed: Some("https://example.com/feed.xml".to_string()),
3941            folder: None,
3942            view: Some("all".to_string()),
3943        };
3944        let s = scope_query(&q);
3945        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
3946        assert!(s.contains("view=all"));
3947
3948        // Default view is omitted.
3949        let q2 = EntryQuery {
3950            feed: None,
3951            folder: None,
3952            view: Some("unread".to_string()),
3953        };
3954        assert_eq!(scope_query(&q2), "");
3955    }
3956
3957    // -- closed-beta invite gate + rate-limit + cache-control ------------------
3958
3959    use axum::body::Body;
3960    use axum::http::Request;
3961    use tower::ServiceExt; // for `oneshot`
3962
3963    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
3964    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
3965    /// can forge matching cookies.
3966    async fn test_state(allowed: &[&str]) -> AppState {
3967        let db = store::init_url("sqlite::memory:").await.unwrap();
3968        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
3969        store::ensure_seed(&db, &dids).await.unwrap();
3970        let config = Config {
3971            allowed_dids: dids,
3972            cookie_secret: "test-cookie-secret-000".to_string(),
3973            beta_cap: 3,
3974            ..Config::default()
3975        };
3976        AppState::new(config, db).unwrap()
3977    }
3978
3979    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
3980    /// looked up in the registry, so create the session first).
3981    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
3982        let sid = state.sessions.create(Session {
3983            did: did.to_string(),
3984            handle: handle.map(str::to_string),
3985        });
3986        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
3987        sc.split(';').next().unwrap().to_string()
3988    }
3989
3990    #[test]
3991    fn rate_limited_paths_match_expected() {
3992        use axum::http::Method;
3993        assert!(is_rate_limited_path("/login", &Method::GET));
3994        assert!(is_rate_limited_path("/login", &Method::POST));
3995        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
3996        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
3997        assert!(is_rate_limited_path("/opml", &Method::POST));
3998        assert!(is_rate_limited_path("/read-all", &Method::POST));
3999        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
4000        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
4001        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
4002        // Read-only navigation is NOT limited.
4003        assert!(!is_rate_limited_path("/", &Method::GET));
4004        assert!(!is_rate_limited_path("/about", &Method::GET));
4005        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
4006        assert!(!is_rate_limited_path("/login", &Method::HEAD));
4007    }
4008
4009    #[test]
4010    fn rate_limiter_allows_burst_then_429s() {
4011        let rl = RateLimiter::shared();
4012        let ip: IpAddr = "203.0.113.7".parse().unwrap();
4013        // The full burst passes.
4014        for _ in 0..(RATE_BURST as usize) {
4015            assert!(rl.check(ip));
4016        }
4017        // The next one (no time elapsed → no refill) is rejected.
4018        assert!(!rl.check(ip));
4019        // A different IP has its own bucket.
4020        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
4021        assert!(rl.check(ip2));
4022    }
4023
4024    #[test]
4025    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
4026        // With NO trusted header configured, a client-supplied X-Forwarded-For
4027        // must be ignored entirely — the limiter keys on the real socket peer,
4028        // so an attacker can't mint a fresh bucket per forged XFF value.
4029        let mut h = HeaderMap::new();
4030        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
4031        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
4032        assert_eq!(
4033            client_ip(&h, Some(&sock), None),
4034            Some("203.0.113.55".parse().unwrap()),
4035            "spoofed XFF must not override the socket peer"
4036        );
4037    }
4038
4039    #[test]
4040    fn client_ip_uses_trusted_header_last_hop() {
4041        // With a trusted proxy header configured, the client IP comes from THAT
4042        // header (the proxy overwrites any client copy). On a comma list we take
4043        // the RIGHT-most hop — the one the trusted proxy appended — so a
4044        // client-forged left-most value is ignored.
4045        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
4046
4047        let mut h = HeaderMap::new();
4048        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
4049        assert_eq!(
4050            client_ip(&h, Some(&sock), Some("fly-client-ip")),
4051            Some("198.51.100.9".parse().unwrap())
4052        );
4053
4054        // Attacker prepends a forged hop; the trusted proxy appends the real one.
4055        let mut h2 = HeaderMap::new();
4056        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
4057        assert_eq!(
4058            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
4059            Some("198.51.100.9".parse().unwrap()),
4060            "must take the right-most (trusted) hop, not the forged left-most"
4061        );
4062
4063        // Trusted header absent → fall back to the socket peer.
4064        let h3 = HeaderMap::new();
4065        assert_eq!(
4066            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
4067            Some("10.0.0.1".parse().unwrap())
4068        );
4069    }
4070
4071    #[test]
4072    fn invite_cookie_round_trips_and_rejects_tamper() {
4073        let secret = "test-cookie-secret-000";
4074        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
4075        let pair = sc.split(';').next().unwrap();
4076        let mut h = HeaderMap::new();
4077        h.insert(header::COOKIE, pair.parse().unwrap());
4078        assert_eq!(
4079            invite_cookie_code(&h, secret).as_deref(),
4080            Some("FEATHER-ABCDWXYZ")
4081        );
4082        // Wrong secret → rejected.
4083        assert!(invite_cookie_code(&h, "other").is_none());
4084    }
4085
4086    #[tokio::test]
4087    async fn preflight_valid_expired_and_full() {
4088        let state = test_state(&["did:plc:admin"]).await;
4089        // A minted, active code preflights OK.
4090        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
4091            .await
4092            .unwrap();
4093        assert!(preflight_code(&state, &code).await.is_ok());
4094
4095        // A code whose expiry is in the past preflights as Expired. (mint_code
4096        // clamps negative ttl to 0, so back-date the row directly for a
4097        // deterministic past expiry.)
4098        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
4099            .await
4100            .unwrap();
4101        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
4102            .bind(chrono::Utc::now().timestamp() - 3600)
4103            .bind(&expired)
4104            .execute(&state.db)
4105            .await
4106            .unwrap();
4107        assert_eq!(
4108            preflight_code(&state, &expired).await,
4109            Err(store::RedeemError::Expired)
4110        );
4111
4112        // Unknown code → NotFound.
4113        assert_eq!(
4114            preflight_code(&state, "FEATHER-NOPENOPE").await,
4115            Err(store::RedeemError::NotFound)
4116        );
4117
4118        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
4119        // must report CapacityFull.
4120        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
4121            .await
4122            .unwrap();
4123        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
4124            .await
4125            .unwrap();
4126        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
4127        assert_eq!(
4128            preflight_code(&state, &code).await,
4129            Err(store::RedeemError::CapacityFull)
4130        );
4131    }
4132
4133    // -- Bot claim link + shared-secret mint ---------------------------------
4134
4135    /// A test state with a configured bot secret (so `/bot/claims` is live).
4136    async fn bot_state(bot_secret: &str) -> AppState {
4137        let db = store::init_url("sqlite::memory:").await.unwrap();
4138        store::ensure_seed(&db, &["did:plc:admin".to_string()])
4139            .await
4140            .unwrap();
4141        let config = Config {
4142            allowed_dids: vec!["did:plc:admin".to_string()],
4143            cookie_secret: "test-cookie-secret-000".to_string(),
4144            beta_cap: 3,
4145            bot_secret: Some(bot_secret.to_string()),
4146            public_url: "https://feather-reader.com".to_string(),
4147            ..Config::default()
4148        };
4149        AppState::new(config, db).unwrap()
4150    }
4151
4152    #[test]
4153    fn claim_token_round_trips_and_rejects_tamper() {
4154        let secret = "test-cookie-secret-000";
4155        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
4156        // No cookie framing — a bare URL-safe token.
4157        assert!(!token.contains(';'));
4158        assert_eq!(
4159            claim_token_code(&token, secret).as_deref(),
4160            Some("FEATHER-ABCDWXYZ")
4161        );
4162        // Wrong secret → rejected.
4163        assert!(claim_token_code(&token, "other").is_none());
4164        // Tampered token → rejected.
4165        let mut bad = token.clone();
4166        bad.push('x');
4167        assert!(claim_token_code(&bad, secret).is_none());
4168        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
4169        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
4170        // recover it WITHOUT the secret). The security is single-use + HMAC
4171        // integrity + rate-limit, not secrecy of the code. Assert the code half is
4172        // publicly decodable (a plain base64url decode, no secret involved).
4173        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
4174        assert_eq!(
4175            test_b64url_decode(b64).as_deref(),
4176            Some("FEATHER-ABCDWXYZ".as_bytes()),
4177            "the code half of the token is plain base64url, decodable by anyone"
4178        );
4179    }
4180
4181    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
4182    /// claim token's code half needs NO secret to recover (it is not confidential).
4183    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
4184        fn val(c: u8) -> Option<u32> {
4185            match c {
4186                b'A'..=b'Z' => Some((c - b'A') as u32),
4187                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
4188                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
4189                b'-' => Some(62),
4190                b'_' => Some(63),
4191                _ => None,
4192            }
4193        }
4194        let mut out = Vec::with_capacity(input.len() / 4 * 3);
4195        for chunk in input.as_bytes().chunks(4) {
4196            let mut n = 0u32;
4197            let mut bits = 0;
4198            for &c in chunk {
4199                n = (n << 6) | val(c)?;
4200                bits += 6;
4201            }
4202            let bytes = bits / 8;
4203            n <<= 24 - bits;
4204            for i in 0..bytes {
4205                out.push((n >> (16 - i * 8)) as u8);
4206            }
4207        }
4208        Some(out)
4209    }
4210
4211    #[tokio::test]
4212    async fn bot_mint_then_claim_grants_a_seat() {
4213        let state = bot_state("bot-secret-abcdef").await;
4214        let app = router(state.clone());
4215
4216        // 1. Mint a claim via the shared-secret endpoint.
4217        let resp = app
4218            .clone()
4219            .oneshot(
4220                Request::builder()
4221                    .method("POST")
4222                    .uri("/bot/claims")
4223                    .header("x-bot-secret", "bot-secret-abcdef")
4224                    .body(Body::empty())
4225                    .unwrap(),
4226            )
4227            .await
4228            .unwrap();
4229        assert_eq!(resp.status(), StatusCode::OK);
4230        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
4231            .await
4232            .unwrap();
4233        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
4234        let token = json["token"].as_str().unwrap().to_string();
4235        let url = json["url"].as_str().unwrap();
4236        assert!(url.starts_with("https://feather-reader.com/claim?t="));
4237        // The raw code is returned for the bot's records but not embedded in url.
4238        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
4239        assert!(!url.contains("FEATHER-"));
4240
4241        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
4242        let resp = app
4243            .clone()
4244            .oneshot(
4245                Request::builder()
4246                    .method("GET")
4247                    .uri(format!("/claim?t={}", qenc(&token)))
4248                    .body(Body::empty())
4249                    .unwrap(),
4250            )
4251            .await
4252            .unwrap();
4253        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4254        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
4255        let set_cookie = resp
4256            .headers()
4257            .get(header::SET_COOKIE)
4258            .unwrap()
4259            .to_str()
4260            .unwrap();
4261        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
4262
4263        // 3. The reserved cookie carries the same code the token wrapped, and
4264        //    redeeming it (the callback's machinery) grants a seat.
4265        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
4266        let out = store::redeem_code(
4267            &state.db,
4268            &code,
4269            "did:plc:follower",
4270            None,
4271            state.config.beta_cap,
4272        )
4273        .await
4274        .unwrap();
4275        assert_eq!(out, Ok(()));
4276        assert!(store::has_beta_access(&state.db, "did:plc:follower")
4277            .await
4278            .unwrap());
4279    }
4280
4281    #[tokio::test]
4282    async fn claim_with_invalid_token_bounces() {
4283        let state = bot_state("bot-secret-abcdef").await;
4284        let app = router(state);
4285        let resp = app
4286            .oneshot(
4287                Request::builder()
4288                    .method("GET")
4289                    .uri("/claim?t=not-a-real-token")
4290                    .body(Body::empty())
4291                    .unwrap(),
4292            )
4293            .await
4294            .unwrap();
4295        // Renders the invite page (200), NOT a redirect to /login.
4296        assert_eq!(resp.status(), StatusCode::OK);
4297    }
4298
4299    #[tokio::test]
4300    async fn claim_with_used_token_is_refused() {
4301        let state = bot_state("bot-secret-abcdef").await;
4302        // Mint a code + wrap it, then redeem it out from under the token.
4303        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
4304            .await
4305            .unwrap();
4306        let token = sign_claim_token(&code, &state.config.cookie_secret);
4307        store::redeem_code(
4308            &state.db,
4309            &code,
4310            "did:plc:someone",
4311            None,
4312            state.config.beta_cap,
4313        )
4314        .await
4315        .unwrap()
4316        .unwrap();
4317        let app = router(state);
4318        let resp = app
4319            .oneshot(
4320                Request::builder()
4321                    .method("GET")
4322                    .uri(format!("/claim?t={}", qenc(&token)))
4323                    .body(Body::empty())
4324                    .unwrap(),
4325            )
4326            .await
4327            .unwrap();
4328        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
4329        assert_eq!(resp.status(), StatusCode::OK);
4330        assert!(resp.headers().get(header::SET_COOKIE).is_none());
4331    }
4332
4333    #[tokio::test]
4334    async fn bot_claims_rejects_bad_and_missing_secret() {
4335        let state = bot_state("bot-secret-abcdef").await;
4336        let app = router(state);
4337        // Wrong secret.
4338        let resp = app
4339            .clone()
4340            .oneshot(
4341                Request::builder()
4342                    .method("POST")
4343                    .uri("/bot/claims")
4344                    .header("x-bot-secret", "wrong")
4345                    .body(Body::empty())
4346                    .unwrap(),
4347            )
4348            .await
4349            .unwrap();
4350        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
4351        // Missing secret.
4352        let resp = app
4353            .oneshot(
4354                Request::builder()
4355                    .method("POST")
4356                    .uri("/bot/claims")
4357                    .body(Body::empty())
4358                    .unwrap(),
4359            )
4360            .await
4361            .unwrap();
4362        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
4363    }
4364
4365    #[tokio::test]
4366    async fn bot_claims_disabled_when_secret_unset() {
4367        // test_state configures NO bot secret → the endpoint is off (503).
4368        let state = test_state(&["did:plc:admin"]).await;
4369        let app = router(state);
4370        let resp = app
4371            .oneshot(
4372                Request::builder()
4373                    .method("POST")
4374                    .uri("/bot/claims")
4375                    .header("x-bot-secret", "anything")
4376                    .body(Body::empty())
4377                    .unwrap(),
4378            )
4379            .await
4380            .unwrap();
4381        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
4382    }
4383
4384    #[tokio::test]
4385    async fn bot_claims_refuses_at_capacity() {
4386        let state = bot_state("bot-secret-abcdef").await;
4387        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
4388        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
4389            .await
4390            .unwrap();
4391        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
4392            .await
4393            .unwrap();
4394        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
4395        let app = router(state);
4396        let resp = app
4397            .oneshot(
4398                Request::builder()
4399                    .method("POST")
4400                    .uri("/bot/claims")
4401                    .header("x-bot-secret", "bot-secret-abcdef")
4402                    .body(Body::empty())
4403                    .unwrap(),
4404            )
4405            .await
4406            .unwrap();
4407        assert_eq!(resp.status(), StatusCode::CONFLICT);
4408        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
4409            .await
4410            .unwrap();
4411        assert!(String::from_utf8_lossy(&bytes).contains("full"));
4412    }
4413
4414    #[tokio::test]
4415    async fn bot_claims_counts_outstanding_codes_against_cap() {
4416        let state = bot_state("bot-secret-abcdef").await;
4417        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
4418        store::mint_code(&state.db, "did:plc:admin", 3600)
4419            .await
4420            .unwrap();
4421        store::mint_code(&state.db, "did:plc:admin", 3600)
4422            .await
4423            .unwrap();
4424        let app = router(state);
4425        let resp = app
4426            .oneshot(
4427                Request::builder()
4428                    .method("POST")
4429                    .uri("/bot/claims")
4430                    .header("x-bot-secret", "bot-secret-abcdef")
4431                    .body(Body::empty())
4432                    .unwrap(),
4433            )
4434            .await
4435            .unwrap();
4436        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
4437        assert_eq!(resp.status(), StatusCode::CONFLICT);
4438    }
4439
4440    /// POST /bot/claims with a JSON body carrying the follower DID.
4441    async fn post_bot_claim_for(
4442        app: &axum::Router,
4443        secret: &str,
4444        did: &str,
4445    ) -> (StatusCode, serde_json::Value) {
4446        let resp = app
4447            .clone()
4448            .oneshot(
4449                Request::builder()
4450                    .method("POST")
4451                    .uri("/bot/claims")
4452                    .header("x-bot-secret", secret)
4453                    .header("content-type", "application/json")
4454                    .body(Body::from(format!(
4455                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
4456                    )))
4457                    .unwrap(),
4458            )
4459            .await
4460            .unwrap();
4461        let status = resp.status();
4462        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
4463            .await
4464            .unwrap();
4465        let json = if bytes.is_empty() {
4466            serde_json::Value::Null
4467        } else {
4468            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
4469        };
4470        (status, json)
4471    }
4472
4473    #[tokio::test]
4474    async fn bot_claims_returns_already_seated_for_a_member() {
4475        // A DID that already holds beta access must get `already_seated` with NO
4476        // code/url — the bot posts nothing. This is the server-side backstop that
4477        // survives a bot-host state loss (it would otherwise re-mint + re-post).
4478        let state = bot_state("bot-secret-abcdef").await;
4479        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
4480            .await
4481            .unwrap();
4482        let app = router(state.clone());
4483        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
4484        assert_eq!(status, StatusCode::OK);
4485        assert_eq!(json["status"], "already_seated");
4486        assert_eq!(json["code"], "");
4487        assert_eq!(json["url"], "");
4488        // No new invite code was minted for the seated DID.
4489        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
4490            .await
4491            .unwrap()
4492            .is_none());
4493    }
4494
4495    #[tokio::test]
4496    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
4497        // Two mint requests for the SAME follower DID must return the SAME code
4498        // (the app is authoritative), never a second one — so a bot-host state loss
4499        // re-requesting cannot double-mint or double-post.
4500        let state = bot_state("bot-secret-abcdef").await;
4501        let app = router(state.clone());
4502
4503        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
4504        assert_eq!(s1, StatusCode::OK);
4505        assert_eq!(j1["status"], "minted");
4506        let code1 = j1["code"].as_str().unwrap().to_string();
4507
4508        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
4509        assert_eq!(s2, StatusCode::OK);
4510        assert_eq!(j2["status"], "existing");
4511        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
4512        assert_eq!(j2["url"], j1["url"], "same url returned");
4513
4514        // Exactly ONE active code exists for that DID.
4515        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
4516    }
4517
4518    #[tokio::test]
4519    async fn bot_claims_records_intended_did_at_mint() {
4520        // A fresh mint records the follower DID so the lookup finds it.
4521        let state = bot_state("bot-secret-abcdef").await;
4522        let app = router(state.clone());
4523        let (status, json) =
4524            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
4525        assert_eq!(status, StatusCode::OK);
4526        let code = json["code"].as_str().unwrap();
4527        assert_eq!(
4528            store::find_active_code_for_did(&state.db, "did:plc:follower2")
4529                .await
4530                .unwrap()
4531                .as_deref(),
4532            Some(code)
4533        );
4534    }
4535
4536    #[tokio::test]
4537    async fn bot_claims_concurrent_same_did_never_double_mints() {
4538        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
4539        // active code. The dedupe check (3b) and the mint are separate statements,
4540        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
4541        // then makes the loser's INSERT conflict, and the handler recovers by
4542        // returning the winner's code (status `existing`) rather than 500-ing.
4543        // Result: exactly ONE active code, and BOTH callers get a usable code.
4544        let state = bot_state("bot-secret-abcdef").await;
4545        let app = router(state.clone());
4546
4547        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
4548        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
4549        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
4550
4551        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
4552        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
4553
4554        // Exactly one active code for the DID — the whole point of the fix.
4555        assert_eq!(
4556            store::count_active_codes(&state.db).await.unwrap(),
4557            1,
4558            "concurrent mints must not create two active codes"
4559        );
4560
4561        // Both callers received the SAME (single) code, and neither got a 500.
4562        let ca = ja["code"].as_str().unwrap_or("");
4563        let cb = jb["code"].as_str().unwrap_or("");
4564        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
4565        assert_eq!(ca, cb, "both callers must get the one minted code");
4566        // One is `minted` (the winner), the other `minted` or `existing` depending
4567        // on interleaving — but never an error status.
4568        for st in [&ja["status"], &jb["status"]] {
4569            let s = st.as_str().unwrap_or("");
4570            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
4571        }
4572    }
4573
4574    #[tokio::test]
4575    async fn bot_claims_rejects_malformed_json_body() {
4576        let state = bot_state("bot-secret-abcdef").await;
4577        let app = router(state);
4578        let resp = app
4579            .oneshot(
4580                Request::builder()
4581                    .method("POST")
4582                    .uri("/bot/claims")
4583                    .header("x-bot-secret", "bot-secret-abcdef")
4584                    .header("content-type", "application/json")
4585                    .body(Body::from("{not json"))
4586                    .unwrap(),
4587            )
4588            .await
4589            .unwrap();
4590        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
4591    }
4592
4593    #[tokio::test]
4594    async fn favicon_ico_served_at_root() {
4595        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
4596        // tags in <head>; the root route must serve the icon, not 404.
4597        let state = test_state(&[]).await;
4598        let app = router(state);
4599        let resp = app
4600            .oneshot(
4601                Request::builder()
4602                    .uri("/favicon.ico")
4603                    .body(Body::empty())
4604                    .unwrap(),
4605            )
4606            .await
4607            .unwrap();
4608        assert_eq!(resp.status(), StatusCode::OK);
4609        let ct = resp
4610            .headers()
4611            .get(header::CONTENT_TYPE)
4612            .unwrap()
4613            .to_str()
4614            .unwrap();
4615        assert!(
4616            ct.contains("icon") || ct.starts_with("image/"),
4617            "content-type = {ct}"
4618        );
4619    }
4620
4621    #[tokio::test]
4622    async fn login_without_invite_redirects_to_beta_redeem() {
4623        // No allow-list seed, no invite cookie: starting OAuth must be refused.
4624        let state = test_state(&[]).await;
4625        let app = router(state);
4626        let resp = app
4627            .oneshot(
4628                Request::builder()
4629                    .method("POST")
4630                    .uri("/login")
4631                    .header("content-type", "application/x-www-form-urlencoded")
4632                    .body(Body::from("handle=alice.bsky.social"))
4633                    .unwrap(),
4634            )
4635            .await
4636            .unwrap();
4637        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4638        assert_eq!(
4639            resp.headers().get(header::LOCATION).unwrap(),
4640            "/beta/redeem"
4641        );
4642    }
4643
4644    #[tokio::test]
4645    async fn login_with_valid_invite_cookie_starts_oauth() {
4646        let state = test_state(&[]).await;
4647        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
4648        let cookie = cookie.split(';').next().unwrap().to_string();
4649        let app = router(state);
4650        let resp = app
4651            .oneshot(
4652                Request::builder()
4653                    .method("POST")
4654                    .uri("/login")
4655                    .header("content-type", "application/x-www-form-urlencoded")
4656                    .header(header::COOKIE, cookie)
4657                    .body(Body::from("handle=alice.bsky.social"))
4658                    .unwrap(),
4659            )
4660            .await
4661            .unwrap();
4662        // Redirects into the sidecar login (not to /beta/redeem).
4663        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
4664        let loc = resp
4665            .headers()
4666            .get(header::LOCATION)
4667            .unwrap()
4668            .to_str()
4669            .unwrap();
4670        assert!(loc.contains("/login"), "loc = {loc}");
4671        assert_ne!(loc, "/beta/redeem");
4672    }
4673
4674    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
4675    /// any network resolution and that a resolution failure fails closed.
4676    async fn resolver_never(_handle: String) -> Option<String> {
4677        None
4678    }
4679
4680    /// A resolver that maps every handle to `did`.
4681    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
4682        move |_handle| std::future::ready(Some(did.to_string()))
4683    }
4684
4685    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
4686    /// that already holds a seat (the seeded-admin first-login case) passes the
4687    /// gate — no session cookie, no invite code.
4688    #[tokio::test]
4689    async fn may_start_oauth_honors_seat_via_resolved_handle() {
4690        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
4691        // no cookie on a fresh deploy.
4692        let state = test_state(&["did:plc:admin"]).await;
4693        let headers = HeaderMap::new();
4694        assert!(
4695            may_start_oauth_with(
4696                &state,
4697                &headers,
4698                "admin.example",
4699                resolver_to("did:plc:admin")
4700            )
4701            .await,
4702            "a handle resolving to a seated DID must pass the gate"
4703        );
4704    }
4705
4706    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
4707    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
4708    /// fails).
4709    #[tokio::test]
4710    async fn may_start_oauth_bounces_non_member_handle() {
4711        let state = test_state(&["did:plc:admin"]).await;
4712        let headers = HeaderMap::new();
4713        assert!(
4714            !may_start_oauth_with(
4715                &state,
4716                &headers,
4717                "rando.example",
4718                resolver_to("did:plc:rando")
4719            )
4720            .await,
4721            "a resolved DID with no seat must be bounced"
4722        );
4723    }
4724
4725    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
4726    /// bounces gracefully — no panic, no handshake.
4727    #[tokio::test]
4728    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
4729        let state = test_state(&["did:plc:admin"]).await;
4730        let headers = HeaderMap::new();
4731        assert!(
4732            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
4733            "an unresolvable handle must fail closed"
4734        );
4735    }
4736
4737    /// The session-cookie fast path admits a seated member WITHOUT calling the
4738    /// resolver (proven by injecting `resolver_never`, which would otherwise
4739    /// bounce).
4740    #[tokio::test]
4741    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
4742        let state = test_state(&[]).await;
4743        let did = "did:plc:member";
4744        store::grant_access(&state.db, did, Some("member.example"), "test", None)
4745            .await
4746            .unwrap();
4747        let cookie = session_cookie(&state, did, Some("member.example"));
4748        let mut headers = HeaderMap::new();
4749        headers.insert(header::COOKIE, cookie.parse().unwrap());
4750        assert!(
4751            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
4752            "a seated session cookie must pass without resolution"
4753        );
4754    }
4755
4756    /// The invite-cookie fast path admits WITHOUT calling the resolver.
4757    #[tokio::test]
4758    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
4759        let state = test_state(&[]).await;
4760        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
4761        let cookie = cookie.split(';').next().unwrap().to_string();
4762        let mut headers = HeaderMap::new();
4763        headers.insert(header::COOKIE, cookie.parse().unwrap());
4764        assert!(
4765            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
4766            "a valid invite cookie must pass without resolution"
4767        );
4768    }
4769
4770    #[tokio::test]
4771    async fn admin_mint_requires_admin_seed_did() {
4772        let state = test_state(&["did:plc:admin"]).await;
4773        // A non-admin (but beta'd) session is forbidden.
4774        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
4775            .await
4776            .unwrap();
4777        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
4778        // An admin session is allowed.
4779        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
4780        let app = router(state);
4781
4782        let forbidden = app
4783            .clone()
4784            .oneshot(
4785                Request::builder()
4786                    .method("POST")
4787                    .uri("/admin/invites?n=2")
4788                    .header(header::COOKIE, rando_cookie)
4789                    .body(Body::empty())
4790                    .unwrap(),
4791            )
4792            .await
4793            .unwrap();
4794        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
4795
4796        let ok = app
4797            .oneshot(
4798                Request::builder()
4799                    .method("POST")
4800                    .uri("/admin/invites?n=2")
4801                    .header(header::COOKIE, admin_cookie)
4802                    .body(Body::empty())
4803                    .unwrap(),
4804            )
4805            .await
4806            .unwrap();
4807        assert_eq!(ok.status(), StatusCode::OK);
4808        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
4809            .await
4810            .unwrap();
4811        let body = String::from_utf8(bytes.to_vec()).unwrap();
4812        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
4813        assert_eq!(minted.len(), 2);
4814        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
4815    }
4816
4817    #[tokio::test]
4818    async fn admin_mint_unauthenticated_is_401() {
4819        let state = test_state(&["did:plc:admin"]).await;
4820        let app = router(state);
4821        let resp = app
4822            .oneshot(
4823                Request::builder()
4824                    .method("POST")
4825                    .uri("/admin/invites")
4826                    .body(Body::empty())
4827                    .unwrap(),
4828            )
4829            .await
4830            .unwrap();
4831        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
4832    }
4833
4834    #[tokio::test]
4835    async fn cache_control_public_on_about_no_store_on_authed() {
4836        let state = test_state(&["did:plc:admin"]).await;
4837        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
4838        let app = router(state);
4839
4840        // /about → public, cacheable.
4841        let about = app
4842            .clone()
4843            .oneshot(
4844                Request::builder()
4845                    .uri("/about")
4846                    .body(Body::empty())
4847                    .unwrap(),
4848            )
4849            .await
4850            .unwrap();
4851        assert_eq!(
4852            about.headers().get(header::CACHE_CONTROL).unwrap(),
4853            "public, max-age=300"
4854        );
4855        // The security headers are still intact.
4856        assert!(about.headers().contains_key("content-security-policy"));
4857        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
4858
4859        // The bare /login landing → public, cacheable.
4860        let login = app
4861            .clone()
4862            .oneshot(
4863                Request::builder()
4864                    .uri("/login")
4865                    .body(Body::empty())
4866                    .unwrap(),
4867            )
4868            .await
4869            .unwrap();
4870        assert_eq!(
4871            login.headers().get(header::CACHE_CONTROL).unwrap(),
4872            "public, max-age=300"
4873        );
4874
4875        // An authenticated page → no-store.
4876        let home = app
4877            .oneshot(
4878                Request::builder()
4879                    .uri("/")
4880                    .header(header::COOKIE, admin_cookie)
4881                    .body(Body::empty())
4882                    .unwrap(),
4883            )
4884            .await
4885            .unwrap();
4886        assert_eq!(
4887            home.headers().get(header::CACHE_CONTROL).unwrap(),
4888            "no-store"
4889        );
4890    }
4891
4892    #[tokio::test]
4893    async fn beta_redeem_page_renders() {
4894        let state = test_state(&[]).await;
4895        let app = router(state);
4896        let resp = app
4897            .oneshot(
4898                Request::builder()
4899                    .uri("/beta/redeem")
4900                    .body(Body::empty())
4901                    .unwrap(),
4902            )
4903            .await
4904            .unwrap();
4905        assert_eq!(resp.status(), StatusCode::OK);
4906        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
4907            .await
4908            .unwrap();
4909        let html = String::from_utf8(bytes.to_vec()).unwrap();
4910        assert!(html.contains("Invite code"));
4911        assert!(html.contains("/beta/redeem"));
4912    }
4913
4914    #[tokio::test]
4915    async fn rate_limit_returns_429_after_burst() {
4916        // Configure a trusted proxy header so the limiter keys on the forwarded
4917        // IP (the oneshot harness sets no ConnectInfo socket peer).
4918        let db = store::init_url("sqlite::memory:").await.unwrap();
4919        store::ensure_seed(&db, &[]).await.unwrap();
4920        let config = Config {
4921            cookie_secret: "test-cookie-secret-000".to_string(),
4922            beta_cap: 3,
4923            trusted_ip_header: Some("cf-connecting-ip".to_string()),
4924            ..Config::default()
4925        };
4926        let state = AppState::new(config, db).unwrap();
4927        let app = router(state);
4928        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
4929        // handler itself returns 200 (re-render) on a bad code; the limiter is
4930        // what eventually yields 429.
4931        let mut saw_429 = false;
4932        for _ in 0..(RATE_BURST as usize + 5) {
4933            let resp = app
4934                .clone()
4935                .oneshot(
4936                    Request::builder()
4937                        .method("POST")
4938                        .uri("/beta/redeem")
4939                        .header("content-type", "application/x-www-form-urlencoded")
4940                        .header("cf-connecting-ip", "203.0.113.200")
4941                        .body(Body::from("code=FEATHER-NOPENOPE"))
4942                        .unwrap(),
4943                )
4944                .await
4945                .unwrap();
4946            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
4947                saw_429 = true;
4948                break;
4949            }
4950        }
4951        assert!(saw_429, "expected a 429 after exhausting the burst");
4952    }
4953
4954    #[tokio::test]
4955    async fn rate_limit_ignores_spoofed_xff_rotation() {
4956        // WITHOUT a trusted header, rotating a forged X-Forwarded-For per request
4957        // must NOT mint a fresh bucket each time: the oneshot harness sets no
4958        // socket peer, so client_ip yields None and the limiter fails open —
4959        // crucially it never keys on the attacker-chosen XFF. We assert every
4960        // request is admitted (no 429), proving the forged header is not being
4961        // used as the bucket key (which would be the vulnerable behaviour only if
4962        // it *were* trusted; here the burst can't be exhausted per-IP because the
4963        // attacker can't address a single victim bucket via XFF).
4964        let state = test_state(&[]).await;
4965        let app = router(state);
4966        for i in 0..(RATE_BURST as usize + 5) {
4967            let forged = format!("10.9.8.{}", i % 250);
4968            let resp = app
4969                .clone()
4970                .oneshot(
4971                    Request::builder()
4972                        .method("POST")
4973                        .uri("/beta/redeem")
4974                        .header("content-type", "application/x-www-form-urlencoded")
4975                        .header("x-forwarded-for", forged)
4976                        .body(Body::from("code=FEATHER-NOPENOPE"))
4977                        .unwrap(),
4978                )
4979                .await
4980                .unwrap();
4981            assert_ne!(
4982                resp.status(),
4983                StatusCode::TOO_MANY_REQUESTS,
4984                "untrusted XFF must not be used as the rate-limit key"
4985            );
4986        }
4987    }
4988
4989    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
4990
4991    /// Build a `multipart/form-data` body carrying a single `file` field whose
4992    /// contents are `payload`, returning `(content_type, body_bytes)`.
4993    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
4994        let boundary = "----featherreadertestboundary";
4995        let mut body = Vec::new();
4996        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
4997        body.extend_from_slice(
4998            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
4999        );
5000        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
5001        body.extend_from_slice(payload);
5002        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
5003        (format!("multipart/form-data; boundary={boundary}"), body)
5004    }
5005
5006    #[tokio::test]
5007    async fn opml_import_oversize_upload_returns_413() {
5008        let state = test_state(&["did:plc:admin"]).await;
5009        let cookie = session_cookie(&state, "did:plc:admin", None);
5010        let app = router(state);
5011
5012        // A payload comfortably above the 2 MiB route cap.
5013        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
5014        let (content_type, body) = opml_multipart(&payload);
5015
5016        let resp = app
5017            .oneshot(
5018                Request::builder()
5019                    .method("POST")
5020                    .uri("/opml")
5021                    .header("content-type", content_type)
5022                    .header(header::COOKIE, cookie)
5023                    .body(Body::from(body))
5024                    .unwrap(),
5025            )
5026            .await
5027            .unwrap();
5028        assert_eq!(
5029            resp.status(),
5030            StatusCode::PAYLOAD_TOO_LARGE,
5031            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
5032        );
5033    }
5034
5035    #[tokio::test]
5036    async fn opml_import_under_limit_upload_is_not_413() {
5037        let state = test_state(&["did:plc:admin"]).await;
5038        let cookie = session_cookie(&state, "did:plc:admin", None);
5039        let app = router(state);
5040
5041        // A small, valid OPML well under the cap: must be accepted (the handler
5042        // redirects to `/` or a flash), i.e. never 413.
5043        let opml = br#"<?xml version="1.0"?>
5044<opml version="2.0"><body>
5045  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
5046</body></opml>"#;
5047        let (content_type, body) = opml_multipart(opml);
5048
5049        let resp = app
5050            .oneshot(
5051                Request::builder()
5052                    .method("POST")
5053                    .uri("/opml")
5054                    .header("content-type", content_type)
5055                    .header(header::COOKIE, cookie)
5056                    .body(Body::from(body))
5057                    .unwrap(),
5058            )
5059            .await
5060            .unwrap();
5061        assert_ne!(
5062            resp.status(),
5063            StatusCode::PAYLOAD_TOO_LARGE,
5064            "an under-cap OPML upload must not be rejected as too large"
5065        );
5066    }
5067
5068    #[tokio::test]
5069    async fn opml_import_logged_out_redirects_to_login() {
5070        // Logged-out callers are redirected before the body is consumed; assert
5071        // the auth short-circuit rather than a body-cap rejection.
5072        let state = test_state(&["did:plc:admin"]).await;
5073        let app = router(state);
5074
5075        let opml = b"<opml version=\"2.0\"><body></body></opml>";
5076        let (content_type, body) = opml_multipart(opml);
5077
5078        let resp = app
5079            .oneshot(
5080                Request::builder()
5081                    .method("POST")
5082                    .uri("/opml")
5083                    .header("content-type", content_type)
5084                    .body(Body::from(body))
5085                    .unwrap(),
5086            )
5087            .await
5088            .unwrap();
5089        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5090        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
5091    }
5092
5093    // -- delete-my-data (POST /account/delete) --------------------------------
5094
5095    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
5096    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
5097    /// channel) the DID it was asked to revoke. Enough to prove the delete
5098    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
5099    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
5100        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5101        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5102        let addr = listener.local_addr().unwrap();
5103        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
5104        tokio::spawn(async move {
5105            let (mut sock, _) = listener.accept().await.unwrap();
5106            let mut buf = vec![0u8; 4096];
5107            let n = sock.read(&mut buf).await.unwrap();
5108            let req = String::from_utf8_lossy(&buf[..n]).to_string();
5109            // Pull the DID out of the JSON body (last line of the request).
5110            let did = req
5111                .split("\r\n\r\n")
5112                .nth(1)
5113                .and_then(|body| {
5114                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
5115                    v.get("did")?.as_str().map(str::to_string)
5116                })
5117                .unwrap_or_default();
5118            let is_revoke = req.starts_with("POST /internal/revoke");
5119            let body = serde_json::json!({
5120                "ok": true, "did": did, "revoked": true, "hadSession": true
5121            })
5122            .to_string();
5123            let resp = format!(
5124                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
5125                body.len(),
5126                body
5127            );
5128            sock.write_all(resp.as_bytes()).await.unwrap();
5129            sock.flush().await.unwrap();
5130            let _ = tx.send(if is_revoke { did } else { String::new() });
5131        });
5132        (format!("http://{addr}"), rx)
5133    }
5134
5135    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
5136    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
5137        let db = store::init_url("sqlite::memory:").await.unwrap();
5138        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
5139        store::ensure_seed(&db, &dids).await.unwrap();
5140        let mut config = Config {
5141            allowed_dids: dids,
5142            cookie_secret: "test-cookie-secret-000".to_string(),
5143            beta_cap: 3,
5144            ..Config::default()
5145        };
5146        config.sidecar.public_url = sidecar_url.to_string();
5147        // The revoke/session/repo calls go over the internal URL; point it at the
5148        // same mock so `/internal/*` requests land there.
5149        config.sidecar.internal_url = sidecar_url.to_string();
5150        AppState::new(config, db).unwrap()
5151    }
5152
5153    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
5154    /// the sidecar revoke for that DID, and clears the session cookie.
5155    #[tokio::test]
5156    async fn account_delete_purges_rows_and_triggers_revoke() {
5157        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
5158        let did = "did:plc:leaver";
5159        let state = test_state_with_sidecar(&[], &sidecar_url).await;
5160
5161        // Seed the DID with local rows across the per-DID tables.
5162        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
5163            .await
5164            .unwrap();
5165        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
5166        store::mint_code(&state.db, did, 3600).await.unwrap();
5167        assert!(store::has_beta_access(&state.db, did).await.unwrap());
5168
5169        let cookie = session_cookie(&state, did, Some("leaver.example"));
5170        let app = router(state.clone());
5171
5172        let resp = app
5173            .oneshot(
5174                Request::builder()
5175                    .method("POST")
5176                    .uri("/account/delete")
5177                    .header(header::COOKIE, cookie)
5178                    .header("content-type", "application/x-www-form-urlencoded")
5179                    .body(Body::from("confirm=DELETE"))
5180                    .unwrap(),
5181            )
5182            .await
5183            .unwrap();
5184
5185        // Signed out: redirect to /login with the cookie cleared.
5186        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5187        assert!(resp
5188            .headers()
5189            .get(header::LOCATION)
5190            .unwrap()
5191            .to_str()
5192            .unwrap()
5193            .starts_with("/login"));
5194        let set_cookie = resp
5195            .headers()
5196            .get(header::SET_COOKIE)
5197            .unwrap()
5198            .to_str()
5199            .unwrap();
5200        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
5201
5202        // The sidecar revoke was called for exactly this DID.
5203        let revoked_did = revoke_rx.await.unwrap();
5204        assert_eq!(
5205            revoked_did, did,
5206            "sidecar revoke must fire for the caller DID"
5207        );
5208
5209        // Local rows are gone.
5210        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
5211        let codes: i64 =
5212            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
5213                .bind(did)
5214                .fetch_one(&state.db)
5215                .await
5216                .unwrap();
5217        assert_eq!(codes, 0);
5218    }
5219
5220    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
5221    /// nothing and bounces back to /manage.
5222    #[tokio::test]
5223    async fn account_delete_without_confirm_is_a_noop() {
5224        let did = "did:plc:staying";
5225        let state = test_state(&[]).await;
5226        store::grant_access(&state.db, did, None, "test", None)
5227            .await
5228            .unwrap();
5229        let cookie = session_cookie(&state, did, None);
5230        let app = router(state.clone());
5231
5232        let resp = app
5233            .oneshot(
5234                Request::builder()
5235                    .method("POST")
5236                    .uri("/account/delete")
5237                    .header(header::COOKIE, cookie)
5238                    .header("content-type", "application/x-www-form-urlencoded")
5239                    .body(Body::from("confirm=nope"))
5240                    .unwrap(),
5241            )
5242            .await
5243            .unwrap();
5244
5245        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5246        assert!(resp
5247            .headers()
5248            .get(header::LOCATION)
5249            .unwrap()
5250            .to_str()
5251            .unwrap()
5252            .starts_with("/manage"));
5253        // Nothing deleted.
5254        assert!(store::has_beta_access(&state.db, did).await.unwrap());
5255    }
5256
5257    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
5258    /// this harness — the default sidecar URL is not served), a DID must STILL
5259    /// be unable to read or mutate an entry in a feed it does not subscribe to.
5260    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
5261    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
5262    /// every cached feed.
5263    #[tokio::test]
5264    async fn pds_outage_does_not_widen_cross_did_access() {
5265        let did_a = "did:plc:aaaa";
5266        let state = test_state(&[]).await;
5267        store::grant_access(&state.db, did_a, None, "test", None)
5268            .await
5269            .unwrap();
5270
5271        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
5272        // lives in feed_b — the one A must never touch during the outage.
5273        let feed_a = store::upsert_feed(
5274            &state.db,
5275            &store::NewFeed {
5276                url: "https://a.example/feed.xml".to_string(),
5277                title: Some("A".to_string()),
5278                ..Default::default()
5279            },
5280        )
5281        .await
5282        .unwrap();
5283        let feed_b = store::upsert_feed(
5284            &state.db,
5285            &store::NewFeed {
5286                url: "https://b.example/feed.xml".to_string(),
5287                title: Some("B".to_string()),
5288                ..Default::default()
5289            },
5290        )
5291        .await
5292        .unwrap();
5293        store::insert_entries(
5294            &state.db,
5295            feed_b,
5296            &[store::NewEntry {
5297                guid: "b-1".to_string(),
5298                url: Some("https://b.example/1".to_string()),
5299                title: Some("B one".to_string()),
5300                published: Some("2026-07-11T00:00:00Z".to_string()),
5301                content_html: Some("<p>secret B body</p>".to_string()),
5302                ..Default::default()
5303            }],
5304            0,
5305        )
5306        .await
5307        .unwrap();
5308        // A subscribes ONLY to feed_a.
5309        store::replace_sub_refs(&state.db, did_a, &[feed_a])
5310            .await
5311            .unwrap();
5312        // Read B's entry id via a transient sub_ref, then drop it so only the
5313        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
5314        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
5315            .await
5316            .unwrap();
5317        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
5318            .await
5319            .unwrap()[0]
5320            .id;
5321        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
5322            .await
5323            .unwrap();
5324
5325        let cookie = session_cookie(&state, did_a, None);
5326        let app = router(state.clone());
5327
5328        // GET /entries/{b} as A → 404 even during the outage.
5329        let get_b = app
5330            .clone()
5331            .oneshot(
5332                Request::builder()
5333                    .method("GET")
5334                    .uri(format!("/entries/{b_entry_id}"))
5335                    .header(header::COOKIE, cookie.clone())
5336                    .body(Body::empty())
5337                    .unwrap(),
5338            )
5339            .await
5340            .unwrap();
5341        assert_eq!(
5342            get_b.status(),
5343            StatusCode::NOT_FOUND,
5344            "A must not read B's entry during a PDS outage"
5345        );
5346
5347        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
5348        let read_b = app
5349            .oneshot(
5350                Request::builder()
5351                    .method("POST")
5352                    .uri(format!("/entries/{b_entry_id}/read"))
5353                    .header(header::COOKIE, cookie)
5354                    .header("content-type", "application/x-www-form-urlencoded")
5355                    .body(Body::from("read=true"))
5356                    .unwrap(),
5357            )
5358            .await
5359            .unwrap();
5360        assert_eq!(
5361            read_b.status(),
5362            StatusCode::NOT_FOUND,
5363            "A must not mark B's entry read during a PDS outage"
5364        );
5365
5366        // The fallback must NOT have widened A's sub_ref to feed_b.
5367        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
5368            .bind(did_a)
5369            .fetch_all(&state.db)
5370            .await
5371            .unwrap();
5372        assert_eq!(
5373            a_feed_ids,
5374            vec![feed_a],
5375            "outage fallback must not add feeds A never subscribed to"
5376        );
5377        // And B's entry has zero read-state (A's attempt did not mutate).
5378        let es_count: i64 =
5379            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5380                .bind(did_a)
5381                .bind(b_entry_id)
5382                .fetch_one(&state.db)
5383                .await
5384                .unwrap();
5385        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
5386    }
5387
5388    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
5389    /// seeding `did` a beta seat + session-capable state.
5390    async fn test_state_with_caps(
5391        did: &str,
5392        max_subs_per_did: i64,
5393        max_feeds_global: i64,
5394    ) -> AppState {
5395        let db = store::init_url("sqlite::memory:").await.unwrap();
5396        let config = Config {
5397            cookie_secret: "test-cookie-secret-000".to_string(),
5398            beta_cap: 100,
5399            max_subs_per_did,
5400            max_feeds_global,
5401            ..Config::default()
5402        };
5403        store::grant_access(&db, did, None, "test", None)
5404            .await
5405            .unwrap();
5406        AppState::new(config, db).unwrap()
5407    }
5408
5409    /// An OPML document with `n` distinct public feeds.
5410    fn opml_with_feeds(n: usize) -> String {
5411        let mut outlines = String::new();
5412        for i in 0..n {
5413            outlines.push_str(&format!(
5414                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
5415            ));
5416        }
5417        format!(
5418            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
5419        )
5420    }
5421
5422    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
5423    /// distinct new feeds than the shared cache can hold caches only up to the
5424    /// ceiling — the rest are trimmed. (Regression: the import loop previously
5425    /// bypassed `max_feeds_global` entirely.)
5426    #[tokio::test]
5427    async fn opml_import_enforces_global_feeds_ceiling() {
5428        let did = "did:plc:importer";
5429        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
5430        let state = test_state_with_caps(did, 0, 3).await;
5431        let cookie = session_cookie(&state, did, None);
5432        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
5433        let app = router(state.clone());
5434
5435        let resp = app
5436            .oneshot(
5437                Request::builder()
5438                    .method("POST")
5439                    .uri("/opml")
5440                    .header(header::COOKIE, cookie)
5441                    .header("content-type", ct)
5442                    .body(Body::from(body))
5443                    .unwrap(),
5444            )
5445            .await
5446            .unwrap();
5447        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5448
5449        let feeds = store::count_feeds(&state.db).await.unwrap();
5450        assert!(
5451            feeds <= 3,
5452            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
5453        );
5454    }
5455
5456    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
5457    /// cap imports zero new feeds.
5458    #[tokio::test]
5459    async fn opml_import_enforces_per_did_cap() {
5460        let did = "did:plc:capped";
5461        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
5462        let state = test_state_with_caps(did, 2, 0).await;
5463        let existing_a = store::upsert_feed(
5464            &state.db,
5465            &store::NewFeed {
5466                url: "https://have-a.example/feed.xml".to_string(),
5467                ..Default::default()
5468            },
5469        )
5470        .await
5471        .unwrap();
5472        let existing_b = store::upsert_feed(
5473            &state.db,
5474            &store::NewFeed {
5475                url: "https://have-b.example/feed.xml".to_string(),
5476                ..Default::default()
5477            },
5478        )
5479        .await
5480        .unwrap();
5481        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
5482            .await
5483            .unwrap();
5484        let before = store::count_feeds(&state.db).await.unwrap();
5485
5486        let cookie = session_cookie(&state, did, None);
5487        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
5488        let app = router(state.clone());
5489        let resp = app
5490            .oneshot(
5491                Request::builder()
5492                    .method("POST")
5493                    .uri("/opml")
5494                    .header(header::COOKIE, cookie)
5495                    .header("content-type", ct)
5496                    .body(Body::from(body))
5497                    .unwrap(),
5498            )
5499            .await
5500            .unwrap();
5501        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5502        // Headroom was 0 → no new feeds imported into the shared cache.
5503        let after = store::count_feeds(&state.db).await.unwrap();
5504        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
5505    }
5506
5507    /// Single-add per-DID cap: a DID at its subscription cap is refused before
5508    /// any fetch, with the limit flash.
5509    #[tokio::test]
5510    async fn single_add_enforces_per_did_cap() {
5511        let did = "did:plc:subcapped";
5512        let state = test_state_with_caps(did, 1, 0).await;
5513        let f = store::upsert_feed(
5514            &state.db,
5515            &store::NewFeed {
5516                url: "https://have.example/feed.xml".to_string(),
5517                ..Default::default()
5518            },
5519        )
5520        .await
5521        .unwrap();
5522        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
5523        let cookie = session_cookie(&state, did, None);
5524        let app = router(state.clone());
5525        let resp = app
5526            .oneshot(
5527                Request::builder()
5528                    .method("POST")
5529                    .uri("/subscriptions")
5530                    .header(header::COOKIE, cookie)
5531                    .header("content-type", "application/x-www-form-urlencoded")
5532                    .body(Body::from("url=https://another.example/feed.xml"))
5533                    .unwrap(),
5534            )
5535            .await
5536            .unwrap();
5537        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5538        let loc = resp
5539            .headers()
5540            .get(header::LOCATION)
5541            .unwrap()
5542            .to_str()
5543            .unwrap();
5544        assert!(
5545            loc.contains("Subscription%20limit%20reached"),
5546            "expected sub-limit flash, got {loc}"
5547        );
5548    }
5549
5550    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
5551    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
5552    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
5553    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
5554    /// view (no reader header) instead swaps the row. This guards the reader OOB
5555    /// toggle wiring, which had no test.
5556    #[tokio::test]
5557    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
5558        let did = "did:plc:reader";
5559        let state = test_state(&[]).await;
5560        store::grant_access(&state.db, did, None, "test", None)
5561            .await
5562            .unwrap();
5563        let feed = store::upsert_feed(
5564            &state.db,
5565            &store::NewFeed {
5566                url: "https://reader.example/feed.xml".to_string(),
5567                title: Some("Reader".to_string()),
5568                ..Default::default()
5569            },
5570        )
5571        .await
5572        .unwrap();
5573        store::insert_entries(
5574            &state.db,
5575            feed,
5576            &[store::NewEntry {
5577                guid: "r-1".to_string(),
5578                url: Some("https://reader.example/1".to_string()),
5579                title: Some("Article".to_string()),
5580                published: Some("2026-07-11T00:00:00Z".to_string()),
5581                content_html: Some("<p>body</p>".to_string()),
5582                ..Default::default()
5583            }],
5584            0,
5585        )
5586        .await
5587        .unwrap();
5588        store::replace_sub_refs(&state.db, did, &[feed])
5589            .await
5590            .unwrap();
5591        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
5592
5593        let cookie = session_cookie(&state, did, None);
5594        let app = router(state.clone());
5595
5596        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
5597        let resp = app
5598            .clone()
5599            .oneshot(
5600                Request::builder()
5601                    .method("POST")
5602                    .uri(format!("/entries/{entry_id}/read"))
5603                    .header(header::COOKIE, cookie.clone())
5604                    .header("HX-Request", "true")
5605                    .header("X-FR-Reader", "1")
5606                    .header("content-type", "application/x-www-form-urlencoded")
5607                    .body(Body::from("read=true"))
5608                    .unwrap(),
5609            )
5610            .await
5611            .unwrap();
5612        assert_eq!(resp.status(), StatusCode::OK);
5613        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
5614            .await
5615            .unwrap();
5616        let html = String::from_utf8(bytes.to_vec()).unwrap();
5617        assert!(
5618            html.contains("hx-swap-oob=\"outerHTML\""),
5619            "reader response must be an OOB swap: {html}"
5620        );
5621        assert!(
5622            html.contains(r#"id="entry-actionbar""#),
5623            "reader response must be the action-bar fragment: {html}"
5624        );
5625        // Now READ: the read button reflects it (aria-pressed=true) and the
5626        // hidden value flips to `false` so the next tap marks it UNREAD.
5627        assert!(
5628            html.contains(r#"aria-pressed="true""#),
5629            "read button must show pressed after marking read: {html}"
5630        );
5631        assert!(
5632            html.contains(r#"name="read" value="false""#),
5633            "hidden read value must flip to false so a second tap reverses: {html}"
5634        );
5635
5636        // A second reader mark-read (submitting the flipped `read=false`) marks
5637        // it UNREAD again — the toggle reverses.
5638        let resp2 = app
5639            .oneshot(
5640                Request::builder()
5641                    .method("POST")
5642                    .uri(format!("/entries/{entry_id}/read"))
5643                    .header(header::COOKIE, cookie)
5644                    .header("HX-Request", "true")
5645                    .header("X-FR-Reader", "1")
5646                    .header("content-type", "application/x-www-form-urlencoded")
5647                    .body(Body::from("read=false"))
5648                    .unwrap(),
5649            )
5650            .await
5651            .unwrap();
5652        assert_eq!(resp2.status(), StatusCode::OK);
5653        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
5654            .await
5655            .unwrap();
5656        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
5657        assert!(
5658            html2.contains(r#"aria-pressed="false""#),
5659            "read button must show un-pressed after reversing: {html2}"
5660        );
5661        assert!(
5662            html2.contains(r#"name="read" value="true""#),
5663            "hidden read value must flip back to true: {html2}"
5664        );
5665    }
5666
5667    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
5668    /// action-bar — the counterpart to the reader-OOB test above.
5669    #[tokio::test]
5670    async fn list_mark_read_returns_row_not_oob_actionbar() {
5671        let did = "did:plc:listv";
5672        let state = test_state(&[]).await;
5673        store::grant_access(&state.db, did, None, "test", None)
5674            .await
5675            .unwrap();
5676        let feed = store::upsert_feed(
5677            &state.db,
5678            &store::NewFeed {
5679                url: "https://list.example/feed.xml".to_string(),
5680                title: Some("List".to_string()),
5681                ..Default::default()
5682            },
5683        )
5684        .await
5685        .unwrap();
5686        store::insert_entries(
5687            &state.db,
5688            feed,
5689            &[store::NewEntry {
5690                guid: "l-1".to_string(),
5691                url: Some("https://list.example/1".to_string()),
5692                title: Some("Article".to_string()),
5693                published: Some("2026-07-11T00:00:00Z".to_string()),
5694                ..Default::default()
5695            }],
5696            0,
5697        )
5698        .await
5699        .unwrap();
5700        store::replace_sub_refs(&state.db, did, &[feed])
5701            .await
5702            .unwrap();
5703        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
5704
5705        let cookie = session_cookie(&state, did, None);
5706        let app = router(state.clone());
5707
5708        let resp = app
5709            .oneshot(
5710                Request::builder()
5711                    .method("POST")
5712                    .uri(format!("/entries/{entry_id}/read"))
5713                    .header(header::COOKIE, cookie)
5714                    .header("HX-Request", "true")
5715                    .header("content-type", "application/x-www-form-urlencoded")
5716                    .body(Body::from("read=true"))
5717                    .unwrap(),
5718            )
5719            .await
5720            .unwrap();
5721        assert_eq!(resp.status(), StatusCode::OK);
5722        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
5723            .await
5724            .unwrap();
5725        let html = String::from_utf8(bytes.to_vec()).unwrap();
5726        assert!(
5727            !html.contains("hx-swap-oob"),
5728            "list-view response must NOT be an OOB swap: {html}"
5729        );
5730    }
5731
5732    // -----------------------------------------------------------------------
5733    // Rename parity (POST /subscriptions/{rkey}/rename)
5734    // -----------------------------------------------------------------------
5735
5736    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
5737    /// its global ceiling must be refused (capacity flash) and must NOT insert a
5738    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
5739    /// rename loop can't inflate the shared cache past the cap.
5740    #[tokio::test]
5741    async fn rename_to_new_url_refused_at_global_feeds_cap() {
5742        let did = "did:plc:renamer";
5743        // Global cap 1; pre-fill it with one feed so headroom is 0.
5744        let state = test_state_with_caps(did, 0, 1).await;
5745        store::upsert_feed(
5746            &state.db,
5747            &store::NewFeed {
5748                url: "https://existing.example/feed.xml".to_string(),
5749                ..Default::default()
5750            },
5751        )
5752        .await
5753        .unwrap();
5754        let before = store::count_feeds(&state.db).await.unwrap();
5755        assert_eq!(before, 1);
5756
5757        let cookie = session_cookie(&state, did, None);
5758        let app = router(state.clone());
5759        let resp = app
5760            .oneshot(
5761                Request::builder()
5762                    .method("POST")
5763                    .uri("/subscriptions/rkey123/rename")
5764                    .header(header::COOKIE, cookie)
5765                    .header("content-type", "application/x-www-form-urlencoded")
5766                    // A URL not in the cache → would be a NEW feeds row.
5767                    .body(Body::from(
5768                        "url=https://brand-new.example/feed.xml&title=Renamed",
5769                    ))
5770                    .unwrap(),
5771            )
5772            .await
5773            .unwrap();
5774        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5775        let loc = resp
5776            .headers()
5777            .get(header::LOCATION)
5778            .unwrap()
5779            .to_str()
5780            .unwrap();
5781        assert!(
5782            loc.contains("feed%20capacity"),
5783            "expected the feed-capacity flash, got {loc}"
5784        );
5785        // No new feeds row was inserted.
5786        let after = store::count_feeds(&state.db).await.unwrap();
5787        assert_eq!(
5788            after, before,
5789            "rename inflated the shared cache past the cap"
5790        );
5791    }
5792
5793    /// A rename to an EXISTING URL adds no row, so it is allowed even at the
5794    /// global cap (only new URLs are gated) — the other half of the guard.
5795    #[tokio::test]
5796    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
5797        let did = "did:plc:renamer2";
5798        let state = test_state_with_caps(did, 0, 1).await;
5799        store::upsert_feed(
5800            &state.db,
5801            &store::NewFeed {
5802                url: "https://existing.example/feed.xml".to_string(),
5803                ..Default::default()
5804            },
5805        )
5806        .await
5807        .unwrap();
5808        let before = store::count_feeds(&state.db).await.unwrap();
5809
5810        let cookie = session_cookie(&state, did, None);
5811        let app = router(state.clone());
5812        let resp = app
5813            .oneshot(
5814                Request::builder()
5815                    .method("POST")
5816                    .uri("/subscriptions/rkey123/rename")
5817                    .header(header::COOKIE, cookie)
5818                    .header("content-type", "application/x-www-form-urlencoded")
5819                    .body(Body::from(
5820                        "url=https://existing.example/feed.xml&title=Retitled",
5821                    ))
5822                    .unwrap(),
5823            )
5824            .await
5825            .unwrap();
5826        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5827        let loc = resp
5828            .headers()
5829            .get(header::LOCATION)
5830            .unwrap()
5831            .to_str()
5832            .unwrap();
5833        assert_eq!(
5834            loc, "/",
5835            "retitle of an existing feed must succeed, got {loc}"
5836        );
5837        assert_eq!(
5838            store::count_feeds(&state.db).await.unwrap(),
5839            before,
5840            "retitle must not add a feeds row"
5841        );
5842    }
5843
5844    /// A rename with a blank/empty `url` must write NOTHING — no `feeds` row and
5845    /// no PDS update — and just redirect home. Guards the empty-URL early return.
5846    #[tokio::test]
5847    async fn rename_with_blank_url_writes_nothing() {
5848        let did = "did:plc:renamer3";
5849        let state = test_state_with_caps(did, 0, 0).await;
5850        let before = store::count_feeds(&state.db).await.unwrap();
5851        assert_eq!(before, 0);
5852
5853        let cookie = session_cookie(&state, did, None);
5854        let app = router(state.clone());
5855        let resp = app
5856            .oneshot(
5857                Request::builder()
5858                    .method("POST")
5859                    .uri("/subscriptions/rkey123/rename")
5860                    .header(header::COOKIE, cookie)
5861                    .header("content-type", "application/x-www-form-urlencoded")
5862                    // Whitespace-only URL trims to empty.
5863                    .body(Body::from("url=%20%20&title=Nope"))
5864                    .unwrap(),
5865            )
5866            .await
5867            .unwrap();
5868        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
5869        assert_eq!(
5870            resp.headers()
5871                .get(header::LOCATION)
5872                .unwrap()
5873                .to_str()
5874                .unwrap(),
5875            "/",
5876        );
5877        // Nothing was cached.
5878        assert_eq!(
5879            store::count_feeds(&state.db).await.unwrap(),
5880            0,
5881            "blank-URL rename wrote a junk feeds row"
5882        );
5883    }
5884
5885    /// Folder pre-selection regression: the manage rename row must mark the
5886    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
5887    /// re-submits the current folder instead of silently un-foldering the feed.
5888    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
5889    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
5890    #[test]
5891    fn manage_rename_row_preselects_current_folder() {
5892        let nav = Nav {
5893            handle: "@reader.example".to_string(),
5894            avatar: "RE".to_string(),
5895            view: "unread".to_string(),
5896            scope_qs: String::new(),
5897            folders: Vec::new(),
5898            loose_feeds: Vec::new(),
5899            manage_active: true,
5900        };
5901        let folder_options = vec![
5902            FolderOption {
5903                uri: "at://did:plc:x/app.folder/work".to_string(),
5904                name: "Work".to_string(),
5905            },
5906            FolderOption {
5907                uri: "at://did:plc:x/app.folder/fun".to_string(),
5908                name: "Fun".to_string(),
5909            },
5910        ];
5911        // A foldered feed (in "Work") and a loose feed (no folder), each with a
5912        // non-empty rkey so the rename form renders.
5913        let foldered = FeedView {
5914            rkey: "sub-foldered".to_string(),
5915            url: "https://work.example/feed.xml".to_string(),
5916            title: "Work Feed".to_string(),
5917            unread: 0,
5918            selected: false,
5919            folder: Some("at://did:plc:x/app.folder/work".to_string()),
5920        };
5921        let loose = FeedView {
5922            rkey: "sub-loose".to_string(),
5923            url: "https://loose.example/feed.xml".to_string(),
5924            title: "Loose Feed".to_string(),
5925            unread: 0,
5926            selected: false,
5927            folder: None,
5928        };
5929        let tmpl = ManageTemplate {
5930            version: VERSION,
5931            repo_url: REPO_URL,
5932            kofi_url: KOFI_URL,
5933            flash: String::new(),
5934            nav,
5935            folder_options,
5936            folders: vec![FolderView {
5937                rkey: "folder-work".to_string(),
5938                uri: "at://did:plc:x/app.folder/work".to_string(),
5939                name: "Work".to_string(),
5940                feeds: vec![foldered],
5941                selected: false,
5942            }],
5943            loose_feeds: vec![loose],
5944        };
5945        let html = tmpl.render().unwrap();
5946
5947        // The foldered feed's "Work" option is pre-selected.
5948        assert!(
5949            html.contains(
5950                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
5951            ),
5952            "foldered feed must pre-select its current folder: {html}"
5953        );
5954        // The loose feed's "No folder" option is pre-selected (appears for the
5955        // loose row, which has folder=None).
5956        assert!(
5957            html.contains(r#"<option value="" selected>No folder</option>"#),
5958            "loose feed must pre-select 'No folder': {html}"
5959        );
5960    }
5961}