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");
38
39const COCKPIT_VENDOR_CHART_JS: &str = include_str!("static/vendor/chart.umd.min.js");
42const COCKPIT_VENDOR_D3_JS: &str = include_str!("static/vendor/d3.min.js");
43const COCKPIT_FONTS_CSS: &str = include_str!("static/fonts/fonts.css");
44const COCKPIT_FAVICON_SVG: &str = include_str!("static/favicon.svg");
45
46const FONT_INTER_WOFF2: &[u8] = include_bytes!("static/fonts/inter-variable.woff2");
50const FONT_JETBRAINS_WOFF2: &[u8] = include_bytes!("static/fonts/jetbrains-mono-variable.woff2");
51const FONT_SPACE_GROTESK_WOFF2: &[u8] = include_bytes!("static/fonts/space-grotesk-variable.woff2");
52
53fn match_font_asset(path: &str) -> Option<&'static [u8]> {
55 match path {
56 "/static/fonts/inter-variable.woff2" => Some(FONT_INTER_WOFF2),
57 "/static/fonts/jetbrains-mono-variable.woff2" => Some(FONT_JETBRAINS_WOFF2),
58 "/static/fonts/space-grotesk-variable.woff2" => Some(FONT_SPACE_GROTESK_WOFF2),
59 _ => None,
60 }
61}
62
63pub mod base_path;
64pub mod routes;
65
66pub async fn start(port: Option<u16>, host: Option<String>, base_path: Option<String>) {
67 let port = port.unwrap_or_else(|| {
68 std::env::var("LEAN_CTX_PORT")
69 .ok()
70 .and_then(|p| p.parse().ok())
71 .unwrap_or(DEFAULT_PORT)
72 });
73
74 let host = host.unwrap_or_else(|| {
75 std::env::var("LEAN_CTX_HOST")
76 .ok()
77 .unwrap_or_else(|| DEFAULT_HOST.to_string())
78 });
79
80 let base_path = Arc::new(
83 base_path
84 .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_BASE_PATH").ok())
85 .map(|b| base_path::normalize(&b))
86 .unwrap_or_default(),
87 );
88
89 let addr = format!("{host}:{port}");
90 let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
91
92 if is_local && dashboard_responding(&host, port) {
95 println!("\n lean-ctx dashboard already running → http://{host}:{port}{base_path}");
96 println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
97 if let Some(t) = load_saved_token() {
98 open_browser(&format!("http://localhost:{port}{base_path}/?token={t}"));
99 } else {
100 open_browser(&format!("http://localhost:{port}{base_path}/"));
101 }
102 return;
103 }
104
105 let (t, token_from_env) = resolve_dashboard_token();
108 save_token(&t);
109 let token = Some(Arc::new(t));
110
111 if let Some(t) = token.as_ref() {
112 let masked = if t.len() > 12 {
113 format!(
114 "{}…{}",
115 &t[..t.floor_char_boundary(8)],
116 &t[t.ceil_char_boundary(t.len().saturating_sub(4))..]
117 )
118 } else {
119 t.to_string()
120 };
121 let src = if token_from_env {
122 " (from LEAN_CTX_HTTP_TOKEN)"
123 } else {
124 ""
125 };
126 if is_local {
127 println!(" Auth: enabled (local){src}");
128 println!(" Browser URL: http://localhost:{port}{base_path}/?token={t}");
129 } else {
130 eprintln!(
131 " \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n \
132 Bearer token{src}: \x1b[1;32m{masked}\x1b[0m\n \
133 Browser URL: http://<your-ip>:{port}{base_path}/?token={t}"
134 );
135 }
136 }
137
138 let listener = match TcpListener::bind(&addr).await {
139 Ok(l) => l,
140 Err(e) => {
141 eprintln!("Failed to bind to {addr}: {e}");
142 std::process::exit(1);
143 }
144 };
145
146 let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
147 |_| "~/.lean-ctx/stats.json".to_string(),
148 |d| d.join("stats.json").display().to_string(),
149 );
150
151 if host == "0.0.0.0" {
152 println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
153 println!(" Local access: http://localhost:{port}");
154 } else {
155 println!("\n lean-ctx dashboard → http://{host}:{port}");
156 }
157 println!(" Stats file: {stats_path}");
158 println!(" Press Ctrl+C to stop");
159 println!(
160 " \x1b[2m💡 Join the public leaderboard at https://leanctx.com/metrics: lean-ctx gain --publish --leaderboard\x1b[0m\n"
161 );
162
163 if is_local {
164 if let Some(t) = token.as_ref() {
165 open_browser(&format!("http://localhost:{port}{base_path}/?token={t}"));
166 } else {
167 open_browser(&format!("http://localhost:{port}{base_path}/"));
168 }
169 }
170 if crate::shell::is_container() && is_local {
171 println!(" Tip (Docker): bind 0.0.0.0 + publish port:");
172 println!(" lean-ctx dashboard --host=0.0.0.0 --port={port}");
173 println!(" docker run ... -p {port}:{port} ...");
174 println!();
175 }
176
177 if crate::core::datadog_push::spawn_if_enabled() {
178 println!(
179 " Datadog push: enabled (agentless, every LEAN_CTX_DATADOG_INTERVAL_SECS or 60s)"
180 );
181 }
182
183 loop {
184 if let Ok((stream, _)) = listener.accept().await {
185 let token_ref = token.clone();
186 let base_ref = base_path.clone();
187 tokio::spawn(handle_request(stream, token_ref, base_ref));
188 }
189 }
190}
191
192const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
194const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
197
198fn resolve_dashboard_token() -> (String, bool) {
207 if let Ok(raw) = std::env::var(HTTP_TOKEN_ENV) {
208 let trimmed = raw.trim();
209 if !trimmed.is_empty() {
210 return (trimmed.to_string(), true);
211 }
212 }
213 (generate_token(), false)
214}
215
216fn generate_token() -> String {
217 let mut bytes = [0u8; 32];
218 if getrandom::fill(&mut bytes).is_err() {
219 tracing::warn!("CSPRNG unavailable — falling back to time-based token");
220 let ts = std::time::SystemTime::now()
221 .duration_since(std::time::UNIX_EPOCH)
222 .unwrap_or_default()
223 .as_nanos();
224 for (i, b) in bytes.iter_mut().enumerate() {
225 *b = ((ts >> (i % 16 * 8)) & 0xFF) as u8;
226 }
227 }
228 format!("lctx_{}", hex_lower(&bytes))
229}
230
231fn save_token(token: &str) {
232 if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
233 let _ = std::fs::create_dir_all(&dir);
234 let path = dir.join("dashboard.token");
235 #[cfg(unix)]
236 {
237 use std::io::Write;
238 use std::os::unix::fs::OpenOptionsExt;
239 let Ok(mut f) = std::fs::OpenOptions::new()
240 .write(true)
241 .create(true)
242 .truncate(true)
243 .mode(0o600)
244 .open(&path)
245 else {
246 return;
247 };
248 let _ = f.write_all(token.as_bytes());
249 }
250 #[cfg(not(unix))]
251 {
252 let _ = std::fs::write(&path, token);
253 }
254 }
255}
256
257fn load_saved_token() -> Option<String> {
258 let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
259 let path = dir.join("dashboard.token");
260 std::fs::read_to_string(path)
261 .ok()
262 .map(|s| s.trim().to_string())
263}
264
265pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
268 let mut result = String::with_capacity(html.len() + 128);
269 let mut remaining = html;
270 while let Some(pos) = remaining.find("<script") {
271 result.push_str(&remaining[..pos]);
272 let tag_start = &remaining[pos..];
273 let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
274 let tag = &tag_start[..=tag_end];
275 if tag.contains("src=") || tag.contains("nonce=") {
276 result.push_str(tag);
277 } else {
278 result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
279 }
280 remaining = &tag_start[tag_end + 1..];
281 }
282 result.push_str(remaining);
283 result
284}
285
286fn hex_lower(bytes: &[u8]) -> String {
287 const HEX: &[u8; 16] = b"0123456789abcdef";
288 let mut out = String::with_capacity(bytes.len() * 2);
289 for &b in bytes {
290 out.push(HEX[(b >> 4) as usize] as char);
291 out.push(HEX[(b & 0x0f) as usize] as char);
292 }
293 out
294}
295
296fn open_browser(url: &str) {
297 #[cfg(target_os = "macos")]
298 {
299 let _ = std::process::Command::new("open").arg(url).spawn();
300 }
301
302 #[cfg(target_os = "linux")]
303 {
304 let _ = std::process::Command::new("xdg-open")
305 .arg(url)
306 .stderr(std::process::Stdio::null())
307 .spawn();
308 }
309
310 #[cfg(target_os = "windows")]
311 {
312 let _ = std::process::Command::new("cmd")
313 .args(["/C", "start", url])
314 .spawn();
315 }
316}
317
318fn dashboard_responding(host: &str, port: u16) -> bool {
319 use std::io::{Read, Write};
320 use std::net::TcpStream;
321 use std::time::Duration;
322
323 let addr = format!("{host}:{port}");
324 let Ok(mut s) = TcpStream::connect_timeout(
325 &addr
326 .parse()
327 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
328 Duration::from_millis(150),
329 ) else {
330 return false;
331 };
332 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
333 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
334
335 let auth_header = load_saved_token()
336 .map(|t| format!("Authorization: Bearer {t}\r\n"))
337 .unwrap_or_default();
338
339 let req = format!(
340 "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
341 );
342 if s.write_all(req.as_bytes()).is_err() {
343 return false;
344 }
345 let mut buf = [0u8; 256];
346 let Ok(n) = s.read(&mut buf) else {
347 return false;
348 };
349 let head = String::from_utf8_lossy(&buf[..n]);
350 head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
351}
352
353const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
354
355fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
356 for line in header_section.lines() {
357 let Some((k, v)) = line.split_once(':') else {
358 continue;
359 };
360 if k.trim().eq_ignore_ascii_case(name) {
361 return Some(v.trim());
362 }
363 }
364 None
365}
366
367fn host_loopback_aliases(host: &str) -> Vec<String> {
369 let mut v = vec![host.to_string()];
370 if let Some(port) = host.strip_prefix("127.0.0.1:") {
371 v.push(format!("localhost:{port}"));
372 }
373 if let Some(port) = host.strip_prefix("localhost:") {
374 v.push(format!("127.0.0.1:{port}"));
375 }
376 if let Some(port) = host.strip_prefix("[::1]:") {
377 v.push(format!("127.0.0.1:{port}"));
378 v.push(format!("localhost:{port}"));
379 }
380 v
381}
382
383fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
384 let origin = origin.trim_end_matches('/');
385 for h in host_loopback_aliases(host) {
386 if origin.eq_ignore_ascii_case(&format!("http://{h}"))
387 || origin.eq_ignore_ascii_case(&format!("https://{h}"))
388 {
389 return true;
390 }
391 }
392 false
393}
394
395fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
398 let uc = method.to_ascii_uppercase();
399 if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
400 return true;
401 }
402 if !path.starts_with("/api/") {
403 return true;
404 }
405 let Some(origin) = header_line_value(header_section, "Origin") else {
406 return true;
407 };
408 if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
409 return true;
410 }
411 let Some(host) = header_line_value(header_section, "Host") else {
412 return false;
413 };
414 origin_matches_dashboard_host(origin, host)
415}
416
417fn find_headers_end(buf: &[u8]) -> Option<usize> {
418 buf.windows(4).position(|w| w == b"\r\n\r\n")
419}
420
421fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
422 let text = String::from_utf8_lossy(header_section);
423 for line in text.lines() {
424 let Some((k, v)) = line.split_once(':') else {
425 continue;
426 };
427 if k.trim().eq_ignore_ascii_case("content-length") {
428 return v.trim().parse::<usize>().ok();
429 }
430 }
431 Some(0)
432}
433
434async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
435 let mut buf = Vec::new();
436 let mut tmp = [0u8; 8192];
437 loop {
438 if let Some(end) = find_headers_end(&buf) {
439 let cl = parse_content_length_header(&buf[..end])?;
440 let total = end + 4 + cl;
441 if total > MAX_HTTP_MESSAGE {
442 return None;
443 }
444 if buf.len() >= total {
445 buf.truncate(total);
446 return Some(buf);
447 }
448 } else if buf.len() > 65_536 {
449 return None;
450 }
451
452 let n = stream.read(&mut tmp).await.ok()?;
453 if n == 0 {
454 return None;
455 }
456 buf.extend_from_slice(&tmp[..n]);
457 if buf.len() > MAX_HTTP_MESSAGE {
458 return None;
459 }
460 }
461}
462
463async fn handle_request(
464 mut stream: tokio::net::TcpStream,
465 token: Option<Arc<String>>,
466 base_path: Arc<String>,
467) {
468 let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
469
470 let Some(buf) = read_http_message(&mut stream).await else {
471 return;
472 };
473 let Some(header_end) = find_headers_end(&buf) else {
474 return;
475 };
476 let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
477 let body_start = header_end + 4;
478 let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
479 return;
480 };
481 if buf.len() < body_start + content_len {
482 return;
483 }
484 let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
485 .unwrap_or("")
486 .to_string();
487
488 let first = header_text.lines().next().unwrap_or("");
489 let mut parts = first.split_whitespace();
490 let method = parts.next().unwrap_or("GET").to_string();
491 let raw_path = parts.next().unwrap_or("/").to_string();
492
493 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
494 let p = &raw_path[..idx];
495 let qs = &raw_path[idx + 1..];
496 let tok = qs
497 .split('&')
498 .find_map(|pair| pair.strip_prefix("token="))
499 .map(std::string::ToString::to_string);
500 (p.to_string(), tok)
501 } else {
502 (raw_path.clone(), None)
503 };
504
505 let query_str = raw_path
506 .find('?')
507 .map_or(String::new(), |i| raw_path[i + 1..].to_string());
508
509 let path = base_path::strip(&path, base_path.as_str()).to_string();
513
514 if let Some(bytes) = match_font_asset(&path) {
517 let header = format!(
518 "HTTP/1.1 200 OK\r\n\
519 Content-Type: font/woff2\r\n\
520 Content-Length: {}\r\n\
521 Cache-Control: public, max-age=31536000, immutable\r\n\
522 X-Content-Type-Options: nosniff\r\n\
523 Connection: close\r\n\
524 \r\n",
525 bytes.len()
526 );
527 let _ = stream.write_all(header.as_bytes()).await;
528 let _ = stream.write_all(bytes).await;
529 return;
530 }
531
532 let is_api = path.starts_with("/api/");
533 let requires_auth = is_api || path == "/metrics";
534
535 if let Some(ref expected) = token {
536 let mut has_header_auth = check_auth(&header_text, expected);
537
538 if !has_header_auth && path == "/metrics" {
543 if let Ok(scrape) = std::env::var(SCRAPE_TOKEN_ENV) {
544 let scrape = scrape.trim();
545 if !scrape.is_empty() && check_auth(&header_text, scrape) {
546 has_header_auth = true;
547 }
548 }
549 }
550
551 if requires_auth && !has_header_auth {
552 let body = r#"{"error":"unauthorized"}"#;
553 let response = format!(
554 "HTTP/1.1 401 Unauthorized\r\n\
555 Content-Type: application/json\r\n\
556 Content-Length: {}\r\n\
557 WWW-Authenticate: Bearer\r\n\
558 Connection: close\r\n\
559 \r\n\
560 {body}",
561 body.len()
562 );
563 let _ = stream.write_all(response.as_bytes()).await;
564 return;
565 }
566
567 if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
568 let body = r#"{"error":"forbidden"}"#;
569 let response = format!(
570 "HTTP/1.1 403 Forbidden\r\n\
571 Content-Type: application/json\r\n\
572 Content-Length: {}\r\n\
573 Connection: close\r\n\
574 \r\n\
575 {body}",
576 body.len()
577 );
578 let _ = stream.write_all(response.as_bytes()).await;
579 return;
580 }
581 }
582
583 let path = path.as_str();
584 let query_str = query_str.as_str();
585 let method = method.as_str();
586
587 let compute = std::panic::catch_unwind(|| {
588 routes::route_response(
589 path,
590 query_str,
591 query_token.as_ref(),
592 token.as_ref(),
593 is_loopback,
594 method,
595 &body_str,
596 )
597 });
598 let (status, content_type, mut body) = match compute {
599 Ok(v) => v,
600 Err(_) => (
601 "500 Internal Server Error",
602 "application/json",
603 r#"{"error":"dashboard route panicked"}"#.to_string(),
604 ),
605 };
606
607 if !base_path.is_empty()
610 && (content_type.contains("text/html")
611 || content_type.contains("text/css")
612 || content_type.contains("javascript"))
613 {
614 body = base_path::rewrite_asset_urls(&body, base_path.as_str());
615 }
616
617 let cache_header = if content_type.starts_with("application/json") {
618 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
619 } else if content_type.starts_with("application/javascript")
620 || content_type.starts_with("text/css")
621 {
622 "Cache-Control: no-cache, must-revalidate\r\n"
623 } else {
624 ""
625 };
626
627 let nonce = {
628 let mut nb = [0u8; 16];
629 if getrandom::fill(&mut nb).is_err() {
630 nb.iter_mut().enumerate().for_each(|(i, b)| {
631 *b = (std::time::SystemTime::now()
632 .duration_since(std::time::UNIX_EPOCH)
633 .unwrap_or_default()
634 .subsec_nanos()
635 .wrapping_add(i as u32)) as u8;
636 });
637 }
638 hex_lower(&nb)
639 };
640 if content_type.contains("text/html") {
641 body = add_nonce_to_inline_scripts(&body, &nonce);
642 }
643 let security_headers = format!(
644 "X-Content-Type-Options: nosniff\r\n\
645 X-Frame-Options: DENY\r\n\
646 Referrer-Policy: no-referrer\r\n\
647 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"
648 );
649
650 let response = format!(
651 "HTTP/1.1 {status}\r\n\
652 Content-Type: {content_type}\r\n\
653 Content-Length: {}\r\n\
654 {cache_header}\
655 {security_headers}\
656 Connection: close\r\n\
657 \r\n\
658 {body}",
659 body.len()
660 );
661
662 let _ = stream.write_all(response.as_bytes()).await;
663}
664
665fn check_auth(request: &str, expected_token: &str) -> bool {
666 for line in request.lines() {
667 let lower = line.to_lowercase();
668 if lower.starts_with("authorization:") {
669 let value = line["authorization:".len()..].trim();
670 if let Some(token) = value
671 .strip_prefix("Bearer ")
672 .or_else(|| value.strip_prefix("bearer "))
673 {
674 return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
675 }
676 }
677 }
678 false
679}
680
681fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
682 if a.len() != b.len() {
683 return false;
684 }
685 bool::from(a.ct_eq(b))
686}
687
688#[cfg(test)]
689mod tests {
690 use super::routes::helpers::normalize_dashboard_demo_path;
691 use super::*;
692 use tempfile::tempdir;
693
694 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
695
696 #[test]
697 fn check_auth_with_valid_bearer() {
698 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
699 assert!(check_auth(req, "lctx_abc123"));
700 }
701
702 #[test]
703 fn check_auth_with_invalid_bearer() {
704 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
705 assert!(!check_auth(req, "lctx_abc123"));
706 }
707
708 #[test]
709 fn check_auth_missing_header() {
710 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
711 assert!(!check_auth(req, "lctx_abc123"));
712 }
713
714 #[test]
715 fn check_auth_lowercase_bearer() {
716 let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
717 assert!(check_auth(req, "lctx_abc123"));
718 }
719
720 #[test]
721 fn query_token_parsing() {
722 let raw_path = "/index.html?token=lctx_abc123&other=val";
723 let idx = raw_path.find('?').unwrap();
724 let qs = &raw_path[idx + 1..];
725 let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
726 assert_eq!(tok, Some("lctx_abc123"));
727 }
728
729 #[test]
730 fn api_path_detection() {
731 assert!("/api/stats".starts_with("/api/"));
732 assert!("/api/version".starts_with("/api/"));
733 assert!(!"/".starts_with("/api/"));
734 assert!(!"/index.html".starts_with("/api/"));
735 assert!(!"/favicon.ico".starts_with("/api/"));
736 }
737
738 #[test]
739 fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
740 let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
741 assert_eq!(
742 normalized,
743 format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
744 );
745 }
746
747 #[test]
748 fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
749 let input = r"C:\repo\backend\list_tables.js";
750 assert_eq!(normalize_dashboard_demo_path(input), input);
751 }
752
753 #[test]
754 fn normalize_dashboard_demo_path_preserves_unc_path() {
755 let input = r"\\server\share\backend\list_tables.js";
756 assert_eq!(normalize_dashboard_demo_path(input), input);
757 }
758
759 #[test]
760 fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
761 assert_eq!(
762 normalize_dashboard_demo_path("./src/main.rs"),
763 "src/main.rs"
764 );
765 assert_eq!(
766 normalize_dashboard_demo_path(r".\src\main.rs"),
767 format!("src{}main.rs", std::path::MAIN_SEPARATOR)
768 );
769 }
770
771 #[test]
772 fn api_profile_returns_json() {
773 let (_status, _ct, body) =
774 routes::route_response("/api/profile", "", None, None, false, "GET", "");
775 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
776 assert!(v.get("active_name").is_some(), "missing active_name");
777 assert!(
778 v.pointer("/profile/profile/name")
779 .and_then(|n| n.as_str())
780 .is_some(),
781 "missing profile.profile.name"
782 );
783 assert!(v.get("available").and_then(|a| a.as_array()).is_some());
784 }
785
786 #[test]
787 fn api_billing_badge_returns_cosmetic_shape() {
788 let (status, ct, body) =
789 routes::route_response("/api/billing-badge", "", None, None, false, "GET", "");
790 assert_eq!(status, "200 OK");
791 assert_eq!(ct, "application/json");
792 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
793 assert!(v.get("plan").and_then(|p| p.as_str()).is_some());
794 assert!(v
795 .get("supporter")
796 .and_then(serde_json::Value::as_bool)
797 .is_some());
798 assert!(
799 matches!(
800 v.get("source").and_then(|s| s.as_str()),
801 Some("live" | "cached" | "expired" | "none")
802 ),
803 "unexpected source: {body}"
804 );
805 }
806
807 #[test]
808 fn api_episodes_returns_json() {
809 let (_status, _ct, body) =
810 routes::route_response("/api/episodes", "", None, None, false, "GET", "");
811 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
812 assert!(v.get("project_hash").is_some());
813 assert!(v.get("stats").is_some());
814 assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
815 }
816
817 #[test]
818 fn api_procedures_returns_json() {
819 let (_status, _ct, body) =
820 routes::route_response("/api/procedures", "", None, None, false, "GET", "");
821 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
822 assert!(v.get("project_hash").is_some());
823 assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
824 assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
825 }
826
827 #[test]
828 fn api_compression_demo_heals_moved_file_paths() {
829 let _g = ENV_LOCK.lock().expect("env lock");
830 let td = tempdir().expect("tempdir");
831 let root = td.path();
832 std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
833 std::fs::write(
834 root.join("src").join("moved").join("foo.rs"),
835 "pub fn foo() { println!(\"hi\"); }\n",
836 )
837 .expect("write foo.rs");
838
839 let root_s = root.to_string_lossy().to_string();
840 std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
841
842 let (_status, _ct, body) = routes::route_response(
843 "/api/compression-demo",
844 "path=src/foo.rs",
845 None,
846 None,
847 false,
848 "GET",
849 "",
850 );
851 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
852 assert!(v.get("error").is_none(), "unexpected error: {body}");
853 assert_eq!(
854 v.get("resolved_from").and_then(|x| x.as_str()),
855 Some("src/moved/foo.rs")
856 );
857
858 std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
859 if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
860 let _ = std::fs::remove_dir_all(dir);
861 }
862 }
863
864 #[test]
865 fn resolve_token_uses_env_var_verbatim() {
866 let _g = ENV_LOCK.lock().expect("env lock");
867 std::env::set_var(HTTP_TOKEN_ENV, "lctx_mystatic");
868 let (token, from_env) = resolve_dashboard_token();
869 std::env::remove_var(HTTP_TOKEN_ENV);
870 assert!(from_env, "token should be reported as env-sourced");
871 assert_eq!(token, "lctx_mystatic");
872 }
873
874 #[test]
875 fn resolve_token_trims_env_var() {
876 let _g = ENV_LOCK.lock().expect("env lock");
877 std::env::set_var(HTTP_TOKEN_ENV, " lctx_padded ");
878 let (token, from_env) = resolve_dashboard_token();
879 std::env::remove_var(HTTP_TOKEN_ENV);
880 assert!(from_env);
881 assert_eq!(token, "lctx_padded");
882 }
883
884 #[test]
885 fn resolve_token_falls_back_to_random_when_unset() {
886 let _g = ENV_LOCK.lock().expect("env lock");
887 std::env::remove_var(HTTP_TOKEN_ENV);
888 let (token, from_env) = resolve_dashboard_token();
889 assert!(!from_env, "unset env should yield a generated token");
890 assert!(
891 token.starts_with("lctx_"),
892 "generated token prefix, got {token}"
893 );
894 assert!(token.len() > 12, "generated token should be 32-byte hex");
895 }
896
897 #[test]
898 fn resolve_token_ignores_empty_env() {
899 let _g = ENV_LOCK.lock().expect("env lock");
900 std::env::set_var(HTTP_TOKEN_ENV, " ");
901 let (token, from_env) = resolve_dashboard_token();
902 std::env::remove_var(HTTP_TOKEN_ENV);
903 assert!(!from_env, "whitespace-only env should fall back to random");
904 assert!(token.starts_with("lctx_"));
905 }
906}