Skip to main content

lean_ctx/dashboard/
mod.rs

1use std::sync::Arc;
2use subtle::ConstantTimeEq;
3use tokio::io::{AsyncReadExt, AsyncWriteExt};
4use tokio::net::TcpListener;
5
6const DEFAULT_PORT: u16 = 3333;
7const DEFAULT_HOST: &str = "127.0.0.1";
8const COCKPIT_INDEX_HTML: &str = include_str!("static/index.html");
9const COCKPIT_STYLE_CSS: &str = include_str!("static/style.css");
10const COCKPIT_LIB_API_JS: &str = include_str!("static/lib/api.js");
11const COCKPIT_LIB_FORMAT_JS: &str = include_str!("static/lib/format.js");
12const COCKPIT_LIB_ROUTER_JS: &str = include_str!("static/lib/router.js");
13const COCKPIT_LIB_CHARTS_JS: &str = include_str!("static/lib/charts.js");
14const COCKPIT_LIB_SHARED_JS: &str = include_str!("static/lib/shared.js");
15const COCKPIT_LIB_DOCTOR_JS: &str = include_str!("static/lib/doctor.js");
16const COCKPIT_COMPONENT_NAV_JS: &str = include_str!("static/components/cockpit-nav.js");
17const COCKPIT_COMPONENT_CONTEXT_JS: &str = include_str!("static/components/cockpit-context.js");
18const COCKPIT_COMPONENT_OVERVIEW_JS: &str = include_str!("static/components/cockpit-overview.js");
19const COCKPIT_COMPONENT_LIVE_JS: &str = include_str!("static/components/cockpit-live.js");
20const COCKPIT_COMPONENT_KNOWLEDGE_JS: &str = include_str!("static/components/cockpit-knowledge.js");
21const COCKPIT_COMPONENT_AGENTS_JS: &str = include_str!("static/components/cockpit-agents.js");
22const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpit-memory.js");
23const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
24const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
25    include_str!("static/components/cockpit-compression.js");
26const COCKPIT_COMPONENT_TOUR_JS: &str = include_str!("static/components/cockpit-tour.js");
27const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
28const COCKPIT_COMPONENT_ARCHITECTURE_JS: &str =
29    include_str!("static/components/cockpit-architecture.js");
30const COCKPIT_COMPONENT_EXPLORER_JS: &str = include_str!("static/components/cockpit-explorer.js");
31const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
32const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
33const COCKPIT_COMPONENT_COMMANDER_JS: &str = include_str!("static/components/cockpit-commander.js");
34const COCKPIT_COMPONENT_PALETTE_JS: &str = include_str!("static/components/cockpit-palette.js");
35const COCKPIT_COMPONENT_ROI_JS: &str = include_str!("static/components/cockpit-roi.js");
36const COCKPIT_COMPONENT_LEADERBOARD_JS: &str =
37    include_str!("static/components/cockpit-leaderboard.js");
38const COCKPIT_COMPONENT_AREA_TABS_JS: &str = include_str!("static/components/cockpit-area-tabs.js");
39const COCKPIT_COMPONENT_PROTECTION_JS: &str =
40    include_str!("static/components/cockpit-protection.js");
41const COCKPIT_COMPONENT_SETTINGS_JS: &str = include_str!("static/components/cockpit-settings.js");
42
43// Vendored third-party libraries — embedded so the dashboard works fully offline
44// (no external CDN). Served as text via the standard route pipeline.
45const COCKPIT_VENDOR_CHART_JS: &str = include_str!("static/vendor/chart.umd.min.js");
46const COCKPIT_VENDOR_D3_JS: &str = include_str!("static/vendor/d3.min.js");
47const COCKPIT_FONTS_CSS: &str = include_str!("static/fonts/fonts.css");
48const COCKPIT_FAVICON_SVG: &str = include_str!("static/favicon.svg");
49
50// Self-hosted variable fonts (binary woff2). Served via a dedicated binary
51// branch in `handle_request` so the bytes are never corrupted by the
52// String-based route pipeline.
53const FONT_INTER_WOFF2: &[u8] = include_bytes!("static/fonts/inter-variable.woff2");
54const FONT_JETBRAINS_WOFF2: &[u8] = include_bytes!("static/fonts/jetbrains-mono-variable.woff2");
55const FONT_SPACE_GROTESK_WOFF2: &[u8] = include_bytes!("static/fonts/space-grotesk-variable.woff2");
56
57/// Maps a request path to an embedded binary font asset.
58fn match_font_asset(path: &str) -> Option<&'static [u8]> {
59    match path {
60        "/static/fonts/inter-variable.woff2" => Some(FONT_INTER_WOFF2),
61        "/static/fonts/jetbrains-mono-variable.woff2" => Some(FONT_JETBRAINS_WOFF2),
62        "/static/fonts/space-grotesk-variable.woff2" => Some(FONT_SPACE_GROTESK_WOFF2),
63        _ => None,
64    }
65}
66
67pub mod base_path;
68pub mod routes;
69pub(crate) mod vscode_open;
70
71pub async fn start(
72    port: Option<u16>,
73    host: Option<String>,
74    base_path: Option<String>,
75    auth_token: Option<String>,
76    open_mode: Option<String>,
77) {
78    // How to reveal the URL once the server is up: --open= flag > env > browser.
79    let open = resolve_open_mode(open_mode.as_deref());
80    let port = port.unwrap_or_else(|| {
81        std::env::var("LEAN_CTX_PORT")
82            .ok()
83            .and_then(|p| p.parse().ok())
84            .unwrap_or(DEFAULT_PORT)
85    });
86
87    let host = host.unwrap_or_else(|| {
88        std::env::var("LEAN_CTX_HOST")
89            .ok()
90            .unwrap_or_else(|| DEFAULT_HOST.to_string())
91    });
92
93    // Reverse-proxy subpath (e.g. `/dashboard`). Normalized to "" or "/prefix".
94    // Shared across connections behind an Arc; "" means "no subpath" (#355).
95    let base_path = Arc::new(
96        base_path
97            .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_BASE_PATH").ok())
98            .map(|b| base_path::normalize(&b))
99            .unwrap_or_default(),
100    );
101
102    let addr = format!("{host}:{port}");
103    let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
104
105    // Resolve any *requested* fixed token (flag > LEAN_CTX_HTTP_TOKEN) up-front;
106    // `None` means "generate a random one". Done before the already-running check
107    // so we can warn when the requested token won't match a live instance (#377).
108    let (requested_token, token_src) = resolve_requested_token(auth_token.as_deref());
109
110    // Avoid accidental multiple dashboard instances (common source of "it hangs").
111    // Only safe to auto-detect for local dashboards without auth.
112    if is_local && dashboard_responding(&host, port) {
113        println!("\n  lean-ctx dashboard already running → http://{host}:{port}{base_path}");
114        if let Some(req) = requested_token.as_deref()
115            && load_saved_token().as_deref() != Some(req)
116        {
117            eprintln!(
118                "  \x1b[33m⚠\x1b[0m The running instance uses a different token — your {token_src} \
119                     will be rejected. Stop it (Ctrl+C) and restart to apply the new token."
120            );
121        }
122        println!("  Tip: use Ctrl+C in the existing terminal to stop it.\n");
123        if let Some(t) = load_saved_token() {
124            open_dashboard_url(
125                &format!("http://localhost:{port}{base_path}/?token={t}"),
126                open,
127            );
128        } else {
129            open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
130        }
131        return;
132    }
133
134    // Always enable auth (even on loopback) to prevent cross-origin reads of /api/*
135    // from a malicious website (CORS is not a reliable boundary for localhost services).
136    let t = requested_token.unwrap_or_else(generate_token);
137    let token = Some(Arc::new(t));
138
139    // Bind BEFORE persisting the token: two racing `lean-ctx dashboard` starts
140    // both used to write their fresh token, the bind loser exited — leaving a
141    // token on disk that the surviving server never accepted. Every later
142    // "already running" browser open (and any tool reading dashboard.token)
143    // then got 401s. Binding first makes the loser exit without touching the
144    // file, so dashboard.token always belongs to the live listener.
145    let listener = match TcpListener::bind(&addr).await {
146        Ok(l) => l,
147        Err(e) => {
148            eprintln!("Failed to bind to {addr}: {e}");
149            std::process::exit(1);
150        }
151    };
152
153    if let Some(t) = token.as_ref() {
154        save_token(t);
155        let masked = if t.len() > 12 {
156            format!(
157                "{}…{}",
158                &t[..t.floor_char_boundary(8)],
159                &t[t.ceil_char_boundary(t.len().saturating_sub(4))..]
160            )
161        } else {
162            t.to_string()
163        };
164        let src = if token_src.is_empty() {
165            String::new()
166        } else {
167            format!(" (from {token_src})")
168        };
169        if is_local {
170            println!("  Auth: enabled (local){src}");
171            println!("  Browser URL:  http://localhost:{port}{base_path}/?token={t}");
172        } else {
173            eprintln!(
174                "  \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n  \
175                 Bearer token{src}: \x1b[1;32m{masked}\x1b[0m\n  \
176                 Browser URL:  http://<your-ip>:{port}{base_path}/?token={t}"
177            );
178        }
179    }
180
181    let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
182        |_| "~/.lean-ctx/stats.json".to_string(),
183        |d| d.join("stats.json").display().to_string(),
184    );
185
186    if host == "0.0.0.0" {
187        println!("\n  lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
188        println!("  Local access:  http://localhost:{port}");
189    } else {
190        println!("\n  lean-ctx dashboard → http://{host}:{port}");
191    }
192    println!("  Stats file: {stats_path}");
193    println!("  Press Ctrl+C to stop");
194    println!(
195        "  \x1b[2m💡 Join the public leaderboard at https://leanctx.com/metrics: lean-ctx gain --publish --leaderboard\x1b[0m\n"
196    );
197
198    if is_local {
199        if let Some(t) = token.as_ref() {
200            open_dashboard_url(
201                &format!("http://localhost:{port}{base_path}/?token={t}"),
202                open,
203            );
204        } else {
205            open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
206        }
207    }
208    if crate::shell::is_container() && is_local {
209        println!("  Tip (Docker): bind 0.0.0.0 + publish port:");
210        println!("    lean-ctx dashboard --host=0.0.0.0 --port={port}");
211        println!("    docker run ... -p {port}:{port} ...");
212        println!();
213    }
214
215    if crate::core::datadog_push::spawn_if_enabled() {
216        println!(
217            "  Datadog push: enabled (agentless, every LEAN_CTX_DATADOG_INTERVAL_SECS or 60s)"
218        );
219    }
220
221    loop {
222        if let Ok((stream, _)) = listener.accept().await {
223            let token_ref = token.clone();
224            let base_ref = base_path.clone();
225            tokio::spawn(handle_request(stream, token_ref, base_ref));
226        }
227    }
228}
229
230/// Name of the env var that pins the dashboard Bearer token (#377).
231const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
232/// Read-only token accepted **only** for `GET /metrics` (GL #401) so
233/// monitoring agents never hold the full dashboard credential.
234const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
235
236/// Resolve the dashboard Bearer token.
237///
238/// Honors `LEAN_CTX_HTTP_TOKEN` (#377): when set to a non-empty value it is used
239/// verbatim so reverse-proxy / container deployments keep a stable token across
240/// restarts and redeploys (nginx can inject a fixed `Authorization: Bearer …`).
241/// When unset or empty, a fresh random token is generated (no behavior change).
242///
243/// Resolve a *requested* fixed token with precedence `--auth-token` flag >
244/// `LEAN_CTX_HTTP_TOKEN` (#377). The flag wins so it survives container/service
245/// environments that strip or fail to inherit the env var. Returns the trimmed,
246/// non-empty token and a human label of its source; `None` means "no fixed token
247/// requested → caller generates a random one".
248fn resolve_requested_token(flag: Option<&str>) -> (Option<String>, &'static str) {
249    if let Some(t) = flag.map(str::trim).filter(|s| !s.is_empty()) {
250        return (Some(t.to_string()), "--auth-token");
251    }
252    if let Ok(raw) = std::env::var(HTTP_TOKEN_ENV) {
253        let trimmed = raw.trim();
254        if !trimmed.is_empty() {
255            return (Some(trimmed.to_string()), HTTP_TOKEN_ENV);
256        }
257    }
258    (None, "")
259}
260
261fn generate_token() -> String {
262    let mut bytes = [0u8; 32];
263    if getrandom::fill(&mut bytes).is_err() {
264        tracing::warn!("CSPRNG unavailable — falling back to time-based token");
265        let ts = std::time::SystemTime::now()
266            .duration_since(std::time::UNIX_EPOCH)
267            .unwrap_or_default()
268            .as_nanos();
269        for (i, b) in bytes.iter_mut().enumerate() {
270            *b = ((ts >> (i % 16 * 8)) & 0xFF) as u8;
271        }
272    }
273    format!("lctx_{}", hex_lower(&bytes))
274}
275
276fn save_token(token: &str) {
277    if let Ok(dir) = crate::core::paths::state_dir() {
278        let _ = std::fs::create_dir_all(&dir);
279        let path = dir.join("dashboard.token");
280        #[cfg(unix)]
281        {
282            use std::io::Write;
283            use std::os::unix::fs::OpenOptionsExt;
284            let Ok(mut f) = std::fs::OpenOptions::new()
285                .write(true)
286                .create(true)
287                .truncate(true)
288                .mode(0o600)
289                .open(&path)
290            else {
291                return;
292            };
293            let _ = f.write_all(token.as_bytes());
294        }
295        #[cfg(not(unix))]
296        {
297            let _ = std::fs::write(&path, token);
298        }
299    }
300}
301
302fn load_saved_token() -> Option<String> {
303    let dir = crate::core::paths::state_dir().ok()?;
304    let path = dir.join("dashboard.token");
305    std::fs::read_to_string(path)
306        .ok()
307        .map(|s| s.trim().to_string())
308}
309
310/// Adds `nonce="..."` to all inline `<script>` tags (those without a `src=` attribute).
311/// External scripts (`<script src="...">`) are left untouched.
312pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
313    let mut result = String::with_capacity(html.len() + 128);
314    let mut remaining = html;
315    while let Some(pos) = remaining.find("<script") {
316        result.push_str(&remaining[..pos]);
317        let tag_start = &remaining[pos..];
318        let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
319        let tag = &tag_start[..=tag_end];
320        if tag.contains("src=") || tag.contains("nonce=") {
321            result.push_str(tag);
322        } else {
323            result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
324        }
325        remaining = &tag_start[tag_end + 1..];
326    }
327    result.push_str(remaining);
328    result
329}
330
331fn hex_lower(bytes: &[u8]) -> String {
332    const HEX: &[u8; 16] = b"0123456789abcdef";
333    let mut out = String::with_capacity(bytes.len() * 2);
334    for &b in bytes {
335        out.push(HEX[(b >> 4) as usize] as char);
336        out.push(HEX[(b & 0x0f) as usize] as char);
337    }
338    out
339}
340
341/// How `lean-ctx dashboard` reveals the URL after the server is up (#424).
342#[derive(Clone, Copy, PartialEq, Eq, Debug)]
343enum DashboardOpen {
344    /// Launch the system default browser (historical default).
345    Browser,
346    /// Don't auto-launch anything — just print the URL. For users who run the
347    /// dashboard inside an editor / reverse proxy and don't want a new window.
348    None,
349    /// Suppress the external browser and print the steps to open the URL in
350    /// VS Code's built-in browser. VS Code exposes no stable CLI flag to open
351    /// its Simple/Integrated Browser, so we guide rather than fake it.
352    Vscode,
353}
354
355/// Resolve the open mode from (in precedence order) the `--open=` flag, the
356/// `LEAN_CTX_DASHBOARD_OPEN` env var, else the `browser` default.
357fn resolve_open_mode(flag: Option<&str>) -> DashboardOpen {
358    let raw = flag
359        .map(str::to_string)
360        .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_OPEN").ok())
361        .unwrap_or_default();
362    match raw.trim().to_ascii_lowercase().as_str() {
363        "none" | "off" | "false" | "no" => DashboardOpen::None,
364        "vscode" | "code" | "editor" => DashboardOpen::Vscode,
365        _ => DashboardOpen::Browser,
366    }
367}
368
369/// Reveal `url` to the user according to `mode`.
370fn open_dashboard_url(url: &str, mode: DashboardOpen) {
371    match mode {
372        DashboardOpen::Browser => open_browser(url),
373        DashboardOpen::None => {}
374        DashboardOpen::Vscode => {
375            // Prefer the extension's native webview tab (#466 item 3): with the
376            // lean-ctx VS Code extension installed, one command opens the
377            // dashboard as a real editor tab — no URL copy/paste. Keep the
378            // Simple Browser path as the no-extension fallback.
379            println!(
380                "  \x1b[2mNative tab: run ⇧⌘P → \"lean-ctx: Open Web Dashboard\" (needs the lean-ctx VS Code extension)\x1b[0m"
381            );
382            println!(
383                "  \x1b[2mNo extension? ⇧⌘P → \"Simple Browser: Show\" → paste the URL above\x1b[0m"
384            );
385        }
386    }
387}
388
389fn open_browser(url: &str) {
390    #[cfg(target_os = "macos")]
391    {
392        let _ = std::process::Command::new("open").arg(url).spawn();
393    }
394
395    #[cfg(target_os = "linux")]
396    {
397        let _ = std::process::Command::new("xdg-open")
398            .arg(url)
399            .stderr(std::process::Stdio::null())
400            .spawn();
401    }
402
403    #[cfg(target_os = "windows")]
404    {
405        let _ = std::process::Command::new("cmd")
406            .args(["/C", "start", url])
407            .spawn();
408    }
409}
410
411fn dashboard_responding(host: &str, port: u16) -> bool {
412    use std::io::{Read, Write};
413    use std::net::TcpStream;
414    use std::time::Duration;
415
416    let addr = format!("{host}:{port}");
417    let Ok(mut s) = TcpStream::connect_timeout(
418        &addr
419            .parse()
420            .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
421        Duration::from_millis(150),
422    ) else {
423        return false;
424    };
425    let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
426    let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
427
428    let auth_header = load_saved_token()
429        .map(|t| format!("Authorization: Bearer {t}\r\n"))
430        .unwrap_or_default();
431
432    let req = format!(
433        "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
434    );
435    if s.write_all(req.as_bytes()).is_err() {
436        return false;
437    }
438    let mut buf = [0u8; 256];
439    let Ok(n) = s.read(&mut buf) else {
440        return false;
441    };
442    let head = String::from_utf8_lossy(&buf[..n]);
443    head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
444}
445
446const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
447
448fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
449    for line in header_section.lines() {
450        let Some((k, v)) = line.split_once(':') else {
451            continue;
452        };
453        if k.trim().eq_ignore_ascii_case(name) {
454            return Some(v.trim());
455        }
456    }
457    None
458}
459
460/// Loopback dashboards often use `localhost` vs `127.0.0.1` interchangeably in `Origin`.
461fn host_loopback_aliases(host: &str) -> Vec<String> {
462    let mut v = vec![host.to_string()];
463    if let Some(port) = host.strip_prefix("127.0.0.1:") {
464        v.push(format!("localhost:{port}"));
465    }
466    if let Some(port) = host.strip_prefix("localhost:") {
467        v.push(format!("127.0.0.1:{port}"));
468    }
469    if let Some(port) = host.strip_prefix("[::1]:") {
470        v.push(format!("127.0.0.1:{port}"));
471        v.push(format!("localhost:{port}"));
472    }
473    v
474}
475
476fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
477    let origin = origin.trim_end_matches('/');
478    for h in host_loopback_aliases(host) {
479        if origin.eq_ignore_ascii_case(&format!("http://{h}"))
480            || origin.eq_ignore_ascii_case(&format!("https://{h}"))
481        {
482            return true;
483        }
484    }
485    false
486}
487
488/// Defense-in-depth for browser POSTs: reject cross-site `Origin` on mutating `/api/*` calls.
489/// Non-browser clients (no `Origin`) remain allowed when Bearer auth succeeds.
490fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
491    let uc = method.to_ascii_uppercase();
492    if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
493        return true;
494    }
495    if !path.starts_with("/api/") {
496        return true;
497    }
498    let Some(origin) = header_line_value(header_section, "Origin") else {
499        return true;
500    };
501    if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
502        return true;
503    }
504    let Some(host) = header_line_value(header_section, "Host") else {
505        return false;
506    };
507    origin_matches_dashboard_host(origin, host)
508}
509
510fn find_headers_end(buf: &[u8]) -> Option<usize> {
511    buf.windows(4).position(|w| w == b"\r\n\r\n")
512}
513
514fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
515    let text = String::from_utf8_lossy(header_section);
516    for line in text.lines() {
517        let Some((k, v)) = line.split_once(':') else {
518            continue;
519        };
520        if k.trim().eq_ignore_ascii_case("content-length") {
521            return v.trim().parse::<usize>().ok();
522        }
523    }
524    Some(0)
525}
526
527async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
528    let mut buf = Vec::new();
529    let mut tmp = [0u8; 8192];
530    loop {
531        if let Some(end) = find_headers_end(&buf) {
532            let cl = parse_content_length_header(&buf[..end])?;
533            let total = end + 4 + cl;
534            if total > MAX_HTTP_MESSAGE {
535                return None;
536            }
537            if buf.len() >= total {
538                buf.truncate(total);
539                return Some(buf);
540            }
541        } else if buf.len() > 65_536 {
542            return None;
543        }
544
545        let n = stream.read(&mut tmp).await.ok()?;
546        if n == 0 {
547            return None;
548        }
549        buf.extend_from_slice(&tmp[..n]);
550        if buf.len() > MAX_HTTP_MESSAGE {
551            return None;
552        }
553    }
554}
555
556async fn handle_request(
557    mut stream: tokio::net::TcpStream,
558    token: Option<Arc<String>>,
559    base_path: Arc<String>,
560) {
561    let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
562
563    let Some(buf) = read_http_message(&mut stream).await else {
564        return;
565    };
566    let Some(header_end) = find_headers_end(&buf) else {
567        return;
568    };
569    let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
570    let body_start = header_end + 4;
571    let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
572        return;
573    };
574    if buf.len() < body_start + content_len {
575        return;
576    }
577    let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
578        .unwrap_or("")
579        .to_string();
580
581    let first = header_text.lines().next().unwrap_or("");
582    let mut parts = first.split_whitespace();
583    let method = parts.next().unwrap_or("GET").to_string();
584    let raw_path = parts.next().unwrap_or("/").to_string();
585
586    let (path, query_token) = if let Some(idx) = raw_path.find('?') {
587        let p = &raw_path[..idx];
588        let qs = &raw_path[idx + 1..];
589        let tok = qs
590            .split('&')
591            .find_map(|pair| pair.strip_prefix("token="))
592            .map(std::string::ToString::to_string);
593        (p.to_string(), tok)
594    } else {
595        (raw_path.clone(), None)
596    };
597
598    let query_str = raw_path
599        .find('?')
600        .map_or(String::new(), |i| raw_path[i + 1..].to_string());
601
602    // Strip the reverse-proxy subpath prefix (if any) so all downstream matching
603    // (fonts, auth, routing) works on root-relative paths whether or not the
604    // proxy already stripped it (#355).
605    let path = base_path::strip(&path, base_path.as_str()).to_string();
606
607    // Binary font assets are public (like CSS/JS) and bypass the String-based
608    // route pipeline so their bytes stay intact.
609    if let Some(bytes) = match_font_asset(&path) {
610        let header = format!(
611            "HTTP/1.1 200 OK\r\n\
612             Content-Type: font/woff2\r\n\
613             Content-Length: {}\r\n\
614             Cache-Control: public, max-age=31536000, immutable\r\n\
615             X-Content-Type-Options: nosniff\r\n\
616             Connection: close\r\n\
617             \r\n",
618            bytes.len()
619        );
620        let _ = stream.write_all(header.as_bytes()).await;
621        let _ = stream.write_all(bytes).await;
622        return;
623    }
624
625    let is_api = path.starts_with("/api/");
626    let requires_auth = is_api || path == "/metrics";
627
628    if let Some(ref expected) = token {
629        let mut has_header_auth = check_auth(&header_text, expected);
630
631        // Read-only scrape token (GL #401): lets a Prometheus/Datadog agent
632        // scrape `/metrics` without holding the full dashboard token. Valid
633        // for the metrics endpoint only — every other API stays gated on the
634        // dashboard token.
635        if !has_header_auth
636            && path == "/metrics"
637            && let Ok(scrape) = std::env::var(SCRAPE_TOKEN_ENV)
638        {
639            let scrape = scrape.trim();
640            if !scrape.is_empty() && check_auth(&header_text, scrape) {
641                has_header_auth = true;
642            }
643        }
644
645        if requires_auth && !has_header_auth {
646            let body = r#"{"error":"unauthorized"}"#;
647            let response = format!(
648                "HTTP/1.1 401 Unauthorized\r\n\
649                 Content-Type: application/json\r\n\
650                 Content-Length: {}\r\n\
651                 WWW-Authenticate: Bearer\r\n\
652                 Connection: close\r\n\
653                 \r\n\
654                 {body}",
655                body.len()
656            );
657            let _ = stream.write_all(response.as_bytes()).await;
658            return;
659        }
660
661        if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
662            let body = r#"{"error":"forbidden"}"#;
663            let response = format!(
664                "HTTP/1.1 403 Forbidden\r\n\
665                 Content-Type: application/json\r\n\
666                 Content-Length: {}\r\n\
667                 Connection: close\r\n\
668                 \r\n\
669                 {body}",
670                body.len()
671            );
672            let _ = stream.write_all(response.as_bytes()).await;
673            return;
674        }
675    }
676
677    // Route handlers are synchronous and a few (graph/index builds) do seconds
678    // of disk work. Running them inline on an async worker thread lets one slow
679    // endpoint starve the small worker pool, so a trivial GET like
680    // `/api/settings` can wait minutes behind it (#431, Windows few-core). Run
681    // them on the blocking pool instead: the async workers stay free to serve
682    // light endpoints promptly. `spawn_blocking` also captures panics (returns
683    // a `JoinError`), so the previous `catch_unwind` is no longer needed.
684    let route_started = std::time::Instant::now();
685    let route_label = path.clone();
686    let compute = tokio::task::spawn_blocking(move || {
687        routes::route_response(
688            &path,
689            &query_str,
690            query_token.as_ref(),
691            token.as_ref(),
692            is_loopback,
693            &method,
694            &body_str,
695        )
696    })
697    .await;
698    let (status, content_type, mut body) = match compute {
699        Ok(v) => v,
700        // The blocking task panicked or was cancelled — surface a 500 rather
701        // than dropping the connection.
702        Err(_) => (
703            "500 Internal Server Error",
704            "application/json",
705            r#"{"error":"dashboard route panicked"}"#.to_string(),
706        ),
707    };
708    // Observability: a slow light endpoint is exactly the #431 symptom, so make
709    // any handler that crosses 1s visible in the logs for future diagnosis.
710    let route_elapsed = route_started.elapsed();
711    if route_elapsed >= std::time::Duration::from_secs(1) {
712        tracing::warn!(
713            target: "lean_ctx::dashboard",
714            "slow dashboard route {route_label} took {} ms",
715            route_elapsed.as_millis()
716        );
717    }
718
719    // Under a reverse-proxy subpath, rewrite root-absolute asset/API URLs in the
720    // served HTML/CSS/JS so the browser requests them under the prefix (#355).
721    if !base_path.is_empty()
722        && (content_type.contains("text/html")
723            || content_type.contains("text/css")
724            || content_type.contains("javascript"))
725    {
726        body = base_path::rewrite_asset_urls(&body, base_path.as_str());
727    }
728
729    let cache_header = if content_type.starts_with("application/json") {
730        "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
731    } else if content_type.starts_with("application/javascript")
732        || content_type.starts_with("text/css")
733    {
734        "Cache-Control: no-cache, must-revalidate\r\n"
735    } else {
736        ""
737    };
738
739    let nonce = {
740        let mut nb = [0u8; 16];
741        if getrandom::fill(&mut nb).is_err() {
742            nb.iter_mut().enumerate().for_each(|(i, b)| {
743                *b = (std::time::SystemTime::now()
744                    .duration_since(std::time::UNIX_EPOCH)
745                    .unwrap_or_default()
746                    .subsec_nanos()
747                    .wrapping_add(i as u32)) as u8;
748            });
749        }
750        hex_lower(&nb)
751    };
752    if content_type.contains("text/html") {
753        body = add_nonce_to_inline_scripts(&body, &nonce);
754    }
755    let security_headers = format!(
756        "X-Content-Type-Options: nosniff\r\n\
757         X-Frame-Options: DENY\r\n\
758         Referrer-Policy: no-referrer\r\n\
759         Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'\r\n"
760    );
761
762    let response = format!(
763        "HTTP/1.1 {status}\r\n\
764         Content-Type: {content_type}\r\n\
765         Content-Length: {}\r\n\
766         {cache_header}\
767         {security_headers}\
768         Connection: close\r\n\
769         \r\n\
770         {body}",
771        body.len()
772    );
773
774    let _ = stream.write_all(response.as_bytes()).await;
775}
776
777fn check_auth(request: &str, expected_token: &str) -> bool {
778    for line in request.lines() {
779        let lower = line.to_lowercase();
780        if lower.starts_with("authorization:") {
781            let value = line["authorization:".len()..].trim();
782            if let Some(token) = value
783                .strip_prefix("Bearer ")
784                .or_else(|| value.strip_prefix("bearer "))
785            {
786                return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
787            }
788        }
789    }
790    false
791}
792
793fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
794    if a.len() != b.len() {
795        return false;
796    }
797    bool::from(a.ct_eq(b))
798}
799
800#[cfg(test)]
801mod tests {
802    use super::routes::helpers::normalize_dashboard_demo_path;
803    use super::*;
804    use tempfile::tempdir;
805
806    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
807
808    #[test]
809    fn check_auth_with_valid_bearer() {
810        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
811        assert!(check_auth(req, "lctx_abc123"));
812    }
813
814    #[test]
815    fn check_auth_with_invalid_bearer() {
816        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
817        assert!(!check_auth(req, "lctx_abc123"));
818    }
819
820    #[test]
821    fn open_mode_flag_parses_all_variants() {
822        // Explicit flag wins and never consults the environment (#424).
823        assert_eq!(resolve_open_mode(Some("none")), DashboardOpen::None);
824        assert_eq!(resolve_open_mode(Some("off")), DashboardOpen::None);
825        assert_eq!(resolve_open_mode(Some("no")), DashboardOpen::None);
826        assert_eq!(resolve_open_mode(Some("vscode")), DashboardOpen::Vscode);
827        assert_eq!(resolve_open_mode(Some("code")), DashboardOpen::Vscode);
828        assert_eq!(resolve_open_mode(Some("editor")), DashboardOpen::Vscode);
829        assert_eq!(resolve_open_mode(Some("VSCode")), DashboardOpen::Vscode);
830        assert_eq!(resolve_open_mode(Some("browser")), DashboardOpen::Browser);
831        // Unknown values fall back to the historical default rather than erroring.
832        assert_eq!(resolve_open_mode(Some("wat")), DashboardOpen::Browser);
833    }
834
835    #[test]
836    fn open_mode_env_is_used_when_no_flag() {
837        let _guard = ENV_LOCK.lock().unwrap();
838        crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "none");
839        assert_eq!(resolve_open_mode(None), DashboardOpen::None);
840        crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "vscode");
841        assert_eq!(resolve_open_mode(None), DashboardOpen::Vscode);
842        // Flag still overrides the env var.
843        assert_eq!(resolve_open_mode(Some("browser")), DashboardOpen::Browser);
844        crate::test_env::remove_var("LEAN_CTX_DASHBOARD_OPEN");
845        assert_eq!(resolve_open_mode(None), DashboardOpen::Browser);
846    }
847
848    #[test]
849    fn check_auth_missing_header() {
850        let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
851        assert!(!check_auth(req, "lctx_abc123"));
852    }
853
854    #[test]
855    fn check_auth_lowercase_bearer() {
856        let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
857        assert!(check_auth(req, "lctx_abc123"));
858    }
859
860    #[test]
861    fn query_token_parsing() {
862        let raw_path = "/index.html?token=lctx_abc123&other=val";
863        let idx = raw_path.find('?').unwrap();
864        let qs = &raw_path[idx + 1..];
865        let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
866        assert_eq!(tok, Some("lctx_abc123"));
867    }
868
869    #[test]
870    fn api_path_detection() {
871        assert!("/api/stats".starts_with("/api/"));
872        assert!("/api/version".starts_with("/api/"));
873        assert!(!"/".starts_with("/api/"));
874        assert!(!"/index.html".starts_with("/api/"));
875        assert!(!"/favicon.ico".starts_with("/api/"));
876    }
877
878    #[test]
879    fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
880        let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
881        assert_eq!(
882            normalized,
883            format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
884        );
885    }
886
887    #[test]
888    fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
889        let input = r"C:\repo\backend\list_tables.js";
890        assert_eq!(normalize_dashboard_demo_path(input), input);
891    }
892
893    #[test]
894    fn normalize_dashboard_demo_path_preserves_unc_path() {
895        let input = r"\\server\share\backend\list_tables.js";
896        assert_eq!(normalize_dashboard_demo_path(input), input);
897    }
898
899    #[test]
900    fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
901        assert_eq!(
902            normalize_dashboard_demo_path("./src/main.rs"),
903            "src/main.rs"
904        );
905        assert_eq!(
906            normalize_dashboard_demo_path(r".\src\main.rs"),
907            format!("src{}main.rs", std::path::MAIN_SEPARATOR)
908        );
909    }
910
911    #[test]
912    fn api_profile_returns_json() {
913        let (_status, _ct, body) =
914            routes::route_response("/api/profile", "", None, None, false, "GET", "");
915        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
916        assert!(v.get("active_name").is_some(), "missing active_name");
917        assert!(
918            v.pointer("/profile/profile/name")
919                .and_then(|n| n.as_str())
920                .is_some(),
921            "missing profile.profile.name"
922        );
923        assert!(v.get("available").and_then(|a| a.as_array()).is_some());
924    }
925
926    #[test]
927    fn api_billing_badge_returns_cosmetic_shape() {
928        let (status, ct, body) =
929            routes::route_response("/api/billing-badge", "", None, None, false, "GET", "");
930        assert_eq!(status, "200 OK");
931        assert_eq!(ct, "application/json");
932        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
933        assert!(v.get("plan").and_then(|p| p.as_str()).is_some());
934        assert!(
935            v.get("supporter")
936                .and_then(serde_json::Value::as_bool)
937                .is_some()
938        );
939        assert!(
940            matches!(
941                v.get("source").and_then(|s| s.as_str()),
942                Some("live" | "cached" | "expired" | "none")
943            ),
944            "unexpected source: {body}"
945        );
946    }
947
948    #[test]
949    fn api_episodes_returns_json() {
950        let (_status, _ct, body) =
951            routes::route_response("/api/episodes", "", None, None, false, "GET", "");
952        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
953        assert!(v.get("project_hash").is_some());
954        assert!(v.get("stats").is_some());
955        assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
956    }
957
958    #[test]
959    fn api_procedures_returns_json() {
960        let (_status, _ct, body) =
961            routes::route_response("/api/procedures", "", None, None, false, "GET", "");
962        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
963        assert!(v.get("project_hash").is_some());
964        assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
965        assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
966    }
967
968    #[test]
969    fn api_compression_demo_heals_moved_file_paths() {
970        let _g = ENV_LOCK.lock().expect("env lock");
971        let td = tempdir().expect("tempdir");
972        let root = td.path();
973        std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
974        std::fs::write(
975            root.join("src").join("moved").join("foo.rs"),
976            "pub fn foo() { println!(\"hi\"); }\n",
977        )
978        .expect("write foo.rs");
979
980        let root_s = root.to_string_lossy().to_string();
981        crate::test_env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
982
983        let (_status, _ct, body) = routes::route_response(
984            "/api/compression-demo",
985            "path=src/foo.rs",
986            None,
987            None,
988            false,
989            "GET",
990            "",
991        );
992        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
993        assert!(v.get("error").is_none(), "unexpected error: {body}");
994        assert_eq!(
995            v.get("resolved_from").and_then(|x| x.as_str()),
996            Some("src/moved/foo.rs")
997        );
998
999        crate::test_env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
1000        if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
1001            let _ = std::fs::remove_dir_all(dir);
1002        }
1003    }
1004
1005    #[test]
1006    fn resolve_token_uses_env_var_verbatim() {
1007        let _g = ENV_LOCK.lock().expect("env lock");
1008        crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_mystatic");
1009        let (token, src) = resolve_requested_token(None);
1010        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1011        assert_eq!(
1012            src, HTTP_TOKEN_ENV,
1013            "token should be reported as env-sourced"
1014        );
1015        assert_eq!(token.as_deref(), Some("lctx_mystatic"));
1016    }
1017
1018    #[test]
1019    fn resolve_token_trims_env_var() {
1020        let _g = ENV_LOCK.lock().expect("env lock");
1021        crate::test_env::set_var(HTTP_TOKEN_ENV, "  lctx_padded  ");
1022        let (token, src) = resolve_requested_token(None);
1023        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1024        assert_eq!(src, HTTP_TOKEN_ENV);
1025        assert_eq!(token.as_deref(), Some("lctx_padded"));
1026    }
1027
1028    #[test]
1029    fn resolve_token_falls_back_to_random_when_unset() {
1030        let _g = ENV_LOCK.lock().expect("env lock");
1031        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1032        let (token, src) = resolve_requested_token(None);
1033        assert!(token.is_none(), "unset env requests no fixed token");
1034        assert!(src.is_empty());
1035        // The production fallback in `start()` generates a random token.
1036        let generated = token.unwrap_or_else(generate_token);
1037        assert!(
1038            generated.starts_with("lctx_"),
1039            "generated token prefix, got {generated}"
1040        );
1041        assert!(
1042            generated.len() > 12,
1043            "generated token should be 32-byte hex"
1044        );
1045    }
1046
1047    #[test]
1048    fn resolve_token_ignores_empty_env() {
1049        let _g = ENV_LOCK.lock().expect("env lock");
1050        crate::test_env::set_var(HTTP_TOKEN_ENV, "   ");
1051        let (token, src) = resolve_requested_token(None);
1052        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1053        assert!(
1054            token.is_none(),
1055            "whitespace-only env requests no fixed token"
1056        );
1057        assert!(src.is_empty());
1058    }
1059
1060    #[test]
1061    fn resolve_token_flag_overrides_env() {
1062        // #377: --auth-token must win over LEAN_CTX_HTTP_TOKEN so it survives
1063        // environments that strip/fail to inherit the env var.
1064        let _g = ENV_LOCK.lock().expect("env lock");
1065        crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
1066        let (token, src) = resolve_requested_token(Some("lctx_fromflag"));
1067        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1068        assert_eq!(src, "--auth-token");
1069        assert_eq!(token.as_deref(), Some("lctx_fromflag"));
1070    }
1071
1072    #[test]
1073    fn resolve_token_uses_flag_when_env_unset() {
1074        let _g = ENV_LOCK.lock().expect("env lock");
1075        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1076        let (token, src) = resolve_requested_token(Some("  lctx_flag_padded  "));
1077        assert_eq!(src, "--auth-token");
1078        assert_eq!(token.as_deref(), Some("lctx_flag_padded"));
1079    }
1080
1081    #[test]
1082    fn resolve_token_empty_flag_falls_back_to_env() {
1083        let _g = ENV_LOCK.lock().expect("env lock");
1084        crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
1085        let (token, src) = resolve_requested_token(Some("   "));
1086        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1087        assert_eq!(src, HTTP_TOKEN_ENV);
1088        assert_eq!(token.as_deref(), Some("lctx_fromenv"));
1089    }
1090}