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_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
26const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
27const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
28const COCKPIT_COMPONENT_COMMANDER_JS: &str = include_str!("static/components/cockpit-commander.js");
29const COCKPIT_COMPONENT_PALETTE_JS: &str = include_str!("static/components/cockpit-palette.js");
30
31// Vendored third-party libraries — embedded so the dashboard works fully offline
32// (no external CDN). Served as text via the standard route pipeline.
33const COCKPIT_VENDOR_CHART_JS: &str = include_str!("static/vendor/chart.umd.min.js");
34const COCKPIT_VENDOR_D3_JS: &str = include_str!("static/vendor/d3.min.js");
35const COCKPIT_FONTS_CSS: &str = include_str!("static/fonts/fonts.css");
36const COCKPIT_FAVICON_SVG: &str = include_str!("static/favicon.svg");
37
38// Self-hosted variable fonts (binary woff2). Served via a dedicated binary
39// branch in `handle_request` so the bytes are never corrupted by the
40// String-based route pipeline.
41const FONT_INTER_WOFF2: &[u8] = include_bytes!("static/fonts/inter-variable.woff2");
42const FONT_JETBRAINS_WOFF2: &[u8] = include_bytes!("static/fonts/jetbrains-mono-variable.woff2");
43const FONT_SPACE_GROTESK_WOFF2: &[u8] = include_bytes!("static/fonts/space-grotesk-variable.woff2");
44
45/// Maps a request path to an embedded binary font asset.
46fn match_font_asset(path: &str) -> Option<&'static [u8]> {
47    match path {
48        "/static/fonts/inter-variable.woff2" => Some(FONT_INTER_WOFF2),
49        "/static/fonts/jetbrains-mono-variable.woff2" => Some(FONT_JETBRAINS_WOFF2),
50        "/static/fonts/space-grotesk-variable.woff2" => Some(FONT_SPACE_GROTESK_WOFF2),
51        _ => None,
52    }
53}
54
55pub mod base_path;
56pub mod routes;
57
58pub async fn start(port: Option<u16>, host: Option<String>, base_path: Option<String>) {
59    let port = port.unwrap_or_else(|| {
60        std::env::var("LEAN_CTX_PORT")
61            .ok()
62            .and_then(|p| p.parse().ok())
63            .unwrap_or(DEFAULT_PORT)
64    });
65
66    let host = host.unwrap_or_else(|| {
67        std::env::var("LEAN_CTX_HOST")
68            .ok()
69            .unwrap_or_else(|| DEFAULT_HOST.to_string())
70    });
71
72    // Reverse-proxy subpath (e.g. `/dashboard`). Normalized to "" or "/prefix".
73    // Shared across connections behind an Arc; "" means "no subpath" (#355).
74    let base_path = Arc::new(
75        base_path
76            .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_BASE_PATH").ok())
77            .map(|b| base_path::normalize(&b))
78            .unwrap_or_default(),
79    );
80
81    let addr = format!("{host}:{port}");
82    let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
83
84    // Avoid accidental multiple dashboard instances (common source of "it hangs").
85    // Only safe to auto-detect for local dashboards without auth.
86    if is_local && dashboard_responding(&host, port) {
87        println!("\n  lean-ctx dashboard already running → http://{host}:{port}{base_path}");
88        println!("  Tip: use Ctrl+C in the existing terminal to stop it.\n");
89        if let Some(t) = load_saved_token() {
90            open_browser(&format!("http://localhost:{port}{base_path}/?token={t}"));
91        } else {
92            open_browser(&format!("http://localhost:{port}{base_path}/"));
93        }
94        return;
95    }
96
97    // Always enable auth (even on loopback) to prevent cross-origin reads of /api/*
98    // from a malicious website (CORS is not a reliable boundary for localhost services).
99    let t = generate_token();
100    save_token(&t);
101    let token = Some(Arc::new(t));
102
103    if let Some(t) = token.as_ref() {
104        let masked = if t.len() > 12 {
105            format!(
106                "{}…{}",
107                &t[..t.floor_char_boundary(8)],
108                &t[t.ceil_char_boundary(t.len().saturating_sub(4))..]
109            )
110        } else {
111            t.to_string()
112        };
113        if is_local {
114            println!("  Auth: enabled (local)");
115            println!("  Browser URL:  http://localhost:{port}{base_path}/?token={t}");
116        } else {
117            eprintln!(
118                "  \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n  \
119                 Bearer token: \x1b[1;32m{masked}\x1b[0m\n  \
120                 Browser URL:  http://<your-ip>:{port}{base_path}/?token={t}"
121            );
122        }
123    }
124
125    let listener = match TcpListener::bind(&addr).await {
126        Ok(l) => l,
127        Err(e) => {
128            eprintln!("Failed to bind to {addr}: {e}");
129            std::process::exit(1);
130        }
131    };
132
133    let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
134        |_| "~/.lean-ctx/stats.json".to_string(),
135        |d| d.join("stats.json").display().to_string(),
136    );
137
138    if host == "0.0.0.0" {
139        println!("\n  lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
140        println!("  Local access:  http://localhost:{port}");
141    } else {
142        println!("\n  lean-ctx dashboard → http://{host}:{port}");
143    }
144    println!("  Stats file: {stats_path}");
145    println!("  Press Ctrl+C to stop");
146    println!(
147        "  \x1b[2m💡 Join the public leaderboard at https://leanctx.com/metrics: lean-ctx gain --publish --leaderboard\x1b[0m\n"
148    );
149
150    if is_local {
151        if let Some(t) = token.as_ref() {
152            open_browser(&format!("http://localhost:{port}{base_path}/?token={t}"));
153        } else {
154            open_browser(&format!("http://localhost:{port}{base_path}/"));
155        }
156    }
157    if crate::shell::is_container() && is_local {
158        println!("  Tip (Docker): bind 0.0.0.0 + publish port:");
159        println!("    lean-ctx dashboard --host=0.0.0.0 --port={port}");
160        println!("    docker run ... -p {port}:{port} ...");
161        println!();
162    }
163
164    loop {
165        if let Ok((stream, _)) = listener.accept().await {
166            let token_ref = token.clone();
167            let base_ref = base_path.clone();
168            tokio::spawn(handle_request(stream, token_ref, base_ref));
169        }
170    }
171}
172
173fn generate_token() -> String {
174    let mut bytes = [0u8; 32];
175    if getrandom::fill(&mut bytes).is_err() {
176        tracing::warn!("CSPRNG unavailable — falling back to time-based token");
177        let ts = std::time::SystemTime::now()
178            .duration_since(std::time::UNIX_EPOCH)
179            .unwrap_or_default()
180            .as_nanos();
181        for (i, b) in bytes.iter_mut().enumerate() {
182            *b = ((ts >> (i % 16 * 8)) & 0xFF) as u8;
183        }
184    }
185    format!("lctx_{}", hex_lower(&bytes))
186}
187
188fn save_token(token: &str) {
189    if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
190        let _ = std::fs::create_dir_all(&dir);
191        let path = dir.join("dashboard.token");
192        #[cfg(unix)]
193        {
194            use std::io::Write;
195            use std::os::unix::fs::OpenOptionsExt;
196            let Ok(mut f) = std::fs::OpenOptions::new()
197                .write(true)
198                .create(true)
199                .truncate(true)
200                .mode(0o600)
201                .open(&path)
202            else {
203                return;
204            };
205            let _ = f.write_all(token.as_bytes());
206        }
207        #[cfg(not(unix))]
208        {
209            let _ = std::fs::write(&path, token);
210        }
211    }
212}
213
214fn load_saved_token() -> Option<String> {
215    let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
216    let path = dir.join("dashboard.token");
217    std::fs::read_to_string(path)
218        .ok()
219        .map(|s| s.trim().to_string())
220}
221
222/// Adds `nonce="..."` to all inline `<script>` tags (those without a `src=` attribute).
223/// External scripts (`<script src="...">`) are left untouched.
224pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
225    let mut result = String::with_capacity(html.len() + 128);
226    let mut remaining = html;
227    while let Some(pos) = remaining.find("<script") {
228        result.push_str(&remaining[..pos]);
229        let tag_start = &remaining[pos..];
230        let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
231        let tag = &tag_start[..=tag_end];
232        if tag.contains("src=") || tag.contains("nonce=") {
233            result.push_str(tag);
234        } else {
235            result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
236        }
237        remaining = &tag_start[tag_end + 1..];
238    }
239    result.push_str(remaining);
240    result
241}
242
243fn hex_lower(bytes: &[u8]) -> String {
244    const HEX: &[u8; 16] = b"0123456789abcdef";
245    let mut out = String::with_capacity(bytes.len() * 2);
246    for &b in bytes {
247        out.push(HEX[(b >> 4) as usize] as char);
248        out.push(HEX[(b & 0x0f) as usize] as char);
249    }
250    out
251}
252
253fn open_browser(url: &str) {
254    #[cfg(target_os = "macos")]
255    {
256        let _ = std::process::Command::new("open").arg(url).spawn();
257    }
258
259    #[cfg(target_os = "linux")]
260    {
261        let _ = std::process::Command::new("xdg-open")
262            .arg(url)
263            .stderr(std::process::Stdio::null())
264            .spawn();
265    }
266
267    #[cfg(target_os = "windows")]
268    {
269        let _ = std::process::Command::new("cmd")
270            .args(["/C", "start", url])
271            .spawn();
272    }
273}
274
275fn dashboard_responding(host: &str, port: u16) -> bool {
276    use std::io::{Read, Write};
277    use std::net::TcpStream;
278    use std::time::Duration;
279
280    let addr = format!("{host}:{port}");
281    let Ok(mut s) = TcpStream::connect_timeout(
282        &addr
283            .parse()
284            .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
285        Duration::from_millis(150),
286    ) else {
287        return false;
288    };
289    let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
290    let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
291
292    let auth_header = load_saved_token()
293        .map(|t| format!("Authorization: Bearer {t}\r\n"))
294        .unwrap_or_default();
295
296    let req = format!(
297        "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
298    );
299    if s.write_all(req.as_bytes()).is_err() {
300        return false;
301    }
302    let mut buf = [0u8; 256];
303    let Ok(n) = s.read(&mut buf) else {
304        return false;
305    };
306    let head = String::from_utf8_lossy(&buf[..n]);
307    head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
308}
309
310const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
311
312fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
313    for line in header_section.lines() {
314        let Some((k, v)) = line.split_once(':') else {
315            continue;
316        };
317        if k.trim().eq_ignore_ascii_case(name) {
318            return Some(v.trim());
319        }
320    }
321    None
322}
323
324/// Loopback dashboards often use `localhost` vs `127.0.0.1` interchangeably in `Origin`.
325fn host_loopback_aliases(host: &str) -> Vec<String> {
326    let mut v = vec![host.to_string()];
327    if let Some(port) = host.strip_prefix("127.0.0.1:") {
328        v.push(format!("localhost:{port}"));
329    }
330    if let Some(port) = host.strip_prefix("localhost:") {
331        v.push(format!("127.0.0.1:{port}"));
332    }
333    if let Some(port) = host.strip_prefix("[::1]:") {
334        v.push(format!("127.0.0.1:{port}"));
335        v.push(format!("localhost:{port}"));
336    }
337    v
338}
339
340fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
341    let origin = origin.trim_end_matches('/');
342    for h in host_loopback_aliases(host) {
343        if origin.eq_ignore_ascii_case(&format!("http://{h}"))
344            || origin.eq_ignore_ascii_case(&format!("https://{h}"))
345        {
346            return true;
347        }
348    }
349    false
350}
351
352/// Defense-in-depth for browser POSTs: reject cross-site `Origin` on mutating `/api/*` calls.
353/// Non-browser clients (no `Origin`) remain allowed when Bearer auth succeeds.
354fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
355    let uc = method.to_ascii_uppercase();
356    if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
357        return true;
358    }
359    if !path.starts_with("/api/") {
360        return true;
361    }
362    let Some(origin) = header_line_value(header_section, "Origin") else {
363        return true;
364    };
365    if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
366        return true;
367    }
368    let Some(host) = header_line_value(header_section, "Host") else {
369        return false;
370    };
371    origin_matches_dashboard_host(origin, host)
372}
373
374fn find_headers_end(buf: &[u8]) -> Option<usize> {
375    buf.windows(4).position(|w| w == b"\r\n\r\n")
376}
377
378fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
379    let text = String::from_utf8_lossy(header_section);
380    for line in text.lines() {
381        let Some((k, v)) = line.split_once(':') else {
382            continue;
383        };
384        if k.trim().eq_ignore_ascii_case("content-length") {
385            return v.trim().parse::<usize>().ok();
386        }
387    }
388    Some(0)
389}
390
391async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
392    let mut buf = Vec::new();
393    let mut tmp = [0u8; 8192];
394    loop {
395        if let Some(end) = find_headers_end(&buf) {
396            let cl = parse_content_length_header(&buf[..end])?;
397            let total = end + 4 + cl;
398            if total > MAX_HTTP_MESSAGE {
399                return None;
400            }
401            if buf.len() >= total {
402                buf.truncate(total);
403                return Some(buf);
404            }
405        } else if buf.len() > 65_536 {
406            return None;
407        }
408
409        let n = stream.read(&mut tmp).await.ok()?;
410        if n == 0 {
411            return None;
412        }
413        buf.extend_from_slice(&tmp[..n]);
414        if buf.len() > MAX_HTTP_MESSAGE {
415            return None;
416        }
417    }
418}
419
420async fn handle_request(
421    mut stream: tokio::net::TcpStream,
422    token: Option<Arc<String>>,
423    base_path: Arc<String>,
424) {
425    let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
426
427    let Some(buf) = read_http_message(&mut stream).await else {
428        return;
429    };
430    let Some(header_end) = find_headers_end(&buf) else {
431        return;
432    };
433    let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
434    let body_start = header_end + 4;
435    let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
436        return;
437    };
438    if buf.len() < body_start + content_len {
439        return;
440    }
441    let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
442        .unwrap_or("")
443        .to_string();
444
445    let first = header_text.lines().next().unwrap_or("");
446    let mut parts = first.split_whitespace();
447    let method = parts.next().unwrap_or("GET").to_string();
448    let raw_path = parts.next().unwrap_or("/").to_string();
449
450    let (path, query_token) = if let Some(idx) = raw_path.find('?') {
451        let p = &raw_path[..idx];
452        let qs = &raw_path[idx + 1..];
453        let tok = qs
454            .split('&')
455            .find_map(|pair| pair.strip_prefix("token="))
456            .map(std::string::ToString::to_string);
457        (p.to_string(), tok)
458    } else {
459        (raw_path.clone(), None)
460    };
461
462    let query_str = raw_path
463        .find('?')
464        .map_or(String::new(), |i| raw_path[i + 1..].to_string());
465
466    // Strip the reverse-proxy subpath prefix (if any) so all downstream matching
467    // (fonts, auth, routing) works on root-relative paths whether or not the
468    // proxy already stripped it (#355).
469    let path = base_path::strip(&path, base_path.as_str()).to_string();
470
471    // Binary font assets are public (like CSS/JS) and bypass the String-based
472    // route pipeline so their bytes stay intact.
473    if let Some(bytes) = match_font_asset(&path) {
474        let header = format!(
475            "HTTP/1.1 200 OK\r\n\
476             Content-Type: font/woff2\r\n\
477             Content-Length: {}\r\n\
478             Cache-Control: public, max-age=31536000, immutable\r\n\
479             X-Content-Type-Options: nosniff\r\n\
480             Connection: close\r\n\
481             \r\n",
482            bytes.len()
483        );
484        let _ = stream.write_all(header.as_bytes()).await;
485        let _ = stream.write_all(bytes).await;
486        return;
487    }
488
489    let is_api = path.starts_with("/api/");
490    let requires_auth = is_api || path == "/metrics";
491
492    if let Some(ref expected) = token {
493        let has_header_auth = check_auth(&header_text, expected);
494
495        if requires_auth && !has_header_auth {
496            let body = r#"{"error":"unauthorized"}"#;
497            let response = format!(
498                "HTTP/1.1 401 Unauthorized\r\n\
499                 Content-Type: application/json\r\n\
500                 Content-Length: {}\r\n\
501                 WWW-Authenticate: Bearer\r\n\
502                 Connection: close\r\n\
503                 \r\n\
504                 {body}",
505                body.len()
506            );
507            let _ = stream.write_all(response.as_bytes()).await;
508            return;
509        }
510
511        if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
512            let body = r#"{"error":"forbidden"}"#;
513            let response = format!(
514                "HTTP/1.1 403 Forbidden\r\n\
515                 Content-Type: application/json\r\n\
516                 Content-Length: {}\r\n\
517                 Connection: close\r\n\
518                 \r\n\
519                 {body}",
520                body.len()
521            );
522            let _ = stream.write_all(response.as_bytes()).await;
523            return;
524        }
525    }
526
527    let path = path.as_str();
528    let query_str = query_str.as_str();
529    let method = method.as_str();
530
531    let compute = std::panic::catch_unwind(|| {
532        routes::route_response(
533            path,
534            query_str,
535            query_token.as_ref(),
536            token.as_ref(),
537            is_loopback,
538            method,
539            &body_str,
540        )
541    });
542    let (status, content_type, mut body) = match compute {
543        Ok(v) => v,
544        Err(_) => (
545            "500 Internal Server Error",
546            "application/json",
547            r#"{"error":"dashboard route panicked"}"#.to_string(),
548        ),
549    };
550
551    // Under a reverse-proxy subpath, rewrite root-absolute asset/API URLs in the
552    // served HTML/CSS/JS so the browser requests them under the prefix (#355).
553    if !base_path.is_empty()
554        && (content_type.contains("text/html")
555            || content_type.contains("text/css")
556            || content_type.contains("javascript"))
557    {
558        body = base_path::rewrite_asset_urls(&body, base_path.as_str());
559    }
560
561    let cache_header = if content_type.starts_with("application/json") {
562        "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
563    } else if content_type.starts_with("application/javascript")
564        || content_type.starts_with("text/css")
565    {
566        "Cache-Control: no-cache, must-revalidate\r\n"
567    } else {
568        ""
569    };
570
571    let nonce = {
572        let mut nb = [0u8; 16];
573        if getrandom::fill(&mut nb).is_err() {
574            nb.iter_mut().enumerate().for_each(|(i, b)| {
575                *b = (std::time::SystemTime::now()
576                    .duration_since(std::time::UNIX_EPOCH)
577                    .unwrap_or_default()
578                    .subsec_nanos()
579                    .wrapping_add(i as u32)) as u8;
580            });
581        }
582        hex_lower(&nb)
583    };
584    if content_type.contains("text/html") {
585        body = add_nonce_to_inline_scripts(&body, &nonce);
586    }
587    let security_headers = format!(
588        "X-Content-Type-Options: nosniff\r\n\
589         X-Frame-Options: DENY\r\n\
590         Referrer-Policy: no-referrer\r\n\
591         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"
592    );
593
594    let response = format!(
595        "HTTP/1.1 {status}\r\n\
596         Content-Type: {content_type}\r\n\
597         Content-Length: {}\r\n\
598         {cache_header}\
599         {security_headers}\
600         Connection: close\r\n\
601         \r\n\
602         {body}",
603        body.len()
604    );
605
606    let _ = stream.write_all(response.as_bytes()).await;
607}
608
609fn check_auth(request: &str, expected_token: &str) -> bool {
610    for line in request.lines() {
611        let lower = line.to_lowercase();
612        if lower.starts_with("authorization:") {
613            let value = line["authorization:".len()..].trim();
614            if let Some(token) = value
615                .strip_prefix("Bearer ")
616                .or_else(|| value.strip_prefix("bearer "))
617            {
618                return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
619            }
620        }
621    }
622    false
623}
624
625fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
626    if a.len() != b.len() {
627        return false;
628    }
629    bool::from(a.ct_eq(b))
630}
631
632#[cfg(test)]
633mod tests {
634    use super::routes::helpers::normalize_dashboard_demo_path;
635    use super::*;
636    use tempfile::tempdir;
637
638    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
639
640    #[test]
641    fn check_auth_with_valid_bearer() {
642        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
643        assert!(check_auth(req, "lctx_abc123"));
644    }
645
646    #[test]
647    fn check_auth_with_invalid_bearer() {
648        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
649        assert!(!check_auth(req, "lctx_abc123"));
650    }
651
652    #[test]
653    fn check_auth_missing_header() {
654        let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
655        assert!(!check_auth(req, "lctx_abc123"));
656    }
657
658    #[test]
659    fn check_auth_lowercase_bearer() {
660        let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
661        assert!(check_auth(req, "lctx_abc123"));
662    }
663
664    #[test]
665    fn query_token_parsing() {
666        let raw_path = "/index.html?token=lctx_abc123&other=val";
667        let idx = raw_path.find('?').unwrap();
668        let qs = &raw_path[idx + 1..];
669        let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
670        assert_eq!(tok, Some("lctx_abc123"));
671    }
672
673    #[test]
674    fn api_path_detection() {
675        assert!("/api/stats".starts_with("/api/"));
676        assert!("/api/version".starts_with("/api/"));
677        assert!(!"/".starts_with("/api/"));
678        assert!(!"/index.html".starts_with("/api/"));
679        assert!(!"/favicon.ico".starts_with("/api/"));
680    }
681
682    #[test]
683    fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
684        let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
685        assert_eq!(
686            normalized,
687            format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
688        );
689    }
690
691    #[test]
692    fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
693        let input = r"C:\repo\backend\list_tables.js";
694        assert_eq!(normalize_dashboard_demo_path(input), input);
695    }
696
697    #[test]
698    fn normalize_dashboard_demo_path_preserves_unc_path() {
699        let input = r"\\server\share\backend\list_tables.js";
700        assert_eq!(normalize_dashboard_demo_path(input), input);
701    }
702
703    #[test]
704    fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
705        assert_eq!(
706            normalize_dashboard_demo_path("./src/main.rs"),
707            "src/main.rs"
708        );
709        assert_eq!(
710            normalize_dashboard_demo_path(r".\src\main.rs"),
711            format!("src{}main.rs", std::path::MAIN_SEPARATOR)
712        );
713    }
714
715    #[test]
716    fn api_profile_returns_json() {
717        let (_status, _ct, body) =
718            routes::route_response("/api/profile", "", None, None, false, "GET", "");
719        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
720        assert!(v.get("active_name").is_some(), "missing active_name");
721        assert!(
722            v.pointer("/profile/profile/name")
723                .and_then(|n| n.as_str())
724                .is_some(),
725            "missing profile.profile.name"
726        );
727        assert!(v.get("available").and_then(|a| a.as_array()).is_some());
728    }
729
730    #[test]
731    fn api_episodes_returns_json() {
732        let (_status, _ct, body) =
733            routes::route_response("/api/episodes", "", None, None, false, "GET", "");
734        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
735        assert!(v.get("project_hash").is_some());
736        assert!(v.get("stats").is_some());
737        assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
738    }
739
740    #[test]
741    fn api_procedures_returns_json() {
742        let (_status, _ct, body) =
743            routes::route_response("/api/procedures", "", None, None, false, "GET", "");
744        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
745        assert!(v.get("project_hash").is_some());
746        assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
747        assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
748    }
749
750    #[test]
751    fn api_compression_demo_heals_moved_file_paths() {
752        let _g = ENV_LOCK.lock().expect("env lock");
753        let td = tempdir().expect("tempdir");
754        let root = td.path();
755        std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
756        std::fs::write(
757            root.join("src").join("moved").join("foo.rs"),
758            "pub fn foo() { println!(\"hi\"); }\n",
759        )
760        .expect("write foo.rs");
761
762        let root_s = root.to_string_lossy().to_string();
763        std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
764
765        let (_status, _ct, body) = routes::route_response(
766            "/api/compression-demo",
767            "path=src/foo.rs",
768            None,
769            None,
770            false,
771            "GET",
772            "",
773        );
774        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
775        assert!(v.get("error").is_none(), "unexpected error: {body}");
776        assert_eq!(
777            v.get("resolved_from").and_then(|x| x.as_str()),
778            Some("src/moved/foo.rs")
779        );
780
781        std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
782        if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
783            let _ = std::fs::remove_dir_all(dir);
784        }
785    }
786}