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_LIB_DOCTOR_JS: &str = include_str!("static/lib/doctor.js");
16const COCKPIT_COMPONENT_NAV_JS: &str = include_str!("static/components/cockpit-nav.js");
17const COCKPIT_COMPONENT_CONTEXT_JS: &str = include_str!("static/components/cockpit-context.js");
18const COCKPIT_COMPONENT_OVERVIEW_JS: &str = include_str!("static/components/cockpit-overview.js");
19const COCKPIT_COMPONENT_LIVE_JS: &str = include_str!("static/components/cockpit-live.js");
20const COCKPIT_COMPONENT_KNOWLEDGE_JS: &str = include_str!("static/components/cockpit-knowledge.js");
21const COCKPIT_COMPONENT_AGENTS_JS: &str = include_str!("static/components/cockpit-agents.js");
22const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpit-memory.js");
23const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
24const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
25    include_str!("static/components/cockpit-compression.js");
26const COCKPIT_COMPONENT_TOUR_JS: &str = include_str!("static/components/cockpit-tour.js");
27const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
28const COCKPIT_COMPONENT_ARCHITECTURE_JS: &str =
29    include_str!("static/components/cockpit-architecture.js");
30const COCKPIT_COMPONENT_EXPLORER_JS: &str = include_str!("static/components/cockpit-explorer.js");
31const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
32const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
33const COCKPIT_COMPONENT_COMMANDER_JS: &str = include_str!("static/components/cockpit-commander.js");
34const COCKPIT_COMPONENT_PALETTE_JS: &str = include_str!("static/components/cockpit-palette.js");
35const COCKPIT_COMPONENT_ROI_JS: &str = include_str!("static/components/cockpit-roi.js");
36const COCKPIT_COMPONENT_REPLAY_JS: &str = include_str!("static/components/cockpit-replay.js");
37const COCKPIT_COMPONENT_LEADERBOARD_JS: &str =
38    include_str!("static/components/cockpit-leaderboard.js");
39const COCKPIT_COMPONENT_AREA_TABS_JS: &str = include_str!("static/components/cockpit-area-tabs.js");
40const COCKPIT_COMPONENT_PROTECTION_JS: &str =
41    include_str!("static/components/cockpit-protection.js");
42const COCKPIT_COMPONENT_SETTINGS_JS: &str = include_str!("static/components/cockpit-settings.js");
43
44// Vendored third-party libraries — embedded so the dashboard works fully offline
45// (no external CDN). Served as text via the standard route pipeline.
46const COCKPIT_VENDOR_CHART_JS: &str = include_str!("static/vendor/chart.umd.min.js");
47const COCKPIT_VENDOR_D3_JS: &str = include_str!("static/vendor/d3.min.js");
48const COCKPIT_FONTS_CSS: &str = include_str!("static/fonts/fonts.css");
49const COCKPIT_FAVICON_SVG: &str = include_str!("static/favicon.svg");
50
51// Self-hosted variable fonts (binary woff2). Served via a dedicated binary
52// branch in `handle_request` so the bytes are never corrupted by the
53// String-based route pipeline.
54const FONT_INTER_WOFF2: &[u8] = include_bytes!("static/fonts/inter-variable.woff2");
55const FONT_JETBRAINS_WOFF2: &[u8] = include_bytes!("static/fonts/jetbrains-mono-variable.woff2");
56const FONT_SPACE_GROTESK_WOFF2: &[u8] = include_bytes!("static/fonts/space-grotesk-variable.woff2");
57
58/// Maps a request path to an embedded binary font asset.
59fn match_font_asset(path: &str) -> Option<&'static [u8]> {
60    match path {
61        "/static/fonts/inter-variable.woff2" => Some(FONT_INTER_WOFF2),
62        "/static/fonts/jetbrains-mono-variable.woff2" => Some(FONT_JETBRAINS_WOFF2),
63        "/static/fonts/space-grotesk-variable.woff2" => Some(FONT_SPACE_GROTESK_WOFF2),
64        _ => None,
65    }
66}
67
68pub mod base_path;
69pub mod routes;
70pub(crate) mod vscode_open;
71
72pub async fn start(
73    port: Option<u16>,
74    host: Option<String>,
75    base_path: Option<String>,
76    auth_token: Option<String>,
77    open_mode: Option<String>,
78    auth_enabled: Option<bool>,
79) {
80    // How to reveal the URL once the server is up: --open= flag > env > browser.
81    let open = resolve_open_mode(open_mode.as_deref());
82    let port = port.unwrap_or_else(|| {
83        std::env::var("LEAN_CTX_PORT")
84            .ok()
85            .and_then(|p| p.parse().ok())
86            .unwrap_or(DEFAULT_PORT)
87    });
88
89    let host = host.unwrap_or_else(|| {
90        std::env::var("LEAN_CTX_HOST")
91            .ok()
92            .unwrap_or_else(|| DEFAULT_HOST.to_string())
93    });
94
95    // Reverse-proxy subpath (e.g. `/dashboard`). Normalized to "" or "/prefix".
96    // Shared across connections behind an Arc; "" means "no subpath" (#355).
97    let base_path = Arc::new(
98        base_path
99            .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_BASE_PATH").ok())
100            .map(|b| base_path::normalize(&b))
101            .unwrap_or_default(),
102    );
103
104    let addr = format!("{host}:{port}");
105    let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
106
107    // Whether the dashboard requires a Bearer token. Precedence:
108    // `--no-auth`/`--auth=` flag > LEAN_CTX_DASHBOARD_AUTH env > `dashboard_auth`
109    // config > default `true`. When disabled, no token is generated and the
110    // sensitive endpoints (`/api/*`, `/metrics`) are guarded by request-header
111    // checks (Sec-Fetch-Site / Origin / Host allowlist) instead — see
112    // `no_auth_request_ok`.
113    let auth_required = resolve_auth_enabled(auth_enabled);
114
115    // Host values accepted in no-auth mode (anti-DNS-rebinding allowlist). Built
116    // once and shared with every connection.
117    let allowed_hosts = Arc::new(build_allowed_hosts(&host, port));
118
119    // Resolve any *requested* fixed token (flag > LEAN_CTX_HTTP_TOKEN) up-front;
120    // `None` means "generate a random one". Done before the already-running check
121    // so we can warn when the requested token won't match a live instance (#377).
122    let (requested_token, token_src) = resolve_requested_token(auth_token.as_deref());
123
124    // Avoid accidental multiple dashboard instances (common source of "it hangs").
125    // Only safe to auto-detect for local dashboards without auth.
126    if is_local && dashboard_responding(&host, port) {
127        println!("\n  lean-ctx dashboard already running → http://{host}:{port}{base_path}");
128        if let Some(req) = requested_token.as_deref()
129            && load_saved_token().as_deref() != Some(req)
130        {
131            eprintln!(
132                "  \x1b[33m⚠\x1b[0m The running instance uses a different token — your {token_src} \
133                     will be rejected. Stop it (Ctrl+C) and restart to apply the new token."
134            );
135        }
136        println!("  Tip: use Ctrl+C in the existing terminal to stop it.\n");
137        if let Some(t) = load_saved_token() {
138            open_dashboard_url(
139                &format!("http://localhost:{port}{base_path}/?token={t}"),
140                open,
141            );
142        } else {
143            open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
144        }
145        return;
146    }
147
148    // Auth defaults on (even on loopback) to prevent cross-origin reads of /api/*
149    // from a malicious website (CORS is not a reliable boundary for localhost
150    // services). When explicitly disabled, run token-less: cross-origin/CSRF and
151    // DNS-rebinding attacks are blocked by `no_auth_request_ok` instead.
152    let token = if auth_required {
153        let t = requested_token.unwrap_or_else(generate_token);
154        Some(Arc::new(t))
155    } else {
156        if requested_token.is_some() {
157            eprintln!(
158                "  \x1b[33m⚠\x1b[0m Ignoring the pinned token ({token_src}) — auth is disabled."
159            );
160        }
161        None
162    };
163
164    // Bind BEFORE persisting the token: two racing `lean-ctx dashboard` starts
165    // both used to write their fresh token, the bind loser exited — leaving a
166    // token on disk that the surviving server never accepted. Every later
167    // "already running" browser open (and any tool reading dashboard.token)
168    // then got 401s. Binding first makes the loser exit without touching the
169    // file, so dashboard.token always belongs to the live listener.
170    let listener = match TcpListener::bind(&addr).await {
171        Ok(l) => l,
172        Err(e) => {
173            eprintln!("Failed to bind to {addr}: {e}");
174            std::process::exit(1);
175        }
176    };
177
178    if let Some(t) = token.as_ref() {
179        save_token(t);
180        let masked = if t.len() > 12 {
181            format!(
182                "{}…{}",
183                &t[..t.floor_char_boundary(8)],
184                &t[t.ceil_char_boundary(t.len().saturating_sub(4))..]
185            )
186        } else {
187            t.to_string()
188        };
189        let src = if token_src.is_empty() {
190            String::new()
191        } else {
192            format!(" (from {token_src})")
193        };
194        if is_local {
195            println!("  Auth: enabled (local){src}");
196            println!("  Browser URL:  http://localhost:{port}{base_path}/?token={t}");
197        } else {
198            eprintln!(
199                "  \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n  \
200                 Bearer token{src}: \x1b[1;32m{masked}\x1b[0m\n  \
201                 Browser URL:  http://<your-ip>:{port}{base_path}/?token={t}"
202            );
203        }
204    } else if is_local {
205        // No-auth on loopback: header-based CSRF protection is the boundary.
206        println!(
207            "  Auth: \x1b[1;33mDISABLED\x1b[0m (no-auth) — CSRF protected via Sec-Fetch-Site/Origin/Host"
208        );
209        println!("  Browser URL:  http://localhost:{port}{base_path}/");
210    } else {
211        // No-auth + non-loopback bind (e.g. Docker --host=0.0.0.0). Browser
212        // cross-origin/CSRF is still blocked, but non-browser clients that can
213        // reach the address have unauthenticated access — warn loudly.
214        eprintln!(
215            "  \x1b[33m⚠\x1b[0m Auth \x1b[1;31mDISABLED\x1b[0m and binding to {host} (not loopback).\n  \
216             Browser cross-origin/CSRF stays blocked (Sec-Fetch-Site/Origin/Host),\n  \
217             but ANY non-browser client that can reach {host}:{port} has full access.\n  \
218             Docker: publish only to the host loopback → -p 127.0.0.1:{port}:{port}\n  \
219             Add reachable hostnames via LEAN_CTX_DASHBOARD_ALLOWED_HOSTS=host:port,…\n  \
220             Browser URL:  http://<your-ip>:{port}{base_path}/"
221        );
222    }
223
224    let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
225        |_| "~/.lean-ctx/stats.json".to_string(),
226        |d| d.join("stats.json").display().to_string(),
227    );
228
229    if host == "0.0.0.0" {
230        println!("\n  lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
231        println!("  Local access:  http://localhost:{port}");
232    } else {
233        println!("\n  lean-ctx dashboard → http://{host}:{port}");
234    }
235    println!("  Stats file: {stats_path}");
236    println!("  Press Ctrl+C to stop");
237    println!(
238        "  \x1b[2m💡 Join the public leaderboard at https://leanctx.com/metrics: lean-ctx gain --publish --leaderboard\x1b[0m\n"
239    );
240
241    if is_local {
242        if let Some(t) = token.as_ref() {
243            open_dashboard_url(
244                &format!("http://localhost:{port}{base_path}/?token={t}"),
245                open,
246            );
247        } else {
248            open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
249        }
250    }
251    if crate::shell::is_container() && is_local {
252        println!("  Tip (Docker): bind 0.0.0.0 + publish port:");
253        println!("    lean-ctx dashboard --host=0.0.0.0 --port={port}");
254        println!("    docker run ... -p {port}:{port} ...");
255        println!();
256    }
257
258    if crate::core::datadog_push::spawn_if_enabled() {
259        println!(
260            "  Datadog push: enabled (agentless, every LEAN_CTX_DATADOG_INTERVAL_SECS or 60s)"
261        );
262    }
263
264    loop {
265        if let Ok((stream, _)) = listener.accept().await {
266            let token_ref = token.clone();
267            let base_ref = base_path.clone();
268            let allowed_ref = allowed_hosts.clone();
269            tokio::spawn(handle_request(stream, token_ref, base_ref, allowed_ref));
270        }
271    }
272}
273
274/// Name of the env var that pins the dashboard Bearer token (#377).
275const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
276/// Read-only token accepted **only** for `GET /metrics` (GL #401) so
277/// monitoring agents never hold the full dashboard credential.
278const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
279/// Toggles dashboard Bearer-token auth. `false`/`0`/`no`/`off` disable it.
280const DASHBOARD_AUTH_ENV: &str = "LEAN_CTX_DASHBOARD_AUTH";
281/// Extra `Host` header values accepted in no-auth mode (CSV, e.g.
282/// `box.local:3333,10.0.0.5:3333`). Extends the loopback/bound-host allowlist
283/// for Docker/reverse-proxy setups reached via a custom hostname.
284const ALLOWED_HOSTS_ENV: &str = "LEAN_CTX_DASHBOARD_ALLOWED_HOSTS";
285
286/// Parse a human boolean (`true/false/1/0/yes/no/on/off`, case-insensitive).
287fn parse_human_bool(s: &str) -> Option<bool> {
288    match s.trim().to_ascii_lowercase().as_str() {
289        "true" | "1" | "yes" | "on" => Some(true),
290        "false" | "0" | "no" | "off" => Some(false),
291        _ => None,
292    }
293}
294
295/// Resolve whether the dashboard requires Bearer-token auth. Precedence:
296/// `--no-auth`/`--auth=` flag > `LEAN_CTX_DASHBOARD_AUTH` env > `dashboard_auth`
297/// config > default `true`.
298fn resolve_auth_enabled(flag: Option<bool>) -> bool {
299    if let Some(v) = flag {
300        return v;
301    }
302    if let Ok(raw) = std::env::var(DASHBOARD_AUTH_ENV)
303        && let Some(v) = parse_human_bool(&raw)
304    {
305        return v;
306    }
307    crate::core::config::Config::load().dashboard_auth
308}
309
310/// Build the `Host` header allowlist for no-auth mode (anti-DNS-rebinding).
311/// Always includes the loopback aliases for `port` (localhost is the intended
312/// audience), the actual bound `host:port`, and any `LEAN_CTX_DASHBOARD_ALLOWED_HOSTS`
313/// entries. Bare-host forms (no port) are added too so non-browser clients that
314/// omit the port aren't rejected. `0.0.0.0` is never added — browsers don't send
315/// `Host: 0.0.0.0`; operators expose reachable names via the env allowlist.
316fn build_allowed_hosts(host: &str, port: u16) -> Vec<String> {
317    let mut allowed: Vec<String> = Vec::new();
318    let mut push = |h: String| {
319        if !h.is_empty() && !allowed.iter().any(|e| e.eq_ignore_ascii_case(&h)) {
320            allowed.push(h);
321        }
322    };
323    for base in ["127.0.0.1", "localhost", "[::1]", "::1"] {
324        push(base.to_string());
325        push(format!("{base}:{port}"));
326    }
327    if host != "0.0.0.0" && host != "::" {
328        push(host.to_string());
329        push(format!("{host}:{port}"));
330    }
331    if let Ok(raw) = std::env::var(ALLOWED_HOSTS_ENV) {
332        for entry in raw.split(',') {
333            push(entry.trim().to_string());
334        }
335    }
336    allowed
337}
338
339/// Is the request `Host` header in the allowlist (case-insensitive)?
340fn host_allowed(host: &str, allowed: &[String]) -> bool {
341    allowed.iter().any(|a| a.eq_ignore_ascii_case(host))
342}
343
344/// True when the `Host` header's hostname is a loopback literal
345/// (`localhost`, `127.0.0.0/8`, or `::1`), **regardless of port**.
346///
347/// A loopback `Host` is never a DNS-rebinding vector: the browser only sends one
348/// when the user navigated to a loopback URL directly (an attacker can't make
349/// their own hostname resolve to — and report a `Host` of — `127.0.0.1`). So in
350/// no-auth mode we accept loopback on any port, not just the bound one. This is
351/// what makes a port-remapped Docker publish work out of the box — e.g. the
352/// container binds `0.0.0.0:3333`, Docker publishes it as `-p 60000:3333`, and
353/// the host browser reaches `http://127.0.0.1:60000`, so the `Host` header is
354/// `127.0.0.1:60000` (the *published* port), which the bind-port allowlist
355/// (`127.0.0.1:3333`) would otherwise reject. Cross-origin/CSRF stays blocked by
356/// the `Sec-Fetch-Site`/`Origin` checks in `no_auth_request_ok`.
357fn host_is_loopback(host: &str) -> bool {
358    // Extract the hostname, dropping any `:port`. IPv6 literals are bracketed
359    // (`[::1]` / `[::1]:port`); a bare IPv6 (`::1`) can't carry a port.
360    let hostname = if let Some(rest) = host.strip_prefix('[') {
361        match rest.split_once(']') {
362            Some((inner, _)) => inner,
363            None => return false,
364        }
365    } else if host.matches(':').count() == 1 {
366        host.rsplit_once(':').map_or(host, |(h, _)| h)
367    } else {
368        // No colon (bare host[:no-port]) or multiple colons (unbracketed IPv6).
369        host
370    };
371    if hostname.eq_ignore_ascii_case("localhost") {
372        return true;
373    }
374    if let Ok(v4) = hostname.parse::<std::net::Ipv4Addr>() {
375        return v4.is_loopback();
376    }
377    if let Ok(v6) = hostname.parse::<std::net::Ipv6Addr>() {
378        return v6.is_loopback();
379    }
380    false
381}
382
383/// Token-free request gate for no-auth dashboards. Applied to the same sensitive
384/// endpoints the Bearer token guards (`/api/*`, `/metrics`). Blocks browser
385/// cross-origin/CSRF and DNS-rebinding without a credential:
386///  * `Sec-Fetch-Site`, when sent, must be `same-origin` or `none` (the header is
387///    set by the browser and cannot be forged by page JS).
388///  * `Host` must be in `allowed_hosts` (missing `Host` is rejected).
389///  * `Origin`, when sent and not `null`, must be same-origin as `Host`.
390///
391/// Non-browser clients (curl, Prometheus) omit `Sec-Fetch-Site`/`Origin` and pass
392/// those checks — they only need to target an allowlisted `Host`.
393fn no_auth_request_ok(header_section: &str, allowed_hosts: &[String]) -> bool {
394    if let Some(sfs) = header_line_value(header_section, "Sec-Fetch-Site") {
395        let sfs = sfs.trim();
396        if !sfs.is_empty()
397            && !sfs.eq_ignore_ascii_case("same-origin")
398            && !sfs.eq_ignore_ascii_case("none")
399        {
400            return false;
401        }
402    }
403    let Some(host) = header_line_value(header_section, "Host") else {
404        return false;
405    };
406    // Accept the explicit allowlist (loopback aliases for the bound port, the
407    // bound host, and any LEAN_CTX_DASHBOARD_ALLOWED_HOSTS entries) OR any
408    // loopback host on any port. The latter covers port-remapped Docker
409    // publishes (e.g. `-p 60000:3333` reached via `127.0.0.1:60000`) without a
410    // manual allowlist entry — loopback hosts are not a rebinding vector.
411    if !host_allowed(host, allowed_hosts) && !host_is_loopback(host) {
412        return false;
413    }
414    if let Some(origin) = header_line_value(header_section, "Origin")
415        && !origin.is_empty()
416        && !origin.eq_ignore_ascii_case("null")
417        && !origin_matches_dashboard_host(origin, host)
418    {
419        return false;
420    }
421    true
422}
423
424/// Resolve the dashboard Bearer token.
425///
426/// Honors `LEAN_CTX_HTTP_TOKEN` (#377): when set to a non-empty value it is used
427/// verbatim so reverse-proxy / container deployments keep a stable token across
428/// restarts and redeploys (nginx can inject a fixed `Authorization: Bearer …`).
429/// When unset or empty, a fresh random token is generated (no behavior change).
430///
431/// Resolve a *requested* fixed token with precedence `--auth-token` flag >
432/// `LEAN_CTX_HTTP_TOKEN` (#377). The flag wins so it survives container/service
433/// environments that strip or fail to inherit the env var. Returns the trimmed,
434/// non-empty token and a human label of its source; `None` means "no fixed token
435/// requested → caller generates a random one".
436fn resolve_requested_token(flag: Option<&str>) -> (Option<String>, &'static str) {
437    if let Some(t) = flag.map(str::trim).filter(|s| !s.is_empty()) {
438        return (Some(t.to_string()), "--auth-token");
439    }
440    if let Ok(raw) = std::env::var(HTTP_TOKEN_ENV) {
441        let trimmed = raw.trim();
442        if !trimmed.is_empty() {
443            return (Some(trimmed.to_string()), HTTP_TOKEN_ENV);
444        }
445    }
446    (None, "")
447}
448
449fn generate_token() -> String {
450    let mut bytes = [0u8; 32];
451    if getrandom::fill(&mut bytes).is_err() {
452        tracing::warn!("CSPRNG unavailable — falling back to time-based token");
453        let ts = std::time::SystemTime::now()
454            .duration_since(std::time::UNIX_EPOCH)
455            .unwrap_or_default()
456            .as_nanos();
457        for (i, b) in bytes.iter_mut().enumerate() {
458            *b = ((ts >> (i % 16 * 8)) & 0xFF) as u8;
459        }
460    }
461    format!("lctx_{}", hex_lower(&bytes))
462}
463
464fn save_token(token: &str) {
465    if let Ok(dir) = crate::core::paths::state_dir() {
466        let _ = std::fs::create_dir_all(&dir);
467        let path = dir.join("dashboard.token");
468        #[cfg(unix)]
469        {
470            use std::io::Write;
471            use std::os::unix::fs::OpenOptionsExt;
472            let Ok(mut f) = std::fs::OpenOptions::new()
473                .write(true)
474                .create(true)
475                .truncate(true)
476                .mode(0o600)
477                .open(&path)
478            else {
479                return;
480            };
481            let _ = f.write_all(token.as_bytes());
482        }
483        #[cfg(not(unix))]
484        {
485            let _ = std::fs::write(&path, token);
486        }
487    }
488}
489
490fn load_saved_token() -> Option<String> {
491    let dir = crate::core::paths::state_dir().ok()?;
492    let path = dir.join("dashboard.token");
493    std::fs::read_to_string(path)
494        .ok()
495        .map(|s| s.trim().to_string())
496}
497
498/// Adds `nonce="..."` to all inline `<script>` tags (those without a `src=` attribute).
499/// External scripts (`<script src="...">`) are left untouched.
500pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
501    let mut result = String::with_capacity(html.len() + 128);
502    let mut remaining = html;
503    while let Some(pos) = remaining.find("<script") {
504        result.push_str(&remaining[..pos]);
505        let tag_start = &remaining[pos..];
506        let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
507        let tag = &tag_start[..=tag_end];
508        if tag.contains("src=") || tag.contains("nonce=") {
509            result.push_str(tag);
510        } else {
511            result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
512        }
513        remaining = &tag_start[tag_end + 1..];
514    }
515    result.push_str(remaining);
516    result
517}
518
519fn hex_lower(bytes: &[u8]) -> String {
520    const HEX: &[u8; 16] = b"0123456789abcdef";
521    let mut out = String::with_capacity(bytes.len() * 2);
522    for &b in bytes {
523        out.push(HEX[(b >> 4) as usize] as char);
524        out.push(HEX[(b & 0x0f) as usize] as char);
525    }
526    out
527}
528
529/// How `lean-ctx dashboard` reveals the URL after the server is up (#424).
530#[derive(Clone, Copy, PartialEq, Eq, Debug)]
531enum DashboardOpen {
532    /// Launch the system default browser (historical default).
533    Browser,
534    /// Don't auto-launch anything — just print the URL. For users who run the
535    /// dashboard inside an editor / reverse proxy and don't want a new window.
536    None,
537    /// Suppress the external browser and print the steps to open the URL in
538    /// VS Code's built-in browser. VS Code exposes no stable CLI flag to open
539    /// its Simple/Integrated Browser, so we guide rather than fake it.
540    Vscode,
541}
542
543/// Resolve the open mode from (in precedence order) the `--open=` flag, the
544/// `LEAN_CTX_DASHBOARD_OPEN` env var, else the `browser` default.
545fn resolve_open_mode(flag: Option<&str>) -> DashboardOpen {
546    let raw = flag
547        .map(str::to_string)
548        .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_OPEN").ok())
549        .unwrap_or_default();
550    match raw.trim().to_ascii_lowercase().as_str() {
551        "none" | "off" | "false" | "no" => DashboardOpen::None,
552        "vscode" | "code" | "editor" => DashboardOpen::Vscode,
553        _ => DashboardOpen::Browser,
554    }
555}
556
557/// Reveal `url` to the user according to `mode`.
558fn open_dashboard_url(url: &str, mode: DashboardOpen) {
559    match mode {
560        DashboardOpen::Browser => open_browser(url),
561        DashboardOpen::None => {}
562        DashboardOpen::Vscode => {
563            // Prefer the extension's native webview tab (#466 item 3): with the
564            // lean-ctx VS Code extension installed, one command opens the
565            // dashboard as a real editor tab — no URL copy/paste. Keep the
566            // Simple Browser path as the no-extension fallback.
567            println!(
568                "  \x1b[2mNative tab: run ⇧⌘P → \"lean-ctx: Open Web Dashboard\" (needs the lean-ctx VS Code extension)\x1b[0m"
569            );
570            println!(
571                "  \x1b[2mNo extension? ⇧⌘P → \"Simple Browser: Show\" → paste the URL above\x1b[0m"
572            );
573        }
574    }
575}
576
577fn open_browser(url: &str) {
578    #[cfg(target_os = "macos")]
579    {
580        let _ = std::process::Command::new("open").arg(url).spawn();
581    }
582
583    #[cfg(target_os = "linux")]
584    {
585        let _ = std::process::Command::new("xdg-open")
586            .arg(url)
587            .stderr(std::process::Stdio::null())
588            .spawn();
589    }
590
591    #[cfg(target_os = "windows")]
592    {
593        let _ = std::process::Command::new("cmd")
594            .args(["/C", "start", url])
595            .spawn();
596    }
597}
598
599fn dashboard_responding(host: &str, port: u16) -> bool {
600    use std::io::{Read, Write};
601    use std::net::TcpStream;
602    use std::time::Duration;
603
604    let addr = format!("{host}:{port}");
605    let Ok(mut s) = TcpStream::connect_timeout(
606        &addr
607            .parse()
608            .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
609        Duration::from_millis(150),
610    ) else {
611        return false;
612    };
613    let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
614    let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
615
616    let auth_header = load_saved_token()
617        .map(|t| format!("Authorization: Bearer {t}\r\n"))
618        .unwrap_or_default();
619
620    let req = format!(
621        "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
622    );
623    if s.write_all(req.as_bytes()).is_err() {
624        return false;
625    }
626    let mut buf = [0u8; 256];
627    let Ok(n) = s.read(&mut buf) else {
628        return false;
629    };
630    let head = String::from_utf8_lossy(&buf[..n]);
631    head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
632}
633
634const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
635
636fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
637    for line in header_section.lines() {
638        let Some((k, v)) = line.split_once(':') else {
639            continue;
640        };
641        if k.trim().eq_ignore_ascii_case(name) {
642            return Some(v.trim());
643        }
644    }
645    None
646}
647
648/// Loopback dashboards often use `localhost` vs `127.0.0.1` interchangeably in `Origin`.
649fn host_loopback_aliases(host: &str) -> Vec<String> {
650    let mut v = vec![host.to_string()];
651    if let Some(port) = host.strip_prefix("127.0.0.1:") {
652        v.push(format!("localhost:{port}"));
653    }
654    if let Some(port) = host.strip_prefix("localhost:") {
655        v.push(format!("127.0.0.1:{port}"));
656    }
657    if let Some(port) = host.strip_prefix("[::1]:") {
658        v.push(format!("127.0.0.1:{port}"));
659        v.push(format!("localhost:{port}"));
660    }
661    v
662}
663
664fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
665    let origin = origin.trim_end_matches('/');
666    for h in host_loopback_aliases(host) {
667        if origin.eq_ignore_ascii_case(&format!("http://{h}"))
668            || origin.eq_ignore_ascii_case(&format!("https://{h}"))
669        {
670            return true;
671        }
672    }
673    false
674}
675
676/// Defense-in-depth for browser POSTs: reject cross-site `Origin` on mutating `/api/*` calls.
677/// Non-browser clients (no `Origin`) remain allowed when Bearer auth succeeds.
678fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
679    let uc = method.to_ascii_uppercase();
680    if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
681        return true;
682    }
683    if !path.starts_with("/api/") {
684        return true;
685    }
686    let Some(origin) = header_line_value(header_section, "Origin") else {
687        return true;
688    };
689    if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
690        return true;
691    }
692    let Some(host) = header_line_value(header_section, "Host") else {
693        return false;
694    };
695    origin_matches_dashboard_host(origin, host)
696}
697
698fn find_headers_end(buf: &[u8]) -> Option<usize> {
699    buf.windows(4).position(|w| w == b"\r\n\r\n")
700}
701
702fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
703    let text = String::from_utf8_lossy(header_section);
704    for line in text.lines() {
705        let Some((k, v)) = line.split_once(':') else {
706            continue;
707        };
708        if k.trim().eq_ignore_ascii_case("content-length") {
709            return v.trim().parse::<usize>().ok();
710        }
711    }
712    Some(0)
713}
714
715async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
716    let mut buf = Vec::new();
717    let mut tmp = [0u8; 8192];
718    loop {
719        if let Some(end) = find_headers_end(&buf) {
720            let cl = parse_content_length_header(&buf[..end])?;
721            let total = end + 4 + cl;
722            if total > MAX_HTTP_MESSAGE {
723                return None;
724            }
725            if buf.len() >= total {
726                buf.truncate(total);
727                return Some(buf);
728            }
729        } else if buf.len() > 65_536 {
730            return None;
731        }
732
733        let n = stream.read(&mut tmp).await.ok()?;
734        if n == 0 {
735            return None;
736        }
737        buf.extend_from_slice(&tmp[..n]);
738        if buf.len() > MAX_HTTP_MESSAGE {
739            return None;
740        }
741    }
742}
743
744async fn handle_request(
745    mut stream: tokio::net::TcpStream,
746    token: Option<Arc<String>>,
747    base_path: Arc<String>,
748    allowed_hosts: Arc<Vec<String>>,
749) {
750    let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
751
752    let Some(buf) = read_http_message(&mut stream).await else {
753        return;
754    };
755    let Some(header_end) = find_headers_end(&buf) else {
756        return;
757    };
758    let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
759    let body_start = header_end + 4;
760    let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
761        return;
762    };
763    if buf.len() < body_start + content_len {
764        return;
765    }
766    let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
767        .unwrap_or("")
768        .to_string();
769
770    let first = header_text.lines().next().unwrap_or("");
771    let mut parts = first.split_whitespace();
772    let method = parts.next().unwrap_or("GET").to_string();
773    let raw_path = parts.next().unwrap_or("/").to_string();
774
775    let (path, query_token) = if let Some(idx) = raw_path.find('?') {
776        let p = &raw_path[..idx];
777        let qs = &raw_path[idx + 1..];
778        let tok = qs
779            .split('&')
780            .find_map(|pair| pair.strip_prefix("token="))
781            .map(std::string::ToString::to_string);
782        (p.to_string(), tok)
783    } else {
784        (raw_path.clone(), None)
785    };
786
787    let query_str = raw_path
788        .find('?')
789        .map_or(String::new(), |i| raw_path[i + 1..].to_string());
790
791    // Strip the reverse-proxy subpath prefix (if any) so all downstream matching
792    // (fonts, auth, routing) works on root-relative paths whether or not the
793    // proxy already stripped it (#355).
794    let path = base_path::strip(&path, base_path.as_str()).to_string();
795
796    // Binary font assets are public (like CSS/JS) and bypass the String-based
797    // route pipeline so their bytes stay intact.
798    if let Some(bytes) = match_font_asset(&path) {
799        let header = format!(
800            "HTTP/1.1 200 OK\r\n\
801             Content-Type: font/woff2\r\n\
802             Content-Length: {}\r\n\
803             Cache-Control: public, max-age=31536000, immutable\r\n\
804             X-Content-Type-Options: nosniff\r\n\
805             Connection: close\r\n\
806             \r\n",
807            bytes.len()
808        );
809        let _ = stream.write_all(header.as_bytes()).await;
810        let _ = stream.write_all(bytes).await;
811        return;
812    }
813
814    let is_api = path.starts_with("/api/");
815    let requires_auth = is_api || path == "/metrics";
816
817    if let Some(ref expected) = token {
818        let mut has_header_auth = check_auth(&header_text, expected);
819
820        // Read-only scrape token (GL #401): lets a Prometheus/Datadog agent
821        // scrape `/metrics` without holding the full dashboard token. Valid
822        // for the metrics endpoint only — every other API stays gated on the
823        // dashboard token.
824        if !has_header_auth
825            && path == "/metrics"
826            && let Ok(scrape) = std::env::var(SCRAPE_TOKEN_ENV)
827        {
828            let scrape = scrape.trim();
829            if !scrape.is_empty() && check_auth(&header_text, scrape) {
830                has_header_auth = true;
831            }
832        }
833
834        if requires_auth && !has_header_auth {
835            let body = r#"{"error":"unauthorized"}"#;
836            let response = format!(
837                "HTTP/1.1 401 Unauthorized\r\n\
838                 Content-Type: application/json\r\n\
839                 Content-Length: {}\r\n\
840                 WWW-Authenticate: Bearer\r\n\
841                 Connection: close\r\n\
842                 \r\n\
843                 {body}",
844                body.len()
845            );
846            let _ = stream.write_all(response.as_bytes()).await;
847            return;
848        }
849
850        if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
851            let body = r#"{"error":"forbidden"}"#;
852            let response = format!(
853                "HTTP/1.1 403 Forbidden\r\n\
854                 Content-Type: application/json\r\n\
855                 Content-Length: {}\r\n\
856                 Connection: close\r\n\
857                 \r\n\
858                 {body}",
859                body.len()
860            );
861            let _ = stream.write_all(response.as_bytes()).await;
862            return;
863        }
864    } else if requires_auth && !no_auth_request_ok(&header_text, &allowed_hosts) {
865        // No-auth mode: the Bearer token is gone, so cross-origin/CSRF and
866        // DNS-rebinding are blocked by request-header validation instead.
867        let body = r#"{"error":"forbidden"}"#;
868        let response = format!(
869            "HTTP/1.1 403 Forbidden\r\n\
870             Content-Type: application/json\r\n\
871             Content-Length: {}\r\n\
872             Connection: close\r\n\
873             \r\n\
874             {body}",
875            body.len()
876        );
877        let _ = stream.write_all(response.as_bytes()).await;
878        return;
879    }
880
881    // Route handlers are synchronous and a few (graph/index builds) do seconds
882    // of disk work. Running them inline on an async worker thread lets one slow
883    // endpoint starve the small worker pool, so a trivial GET like
884    // `/api/settings` can wait minutes behind it (#431, Windows few-core). Run
885    // them on the blocking pool instead: the async workers stay free to serve
886    // light endpoints promptly. `spawn_blocking` also captures panics (returns
887    // a `JoinError`), so the previous `catch_unwind` is no longer needed.
888    let route_started = std::time::Instant::now();
889    let route_label = path.clone();
890    let compute = tokio::task::spawn_blocking(move || {
891        routes::route_response(
892            &path,
893            &query_str,
894            query_token.as_ref(),
895            token.as_ref(),
896            is_loopback,
897            &method,
898            &body_str,
899        )
900    })
901    .await;
902    let (status, content_type, mut body) = match compute {
903        Ok(v) => v,
904        // The blocking task panicked or was cancelled — surface a 500 rather
905        // than dropping the connection.
906        Err(_) => (
907            "500 Internal Server Error",
908            "application/json",
909            r#"{"error":"dashboard route panicked"}"#.to_string(),
910        ),
911    };
912    // Observability: a slow light endpoint is exactly the #431 symptom, so make
913    // any handler that crosses 1s visible in the logs for future diagnosis.
914    let route_elapsed = route_started.elapsed();
915    if route_elapsed >= std::time::Duration::from_secs(1) {
916        tracing::warn!(
917            target: "lean_ctx::dashboard",
918            "slow dashboard route {route_label} took {} ms",
919            route_elapsed.as_millis()
920        );
921    }
922
923    // Under a reverse-proxy subpath, rewrite root-absolute asset/API URLs in the
924    // served HTML/CSS/JS so the browser requests them under the prefix (#355).
925    if !base_path.is_empty()
926        && (content_type.contains("text/html")
927            || content_type.contains("text/css")
928            || content_type.contains("javascript"))
929    {
930        body = base_path::rewrite_asset_urls(&body, base_path.as_str());
931    }
932
933    let cache_header = if content_type.starts_with("application/json") {
934        "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
935    } else if content_type.starts_with("application/javascript")
936        || content_type.starts_with("text/css")
937    {
938        "Cache-Control: no-cache, must-revalidate\r\n"
939    } else {
940        ""
941    };
942
943    let nonce = {
944        let mut nb = [0u8; 16];
945        if getrandom::fill(&mut nb).is_err() {
946            nb.iter_mut().enumerate().for_each(|(i, b)| {
947                *b = (std::time::SystemTime::now()
948                    .duration_since(std::time::UNIX_EPOCH)
949                    .unwrap_or_default()
950                    .subsec_nanos()
951                    .wrapping_add(i as u32)) as u8;
952            });
953        }
954        hex_lower(&nb)
955    };
956    if content_type.contains("text/html") {
957        body = add_nonce_to_inline_scripts(&body, &nonce);
958    }
959    let security_headers = format!(
960        "X-Content-Type-Options: nosniff\r\n\
961         X-Frame-Options: DENY\r\n\
962         Referrer-Policy: no-referrer\r\n\
963         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"
964    );
965
966    let response = format!(
967        "HTTP/1.1 {status}\r\n\
968         Content-Type: {content_type}\r\n\
969         Content-Length: {}\r\n\
970         {cache_header}\
971         {security_headers}\
972         Connection: close\r\n\
973         \r\n\
974         {body}",
975        body.len()
976    );
977
978    let _ = stream.write_all(response.as_bytes()).await;
979}
980
981fn check_auth(request: &str, expected_token: &str) -> bool {
982    for line in request.lines() {
983        let lower = line.to_lowercase();
984        if lower.starts_with("authorization:") {
985            let value = line["authorization:".len()..].trim();
986            if let Some(token) = value
987                .strip_prefix("Bearer ")
988                .or_else(|| value.strip_prefix("bearer "))
989            {
990                return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
991            }
992        }
993    }
994    false
995}
996
997fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
998    if a.len() != b.len() {
999        return false;
1000    }
1001    bool::from(a.ct_eq(b))
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::routes::helpers::normalize_dashboard_demo_path;
1007    use super::*;
1008    use tempfile::tempdir;
1009
1010    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1011
1012    #[test]
1013    fn check_auth_with_valid_bearer() {
1014        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
1015        assert!(check_auth(req, "lctx_abc123"));
1016    }
1017
1018    #[test]
1019    fn check_auth_with_invalid_bearer() {
1020        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
1021        assert!(!check_auth(req, "lctx_abc123"));
1022    }
1023
1024    #[test]
1025    fn open_mode_flag_parses_all_variants() {
1026        // Explicit flag wins and never consults the environment (#424).
1027        assert_eq!(resolve_open_mode(Some("none")), DashboardOpen::None);
1028        assert_eq!(resolve_open_mode(Some("off")), DashboardOpen::None);
1029        assert_eq!(resolve_open_mode(Some("no")), DashboardOpen::None);
1030        assert_eq!(resolve_open_mode(Some("vscode")), DashboardOpen::Vscode);
1031        assert_eq!(resolve_open_mode(Some("code")), DashboardOpen::Vscode);
1032        assert_eq!(resolve_open_mode(Some("editor")), DashboardOpen::Vscode);
1033        assert_eq!(resolve_open_mode(Some("VSCode")), DashboardOpen::Vscode);
1034        assert_eq!(resolve_open_mode(Some("browser")), DashboardOpen::Browser);
1035        // Unknown values fall back to the historical default rather than erroring.
1036        assert_eq!(resolve_open_mode(Some("wat")), DashboardOpen::Browser);
1037    }
1038
1039    #[test]
1040    fn open_mode_env_is_used_when_no_flag() {
1041        let _guard = ENV_LOCK.lock().unwrap();
1042        crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "none");
1043        assert_eq!(resolve_open_mode(None), DashboardOpen::None);
1044        crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "vscode");
1045        assert_eq!(resolve_open_mode(None), DashboardOpen::Vscode);
1046        // Flag still overrides the env var.
1047        assert_eq!(resolve_open_mode(Some("browser")), DashboardOpen::Browser);
1048        crate::test_env::remove_var("LEAN_CTX_DASHBOARD_OPEN");
1049        assert_eq!(resolve_open_mode(None), DashboardOpen::Browser);
1050    }
1051
1052    #[test]
1053    fn check_auth_missing_header() {
1054        let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
1055        assert!(!check_auth(req, "lctx_abc123"));
1056    }
1057
1058    #[test]
1059    fn check_auth_lowercase_bearer() {
1060        let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
1061        assert!(check_auth(req, "lctx_abc123"));
1062    }
1063
1064    #[test]
1065    fn query_token_parsing() {
1066        let raw_path = "/index.html?token=lctx_abc123&other=val";
1067        let idx = raw_path.find('?').unwrap();
1068        let qs = &raw_path[idx + 1..];
1069        let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
1070        assert_eq!(tok, Some("lctx_abc123"));
1071    }
1072
1073    #[test]
1074    fn api_path_detection() {
1075        assert!("/api/stats".starts_with("/api/"));
1076        assert!("/api/version".starts_with("/api/"));
1077        assert!(!"/".starts_with("/api/"));
1078        assert!(!"/index.html".starts_with("/api/"));
1079        assert!(!"/favicon.ico".starts_with("/api/"));
1080    }
1081
1082    #[test]
1083    fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
1084        let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
1085        assert_eq!(
1086            normalized,
1087            format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
1088        );
1089    }
1090
1091    #[test]
1092    fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
1093        let input = r"C:\repo\backend\list_tables.js";
1094        assert_eq!(normalize_dashboard_demo_path(input), input);
1095    }
1096
1097    #[test]
1098    fn normalize_dashboard_demo_path_preserves_unc_path() {
1099        let input = r"\\server\share\backend\list_tables.js";
1100        assert_eq!(normalize_dashboard_demo_path(input), input);
1101    }
1102
1103    #[test]
1104    fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
1105        assert_eq!(
1106            normalize_dashboard_demo_path("./src/main.rs"),
1107            "src/main.rs"
1108        );
1109        assert_eq!(
1110            normalize_dashboard_demo_path(r".\src\main.rs"),
1111            format!("src{}main.rs", std::path::MAIN_SEPARATOR)
1112        );
1113    }
1114
1115    #[test]
1116    fn api_profile_returns_json() {
1117        let (_status, _ct, body) =
1118            routes::route_response("/api/profile", "", None, None, false, "GET", "");
1119        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1120        assert!(v.get("active_name").is_some(), "missing active_name");
1121        assert!(
1122            v.pointer("/profile/profile/name")
1123                .and_then(|n| n.as_str())
1124                .is_some(),
1125            "missing profile.profile.name"
1126        );
1127        assert!(v.get("available").and_then(|a| a.as_array()).is_some());
1128    }
1129
1130    #[test]
1131    fn api_billing_badge_returns_cosmetic_shape() {
1132        let (status, ct, body) =
1133            routes::route_response("/api/billing-badge", "", None, None, false, "GET", "");
1134        assert_eq!(status, "200 OK");
1135        assert_eq!(ct, "application/json");
1136        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1137        assert!(v.get("plan").and_then(|p| p.as_str()).is_some());
1138        assert!(
1139            v.get("supporter")
1140                .and_then(serde_json::Value::as_bool)
1141                .is_some()
1142        );
1143        assert!(
1144            matches!(
1145                v.get("source").and_then(|s| s.as_str()),
1146                Some("live" | "cached" | "expired" | "none")
1147            ),
1148            "unexpected source: {body}"
1149        );
1150    }
1151
1152    #[test]
1153    fn api_episodes_returns_json() {
1154        let (_status, _ct, body) =
1155            routes::route_response("/api/episodes", "", None, None, false, "GET", "");
1156        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1157        assert!(v.get("project_hash").is_some());
1158        assert!(v.get("stats").is_some());
1159        assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
1160    }
1161
1162    #[test]
1163    fn api_procedures_returns_json() {
1164        let (_status, _ct, body) =
1165            routes::route_response("/api/procedures", "", None, None, false, "GET", "");
1166        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1167        assert!(v.get("project_hash").is_some());
1168        assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
1169        assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
1170    }
1171
1172    #[test]
1173    fn api_compression_demo_heals_moved_file_paths() {
1174        let _g = ENV_LOCK.lock().expect("env lock");
1175        let td = tempdir().expect("tempdir");
1176        let root = td.path();
1177        std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
1178        std::fs::write(
1179            root.join("src").join("moved").join("foo.rs"),
1180            "pub fn foo() { println!(\"hi\"); }\n",
1181        )
1182        .expect("write foo.rs");
1183
1184        let root_s = root.to_string_lossy().to_string();
1185        crate::test_env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
1186
1187        let (_status, _ct, body) = routes::route_response(
1188            "/api/compression-demo",
1189            "path=src/foo.rs",
1190            None,
1191            None,
1192            false,
1193            "GET",
1194            "",
1195        );
1196        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1197        assert!(v.get("error").is_none(), "unexpected error: {body}");
1198        assert_eq!(
1199            v.get("resolved_from").and_then(|x| x.as_str()),
1200            Some("src/moved/foo.rs")
1201        );
1202
1203        crate::test_env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
1204        if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
1205            let _ = std::fs::remove_dir_all(dir);
1206        }
1207    }
1208
1209    #[test]
1210    fn resolve_token_uses_env_var_verbatim() {
1211        let _g = ENV_LOCK.lock().expect("env lock");
1212        crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_mystatic");
1213        let (token, src) = resolve_requested_token(None);
1214        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1215        assert_eq!(
1216            src, HTTP_TOKEN_ENV,
1217            "token should be reported as env-sourced"
1218        );
1219        assert_eq!(token.as_deref(), Some("lctx_mystatic"));
1220    }
1221
1222    #[test]
1223    fn resolve_token_trims_env_var() {
1224        let _g = ENV_LOCK.lock().expect("env lock");
1225        crate::test_env::set_var(HTTP_TOKEN_ENV, "  lctx_padded  ");
1226        let (token, src) = resolve_requested_token(None);
1227        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1228        assert_eq!(src, HTTP_TOKEN_ENV);
1229        assert_eq!(token.as_deref(), Some("lctx_padded"));
1230    }
1231
1232    #[test]
1233    fn resolve_token_falls_back_to_random_when_unset() {
1234        let _g = ENV_LOCK.lock().expect("env lock");
1235        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1236        let (token, src) = resolve_requested_token(None);
1237        assert!(token.is_none(), "unset env requests no fixed token");
1238        assert!(src.is_empty());
1239        // The production fallback in `start()` generates a random token.
1240        let generated = token.unwrap_or_else(generate_token);
1241        assert!(
1242            generated.starts_with("lctx_"),
1243            "generated token prefix, got {generated}"
1244        );
1245        assert!(
1246            generated.len() > 12,
1247            "generated token should be 32-byte hex"
1248        );
1249    }
1250
1251    #[test]
1252    fn resolve_token_ignores_empty_env() {
1253        let _g = ENV_LOCK.lock().expect("env lock");
1254        crate::test_env::set_var(HTTP_TOKEN_ENV, "   ");
1255        let (token, src) = resolve_requested_token(None);
1256        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1257        assert!(
1258            token.is_none(),
1259            "whitespace-only env requests no fixed token"
1260        );
1261        assert!(src.is_empty());
1262    }
1263
1264    #[test]
1265    fn resolve_token_flag_overrides_env() {
1266        // #377: --auth-token must win over LEAN_CTX_HTTP_TOKEN so it survives
1267        // environments that strip/fail to inherit the env var.
1268        let _g = ENV_LOCK.lock().expect("env lock");
1269        crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
1270        let (token, src) = resolve_requested_token(Some("lctx_fromflag"));
1271        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1272        assert_eq!(src, "--auth-token");
1273        assert_eq!(token.as_deref(), Some("lctx_fromflag"));
1274    }
1275
1276    #[test]
1277    fn resolve_token_uses_flag_when_env_unset() {
1278        let _g = ENV_LOCK.lock().expect("env lock");
1279        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1280        let (token, src) = resolve_requested_token(Some("  lctx_flag_padded  "));
1281        assert_eq!(src, "--auth-token");
1282        assert_eq!(token.as_deref(), Some("lctx_flag_padded"));
1283    }
1284
1285    #[test]
1286    fn resolve_token_empty_flag_falls_back_to_env() {
1287        let _g = ENV_LOCK.lock().expect("env lock");
1288        crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
1289        let (token, src) = resolve_requested_token(Some("   "));
1290        crate::test_env::remove_var(HTTP_TOKEN_ENV);
1291        assert_eq!(src, HTTP_TOKEN_ENV);
1292        assert_eq!(token.as_deref(), Some("lctx_fromenv"));
1293    }
1294
1295    #[test]
1296    fn parse_human_bool_accepts_common_forms() {
1297        for s in ["true", "TRUE", "1", "yes", "on", " On "] {
1298            assert_eq!(parse_human_bool(s), Some(true), "{s}");
1299        }
1300        for s in ["false", "FALSE", "0", "no", "off", " Off "] {
1301            assert_eq!(parse_human_bool(s), Some(false), "{s}");
1302        }
1303        assert_eq!(parse_human_bool("maybe"), None);
1304    }
1305
1306    #[test]
1307    fn build_allowed_hosts_covers_loopback_and_bound_host() {
1308        let _g = ENV_LOCK.lock().expect("env lock");
1309        crate::test_env::remove_var(ALLOWED_HOSTS_ENV);
1310        let allowed = build_allowed_hosts("0.0.0.0", 3333);
1311        assert!(host_allowed("127.0.0.1:3333", &allowed));
1312        assert!(host_allowed("localhost:3333", &allowed));
1313        assert!(host_allowed("[::1]:3333", &allowed));
1314        assert!(host_allowed("127.0.0.1", &allowed)); // bare host, no port
1315        // 0.0.0.0 binds all interfaces but is never a valid browser Host.
1316        assert!(!host_allowed("0.0.0.0:3333", &allowed));
1317        assert!(!host_allowed("evil.com", &allowed));
1318    }
1319
1320    #[test]
1321    fn build_allowed_hosts_honors_env_extra_hosts() {
1322        let _g = ENV_LOCK.lock().expect("env lock");
1323        crate::test_env::set_var(ALLOWED_HOSTS_ENV, "box.local:3333, 10.0.0.5:3333");
1324        let allowed = build_allowed_hosts("127.0.0.1", 3333);
1325        crate::test_env::remove_var(ALLOWED_HOSTS_ENV);
1326        assert!(host_allowed("box.local:3333", &allowed));
1327        assert!(host_allowed("10.0.0.5:3333", &allowed));
1328    }
1329
1330    fn allowed_loopback() -> Vec<String> {
1331        vec![
1332            "127.0.0.1:3333".into(),
1333            "localhost:3333".into(),
1334            "127.0.0.1".into(),
1335            "localhost".into(),
1336        ]
1337    }
1338
1339    #[test]
1340    fn no_auth_allows_non_browser_client() {
1341        // curl: no Sec-Fetch-Site, no Origin, just a loopback Host.
1342        let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\n\r\n";
1343        assert!(no_auth_request_ok(req, &allowed_loopback()));
1344    }
1345
1346    #[test]
1347    fn no_auth_allows_same_origin_browser_request() {
1348        let req = "GET /api/stats HTTP/1.1\r\nHost: localhost:3333\r\n\
1349                   Origin: http://localhost:3333\r\nSec-Fetch-Site: same-origin\r\n\r\n";
1350        assert!(no_auth_request_ok(req, &allowed_loopback()));
1351    }
1352
1353    #[test]
1354    fn no_auth_allows_direct_navigation() {
1355        // Top-level navigation carries Sec-Fetch-Site: none and no Origin.
1356        let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\nSec-Fetch-Site: none\r\n\r\n";
1357        assert!(no_auth_request_ok(req, &allowed_loopback()));
1358    }
1359
1360    #[test]
1361    fn no_auth_rejects_cross_site_fetch() {
1362        let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\n\
1363                   Sec-Fetch-Site: cross-site\r\n\r\n";
1364        assert!(!no_auth_request_ok(req, &allowed_loopback()));
1365    }
1366
1367    #[test]
1368    fn no_auth_rejects_same_site_fetch() {
1369        let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\n\
1370                   Sec-Fetch-Site: same-site\r\n\r\n";
1371        assert!(!no_auth_request_ok(req, &allowed_loopback()));
1372    }
1373
1374    #[test]
1375    fn no_auth_rejects_foreign_origin() {
1376        let req = "POST /api/settings HTTP/1.1\r\nHost: 127.0.0.1:3333\r\n\
1377                   Origin: http://evil.com\r\n\r\n";
1378        assert!(!no_auth_request_ok(req, &allowed_loopback()));
1379    }
1380
1381    #[test]
1382    fn no_auth_rejects_dns_rebinding_host() {
1383        // After DNS rebinding the browser sends the attacker's hostname as Host.
1384        let req = "GET /api/stats HTTP/1.1\r\nHost: evil.com\r\n\
1385                   Sec-Fetch-Site: same-origin\r\n\r\n";
1386        assert!(!no_auth_request_ok(req, &allowed_loopback()));
1387    }
1388
1389    #[test]
1390    fn no_auth_rejects_missing_host() {
1391        let req = "GET /api/stats HTTP/1.1\r\n\r\n";
1392        assert!(!no_auth_request_ok(req, &allowed_loopback()));
1393    }
1394
1395    #[test]
1396    fn host_is_loopback_covers_literals_on_any_port() {
1397        assert!(host_is_loopback("127.0.0.1"));
1398        assert!(host_is_loopback("127.0.0.1:3333"));
1399        assert!(host_is_loopback("127.0.0.1:60000")); // port-remapped Docker publish
1400        assert!(host_is_loopback("localhost"));
1401        assert!(host_is_loopback("localhost:60000"));
1402        assert!(host_is_loopback("LocalHost:8080"));
1403        assert!(host_is_loopback("127.5.6.7:9999")); // whole 127.0.0.0/8 is loopback
1404        assert!(host_is_loopback("[::1]"));
1405        assert!(host_is_loopback("[::1]:60000"));
1406        assert!(host_is_loopback("::1"));
1407        // Non-loopback hosts must NOT be treated as loopback.
1408        assert!(!host_is_loopback("evil.com"));
1409        assert!(!host_is_loopback("evil.com:60000"));
1410        assert!(!host_is_loopback("10.0.0.5:3333"));
1411        assert!(!host_is_loopback("[2001:db8::1]:3333"));
1412        assert!(!host_is_loopback("box.local:3333"));
1413    }
1414
1415    #[test]
1416    fn no_auth_allows_loopback_host_on_remapped_port() {
1417        // Container binds 0.0.0.0:3333; Docker publishes -p 60000:3333; the host
1418        // browser reaches 127.0.0.1:60000, so Host carries the *published* port,
1419        // which the bind-port allowlist does not contain. It must still pass via
1420        // the loopback-any-port rule (GH no-auth Docker port-remap).
1421        let allowed = build_allowed_hosts("0.0.0.0", 3333);
1422        assert!(!host_allowed("127.0.0.1:60000", &allowed));
1423        let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:60000\r\n\
1424                   Sec-Fetch-Site: same-origin\r\n\r\n";
1425        assert!(no_auth_request_ok(req, &allowed));
1426
1427        // localhost on a remapped port works too.
1428        let req2 = "GET /api/stats HTTP/1.1\r\nHost: localhost:60000\r\n\r\n";
1429        assert!(no_auth_request_ok(req2, &allowed));
1430    }
1431
1432    #[test]
1433    fn no_auth_still_rejects_rebinding_on_remapped_port() {
1434        // The loopback-any-port relaxation must not weaken DNS-rebinding defense:
1435        // a non-loopback Host is rejected regardless of port.
1436        let allowed = build_allowed_hosts("0.0.0.0", 3333);
1437        let req = "GET /api/stats HTTP/1.1\r\nHost: evil.com:60000\r\n\
1438                   Sec-Fetch-Site: same-origin\r\n\r\n";
1439        assert!(!no_auth_request_ok(req, &allowed));
1440    }
1441
1442    #[test]
1443    fn no_auth_allows_null_origin() {
1444        // Some sandboxed/file contexts send Origin: null — not attributable to a
1445        // site, and the Host + Sec-Fetch-Site checks still apply.
1446        let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\nOrigin: null\r\n\r\n";
1447        assert!(no_auth_request_ok(req, &allowed_loopback()));
1448    }
1449}