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