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
40const 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
47const 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
54fn 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 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 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 let (requested_token, token_src) = resolve_requested_token(auth_token.as_deref());
105
106 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 let t = requested_token.unwrap_or_else(generate_token);
133 let token = Some(Arc::new(t));
134
135 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
226const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
228const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
231
232fn 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
306pub 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
339enum DashboardOpen {
340 Browser,
342 None,
345 Vscode,
349}
350
351fn 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
365fn 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
449fn 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
477fn 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 let path = base_path::strip(&path, base_path.as_str()).to_string();
595
596 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 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 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 Err(_) => (
692 "500 Internal Server Error",
693 "application/json",
694 r#"{"error":"dashboard route panicked"}"#.to_string(),
695 ),
696 };
697 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 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 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 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 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 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 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}