diff --git a/compose.yaml b/compose.yaml index 436f0e1e..35db9fac 100644 --- a/compose.yaml +++ b/compose.yaml @@ -19,6 +19,8 @@ services: - runtime expose: - "8787" + environment: + YOI_BROWSER_PUBLIC_URL: "${YOI_BROWSER_PUBLIC_URL:-http://localhost:8080}" volumes: - server-data:/server-data - ./docker/workspace:/workspace:ro diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index de441fd4..865bdac7 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::{fs, io}; use serde::{Deserialize, Serialize}; +use url::Url; use crate::hosts::RemoteRuntimeConfig; use crate::identity::WorkspaceIdentity; @@ -15,22 +16,12 @@ pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml"; pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str = include_str!("../../../resources/workspace-backend.default.toml"); const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; -const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; -const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787"; -const DEFAULT_AUTH_RP_ID: &str = "localhost"; +const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173"; const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session"; const DEFAULT_MAX_RECORDS: usize = 200; -fn default_auth_rp_id() -> String { - DEFAULT_AUTH_RP_ID.to_string() -} - -fn default_auth_origin() -> String { - DEFAULT_AUTH_PUBLIC_BASE_URL.to_string() -} - -fn default_auth_public_base_url() -> String { - DEFAULT_AUTH_PUBLIC_BASE_URL.to_string() +fn default_browser_public_url() -> String { + DEFAULT_BROWSER_PUBLIC_URL.to_string() } fn default_auth_cookie_name() -> String { @@ -65,8 +56,6 @@ pub struct WorkspaceBackendServerConfig { #[serde(default)] pub listen: Option, #[serde(default)] - pub frontend_url: Option, - #[serde(default)] pub static_assets_dir: Option, } @@ -91,12 +80,8 @@ pub struct WorkspaceBackendLimitsConfig { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendAuthConfig { - #[serde(default = "default_auth_rp_id")] - pub rp_id: String, - #[serde(default = "default_auth_origin")] - pub origin: String, - #[serde(default = "default_auth_public_base_url")] - pub public_base_url: String, + #[serde(default = "default_browser_public_url")] + pub browser_public_url: String, #[serde(default = "default_auth_cookie_name")] pub cookie_name: String, } @@ -104,9 +89,7 @@ pub struct WorkspaceBackendAuthConfig { impl Default for WorkspaceBackendAuthConfig { fn default() -> Self { Self { - rp_id: default_auth_rp_id(), - origin: default_auth_origin(), - public_base_url: default_auth_public_base_url(), + browser_public_url: default_browser_public_url(), cookie_name: default_auth_cookie_name(), } } @@ -398,13 +381,10 @@ impl WorkspaceBackendConfigFile { )) })?; + let (browser_public_url, browser_rp_id) = + resolve_browser_public_url(&self.auth.browser_public_url)?; let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); server.database_path = database_path.clone(); - server.frontend_url = self - .server - .frontend_url - .clone() - .unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string()); server.static_assets_dir = self .server .static_assets_dir @@ -424,10 +404,10 @@ impl WorkspaceBackendConfigFile { .map(resolve_remote_runtime) .collect::>>()?; server.auth = AuthConfig::Passkey { - rp_id: self.auth.rp_id.trim().to_string(), - origin: self.auth.origin.trim().to_string(), - public_base_url: self.auth.public_base_url.trim().to_string(), - cookie_name: self.auth.cookie_name.trim().to_string(), + rp_id: browser_rp_id, + origin: browser_public_url.clone(), + public_base_url: browser_public_url, + cookie_name: normalize_required_string("auth.cookie_name", &self.auth.cookie_name)?, }; Ok(ResolvedWorkspaceBackendConfig { @@ -451,6 +431,20 @@ impl ResolvedWorkspaceBackendConfig { self } + pub fn with_browser_public_url(mut self, public_url: &str) -> Result { + let (origin, rp_id) = resolve_browser_public_url(public_url)?; + let AuthConfig::Passkey { + rp_id: configured_rp_id, + origin: configured_origin, + public_base_url, + .. + } = &mut self.server.auth; + *configured_rp_id = rp_id; + *configured_origin = origin.clone(); + *public_base_url = origin; + Ok(self) + } + pub fn with_backend_base_url(mut self, base_url: impl Into) -> Self { self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string()); self @@ -578,6 +572,36 @@ pub(crate) fn resolve_remote_runtime( )) } +fn resolve_browser_public_url(value: &str) -> Result<(String, String)> { + let value = normalize_required_string("auth.browser_public_url", value)?; + let url = Url::parse(&value).map_err(|error| { + Error::Config(format!( + "auth.browser_public_url must be an absolute http(s) URL: {error}" + )) + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(Error::Config( + "auth.browser_public_url must use the http or https scheme".to_string(), + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(Error::Config( + "auth.browser_public_url must not contain user information".to_string(), + )); + } + if url.path() != "/" || url.query().is_some() || url.fragment().is_some() { + return Err(Error::Config( + "auth.browser_public_url must contain only an origin without a path, query, or fragment" + .to_string(), + )); + } + let rp_id = url + .host_str() + .ok_or_else(|| Error::Config("auth.browser_public_url must contain a host".to_string()))? + .to_string(); + Ok((url.origin().ascii_serialization(), rp_id)) +} + fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() @@ -605,7 +629,15 @@ mod tests { let resolved = config.resolve(dir.path(), identity()).unwrap(); assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap()); - assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL); + let AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + .. + } = &resolved.server.auth; + assert_eq!(rp_id, "localhost"); + assert_eq!(origin, DEFAULT_BROWSER_PUBLIC_URL); + assert_eq!(public_base_url, DEFAULT_BROWSER_PUBLIC_URL); assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS); assert!(resolved.database_path.ends_with("server.db")); assert!( @@ -634,6 +666,87 @@ mod tests { ); } + #[test] + fn browser_public_url_drives_all_browser_auth_settings() { + let dir = tempfile::tempdir().unwrap(); + let resolved = WorkspaceBackendConfigFile::parse_str( + "[auth]\nbrowser_public_url = \"https://Yoi.Example:443/\"\n", + "test", + ) + .unwrap() + .resolve(dir.path(), identity()) + .unwrap(); + + let AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + .. + } = &resolved.server.auth; + assert_eq!(rp_id, "yoi.example"); + assert_eq!(origin, "https://yoi.example"); + assert_eq!(public_base_url, "https://yoi.example"); + } + + #[test] + fn browser_public_url_override_replaces_the_derived_settings() { + let dir = tempfile::tempdir().unwrap(); + let resolved = WorkspaceBackendConfigFile::default() + .resolve(dir.path(), identity()) + .unwrap() + .with_browser_public_url("https://deploy.example.test:8443") + .unwrap(); + + let AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + .. + } = &resolved.server.auth; + assert_eq!(rp_id, "deploy.example.test"); + assert_eq!(origin, "https://deploy.example.test:8443"); + assert_eq!(public_base_url, "https://deploy.example.test:8443"); + } + + #[test] + fn browser_public_url_rejects_non_origin_urls() { + for value in [ + "https://example.test/path", + "https://example.test?query=true", + "file:///tmp/web", + ] { + let result = WorkspaceBackendConfigFile::parse_str( + &format!("[auth]\nbrowser_public_url = {value:?}\n"), + "test", + ) + .unwrap() + .resolve(tempfile::tempdir().unwrap().path(), identity()); + let error = match result { + Ok(_) => panic!("expected {value} to be rejected"), + Err(error) => error, + }; + assert!( + error.to_string().contains("auth.browser_public_url"), + "unexpected error for {value}: {error}" + ); + } + } + + #[test] + fn rejects_legacy_independent_browser_auth_settings() { + for key in ["rp_id", "origin", "public_base_url"] { + let error = WorkspaceBackendConfigFile::parse_str( + &format!("[auth]\n{key} = \"legacy.example\"\n"), + "test", + ) + .unwrap_err(); + assert!( + error.to_string().contains("unknown field"), + "unexpected error for {key}: {error}" + ); + } + } + #[test] fn rejects_unknown_fields() { let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test") diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index e9680ee5..7343cda3 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -16,6 +16,8 @@ use yoi_workspace_server::{ WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, }; +const BROWSER_PUBLIC_URL_ENV: &str = "YOI_BROWSER_PUBLIC_URL"; + #[derive(Debug)] enum Command { Serve(ServeOptions), @@ -634,6 +636,9 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box Result<(), Box Result, CliError> { + match std::env::var(BROWSER_PUBLIC_URL_ENV) { + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(CliError(format!( + "{BROWSER_PUBLIC_URL_ENV} must contain valid UTF-8" + ))), + } +} + fn append_trusted_runtime_sources( store: &SqliteWorkspaceStore, remote_runtime_sources: &mut Vec, @@ -966,7 +981,7 @@ fn print_skills_help() { fn print_serve_help() { println!( "yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database ] - yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen Listen address (default 127.0.0.1:8787)\n -h, --help Print help" + yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen Listen address (default 127.0.0.1:8787)\n -h, --help Print help\n\nEnvironment:\n YOI_BROWSER_PUBLIC_URL Browser-facing Vite/Nginx origin used by WebAuthn, device login, cookies, and CSRF checks" ); } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 60a07fdb..e1946a88 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -155,7 +155,6 @@ pub struct ServerConfig { pub workspace_created_at: String, pub workspace_root: PathBuf, pub database_path: PathBuf, - pub frontend_url: String, pub embedded_runtime_store_root: PathBuf, pub static_assets_dir: Option, pub auth: AuthConfig, @@ -182,13 +181,12 @@ impl ServerConfig { workspace_created_at: identity.created_at, workspace_root, database_path, - frontend_url: "http://127.0.0.1:5173".to_string(), embedded_runtime_store_root, static_assets_dir: None, auth: AuthConfig::Passkey { rp_id: "localhost".to_string(), - origin: "http://localhost:8787".to_string(), - public_base_url: "http://localhost:8787".to_string(), + origin: "http://localhost:5173".to_string(), + public_base_url: "http://localhost:5173".to_string(), cookie_name: "yoi_workspace_session".to_string(), }, max_records: 200, diff --git a/docs/design/workspace-runtime-docker.md b/docs/design/workspace-runtime-docker.md index 1b01f37c..2b8dc232 100644 --- a/docs/design/workspace-runtime-docker.md +++ b/docs/design/workspace-runtime-docker.md @@ -64,6 +64,14 @@ docker/workspace/.yoi/workspace-backend.local.toml The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime. +`YOI_BROWSER_PUBLIC_URL` is the single browser-facing deployment setting used by the Server for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks. Compose defaults it to `http://localhost:8080`; deployments exposed through another host, port, or HTTPS endpoint must set the exact Nginx-facing origin, for example: + +```text +YOI_BROWSER_PUBLIC_URL=https://yoi.example.com docker compose up +``` + +The value is an origin, not an API/backend URL, and must not include a path, query, or fragment. + Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly. ## Worker launch path diff --git a/resources/workspace-backend.default.toml b/resources/workspace-backend.default.toml index 4502ae1d..a54540ad 100644 --- a/resources/workspace-backend.default.toml +++ b/resources/workspace-backend.default.toml @@ -17,9 +17,6 @@ # Backend HTTP/WebSocket listen address. listen = "127.0.0.1:8787" -# Browser-facing frontend URL used by local tooling/display. -frontend_url = "http://127.0.0.1:5173" - # Static SPA build directory override. Leave commented for dev/API-only mode. # Relative paths are resolved from the workspace root. # static_assets_dir = "web/workspace/dist" @@ -43,11 +40,10 @@ frontend_url = "http://127.0.0.1:5173" max_records = 200 [auth] -# WebAuthn / Passkey relying-party settings. For local development keep rp_id -# aligned with the browser host in public_base_url/origin. -rp_id = "localhost" -origin = "http://localhost:8787" -public_base_url = "http://localhost:8787" +# Vite owns this origin in local development. Set YOI_BROWSER_PUBLIC_URL to the +# browser-facing Nginx origin in deployments. The Server derives the WebAuthn +# origin, RP ID, device-login URL, cookie policy, and CSRF check from this URL. +browser_public_url = "http://localhost:5173" cookie_name = "yoi_workspace_session" # Repository registry. Browser/API repository projection reads only configured diff --git a/web/workspace/vite.config.ts b/web/workspace/vite.config.ts index 6281ee7d..6598b18c 100644 --- a/web/workspace/vite.config.ts +++ b/web/workspace/vite.config.ts @@ -5,6 +5,9 @@ export default defineConfig({ plugins: [sveltekit()], server: { + host: "localhost", + port: 5173, + strictPort: true, allowedHosts: ["develop.hareworks.net"], watch: { ignored: [