From 864367f4f593aeb8bb7e143863c38d20b7229d00 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 26 Aug 2026 03:56:35 +0900 Subject: [PATCH] fix: remove repository-local server configuration paths --- compose.yaml | 4 +- crates/workspace-server/src/config.rs | 793 ++++-------------- crates/workspace-server/src/identity.rs | 5 +- crates/workspace-server/src/lib.rs | 6 +- crates/workspace-server/src/main.rs | 292 +------ crates/workspace-server/src/server.rs | 189 ++--- .../workspace-server/src/workspace_catalog.rs | 143 ++-- docker.nix | 4 +- docs/design/workspace-runtime-docker.md | 11 +- docs/development/server-runtime-auth.md | 6 +- resources/workspace-backend.default.toml | 61 -- 11 files changed, 340 insertions(+), 1174 deletions(-) delete mode 100644 resources/workspace-backend.default.toml diff --git a/compose.yaml b/compose.yaml index 35db9fac..164d42eb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -19,11 +19,9 @@ 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 + - /etc/yoi/server.toml:/server-config/server.toml:ro webui: image: yoi-webui:latest diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index 865bdac7..af52b7d0 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -7,42 +7,50 @@ use url::Url; use crate::hosts::RemoteRuntimeConfig; use crate::identity::WorkspaceIdentity; -use crate::repositories::ConfiguredRepository; use crate::server::{AuthConfig, ServerConfig}; use crate::{Error, Result}; -pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml"; pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml"; -pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str = - include_str!("../../../resources/workspace-backend.default.toml"); +pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml"; const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173"; const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session"; const DEFAULT_MAX_RECORDS: usize = 200; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ServerHostConfigFile { + #[serde(default)] + pub browser: ServerBrowserConfig, +} + +impl Default for ServerHostConfigFile { + fn default() -> Self { + Self { + browser: ServerBrowserConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ServerBrowserConfig { + #[serde(default = "default_browser_public_url")] + pub public_url: String, +} + +impl Default for ServerBrowserConfig { + fn default() -> Self { + Self { + public_url: default_browser_public_url(), + } + } +} + fn default_browser_public_url() -> String { DEFAULT_BROWSER_PUBLIC_URL.to_string() } -fn default_auth_cookie_name() -> String { - DEFAULT_AUTH_COOKIE_NAME.to_string() -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendConfigFile { - #[serde(default)] - pub server: WorkspaceBackendServerConfig, - #[serde(default)] - pub data: WorkspaceBackendDataConfig, - #[serde(default)] - pub limits: WorkspaceBackendLimitsConfig, - #[serde(default)] - pub auth: WorkspaceBackendAuthConfig, - #[serde(default)] - pub repositories: Vec, -} - #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct BackendRuntimesConfigFile { @@ -50,63 +58,6 @@ pub struct BackendRuntimesConfigFile { pub runtimes: WorkspaceBackendRuntimesConfig, } -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendServerConfig { - #[serde(default)] - pub listen: Option, - #[serde(default)] - pub static_assets_dir: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendDataConfig { - #[serde(default)] - pub root: Option, - #[serde(default)] - pub workspace_database_path: Option, - #[serde(default)] - pub embedded_runtime_store_root: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendLimitsConfig { - #[serde(default)] - pub max_records: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceBackendAuthConfig { - #[serde(default = "default_browser_public_url")] - pub browser_public_url: String, - #[serde(default = "default_auth_cookie_name")] - pub cookie_name: String, -} - -impl Default for WorkspaceBackendAuthConfig { - fn default() -> Self { - Self { - browser_public_url: default_browser_public_url(), - cookie_name: default_auth_cookie_name(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceRepositoryConfigFile { - pub id: String, - pub provider: String, - pub uri: String, - #[serde(default)] - pub display_name: Option, - #[serde(default)] - pub default_selector: Option, -} - #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendRuntimesConfig { @@ -125,61 +76,6 @@ pub struct RemoteRuntimeConfigFile { pub token_ref: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConfigDiff { - pub differs: bool, - pub text: String, -} - -impl ConfigDiff { - fn new(default: &str, local: &str) -> Self { - if default == local { - return Self { - differs: false, - text: "workspace backend local config matches the packaged default\n".to_string(), - }; - } - - let mut text = String::from("--- packaged default\n+++ workspace local\n"); - let default_lines = default.lines().collect::>(); - let local_lines = local.lines().collect::>(); - let max = default_lines.len().max(local_lines.len()); - for index in 0..max { - match (default_lines.get(index), local_lines.get(index)) { - (Some(left), Some(right)) if left == right => { - text.push(' '); - text.push_str(left); - text.push('\n'); - } - (Some(left), Some(right)) => { - text.push('-'); - text.push_str(left); - text.push('\n'); - text.push('+'); - text.push_str(right); - text.push('\n'); - } - (Some(left), None) => { - text.push('-'); - text.push_str(left); - text.push('\n'); - } - (None, Some(right)) => { - text.push('+'); - text.push_str(right); - text.push('\n'); - } - (None, None) => {} - } - } - - Self { - differs: true, - text, - } - } -} - #[derive(Clone)] pub struct ResolvedWorkspaceBackendConfig { pub server: ServerConfig, @@ -187,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig { pub database_path: PathBuf, } +impl ServerHostConfigFile { + pub fn path_for_config_dir(config_dir: impl AsRef) -> PathBuf { + config_dir.as_ref().join(SERVER_HOST_CONFIG_FILE_NAME) + } + + pub fn default_path() -> Option { + manifest::paths::config_dir().map(Self::path_for_config_dir) + } + + pub fn load_default() -> Result { + let Some(path) = Self::default_path() else { + return Ok(Self::default()); + }; + match fs::read_to_string(&path) { + Ok(raw) => Self::parse_str(&raw, &path), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(Error::Io(error)), + } + } + + pub fn load_from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let raw = fs::read_to_string(path).map_err(|error| { + Error::Config(format!( + "failed to read Server host config `{}`: {error}", + path.display() + )) + })?; + Self::parse_str(&raw, path) + } + + pub fn parse_str(raw: &str, path: impl AsRef) -> Result { + toml::from_str(raw).map_err(|error| { + Error::Config(format!( + "failed to parse Server host config `{}`: {error}", + path.as_ref().display() + )) + }) + } +} + impl BackendRuntimesConfigFile { pub fn path_for_config_dir(config_dir: impl AsRef) -> PathBuf { config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME) @@ -255,148 +192,22 @@ impl BackendRuntimesConfigFile { } } -impl WorkspaceBackendConfigFile { - pub fn path_for_workspace(workspace_root: impl AsRef) -> PathBuf { - workspace_root - .as_ref() - .join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH) - } - - pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef) -> Result<()> { - let path = Self::path_for_workspace(workspace_root); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - { - Ok(mut file) => { - use std::io::Write; - file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?; - file.sync_all()?; - Ok(()) - } - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()), - Err(error) => Err(Error::Io(error)), - } - } - - pub fn local_config_diff_for_workspace(workspace_root: impl AsRef) -> Result { - let workspace_root = workspace_root.as_ref(); - let path = Self::path_for_workspace(workspace_root); - match fs::read_to_string(&path) { - Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)), - Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!( - "workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first", - path.display(), - workspace_root.display() - ))), - Err(error) => Err(Error::Io(error)), - } - } - - pub fn load_for_workspace(workspace_root: impl AsRef) -> Result { - let path = Self::path_for_workspace(workspace_root); - match fs::read_to_string(&path) { - Ok(raw) => Self::parse_str(&raw, &path), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()), - Err(error) => Err(Error::Io(error)), - } - } - - pub fn write_for_workspace(&self, workspace_root: impl AsRef) -> Result<()> { - let path = Self::path_for_workspace(workspace_root); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let raw = toml::to_string_pretty(self).map_err(|error| { - Error::Config(format!( - "failed to serialize workspace backend config: {error}" - )) - })?; - fs::write(path, raw)?; - Ok(()) - } - - pub fn parse_str(raw: &str, path: impl AsRef) -> Result { - toml::from_str(raw).map_err(|error| { - Error::Config(format!( - "failed to parse workspace backend config `{}`: {error}", - path.as_ref().display() - )) - }) - } - - pub fn resolve( - &self, - workspace_root: impl AsRef, - identity: WorkspaceIdentity, - ) -> Result { - self.resolve_with_runtime_config( - workspace_root, - identity, - &BackendRuntimesConfigFile::default(), - ) - } - - pub fn resolve_with_runtime_config( - &self, +impl ResolvedWorkspaceBackendConfig { + pub fn local_dev( workspace_root: impl AsRef, identity: WorkspaceIdentity, + host_config: &ServerHostConfigFile, runtime_config: &BackendRuntimesConfigFile, - ) -> Result { + ) -> Result { let workspace_root = workspace_root.as_ref(); - let data_root = self - .data - .root - .as_ref() - .map(|path| resolve_workspace_path(workspace_root, path)) - .unwrap_or_else(|| { - ServerConfig::default_workspace_backend_data_root(&identity.workspace_id) - }); - let database_path = self - .data - .workspace_database_path - .as_ref() - .map(|path| resolve_workspace_path(workspace_root, path)) - .unwrap_or_else(ServerConfig::default_server_database_path); - let embedded_runtime_store_root = self - .data - .embedded_runtime_store_root - .as_ref() - .map(|path| resolve_workspace_path(workspace_root, path)) - .unwrap_or_else(|| data_root.join("embedded-runtime")); - let listen = self - .server - .listen - .as_deref() - .unwrap_or(DEFAULT_LISTEN) - .parse::() - .map_err(|_| { - Error::Config(format!( - "invalid workspace backend server.listen `{}`", - self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN) - )) - })?; - + let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id); + let database_path = ServerConfig::default_server_database_path(); let (browser_public_url, browser_rp_id) = - resolve_browser_public_url(&self.auth.browser_public_url)?; + resolve_browser_public_url(&host_config.browser.public_url)?; let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); server.database_path = database_path.clone(); - server.static_assets_dir = self - .server - .static_assets_dir - .as_ref() - .map(|path| resolve_workspace_path(workspace_root, path)); - server.embedded_runtime_store_root = embedded_runtime_store_root; - server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS); - server.repositories = self - .repositories - .iter() - .map(|repository| resolve_repository(workspace_root, repository)) - .collect::>>()?; + server.embedded_runtime_store_root = data_root.join("embedded-runtime"); + server.max_records = DEFAULT_MAX_RECORDS; server.remote_runtime_sources = runtime_config .runtimes .remote @@ -407,10 +218,13 @@ impl WorkspaceBackendConfigFile { 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)?, + cookie_name: DEFAULT_AUTH_COOKIE_NAME.to_string(), }; + let listen = DEFAULT_LISTEN.parse::().map_err(|error| { + Error::Config(format!("invalid built-in Server listen address: {error}")) + })?; - Ok(ResolvedWorkspaceBackendConfig { + Ok(Self { server, listen, database_path, @@ -419,32 +233,6 @@ impl WorkspaceBackendConfigFile { } impl ResolvedWorkspaceBackendConfig { - pub fn with_database_path(mut self, path: impl Into) -> Self { - let path = path.into(); - self.database_path = path.clone(); - self.server.database_path = path; - self - } - - pub fn with_static_assets_dir(mut self, path: Option) -> Self { - self.server.static_assets_dir = path; - 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 @@ -456,33 +244,6 @@ impl ResolvedWorkspaceBackendConfig { } } -fn resolve_repository( - workspace_root: &Path, - config: &WorkspaceRepositoryConfigFile, -) -> Result { - let id = normalize_required_string("repository id", &config.id)?; - validate_repository_id(&id)?; - let provider = - normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase(); - let uri = normalize_required_string("repository uri", &config.uri)?; - let (source, path) = resolve_repository_source(workspace_root, &id, &uri)?; - let display_name = normalize_optional_string(config.display_name.as_deref()); - let default_selector = normalize_optional_string(config.default_selector.as_deref()); - - Ok(ConfiguredRepository { - id, - provider, - source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), - source, - source_revision: 1, - observed_status: workspace_api::RepositoryObservedStatus::Unverified, - observed_at: None, - path, - display_name, - default_selector, - }) -} - fn normalize_required_string(field: &str, value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -491,73 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result { Ok(trimmed.to_string()) } -fn normalize_optional_string(value: Option<&str>) -> Option { - value.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn validate_repository_id(id: &str) -> Result<()> { - if id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) - { - Ok(()) - } else { - Err(Error::Config(format!( - "repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`" - ))) - } -} - -fn resolve_repository_source( - workspace_root: &Path, - id: &str, - uri: &str, -) -> Result<(workspace_api::RepositorySource, Option)> { - match crate::repository_source::parse_repository_source(uri) { - Ok(source) => { - let path = match source.kind { - workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)), - workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri) - .ok() - .and_then(|uri| uri.to_file_path().ok()), - workspace_api::RepositorySourceKind::Ssh - | workspace_api::RepositorySourceKind::Http - | workspace_api::RepositorySourceKind::Https => None, - workspace_api::RepositorySourceKind::Invalid => { - return Err(Error::Config(format!( - "repository `{id}` has an invalid source" - ))); - } - }; - Ok((source, path)) - } - Err(_) if !Path::new(uri).is_absolute() && !uri.contains("://") => { - let path = resolve_workspace_path(workspace_root, Path::new(uri)); - let source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, - uri: path.to_string_lossy().into_owned(), - }; - Ok((source, Some(path))) - } - Err(error) => Err(Error::Config(format!( - "repository `{id}` has an invalid source: {error}" - ))), - } -} - pub(crate) fn resolve_remote_runtime( config: &RemoteRuntimeConfigFile, ) -> Result { if let Some(token_ref) = config.token_ref.as_deref() { return Err(Error::Config(format!( - "remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet", + "remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet", config.id ))); } @@ -573,43 +273,35 @@ 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 value = normalize_required_string("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}" + "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(), + "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(), + "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" + "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()))? + .ok_or_else(|| Error::Config("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() - } else { - workspace_root.join(path) - } -} - #[cfg(test)] mod tests { use super::*; @@ -622,11 +314,22 @@ mod tests { } } - #[test] - fn missing_config_path_uses_defaults() { + fn resolved_with_runtimes( + runtimes: &BackendRuntimesConfigFile, + ) -> ResolvedWorkspaceBackendConfig { let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); + ResolvedWorkspaceBackendConfig::local_dev( + dir.path(), + identity(), + &ServerHostConfigFile::default(), + runtimes, + ) + .unwrap() + } + + #[test] + fn default_settings_resolve_without_a_repository_file() { + let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default()); assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap()); let AuthConfig::Passkey { @@ -650,12 +353,8 @@ mod tests { #[test] fn backend_base_url_is_explicit_and_normalized() { - let dir = tempfile::tempdir().unwrap(); let listen = "127.0.0.1:48787".parse().unwrap(); - let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path()) - .unwrap() - .resolve(dir.path(), identity()) - .unwrap() + let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default()) .with_listen(listen) .with_backend_base_url("http://127.0.0.1:48787/"); @@ -667,14 +366,18 @@ 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", + fn browser_public_url_from_host_config_drives_all_browser_auth_settings() { + let host_config = ServerHostConfigFile::parse_str( + "[browser]\npublic_url = \"https://Yoi.Example:443/\"\n", + "server.toml", + ) + .unwrap(); + let resolved = ResolvedWorkspaceBackendConfig::local_dev( + tempfile::tempdir().unwrap().path(), + identity(), + &host_config, + &BackendRuntimesConfigFile::default(), ) - .unwrap() - .resolve(dir.path(), identity()) .unwrap(); let AuthConfig::Passkey { @@ -688,26 +391,6 @@ mod tests { 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 [ @@ -715,204 +398,51 @@ mod tests { "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 host_config = ServerHostConfigFile { + browser: ServerBrowserConfig { + public_url: value.to_string(), + }, + }; + let result = ResolvedWorkspaceBackendConfig::local_dev( + tempfile::tempdir().unwrap().path(), + identity(), + &host_config, + &BackendRuntimesConfigFile::default(), + ); let error = match result { Ok(_) => panic!("expected {value} to be rejected"), Err(error) => error, }; assert!( - error.to_string().contains("auth.browser_public_url"), + error.to_string().contains("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") - .unwrap_err(); - assert!( - error.to_string().contains("unknown field"), - "unexpected error: {error}" - ); - } - - #[test] - fn resolves_relative_paths_against_workspace_root() { + fn server_host_config_loads_only_from_the_explicit_host_path() { let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[server] -static_assets_dir = "web/build" - -[data] -root = ".yoi/backend-data" -workspace_database_path = ".yoi/custom.db" -embedded_runtime_store_root = ".yoi/runtime-store" -"#, - "test", - ) - .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - - assert_eq!( - resolved.server.static_assets_dir, - Some(dir.path().join("web/build")) - ); - assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db")); - assert_eq!( - resolved.server.embedded_runtime_store_root, - dir.path().join(".yoi/runtime-store") - ); - } - - #[test] - fn absolute_paths_are_preserved() { - let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[data] -workspace_database_path = "/tmp/yoi-workspace.db" -embedded_runtime_store_root = "/tmp/yoi-runtime" -"#, - "test", - ) - .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - - assert_eq!( - resolved.database_path, - PathBuf::from("/tmp/yoi-workspace.db") - ); - assert_eq!( - resolved.server.embedded_runtime_store_root, - PathBuf::from("/tmp/yoi-runtime") - ); - } - - #[test] - fn data_root_derives_runtime_store_path_only() { - let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[data] -root = ".local-data" -"#, - "test", - ) - .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - - assert!(resolved.database_path.ends_with("server.db")); - assert_eq!( - resolved.server.embedded_runtime_store_root, - dir.path().join(".local-data/embedded-runtime") - ); - } - - #[test] - fn copies_local_config_without_overwriting() { - let dir = tempfile::tempdir().unwrap(); - WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap(); - let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path()); - let raw = fs::read_to_string(&path).unwrap(); - assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE); - WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap(); - - fs::write(&path, "# custom local config\n").unwrap(); - WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap(); - assert_eq!( - fs::read_to_string(&path).unwrap(), - "# custom local config\n" - ); - } - - #[test] - fn local_config_diff_reports_match_and_difference() { - let dir = tempfile::tempdir().unwrap(); - WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap(); - let matched = - WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap(); - assert!(!matched.differs); - + let path = ServerHostConfigFile::path_for_config_dir(dir.path()); fs::write( - WorkspaceBackendConfigFile::path_for_workspace(dir.path()), - "[server]\nlisten = \"127.0.0.1:9999\"\n", + &path, + "[browser]\npublic_url = \"https://deploy.example.test\"\n", ) .unwrap(); - let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap(); - assert!(diff.differs); - assert!(diff.text.contains("+++ workspace local")); - assert!(diff.text.contains("127.0.0.1:9999")); + + let loaded = ServerHostConfigFile::load_from_path(&path).unwrap(); + assert_eq!(loaded.browser.public_url, "https://deploy.example.test"); + assert_eq!(path, dir.path().join("server.toml")); } #[test] - fn resolves_repository_uri_relative_to_workspace_root() { - let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[[repositories]] -id = "main" -provider = "git" -uri = "." -display_name = "Main" -default_selector = "HEAD" -"#, - "test", - ) - .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - let repository = resolved.server.repositories.first().unwrap(); - - assert_eq!(repository.id, "main"); - assert_eq!(repository.provider, "git"); - assert_eq!(repository.path.as_deref(), Some(dir.path())); - assert_eq!(repository.display_name.as_deref(), Some("Main")); - assert_eq!(repository.default_selector.as_deref(), Some("HEAD")); - } - - #[test] - fn remote_repository_source_is_preserved_without_a_local_path() { - let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( - r#" -[[repositories]] -id = "main" -provider = "git" -uri = "https://example.com/org/repo.git" -"#, - "test", - ) - .unwrap(); - let resolved = config.resolve(dir.path(), identity()).unwrap(); - let repository = &resolved.server.repositories[0]; - - assert_eq!( - repository.source.kind, - workspace_api::RepositorySourceKind::Https + fn explicit_missing_server_host_config_fails_closed() { + let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err(); + assert!( + error + .to_string() + .contains("failed to read Server host config") ); - assert_eq!(repository.source.uri, "https://example.com/org/repo.git"); - assert!(repository.path.is_none()); } #[test] @@ -937,28 +467,8 @@ uri = "https://example.com/org/repo.git" ); } - #[test] - fn workspace_backend_config_rejects_runtime_entries() { - let error = WorkspaceBackendConfigFile::parse_str( - r#" -[[runtimes.remote]] -id = "arc" -endpoint = "http://legacy.example.test" -display_name = "legacy arc" -"#, - "test", - ) - .unwrap_err(); - assert!( - error.to_string().contains("unknown field `runtimes`"), - "unexpected error: {error}" - ); - } - #[test] fn backend_runtimes_config_is_the_only_runtime_source() { - let dir = tempfile::tempdir().unwrap(); - let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap(); let runtime_config = BackendRuntimesConfigFile::parse_str( r#" [[runtimes.remote]] @@ -969,9 +479,7 @@ display_name = "xdg arc" "runtimes.toml", ) .unwrap(); - let resolved = workspace_config - .resolve_with_runtime_config(dir.path(), identity(), &runtime_config) - .unwrap(); + let resolved = resolved_with_runtimes(&runtime_config); assert_eq!(resolved.server.remote_runtime_sources.len(), 1); assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc"); assert_eq!( @@ -1000,8 +508,6 @@ token = "secret" #[test] fn token_ref_fails_closed_until_secret_resolution_exists() { - let dir = tempfile::tempdir().unwrap(); - let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap(); let runtime_config = BackendRuntimesConfigFile::parse_str( r#" [[runtimes.remote]] @@ -1012,9 +518,10 @@ token_ref = "local:remote-token" "runtimes.toml", ) .unwrap(); - let error = match workspace_config.resolve_with_runtime_config( - dir.path(), + let error = match ResolvedWorkspaceBackendConfig::local_dev( + tempfile::tempdir().unwrap().path(), identity(), + &ServerHostConfigFile::default(), &runtime_config, ) { Ok(_) => panic!("token_ref should fail closed until secret resolution exists"), diff --git a/crates/workspace-server/src/identity.rs b/crates/workspace-server/src/identity.rs index 2067ef0f..25b56bb7 100644 --- a/crates/workspace-server/src/identity.rs +++ b/crates/workspace-server/src/identity.rs @@ -46,8 +46,7 @@ impl WorkspaceIdentity { Ok(raw) => Self::parse_str(&raw, &path), Err(error) if error.kind() == ErrorKind::NotFound => { Err(Error::WorkspaceIdentity(format!( - "workspace is not initialized at {}; run `yoi-server init --workspace {}` first", - workspace_root.as_ref().display(), + "workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing", workspace_root.as_ref().display() ))) } @@ -219,7 +218,7 @@ mod tests { let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err(); assert!( - error.to_string().contains("workspace is not initialized"), + error.to_string().contains("workspace identity is missing"), "unexpected error: {error}" ); assert!(!WorkspaceIdentity::path(&workspace_root).exists()); diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 14e7e2c3..f4a1c5b0 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -38,11 +38,7 @@ pub use authority::{ ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, WorkspaceAuthority, }; -pub use config::{ - BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig, - WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE, - WorkspaceBackendConfigFile, -}; +pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; pub use repositories::{ diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 7343cda3..18d8f439 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -11,19 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key}; use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord}; use yoi_workspace_server::{ - BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig, - WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService, - WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, + BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, + ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, }; -const BROWSER_PUBLIC_URL_ENV: &str = "YOI_BROWSER_PUBLIC_URL"; - #[derive(Debug)] enum Command { Serve(ServeOptions), - Init(InitOptions), - ConfigDefault, - ConfigDiff(WorkspacePathOptions), Identity(Vec), TrustRuntime(Vec), MigrateDryRun { database: Option }, @@ -34,16 +28,7 @@ enum Command { #[derive(Debug)] struct ServeOptions { listen: Option, -} - -#[derive(Debug)] -struct InitOptions { - workspace: PathBuf, -} - -#[derive(Debug)] -struct WorkspacePathOptions { - workspace: PathBuf, + config: Option, } #[derive(Debug)] @@ -84,9 +69,6 @@ async fn run() -> Result<(), Box> { let args = std::env::args().skip(1).collect::>(); match parse_command(&args)? { Command::Serve(options) => run_serve(options).await, - Command::Init(options) => run_init(options).await, - Command::ConfigDefault => run_config_default(), - Command::ConfigDiff(options) => run_config_diff(options), Command::Identity(args) => run_identity_command(args), Command::TrustRuntime(args) => run_trust_runtime_command(args), Command::MigrateDryRun { database } => { @@ -112,14 +94,6 @@ fn parse_command(args: &[String]) -> Result { }; match command.as_str() { - "init" => { - if rest.iter().any(|arg| arg == "--help" || arg == "-h") { - print_init_help(); - return Ok(Command::Help); - } - Ok(Command::Init(parse_init_options(rest)?)) - } - "config" => parse_config_command(rest), "identity" => Ok(Command::Identity(rest.to_vec())), "trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())), "migrate" => parse_migrate_command(rest), @@ -136,61 +110,11 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`" + "unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`" ))), } } -async fn run_init(options: InitOptions) -> Result<(), Box> { - run_init_with_database_path(options, ServerConfig::default_server_database_path()).await -} - -async fn run_init_with_database_path( - options: InitOptions, - database_path: PathBuf, -) -> Result<(), Box> { - let identity = WorkspaceIdentity::load_or_init(&options.workspace)?; - WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?; - - if let Some(parent) = database_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?); - let service = WorkspaceCatalogService::new(store); - service.create_with_workspace_id( - WorkspaceCreateRequest { - operation_key: format!("cli-init:{}", identity.workspace_id), - display_name: identity.display_name.clone(), - repository: InitialRepositoryIntent { - uri: options.workspace.display().to_string(), - display_name: Some("Main repository".to_string()), - default_ref: Some("HEAD".to_string()), - }, - }, - None, - Some(identity.workspace_id.clone()), - )?; - - eprintln!( - "yoi-server: initialized workspace `{}` ({}) in server DB `{}`", - options.workspace.display(), - identity.workspace_id, - database_path.display() - ); - Ok(()) -} - -fn run_config_default() -> Result<(), Box> { - print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}"); - Ok(()) -} - -fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box> { - let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?; - print!("{}", diff.text); - Ok(()) -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] struct ServerIdentityFile { identity: RuntimeIdentityMaterial, @@ -628,22 +552,23 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box ServerHostConfigFile::load_from_path(path)?, + None => ServerHostConfigFile::load_default()?, + }; let runtime_config = BackendRuntimesConfigFile::load_default()?; - let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config( + let mut resolved = ResolvedWorkspaceBackendConfig::local_dev( &workspace_root, identity, + &host_config, &runtime_config, )?; resolved.database_path = database_path.clone(); resolved.server.database_path = database_path.clone(); - if let Some(browser_public_url) = browser_public_url_from_environment()? { - resolved = resolved.with_browser_public_url(&browser_public_url)?; - } append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?; if let Some(listen) = options.listen { resolved = resolved.with_listen(listen); } - resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback(); let listener = TcpListener::bind(resolved.listen).await?; let local_addr = listener.local_addr()?; @@ -660,16 +585,6 @@ async fn run_serve(options: ServeOptions) -> 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, @@ -737,41 +652,6 @@ fn infer_workspace_root_from_repositories( )) } -fn parse_config_command(args: &[String]) -> Result { - let Some((subcommand, rest)) = args.split_first() else { - print_config_help(); - return Ok(Command::Help); - }; - match subcommand.as_str() { - "default" => { - if rest.iter().any(|arg| arg == "--help" || arg == "-h") { - print_config_help(); - return Ok(Command::Help); - } - if !rest.is_empty() { - return Err(CliError( - "config default does not accept options".to_string(), - )); - } - Ok(Command::ConfigDefault) - } - "diff" => { - if rest.iter().any(|arg| arg == "--help" || arg == "-h") { - print_config_help(); - return Ok(Command::Help); - } - Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?)) - } - "--help" | "-h" => { - print_config_help(); - Ok(Command::Help) - } - other => Err(CliError(format!( - "unknown config subcommand `{other}`; expected `default` or `diff`" - ))), - } -} - fn parse_migrate_command(args: &[String]) -> Result { let mut dry_run = false; let mut database = None; @@ -855,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result Result { - let mut workspace = std::env::current_dir() - .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - match arg.as_str() { - "--workspace" => { - let value = iter - .next() - .ok_or_else(|| CliError("--workspace requires a path".to_string()))?; - workspace = PathBuf::from(value); - } - value if value.starts_with("--workspace=") => { - workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); - } - other => return Err(CliError(format!("unknown workspace option `{other}`"))), - } - } - let workspace = workspace - .canonicalize() - .map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?; - Ok(WorkspacePathOptions { workspace }) -} - -fn parse_init_options(args: &[String]) -> Result { - let mut workspace = std::env::current_dir() - .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; - let mut iter = args.iter(); - while let Some(arg) = iter.next() { - match arg.as_str() { - "--workspace" => { - let value = iter - .next() - .ok_or_else(|| CliError("--workspace requires a path".to_string()))?; - workspace = PathBuf::from(value); - } - value if value.starts_with("--workspace=") => { - workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); - } - other => return Err(CliError(format!("unknown init option `{other}`"))), - } - } - - let workspace = workspace - .canonicalize() - .map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?; - Ok(InitOptions { workspace }) -} - fn parse_serve_options(args: &[String]) -> Result { let mut listen = None; + let mut config = None; let mut index = 0; while index < args.len() { @@ -921,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result { _ if arg.starts_with("--listen=") => { listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?); } + "--config" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| CliError("--config requires a path".to_string()))?; + config = Some(PathBuf::from(value)); + } + _ if arg.starts_with("--config=") => { + config = Some(PathBuf::from(value_after_equals(arg, "--config")?)); + } _ if arg.starts_with('-') => { return Err(CliError(format!("unknown serve option `{arg}`"))); } @@ -933,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result { index += 1; } - Ok(ServeOptions { listen }) + Ok(ServeOptions { listen, config }) } fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> { @@ -955,23 +797,11 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config [OPTIONS]\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server migrate --dry-run [--database ] + "yoi-server\n\nUsage:\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server migrate --dry-run [--database ] yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" ); } -fn print_init_help() { - println!( - "yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace Workspace root to initialize (defaults to cwd)\n -h, --help Print help" - ); -} - -fn print_config_help() { - println!( - "yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace Workspace root (defaults to cwd)\n -h, --help Print help" - ); -} - fn print_skills_help() { println!( "yoi-server skills\n\nUsage:\n yoi-server skills list --workspace \n yoi-server skills lint --workspace \n yoi-server skills show --workspace \n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace Workspace id in the Server DB (required)\n -h, --help Print help" @@ -981,24 +811,23 @@ 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\n\nEnvironment:\n YOI_BROWSER_PUBLIC_URL Browser-facing Vite/Nginx origin used by WebAuthn, device login, cookies, and CSRF checks" + yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen Listen address (default 127.0.0.1:8787)\n --config Host-level Server config path\n -h, --help Print help" ); } #[cfg(test)] mod tests { use super::*; - use yoi_workspace_server::{ - WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE, - WORKSPACE_IDENTITY_RELATIVE_PATH, - }; #[test] - fn parse_init_defaults_workspace_to_cwd_or_flag() { - let temp = tempfile::tempdir().unwrap(); - let args = vec!["--workspace".to_string(), temp.path().display().to_string()]; - let options = parse_init_options(&args).unwrap(); - assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); + fn removed_repository_local_commands_are_rejected() { + for command in ["init", "config"] { + let error = parse_command(&[command.to_string()]).unwrap_err(); + assert!( + error.to_string().contains("unknown command"), + "unexpected error for {command}: {error}" + ); + } } #[test] @@ -1038,10 +867,18 @@ mod tests { } #[test] - fn parse_serve_accepts_listen_only() { - let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()]; + fn parse_serve_accepts_listen_and_host_config() { + let args = vec![ + "--listen".to_string(), + "127.0.0.1:0".to_string(), + "--config=/etc/yoi/server.toml".to_string(), + ]; let options = parse_serve_options(&args).unwrap(); assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap()); + assert_eq!( + options.config.unwrap(), + PathBuf::from("/etc/yoi/server.toml") + ); } #[test] @@ -1096,49 +933,4 @@ mod tests { ); ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap(); } - - #[tokio::test] - async fn init_creates_identity_local_config_and_server_records() { - let temp = tempfile::tempdir().unwrap(); - let database_path = temp.path().join("data").join("server").join("server.db"); - std::fs::create_dir(temp.path().join(".git")).unwrap(); - run_init_with_database_path( - InitOptions { - workspace: temp.path().canonicalize().unwrap(), - }, - database_path.clone(), - ) - .await - .unwrap(); - - assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists()); - let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH); - assert!(local_config_path.exists()); - assert_eq!( - std::fs::read_to_string(local_config_path).unwrap(), - WORKSPACE_BACKEND_CONFIG_TEMPLATE - ); - assert!( - !temp - .path() - .join(".yoi/workspace-backend.default.toml") - .exists() - ); - assert!(!temp.path().join(".yoi/workspace.db").exists()); - assert!(!temp.path().join(".yoi/embedded-runtime").exists()); - assert!(database_path.exists()); - - let store = SqliteWorkspaceStore::open(&database_path).unwrap(); - let workspaces = store.list_workspaces().unwrap(); - assert_eq!(workspaces.len(), 1); - let repositories = store - .list_repositories(&workspaces[0].workspace_id) - .unwrap(); - assert_eq!(repositories.len(), 1); - assert_eq!(repositories[0].repository_id, "main"); - assert_eq!( - repositories[0].uri, - temp.path().canonicalize().unwrap().display().to_string() - ); - } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index e1946a88..3fb3207e 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -164,9 +164,6 @@ pub struct ServerConfig { pub remote_runtime_sources: Vec, pub runtime_config_path: Option, pub backend_base_url: Option, - /// Allows the first ownerless Workspace to be created without a session. - /// This must only be enabled for a loopback-bound local Server. - pub allow_local_workspace_bootstrap: bool, } impl ServerConfig { @@ -195,7 +192,6 @@ impl ServerConfig { remote_runtime_sources: Vec::new(), runtime_config_path: BackendRuntimesConfigFile::default_path(), backend_base_url: None, - allow_local_workspace_bootstrap: false, } } @@ -252,11 +248,6 @@ impl ServerConfig { Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime") } - pub fn with_local_workspace_bootstrap(mut self, enabled: bool) -> Self { - self.allow_local_workspace_bootstrap = enabled; - self - } - pub fn with_embedded_runtime_store_root(mut self, root: impl Into) -> Self { self.embedded_runtime_store_root = root.into(); self @@ -842,9 +833,9 @@ async fn list_server_workspaces( ) -> Response { let owner = match resolve_server_actor(&api, &headers).await { Ok(Some(actor)) => Some(actor.account_id), - Ok(None) => match api.catalog.list(None, 1) { - Ok(workspaces) if workspaces.is_empty() => return Json(workspaces).into_response(), - Ok(_) => return StatusCode::UNAUTHORIZED.into_response(), + Ok(None) => match api.catalog.is_empty() { + Ok(true) => return Json(Vec::::new()).into_response(), + Ok(false) => return StatusCode::UNAUTHORIZED.into_response(), Err(error) => return server_error_response(error), }, Err(error) => return server_error_response(error), @@ -863,19 +854,14 @@ async fn create_server_workspace( headers: HeaderMap, Json(request): Json, ) -> Response { - let (owner_account_id, local_bootstrap) = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => (Some(actor.account_id), false), - Ok(None) if api.template.allow_local_workspace_bootstrap => (None, true), + let owner_account_id = match resolve_server_actor(&api, &headers).await { + Ok(Some(actor)) => actor.account_id, Ok(None) => { return forbidden_server_response("Workspace creation requires an authenticated owner"); } Err(error) => return server_error_response(error), }; - let created = match if local_bootstrap { - api.catalog.create_first_ownerless(request) - } else { - api.catalog.create(request, owner_account_id) - } { + let created = match api.catalog.create(request, owner_account_id) { Ok(created) => created, Err(error) => return server_error_response(error), }; @@ -1213,6 +1199,33 @@ pub async fn build_workspace_server_router( ))) } +#[cfg(test)] +async fn seed_test_registered_workspace( + store: &dyn ControlPlaneStore, + config: &ServerConfig, +) -> Result<()> { + let account_id = format!("account-{}", config.workspace_id); + store.upsert_account(&AccountRecord { + account_id: account_id.clone(), + kind: "user".to_owned(), + handle: format!("owner-{}", config.workspace_id), + display_name: "Workspace Owner".to_owned(), + created_at: config.workspace_created_at.clone(), + updated_at: config.workspace_created_at.clone(), + })?; + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: config.workspace_id.clone(), + owner_account_id: Some(account_id), + display_name: config.workspace_display_name.clone(), + state: "active".to_owned(), + created_at: config.workspace_created_at.clone(), + updated_at: config.workspace_created_at.clone(), + }) + .await?; + Ok(()) +} + impl WorkspaceApi { pub fn with_config_schema_provider( mut self, @@ -1276,6 +1289,7 @@ impl WorkspaceApi { store: Arc, execution_backend: Arc, ) -> Result { + seed_test_registered_workspace(store.as_ref(), &config).await?; Self::new_with_execution_backend_and_broker( config, store, @@ -1295,16 +1309,12 @@ impl WorkspaceApi { Arc, >, ) -> Result { - store - .upsert_workspace(&WorkspaceRecord { - workspace_id: config.workspace_id.clone(), - owner_account_id: None, - display_name: config.workspace_display_name.clone(), - state: "active".to_string(), - created_at: config.workspace_created_at.clone(), - updated_at: config.workspace_created_at.clone(), - }) - .await?; + if store.get_workspace(&config.workspace_id).await?.is_none() { + return Err(crate::Error::Config(format!( + "Workspace {} is not registered in the Server DB", + config.workspace_id + ))); + } import_configured_repositories(store.as_ref(), &config)?; config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?; let embedded_runtime = EmbeddedWorkerRuntime::new_fs_store_with_execution_backend( @@ -15167,13 +15177,34 @@ mod tests { )); } + #[tokio::test] + async fn workspace_api_does_not_register_a_missing_workspace() { + let dir = tempfile::tempdir().unwrap(); + let config = test_server_config(dir.path()); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + + let error = match WorkspaceApi::new(config, store.clone()).await { + Ok(_) => panic!("missing Workspace registration must fail closed"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("is not registered in the Server DB") + ); + assert!(store.list_workspaces().unwrap().is_empty()); + } + #[tokio::test] async fn production_profile_backend_rejects_unrecoverable_pending_orchestrator_restore() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let config = test_server_config(workspace.path()); - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); - let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap(); + let store = Arc::new(SqliteWorkspaceStore::open(config.database_path.clone()).unwrap()); + seed_test_registered_workspace(store.as_ref(), &config) + .await + .unwrap(); + let api = WorkspaceApi::new(config, store).await.unwrap(); let workspace_id = api.config.workspace_id.clone(); let result = scoped_start_workspace_orchestrator( @@ -15725,13 +15756,8 @@ mod tests { #[test] fn backend_errors_preserve_operation_details() { - let sanitized = sanitize_backend_error( - "failed to open /home/example/.yoi/workspace-backend.local.toml", - ); - assert_eq!( - sanitized, - "failed to open /home/example/.yoi/workspace-backend.local.toml" - ); + let sanitized = sanitize_backend_error("failed to open server database"); + assert_eq!(sanitized, "failed to open server database"); } #[test] @@ -16170,6 +16196,16 @@ mod tests { } = &config.auth; let expected_origin = expected_origin.clone(); let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + store + .upsert_account(&AccountRecord { + account_id: "account-auth".to_owned(), + kind: "user".to_owned(), + handle: "auth-user".to_owned(), + display_name: "Auth User".to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + }) + .unwrap(); let catalog = WorkspaceCatalogService::new(store.clone()); let repository = temp.path().join("repository"); std::fs::create_dir_all(&repository).unwrap(); @@ -16192,19 +16228,9 @@ mod tests { default_ref: None, }, }, - None, + "account-auth".to_owned(), ) .unwrap(); - store - .upsert_account(&AccountRecord { - account_id: "account-auth".to_owned(), - kind: "user".to_owned(), - handle: "auth-user".to_owned(), - display_name: "Auth User".to_owned(), - created_at: "2026-01-01T00:00:00Z".to_owned(), - updated_at: "2026-01-01T00:00:00Z".to_owned(), - }) - .unwrap(); store .upsert_user(&UserRecord { user_id: "user-auth".to_owned(), @@ -16556,6 +16582,7 @@ mod tests { let mut template = test_server_config(dir.path()); template.static_assets_dir = Some(static_dir); let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); + let token = seed_test_api_token(store.as_ref(), "two-workspaces"); let catalog = WorkspaceCatalogService::new(store.clone()); let workspace_a = catalog .create( @@ -16568,7 +16595,7 @@ mod tests { default_ref: None, }, }, - None, + "account-two-workspaces".to_owned(), ) .unwrap(); let workspace_b = catalog @@ -16582,10 +16609,9 @@ mod tests { default_ref: None, }, }, - None, + "account-two-workspaces".to_owned(), ) .unwrap(); - let token = seed_test_api_token(store.as_ref(), "two-workspaces"); let app = build_workspace_server_router(template, store) .await .unwrap(); @@ -16686,13 +16712,12 @@ mod tests { } #[tokio::test] - async fn local_bootstrap_create_activates_workspace_without_server_restart() { + async fn local_workspace_creation_requires_an_authenticated_owner() { let dir = tempfile::tempdir().unwrap(); let repository = dir.path().join("repository"); std::fs::create_dir_all(repository.join(".git")).unwrap(); - let template = test_server_config(dir.path()).with_local_workspace_bootstrap(true); + let template = test_server_config(dir.path()); let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); - let token = seed_test_api_token(store.as_ref(), "bootstrap"); let app = build_workspace_server_router(template, store) .await .unwrap(); @@ -16706,8 +16731,7 @@ mod tests { } }); - let created = app - .clone() + let response = app .oneshot( Request::builder() .method(Method::POST) @@ -16718,54 +16742,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(created.status(), StatusCode::CREATED); - let body = to_bytes(created.into_body(), usize::MAX).await.unwrap(); - let body: Value = serde_json::from_slice(&body).unwrap(); - let workspace_id = body["workspace"]["workspace_id"].as_str().unwrap(); - - let workspace = get_json_authenticated( - app.clone(), - &format!("/api/w/{workspace_id}/workspace"), - &token, - ) - .await; - assert_eq!(workspace["display_name"], "Created Workspace"); - - let replayed = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/workspaces") - .header(axum::http::header::CONTENT_TYPE, "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(replayed.status(), StatusCode::OK); - - let second_payload = json!({ - "operation_key": "bootstrap-2", - "display_name": "Second Ownerless Workspace", - "repository": { - "uri": repository, - "display_name": "Repository", - "default_ref": "HEAD" - } - }); - let second = app - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/workspaces") - .header(axum::http::header::CONTENT_TYPE, "application/json") - .body(Body::from(second_payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(second.status(), StatusCode::CONFLICT); + assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[test] diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index 4bcafefd..ed70673a 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -54,6 +54,10 @@ impl WorkspaceCatalogService { Self { store } } + pub fn is_empty(&self) -> Result { + Ok(self.store.list_workspaces()?.is_empty()) + } + pub fn list( &self, owner_account_id: Option<&str>, @@ -76,33 +80,16 @@ impl WorkspaceCatalogService { pub fn create( &self, request: WorkspaceCreateRequest, - owner_account_id: Option, + owner_account_id: String, ) -> Result { - self.create_internal(request, owner_account_id, None, false) - } - - pub fn create_first_ownerless( - &self, - request: WorkspaceCreateRequest, - ) -> Result { - self.create_internal(request, None, None, true) - } - - pub fn create_with_workspace_id( - &self, - request: WorkspaceCreateRequest, - owner_account_id: Option, - requested_workspace_id: Option, - ) -> Result { - self.create_internal(request, owner_account_id, requested_workspace_id, false) + self.create_internal(request, owner_account_id, None) } fn create_internal( &self, request: WorkspaceCreateRequest, - owner_account_id: Option, + owner_account_id: String, requested_workspace_id: Option, - require_empty_catalog: bool, ) -> Result { let operation_key = normalize_required( "operation_key", @@ -142,7 +129,7 @@ impl WorkspaceCatalogService { let fingerprint = workspace_create_fingerprint( requested_workspace_id.as_deref(), &display_name, - owner_account_id.as_deref(), + Some(&owner_account_id), &repository_uri, &repository_name, &default_ref, @@ -153,10 +140,10 @@ impl WorkspaceCatalogService { .create_workspace_bootstrap(&WorkspaceBootstrapRecord { operation_key, request_fingerprint: fingerprint.clone(), - require_empty_catalog, + require_empty_catalog: false, workspace: WorkspaceRecord { workspace_id: workspace_id.clone(), - owner_account_id, + owner_account_id: Some(owner_account_id), display_name, state: "active".to_string(), created_at: now.clone(), @@ -235,7 +222,7 @@ fn workspace_create_fingerprint( #[cfg(test)] mod tests { use super::*; - use crate::store::SqliteWorkspaceStore; + use crate::store::{AccountRecord, SqliteWorkspaceStore}; use workspace_api::RepositorySourceKind; fn git_repository() -> tempfile::TempDir { @@ -244,6 +231,22 @@ mod tests { dir } + fn owner_account(store: &SqliteWorkspaceStore) -> String { + let account_id = Uuid::now_v7().to_string(); + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + store + .upsert_account(&AccountRecord { + account_id: account_id.clone(), + kind: "user".to_string(), + handle: format!("owner-{}", &account_id[..8]), + display_name: "Workspace Owner".to_string(), + created_at: now.clone(), + updated_at: now, + }) + .unwrap(); + account_id + } + #[tokio::test] async fn create_is_atomic_and_exact_retries_converge() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); @@ -259,8 +262,11 @@ mod tests { }, }; - let created = service.create(request.clone(), None).unwrap(); - let replayed = service.create(request, None).unwrap(); + let owner_account_id = owner_account(store.as_ref()); + let created = service + .create(request.clone(), owner_account_id.clone()) + .unwrap(); + let replayed = service.create(request, owner_account_id).unwrap(); assert!(!created.replayed); assert!(replayed.replayed); @@ -284,64 +290,10 @@ mod tests { ); } - #[test] - fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); - let service = WorkspaceCatalogService::new(store.clone()); - let repository_a = git_repository(); - let repository_b = git_repository(); - let requests = [ - WorkspaceCreateRequest { - operation_key: "bootstrap-a".to_string(), - display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { - uri: repository_a.path().display().to_string(), - display_name: None, - default_ref: None, - }, - }, - WorkspaceCreateRequest { - operation_key: "bootstrap-b".to_string(), - display_name: "Workspace B".to_string(), - repository: InitialRepositoryIntent { - uri: repository_b.path().display().to_string(), - display_name: None, - default_ref: None, - }, - }, - ]; - let barrier = Arc::new(std::sync::Barrier::new(2)); - let results = std::thread::scope(|scope| { - requests - .into_iter() - .map(|request| { - let service = service.clone(); - let barrier = barrier.clone(); - scope.spawn(move || { - barrier.wait(); - service.create_first_ownerless(request) - }) - }) - .collect::>() - .into_iter() - .map(|handle| handle.join().unwrap()) - .collect::>() - }); - - assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); - assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); - assert_eq!(store.list_workspaces().unwrap().len(), 1); - let error = results - .into_iter() - .find_map(Result::err) - .unwrap() - .to_string(); - assert!(error.contains("catalog is empty"), "{error}"); - } - #[tokio::test] async fn idempotency_key_reuse_with_different_payload_is_rejected() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let owner_account_id = owner_account(store.as_ref()); let service = WorkspaceCatalogService::new(store); let repository = git_repository(); let mut request = WorkspaceCreateRequest { @@ -353,10 +305,15 @@ mod tests { default_ref: None, }, }; - service.create(request.clone(), None).unwrap(); + service + .create(request.clone(), owner_account_id.clone()) + .unwrap(); request.display_name = "Workspace B".to_string(); - let error = service.create(request, None).unwrap_err().to_string(); + let error = service + .create(request, owner_account_id) + .unwrap_err() + .to_string(); assert!(error.contains("different input"), "{error}"); } @@ -378,17 +335,21 @@ mod tests { #[test] fn remote_repository_creation_persists_typed_source_without_auth_metadata() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let owner_account_id = owner_account(store.as_ref()); let service = WorkspaceCatalogService::new(store.clone()); let result = service - .create_first_ownerless(WorkspaceCreateRequest { - operation_key: "remote-create".to_string(), - display_name: "Remote Workspace".to_string(), - repository: InitialRepositoryIntent { - uri: "ssh://git@example.test/org/repository.git".to_string(), - display_name: Some("Remote Repository".to_string()), - default_ref: Some("main".to_string()), + .create( + WorkspaceCreateRequest { + operation_key: "remote-create".to_string(), + display_name: "Remote Workspace".to_string(), + repository: InitialRepositoryIntent { + uri: "ssh://git@example.test/org/repository.git".to_string(), + display_name: Some("Remote Repository".to_string()), + default_ref: Some("main".to_string()), + }, }, - }) + owner_account_id, + ) .unwrap(); let persisted = store diff --git a/docker.nix b/docker.nix index ce56e548..792d85d6 100644 --- a/docker.nix +++ b/docker.nix @@ -264,6 +264,8 @@ in "serve" "--listen" "0.0.0.0:8787" + "--config" + "/server-config/server.toml" ]; Env = [ "PATH=/bin" @@ -274,7 +276,7 @@ in }; Volumes = { "/server-data" = { }; - "/workspace" = { }; + "/server-config" = { }; }; WorkingDir = "/server-data"; }; diff --git a/docs/design/workspace-runtime-docker.md b/docs/design/workspace-runtime-docker.md index 2b8dc232..2f8a2d5d 100644 --- a/docs/design/workspace-runtime-docker.md +++ b/docs/design/workspace-runtime-docker.md @@ -58,19 +58,18 @@ The Compose files live at: ```text compose.yaml -docker/workspace/.yoi/workspace.toml -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: +The operator-owned `/etc/yoi/server.toml` is mounted read-only at `/server-config/server.toml`. Its `browser.public_url` is the single browser-facing setting used for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks: -```text -YOI_BROWSER_PUBLIC_URL=https://yoi.example.com docker compose up +```toml +[browser] +public_url = "https://yoi.example.com" ``` -The value is an origin, not an API/backend URL, and must not include a path, query, or fragment. +Create this host file before starting Compose and set it to the exact Nginx-facing origin. It is not an API/backend URL and must not include a path, query, or fragment. It is deployment topology outside the source and Workspace repositories, not Workspace DB state or repository-local configuration. 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. diff --git a/docs/development/server-runtime-auth.md b/docs/development/server-runtime-auth.md index 5345e560..fd30919c 100644 --- a/docs/development/server-runtime-auth.md +++ b/docs/development/server-runtime-auth.md @@ -153,11 +153,7 @@ For repository builds: cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787 ``` -If the Server DB has no workspace record yet, initialize it first: - -```bash -yoi-server init --workspace -``` +An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration. ## Smoke checks diff --git a/resources/workspace-backend.default.toml b/resources/workspace-backend.default.toml deleted file mode 100644 index a54540ad..00000000 --- a/resources/workspace-backend.default.toml +++ /dev/null @@ -1,61 +0,0 @@ -# Workspace Backend local config template. -# -# `yoi-server init` copies this packaged template to -# `.yoi/workspace-backend.local.toml` without overwriting an existing file. -# The `.local` file is intentionally git-ignored. -# -# Print the latest packaged template with: -# yoi-server config default -# -# Compare the local config with the latest packaged template with: -# yoi-server config diff -# -# Omit a key to use the built-in fallback. TOML has no `null`, so optional -# settings are represented by leaving the key commented out. - -[server] -# Backend HTTP/WebSocket listen address. -listen = "127.0.0.1:8787" - -# 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" - -[data] -# Workspace-scoped runtime/data root override. Leave commented to use the user-data fallback: -# /server/workspaces// -# Relative paths are resolved from the workspace root. -# root = ".yoi/workspace-backend.data" - -# Explicit control-plane SQLite DB path override. Normal `serve` uses the Yoi -# server DB at `/server/server.db`; keep this commented unless a -# local test needs a custom DB path. -# workspace_database_path = ".yoi/workspace-backend.data/server.db" - -# Explicit embedded Runtime fs-store root override. -# If omitted, this falls back to `/embedded-runtime`. -# embedded_runtime_store_root = ".yoi/workspace-backend.data/embedded-runtime" - -[limits] -max_records = 200 - -[auth] -# 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 -# entries and never falls back to the backend process cwd. Relative URI values -# are resolved from this workspace config root. Git is the v0 supported provider. -# -# [[repositories]] -# id = "main" -# provider = "git" -# uri = "." -# display_name = "Main repository" -# default_selector = "HEAD" - -# Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml` -# or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config.