diff --git a/crates/app-tauri/src/server.rs b/crates/app-tauri/src/server.rs index 7f71c5a..1fc8192 100644 --- a/crates/app-tauri/src/server.rs +++ b/crates/app-tauri/src/server.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use std::env; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -95,6 +95,7 @@ struct ServerConfig { allow_remote: bool, trust_reverse_proxy: bool, app_data_dir: PathBuf, + web_root: PathBuf, } impl ServerConfig { @@ -106,6 +107,7 @@ impl ServerConfig { let mut allow_remote = false; let mut trust_reverse_proxy = false; let mut app_data_dir = default_app_data_dir(); + let mut web_root = None; let mut it = args.into_iter(); while let Some(arg) = it.next() { @@ -132,10 +134,17 @@ impl ServerConfig { .ok_or_else(|| "--app-data-dir requires a path".to_owned())?; app_data_dir = PathBuf::from(value); } + "--web-root" => { + let value = it + .next() + .ok_or_else(|| "--web-root requires a path".to_owned())?; + web_root = Some(PathBuf::from(value)); + } "--help" | "-h" => return Err(Self::usage()), other => return Err(format!("unknown --serve argument: {other}")), } } + let web_root = resolve_web_root(web_root)?; Ok(Self { listen, @@ -143,6 +152,7 @@ impl ServerConfig { allow_remote, trust_reverse_proxy, app_data_dir, + web_root, }) } @@ -182,7 +192,46 @@ impl ServerConfig { } fn usage() -> String { - "usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() + "usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() + } +} + +fn resolve_web_root(explicit: Option) -> Result { + if let Some(path) = explicit { + return validate_web_root(path, "--web-root"); + } + if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) { + return validate_web_root(path, "IDEA_WEB_ROOT"); + } + + let mut candidates = Vec::new(); + if let Ok(exe) = env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join("web")); + candidates.push(dir.join("frontend").join("dist")); + } + } + if let Ok(cwd) = env::current_dir() { + candidates.push(cwd.join("frontend").join("dist")); + } + + for candidate in candidates { + if candidate.join("index.html").is_file() { + return Ok(candidate); + } + } + + Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned()) +} + +fn validate_web_root(path: PathBuf, source: &str) -> Result { + if path.join("index.html").is_file() { + Ok(path) + } else { + Err(format!( + "{source} does not contain an index.html: {}", + path.display() + )) } } @@ -192,6 +241,7 @@ struct ServerState { pairing_code: String, sessions: Mutex>, ws_pty_bridge: Arc>, + security_logger: Arc, } impl ServerState { @@ -202,17 +252,28 @@ impl ServerState { pairing_code: new_pairing_code(), sessions: Mutex::new(HashSet::new()), ws_pty_bridge: Arc::new(OutputBridge::new()), + security_logger: Arc::new(StderrSecurityLogger), } } #[cfg(test)] fn new_for_test(config: ServerConfig, pairing_code: impl Into) -> Self { + Self::new_for_test_with_logger(config, pairing_code, Arc::new(NoopSecurityLogger)) + } + + #[cfg(test)] + fn new_for_test_with_logger( + config: ServerConfig, + pairing_code: impl Into, + security_logger: Arc, + ) -> Self { Self { app: AppState::build(config.app_data_dir.clone()), config, pairing_code: pairing_code.into(), sessions: Mutex::new(HashSet::new()), ws_pty_bridge: Arc::new(OutputBridge::new()), + security_logger, } } @@ -234,6 +295,93 @@ impl ServerState { .map(|sessions| sessions.contains(token)) .unwrap_or(false) } + + fn revoke_session(&self, token: &str) -> bool { + self.sessions + .lock() + .map(|mut sessions| sessions.remove(token)) + .unwrap_or(false) + } + + fn log_security(&self, event: SecurityLogEvent) { + self.security_logger.log(event); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SecurityLogEvent { + PairingSucceeded { + origin: Option, + }, + PairingFailed { + origin: Option, + reason: &'static str, + }, + OriginRejected { + origin: Option, + route: String, + }, + WsUpgradeRejected { + origin: Option, + reason: &'static str, + }, + SessionRevoked { + origin: Option, + }, +} + +trait SecurityLogger: Send + Sync { + fn log(&self, event: SecurityLogEvent); +} + +struct StderrSecurityLogger; + +impl SecurityLogger for StderrSecurityLogger { + fn log(&self, event: SecurityLogEvent) { + eprintln!("idea --serve security: {}", security_log_line(&event)); + } +} + +#[cfg(test)] +struct NoopSecurityLogger; + +#[cfg(test)] +impl SecurityLogger for NoopSecurityLogger { + fn log(&self, _event: SecurityLogEvent) {} +} + +fn security_log_line(event: &SecurityLogEvent) -> String { + match event { + SecurityLogEvent::PairingSucceeded { origin } => { + format!("pairing succeeded origin={}", origin_label(origin)) + } + SecurityLogEvent::PairingFailed { origin, reason } => { + format!( + "pairing failed reason={reason} origin={}", + origin_label(origin) + ) + } + SecurityLogEvent::OriginRejected { origin, route } => { + format!( + "origin rejected route={} origin={}", + route, + origin_label(origin) + ) + } + SecurityLogEvent::WsUpgradeRejected { origin, reason } => { + format!( + "websocket upgrade rejected reason={reason} origin={}", + origin_label(origin) + ) + } + SecurityLogEvent::SessionRevoked { origin } => { + format!("session revoked origin={}", origin_label(origin)) + } + } +} + +fn origin_label(origin: &Option) -> &str { + origin.as_deref().unwrap_or("") } async fn run_server(config: ServerConfig, state: Arc) -> Result<(), String> { @@ -444,8 +592,21 @@ fn validate_ws_upgrade( headers: &HeaderMap, state: &ServerState, ) -> Result>> { - let origin = validate_request_origin(headers, &state.config)?; + let origin = match validate_request_origin(headers, &state.config) { + Ok(origin) => origin, + Err(response) => { + state.log_security(SecurityLogEvent::OriginRejected { + origin: request_origin(headers), + route: WS_PATH.to_owned(), + }); + return Err(response); + } + }; let Some(token) = session_cookie(headers) else { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "missing_session", + }); return Err(Box::new(error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", @@ -454,6 +615,10 @@ fn validate_ws_upgrade( ))); }; if !state.has_session(&token) { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "invalid_session", + }); return Err(Box::new(error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", @@ -464,6 +629,10 @@ fn validate_ws_upgrade( if !header_contains_token(headers, "upgrade", "websocket") || !header_contains_token(headers, "connection", "upgrade") { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "invalid_upgrade", + }); return Err(Box::new(error_response( StatusCode::BAD_REQUEST, "INVALID", @@ -475,6 +644,10 @@ fn validate_ws_upgrade( .get("sec-websocket-key") .and_then(|value| value.to_str().ok()) else { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "missing_key", + }); return Err(Box::new(error_response( StatusCode::BAD_REQUEST, "INVALID", @@ -940,26 +1113,58 @@ async fn dispatch_http( body: Bytes, state: Arc, ) -> Response { - let origin = match validate_request_origin(&headers, &state.config) { - Ok(origin) => origin, - Err(response) => return *response, - }; - if has_forbidden_query_secret(&uri) { return error_response( StatusCode::BAD_REQUEST, "INVALID", "secrets in URL query strings are forbidden", - origin.as_deref(), + None, ); } + if !is_api_path(uri.path()) { + return serve_static(&state.config.web_root, method, uri.path()).await; + } + + let origin = match validate_request_origin(&headers, &state.config) { + Ok(origin) => origin, + Err(response) => { + state.log_security(SecurityLogEvent::OriginRejected { + origin: request_origin(&headers), + route: uri.path().to_owned(), + }); + return *response; + } + }; + if method == Method::OPTIONS { return cors_response(StatusCode::NO_CONTENT, origin.as_deref()); } match (method, uri.path()) { (Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await, + (Method::POST, "/api/logout") => { + let Some(token) = session_cookie(&headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ); + }; + if !state.revoke_session(&token) { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ); + } + state.log_security(SecurityLogEvent::SessionRevoked { + origin: origin.clone(), + }); + logout_response(&state, origin.as_deref()) + } (Method::POST, "/api/invoke") => { let Some(token) = session_cookie(&headers) else { return error_response( @@ -979,18 +1184,136 @@ async fn dispatch_http( } invoke(body, state, origin.as_deref()).await } - (_, "/api/pair" | "/api/invoke") => error_response( + (_, "/api/pair" | "/api/invoke" | "/api/logout") => error_response( StatusCode::METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "method not allowed", origin.as_deref(), ), - _ => error_response( + _ if is_api_path(uri.path()) => error_response( StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", origin.as_deref(), ), + _ => error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None), + } +} + +fn is_api_path(path: &str) -> bool { + path == "/api" || path.starts_with("/api/") +} + +async fn serve_static(web_root: &Path, method: Method, path: &str) -> Response { + if !matches!(method, Method::GET | Method::HEAD) { + return error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + None, + ); + } + + let Some(route) = static_route(web_root, path) else { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + }; + + let file = if route.candidate.is_file() { + route.candidate + } else if route.spa_fallback { + web_root.join("index.html") + } else { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + }; + + let bytes = match tokio::fs::read(&file).await { + Ok(bytes) => bytes, + Err(_) => { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + } + }; + static_file_response(&file, bytes, method == Method::HEAD) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StaticRoute { + candidate: PathBuf, + spa_fallback: bool, +} + +fn static_route(web_root: &Path, request_path: &str) -> Option { + if !request_path.starts_with('/') || request_path.contains('\\') { + return None; + } + let lower = request_path.to_ascii_lowercase(); + if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { + return None; + } + + let mut candidate = web_root.to_path_buf(); + let mut last = ""; + for segment in request_path + .split('/') + .filter(|segment| !segment.is_empty()) + { + if segment == "." || segment == ".." || segment.starts_with('.') || segment.contains('%') { + return None; + } + candidate.push(segment); + last = segment; + } + + if request_path == "/" { + return Some(StaticRoute { + candidate: web_root.join("index.html"), + spa_fallback: false, + }); + } + + Some(StaticRoute { + candidate, + spa_fallback: !last.contains('.'), + }) +} + +fn static_file_response(path: &Path, bytes: Vec, head_only: bool) -> Response { + let is_index = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "index.html"); + let body = if head_only { Vec::new() } else { bytes }; + let mut response = Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, content_type(path)) + .header("x-content-type-options", "nosniff") + .body(Full::new(Bytes::from(body))) + .expect("static response is valid"); + if is_index { + response.headers_mut().insert( + "content-security-policy", + HeaderValue::from_static( + "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'", + ), + ); + } + response +} + +fn content_type(path: &Path) -> &'static str { + match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "ico" => "image/x-icon", + "wasm" => "application/wasm", + "txt" => "text/plain; charset=utf-8", + _ => "application/octet-stream", } } @@ -1017,6 +1340,10 @@ async fn pair( }; if request.code != state.pairing_code() { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "wrong_code", + }); return error_response( StatusCode::FORBIDDEN, "FORBIDDEN", @@ -1026,6 +1353,9 @@ async fn pair( } let token = state.create_session(); + state.log_security(SecurityLogEvent::PairingSucceeded { + origin: origin.map(str::to_owned), + }); let cookie = Cookie::build((SESSION_COOKIE, token)) .path("/") .http_only(true) @@ -1040,6 +1370,21 @@ async fn pair( response } +fn logout_response(state: &ServerState, origin: Option<&str>) -> Response { + let mut response = json_response(StatusCode::OK, &json!({ "revoked": true }), origin); + let secure = if state.config.secure_cookie() { + "; Secure" + } else { + "" + }; + let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly{secure}; SameSite=Strict; Max-Age=0"); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&cookie).expect("session clearing cookie is header-safe"), + ); + response +} + #[derive(Deserialize)] struct InvokeRequest { command: String, @@ -1313,6 +1658,13 @@ fn validate_request_origin( } } +fn request_origin(headers: &HeaderMap) -> Option { + headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + fn origin_allowed(origin: &str, config: &ServerConfig) -> bool { if let Some(public_origin) = &config.public_origin { return origin == public_origin; @@ -1948,6 +2300,36 @@ mod tests { use http::header::HeaderName; use std::time::Duration; + #[derive(Default)] + struct RecordingSecurityLogger { + events: Mutex>, + } + + impl RecordingSecurityLogger { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + impl SecurityLogger for RecordingSecurityLogger { + fn log(&self, event: SecurityLogEvent) { + self.events.lock().unwrap().push(event); + } + } + + fn create_web_root() -> PathBuf { + let root = std::env::temp_dir().join(format!("idea-web-root-{}", Uuid::new_v4())); + std::fs::create_dir_all(root.join("assets")).unwrap(); + std::fs::write( + root.join("index.html"), + r#"
"#, + ) + .unwrap(); + std::fs::write(root.join("assets").join("app.js"), "console.log('idea');").unwrap(); + std::fs::write(root.join("assets").join("style.css"), "body{margin:0}").unwrap(); + root + } + fn test_config() -> ServerConfig { ServerConfig { listen: "127.0.0.1:17373".parse().unwrap(), @@ -1955,6 +2337,7 @@ mod tests { allow_remote: false, trust_reverse_proxy: false, app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), + web_root: create_web_root(), } } @@ -1998,6 +2381,13 @@ mod tests { (status, value, headers) } + async fn response_bytes(response: Response) -> (StatusCode, Bytes, HeaderMap) { + let status = response.status(); + let headers = response.headers().clone(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, body, headers) + } + async fn pair_and_cookie(state: Arc) -> String { let response = request( state, @@ -3017,6 +3407,130 @@ mod tests { assert_eq!(body["code"], "UNAUTHORIZED"); } + #[tokio::test] + async fn logout_revokes_session_for_http_and_websocket() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/logout", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "revoked": true })); + assert!(headers + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .contains("Max-Age=0")); + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + + let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn security_logs_pairing_origin_ws_and_revoke_without_secrets() { + let logger = Arc::new(RecordingSecurityLogger::default()); + let state = Arc::new(ServerState::new_for_test_with_logger( + test_config(), + "PAIR1234", + logger.clone(), + )); + + let bad_origin = handle_request( + Request::builder() + .method(Method::POST) + .uri("/api/pair") + .header(ORIGIN, "https://evil.example") + .header(CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) + .unwrap(), + Arc::clone(&state), + ) + .await; + assert_eq!(bad_origin.status(), StatusCode::FORBIDDEN); + + let wrong_code = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "WRONG999" }), + &[], + ) + .await; + assert_eq!(wrong_code.status(), StatusCode::FORBIDDEN); + + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let _ = request( + Arc::clone(&state), + Method::POST, + "/api/logout", + json!({}), + &[("cookie", &cookie)], + ) + .await; + + let headers = ws_headers( + "http://127.0.0.1:17373", + Some("idea_session=invalid-token-value"), + ); + let _ = validate_ws_upgrade(&headers, &state); + + let events = logger.events(); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::OriginRejected { route, .. } if route == "/api/pair" + ))); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::PairingFailed { + reason: "wrong_code", + .. + } + ))); + assert!(events + .iter() + .any(|event| matches!(event, SecurityLogEvent::PairingSucceeded { .. }))); + assert!(events + .iter() + .any(|event| matches!(event, SecurityLogEvent::SessionRevoked { .. }))); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::WsUpgradeRejected { + reason: "invalid_session", + .. + } + ))); + + let lines = events.iter().map(security_log_line).collect::>(); + assert!( + !lines.iter().any(|line| line.contains("PAIR1234") + || line.contains("WRONG999") + || line.contains("invalid-token-value")), + "security logs must not leak pairing codes or session tokens" + ); + } + #[tokio::test] async fn pairing_rejects_wrong_code() { let state = state(); @@ -3114,6 +3628,112 @@ mod tests { assert_eq!(body["code"], "INVALID"); } + #[tokio::test] + async fn serves_web_index_same_origin_with_csp() { + let state = state(); + + let response = request(state, Method::GET, "/", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); + assert_eq!( + headers.get(CONTENT_TYPE).unwrap(), + "text/html; charset=utf-8" + ); + assert!(headers.contains_key("content-security-policy")); + assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff"); + } + + #[tokio::test] + async fn serves_static_assets_with_mime_types() { + let state = state(); + + let response = request(state, Method::GET, "/assets/app.js", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(std::str::from_utf8(&body).unwrap(), "console.log('idea');"); + assert_eq!( + headers.get(CONTENT_TYPE).unwrap(), + "text/javascript; charset=utf-8" + ); + } + + #[tokio::test] + async fn serves_spa_fallback_for_non_api_routes_without_extension() { + let state = state(); + + let response = request(state, Method::GET, "/projects/abc", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); + assert!(headers.contains_key("content-security-policy")); + } + + #[tokio::test] + async fn missing_asset_and_api_routes_do_not_fallback_to_spa() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let missing_asset = request( + Arc::clone(&state), + Method::GET, + "/assets/missing.js", + json!({}), + &[], + ) + .await; + let (asset_status, asset_body, _) = response_json(missing_asset).await; + assert_eq!(asset_status, StatusCode::NOT_FOUND); + assert_eq!(asset_body["code"], "NOT_FOUND"); + + let api_unknown = request( + state, + Method::GET, + "/api/unknown", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (api_status, api_body, _) = response_json(api_unknown).await; + assert_eq!(api_status, StatusCode::NOT_FOUND); + assert_eq!(api_body["code"], "NOT_FOUND"); + } + + #[test] + fn static_route_rejects_traversal_and_dotfiles() { + let root = PathBuf::from("/tmp/web"); + + assert!(static_route(&root, "/../secret").is_none()); + assert!(static_route(&root, "/%2e%2e/secret").is_none()); + assert!(static_route(&root, "/.env").is_none()); + assert_eq!( + static_route(&root, "/dashboard").unwrap(), + StaticRoute { + candidate: root.join("dashboard"), + spa_fallback: true + } + ); + assert_eq!( + static_route(&root, "/assets/app.js").unwrap(), + StaticRoute { + candidate: root.join("assets").join("app.js"), + spa_fallback: false + } + ); + } + + #[test] + fn explicit_web_root_requires_index_html() { + let missing = std::env::temp_dir().join(format!("idea-empty-web-{}", Uuid::new_v4())); + std::fs::create_dir_all(&missing).unwrap(); + + assert!(validate_web_root(missing, "--web-root").is_err()); + assert!(validate_web_root(create_web_root(), "--web-root").is_ok()); + } + #[tokio::test] async fn non_allowed_origin_is_refused() { let state = state(); diff --git a/docs/server-client-mode-remote.md b/docs/server-client-mode-remote.md new file mode 100644 index 0000000..03504b9 --- /dev/null +++ b/docs/server-client-mode-remote.md @@ -0,0 +1,45 @@ +# IdeA Server/Client Remote Deployment + +`idea --serve` serves the web UI and the API from the same origin. The frontend +build must be available as a web root containing `index.html`. + +## Local Development + +```bash +pnpm --dir frontend build +idea --serve --web-root frontend/dist +``` + +The default local listener is `127.0.0.1:17373`. Loopback development may use +plain HTTP; the session cookie is still `HttpOnly` and `SameSite=Strict`. + +## Packaged Artifact + +The release artifact is still a single IdeA binary/AppImage. Packaging must copy +the frontend build output (`frontend/dist`) into the packaged `web/` resource +next to the binary. `idea --serve` resolves assets in this order: + +1. `--web-root PATH` +2. `IDEA_WEB_ROOT` +3. packaged `web/`, then packaged `frontend/dist/` +4. development fallback `frontend/dist` relative to the current directory + +If no `index.html` is found, the server refuses to start. + +## Remote HTTPS + +Public exposure requires a reverse proxy that terminates HTTPS: + +```bash +idea --serve \ + --listen 127.0.0.1:17373 \ + --allow-remote \ + --public-origin https://idea.example.com \ + --trust-reverse-proxy +``` + +The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`. +Do not expose the backend on a public non-loopback address without TLS proxying. +Secrets must never be placed in URLs; pairing uses `POST /api/pair` and then an +`HttpOnly`, `Secure`, `SameSite=Strict` session cookie. +