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