Skip to main content

scope/web/
mod.rs

1//! # Web Server Module
2//!
3//! Provides a locally hosted HTTP server with REST API endpoints and a
4//! single-page web UI that mirrors all CLI functionality. The server uses
5//! the same `Config` and `DefaultClientFactory` as the CLI, ensuring
6//! identical behavior.
7//!
8//! ## Usage
9//!
10//! ```bash
11//! # Start in foreground (default port 8080)
12//! scope web
13//!
14//! # Custom port and bind address
15//! scope web --port 3000 --bind 0.0.0.0
16//!
17//! # Run as background daemon
18//! scope web --daemon
19//!
20//! # Stop a running daemon
21//! scope web --stop
22//! ```
23
24pub mod api;
25pub mod monitor;
26
27use crate::chains::DefaultClientFactory;
28use crate::config::Config;
29use axum::Router;
30use axum::response::IntoResponse;
31use std::net::SocketAddr;
32use std::sync::Arc;
33use tower_http::cors::{Any, CorsLayer};
34
35/// Shared application state passed to all handlers via Axum extractors.
36pub struct AppState {
37    /// Application configuration (same as CLI).
38    pub config: Config,
39    /// Client factory for creating chain and DEX clients.
40    pub factory: DefaultClientFactory,
41}
42
43/// Builds the Axum router with all API routes and static file serving.
44pub fn build_router(state: Arc<AppState>) -> Router {
45    let cors = CorsLayer::new()
46        .allow_origin(Any)
47        .allow_methods(Any)
48        .allow_headers(Any);
49
50    let api_routes = api::routes(state.clone());
51
52    Router::new()
53        .nest("/api", api_routes)
54        .route("/ws/monitor", axum::routing::get(monitor::ws_handler))
55        .fallback(axum::routing::get(serve_ui))
56        .layer(cors)
57        .with_state(state)
58}
59
60/// Serves the embedded single-page web UI.
61async fn serve_ui(uri: axum::http::Uri) -> impl axum::response::IntoResponse {
62    let path = uri.path().trim_start_matches('/');
63
64    // Serve specific static assets
65    match path {
66        "" | "index.html" => {
67            axum::response::Html(include_str!("static/index.html")).into_response()
68        }
69        "app.js" => (
70            [(axum::http::header::CONTENT_TYPE, "application/javascript")],
71            include_str!("static/app.js"),
72        )
73            .into_response(),
74        "style.css" => (
75            [(axum::http::header::CONTENT_TYPE, "text/css")],
76            include_str!("static/style.css"),
77        )
78            .into_response(),
79        // SPA fallback: serve index.html for client-side routing
80        _ => axum::response::Html(include_str!("static/index.html")).into_response(),
81    }
82}
83
84/// Starts the web server on the given address.
85///
86/// This is the main entry point called from the CLI `web` command handler.
87pub async fn start_server(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
88    let factory = DefaultClientFactory {
89        chains_config: config.chains.clone(),
90    };
91    let state = Arc::new(AppState { config, factory });
92    let app = build_router(state);
93
94    tracing::info!("Scope web server listening on http://{}", addr);
95    eprintln!("Scope web server listening on http://{}", addr);
96
97    let listener = tokio::net::TcpListener::bind(addr).await?;
98    axum::serve(listener, app).await?;
99
100    Ok(())
101}
102
103// ============================================================================
104// Daemon management
105// ============================================================================
106
107/// Returns the path to the PID file for the daemon.
108pub fn pid_file_path() -> std::path::PathBuf {
109    Config::default_data_dir().join("scope-web.pid")
110}
111
112/// Returns the path to the log file for the daemon.
113pub fn log_file_path() -> std::path::PathBuf {
114    Config::default_data_dir().join("scope-web.log")
115}
116
117/// Stops a running daemon by reading its PID file and sending SIGTERM.
118pub fn stop_daemon() -> anyhow::Result<()> {
119    let pid_path = pid_file_path();
120    if !pid_path.exists() {
121        eprintln!("No daemon PID file found at {}", pid_path.display());
122        eprintln!("Is the daemon running?");
123        return Ok(());
124    }
125
126    let pid_str = std::fs::read_to_string(&pid_path)?;
127    let pid: u32 = pid_str
128        .trim()
129        .parse()
130        .map_err(|e| anyhow::anyhow!("Invalid PID in {}: {}", pid_path.display(), e))?;
131
132    eprintln!("Stopping Scope web daemon (PID {})...", pid);
133
134    #[cfg(unix)]
135    {
136        // Send SIGTERM to the daemon process
137        let result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
138        if result == 0 {
139            eprintln!("Daemon stopped.");
140        } else {
141            eprintln!("Failed to stop daemon (process may have already exited).");
142        }
143    }
144
145    #[cfg(not(unix))]
146    {
147        eprintln!("Daemon stop is only supported on Unix systems.");
148        eprintln!("Please manually terminate PID {}.", pid);
149    }
150
151    // Remove PID file
152    let _ = std::fs::remove_file(&pid_path);
153    Ok(())
154}
155
156/// Starts the server as a background daemon (Unix only).
157///
158/// Spawns the current executable as a detached child process with
159/// stdout/stderr redirected to a log file, then writes the PID.
160#[cfg(unix)]
161pub fn start_daemon(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
162    use std::io::Write;
163
164    let _ = config; // Config reloaded from disk in child
165
166    let data_dir = Config::default_data_dir();
167    std::fs::create_dir_all(&data_dir)?;
168
169    let pid_path = pid_file_path();
170    let log_path = log_file_path();
171
172    eprintln!("Starting Scope web daemon...");
173    eprintln!("  URL:  http://{}", addr);
174    eprintln!("  PID:  {}", pid_path.display());
175    eprintln!("  Log:  {}", log_path.display());
176
177    // Redirect stdout/stderr to log file
178    let log_file = std::fs::OpenOptions::new()
179        .create(true)
180        .append(true)
181        .open(&log_path)?;
182    let log_file_err = log_file.try_clone()?;
183
184    let current_exe = std::env::current_exe()?;
185    let child = std::process::Command::new(current_exe)
186        .args([
187            "web",
188            "--port",
189            &addr.port().to_string(),
190            "--bind",
191            &addr.ip().to_string(),
192        ])
193        .env("SCOPE_WEB_DAEMON_CHILD", "1")
194        .stdout(log_file)
195        .stderr(log_file_err)
196        .stdin(std::process::Stdio::null())
197        .spawn()?;
198
199    let pid = child.id();
200
201    // Write PID file
202    let mut f = std::fs::File::create(&pid_path)?;
203    write!(f, "{}", pid)?;
204
205    eprintln!("Daemon started with PID {}", pid);
206    eprintln!("Stop with: scope web --stop");
207
208    Ok(())
209}
210
211/// Fallback for non-Unix: run in foreground.
212#[cfg(not(unix))]
213pub fn start_daemon(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
214    eprintln!("Daemon mode is only supported on Unix systems.");
215    eprintln!("Starting in foreground instead...");
216    let rt = tokio::runtime::Runtime::new()?;
217    rt.block_on(start_server(addr, config))
218}
219
220/// Returns true if running as a daemon child process.
221pub fn is_daemon_child() -> bool {
222    std::env::var("SCOPE_WEB_DAEMON_CHILD").is_ok()
223}
224
225// ============================================================================
226// Unit Tests
227// ============================================================================
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use axum::body;
233
234    #[test]
235    fn test_pid_file_path() {
236        let path = pid_file_path();
237        assert!(path.to_string_lossy().contains("scope-web.pid"));
238    }
239
240    #[test]
241    fn test_log_file_path() {
242        let path = log_file_path();
243        assert!(path.to_string_lossy().contains("scope-web.log"));
244    }
245
246    #[test]
247    fn test_build_router() {
248        let config = Config::default();
249        let factory = DefaultClientFactory {
250            chains_config: config.chains.clone(),
251        };
252        let state = Arc::new(AppState { config, factory });
253        let _router = build_router(state);
254    }
255
256    #[test]
257    fn test_is_daemon_child_default() {
258        // Should be false in test context (env var not set)
259        // Note: may be true if test runner sets it, so just ensure it doesn't panic
260        let _ = is_daemon_child();
261    }
262
263    #[tokio::test]
264    async fn test_serve_ui_index() {
265        let response = serve_ui(axum::http::Uri::from_static("/"))
266            .await
267            .into_response();
268        assert_eq!(response.status(), axum::http::StatusCode::OK);
269        let body = body::to_bytes(response.into_body(), 1_000_000)
270            .await
271            .unwrap();
272        let html = String::from_utf8(body.to_vec()).unwrap();
273        assert!(html.contains("<!DOCTYPE html>"));
274        assert!(html.contains("Scope"));
275    }
276
277    #[tokio::test]
278    async fn test_serve_ui_index_html() {
279        let response = serve_ui(axum::http::Uri::from_static("/index.html"))
280            .await
281            .into_response();
282        assert_eq!(response.status(), axum::http::StatusCode::OK);
283    }
284
285    #[tokio::test]
286    async fn test_serve_ui_app_js() {
287        let response = serve_ui(axum::http::Uri::from_static("/app.js"))
288            .await
289            .into_response();
290        assert_eq!(response.status(), axum::http::StatusCode::OK);
291        let body = body::to_bytes(response.into_body(), 1_000_000)
292            .await
293            .unwrap();
294        let js = String::from_utf8(body.to_vec()).unwrap();
295        assert!(js.contains("function") || js.contains("const") || js.contains("var"));
296    }
297
298    #[tokio::test]
299    async fn test_serve_ui_style_css() {
300        let response = serve_ui(axum::http::Uri::from_static("/style.css"))
301            .await
302            .into_response();
303        assert_eq!(response.status(), axum::http::StatusCode::OK);
304        let body = body::to_bytes(response.into_body(), 1_000_000)
305            .await
306            .unwrap();
307        let css = String::from_utf8(body.to_vec()).unwrap();
308        assert!(css.contains(":root") || css.contains("body") || css.contains("nav"));
309    }
310
311    #[tokio::test]
312    async fn test_serve_ui_spa_fallback() {
313        // Unknown paths should return index.html (SPA routing)
314        let response = serve_ui(axum::http::Uri::from_static("/some/random/path"))
315            .await
316            .into_response();
317        assert_eq!(response.status(), axum::http::StatusCode::OK);
318        let body = body::to_bytes(response.into_body(), 1_000_000)
319            .await
320            .unwrap();
321        let html = String::from_utf8(body.to_vec()).unwrap();
322        assert!(html.contains("<!DOCTYPE html>"));
323    }
324
325    #[test]
326    fn test_pid_file_path_in_data_dir() {
327        let path = pid_file_path();
328        assert!(path.ends_with("scope-web.pid"));
329        assert!(path.parent().is_some());
330    }
331
332    #[test]
333    fn test_log_file_path_in_data_dir() {
334        let path = log_file_path();
335        assert!(path.ends_with("scope-web.log"));
336        assert!(path.parent().is_some());
337    }
338
339    #[test]
340    fn test_app_state_construction() {
341        let config = Config::default();
342        let factory = DefaultClientFactory {
343            chains_config: config.chains.clone(),
344        };
345        let state = AppState { config, factory };
346        // Verify state fields are accessible
347        let _ = state.config.chains.api_keys.len();
348    }
349
350    #[test]
351    fn test_stop_daemon_no_pid_file() {
352        // stop_daemon should handle missing PID file gracefully
353        // We can't easily override the data dir, but verify the function exists
354        // and the path helpers return valid paths
355        let pid = pid_file_path();
356        let log = log_file_path();
357        assert_eq!(pid.parent(), log.parent());
358    }
359
360    // Helper to create the test app state
361    fn test_state() -> Arc<AppState> {
362        let config = Config::default();
363        let factory = DefaultClientFactory {
364            chains_config: config.chains.clone(),
365        };
366        Arc::new(AppState { config, factory })
367    }
368
369    // ========================================================================
370    // HTTP routing integration tests — exercise handler code paths
371    // ========================================================================
372
373    #[tokio::test]
374    async fn test_route_get_config_status() {
375        use axum::http::{Request, StatusCode};
376        use tower::ServiceExt;
377
378        let state = test_state();
379        let app = build_router(state);
380
381        let req = Request::builder()
382            .uri("/api/config/status")
383            .method("GET")
384            .body(axum::body::Body::empty())
385            .unwrap();
386
387        let response = app.oneshot(req).await.unwrap();
388        assert_eq!(response.status(), StatusCode::OK);
389
390        let body = body::to_bytes(response.into_body(), 1_000_000)
391            .await
392            .unwrap();
393        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
394        assert!(json.get("config_path").is_some());
395        assert!(json.get("api_keys").is_some());
396        assert!(json.get("version").is_some());
397    }
398
399    #[tokio::test]
400    async fn test_route_post_config_save() {
401        use axum::http::{Request, StatusCode, header};
402        use tower::ServiceExt;
403
404        let state = test_state();
405        let app = build_router(state);
406
407        let payload = serde_json::json!({
408            "api_keys": {},
409            "rpc_endpoints": {}
410        });
411
412        let req = Request::builder()
413            .uri("/api/config")
414            .method("POST")
415            .header(header::CONTENT_TYPE, "application/json")
416            .body(axum::body::Body::from(payload.to_string()))
417            .unwrap();
418
419        let response = app.oneshot(req).await.unwrap();
420        // May succeed or fail depending on file system, but should not panic
421        let status = response.status();
422        assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
423    }
424
425    #[tokio::test]
426    async fn test_route_post_address() {
427        use axum::http::{Request, StatusCode, header};
428        use tower::ServiceExt;
429
430        let state = test_state();
431        let app = build_router(state);
432
433        let payload = serde_json::json!({
434            "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
435            "chain": "ethereum"
436        });
437
438        let req = Request::builder()
439            .uri("/api/address")
440            .method("POST")
441            .header(header::CONTENT_TYPE, "application/json")
442            .body(axum::body::Body::from(payload.to_string()))
443            .unwrap();
444
445        let response = app.oneshot(req).await.unwrap();
446        // Will likely fail due to no API key, but exercises the handler
447        let status = response.status();
448        assert!(
449            status == StatusCode::OK
450                || status == StatusCode::BAD_REQUEST
451                || status == StatusCode::INTERNAL_SERVER_ERROR
452        );
453    }
454
455    #[tokio::test]
456    async fn test_route_post_tx() {
457        use axum::http::{Request, StatusCode, header};
458        use tower::ServiceExt;
459
460        let state = test_state();
461        let app = build_router(state);
462
463        let payload = serde_json::json!({
464            "hash": "0xabc123def456789012345678901234567890123456789012345678901234abcd",
465            "chain": "ethereum"
466        });
467
468        let req = Request::builder()
469            .uri("/api/tx")
470            .method("POST")
471            .header(header::CONTENT_TYPE, "application/json")
472            .body(axum::body::Body::from(payload.to_string()))
473            .unwrap();
474
475        let response = app.oneshot(req).await.unwrap();
476        let status = response.status();
477        assert!(
478            status == StatusCode::OK
479                || status == StatusCode::BAD_REQUEST
480                || status == StatusCode::INTERNAL_SERVER_ERROR
481        );
482    }
483
484    #[tokio::test]
485    async fn test_route_post_crawl() {
486        use axum::http::{Request, StatusCode, header};
487        use tower::ServiceExt;
488
489        let state = test_state();
490        let app = build_router(state);
491
492        let payload = serde_json::json!({
493            "token": "USDC",
494            "chain": "ethereum"
495        });
496
497        let req = Request::builder()
498            .uri("/api/crawl")
499            .method("POST")
500            .header(header::CONTENT_TYPE, "application/json")
501            .body(axum::body::Body::from(payload.to_string()))
502            .unwrap();
503
504        let response = app.oneshot(req).await.unwrap();
505        let status = response.status();
506        assert!(
507            status == StatusCode::OK
508                || status == StatusCode::BAD_REQUEST
509                || status == StatusCode::INTERNAL_SERVER_ERROR
510        );
511    }
512
513    #[tokio::test]
514    async fn test_route_get_discover() {
515        use axum::http::{Request, StatusCode};
516        use tower::ServiceExt;
517
518        let state = test_state();
519        let app = build_router(state);
520
521        let req = Request::builder()
522            .uri("/api/discover?source=profiles&limit=5")
523            .method("GET")
524            .body(axum::body::Body::empty())
525            .unwrap();
526
527        let response = app.oneshot(req).await.unwrap();
528        let status = response.status();
529        assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
530    }
531
532    #[tokio::test]
533    async fn test_route_post_insights() {
534        use axum::http::{Request, StatusCode, header};
535        use tower::ServiceExt;
536
537        let state = test_state();
538        let app = build_router(state);
539
540        let payload = serde_json::json!({
541            "target": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
542        });
543
544        let req = Request::builder()
545            .uri("/api/insights")
546            .method("POST")
547            .header(header::CONTENT_TYPE, "application/json")
548            .body(axum::body::Body::from(payload.to_string()))
549            .unwrap();
550
551        let response = app.oneshot(req).await.unwrap();
552        let status = response.status();
553        assert!(
554            status == StatusCode::OK
555                || status == StatusCode::BAD_REQUEST
556                || status == StatusCode::INTERNAL_SERVER_ERROR
557        );
558    }
559
560    #[tokio::test]
561    async fn test_route_post_compliance_risk() {
562        use axum::http::{Request, StatusCode, header};
563        use tower::ServiceExt;
564
565        let state = test_state();
566        let app = build_router(state);
567
568        let payload = serde_json::json!({
569            "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
570            "chain": "ethereum",
571            "detailed": true
572        });
573
574        let req = Request::builder()
575            .uri("/api/compliance/risk")
576            .method("POST")
577            .header(header::CONTENT_TYPE, "application/json")
578            .body(axum::body::Body::from(payload.to_string()))
579            .unwrap();
580
581        let response = app.oneshot(req).await.unwrap();
582        let status = response.status();
583        assert!(
584            status == StatusCode::OK
585                || status == StatusCode::BAD_REQUEST
586                || status == StatusCode::INTERNAL_SERVER_ERROR
587        );
588    }
589
590    #[tokio::test]
591    async fn test_route_post_export() {
592        use axum::http::{Request, StatusCode, header};
593        use tower::ServiceExt;
594
595        let state = test_state();
596        let app = build_router(state);
597
598        let payload = serde_json::json!({
599            "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
600            "chain": "ethereum",
601            "format": "json"
602        });
603
604        let req = Request::builder()
605            .uri("/api/export")
606            .method("POST")
607            .header(header::CONTENT_TYPE, "application/json")
608            .body(axum::body::Body::from(payload.to_string()))
609            .unwrap();
610
611        let response = app.oneshot(req).await.unwrap();
612        let status = response.status();
613        assert!(
614            status == StatusCode::OK
615                || status == StatusCode::BAD_REQUEST
616                || status == StatusCode::INTERNAL_SERVER_ERROR
617        );
618    }
619
620    #[tokio::test]
621    async fn test_route_post_token_health() {
622        use axum::http::{Request, StatusCode, header};
623        use tower::ServiceExt;
624
625        let state = test_state();
626        let app = build_router(state);
627
628        let payload = serde_json::json!({
629            "token": "USDC",
630            "chain": "ethereum",
631            "with_market": false
632        });
633
634        let req = Request::builder()
635            .uri("/api/token-health")
636            .method("POST")
637            .header(header::CONTENT_TYPE, "application/json")
638            .body(axum::body::Body::from(payload.to_string()))
639            .unwrap();
640
641        let response = app.oneshot(req).await.unwrap();
642        let status = response.status();
643        assert!(
644            status == StatusCode::OK
645                || status == StatusCode::BAD_REQUEST
646                || status == StatusCode::INTERNAL_SERVER_ERROR
647        );
648    }
649
650    #[tokio::test]
651    async fn test_route_post_market_summary() {
652        use axum::http::{Request, StatusCode, header};
653        use tower::ServiceExt;
654
655        let state = test_state();
656        let app = build_router(state);
657
658        let payload = serde_json::json!({
659            "pair": "USDC",
660            "market_venue": "binance"
661        });
662
663        let req = Request::builder()
664            .uri("/api/market/summary")
665            .method("POST")
666            .header(header::CONTENT_TYPE, "application/json")
667            .body(axum::body::Body::from(payload.to_string()))
668            .unwrap();
669
670        let response = app.oneshot(req).await.unwrap();
671        let status = response.status();
672        assert!(
673            status == StatusCode::OK
674                || status == StatusCode::BAD_REQUEST
675                || status == StatusCode::INTERNAL_SERVER_ERROR
676        );
677    }
678
679    #[tokio::test]
680    async fn test_route_get_address_book_list() {
681        use axum::http::{Request, StatusCode};
682        use tower::ServiceExt;
683
684        let state = test_state();
685        let app = build_router(state);
686
687        let req = Request::builder()
688            .uri("/api/address-book/list")
689            .method("GET")
690            .body(axum::body::Body::empty())
691            .unwrap();
692
693        let response = app.oneshot(req).await.unwrap();
694        let status = response.status();
695        assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
696    }
697
698    #[tokio::test]
699    async fn test_route_post_address_book_add() {
700        use axum::http::{Request, StatusCode, header};
701        use tower::ServiceExt;
702
703        let state = test_state();
704        let app = build_router(state);
705
706        let payload = serde_json::json!({
707            "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
708            "chain": "ethereum",
709            "label": "Test Wallet"
710        });
711
712        let req = Request::builder()
713            .uri("/api/address-book/add")
714            .method("POST")
715            .header(header::CONTENT_TYPE, "application/json")
716            .body(axum::body::Body::from(payload.to_string()))
717            .unwrap();
718
719        let response = app.oneshot(req).await.unwrap();
720        let status = response.status();
721        assert!(
722            status == StatusCode::OK
723                || status == StatusCode::BAD_REQUEST
724                || status == StatusCode::INTERNAL_SERVER_ERROR
725        );
726    }
727
728    #[tokio::test]
729    async fn test_route_invalid_json_body() {
730        use axum::http::{Request, header};
731        use tower::ServiceExt;
732
733        let state = test_state();
734        let app = build_router(state);
735
736        let req = Request::builder()
737            .uri("/api/address")
738            .method("POST")
739            .header(header::CONTENT_TYPE, "application/json")
740            .body(axum::body::Body::from("not json"))
741            .unwrap();
742
743        let response = app.oneshot(req).await.unwrap();
744        // Should return 4xx for bad JSON
745        assert!(response.status().is_client_error());
746    }
747
748    #[tokio::test]
749    async fn test_route_missing_required_field() {
750        use axum::http::{Request, header};
751        use tower::ServiceExt;
752
753        let state = test_state();
754        let app = build_router(state);
755
756        // Missing required "address" field
757        let payload = serde_json::json!({ "chain": "ethereum" });
758
759        let req = Request::builder()
760            .uri("/api/address")
761            .method("POST")
762            .header(header::CONTENT_TYPE, "application/json")
763            .body(axum::body::Body::from(payload.to_string()))
764            .unwrap();
765
766        let response = app.oneshot(req).await.unwrap();
767        assert!(response.status().is_client_error());
768    }
769
770    #[tokio::test]
771    async fn test_route_static_fallback() {
772        use axum::http::{Request, StatusCode};
773        use tower::ServiceExt;
774
775        let state = test_state();
776        let app = build_router(state);
777
778        let req = Request::builder()
779            .uri("/nonexistent-path")
780            .method("GET")
781            .body(axum::body::Body::empty())
782            .unwrap();
783
784        let response = app.oneshot(req).await.unwrap();
785        assert_eq!(response.status(), StatusCode::OK);
786    }
787}