From d3c29bc118aa69f56f65f10131dcacd459d4a5e3 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:17:56 +0530 Subject: [PATCH 01/34] refactor!: rewrite config system with strict TOML and runtime modes Replace figment-based config with strict config.toml/secrets.toml pair. Introduces RuntimePaths with portable/installed modes, BootstrapConfig for TOML parsing, domain-separated key derivation from master_key, and removes env/CLI overrides. Removes dotenvy, figment, directories deps. --- Cargo.lock | 205 +-------- apps/backend/Cargo.toml | 5 +- apps/backend/src/config.rs | 865 ++++++++++-------------------------- apps/backend/src/lib.rs | 2 + apps/backend/src/paths.rs | 468 +++++++------------ apps/backend/src/runtime.rs | 38 ++ apps/backend/src/secrets.rs | 188 +++++--- apps/backend/src/tracing.rs | 46 +- 8 files changed, 573 insertions(+), 1244 deletions(-) create mode 100644 apps/backend/src/runtime.rs diff --git a/Cargo.lock b/Cargo.lock index 2690fab2..f2d872e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,15 +347,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -801,10 +792,7 @@ dependencies = [ "cookie", "croner", "dashmap", - "directories", - "dotenvy", "email_address", - "figment", "futures-util", "hex", "hmac", @@ -835,7 +823,7 @@ dependencies = [ "tokio-stream", "tokio-test", "tokio-util", - "toml 1.1.2+spec-1.1.0", + "toml", "tonic", "tonic-build", "tonic-health", @@ -1280,27 +1268,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -1470,20 +1437,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "figment" -version = "0.10.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" -dependencies = [ - "atomic", - "pear", - "serde", - "toml 0.8.23", - "uncased", - "version_check", -] - [[package]] name = "filetime" version = "0.2.29" @@ -2200,12 +2153,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - [[package]] name = "inout" version = "0.2.2" @@ -2391,15 +2338,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "libc", -] - [[package]] name = "libsqlite3-sys" version = "0.37.0" @@ -2822,12 +2760,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "ordered-float" version = "5.3.0" @@ -2893,29 +2825,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pear" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3086,7 +2995,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit", ] [[package]] @@ -3098,19 +3007,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", - "yansi", -] - [[package]] name = "profiling" version = "1.0.18" @@ -3442,17 +3338,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -3916,15 +3801,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -4751,18 +4627,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -4771,20 +4635,11 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.3", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "winnow", ] [[package]] @@ -4796,20 +4651,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -4817,9 +4658,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.3", + "winnow", ] [[package]] @@ -4828,15 +4669,9 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -5158,15 +4993,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - [[package]] name = "unicase" version = "2.9.0" @@ -5750,15 +5576,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.3" @@ -5827,12 +5644,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" version = "0.8.3" diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 6f1a095f..3358057b 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -36,7 +36,6 @@ chrono = { version = "0.4", features = ["serde"] } bcrypt = "0.19" utoipa = { version = "5", features = ["axum_extras"] } utoipa-scalar = { version = "0.3", features = ["axum"] } -dotenvy = "0.15" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "avif"] } object_store = { version = "0.14", features = ["aws"] } bytes = "1" @@ -71,10 +70,8 @@ rmcp = { version = "2.0", features = ["server", "transport-io", "transport-strea tokio-util = { version = "0.7", features = ["rt"] } tokio-stream = { version = "0.1", features = ["net"] } schemars = "1" -clap = { version = "4.6.1", features = ["derive", "env"] } -figment = { version = "0.10.19", features = ["toml", "env"] } +clap = { version = "4.6.1", features = ["derive"] } toml = "1.1.2" -directories = "6.0.0" zstd = "0.13" tar = "0.4" croner = "3" diff --git a/apps/backend/src/config.rs b/apps/backend/src/config.rs index b5865f0c..33cfc5cb 100644 --- a/apps/backend/src/config.rs +++ b/apps/backend/src/config.rs @@ -1,35 +1,82 @@ -use std::path::PathBuf; +use std::io::Write; -use figment::{ - Figment, - providers::{Env, Format, Serialized, Toml}, -}; -use serde::{Deserialize, Deserializer}; +use serde::{Deserialize, Serialize}; -use crate::cli::Cli; -use crate::paths; +use crate::paths::RuntimePaths; use crate::secrets; -#[derive(Clone, Debug, Default)] +pub const DEFAULT_LOG_LEVEL: &str = "cms=info,vcms=info"; +pub const DEFAULT_MAX_UPLOAD_BYTES: usize = 50 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BootstrapConfig { + pub server: ServerConfig, + pub log: LogConfig, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ServerConfig { + pub http_address: String, + pub grpc_address: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LogConfig { + pub level: String, + pub output: LogOutput, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LogOutput { + File, + Stdout, +} + +impl std::fmt::Display for LogOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::File => "file", + Self::Stdout => "stdout", + }) + } +} + +impl Default for BootstrapConfig { + fn default() -> Self { + Self { + server: ServerConfig { + http_address: "127.0.0.1:3000".to_owned(), + grpc_address: "127.0.0.1:50051".to_owned(), + }, + log: LogConfig { + level: DEFAULT_LOG_LEVEL.to_owned(), + output: LogOutput::File, + }, + } + } +} + +/// Runtime compatibility carrier. Values no longer come from environment or CLI; +/// bootstrap values come from `config.toml`, secrets from `secrets.toml`, and the +/// remaining fields are fixed defaults until DB-backed instance settings load. +#[derive(Clone, Debug)] pub struct Config { pub database_url: String, pub bind_address: String, pub grpc_bind_address: String, - - // Filesystem storage pub storage_fs_path: Option, - - // S3-compatible storage pub s3_access_key_id: Option, pub s3_secret_access_key: Option, pub s3_bucket: Option, pub s3_region: Option, pub s3_endpoint: Option, pub s3_public_url: Option, - - // Backup destination + options (independent of the site-files storage above) pub backup_enabled: bool, - pub backup_destination: String, // "filesystem" | "s3" + pub backup_destination: String, pub backup_local_path: Option, pub backup_zstd_level: i32, pub backup_default_retention: i64, @@ -39,213 +86,108 @@ pub struct Config { pub backup_s3_region: Option, pub backup_s3_endpoint: Option, pub backup_s3_public_url: Option, - /// AES-256 backup key (hex). From `BACKUP_ENCRYPTION_KEY` or persisted secrets. pub backup_encryption_key: Option, - - // Upload limits pub max_upload_size_bytes: usize, - /// Lifetime of signed upload URLs (seconds). pub upload_token_expiry_secs: i64, - - // Cookie security pub cookie_secure: bool, pub session_lifetime_hours: i64, pub public_registration_enabled: bool, pub allowed_origins: Vec, pub production: bool, - - // Database pool pub db_max_connections: u32, pub db_min_connections: u32, pub db_acquire_timeout_secs: u64, pub db_idle_timeout_secs: u64, - - // Rate limiting pub rate_limit_max_requests: u32, pub rate_limit_window_secs: u64, - - // Password hashing cost (bcrypt work factor) pub bcrypt_cost: u32, - // Trust reverse-proxy client-IP headers (X-Forwarded-For / X-Real-IP) pub trust_proxy_headers: bool, - // Allow webhooks to target private/loopback addresses (SSRF guard off) pub webhook_allow_private_targets: bool, - - // Hash secret for access token fast lookup - pub hmac_secret: String, - - // Full-text search (Tantivy) + pub token_index_key: String, + pub session_auth_key: String, + pub signed_upload_key: String, + pub webhook_encryption_key: String, pub search_enabled: bool, pub search_index_path: Option, - - // MCP configuration pub mcp_enabled: bool, pub mcp_allowed_hosts: Vec, pub mcp_allowed_origins: Vec, pub public_url: Option, - - // Logging pub log_level: String, pub log_output: String, - pub log_format: String, - pub log_annotations: bool, pub log_dir: String, } -// `cms` is the library crate (handlers/services/etc.); `vcms` is the binary crate -// (main.rs lifecycle/startup logs). Both must be enabled to see all of our logs. -static DEFAULT_LOG_LEVEL: &str = "cms=debug,vcms=debug,tower_http=debug,axum=debug"; - -/// Intermediate, fully-optional config deserialized from the merged figment -/// layers (defaults < TOML file < env vars < CLI flags). Converted into the -/// runtime [`Config`] by [`RawConfig::into_config`], which applies hardcoded -/// defaults and the secret-warning behavior. -#[derive(Debug, Default, Deserialize)] -struct RawConfig { - database_url: Option, - hmac_secret: Option, - bind_address: Option, - grpc_bind_address: Option, - - storage_fs_path: Option, - - s3_access_key_id: Option, - s3_secret_access_key: Option, - s3_bucket: Option, - s3_region: Option, - s3_endpoint: Option, - s3_public_url: Option, - - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - backup_enabled: Option, - backup_destination: Option, - backup_local_path: Option, - backup_zstd_level: Option, - backup_default_retention: Option, - backup_s3_access_key_id: Option, - backup_s3_secret_access_key: Option, - backup_s3_bucket: Option, - backup_s3_region: Option, - backup_s3_endpoint: Option, - backup_s3_public_url: Option, - backup_encryption_key: Option, - - max_upload_size_mb: Option, - upload_token_expiry_secs: Option, - - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - cookie_secure: Option, - session_lifetime_hours: Option, - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - public_registration_enabled: Option, - #[serde(default, deserialize_with = "de_opt_str_list")] - allowed_origins: Option>, - /// Maps the `VCMS_ENV` env var / `env` TOML key; "production" => production. - env: Option, - - db_max_connections: Option, - db_min_connections: Option, - db_acquire_timeout_secs: Option, - db_idle_timeout_secs: Option, - - rate_limit_max_requests: Option, - rate_limit_window_secs: Option, - - bcrypt_cost: Option, - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - trust_proxy_headers: Option, - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - webhook_allow_private_targets: Option, - - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - search_enabled: Option, - search_index_path: Option, - - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - mcp_enabled: Option, - #[serde(default, deserialize_with = "de_opt_str_list")] - mcp_allowed_hosts: Option>, - #[serde(default, deserialize_with = "de_opt_str_list")] - mcp_allowed_origins: Option>, - public_url: Option, - - log: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct RawLog { - level: Option, - output: Option, - format: Option, - #[serde(default, deserialize_with = "de_opt_lenient_bool")] - annotations: Option, - dir: Option, -} - impl Config { - /// Load configuration by merging, in increasing precedence: - /// built-in defaults < config file < environment variables < CLI flags. - pub fn load(cli: &Cli) -> Result> { - let mut figment = Figment::new(); - - // Lowest-precedence secret layer: persisted HMAC secret from the config dir's - // `secrets.toml`. Read-only here (generation happens in - // `secrets::ensure()` during serve/admin). Best-effort: a missing or - // unreadable file just leaves the built-in defaults in play. Env vars - // and CLI flags still override these. - if let Ok(Some(persisted)) = secrets::load() { - figment = figment.merge(Serialized::defaults(persisted)); - } - - if let Some(path) = resolve_config_path(cli) { - figment = figment.merge(Toml::file(path)); - } - - // Environment layer: keep the existing unprefixed names. Remap the few - // that don't match a field 1:1 (legacy log vars, VCMS_ENV) and project - // log keys into the nested `log` table via the "." separator. - figment = figment.merge( - Env::raw() - .map(|key| { - let k = key.as_str().to_ascii_lowercase(); - match k.as_str() { - "rust_log" => "log.level".into(), - "log_output" => "log.output".into(), - "log_format" => "log.format".into(), - "log_annotations" => "log.annotations".into(), - "log_dir" => "log.dir".into(), - "vcms_env" => "env".into(), - _ => k.into(), - } - }) - .split("."), - ); - - // CLI flag layer: highest precedence, only for flags the user passed. - if let Some(v) = &cli.bind { - figment = figment.merge(Serialized::default("bind_address", v)); - } - if let Some(v) = &cli.database_url { - figment = figment.merge(Serialized::default("database_url", v)); - } - if let Some(v) = &cli.log_level { - figment = figment.merge(Serialized::default("log.level", v)); - } - - figment.extract::().map_err(Box::new)?.into_config() - } + pub fn load(paths: &RuntimePaths) -> Result> { + let bootstrap = load_bootstrap(paths)?; + let persisted = secrets::load(paths)?.ok_or_else(|| { + format!( + "{} is missing; run `vcms serve` for a fresh instance", + paths.secrets_file().display() + ) + })?; + validate_bootstrap(&bootstrap)?; - /// Convenience for callers that only want env + defaults (no CLI flags). - pub fn from_env() -> Self { - dotenvy::dotenv().ok(); - Self::load(&Cli::default()).expect("Failed to load configuration") + Ok(Self { + database_url: persisted.database_url.unwrap_or_else(|| paths.database_url()), + bind_address: bootstrap.server.http_address, + grpc_bind_address: bootstrap.server.grpc_address, + storage_fs_path: Some(paths.storage_dir().to_string_lossy().into_owned()), + s3_access_key_id: None, + s3_secret_access_key: None, + s3_bucket: None, + s3_region: None, + s3_endpoint: None, + s3_public_url: None, + backup_enabled: true, + backup_destination: "filesystem".to_owned(), + backup_local_path: Some(paths.backups_dir().to_string_lossy().into_owned()), + backup_zstd_level: 12, + backup_default_retention: 7, + backup_s3_access_key_id: None, + backup_s3_secret_access_key: None, + backup_s3_bucket: None, + backup_s3_region: None, + backup_s3_endpoint: None, + backup_s3_public_url: None, + backup_encryption_key: Some(persisted.backup_encryption_key), + max_upload_size_bytes: DEFAULT_MAX_UPLOAD_BYTES, + upload_token_expiry_secs: crate::signed_upload::DEFAULT_UPLOAD_TOKEN_EXPIRY_SECS, + cookie_secure: false, + session_lifetime_hours: 24, + public_registration_enabled: false, + allowed_origins: Vec::new(), + production: false, + db_max_connections: 10, + db_min_connections: 2, + db_acquire_timeout_secs: 30, + db_idle_timeout_secs: 600, + rate_limit_max_requests: 100, + rate_limit_window_secs: 60, + bcrypt_cost: bcrypt::DEFAULT_COST, + trust_proxy_headers: false, + webhook_allow_private_targets: false, + token_index_key: secrets::derive_key_hex(&persisted.master_key, "access-token-index"), + session_auth_key: secrets::derive_key_hex(&persisted.master_key, "dashboard-session-auth"), + signed_upload_key: secrets::derive_key_hex(&persisted.master_key, "signed-upload-auth"), + webhook_encryption_key: secrets::derive_key_hex(&persisted.master_key, "webhook-encryption"), + search_enabled: true, + search_index_path: Some(paths.search_dir().to_string_lossy().into_owned()), + mcp_enabled: true, + mcp_allowed_hosts: default_mcp_allowed_hosts(), + mcp_allowed_origins: Vec::new(), + public_url: None, + log_level: bootstrap.log.level, + log_output: bootstrap.log.output.to_string(), + log_dir: paths.logs_dir().to_string_lossy().into_owned(), + }) } pub fn has_s3(&self) -> bool { self.s3_access_key_id.is_some() && self.s3_secret_access_key.is_some() && self.s3_bucket.is_some() } - /// Whether a dedicated S3 backup destination is fully configured. pub fn has_backup_s3(&self) -> bool { self.backup_s3_access_key_id.is_some() && self.backup_s3_secret_access_key.is_some() @@ -253,477 +195,148 @@ impl Config { } pub fn validate_security(&self) -> Result<(), String> { - // The secret is always real here: config load hard-errors when no - // secret is resolved, so there is no built-in placeholder to guard - // against. We only enforce strength/cookie policy in production. - if self.production { - if self.hmac_secret.len() < 32 { - return Err("HMAC_SECRET must be at least 32 bytes long".into()); - } - if !self.cookie_secure { - return Err("COOKIE_SECURE must be enabled in production".into()); - } - } - Ok(()) } - /// Render the effective configuration as TOML with secrets redacted. - /// Used by `vcms config show`. - pub fn redacted_toml(&self) -> String { + pub fn redacted_toml(&self, paths: &RuntimePaths) -> String { + let db_kind = if self.database_url.starts_with("postgres") { + "postgres" + } else if self.database_url.starts_with("mysql") { + "mysql" + } else { + "sqlite" + }; format!( - "# Effective configuration (secrets redacted)\n\ - database_url = \"{}\"\n\ - hmac_secret = \"{}\"\n\ - bind_address = \"{}\"\n\ - grpc_bind_address = \"{}\"\n\ - public_url = {}\n\ - env = \"{}\"\n\n\ - # Storage\n\ - storage_fs_path = {}\n\ - s3_access_key_id = {}\n\ - s3_secret_access_key = {}\n\ - s3_bucket = {}\n\ - s3_region = {}\n\ - s3_endpoint = {}\n\ - s3_public_url = {}\n\n\ - # Uploads\n\ - max_upload_size_mb = {}\n\ - upload_token_expiry_secs = {}\n\n\ - # Security / sessions\n\ - cookie_secure = {}\n\ - session_lifetime_hours = {}\n\ - public_registration_enabled = {}\n\ - allowed_origins = {}\n\n\ - # Database pool\n\ - db_max_connections = {}\n\ - db_min_connections = {}\n\ - db_acquire_timeout_secs = {}\n\ - db_idle_timeout_secs = {}\n\n\ - # Rate limiting\n\ - rate_limit_max_requests = {}\n\ - rate_limit_window_secs = {}\n\ - trust_proxy_headers = {}\n\n\ - # Password hashing\n\ - bcrypt_cost = {}\n\n\ - # MCP\n\ - mcp_enabled = {}\n\ - mcp_allowed_hosts = {}\n\ - mcp_allowed_origins = {}\n\n\ - [log]\n\ - level = \"{}\"\n\ - output = \"{}\"\n\ - format = \"{}\"\n\ - annotations = {}\n\ - dir = \"{}\"\n", - self.database_url, - redact(&self.hmac_secret), + "mode = \"{}\"\nroot = \"{}\"\ndatabase = \"{} (configured)\"\n\n[secrets]\nmaster_key_present = true\nbackup_encryption_key_present = {}\ndatabase_url_present = {}\n\n[server]\nhttp_address = \"{}\"\ngrpc_address = \"{}\"\n\n[log]\nlevel = \"{}\"\noutput = \"{}\"\npath = \"{}\"\n", + paths.mode(), + paths.root().display(), + db_kind, + self.backup_encryption_key.is_some(), + !self.database_url.starts_with("sqlite://"), self.bind_address, self.grpc_bind_address, - opt_str(&self.public_url), - if self.production { "production" } else { "development" }, - opt_str(&self.storage_fs_path), - opt_secret(&self.s3_access_key_id), - opt_secret(&self.s3_secret_access_key), - opt_str(&self.s3_bucket), - opt_str(&self.s3_region), - opt_str(&self.s3_endpoint), - opt_str(&self.s3_public_url), - self.max_upload_size_bytes / (1024 * 1024), - self.upload_token_expiry_secs, - self.cookie_secure, - self.session_lifetime_hours, - self.public_registration_enabled, - toml_list(&self.allowed_origins), - self.db_max_connections, - self.db_min_connections, - self.db_acquire_timeout_secs, - self.db_idle_timeout_secs, - self.rate_limit_max_requests, - self.rate_limit_window_secs, - self.trust_proxy_headers, - self.bcrypt_cost, - self.mcp_enabled, - toml_list(&self.mcp_allowed_hosts), - toml_list(&self.mcp_allowed_origins), self.log_level, self.log_output, - self.log_format, - self.log_annotations, - self.log_dir, + paths.logs_dir().display(), ) } } -impl RawConfig { - fn into_config(self) -> Result> { - // No silent placeholder: a secret must come from secrets.toml, config, or - // the HMAC_SECRET env var. `serve`/`admin` generate one via - // `secrets::ensure()` and `mcp stdio` guards on its presence, so this only - // fires on a genuinely uninitialized instance. - let hmac_secret = self.hmac_secret.ok_or_else(|| { - Box::new(figment::Error::from(format!( - "No HMAC secret resolved; run `vcms serve` once to generate {}, or set HMAC_SECRET", - paths::secrets_file().display(), - ))) - })?; - - let log = self.log.unwrap_or_default(); - - let upload_token_expiry_secs = self - .upload_token_expiry_secs - .unwrap_or(crate::signed_upload::DEFAULT_UPLOAD_TOKEN_EXPIRY_SECS); - if upload_token_expiry_secs <= 0 { - return Err(Box::new(figment::Error::from(format!( - "upload_token_expiry_secs must be positive (got {upload_token_expiry_secs})" - )))); +impl Default for Config { + fn default() -> Self { + let paths = RuntimePaths::portable("."); + let bootstrap = BootstrapConfig::default(); + let persisted = secrets::fresh(None); + Self { + database_url: paths.database_url(), + bind_address: bootstrap.server.http_address, + grpc_bind_address: bootstrap.server.grpc_address, + storage_fs_path: Some(paths.storage_dir().to_string_lossy().into_owned()), + s3_access_key_id: None, + s3_secret_access_key: None, + s3_bucket: None, + s3_region: None, + s3_endpoint: None, + s3_public_url: None, + backup_enabled: true, + backup_destination: "filesystem".to_owned(), + backup_local_path: Some(paths.backups_dir().to_string_lossy().into_owned()), + backup_zstd_level: 12, + backup_default_retention: 7, + backup_s3_access_key_id: None, + backup_s3_secret_access_key: None, + backup_s3_bucket: None, + backup_s3_region: None, + backup_s3_endpoint: None, + backup_s3_public_url: None, + backup_encryption_key: Some(persisted.backup_encryption_key), + max_upload_size_bytes: DEFAULT_MAX_UPLOAD_BYTES, + upload_token_expiry_secs: crate::signed_upload::DEFAULT_UPLOAD_TOKEN_EXPIRY_SECS, + cookie_secure: false, + session_lifetime_hours: 24, + public_registration_enabled: false, + allowed_origins: Vec::new(), + production: false, + db_max_connections: 10, + db_min_connections: 2, + db_acquire_timeout_secs: 30, + db_idle_timeout_secs: 600, + rate_limit_max_requests: 100, + rate_limit_window_secs: 60, + bcrypt_cost: bcrypt::DEFAULT_COST, + trust_proxy_headers: false, + webhook_allow_private_targets: false, + token_index_key: secrets::derive_key_hex(&persisted.master_key, "access-token-index"), + session_auth_key: secrets::derive_key_hex(&persisted.master_key, "dashboard-session-auth"), + signed_upload_key: secrets::derive_key_hex(&persisted.master_key, "signed-upload-auth"), + webhook_encryption_key: secrets::derive_key_hex(&persisted.master_key, "webhook-encryption"), + search_enabled: true, + search_index_path: Some(paths.search_dir().to_string_lossy().into_owned()), + mcp_enabled: true, + mcp_allowed_hosts: default_mcp_allowed_hosts(), + mcp_allowed_origins: Vec::new(), + public_url: None, + log_level: bootstrap.log.level, + log_output: bootstrap.log.output.to_string(), + log_dir: paths.logs_dir().to_string_lossy().into_owned(), } - - Ok(Config { - database_url: self.database_url.unwrap_or_else(paths::default_database_url), - hmac_secret, - bind_address: self.bind_address.unwrap_or_else(|| "0.0.0.0:3000".into()), - grpc_bind_address: self.grpc_bind_address.unwrap_or_else(|| "0.0.0.0:50051".into()), - storage_fs_path: self.storage_fs_path, - s3_access_key_id: self.s3_access_key_id, - s3_secret_access_key: self.s3_secret_access_key, - s3_bucket: self.s3_bucket, - s3_region: self.s3_region, - s3_endpoint: self.s3_endpoint, - s3_public_url: self.s3_public_url, - backup_enabled: self.backup_enabled.unwrap_or(true), - backup_destination: self.backup_destination.unwrap_or_else(|| "filesystem".into()), - backup_local_path: self.backup_local_path, - backup_zstd_level: self.backup_zstd_level.unwrap_or(12), - backup_default_retention: self.backup_default_retention.unwrap_or(7), - backup_s3_access_key_id: self.backup_s3_access_key_id, - backup_s3_secret_access_key: self.backup_s3_secret_access_key, - backup_s3_bucket: self.backup_s3_bucket, - backup_s3_region: self.backup_s3_region, - backup_s3_endpoint: self.backup_s3_endpoint, - backup_s3_public_url: self.backup_s3_public_url, - backup_encryption_key: self.backup_encryption_key, - max_upload_size_bytes: self.max_upload_size_mb.unwrap_or(50) * 1024 * 1024, - upload_token_expiry_secs, - cookie_secure: self.cookie_secure.unwrap_or(false), - session_lifetime_hours: self.session_lifetime_hours.unwrap_or(24), - public_registration_enabled: self.public_registration_enabled.unwrap_or(false), - allowed_origins: self.allowed_origins.unwrap_or_default(), - production: self.env.map(|v| v.eq_ignore_ascii_case("production")).unwrap_or(false), - db_max_connections: self.db_max_connections.unwrap_or(20), - db_min_connections: self.db_min_connections.unwrap_or(2), - db_acquire_timeout_secs: self.db_acquire_timeout_secs.unwrap_or(30), - db_idle_timeout_secs: self.db_idle_timeout_secs.unwrap_or(600), - rate_limit_max_requests: self.rate_limit_max_requests.unwrap_or(100), - rate_limit_window_secs: self.rate_limit_window_secs.unwrap_or(60), - bcrypt_cost: self.bcrypt_cost.unwrap_or(bcrypt::DEFAULT_COST), - trust_proxy_headers: self.trust_proxy_headers.unwrap_or(false), - webhook_allow_private_targets: self.webhook_allow_private_targets.unwrap_or(false), - search_enabled: self.search_enabled.unwrap_or(true), - search_index_path: self.search_index_path, - mcp_enabled: self.mcp_enabled.unwrap_or(true), - mcp_allowed_hosts: self.mcp_allowed_hosts.unwrap_or_else(default_mcp_allowed_hosts), - mcp_allowed_origins: self.mcp_allowed_origins.unwrap_or_default(), - public_url: self.public_url.map(|v| v.trim_end_matches('/').to_string()), - log_level: log.level.unwrap_or_else(|| DEFAULT_LOG_LEVEL.to_string()), - log_output: log.output.unwrap_or_else(|| "stdout".into()), - log_format: log.format.unwrap_or_else(|| "pretty".into()), - log_annotations: log.annotations.unwrap_or(false), - log_dir: log - .dir - .unwrap_or_else(|| paths::logs_dir().to_string_lossy().into_owned()), - }) - } -} - -/// Resolve the config file path. First match wins; a missing file is fine. -/// Order: `--config` / `VCMS_CONFIG` > `./vcms.toml` > user config dir > `/etc/vcms`. -pub fn resolve_config_path(cli: &Cli) -> Option { - if let Some(path) = &cli.config { - return Some(path.clone()); } - config_search_paths().into_iter().find(|candidate| candidate.exists()) } -/// The ordered list of locations searched when no `--config` is given. -pub fn config_search_paths() -> Vec { - let mut paths = vec![PathBuf::from("vcms.toml")]; - if let Some(path) = user_config_path() { - paths.push(path); +pub fn ensure_bootstrap(paths: &RuntimePaths) -> Result<(), Box> { + let path = paths.config_file(); + if path.exists() { + load_bootstrap(paths)?; + return Ok(()); } - paths.push(PathBuf::from("/etc/vcms/config.toml")); - paths + let body = toml::to_string_pretty(&BootstrapConfig::default())?; + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?; + file.write_all(body.as_bytes())?; + file.sync_all()?; + Ok(()) } -/// The user-config location for the config file: the platform config dir -/// (`config.toml`), or `$VCMS_HOME/config.toml` in single-dir mode. -pub fn user_config_path() -> Option { - Some(paths::config_file()) +pub fn load_bootstrap(paths: &RuntimePaths) -> Result> { + let raw = std::fs::read_to_string(paths.config_file())?; + let config: BootstrapConfig = toml::from_str(&raw)?; + validate_bootstrap(&config)?; + Ok(config) } -/// The scaffold written by `vcms config init` — non-secret keys only, with -/// secrets shown as commented env-var hints. -pub fn default_config_toml() -> String { - let hosts = default_mcp_allowed_hosts() - .iter() - .map(|h| format!("\"{h}\"")) - .collect::>() - .join(", "); - format!( - "# CMS configuration. Non-secret settings live here; secrets stay in the\n\ - # environment (or a .env file). Precedence: CLI flag > env var > this file.\n\ - #\n\ - # Secrets are NOT read from this file by convention — set them via env:\n\ - # DATABASE_URL, HMAC_SECRET,\n\ - # S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY\n\n\ - bind_address = \"0.0.0.0:3000\"\n\ - grpc_bind_address = \"0.0.0.0:50051\"\n\ - # public_url = \"https://cms.example.com\"\n\ - # env = \"production\"\n\n\ - # --- Storage (filesystem) ---\n\ - # storage_fs_path = \"./storage\"\n\n\ - # --- Storage (S3, non-secret parts) ---\n\ - # s3_bucket = \"my-bucket\"\n\ - # s3_region = \"us-east-1\"\n\ - # s3_endpoint = \"https://s3.example.com\"\n\ - # s3_public_url = \"https://cdn.example.com\"\n\n\ - # --- Uploads ---\n\ - max_upload_size_mb = 50\n\ - # Signed upload URL lifetime (seconds)\n\ - upload_token_expiry_secs = {default_upload_expiry}\n\n\ - # --- Security / sessions ---\n\ - cookie_secure = false\n\ - session_lifetime_hours = 24\n\ - public_registration_enabled = false\n\ - allowed_origins = []\n\n\ - # --- Database pool ---\n\ - db_max_connections = 10\n\ - db_min_connections = 2\n\ - db_acquire_timeout_secs = 30\n\ - db_idle_timeout_secs = 600\n\n\ - # --- Rate limiting ---\n\ - rate_limit_max_requests = 100\n\ - rate_limit_window_secs = 60\n\ - # Trust X-Forwarded-For / X-Real-IP for client IP (enable only behind a trusted proxy)\n\ - trust_proxy_headers = false\n\n\ - # --- Password hashing ---\n\ - bcrypt_cost = 12\n\n\ - # --- Webhooks ---\n\ - # Allow webhooks to target private/loopback addresses (SSRF guard off)\n\ - webhook_allow_private_targets = false\n\n\ - # --- Backups ---\n\ - # Run the scheduled-backup poller and allow on-demand backups.\n\ - backup_enabled = true\n\ - # Destination for backup artifacts: \"filesystem\" (default, the data dir's backups/)\n\ - # or \"s3\". S3 credentials are secrets — set them via env (see below).\n\ - backup_destination = \"filesystem\"\n\ - # backup_local_path = \"./backups\"\n\ - backup_zstd_level = 12\n\ - backup_default_retention = 7\n\ - # S3 backup destination (non-secret parts; keep it in a SEPARATE account/bucket\n\ - # from your site-files S3 for blast-radius isolation):\n\ - # backup_s3_bucket = \"my-cms-backups\"\n\ - # backup_s3_region = \"us-east-1\"\n\ - # backup_s3_endpoint = \"https://s3.example.com\"\n\ - # Secrets via env: BACKUP_S3_ACCESS_KEY_ID, BACKUP_S3_SECRET_ACCESS_KEY,\n\ - # and BACKUP_ENCRYPTION_KEY (else auto-generated into secrets.toml).\n\n\ - # --- Full-text search (Tantivy) ---\n\ - # Build a local inverted index so entry search is ranked + tokenized.\n\ - # When false, search falls back to a basic SQL LIKE match.\n\ - search_enabled = true\n\ - # search_index_path = \"./search\" # defaults to the cache dir's search/\n\n\ - # --- MCP ---\n\ - mcp_enabled = true\n\ - mcp_allowed_hosts = [{hosts}]\n\ - mcp_allowed_origins = []\n\n\ - # --- Logging ---\n\ - [log]\n\ - level = \"{DEFAULT_LOG_LEVEL}\"\n\ - output = \"stdout\" # stdout | file\n\ - format = \"pretty\" # pretty | json\n\ - annotations = false # include file + line numbers\n\ - dir = \"logs\" # used when output = \"file\"\n", - default_upload_expiry = crate::signed_upload::DEFAULT_UPLOAD_TOKEN_EXPIRY_SECS, - ) +fn validate_bootstrap(config: &BootstrapConfig) -> Result<(), Box> { + config + .server + .http_address + .parse::() + .map_err(|error| format!("server.http_address is invalid: {error}"))?; + config + .server + .grpc_address + .parse::() + .map_err(|error| format!("server.grpc_address is invalid: {error}"))?; + tracing_subscriber::EnvFilter::try_new(&config.log.level) + .map_err(|error| format!("log.level is invalid: {error}"))?; + Ok(()) } fn default_mcp_allowed_hosts() -> Vec { vec![ - "localhost".to_string(), - "127.0.0.1".to_string(), - "[::1]".to_string(), - "localhost:3000".to_string(), - "127.0.0.1:3000".to_string(), - "[::1]:3000".to_string(), + "localhost".to_owned(), + "127.0.0.1".to_owned(), + "[::1]".to_owned(), + "localhost:3000".to_owned(), + "127.0.0.1:3000".to_owned(), + "[::1]:3000".to_owned(), ] } -/// Accepts booleans as `true`/`false`, integers (`0`/`1`), or strings -/// (`"true"`/`"1"`) for env back-compat. -fn de_opt_lenient_bool<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum Lenient { - Bool(bool), - Int(i64), - Str(String), - } - Ok(match Option::::deserialize(deserializer)? { - None => None, - Some(Lenient::Bool(b)) => Some(b), - Some(Lenient::Int(i)) => Some(i != 0), - Some(Lenient::Str(s)) => Some(matches!(s.to_ascii_lowercase().as_str(), "1" | "true")), - }) -} - -/// Accepts a list either as a TOML array or as a comma-separated string -/// (env back-compat with the previous CSV parsing). -fn de_opt_str_list<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum List { - Vec(Vec), - Str(String), - } - Ok(match Option::::deserialize(deserializer)? { - None => None, - Some(List::Vec(v)) => Some(v), - Some(List::Str(s)) => Some( - s.split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .collect(), - ), - }) -} - -fn redact(secret: &str) -> String { - if secret.is_empty() { - "".into() - } else { - "***redacted***".into() - } -} - -fn opt_str(value: &Option) -> String { - match value { - Some(v) => format!("\"{v}\""), - None => "\"\"".to_string(), - } -} - -fn opt_secret(value: &Option) -> String { - match value { - Some(_) => "\"***redacted***\"".to_string(), - None => "\"\"".to_string(), - } -} - -fn toml_list(values: &[String]) -> String { - let inner = values.iter().map(|v| format!("\"{v}\"")).collect::>().join(", "); - format!("[{inner}]") -} - #[cfg(test)] mod tests { use super::*; - fn s3_config() -> Config { - Config { - s3_access_key_id: Some("key".to_string()), - s3_secret_access_key: Some("secret".to_string()), - s3_bucket: Some("bucket".to_string()), - s3_region: Some("us-east-1".to_string()), - ..Config::default() - } - } - #[test] - fn test_has_s3_returns_true_when_all_s3_fields_are_set() { - assert!(s3_config().has_s3()); - } - - #[test] - fn test_has_s3_returns_false_when_access_key_id_is_missing() { - let config = Config { - s3_access_key_id: None, - ..s3_config() - }; - assert!(!config.has_s3()); - } - - #[test] - fn test_has_s3_returns_false_when_secret_is_missing() { - let config = Config { - s3_secret_access_key: None, - ..s3_config() - }; - assert!(!config.has_s3()); - } - - #[test] - fn test_has_s3_returns_false_when_bucket_is_missing() { - let config = Config { - s3_bucket: None, - ..s3_config() - }; - assert!(!config.has_s3()); - } - - #[test] - fn test_config_default_values() { - let config = Config { - storage_fs_path: Some("/tmp/storage".to_string()), - max_upload_size_bytes: 50 * 1024 * 1024, - cookie_secure: true, - ..Config::default() - }; - - assert!(!config.has_s3()); - assert_eq!(config.max_upload_size_bytes, 50 * 1024 * 1024); - assert!(config.cookie_secure); - } - - #[test] - fn test_raw_config_into_config_applies_defaults() { - let config = RawConfig { - hmac_secret: Some("test-secret".to_string()), - ..RawConfig::default() - } - .into_config() - .expect("a supplied secret should produce a config"); - assert!(config.database_url.starts_with("sqlite://")); - assert!(config.database_url.ends_with("vcms.db")); - assert_eq!(config.bind_address, "0.0.0.0:3000"); - assert_eq!(config.max_upload_size_bytes, 50 * 1024 * 1024); - assert_eq!(config.log_level, DEFAULT_LOG_LEVEL); - assert_eq!(config.log_output, "stdout"); - assert!(config.mcp_enabled); - assert!(!config.production); - } - - #[test] - fn test_into_config_requires_hmac_secret() { - // No secret from any layer => hard error, never a silent placeholder. - assert!(RawConfig::default().into_config().is_err()); - } - - #[test] - fn test_env_maps_to_production() { - let config = RawConfig { - hmac_secret: Some("test-secret".to_string()), - env: Some("production".to_string()), - ..RawConfig::default() - } - .into_config() - .expect("a supplied secret should produce a config"); - assert!(config.production); + fn bootstrap_rejects_unknown_fields() { + let raw = "[server]\nhttp_address='127.0.0.1:3000'\ngrpc_address='127.0.0.1:50051'\nextra=true\n[log]\nlevel='info'\noutput='file'\n"; + assert!(toml::from_str::(raw).is_err()); } } diff --git a/apps/backend/src/lib.rs b/apps/backend/src/lib.rs index 58333589..a64b08c3 100644 --- a/apps/backend/src/lib.rs +++ b/apps/backend/src/lib.rs @@ -43,6 +43,7 @@ pub mod handlers { pub mod entry_handler; pub mod file_handler; pub mod instance_handler; + pub mod settings_handler; pub mod singleton_handler; pub mod site_handler; pub mod webhook_handler; @@ -50,6 +51,7 @@ pub mod handlers { pub mod grpc; pub mod repository; pub mod router; +pub mod runtime; pub mod services; pub mod storage; pub mod utils; diff --git a/apps/backend/src/paths.rs b/apps/backend/src/paths.rs index ec308682..5276bb37 100644 --- a/apps/backend/src/paths.rs +++ b/apps/backend/src/paths.rs @@ -1,365 +1,223 @@ -//! Resolution of where the CMS keeps its runtime files. +//! Deterministic runtime layout. //! -//! There are two layouts: -//! -//! * **Split** (the default for an interactive install) — files land in the -//! platform-conventional per-type directories via the `directories` crate: -//! config/secrets in the config dir, the database/uploads/backups in the data dir, -//! the (rebuildable) search index in the cache dir, and logs in the state dir. -//! On Linux this is XDG (`~/.config/vcms`, `~/.local/share/vcms`, `~/.cache/vcms`, -//! `~/.local/state/vcms`); on macOS `~/Library/Application Support/vcms` (+ Caches); -//! on Windows `%APPDATA%`/`%LOCALAPPDATA%` under `vcms`. -//! -//! * **Single** — everything nests under one root. Chosen when `$VCMS_HOME` is set, -//! the system service home exists, or a legacy `~/.vcms` already holds data. - -use std::path::PathBuf; +//! Portable instances always use `/vcms_data`. Installed instances always +//! use the platform service root. Selection is performed once from native service +//! registration and the resulting paths are passed through startup explicitly. -/// Environment variable that forces the single-directory layout at a chosen root. -pub const CMS_HOME_ENV: &str = "VCMS_HOME"; +use std::path::{Path, PathBuf}; -/// Where each class of file lives. Resolved fresh on each call (cheap) so tests and -/// the service can change `$VCMS_HOME` without a process restart. -enum Layout { - /// One root holding everything (`$VCMS_HOME`, a legacy `~/.vcms`, or a `.vcms` - /// fallback when no platform dirs are detectable). - Single(PathBuf), - /// Per-type platform directories. - Split { - config: PathBuf, - data: PathBuf, - cache: PathBuf, - state: PathBuf, - }, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuntimeMode { + Portable, + Installed, } -impl Layout { - fn config(&self) -> PathBuf { - match self { - Layout::Single(root) => root.clone(), - Layout::Split { config, .. } => config.clone(), - } +impl std::fmt::Display for RuntimeMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Portable => "portable", + Self::Installed => "installed", + }) } +} - fn data(&self) -> PathBuf { - match self { - Layout::Single(root) => root.clone(), - Layout::Split { data, .. } => data.clone(), +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuntimePaths { + mode: RuntimeMode, + root: PathBuf, +} + +impl RuntimePaths { + pub fn portable(cwd: impl AsRef) -> Self { + Self { + mode: RuntimeMode::Portable, + root: cwd.as_ref().join("vcms_data"), } } - fn cache(&self) -> PathBuf { - match self { - Layout::Single(root) => root.clone(), - Layout::Split { cache, .. } => cache.clone(), - } + pub fn installed() -> Result { + Ok(Self { + mode: RuntimeMode::Installed, + root: system_root().ok_or_else(|| "installed mode is unsupported on this platform".to_owned())?, + }) } - fn state(&self) -> PathBuf { - match self { - Layout::Single(root) => root.clone(), - Layout::Split { state, .. } => state.clone(), + pub fn for_mode(mode: RuntimeMode) -> Result { + match mode { + RuntimeMode::Portable => std::env::current_dir() + .map(Self::portable) + .map_err(|error| format!("cannot resolve current directory: {error}")), + RuntimeMode::Installed => Self::installed(), } } -} -/// Conventional system directory an installed OS service owns, per platform. The app -/// does not auto-select this path; service packages pass it through `$VCMS_HOME`. -pub fn system_home() -> Option { - #[cfg(windows)] - { - Some(PathBuf::from(r"C:\ProgramData\vcms")) - } - #[cfg(target_os = "linux")] - { - Some(PathBuf::from("/var/lib/vcms")) - } - #[cfg(target_os = "macos")] - { - Some(PathBuf::from("/Library/Application Support/vcms")) - } - #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] - { - None + pub fn mode(&self) -> RuntimeMode { + self.mode } -} -/// Resolve the active layout: `$VCMS_HOME` → system home → legacy `~/.vcms` → platform split. -/// Gathers the real filesystem/env inputs and defers the precedence decision to the -/// pure [`resolve_layout`] (kept separate so it is testable without touching real -/// system dirs). -fn layout() -> Layout { - let env_home = std::env::var_os(CMS_HOME_ENV) - .filter(|v| !v.is_empty()) - .map(PathBuf::from); - let system = system_home().filter(|path| path.is_dir()); + pub fn root(&self) -> &Path { + &self.root + } - // Back-compat: a pre-existing single `~/.vcms` keeps owning everything so an upgrade - // never strands a user's database or secrets. - let legacy = directories::BaseDirs::new() - .map(|base| base.home_dir().join(".vcms")) - .filter(|p| p.is_dir()); + pub fn config_file(&self) -> PathBuf { + self.root.join("config.toml") + } - let split = directories::ProjectDirs::from("", "", "vcms").map(|dirs| split_from(&dirs)); + pub fn secrets_file(&self) -> PathBuf { + self.root.join("secrets.toml") + } - resolve_layout(env_home, system, legacy, split) -} + pub fn database_file(&self) -> PathBuf { + self.root.join("vcms.db") + } -/// Pure precedence policy. Each `Option` is a candidate the caller has already vetted -/// (env set & non-empty; legacy dir confirmed to exist; split from `ProjectDirs`). -/// Falls back to a local `.vcms` blob when no platform dirs are detectable (rare). -fn resolve_layout( - env_home: Option, - system_home: Option, - legacy_home: Option, - split: Option, -) -> Layout { - if let Some(root) = env_home { - return Layout::Single(root); + pub fn storage_dir(&self) -> PathBuf { + self.root.join("storage") } - if let Some(root) = system_home { - return Layout::Single(root); + + pub fn backups_dir(&self) -> PathBuf { + self.root.join("backups") } - if let Some(root) = legacy_home { - return Layout::Single(root); + + pub fn search_dir(&self) -> PathBuf { + self.root.join("search") } - split.unwrap_or_else(|| Layout::Single(PathBuf::from(".vcms"))) -} -/// Map `ProjectDirs` onto our four directory classes. Logs use the state dir where -/// the platform has one (Linux), else the local data dir (macOS/Windows). -fn split_from(dirs: &directories::ProjectDirs) -> Layout { - Layout::Split { - config: dirs.config_dir().to_path_buf(), - data: dirs.data_dir().to_path_buf(), - cache: dirs.cache_dir().to_path_buf(), - state: dirs.state_dir().unwrap_or_else(|| dirs.data_local_dir()).to_path_buf(), + pub fn logs_dir(&self) -> PathBuf { + self.root.join("logs") } -} -/// `config.toml` — the non-secret config file (config dir). -pub fn config_file() -> PathBuf { - layout().config().join("config.toml") -} + pub fn database_url(&self) -> String { + format!("sqlite://{}", self.database_file().to_string_lossy().replace('\\', "/")) + } -/// `secrets.toml` — auto-generated secrets file (config dir, 0600 on unix). -pub fn secrets_file() -> PathBuf { - layout().config().join("secrets.toml") + pub fn ensure(&self) -> std::io::Result<()> { + let root_existed = self.root.exists(); + create_private_dir(&self.root)?; + #[cfg(windows)] + if !root_existed { + harden_windows_acl(&self.root, true)?; + } + for path in [ + self.storage_dir(), + self.backups_dir(), + self.search_dir(), + self.logs_dir(), + ] { + create_private_dir(&path)?; + } + Ok(()) + } } -/// `.env` — optional environment file loaded at startup (config dir). -pub fn env_file() -> PathBuf { - layout().config().join(".env") +#[cfg(unix)] +fn create_private_dir(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let existed = path.exists(); + std::fs::create_dir_all(path)?; + if existed { + Ok(()) + } else { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + } } -/// `vcms.db` — the default SQLite database file (data dir). -pub fn default_db_path() -> PathBuf { - layout().data().join("vcms.db") +#[cfg(windows)] +fn create_private_dir(path: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(path) } -/// `storage/` — default filesystem storage directory for uploads (data dir). -pub fn storage_dir() -> PathBuf { - layout().data().join("storage") +#[cfg(windows)] +pub(crate) fn harden_windows_acl(path: &Path, directory: bool) -> std::io::Result<()> { + use std::io::{Error, ErrorKind}; + let whoami = std::process::Command::new("whoami.exe").output()?; + if !whoami.status.success() { + return Err(Error::other("whoami failed while hardening ACL")); + } + let user = String::from_utf8(whoami.stdout).map_err(|error| Error::new(ErrorKind::InvalidData, error))?; + let user = user.trim(); + let suffix = if directory { ":(OI)(CI)F" } else { ":F" }; + let grants = [ + format!("{user}{suffix}"), + format!("*S-1-5-18{suffix}"), + format!("*S-1-5-32-544{suffix}"), + ]; + let status = std::process::Command::new("icacls.exe") + .arg(path) + .args(["/inheritance:r", "/grant:r"]) + .args(grants) + .status()?; + if status.success() { + Ok(()) + } else { + Err(Error::other("icacls failed while hardening ACL")) + } } -/// `backups/` — default local destination for backup artifacts (data dir). -pub fn backups_dir() -> PathBuf { - layout().data().join("backups") +#[cfg(not(any(unix, windows)))] +fn create_private_dir(path: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(path) } -/// `search/` — default location for the derived Tantivy index (cache dir). -pub fn search_dir() -> PathBuf { - layout().cache().join("search") +#[cfg(unix)] +pub fn permissions_secure(path: &Path, expected: u32) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o777 == expected) } -/// `logs/` — directory for rolling log files (state dir). -pub fn logs_dir() -> PathBuf { - layout().state().join("logs") +#[cfg(windows)] +pub fn permissions_secure(path: &Path, _expected: u32) -> bool { + let Ok(output) = std::process::Command::new("icacls.exe").arg(path).output() else { + return false; + }; + let acl = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); + output.status.success() + && !acl.contains("(i)") + && !acl.contains("everyone:") + && !acl.contains("authenticated users:") + && !acl.contains("builtin\\users:") } -/// Build the default `DATABASE_URL` (`sqlite:///vcms.db`). -/// -/// SQLite URLs use forward slashes, so backslashes are normalized for Windows. -pub fn default_database_url() -> String { - format!("sqlite://{}", default_db_path().to_string_lossy().replace('\\', "/")) +#[cfg(not(any(unix, windows)))] +pub fn permissions_secure(_path: &Path, _expected: u32) -> bool { + true } -/// Fail fast when the active root is the conventional system service home but this -/// process can't access it (e.g. non-elevated CLI against the ACL-locked -/// `C:\ProgramData\vcms` or root-owned `/var/lib/vcms`). -fn preflight_system_home() -> std::io::Result<()> { - let Layout::Single(root) = layout() else { - return Ok(()); - }; - if system_home().as_deref() != Some(root.as_path()) || !root.is_dir() { - return Ok(()); +pub fn system_root() -> Option { + #[cfg(windows)] + { + Some(PathBuf::from(r"C:\ProgramData\vcms")) } - // A read probe: opening the db and secrets both need list+read on this dir. - match std::fs::read_dir(&root) { - Ok(_) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!( - "vcms data at {} requires Administrator/root; re-run in an elevated terminal.", - root.display() - ), - )), - Err(e) => Err(e), + #[cfg(target_os = "linux")] + { + Some(PathBuf::from("/var/lib/vcms")) } -} - -/// Create the directories the running instance writes to (config, data, storage, -/// logs). Called by commands that own/initialize the instance (`serve`, `admin`, -/// `backup`, `restore`); read-only commands such as `mcp stdio`, `config path/show` -/// never call this and so never create anything or trip the preflight below. -pub fn ensure() -> std::io::Result<()> { - preflight_system_home()?; - let layout = layout(); - for dir in [ - layout.config(), - layout.data(), - layout.state().join("logs"), - layout.data().join("storage"), - ] { - std::fs::create_dir_all(dir)?; + #[cfg(target_os = "macos")] + { + Some(PathBuf::from("/Library/Application Support/vcms")) } - Ok(()) -} - -/// Whether a file sits inside the home root in single-dir mode. Only meaningful for -/// `$VCMS_HOME`/legacy installs; in split mode there is no single root. -pub fn single_root() -> Option { - match layout() { - Layout::Single(root) => Some(root), - Layout::Split { .. } => None, + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] + { + None } } #[cfg(test)] mod tests { use super::*; - use crate::test_helpers::with_home; - use std::path::Path; - - #[test] - fn vcms_home_forces_single_layout() { - let dir = tempfile::tempdir().expect("temp dir"); - with_home(dir.path(), || { - assert_eq!(single_root().as_deref(), Some(dir.path())); - assert_eq!(config_file(), dir.path().join("config.toml")); - assert_eq!(secrets_file(), dir.path().join("secrets.toml")); - assert_eq!(env_file(), dir.path().join(".env")); - assert_eq!(default_db_path(), dir.path().join("vcms.db")); - assert_eq!(storage_dir(), dir.path().join("storage")); - assert_eq!(backups_dir(), dir.path().join("backups")); - assert_eq!(search_dir(), dir.path().join("search")); - assert_eq!(logs_dir(), dir.path().join("logs")); - }); - } - - #[test] - fn ensure_creates_subdirectories_in_single_mode() { - let dir = tempfile::tempdir().expect("temp dir"); - let root = dir.path().join("home"); - with_home(&root, || { - ensure().expect("ensure should create dirs"); - assert!(root.is_dir()); - assert!(logs_dir().is_dir()); - assert!(storage_dir().is_dir()); - }); - } - - #[test] - fn default_database_url_uses_forward_slashes() { - let dir = tempfile::tempdir().expect("temp dir"); - with_home(dir.path(), || { - let url = default_database_url(); - assert!(url.starts_with("sqlite://")); - assert!(!url.contains('\\')); - }); - } - - #[test] - fn split_layout_separates_file_classes() { - // Drive the pure mapping directly so the assertion is platform-independent - // (real ProjectDirs vary by OS and ambient env). - let layout = Layout::Split { - config: PathBuf::from("/cfg"), - data: PathBuf::from("/dat"), - cache: PathBuf::from("/cch"), - state: PathBuf::from("/st"), - }; - assert!(layout.config().join("config.toml").starts_with(Path::new("/cfg"))); - assert!(layout.data().join("vcms.db").starts_with(Path::new("/dat"))); - assert!(layout.cache().join("search").starts_with(Path::new("/cch"))); - assert!(layout.state().join("logs").starts_with(Path::new("/st"))); - // config / data / cache / state are genuinely distinct in split mode. - assert_ne!(layout.config(), layout.data()); - assert_ne!(layout.data(), layout.cache()); - assert_ne!(layout.cache(), layout.state()); - } - - #[test] - fn split_state_falls_back_to_local_data_when_absent() { - // When a platform lacks a state dir, logs must still resolve (we feed the - // local-data dir into `state`). Modeled here as state == data. - let layout = Layout::Split { - config: PathBuf::from("/cfg"), - data: PathBuf::from("/dat"), - cache: PathBuf::from("/cch"), - state: PathBuf::from("/dat"), - }; - assert!(layout.state().join("logs").starts_with(Path::new("/dat"))); - } - - fn split_sample() -> Option { - Some(Layout::Split { - config: PathBuf::from("/c"), - data: PathBuf::from("/d"), - cache: PathBuf::from("/ch"), - state: PathBuf::from("/s"), - }) - } - - fn single_root_of(layout: Layout) -> Option { - match layout { - Layout::Single(root) => Some(root), - Layout::Split { .. } => None, - } - } - - #[test] - fn resolve_env_home_outranks_everything() { - let out = resolve_layout( - Some(PathBuf::from("/env")), - Some(PathBuf::from("/system")), - Some(PathBuf::from("/legacy")), - split_sample(), - ); - assert_eq!(single_root_of(out), Some(PathBuf::from("/env"))); - } - - #[test] - fn resolve_system_home_when_present() { - let out = resolve_layout(None, Some(PathBuf::from("/system")), None, split_sample()); - assert_eq!(single_root_of(out), Some(PathBuf::from("/system"))); - } - - #[test] - fn resolve_legacy_home_when_no_env_or_system() { - let out = resolve_layout(None, None, Some(PathBuf::from("/legacy")), split_sample()); - assert_eq!(single_root_of(out), Some(PathBuf::from("/legacy"))); - } #[test] - fn resolve_falls_through_to_split() { - let out = resolve_layout(None, None, None, split_sample()); - assert!(matches!(out, Layout::Split { .. })); + fn portable_layout_is_one_directory_under_cwd() { + let paths = RuntimePaths::portable(Path::new("/work/site")); + assert_eq!(paths.root(), Path::new("/work/site/vcms_data")); + assert_eq!(paths.config_file(), Path::new("/work/site/vcms_data/config.toml")); + assert_eq!(paths.database_file(), Path::new("/work/site/vcms_data/vcms.db")); + assert_eq!(paths.storage_dir(), Path::new("/work/site/vcms_data/storage")); } #[test] - fn resolve_local_blob_when_nothing_detectable() { - let out = resolve_layout(None, None, None, None); - assert_eq!(single_root_of(out), Some(PathBuf::from(".vcms"))); + fn sqlite_url_uses_forward_slashes() { + let paths = RuntimePaths::portable(Path::new(r"C:\work")); + assert!(!paths.database_url().contains('\\')); } } diff --git a/apps/backend/src/runtime.rs b/apps/backend/src/runtime.rs new file mode 100644 index 00000000..73f1a591 --- /dev/null +++ b/apps/backend/src/runtime.rs @@ -0,0 +1,38 @@ +use crate::config::Config; +use crate::paths::{RuntimeMode, RuntimePaths}; +use crate::secrets::PersistedSecrets; + +#[derive(Clone, Debug)] +pub struct RuntimeContext { + pub mode: RuntimeMode, + pub paths: RuntimePaths, + pub bootstrap: Config, + pub secrets: PersistedSecrets, +} + +impl RuntimeContext { + pub fn initialize(mode: RuntimeMode) -> Result> { + let paths = RuntimePaths::for_mode(mode)?; + paths + .ensure() + .map_err(|error| format!("preparing {}: {error}", paths.root().display()))?; + crate::config::ensure_bootstrap(&paths)?; + let secrets = crate::secrets::ensure(&paths)?; + let bootstrap = Config::load(&paths)?; + Ok(Self { + mode, + paths, + bootstrap, + secrets, + }) + } + + pub fn initialize_default() -> Result> { + let mode = if crate::service::is_installed()? { + RuntimeMode::Installed + } else { + RuntimeMode::Portable + }; + Self::initialize(mode) + } +} diff --git a/apps/backend/src/secrets.rs b/apps/backend/src/secrets.rs index 18e6e16d..f5f441bd 100644 --- a/apps/backend/src/secrets.rs +++ b/apps/backend/src/secrets.rs @@ -1,97 +1,145 @@ -//! Persisted instance secrets (`secrets.toml` in the config dir). -//! -//! On first `serve`/`admin`, a random `HMAC_SECRET` value is -//! generated and written to `secrets.toml` (perms `0600` on unix). Every later -//! process — including a `vcms mcp stdio` child launched from an unknown working -//! directory — reads the *same* values, so site-token verification matches the -//! server that signed the token. Environment variables still override the file. -//! -//! These secrets intentionally live in a dedicated, restricted file rather than -//! `config.toml`: the TOML config is for non-secret settings, while this file is -//! machine-managed and never scaffolded by `vcms config init`. +//! Restricted instance trust root. + +use std::io::Write; use rand::Rng; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; -use crate::paths; +use crate::paths::RuntimePaths; -/// Auto-generated secrets persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct PersistedSecrets { - pub hmac_secret: String, - /// AES-256 key (hex, 64 chars) used to encrypt backup artifacts. Added in a - /// later release, so existing `secrets.toml` files may lack it; [`ensure`] - /// backfills and persists it on next run. Overridable via `BACKUP_ENCRYPTION_KEY`. - #[serde(default)] - pub backup_encryption_key: Option, + /// Root key used only to derive purpose-specific runtime keys. + pub master_key: String, + /// Independent key for encrypted backup artifacts. + pub backup_encryption_key: String, + /// External database URLs may contain credentials and therefore live here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database_url: Option, } -/// Read the persisted secrets file if it exists. -/// -/// Returns `None` when the file is absent (a fresh, uninitialized instance) and -/// an error only when the file exists but cannot be read or parsed. -pub fn load() -> Result, Box> { - let path = paths::secrets_file(); +pub fn load(paths: &RuntimePaths) -> Result, Box> { + let path = paths.secrets_file(); if !path.exists() { return Ok(None); } let contents = std::fs::read_to_string(&path)?; let secrets: PersistedSecrets = toml::from_str(&contents)?; + validate(&secrets)?; Ok(Some(secrets)) } -/// Load existing secrets, or generate and persist new ones if absent. -/// -/// Called by instance-owning commands (`serve`, `admin`) before loading config. -/// Read-only commands (`mcp stdio`) must use [`load`] instead and never create -/// the file. -pub fn ensure() -> Result> { - if let Some(mut existing) = load()? { - // Backfill the backup key for instances created before it existed. - if existing.backup_encryption_key.is_none() { - existing.backup_encryption_key = Some(random_hex(32)); - persist(&existing)?; - } +/// Create secrets only for a genuinely fresh instance. Existing data without its +/// trust root must stop for explicit recovery instead of silently changing keys. +pub fn ensure(paths: &RuntimePaths) -> Result> { + if let Some(existing) = load(paths)? { return Ok(existing); } + if paths.database_file().exists() { + return Err(format!( + "{} is missing but {} already exists; restore secrets.toml or run `vcms secrets reset --yes`", + paths.secrets_file().display(), + paths.database_file().display() + ) + .into()); + } - let secrets = PersistedSecrets { - hmac_secret: random_hex(32), - backup_encryption_key: Some(random_hex(32)), - }; - - persist(&secrets)?; + let secrets = fresh(None); + persist_new(paths, &secrets)?; Ok(secrets) } -/// Write the secrets file with owner-only permissions. -fn persist(secrets: &PersistedSecrets) -> Result<(), Box> { - let path = paths::secrets_file(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; +pub fn fresh(database_url: Option) -> PersistedSecrets { + PersistedSecrets { + master_key: random_hex(32), + backup_encryption_key: random_hex(32), + database_url, + } +} + +pub fn replace(paths: &RuntimePaths, secrets: &PersistedSecrets) -> Result<(), Box> { + validate(secrets)?; + let target = paths.secrets_file(); + let temp = target.with_extension("toml.tmp"); + write_restricted(&temp, &toml::to_string_pretty(secrets)?, false)?; + #[cfg(not(windows))] + std::fs::rename(&temp, &target)?; + #[cfg(windows)] + { + std::fs::copy(&temp, &target)?; + restrict_permissions(&target)?; + std::fs::remove_file(&temp)?; } - let body = toml::to_string_pretty(secrets)?; - std::fs::write(&path, body)?; - restrict_permissions(&path)?; Ok(()) } -/// Generate `n` random bytes, hex-encoded (so a 32-byte secret is 64 chars). +fn persist_new(paths: &RuntimePaths, secrets: &PersistedSecrets) -> Result<(), Box> { + write_restricted(&paths.secrets_file(), &toml::to_string_pretty(secrets)?, true) +} + +fn write_restricted(path: &std::path::Path, body: &str, create_new: bool) -> Result<(), Box> { + let mut options = std::fs::OpenOptions::new(); + options + .write(true) + .create(true) + .truncate(!create_new) + .create_new(create_new); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(body.as_bytes())?; + file.sync_all()?; + restrict_permissions(path)?; + Ok(()) +} + +fn validate(secrets: &PersistedSecrets) -> Result<(), Box> { + for (name, value) in [ + ("master_key", secrets.master_key.as_str()), + ("backup_encryption_key", secrets.backup_encryption_key.as_str()), + ] { + if value.len() != 64 || hex::decode(value).is_err() { + return Err(format!("{name} must be exactly 32 bytes encoded as 64 hexadecimal characters").into()); + } + } + Ok(()) +} + +/// Derive a stable, domain-separated 32-byte key without exposing the root key to +/// consumers. The hexadecimal result preserves existing service interfaces. +pub fn derive_key_hex(master_key: &str, purpose: &str) -> String { + let root = hex::decode(master_key).expect("validated master key"); + let mut digest = Sha256::new(); + digest.update(b"vcms-key-v1\0"); + digest.update(purpose.as_bytes()); + digest.update(b"\0"); + digest.update(root); + hex::encode(digest.finalize()) +} + fn random_hex(n: usize) -> String { let mut bytes = vec![0u8; n]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } -/// Tighten the secrets file to owner-only read/write where the platform supports -/// it. Best-effort on Windows (filesystem ACLs already restrict to the user). #[cfg(unix)] fn restrict_permissions(path: &std::path::Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) } -#[cfg(not(unix))] +#[cfg(windows)] +fn restrict_permissions(path: &std::path::Path) -> std::io::Result<()> { + crate::paths::harden_windows_acl(path, false) +} + +#[cfg(not(any(unix, windows)))] fn restrict_permissions(_path: &std::path::Path) -> std::io::Result<()> { Ok(()) } @@ -99,27 +147,23 @@ fn restrict_permissions(_path: &std::path::Path) -> std::io::Result<()> { #[cfg(test)] mod tests { use super::*; - use crate::test_helpers::with_home; #[test] - fn load_returns_none_when_absent() { - let dir = tempfile::tempdir().expect("temp dir"); - with_home(dir.path(), || { - assert!(load().expect("load ok").is_none()); - }); + fn fresh_secrets_are_valid_and_domain_separated() { + let secrets = fresh(None); + validate(&secrets).unwrap(); + assert_ne!( + derive_key_hex(&secrets.master_key, "access-token-index"), + derive_key_hex(&secrets.master_key, "webhook-encryption") + ); } #[test] - fn ensure_generates_then_reuses_secrets() { - let dir = tempfile::tempdir().expect("temp dir"); - with_home(dir.path(), || { - let first = ensure().expect("generate"); - assert_eq!(first.hmac_secret.len(), 64); - assert!(paths::secrets_file().exists()); - - // A second call must return the persisted values, not regenerate. - let second = ensure().expect("reuse"); - assert_eq!(first.hmac_secret, second.hmac_secret); - }); + fn missing_secrets_are_not_recreated_over_existing_database() { + let dir = tempfile::tempdir().unwrap(); + let paths = RuntimePaths::portable(dir.path()); + paths.ensure().unwrap(); + std::fs::write(paths.database_file(), b"db").unwrap(); + assert!(ensure(&paths).unwrap_err().to_string().contains("secrets reset")); } } diff --git a/apps/backend/src/tracing.rs b/apps/backend/src/tracing.rs index 7fa34a06..e6943904 100644 --- a/apps/backend/src/tracing.rs +++ b/apps/backend/src/tracing.rs @@ -10,23 +10,15 @@ pub fn init_tracing(config: &crate::config::Config) -> Option { + match log_output { + "file" => { let log_dir = config.log_dir.as_str(); let file_appender = RollingFileAppender::new(Rotation::DAILY, log_dir, "cms.log"); let (file_writer, guard) = tracing_appender::non_blocking(file_appender); - let file_layer = fmt::layer() - .with_writer(file_writer) - .with_target(true) - .with_file(log_annotations) - .with_line_number(log_annotations) - .json(); + let file_layer = fmt::layer().with_writer(file_writer).with_target(true).json(); tracing_subscriber::registry().with(env_filter).with(file_layer).init(); @@ -40,33 +32,8 @@ pub fn init_tracing(config: &crate::config::Config) -> Option { - let stdout_layer = fmt::layer() - .with_target(true) - .with_file(log_annotations) - .with_line_number(log_annotations) - .json(); - - tracing_subscriber::registry() - .with(env_filter) - .with(stdout_layer) - .init(); - - tracing::info!( - log_output = %log_output, - log_format = "json", - rust_log = %env_filter_str, - "Tracing initialized" - ); - - None - } _ => { - let stdout_layer = fmt::layer() - .with_target(true) - .with_file(log_annotations) - .with_line_number(log_annotations) - .pretty(); + let stdout_layer = fmt::layer().with_target(true).pretty(); tracing_subscriber::registry() .with(env_filter) @@ -86,10 +53,9 @@ pub fn init_tracing(config: &crate::config::Config) -> Option Date: Sat, 18 Jul 2026 23:18:28 +0530 Subject: [PATCH 02/34] refactor!: simplify CLI and main entrypoint Remove global CLI flags (--config, --bind, --database-url, --log-level), dotenvy loading, and config init/path subcommands. Add secrets reset --yes, config show reads DB settings. Main uses tokio::runtime::Builder manually. Doctor uses RuntimeContext. Cross-platform service run entrypoint. --- apps/backend/src/cli.rs | 112 +++-------- apps/backend/src/diagnostics.rs | 112 ++++++----- apps/backend/src/main.rs | 331 ++++++++++++++++++++------------ 3 files changed, 289 insertions(+), 266 deletions(-) diff --git a/apps/backend/src/cli.rs b/apps/backend/src/cli.rs index 5eedb86f..0b7e39fd 100644 --- a/apps/backend/src/cli.rs +++ b/apps/backend/src/cli.rs @@ -2,71 +2,44 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; -/// CMS server and administrative command-line interface. #[derive(Parser, Debug, Default)] #[command( name = "vcms", version, about = "Velopulent CMS", - long_about = "Velopulent CMS — self-hosted headless content management system.\n\n\ - Runtime files default to the platform's per-type directories (config, data, \ - cache, state) via the `directories` crate. Set $VCMS_HOME to keep everything \ - under one root instead. OS packages set it from their service definitions. \ - `vcms serve` creates what it needs on first run and generates secrets if absent.", - after_help = "DATA DIRECTORIES (defaults; set $VCMS_HOME for a single root):\n \ - config dir config.toml, secrets.toml (0600 on unix), .env\n \ - data dir vcms.db (+ -wal / -shm), storage/, backups/\n \ - cache dir search/ (derived Tantivy index, rebuildable)\n \ - state dir logs/ (when [log] output = \"file\")\n\n\ - KEY ENVIRONMENT (env overrides config; CLI flags override env):\n \ - VCMS_HOME force single-root layout [default: platform split dirs]\n \ - DATABASE_URL sqlite/postgres/mysql URL [default: sqlite:///vcms.db]\n \ - HMAC_SECRET token-lookup HMAC key [auto-generated to secrets.toml]\n\n\ - DOCUMENTATION:\n \ - https://cms.velopulent.com/docs" + long_about = "Velopulent CMS — self-hosted headless content management system.\n\n`vcms serve` runs a portable instance from ./vcms_data. When the native service is installed, operational commands use its fixed system data root.", + after_help = "DOCUMENTATION:\n https://cms.velopulent.com/docs" )] pub struct Cli { - /// Path to a config file (overrides the search path). - #[arg(long, global = true, env = "VCMS_CONFIG", value_name = "PATH")] - pub config: Option, - - /// REST API bind address, e.g. 0.0.0.0:3000 (overrides config + env). - #[arg(long, global = true, value_name = "ADDR")] - pub bind: Option, - - /// Database URL, e.g. sqlite:path / postgres://… (overrides config + env) - /// [default: sqlite:///vcms.db]. - #[arg(long, global = true, value_name = "URL")] - pub database_url: Option, - - /// Log filter directive, e.g. "cms=info" (overrides config + env). - #[arg(long, global = true, value_name = "LEVEL")] - pub log_level: Option, - #[command(subcommand)] pub command: Option, } #[derive(Subcommand, Debug)] pub enum Command { - /// Run the server (default when no subcommand is given). + /// Run a portable server from ./vcms_data. Serve, /// Run Model Context Protocol transports. Mcp { #[command(subcommand)] transport: McpTransport, }, - /// Inspect or scaffold configuration. + /// Inspect effective configuration. Config { #[command(subcommand)] action: ConfigAction, }, + /// Manage the restricted instance trust root. + Secrets { + #[command(subcommand)] + action: SecretsAction, + }, /// Administrative operations. Admin { #[command(subcommand)] action: AdminAction, }, - /// Create or list backups (runs offline, without the HTTP server). + /// Create or list backups. Backup { #[command(subcommand)] action: BackupAction, @@ -78,22 +51,16 @@ pub enum Command { }, /// Validate configuration, storage, database, ports, and service context. Doctor, - /// Restore a backup artifact (runs offline; destructive — replaces data in scope). + /// Restore a backup artifact (destructive). Restore { - /// Path to the backup artifact (`.cmsbak`). #[arg(long, value_name = "PATH")] file: PathBuf, - /// Restore scope: `instance` (whole instance) or `site` (a single site). #[arg(long, default_value = "instance", value_name = "SCOPE")] scope: String, - /// Site id to restore (required when --scope site, or to extract one site - /// from an instance backup). #[arg(long, value_name = "SITE_ID")] site: Option, - /// Import the site under fresh ids instead of replacing in place. #[arg(long)] import_as_new: bool, - /// Skip the destructive-action confirmation prompt. #[arg(long)] yes: bool, }, @@ -101,25 +68,18 @@ pub enum Command { #[derive(Subcommand, Debug)] pub enum BackupAction { - /// Create a backup. Create { - /// `instance` (everything) or `site` (one site). #[arg(long, default_value = "instance", value_name = "SCOPE")] scope: String, - /// Site id (required when --scope site). #[arg(long, value_name = "SITE_ID")] site: Option, - /// Write the artifact to this file instead of the configured destination. #[arg(long, value_name = "PATH")] out: Option, - /// Exclude uploaded files from the backup. #[arg(long = "no-files")] no_files: bool, - /// Encrypt the artifact (requires a backup encryption key). #[arg(long)] encrypt: bool, }, - /// List recorded backups. List, } @@ -127,41 +87,31 @@ pub enum BackupAction { pub enum ServiceAction { /// Print normalized native service state and manager details. Status, - /// Internal entry point invoked by the Windows Service Control Manager. - /// - /// Not for direct use — the SCM launches this to host the server inside a - /// Windows service. Hidden from `--help`. + /// Internal native-service entry point. Not for direct use. #[command(hide = true)] - #[cfg(windows)] Run, } #[derive(Subcommand, Debug)] pub enum McpTransport { - /// Run MCP over stdin/stdout. Stdio, } #[derive(Subcommand, Debug)] pub enum ConfigAction { - /// Write a default config file (non-secret keys only). - Init { - /// Overwrite an existing config file. + Show, +} + +#[derive(Subcommand, Debug)] +pub enum SecretsAction { + Reset { #[arg(long)] - force: bool, - /// Write to this path instead of the default user config dir. - #[arg(long, value_name = "PATH")] - path: Option, + yes: bool, }, - /// Print the effective merged configuration (secrets redacted). - Show, - /// Print the resolved config file path and the search order. - Path, } #[derive(Subcommand, Debug)] pub enum AdminAction { - /// Reset a user's password. The user is identified by their unique login email. ResetPassword { #[arg(long, value_name = "EMAIL")] email: String, @@ -176,26 +126,24 @@ mod tests { use clap::Parser; #[test] - fn parses_mcp_stdio_command() { - let cli = Cli::try_parse_from(["vcms", "mcp", "stdio"]).expect("command should parse"); + fn no_subcommand_is_help_mode() { + assert!(Cli::try_parse_from(["vcms"]).unwrap().command.is_none()); + } + + #[test] + fn parses_operational_commands() { + let mcp = Cli::try_parse_from(["vcms", "mcp", "stdio"]).unwrap(); assert!(matches!( - cli.command, + mcp.command, Some(Command::Mcp { transport: McpTransport::Stdio }) )); - } - - #[test] - fn parses_operational_commands() { - let doctor = Cli::try_parse_from(["vcms", "doctor"]).unwrap(); - assert!(matches!(doctor.command, Some(Command::Doctor))); - - let status = Cli::try_parse_from(["vcms", "service", "status"]).unwrap(); + let service = Cli::try_parse_from(["vcms", "service", "run"]).unwrap(); assert!(matches!( - status.command, + service.command, Some(Command::Service { - action: ServiceAction::Status + action: ServiceAction::Run }) )); } diff --git a/apps/backend/src/diagnostics.rs b/apps/backend/src/diagnostics.rs index c1b2c1ad..5345a42b 100644 --- a/apps/backend/src/diagnostics.rs +++ b/apps/backend/src/diagnostics.rs @@ -1,74 +1,70 @@ use std::net::SocketAddr; -use std::path::Path; -use crate::cli::Cli; -use crate::config::{self, Config}; use crate::database::connect_db_without_migrations; -pub async fn run(cli: &Cli) -> Result<(), Box> { +pub async fn run() -> Result<(), Box> { let mut failed = false; - let config_path = config::resolve_config_path(cli); - let loaded = Config::load(cli).map_err(|error| format!("configuration invalid: {error}"))?; + let context = crate::runtime::RuntimeContext::initialize_default()?; + let config = &context.bootstrap; + + report("mode", true, context.mode.to_string(), &mut failed); + report( + "root", + context.paths.root().is_dir(), + context.paths.root().display().to_string(), + &mut failed, + ); report( "config", - config_path.as_deref().is_none_or(Path::is_file), - config_path - .as_deref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "built-in defaults (run `vcms config init`)".to_owned()), + context.paths.config_file().is_file(), + context.paths.config_file().display().to_string(), + &mut failed, + ); + report( + "secrets", + context.paths.secrets_file().is_file(), + "present; values redacted".to_owned(), + &mut failed, + ); + report( + "root-permissions", + crate::paths::permissions_secure(context.paths.root(), 0o700), + "private root expected".to_owned(), &mut failed, ); + report( + "secret-permissions", + crate::paths::permissions_secure(&context.paths.secrets_file(), 0o600), + "private secrets expected".to_owned(), + &mut failed, + ); + for (name, path) in [ + ("storage-dir", context.paths.storage_dir()), + ("backup-dir", context.paths.backups_dir()), + ("log-dir", context.paths.logs_dir()), + ("search-dir", context.paths.search_dir()), + ] { + report(name, path.is_dir(), path.display().to_string(), &mut failed); + } - let directories = [ - ( - "config-dir", - crate::paths::config_file().parent().map(Path::to_path_buf), + match connect_db_without_migrations(config).await { + Ok(pool) => report( + "database", + pool.ping().await.is_ok(), + "reachable".to_owned(), + &mut failed, ), - ( - "data-dir", - crate::paths::default_db_path().parent().map(Path::to_path_buf), + Err(_) if config.database_url.starts_with("sqlite:") && !context.paths.database_file().exists() => report( + "database", + true, + "not initialized; first serve will create it".to_owned(), + &mut failed, ), - ("storage-dir", Some(crate::paths::storage_dir())), - ]; - for (name, path) in directories { - if let Some(path) = path { - let result = std::fs::metadata(&path); - let ok = result - .as_ref() - .is_ok_and(|metadata| metadata.is_dir() && !metadata.permissions().readonly()); - report(name, ok, path.display().to_string(), &mut failed); - } - } - - match connect_db_without_migrations(&loaded).await { - Ok(pool) => { - report( - "database", - pool.ping().await.is_ok(), - "reachable; schema current".to_owned(), - &mut failed, - ); - } - Err(_) if loaded.database_url.starts_with("sqlite:") && !crate::paths::default_db_path().exists() => { - report( - "database", - true, - "not initialized; first service start will create and migrate it".to_owned(), - &mut failed, - ); - } Err(error) => report("database", false, sanitize(&error.to_string()), &mut failed), } - check_bind("rest-bind", &loaded.bind_address, &mut failed).await; - check_bind("grpc-bind", &loaded.grpc_bind_address, &mut failed).await; - report( - "service-identity", - true, - std::env::var("USER") - .or_else(|_| std::env::var("USERNAME")) - .unwrap_or_else(|_| "unknown".to_owned()), - &mut failed, - ); + check_bind("rest-bind", &config.bind_address, &mut failed).await; + check_bind("grpc-bind", &config.grpc_bind_address, &mut failed).await; + if failed { Err("doctor found one or more failures".into()) } else { @@ -97,7 +93,7 @@ fn report(name: &str, ok: bool, detail: String, failed: &mut bool) { fn sanitize(message: &str) -> String { if message.len() > 240 { - format!("{}…", &message[..240]) + format!("{}…", &message[..240]) } else { message.to_owned() } diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index ea85f893..99fc7f85 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -1,133 +1,229 @@ -use clap::Parser; -use cms::cli::{AdminAction, BackupAction, Cli, Command, ConfigAction, McpTransport}; -use cms::config::{self, Config}; +use std::error::Error; + +use clap::{CommandFactory, Parser}; +use cms::cli::{AdminAction, BackupAction, Cli, Command, ConfigAction, McpTransport, SecretsAction}; use cms::database::init_db_with_config; use cms::repository::Repository; - -use std::error::Error; use tracing::info; -#[tokio::main] -async fn main() { - // Load env from the cwd `.env` (dev) first, then `$VCMS_HOME/.env`. dotenvy is - // first-wins, so real env vars and the cwd file keep precedence over the home file. - // VCMS_HOME must be a real env var — resolved here before the `.env` loads — never - // set inside that file (chicken-and-egg). - dotenvy::dotenv().ok(); - let env_file = cms::paths::env_file(); - match dotenvy::from_path(&env_file) { - Ok(()) => {} - Err(e) if e.not_found() => {} // missing file is fine - // The home `.env` is optional. If VCMS_HOME points at a service-owned directory, - // a non-elevated CLI may not read it — most notably `vcms mcp stdio`, a proxy - // that needs no home file. Treat "permission denied" like "absent" and move on: - // data-touching commands still hit `paths::ensure`'s preflight. - Err(dotenvy::Error::Io(io)) if io.kind() == std::io::ErrorKind::PermissionDenied => {} - Err(e) => { - eprintln!("Error: cannot load {}: {e}", env_file.display()); +fn main() { + let cli = Cli::parse(); + if cli.command.is_none() { + let _ = Cli::command().print_help(); + println!(); + return; + } + + #[cfg(windows)] + if let Some(Command::Service { + action: cms::cli::ServiceAction::Run, + }) = &cli.command + { + if let Err(error) = cms::service::run_service_sync( + match &cli.command { + Some(Command::Service { action }) => action, + _ => unreachable!(), + }, + &cli, + ) { + eprintln!("Error: {error}"); std::process::exit(1); } + return; } - let cli = Cli::parse(); + let result = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| -> Box { Box::new(error) }) + .and_then(|runtime| runtime.block_on(dispatch(cli))); + if let Err(error) = result { + eprintln!("Error: {error}"); + std::process::exit(1); + } +} - let result = match &cli.command { - Some(Command::Config { action }) => run_config(action, &cli), - Some(Command::Admin { action }) => run_admin(action, &cli).await, - Some(Command::Backup { action }) => run_backup(action, &cli).await, +async fn dispatch(cli: Cli) -> Result<(), Box> { + match &cli.command { + Some(Command::Config { action }) => run_config(action).await, + Some(Command::Secrets { action }) => run_secrets(action).await, + Some(Command::Admin { action }) => run_admin(action).await, + Some(Command::Backup { action }) => run_backup(action).await, Some(Command::Restore { file, scope, site, import_as_new, yes, - }) => run_restore(file, scope, site, *import_as_new, *yes, &cli).await, + }) => run_restore(file, scope, site, *import_as_new, *yes).await, Some(Command::Service { action }) => cms::service::run_service(action, &cli).await, - Some(Command::Doctor) => cms::diagnostics::run(&cli).await, + Some(Command::Doctor) => cms::diagnostics::run().await, Some(Command::Mcp { transport: McpTransport::Stdio, }) => run_mcp_stdio().await, - Some(Command::Serve) | None => cms::server::run(&cli, cms::server::shutdown_signal(), || {}).await, - }; - - if let Err(e) = result { - eprintln!("Error: {e}"); - std::process::exit(1); + Some(Command::Serve) => { + if cms::service::is_installed()? { + return Err("native vcms service is installed; portable `vcms serve` is disabled".into()); + } + let context = cms::runtime::RuntimeContext::initialize(cms::paths::RuntimeMode::Portable)?; + cms::server::run(context, cms::server::shutdown_signal(), || {}).await + } + None => Ok(()), } } async fn run_mcp_stdio() -> Result<(), Box> { - // A thin proxy to the running server's `/mcp` endpoint — it touches no disk - // (no home dir, database, secrets, or search index), so it works even when those - // belong to the privileged service account. It needs only a server URL and a - // `vcms_site_*` access token, forwarded as the bearer credential. cms::tracing::init_proxy_tracing(); - let token = std::env::var("VCMS_MCP_TOKEN").map_err(|_| "VCMS_MCP_TOKEN is required for `vcms mcp stdio`")?; - let token = token.trim().to_string(); + let token = token.trim().to_owned(); if token.is_empty() { return Err("VCMS_MCP_TOKEN must not be empty".into()); } - - let base = std::env::var("VCMS_MCP_URL").unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()); + let base = std::env::var("VCMS_MCP_URL").unwrap_or_else(|_| "http://127.0.0.1:3000".to_owned()); let endpoint = format!("{}/mcp", base.trim_end_matches('/')); - info!(%endpoint, "Starting MCP stdio proxy"); cms::mcp::transports::stdio::serve(endpoint, token).await } -fn run_config(action: &ConfigAction, cli: &Cli) -> Result<(), Box> { +async fn run_config(action: &ConfigAction) -> Result<(), Box> { match action { - ConfigAction::Init { force, path } => { - let target = match path { - Some(p) => p.clone(), - None => config::user_config_path().ok_or("Could not determine user config directory")?, - }; - if target.exists() && !force { - return Err(format!("{} already exists (use --force to overwrite)", target.display()).into()); - } - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&target, config::default_config_toml())?; - println!("Wrote default config to {}", target.display()); - } ConfigAction::Show => { - let config = Config::load(cli)?; - print!("{}", config.redacted_toml()); + let context = cms::runtime::RuntimeContext::initialize_default()?; + print!("{}", context.bootstrap.redacted_toml(&context.paths)); + match cms::database::connect_db_without_migrations(&context.bootstrap).await { + Ok(pool) => { + match cms::services::settings::SettingsService::load(pool, &context.secrets.master_key).await { + Ok(settings) => println!( + "\n[instance_settings]\n{}", + serde_json::to_string_pretty(settings.current().as_ref())? + ), + Err(error) => println!("\ninstance_settings = \"unavailable: {error}\""), + } + } + Err(_) => println!("\ninstance_settings = \"unavailable until database initialization\""), + } } - ConfigAction::Path => { - match config::resolve_config_path(cli) { - Some(p) => println!("Active config file: {}", p.display()), - None => println!("Active config file: "), + } + Ok(()) +} + +async fn run_secrets(action: &SecretsAction) -> Result<(), Box> { + match action { + SecretsAction::Reset { yes } => { + if !yes { + return Err( + "Secret reset invalidates API tokens and integration credentials. Re-run with --yes.".into(), + ); } - println!("\nSearch order (first existing wins):"); - match &cli.config { - Some(explicit) => println!(" 1. --config / VCMS_CONFIG: {}", explicit.display()), - None => println!(" 1. --config / VCMS_CONFIG: "), + let mode = if cms::service::is_installed()? { + cms::paths::RuntimeMode::Installed + } else { + cms::paths::RuntimeMode::Portable + }; + let paths = cms::paths::RuntimePaths::for_mode(mode)?; + paths.ensure()?; + cms::config::ensure_bootstrap(&paths)?; + let existing = cms::secrets::load(&paths)?.ok_or("No existing instance secrets were found to reset")?; + if existing.database_url.is_none() && !paths.database_file().exists() { + return Err("No existing instance database was found to reset".into()); } - for (i, p) in config::config_search_paths().iter().enumerate() { - let marker = if p.exists() { " (exists)" } else { "" }; - println!(" {}. {}{}", i + 2, p.display(), marker); + let old_database_url = existing.database_url; + let fresh = cms::secrets::fresh(old_database_url); + cms::secrets::replace(&paths, &fresh)?; + let config = cms::config::Config::load(&paths)?; + let pool = init_db_with_config(&config).await?; + let (tokens, webhooks, s3_sites) = invalidate_credentials(&pool).await?; + println!("Trust root replaced. Recovery report:"); + println!("- {tokens} API access token(s) invalidated"); + println!("- Active dashboard sessions invalidated"); + println!("- {webhooks} webhook(s) disabled; secret headers cleared"); + println!("- Encrypted storage and backup credentials cleared"); + if s3_sites > 0 { + println!("- {s3_sites} S3-backed site(s) require new storage credentials"); } } } Ok(()) } -async fn run_admin(action: &AdminAction, cli: &Cli) -> Result<(), Box> { - cms::paths::ensure()?; - cms::secrets::ensure()?; - let config = Config::load(cli)?; - let pool = init_db_with_config(&config).await?; - let repository = Repository::new(&pool); +async fn invalidate_credentials(pool: &cms::database::pool::DbPool) -> Result<(i64, i64, i64), Box> { + use cms::database::pool::DbPool; + match pool { + DbPool::Postgres(pool) => { + let tokens = sqlx::query_scalar("SELECT COUNT(*) FROM access_tokens") + .fetch_one(pool) + .await?; + let webhooks = sqlx::query_scalar("SELECT COUNT(*) FROM site_webhooks WHERE headers_encrypted <> ''") + .fetch_one(pool) + .await?; + let sites = sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(pool) + .await?; + sqlx::query("DELETE FROM access_tokens").execute(pool).await?; + sqlx::query("DELETE FROM sessions").execute(pool).await?; + sqlx::query("UPDATE site_webhooks SET headers_encrypted = '', enabled = FALSE") + .execute(pool) + .await?; + sqlx::query("UPDATE instance_settings SET credentials_encrypted = NULL") + .execute(pool) + .await?; + return Ok((tokens, webhooks, sites)); + } + DbPool::MySql(pool) => { + let tokens = sqlx::query_scalar("SELECT COUNT(*) FROM access_tokens") + .fetch_one(pool) + .await?; + let webhooks = sqlx::query_scalar("SELECT COUNT(*) FROM site_webhooks WHERE headers_encrypted <> ''") + .fetch_one(pool) + .await?; + let sites = sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(pool) + .await?; + sqlx::query("DELETE FROM access_tokens").execute(pool).await?; + sqlx::query("DELETE FROM sessions").execute(pool).await?; + sqlx::query("UPDATE site_webhooks SET headers_encrypted = '', enabled = FALSE") + .execute(pool) + .await?; + sqlx::query("UPDATE instance_settings SET credentials_encrypted = NULL") + .execute(pool) + .await?; + return Ok((tokens, webhooks, sites)); + } + DbPool::Sqlite(pool) => { + let tokens = sqlx::query_scalar("SELECT COUNT(*) FROM access_tokens") + .fetch_one(pool) + .await?; + let webhooks = sqlx::query_scalar("SELECT COUNT(*) FROM site_webhooks WHERE headers_encrypted <> ''") + .fetch_one(pool) + .await?; + let sites = sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(pool) + .await?; + sqlx::query("DELETE FROM access_tokens").execute(pool).await?; + sqlx::query("DELETE FROM sessions").execute(pool).await?; + sqlx::query("UPDATE site_webhooks SET headers_encrypted = '', enabled = 0") + .execute(pool) + .await?; + sqlx::query("UPDATE instance_settings SET credentials_encrypted = NULL") + .execute(pool) + .await?; + return Ok((tokens, webhooks, sites)); + } + } +} +async fn run_admin(action: &AdminAction) -> Result<(), Box> { + let context = cms::runtime::RuntimeContext::initialize_default()?; + let pool = init_db_with_config(&context.bootstrap).await?; + let repository = Repository::new(&pool); match action { AdminAction::ResetPassword { email, password } => { - let id = match repository.user.find_by_email(email).await? { - Some(user) => user.id, - None => return Err(format!("User with email '{email}' not found").into()), - }; + let id = repository + .user + .find_by_email(email) + .await? + .ok_or_else(|| format!("User with email '{email}' not found"))? + .id; let password_hash = bcrypt::hash(password, bcrypt::DEFAULT_COST)?; repository.user.update_password(&id, &password_hash, false).await?; println!("Password updated for user with email '{email}'."); @@ -136,12 +232,11 @@ async fn run_admin(action: &AdminAction, cli: &Cli) -> Result<(), Box Ok(()) } -async fn run_backup(action: &BackupAction, cli: &Cli) -> Result<(), Box> { +async fn run_backup(action: &BackupAction) -> Result<(), Box> { use cms::services::backup::{BackupService, CreateBackupOptions, build_backup_destination, meta}; - cms::paths::ensure()?; - cms::secrets::ensure()?; - let config = Config::load(cli)?; + let context = cms::runtime::RuntimeContext::initialize_default()?; + let config = context.bootstrap; let pool = init_db_with_config(&config).await?; let storage_registry = cms::server::initialize_storage(&config); let destination = build_backup_destination(&config)?; @@ -190,16 +285,16 @@ async fn run_backup(action: &BackupAction, cli: &Cli) -> Result<(), Box, import_as_new: bool, yes: bool, - cli: &Cli, ) -> Result<(), Box> { use cms::services::backup::{ BackupService, RestoreRequest, RestoreSource, RestoreTarget, build_backup_destination, }; - if !yes { - return Err("Restore is destructive and replaces data within the chosen scope. \ - Re-run with --yes to proceed." - .into()); + return Err("Restore replaces data within the chosen scope. Re-run with --yes.".into()); } - cms::paths::ensure()?; - cms::secrets::ensure()?; - let config = Config::load(cli)?; + let context = cms::runtime::RuntimeContext::initialize_default()?; + let config = context.bootstrap; let pool = init_db_with_config(&config).await?; let storage_registry = cms::server::initialize_storage(&config); let destination = build_backup_destination(&config)?; - let service = BackupService::new(pool.clone(), storage_registry, destination, &config); - - let bytes = std::fs::read(file)?; + let service = BackupService::new(pool, storage_registry, destination, &config); let target = match scope { "instance" => RestoreTarget::WholeInstance, - "site" => { - let sid = site.clone().ok_or("--site is required for --scope site")?; - RestoreTarget::Site { - site_id: sid, - import_as_new, - } - } + "site" => RestoreTarget::Site { + site_id: site.clone().ok_or("--site is required for --scope site")?, + import_as_new, + }, other => return Err(format!("unknown scope '{other}' (use instance|site)").into()), }; - - service + let report = service .restore(RestoreRequest { - source: RestoreSource::Bytes(bytes), + source: RestoreSource::Bytes(std::fs::read(file)?), target, created_by: None, }) .await?; - println!("Restore complete."); - let reindex_path = match (scope, site) { - ("site", Some(sid)) => format!("/api/dashboard/sites/{sid}/search/reindex"), - _ => "/api/dashboard/instance/search/reindex".to_string(), - }; - println!( - "Note: the full-text search index may now be stale. Rebuild it from the dashboard \ - (Settings → Backups → Rebuild search index) or POST {reindex_path} once the server is running." - ); + println!("Restore complete. Recovery required:"); + for item in report.recovery_required { + println!("- {item}"); + } Ok(()) } @@ -270,7 +349,7 @@ fn parse_scope(scope: &str, site: Option<&str>) -> Result Ok(Scope::Instance), "site" => site - .map(|s| Scope::Site(s.to_string())) + .map(|value| Scope::Site(value.to_owned())) .ok_or_else(|| "--site is required for --scope site".into()), other => Err(format!("unknown scope '{other}' (use instance|site)").into()), } From 44eb8ffdd2c315c7d139faccd22467615e94398e Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:18:56 +0530 Subject: [PATCH 03/34] refactor(auth)!: use domain-separated derived keys Replace single hmac_secret with token_index_key, session_auth_key, signed_upload_key, and webhook_encryption_key derived from master_key via secrets::derive_key_hex(). Update middleware, gRPC, MCP, and handler consumers to use the correct domain key. --- apps/backend/src/grpc/auth.rs | 8 ++++---- apps/backend/src/handlers/file_handler.rs | 2 +- apps/backend/src/mcp/auth.rs | 2 +- apps/backend/src/mcp/tools/file.rs | 2 +- apps/backend/src/middleware/api_auth.rs | 2 +- apps/backend/src/middleware/auth.rs | 4 ++-- apps/backend/src/middleware/dashboard_auth.rs | 6 +++--- apps/backend/src/router/graphql.rs | 2 +- apps/backend/src/services/mod.rs | 9 +++++---- 9 files changed, 19 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/grpc/auth.rs b/apps/backend/src/grpc/auth.rs index 8678a470..842b2621 100644 --- a/apps/backend/src/grpc/auth.rs +++ b/apps/backend/src/grpc/auth.rs @@ -23,7 +23,7 @@ pub fn parse_token(token: &str, config: &Config) -> Result Response { // The token is the auth: HMAC-signed by the server, time-limited, single-use. - let token = match SignedUploadToken::verify(&token, &config.hmac_secret) { + let token = match SignedUploadToken::verify(&token, &config.signed_upload_key) { Ok(t) => t, Err(SignedUploadError::Expired) => { return (StatusCode::GONE, Json(json!({"error": "Upload URL expired"}))).into_response(); diff --git a/apps/backend/src/mcp/auth.rs b/apps/backend/src/mcp/auth.rs index 7a4edb46..fd464556 100644 --- a/apps/backend/src/mcp/auth.rs +++ b/apps/backend/src/mcp/auth.rs @@ -80,7 +80,7 @@ pub async fn authenticate_mcp_request(mut request: Request, next: Next) -> None => return auth_response(StatusCode::UNAUTHORIZED, "Missing Authorization bearer token"), }; - match verify_access_token(&token, &repository, &config.hmac_secret).await { + match verify_access_token(&token, &repository, &config.token_index_key).await { Ok(actor) => { request.extensions_mut().insert(actor); next.run(request).await diff --git a/apps/backend/src/mcp/tools/file.rs b/apps/backend/src/mcp/tools/file.rs index 52394de5..8019c32d 100644 --- a/apps/backend/src/mcp/tools/file.rs +++ b/apps/backend/src/mcp/tools/file.rs @@ -147,7 +147,7 @@ pub async fn create_upload_url( ¶ms.0.filename, ¶ms.0.content_type, &storage_provider, - &config.hmac_secret, + &config.signed_upload_key, config.upload_token_expiry_secs, ); diff --git a/apps/backend/src/middleware/api_auth.rs b/apps/backend/src/middleware/api_auth.rs index 7f684f20..f457a08d 100644 --- a/apps/backend/src/middleware/api_auth.rs +++ b/apps/backend/src/middleware/api_auth.rs @@ -51,7 +51,7 @@ pub async fn api_auth_middleware(mut request: Request, next: Next) -> Response { } }; - let actor = match verify_access_token(&token, &repository, &config.hmac_secret).await { + let actor = match verify_access_token(&token, &repository, &config.token_index_key).await { Ok(actor) => actor, Err((status, err)) => return (status, err).into_response(), }; diff --git a/apps/backend/src/middleware/auth.rs b/apps/backend/src/middleware/auth.rs index 0d1e51dd..d9e11cb7 100644 --- a/apps/backend/src/middleware/auth.rs +++ b/apps/backend/src/middleware/auth.rs @@ -117,7 +117,7 @@ where .get::() .cloned() .ok_or_else(|| AuthError::unauthorized("Server configuration error"))?; - let actor = verify_access_token(&token, &repository, &config.hmac_secret).await?; + let actor = verify_access_token(&token, &repository, &config.token_index_key).await?; return Ok(AuthContext { actor, auth_method: AuthMethod::ApiKey, @@ -135,7 +135,7 @@ where .get::() .cloned() .ok_or_else(|| AuthError::unauthorized("Server configuration error"))?; - let user = verify_session(&token, &repository, &config.hmac_secret).await?; + let user = verify_session(&token, &repository, &config.session_auth_key).await?; return Ok(AuthContext { actor: Actor::User(user), auth_method: AuthMethod::Session, diff --git a/apps/backend/src/middleware/dashboard_auth.rs b/apps/backend/src/middleware/dashboard_auth.rs index 8be638de..a558378e 100644 --- a/apps/backend/src/middleware/dashboard_auth.rs +++ b/apps/backend/src/middleware/dashboard_auth.rs @@ -53,7 +53,7 @@ pub async fn dashboard_auth_middleware(mut request: Request, next: Next) -> Resp .into_response(); } }; - let user = match verify_session(&token, &repository, &config.hmac_secret).await { + let user = match verify_session(&token, &repository, &config.session_auth_key).await { Ok(user) => user, Err((status, error)) => return (status, error).into_response(), }; @@ -61,7 +61,7 @@ pub async fn dashboard_auth_middleware(mut request: Request, next: Next) -> Resp if matches!(request.method().as_str(), "POST" | "PUT" | "PATCH" | "DELETE") { let session = match repository .session - .find_active_by_hash(&compute_key_hmac(&token, &config.hmac_secret)) + .find_active_by_hash(&compute_key_hmac(&token, &config.session_auth_key)) .await { Ok(Some(session)) => session, @@ -74,7 +74,7 @@ pub async fn dashboard_auth_middleware(mut request: Request, next: Next) -> Resp } }; let (parts, body) = request.into_parts(); - if let Err((status, msg)) = verify_csrf(&parts, &config.hmac_secret, &session.csrf_token_hash) { + if let Err((status, msg)) = verify_csrf(&parts, &config.session_auth_key, &session.csrf_token_hash) { return (status, Json(serde_json::json!({"error": "csrf_error", "message": msg}))).into_response(); } request = Request::from_parts(parts, body); diff --git a/apps/backend/src/router/graphql.rs b/apps/backend/src/router/graphql.rs index ffa91f0c..45148950 100644 --- a/apps/backend/src/router/graphql.rs +++ b/apps/backend/src/router/graphql.rs @@ -30,7 +30,7 @@ async fn graphql_handler( tokio::spawn, ); - let gql_ctx = GqlContext::from_request(repository, services, auth_header, &config.hmac_secret).await; + let gql_ctx = GqlContext::from_request(repository, services, auth_header, &config.token_index_key).await; let response = schema.execute(req.into_inner().data(gql_ctx).data(entry_loader)).await; async_graphql_axum::GraphQLResponse::from(response) diff --git a/apps/backend/src/services/mod.rs b/apps/backend/src/services/mod.rs index 86908277..7acf7b4c 100644 --- a/apps/backend/src/services/mod.rs +++ b/apps/backend/src/services/mod.rs @@ -8,6 +8,7 @@ pub mod entry; pub mod error; pub mod file; pub mod search; +pub mod settings; pub mod singleton; pub mod site; pub mod webhook; @@ -76,7 +77,7 @@ impl Services { auth: Arc::new(auth::AuthService::new( repository.user.clone(), repository.session.clone(), - config.hmac_secret.clone(), + config.session_auth_key.clone(), config.cookie_secure, config.session_lifetime_hours, config.public_registration_enabled, @@ -85,7 +86,7 @@ impl Services { site: Arc::new(site::SiteService::new(repository.site.clone(), repository.user.clone())), access_token: Arc::new(access_token::AccessTokenService::new( repository.access_token.clone(), - config.hmac_secret.clone(), + config.token_index_key.clone(), config.bcrypt_cost, )), collection: Arc::new(collection::CollectionService::new( @@ -112,7 +113,7 @@ impl Services { ), webhook: Arc::new(webhook::WebhookService::new( repository.webhook.clone(), - &config.hmac_secret, + &config.webhook_encryption_key, config.webhook_allow_private_targets, )), search, @@ -131,7 +132,7 @@ fn search_index_path(config: &Config) -> PathBuf { .search_index_path .clone() .map(PathBuf::from) - .unwrap_or_else(crate::paths::search_dir) + .unwrap_or_else(|| PathBuf::from("vcms_data/search")) } /// Open the search index for queries. Returns `None` when search is disabled or the From 533657478cc8b229bf0100a6e68b8a5a56b76c57 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:19:47 +0530 Subject: [PATCH 04/34] refactor(storage)!: make StorageRegistry interior-mutable Change providers HashMap to RwLock for concurrent provider swap at runtime. register() and get() now take &self instead of &mut self. Adds remove() method for hot-swapping providers. --- apps/backend/src/storage/mod.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/storage/mod.rs b/apps/backend/src/storage/mod.rs index 95143ac1..ee555660 100644 --- a/apps/backend/src/storage/mod.rs +++ b/apps/backend/src/storage/mod.rs @@ -4,7 +4,7 @@ pub mod s3; use async_trait::async_trait; use bytes::Bytes; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; pub use filesystem::FileSystemStorage; pub use s3::S3Storage; @@ -36,24 +36,34 @@ impl StorageKind { } } -#[derive(Clone)] pub struct StorageRegistry { - providers: HashMap>, + providers: RwLock>>, } impl StorageRegistry { pub fn new() -> Self { Self { - providers: HashMap::new(), + providers: RwLock::new(HashMap::new()), } } - pub fn register(&mut self, name: &str, provider: Arc) { - self.providers.insert(name.to_string(), provider); + pub fn register(&self, name: &str, provider: Arc) { + self.providers + .write() + .expect("storage registry poisoned") + .insert(name.to_string(), provider); + } + + pub fn remove(&self, name: &str) { + self.providers.write().expect("storage registry poisoned").remove(name); } pub fn get(&self, name: &str) -> Option> { - self.providers.get(name).cloned() + self.providers + .read() + .expect("storage registry poisoned") + .get(name) + .cloned() } pub fn get_by_kind(&self, kind: StorageKind) -> Option> { @@ -214,7 +224,7 @@ mod tests { #[test] fn test_storage_registry_register_and_get() { - let mut registry = StorageRegistry::new(); + let registry = StorageRegistry::new(); let storage: Arc = Arc::new(MockStorage::default()); registry.register("filesystem", storage.clone()); @@ -224,7 +234,7 @@ mod tests { #[test] fn test_storage_registry_get_by_kind() { - let mut registry = StorageRegistry::new(); + let registry = StorageRegistry::new(); let storage: Arc = Arc::new(MockStorage::default()); registry.register("filesystem", storage); From edb770de9587b2ece66b3610e0ac98cb6e1c59a5 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:20:18 +0530 Subject: [PATCH 05/34] feat(settings)!: add DB-backed instance settings with REST API Add instance_settings table (all backends) storing owner-only runtime configuration: general, security, storage, and backups sections. AES-256-GCM encrypted credential storage. REST handler with GET/PUT per section. S3 probe before persisting storage credentials. Owner-only authorization. --- .../20260619000000_instance_settings.sql | 8 + .../20260619000000_instance_settings.sql | 7 + .../20260619000000_instance_settings.sql | 7 + apps/backend/src/handlers/settings_handler.rs | 333 ++++++++++++++ apps/backend/src/models/authorization.rs | 3 + apps/backend/src/router/instance.rs | 8 + apps/backend/src/services/settings.rs | 420 ++++++++++++++++++ 7 files changed, 786 insertions(+) create mode 100644 apps/backend/migrations/mysql/20260619000000_instance_settings.sql create mode 100644 apps/backend/migrations/postgres/20260619000000_instance_settings.sql create mode 100644 apps/backend/migrations/sqlite/20260619000000_instance_settings.sql create mode 100644 apps/backend/src/handlers/settings_handler.rs create mode 100644 apps/backend/src/services/settings.rs diff --git a/apps/backend/migrations/mysql/20260619000000_instance_settings.sql b/apps/backend/migrations/mysql/20260619000000_instance_settings.sql new file mode 100644 index 00000000..764888bf --- /dev/null +++ b/apps/backend/migrations/mysql/20260619000000_instance_settings.sql @@ -0,0 +1,8 @@ +CREATE TABLE instance_settings ( + id INTEGER PRIMARY KEY, + version INTEGER NOT NULL, + settings_json LONGTEXT NOT NULL, + credentials_encrypted LONGTEXT, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CHECK (id = 1) +); diff --git a/apps/backend/migrations/postgres/20260619000000_instance_settings.sql b/apps/backend/migrations/postgres/20260619000000_instance_settings.sql new file mode 100644 index 00000000..baf02f1a --- /dev/null +++ b/apps/backend/migrations/postgres/20260619000000_instance_settings.sql @@ -0,0 +1,7 @@ +CREATE TABLE instance_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + settings_json TEXT NOT NULL, + credentials_encrypted TEXT, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); diff --git a/apps/backend/migrations/sqlite/20260619000000_instance_settings.sql b/apps/backend/migrations/sqlite/20260619000000_instance_settings.sql new file mode 100644 index 00000000..f26e15d3 --- /dev/null +++ b/apps/backend/migrations/sqlite/20260619000000_instance_settings.sql @@ -0,0 +1,7 @@ +CREATE TABLE instance_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + settings_json TEXT NOT NULL, + credentials_encrypted TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/apps/backend/src/handlers/settings_handler.rs b/apps/backend/src/handlers/settings_handler.rs new file mode 100644 index 00000000..146fbd02 --- /dev/null +++ b/apps/backend/src/handlers/settings_handler.rs @@ -0,0 +1,333 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::Extension, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::config::Config; +use crate::database::pool::DbPool; +use crate::error::AppError; +use crate::middleware::auth::{AuthContext, require_instance_action}; +use crate::models::authorization::Action; +use crate::repository::Repository; +use crate::services::backup::BackupService; +use crate::services::settings::{ + BackupSettings, CredentialPair, EncryptedCredentials, GeneralSettings, InstanceSettings, SecuritySettings, + SettingsService, StorageSettings, +}; +use crate::storage::{FileSystemStorage, S3Storage, StorageProvider, StorageRegistry}; + +#[derive(Serialize)] +pub struct SettingsResponse { + #[serde(flatten)] + settings: InstanceSettings, + storage_credentials: CredentialState, + backup_credentials: CredentialState, +} + +#[derive(Serialize)] +pub struct CredentialState { + configured: bool, + masked_access_key_id: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StorageUpdate { + #[serde(flatten)] + settings: StorageSettings, + access_key_id: Option, + secret_access_key: Option, + #[serde(default)] + confirm_target_change: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackupUpdate { + #[serde(flatten)] + settings: BackupSettings, + access_key_id: Option, + secret_access_key: Option, + #[serde(default)] + confirm_target_change: bool, +} + +async fn owner(auth: &AuthContext, repository: &Repository) -> Result<(), Response> { + require_instance_action(auth, repository, Action::InstanceSettings) + .await + .map(|_| ()) + .map_err(|error| error.into_response()) +} + +pub async fn get_settings( + auth: AuthContext, + Extension(repository): Extension, + Extension(settings): Extension, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + response(&settings).await.into_response() +} + +pub async fn update_general( + auth: AuthContext, + Extension(repository): Extension, + Extension(settings): Extension, + Json(section): Json, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + let mut next = (*settings.current()).clone(); + next.general = section; + publish(&settings, next, settings.credentials().await).await +} + +pub async fn update_security( + auth: AuthContext, + Extension(repository): Extension, + Extension(settings): Extension, + Json(section): Json, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + let mut next = (*settings.current()).clone(); + next.security = section; + publish(&settings, next, settings.credentials().await).await +} + +pub async fn update_storage( + auth: AuthContext, + Extension(repository): Extension, + Extension(pool): Extension, + Extension(settings): Extension, + Extension(registry): Extension>, + Json(update): Json, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + let current = settings.current(); + if target_changed(¤t.storage, &update.settings) && !update.confirm_target_change { + return AppError::Conflict("Changing the S3 bucket or endpoint requires confirm_target_change=true".into()) + .into_response(); + } + if update.settings.provider == "filesystem" && current.storage.provider == "s3" { + match s3_site_count(&pool).await { + Ok(count) if count > 0 => { + return AppError::Conflict(format!("{count} site(s) still use S3 storage")).into_response(); + } + Err(error) => return AppError::Internal(error.to_string()).into_response(), + _ => {} + } + } + let mut credentials = settings.credentials().await; + let provider: Option = if update.settings.provider == "filesystem" { + credentials.storage = None; + None + } else { + match merged_pair( + credentials.storage.clone(), + update.access_key_id, + update.secret_access_key, + ) { + Ok(pair) => { + let provider = match probe_s3(&update.settings, &pair).await { + Ok(provider) => provider, + Err(error) => return AppError::BadRequest(format!("S3 probe failed: {error}")).into_response(), + }; + credentials.storage = Some(pair); + Some(provider) + } + Err(error) => return AppError::BadRequest(error).into_response(), + } + }; + let mut next = (*current).clone(); + next.storage = update.settings; + if settings.current().storage != current.storage { + return AppError::Conflict("Storage settings changed while the S3 probe was running; review and retry".into()) + .into_response(); + } + if let Err(error) = settings.publish(next, credentials).await { + return AppError::BadRequest(error).into_response(); + } + match provider { + Some(provider) => registry.register("s3", Arc::new(provider)), + None => registry.remove("s3"), + } + response(&settings).await.into_response() +} + +pub async fn update_backups( + auth: AuthContext, + Extension(repository): Extension, + Extension(config): Extension, + Extension(settings): Extension, + Extension(backup): Extension>, + Json(update): Json, +) -> Response { + if let Err(response) = owner(&auth, &repository).await { + return response; + } + let current = settings.current(); + if backup_target_changed(¤t.backups, &update.settings) && !update.confirm_target_change { + return AppError::Conflict( + "Changing the backup S3 bucket or endpoint requires confirm_target_change=true".into(), + ) + .into_response(); + } + let mut credentials = settings.credentials().await; + let destination: Arc = if update.settings.destination == "filesystem" { + credentials.backups = None; + match FileSystemStorage::new(config.backup_local_path.as_deref().unwrap_or("vcms_data/backups")) { + Ok(provider) => Arc::new(provider), + Err(error) => return AppError::Internal(error.to_string()).into_response(), + } + } else { + match merged_pair( + credentials.backups.clone(), + update.access_key_id, + update.secret_access_key, + ) { + Ok(pair) => { + let storage = StorageSettings { + provider: "s3".into(), + bucket: update.settings.bucket.clone(), + region: update.settings.region.clone(), + endpoint: update.settings.endpoint.clone(), + public_url: None, + }; + let provider = match probe_s3(&storage, &pair).await { + Ok(provider) => provider, + Err(error) => return AppError::BadRequest(format!("S3 probe failed: {error}")).into_response(), + }; + credentials.backups = Some(pair); + Arc::new(provider) + } + Err(error) => return AppError::BadRequest(error).into_response(), + } + }; + let mut next = (*current).clone(); + next.backups = update.settings; + if settings.current().backups != current.backups { + return AppError::Conflict("Backup settings changed while the S3 probe was running; review and retry".into()) + .into_response(); + } + if let Err(error) = settings.publish(next, credentials).await { + return AppError::BadRequest(error).into_response(); + } + backup.set_destination(destination); + response(&settings).await.into_response() +} + +async fn publish(settings: &SettingsService, next: InstanceSettings, credentials: EncryptedCredentials) -> Response { + match settings.publish(next, credentials).await { + Ok(()) => response(settings).await.into_response(), + Err(error) => AppError::BadRequest(error).into_response(), + } +} + +async fn response(settings: &SettingsService) -> Json { + let credentials = settings.credentials().await; + Json(SettingsResponse { + settings: (*settings.current()).clone(), + storage_credentials: credential_state(credentials.storage.as_ref()), + backup_credentials: credential_state(credentials.backups.as_ref()), + }) +} + +fn credential_state(pair: Option<&CredentialPair>) -> CredentialState { + CredentialState { + configured: pair.is_some(), + masked_access_key_id: pair.map(|pair| { + let tail: String = pair + .access_key_id + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect(); + format!("••••{tail}") + }), + } +} + +fn merged_pair( + current: Option, + id: Option, + secret: Option, +) -> Result { + match (id, secret) { + (Some(access_key_id), Some(secret_access_key)) + if !access_key_id.is_empty() && !secret_access_key.is_empty() => + { + Ok(CredentialPair { + access_key_id, + secret_access_key, + }) + } + (None, None) => current.ok_or_else(|| "S3 credentials are required".into()), + _ => Err("provide both access_key_id and secret_access_key, or omit both".into()), + } +} + +async fn probe_s3(settings: &StorageSettings, credentials: &CredentialPair) -> Result { + let storage = S3Storage::new( + &credentials.access_key_id, + &credentials.secret_access_key, + settings.bucket.as_deref().ok_or("bucket is required")?, + settings.region.as_deref().unwrap_or("us-east-1"), + settings.endpoint.as_deref(), + settings.public_url.as_deref(), + ) + .map_err(|error| error.to_string())?; + let key = format!(".vcms-probe/{}", Uuid::new_v4()); + storage + .put(&key, Bytes::from_static(b"vcms"), "application/octet-stream") + .await + .map_err(|error| error.to_string())?; + let read = storage.get(&key).await.map_err(|error| error.to_string())?; + storage.delete(&key).await.map_err(|error| error.to_string())?; + if read.as_ref() != b"vcms" { + return Err("probe read returned unexpected content".into()); + } + Ok(storage) +} + +fn target_changed(old: &StorageSettings, new: &StorageSettings) -> bool { + old.provider == "s3" && new.provider == "s3" && (old.bucket != new.bucket || old.endpoint != new.endpoint) +} + +fn backup_target_changed(old: &BackupSettings, new: &BackupSettings) -> bool { + old.destination == "s3" && new.destination == "s3" && (old.bucket != new.bucket || old.endpoint != new.endpoint) +} + +async fn s3_site_count(pool: &DbPool) -> Result { + match pool { + DbPool::Postgres(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(pool) + .await + } + DbPool::MySql(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(pool) + .await + } + DbPool::Sqlite(pool) => { + sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") + .fetch_one(pool) + .await + } + } +} diff --git a/apps/backend/src/models/authorization.rs b/apps/backend/src/models/authorization.rs index 03dc6886..cca8baf8 100644 --- a/apps/backend/src/models/authorization.rs +++ b/apps/backend/src/models/authorization.rs @@ -79,6 +79,8 @@ impl fmt::Display for SiteRole { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Action { InstanceManage, + /// Change installation-wide runtime settings (owner-only). + InstanceSettings, SiteCreate, SiteRead, SiteManage, @@ -116,6 +118,7 @@ impl Authorizer { Some(InstanceRole::InstanceOwner) => matches!( action, Action::InstanceManage + | Action::InstanceSettings | Action::SiteCreate | Action::SiteDelete | Action::InstanceRolesGrant diff --git a/apps/backend/src/router/instance.rs b/apps/backend/src/router/instance.rs index 1cb6f7f4..f1171b20 100644 --- a/apps/backend/src/router/instance.rs +++ b/apps/backend/src/router/instance.rs @@ -6,6 +6,9 @@ use axum::{ use crate::handlers::instance_handler::{ create_user, delete_user, list_users, set_user_password, update_instance_role, update_user, }; +use crate::handlers::settings_handler::{ + get_settings, update_backups, update_general, update_security, update_storage, +}; pub fn routes() -> Router { Router::new() @@ -15,4 +18,9 @@ pub fn routes() -> Router { .route("/instance/users/{user_id}", delete(delete_user)) .route("/instance/users/{user_id}/role", put(update_instance_role)) .route("/instance/users/{user_id}/password", post(set_user_password)) + .route("/instance/settings", get(get_settings)) + .route("/instance/settings/general", put(update_general)) + .route("/instance/settings/security", put(update_security)) + .route("/instance/settings/storage", put(update_storage)) + .route("/instance/settings/backups", put(update_backups)) } diff --git a/apps/backend/src/services/settings.rs b/apps/backend/src/services/settings.rs new file mode 100644 index 00000000..80b81cba --- /dev/null +++ b/apps/backend/src/services/settings.rs @@ -0,0 +1,420 @@ +use std::sync::Arc; + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use tokio::sync::{RwLock, watch}; + +use crate::database::pool::DbPool; + +pub const SETTINGS_VERSION: u32 = 1; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct InstanceSettings { + pub version: u32, + pub general: GeneralSettings, + pub security: SecuritySettings, + pub storage: StorageSettings, + pub backups: BackupSettings, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct GeneralSettings { + pub public_url: Option, + pub public_registration: bool, + pub session_lifetime_hours: u64, + pub upload_limit_mb: usize, + pub mcp_enabled: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SecuritySettings { + pub secure_cookies: bool, + pub allowed_origins: Vec, + pub trusted_proxy_headers: bool, + pub private_webhook_targets: bool, + pub mcp_allowed_hosts: Vec, + pub mcp_allowed_origins: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct StorageSettings { + pub provider: String, + pub bucket: Option, + pub region: Option, + pub endpoint: Option, + pub public_url: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BackupSettings { + pub enabled: bool, + pub destination: String, + pub retention: u32, + pub bucket: Option, + pub region: Option, + pub endpoint: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CredentialPair { + pub access_key_id: String, + pub secret_access_key: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EncryptedCredentials { + pub storage: Option, + pub backups: Option, +} + +impl Default for InstanceSettings { + fn default() -> Self { + Self { + version: SETTINGS_VERSION, + general: GeneralSettings { + public_url: None, + public_registration: false, + session_lifetime_hours: 24, + upload_limit_mb: 50, + mcp_enabled: true, + }, + security: SecuritySettings { + secure_cookies: false, + allowed_origins: Vec::new(), + trusted_proxy_headers: false, + private_webhook_targets: false, + mcp_allowed_hosts: vec!["localhost".into(), "127.0.0.1".into()], + mcp_allowed_origins: Vec::new(), + }, + storage: StorageSettings { + provider: "filesystem".into(), + bucket: None, + region: Some("us-east-1".into()), + endpoint: None, + public_url: None, + }, + backups: BackupSettings { + enabled: true, + destination: "filesystem".into(), + retention: 7, + bucket: None, + region: Some("us-east-1".into()), + endpoint: None, + }, + } + } +} + +#[derive(Clone)] +pub struct SettingsService { + pool: DbPool, + key: Arc<[u8; 32]>, + snapshot: watch::Sender>, + credentials: Arc>, +} + +impl SettingsService { + pub async fn load(pool: DbPool, master_key: &str) -> Result { + let key = decode_key(&crate::secrets::derive_key_hex( + master_key, + "instance-settings-encryption", + ))?; + let row = load_row(&pool).await.map_err(|error| error.to_string())?; + let (settings, credentials) = match row { + Some((version, settings_json, encrypted)) => { + if version != SETTINGS_VERSION as i64 { + return Err(format!("unsupported instance settings version {version}")); + } + let settings = serde_json::from_str(&settings_json).map_err(|error| error.to_string())?; + let credentials = match encrypted.as_deref() { + Some(value) => decrypt_credentials(&key, value).unwrap_or_else(|error| { + tracing::error!( + "Encrypted integration credentials are unusable; integrations disabled: {error}" + ); + EncryptedCredentials::default() + }), + None => EncryptedCredentials::default(), + }; + (settings, credentials) + } + None => { + let settings = InstanceSettings::default(); + persist(&pool, &settings, None) + .await + .map_err(|error| error.to_string())?; + (settings, EncryptedCredentials::default()) + } + }; + let (snapshot, _) = watch::channel(Arc::new(settings)); + Ok(Self { + pool, + key: Arc::new(key), + snapshot, + credentials: Arc::new(RwLock::new(credentials)), + }) + } + + pub fn current(&self) -> Arc { + self.snapshot.borrow().clone() + } + + pub fn subscribe(&self) -> watch::Receiver> { + self.snapshot.subscribe() + } + + pub async fn credentials(&self) -> EncryptedCredentials { + self.credentials.read().await.clone() + } + + pub async fn publish(&self, settings: InstanceSettings, credentials: EncryptedCredentials) -> Result<(), String> { + validate(&settings)?; + let encrypted = if credentials.storage.is_some() || credentials.backups.is_some() { + Some(encrypt_credentials(&self.key, &credentials)?) + } else { + None + }; + persist(&self.pool, &settings, encrypted.as_deref()) + .await + .map_err(|error| error.to_string())?; + *self.credentials.write().await = credentials; + self.snapshot.send_replace(Arc::new(settings)); + Ok(()) + } + + pub async fn reload(&self) -> Result<(), String> { + let Some((version, settings_json, encrypted)) = + load_row(&self.pool).await.map_err(|error| error.to_string())? + else { + return Err("instance settings row is missing after restore".into()); + }; + if version != SETTINGS_VERSION as i64 { + return Err(format!("unsupported instance settings version {version}")); + } + let settings: InstanceSettings = serde_json::from_str(&settings_json).map_err(|error| error.to_string())?; + validate(&settings)?; + let credentials = match encrypted.as_deref() { + Some(value) => decrypt_credentials(&self.key, value).unwrap_or_default(), + None => EncryptedCredentials::default(), + }; + *self.credentials.write().await = credentials; + self.snapshot.send_replace(Arc::new(settings)); + Ok(()) + } + + pub async fn apply_to_config(&self, config: &mut crate::config::Config) { + let settings = self.current(); + let credentials = self.credentials().await; + config.public_url = settings.general.public_url.clone(); + config.public_registration_enabled = settings.general.public_registration; + config.session_lifetime_hours = settings.general.session_lifetime_hours as i64; + config.max_upload_size_bytes = settings.general.upload_limit_mb * 1024 * 1024; + config.mcp_enabled = settings.general.mcp_enabled; + config.cookie_secure = settings.security.secure_cookies; + config.allowed_origins = settings.security.allowed_origins.clone(); + config.trust_proxy_headers = settings.security.trusted_proxy_headers; + config.webhook_allow_private_targets = settings.security.private_webhook_targets; + config.mcp_allowed_hosts = settings.security.mcp_allowed_hosts.clone(); + config.mcp_allowed_origins = settings.security.mcp_allowed_origins.clone(); + + config.s3_bucket = settings.storage.bucket.clone(); + config.s3_region = settings.storage.region.clone(); + config.s3_endpoint = settings.storage.endpoint.clone(); + config.s3_public_url = settings.storage.public_url.clone(); + config.s3_access_key_id = credentials.storage.as_ref().map(|value| value.access_key_id.clone()); + config.s3_secret_access_key = credentials + .storage + .as_ref() + .map(|value| value.secret_access_key.clone()); + + config.backup_enabled = settings.backups.enabled; + config.backup_destination = settings.backups.destination.clone(); + config.backup_default_retention = settings.backups.retention as i64; + config.backup_s3_bucket = settings.backups.bucket.clone(); + config.backup_s3_region = settings.backups.region.clone(); + config.backup_s3_endpoint = settings.backups.endpoint.clone(); + config.backup_s3_access_key_id = credentials.backups.as_ref().map(|value| value.access_key_id.clone()); + config.backup_s3_secret_access_key = credentials + .backups + .as_ref() + .map(|value| value.secret_access_key.clone()); + } +} + +pub fn validate(settings: &InstanceSettings) -> Result<(), String> { + if settings.version != SETTINGS_VERSION { + return Err("settings version is not supported".into()); + } + if !(1..=168).contains(&settings.general.session_lifetime_hours) { + return Err("session_lifetime_hours must be between 1 and 168".into()); + } + if !(1..=1024).contains(&settings.general.upload_limit_mb) { + return Err("upload_limit_mb must be between 1 and 1024".into()); + } + if let Some(value) = settings.general.public_url.as_deref() { + validate_http_url(value, "public_url")?; + } + for origin in settings + .security + .allowed_origins + .iter() + .chain(&settings.security.mcp_allowed_origins) + { + let parsed = validate_http_url(origin, "allowed origin")?; + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return Err(format!( + "allowed origin must not contain a path, query, or fragment: {origin}" + )); + } + } + if settings + .security + .mcp_allowed_hosts + .iter() + .any(|value| value.trim().is_empty()) + { + return Err("MCP allowed hosts cannot contain empty values".into()); + } + if !matches!(settings.storage.provider.as_str(), "filesystem" | "s3") { + return Err("storage provider must be filesystem or s3".into()); + } + if !matches!(settings.backups.destination.as_str(), "filesystem" | "s3") { + return Err("backup destination must be filesystem or s3".into()); + } + if settings.storage.provider == "s3" && settings.storage.bucket.as_deref().is_none_or(str::is_empty) { + return Err("storage bucket is required for S3".into()); + } + if settings.backups.destination == "s3" && settings.backups.bucket.as_deref().is_none_or(str::is_empty) { + return Err("backup bucket is required for S3".into()); + } + if settings.backups.retention == 0 || settings.backups.retention > 10_000 { + return Err("backup retention must be between 1 and 10000".into()); + } + Ok(()) +} + +fn validate_http_url<'a>(value: &'a str, name: &str) -> Result { + let parsed = url::Url::parse(value).map_err(|error| format!("{name} is invalid: {error}"))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err(format!("{name} must be an absolute HTTP(S) URL")); + } + Ok(parsed) +} + +async fn load_row(pool: &DbPool) -> Result)>, sqlx::Error> { + match pool { + DbPool::Postgres(pool) => { + sqlx::query_as( + "SELECT version::bigint, settings_json, credentials_encrypted FROM instance_settings WHERE id = 1", + ) + .fetch_optional(pool) + .await + } + DbPool::MySql(pool) => sqlx::query_as( + "SELECT CAST(version AS SIGNED), settings_json, credentials_encrypted FROM instance_settings WHERE id = 1", + ) + .fetch_optional(pool) + .await, + DbPool::Sqlite(pool) => { + sqlx::query_as("SELECT version, settings_json, credentials_encrypted FROM instance_settings WHERE id = 1") + .fetch_optional(pool) + .await + } + } +} + +async fn persist(pool: &DbPool, settings: &InstanceSettings, encrypted: Option<&str>) -> Result<(), sqlx::Error> { + let json = serde_json::to_string(settings).map_err(|error| sqlx::Error::Encode(error.into()))?; + match pool { + DbPool::Postgres(pool) => sqlx::query("INSERT INTO instance_settings (id, version, settings_json, credentials_encrypted) VALUES (1, $1, $2, $3) ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, settings_json = EXCLUDED.settings_json, credentials_encrypted = EXCLUDED.credentials_encrypted, updated_at = NOW()") + .bind(SETTINGS_VERSION as i32).bind(json).bind(encrypted).execute(pool).await.map(|_| ()), + DbPool::MySql(pool) => sqlx::query("INSERT INTO instance_settings (id, version, settings_json, credentials_encrypted) VALUES (1, ?, ?, ?) ON DUPLICATE KEY UPDATE version = VALUES(version), settings_json = VALUES(settings_json), credentials_encrypted = VALUES(credentials_encrypted)") + .bind(SETTINGS_VERSION).bind(json).bind(encrypted).execute(pool).await.map(|_| ()), + DbPool::Sqlite(pool) => sqlx::query("INSERT INTO instance_settings (id, version, settings_json, credentials_encrypted) VALUES (1, ?1, ?2, ?3) ON CONFLICT(id) DO UPDATE SET version = excluded.version, settings_json = excluded.settings_json, credentials_encrypted = excluded.credentials_encrypted, updated_at = datetime('now')") + .bind(SETTINGS_VERSION).bind(json).bind(encrypted).execute(pool).await.map(|_| ()), + } +} + +fn decode_key(value: &str) -> Result<[u8; 32], String> { + let bytes = hex::decode(value).map_err(|error| error.to_string())?; + bytes.try_into().map_err(|_| "derived key must be 32 bytes".into()) +} + +fn encrypt_credentials(key: &[u8; 32], credentials: &EncryptedCredentials) -> Result { + let cipher = Aes256Gcm::new(&Key::::from(*key)); + let mut nonce = [0_u8; 12]; + rand::rng().fill_bytes(&mut nonce); + let plain = serde_json::to_vec(credentials).map_err(|error| error.to_string())?; + let nonce_value = Nonce::from(nonce); + let cipher_text = cipher + .encrypt(&nonce_value, plain.as_ref()) + .map_err(|_| "credential encryption failed")?; + let mut envelope = nonce.to_vec(); + envelope.extend(cipher_text); + Ok(format!("v1:{}", BASE64_STANDARD.encode(envelope))) +} + +fn decrypt_credentials(key: &[u8; 32], envelope: &str) -> Result { + let encoded = envelope + .strip_prefix("v1:") + .ok_or("unsupported credential envelope version")?; + let bytes = BASE64_STANDARD.decode(encoded).map_err(|error| error.to_string())?; + if bytes.len() < 13 { + return Err("credential envelope is truncated".into()); + } + let cipher = Aes256Gcm::new(&Key::::from(*key)); + let nonce: [u8; 12] = bytes[..12].try_into().map_err(|_| "credential nonce is invalid")?; + let nonce_value = Nonce::from(nonce); + let plain = cipher + .decrypt(&nonce_value, &bytes[12..]) + .map_err(|_| "credential decryption failed")?; + serde_json::from_slice(&plain).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_limits_and_origins() { + let mut settings = InstanceSettings::default(); + settings.general.upload_limit_mb = 0; + assert!(validate(&settings).unwrap_err().contains("upload_limit_mb")); + settings.general.upload_limit_mb = 50; + settings.security.allowed_origins = vec!["https://example.com/path".into()]; + assert!(validate(&settings).unwrap_err().contains("must not contain a path")); + } + + #[test] + fn credential_envelope_is_versioned_and_authenticated() { + let key = [7_u8; 32]; + let credentials = EncryptedCredentials { + storage: Some(CredentialPair { + access_key_id: "AKIA_TEST".into(), + secret_access_key: "secret".into(), + }), + backups: None, + }; + let encrypted = encrypt_credentials(&key, &credentials).unwrap(); + assert!(encrypted.starts_with("v1:")); + assert!(!encrypted.contains("secret")); + assert_eq!( + decrypt_credentials(&key, &encrypted) + .unwrap() + .storage + .unwrap() + .access_key_id, + "AKIA_TEST" + ); + assert!(decrypt_credentials(&[8_u8; 32], &encrypted).is_err()); + } +} From 6bfa3ff9e6904fd68f9916f35c3a7a503361af2d Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:21:37 +0530 Subject: [PATCH 06/34] feat(settings): propagate settings via router and services Load instance settings at startup, apply to config. Wire with_settings() on auth, webhook, and backup services. Backup scheduler always runs, gated by settings. Dynamic CORS and upload limits from SettingsService. --- apps/backend/src/router/mod.rs | 145 +++++++++++------- apps/backend/src/server.rs | 37 +++-- apps/backend/src/services/auth.rs | 41 ++++- apps/backend/src/services/backup/scheduler.rs | 3 + 4 files changed, 149 insertions(+), 77 deletions(-) diff --git a/apps/backend/src/router/mod.rs b/apps/backend/src/router/mod.rs index 2322fe1b..9f9575ae 100644 --- a/apps/backend/src/router/mod.rs +++ b/apps/backend/src/router/mod.rs @@ -17,11 +17,17 @@ mod webhooks; use std::sync::Arc; +use axum::body::Body; use axum::extract::DefaultBodyLimit; -use axum::http::{HeaderName, Method, header}; -use axum::{Extension, Router, middleware::from_fn, routing::get}; +use axum::extract::{Request, State}; +use axum::http::{HeaderValue, Method, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::{ + Extension, Router, + middleware::{Next, from_fn, from_fn_with_state}, + routing::get, +}; use tokio_util::sync::CancellationToken; -use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::trace::TraceLayer; use crate::config::Config; @@ -35,6 +41,7 @@ use crate::middleware::site_resolver::{api_site_resolver, dashboard_site_resolve use crate::repository::Repository; use crate::services::Services; use crate::services::backup::BackupService; +use crate::services::settings::SettingsService; use crate::storage::StorageRegistry; use crate::tracing::trace_request; @@ -84,6 +91,7 @@ pub fn create_router( storage_registry: Arc, services: Services, backup: Arc, + settings: SettingsService, ) -> Router { let rate_limiter = RateLimiter::new( config.rate_limit_max_requests, @@ -92,49 +100,13 @@ pub fn create_router( ); let max_upload_bytes = config.max_upload_size_bytes; - let cors = if config.allowed_origins.is_empty() { - // No cross-origin access configured: same-origin only. Emitting no - // Access-Control-Allow-Origin header makes browsers block cross-site reads. - CorsLayer::new() - } else { - let origins = config - .allowed_origins - .iter() - .filter_map(|origin| origin.parse().ok()) - .collect::>(); - // Explicit method + header allow-lists are required for credentialed CORS; - // `Any` is silently ignored by browsers when credentials are allowed. - CorsLayer::new() - .allow_origin(AllowOrigin::list(origins)) - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::PATCH, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers([ - header::AUTHORIZATION, - header::CONTENT_TYPE, - header::ACCEPT, - HeaderName::from_static("x-csrf-token"), - ]) - .allow_credentials(true) - }; - - let mcp_enabled = config.mcp_enabled; // Share the single `Services` (and its single-writer search index) with the // MCP HTTP server instead of constructing a second one. - let mcp_services = if mcp_enabled { - Some(Arc::new(services.clone())) - } else { - None - }; + let mcp_services = Arc::new(services.clone()); let mut router = Router::new() // Public, minimal operational probes. Detailed failures remain in logs/doctor. - .merge(health::routes(pool)) + .merge(health::routes(pool.clone())) // ── Auth (no middleware) ── .merge(auth::auth_routes()) // ── Public API (/api/v1/*) ── @@ -168,28 +140,91 @@ pub fn create_router( // ── Global layers ── .layer(from_fn(trace_request)) .layer(TraceLayer::new_for_http()) - .layer(cors) - .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) + .layer(from_fn_with_state(settings.clone(), dynamic_runtime_policy)) + .layer(DefaultBodyLimit::max(1024 * 1024 * 1024)) .layer(Extension(repository.clone())) .layer(Extension(config.clone())) .layer(Extension(storage_registry.clone())) .layer(Extension(services)) .layer(Extension(backup)) + .layer(Extension(settings)) + .layer(Extension(pool)) .layer(Extension(rate_limiter)); - if mcp_enabled { - let mcp_ct = CancellationToken::new(); - let mcp_router = mcp::mcp_routes( - mcp_services.expect("mcp services present when mcp enabled"), - Arc::new(repository), - Arc::new(config), - storage_registry, - mcp_ct, - ); - router = router.merge(mcp_router); + let mcp_ct = CancellationToken::new(); + let mcp_router = mcp::mcp_routes( + mcp_services, + Arc::new(repository), + Arc::new(config), + storage_registry, + mcp_ct, + ); + router = router.merge(mcp_router); + + router +} + +async fn dynamic_runtime_policy(State(service): State, request: Request, next: Next) -> Response { + let settings = service.current(); + let path = request.uri().path(); + if path.starts_with("/mcp") && !settings.general.mcp_enabled { + return StatusCode::NOT_FOUND.into_response(); + } + if path.contains("upload") + && request + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|size| size > settings.general.upload_limit_mb * 1024 * 1024) + { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + "upload exceeds the configured instance limit", + ) + .into_response(); + } + + let origin = request.headers().get(header::ORIGIN).cloned(); + let allowed = origin.as_ref().is_some_and(|origin| { + origin + .to_str() + .is_ok_and(|value| settings.security.allowed_origins.iter().any(|item| item == value)) + }); + if request.method() == Method::OPTIONS && origin.is_some() { + if !allowed { + return StatusCode::FORBIDDEN.into_response(); + } + return cors_response(StatusCode::NO_CONTENT.into_response(), origin); + } + let response = next.run(request).await; + if allowed { + cors_response(response, origin) } else { - drop(repository); + response } +} - router +fn cors_response(mut response: Response, origin: Option) -> Response { + if let Some(origin) = origin { + response + .headers_mut() + .insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_CREDENTIALS, + HeaderValue::from_static("true"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, POST, PUT, PATCH, DELETE, OPTIONS"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("authorization, content-type, accept, x-csrf-token"), + ); + response + .headers_mut() + .append(header::VARY, HeaderValue::from_static("Origin")); + } + response } diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index 3ffdbd2d..23a67c78 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -56,6 +56,11 @@ pub async fn run( .await .map_err(|e| format!("opening database: {e}"))?; + let settings = crate::services::settings::SettingsService::load(pool.clone(), &runtime.secrets.master_key) + .await + .map_err(|error| format!("loading instance settings: {error}"))?; + settings.apply_to_config(&mut config).await; + let repository = Repository::new(&pool); seed_admin(&repository).await; @@ -65,16 +70,21 @@ pub async fn run( // same `Services` so the single-writer search index is opened only once. let repository_arc = Arc::new(repository.clone()); let config_arc = Arc::new(config.clone()); - let services = Services::new(repository_arc.clone(), &pool, &config); + let mut services = Services::new(repository_arc.clone(), &pool, &config); + services.auth = Arc::new((*services.auth).clone().with_settings(settings.clone())); + services.webhook = Arc::new((*services.webhook).clone().with_settings(settings.clone())); let backup_destination = crate::services::backup::build_backup_destination(&config) .map_err(|e| format!("Failed to initialize backup destination: {e}"))?; - let backup_service = Arc::new(crate::services::backup::BackupService::new( - pool.clone(), - storage_registry.clone(), - backup_destination, - &config, - )); + let backup_service = Arc::new( + crate::services::backup::BackupService::new( + pool.clone(), + storage_registry.clone(), + backup_destination, + &config, + ) + .with_settings(settings.clone()), + ); let app = create_router( pool.clone(), @@ -83,6 +93,7 @@ pub async fn run( storage_registry.clone(), services.clone(), backup_service.clone(), + settings, ); // Reconcile backups/restore jobs left mid-flight by a previous process: any @@ -93,13 +104,11 @@ pub async fn run( Err(e) => tracing::error!("Failed to reconcile interrupted backups: {e}"), } - if config.backup_enabled { - let scheduler_service = backup_service.clone(); - tokio::spawn(async move { - crate::services::backup::scheduler::run(scheduler_service).await; - }); - info!("Backup scheduler started"); - } + let scheduler_service = backup_service.clone(); + tokio::spawn(async move { + crate::services::backup::scheduler::run(scheduler_service).await; + }); + info!("Backup scheduler started"); // The search indexer is the single writer/consumer: it rebuilds the index when // empty (first run / wiped), then drains the cross-process queue forever. diff --git a/apps/backend/src/services/auth.rs b/apps/backend/src/services/auth.rs index 66946a5a..6adfa892 100644 --- a/apps/backend/src/services/auth.rs +++ b/apps/backend/src/services/auth.rs @@ -40,6 +40,7 @@ pub struct AuthService { session_lifetime_hours: i64, public_registration_enabled: bool, bcrypt_cost: u32, + settings: Option, } #[derive(Error, Debug)] @@ -108,16 +109,40 @@ impl AuthService { session_lifetime_hours, public_registration_enabled, bcrypt_cost, + settings: None, } } + pub fn with_settings(mut self, settings: crate::services::settings::SettingsService) -> Self { + self.settings = Some(settings); + self + } + + fn runtime_values(&self) -> (bool, i64, bool) { + self.settings.as_ref().map_or( + ( + self.cookie_secure, + self.session_lifetime_hours, + self.public_registration_enabled, + ), + |service| { + let settings = service.current(); + ( + settings.security.secure_cookies, + settings.general.session_lifetime_hours as i64, + settings.general.public_registration, + ) + }, + ) + } + pub async fn register(&self, name: &str, email: &str, password: &str) -> Result { let user_count = self .user_repo .count() .await .map_err(|e| AuthError::DatabaseError(e.to_string()))?; - if user_count > 0 && !self.public_registration_enabled { + if user_count > 0 && !self.runtime_values().2 { return Err(AuthError::RegistrationDisabled); } let name = name.trim(); @@ -204,8 +229,7 @@ impl AuthService { info!("Login successful for user: id={}, name={}", user.id, user.name); let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let csrf_token = Uuid::new_v4().to_string(); - let expires_at = - (chrono::Utc::now() + chrono::Duration::hours(self.session_lifetime_hours)).to_rfc3339(); + let expires_at = (chrono::Utc::now() + chrono::Duration::hours(self.runtime_values().1)).to_rfc3339(); self.session_repo .create( &Uuid::now_v7().to_string(), @@ -451,24 +475,25 @@ impl AuthService { } pub fn cookie_secure(&self) -> bool { - self.cookie_secure + self.runtime_values().0 } pub fn build_auth_cookies_response(&self, user: UserPublic, token: &str, csrf_token: &str) -> Response { + let (cookie_secure, session_lifetime_hours, _) = self.runtime_values(); let token_cookie = Cookie::build(("token", token.to_string())) .http_only(true) - .secure(self.cookie_secure) + .secure(cookie_secure) .same_site(axum_extra::extract::cookie::SameSite::Strict) .path("/") - .max_age(Duration::hours(self.session_lifetime_hours)) + .max_age(Duration::hours(session_lifetime_hours)) .build(); let csrf_cookie = Cookie::build(("csrf", csrf_token.to_string())) .http_only(false) - .secure(self.cookie_secure) + .secure(cookie_secure) .same_site(axum_extra::extract::cookie::SameSite::Strict) .path("/") - .max_age(Duration::hours(self.session_lifetime_hours)) + .max_age(Duration::hours(session_lifetime_hours)) .build(); let mut response = (StatusCode::OK, Json(AuthResponse { user })).into_response(); diff --git a/apps/backend/src/services/backup/scheduler.rs b/apps/backend/src/services/backup/scheduler.rs index 58442456..96bdfedb 100644 --- a/apps/backend/src/services/backup/scheduler.rs +++ b/apps/backend/src/services/backup/scheduler.rs @@ -17,6 +17,9 @@ pub async fn run(service: Arc) { let mut ticker = tokio::time::interval(POLL_INTERVAL); loop { ticker.tick().await; + if !service.scheduler_enabled() { + continue; + } if let Err(e) = tick(&service).await { tracing::error!(error = %e, "backup scheduler tick failed"); } From 33e116357cdcc50f59e0442b754602a231fc521a Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:22:50 +0530 Subject: [PATCH 07/34] feat(webhook)!: add enabled flag and versioned encryption Add enabled boolean column to site_webhooks (all backends) to disable webhooks without deleting. Encrypt headers with v1: prefix version tag. WebhookService gains with_settings() for runtime SSRF override. Dump scrubs headers_encrypted and enabled during backup. --- .../mysql/20260620000000_webhook_enabled.sql | 1 + .../20260620000000_webhook_enabled.sql | 1 + .../sqlite/20260620000000_webhook_enabled.sql | 1 + apps/backend/src/models/webhook.rs | 1 + apps/backend/src/repository/mysql/webhook.rs | 6 +- .../src/repository/postgres/webhook.rs | 6 +- apps/backend/src/repository/sqlite/webhook.rs | 6 +- apps/backend/src/services/backup/schema.rs | 9 ++- apps/backend/src/services/webhook.rs | 64 ++++++++++++++----- apps/backend/src/test_helpers.rs | 19 +----- 10 files changed, 70 insertions(+), 44 deletions(-) create mode 100644 apps/backend/migrations/mysql/20260620000000_webhook_enabled.sql create mode 100644 apps/backend/migrations/postgres/20260620000000_webhook_enabled.sql create mode 100644 apps/backend/migrations/sqlite/20260620000000_webhook_enabled.sql diff --git a/apps/backend/migrations/mysql/20260620000000_webhook_enabled.sql b/apps/backend/migrations/mysql/20260620000000_webhook_enabled.sql new file mode 100644 index 00000000..1cf81724 --- /dev/null +++ b/apps/backend/migrations/mysql/20260620000000_webhook_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE site_webhooks ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/apps/backend/migrations/postgres/20260620000000_webhook_enabled.sql b/apps/backend/migrations/postgres/20260620000000_webhook_enabled.sql new file mode 100644 index 00000000..1cf81724 --- /dev/null +++ b/apps/backend/migrations/postgres/20260620000000_webhook_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE site_webhooks ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/apps/backend/migrations/sqlite/20260620000000_webhook_enabled.sql b/apps/backend/migrations/sqlite/20260620000000_webhook_enabled.sql new file mode 100644 index 00000000..436a3d5d --- /dev/null +++ b/apps/backend/migrations/sqlite/20260620000000_webhook_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE site_webhooks ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; diff --git a/apps/backend/src/models/webhook.rs b/apps/backend/src/models/webhook.rs index a7a4b4c8..116a5100 100644 --- a/apps/backend/src/models/webhook.rs +++ b/apps/backend/src/models/webhook.rs @@ -9,6 +9,7 @@ pub struct SiteWebhook { pub label: String, pub url: String, pub headers_encrypted: String, + pub enabled: bool, pub created_by: Option, pub created_at: String, pub updated_at: String, diff --git a/apps/backend/src/repository/mysql/webhook.rs b/apps/backend/src/repository/mysql/webhook.rs index e700fc89..ff3cc634 100644 --- a/apps/backend/src/repository/mysql/webhook.rs +++ b/apps/backend/src/repository/mysql/webhook.rs @@ -19,7 +19,7 @@ impl MysqlWebhookRepository { impl WebhookRepository for MysqlWebhookRepository { async fn list_for_site(&self, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE site_id = ? ORDER BY created_at", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE site_id = ? ORDER BY created_at", ) .bind(site_id) .fetch_all(&self.pool) @@ -30,7 +30,7 @@ impl WebhookRepository for MysqlWebhookRepository { async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE id = ? AND site_id = ?", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE id = ? AND site_id = ?", ) .bind(id) .bind(site_id) @@ -158,7 +158,7 @@ impl WebhookRepository for MysqlWebhookRepository { impl MysqlWebhookRepository { async fn get_by_id_unscoped(&self, id: &str) -> Result, RepositoryError> { sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE id = ?", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM site_webhooks WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) diff --git a/apps/backend/src/repository/postgres/webhook.rs b/apps/backend/src/repository/postgres/webhook.rs index 56e45f41..ff12ef30 100644 --- a/apps/backend/src/repository/postgres/webhook.rs +++ b/apps/backend/src/repository/postgres/webhook.rs @@ -19,7 +19,7 @@ impl PostgresWebhookRepository { impl WebhookRepository for PostgresWebhookRepository { async fn list_for_site(&self, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE site_id = $1 ORDER BY created_at", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE site_id = $1 ORDER BY created_at", ) .bind(site_id) .fetch_all(&self.pool) @@ -30,7 +30,7 @@ impl WebhookRepository for PostgresWebhookRepository { async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE id = $1 AND site_id = $2", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE id = $1 AND site_id = $2", ) .bind(id) .bind(site_id) @@ -160,7 +160,7 @@ impl WebhookRepository for PostgresWebhookRepository { impl PostgresWebhookRepository { async fn get_by_id_unscoped(&self, id: &str) -> Result, RepositoryError> { sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE id = $1", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE id = $1", ) .bind(id) .fetch_optional(&self.pool) diff --git a/apps/backend/src/repository/sqlite/webhook.rs b/apps/backend/src/repository/sqlite/webhook.rs index 3aea7246..420e649a 100644 --- a/apps/backend/src/repository/sqlite/webhook.rs +++ b/apps/backend/src/repository/sqlite/webhook.rs @@ -19,7 +19,7 @@ impl SqliteWebhookRepository { impl WebhookRepository for SqliteWebhookRepository { async fn list_for_site(&self, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at, updated_at FROM site_webhooks WHERE site_id = ? ORDER BY created_at", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at, updated_at FROM site_webhooks WHERE site_id = ? ORDER BY created_at", ) .bind(site_id) .fetch_all(&self.pool) @@ -30,7 +30,7 @@ impl WebhookRepository for SqliteWebhookRepository { async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at, updated_at FROM site_webhooks WHERE id = ? AND site_id = ?", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at, updated_at FROM site_webhooks WHERE id = ? AND site_id = ?", ) .bind(id) .bind(site_id) @@ -158,7 +158,7 @@ impl WebhookRepository for SqliteWebhookRepository { impl SqliteWebhookRepository { async fn get_by_id_unscoped(&self, id: &str) -> Result, RepositoryError> { sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, created_by, created_at, updated_at FROM site_webhooks WHERE id = ?", + "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at, updated_at FROM site_webhooks WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) diff --git a/apps/backend/src/services/backup/schema.rs b/apps/backend/src/services/backup/schema.rs index d9061ff1..5749a6cd 100644 --- a/apps/backend/src/services/backup/schema.rs +++ b/apps/backend/src/services/backup/schema.rs @@ -188,6 +188,7 @@ pub static TABLES: &[TableSpec] = &[ col("label", Text), col("url", Text), col("headers_encrypted", Text), + col("enabled", Bool), col("created_by", Text), col("created_at", Timestamp), col("updated_at", Timestamp), @@ -285,7 +286,13 @@ pub async fn dump_tables(pool: &DbPool, scope: &Scope) -> Result, encryption_key: Arc<[u8; 32]>, allow_private_targets: bool, + settings: Option, } #[derive(Error, Debug)] @@ -91,9 +92,22 @@ impl WebhookService { webhook_repo, encryption_key: Arc::new(key), allow_private_targets, + settings: None, } } + pub fn with_settings(mut self, settings: crate::services::settings::SettingsService) -> Self { + self.settings = Some(settings); + self + } + + fn allow_private_targets(&self) -> bool { + self.settings + .as_ref() + .map(|service| service.current().security.private_webhook_targets) + .unwrap_or(self.allow_private_targets) + } + pub async fn list_webhooks(&self, site_id: &str) -> Result, WebhookError> { self.webhook_repo .list_for_site(site_id) @@ -130,7 +144,7 @@ impl WebhookService { return Err(WebhookError::InvalidLabel("Label is required".into())); } - if let Err(e) = validate_url(url, self.allow_private_targets) { + if let Err(e) = validate_url(url, self.allow_private_targets()) { warn!( "Webhook creation failed: invalid url_pattern={}, error={}", sanitize_url_for_logging(url), @@ -181,7 +195,7 @@ impl WebhookService { ); if let Some(url_val) = url - && let Err(e) = validate_url(url_val, self.allow_private_targets) + && let Err(e) = validate_url(url_val, self.allow_private_targets()) { warn!( "Webhook update failed: invalid url_pattern={}, error={}", @@ -267,16 +281,23 @@ impl WebhookService { sanitize_url_for_logging(&webhook.url) ); - let headers = decrypt_headers(&webhook.headers_encrypted, &self.encryption_key); + let headers = decrypt_headers_checked(&webhook.headers_encrypted, &self.encryption_key).ok_or_else(|| { + WebhookError::DeliveryFailed("Webhook credentials cannot be decrypted; reconfigure this webhook".into()) + })?; debug!("Decrypted headers for webhook: header_count={}", headers.len()); // Re-validate and resolve right before sending: the stored host may now // resolve to a private address (DNS rebinding). Pin the connection to a // vetted IP and refuse redirects so it can't be bounced internally. - validate_url(&webhook.url, self.allow_private_targets)?; + if !webhook.enabled { + return Err(WebhookError::DeliveryFailed( + "Webhook is disabled and needs reconfiguration".into(), + )); + } + validate_url(&webhook.url, self.allow_private_targets())?; let parsed_url = url::Url::parse(&webhook.url).map_err(|_| WebhookError::InvalidUrl("Invalid URL format".into()))?; - let safe_addr = resolve_safe_addr(&parsed_url, self.allow_private_targets).await?; + let safe_addr = resolve_safe_addr(&parsed_url, self.allow_private_targets()).await?; let host = parsed_url.host_str().unwrap_or_default().to_string(); let client = reqwest::Client::builder() @@ -509,7 +530,7 @@ fn derive_encryption_key(secret: &str) -> [u8; 32] { } /// Encrypt webhook headers with AES-256-GCM. Output is -/// `base64(nonce[12] || ciphertext||tag)`. A fresh random nonce is used per call. +/// `v1:base64(nonce[12] || ciphertext||tag)`. A fresh random nonce is used per call. fn encrypt_headers(headers: &HashMap, key: &[u8; 32]) -> String { let json = serde_json::to_string(headers).unwrap_or_default(); let cipher = Aes256Gcm::new(&Key::::from(*key)); @@ -521,7 +542,7 @@ fn encrypt_headers(headers: &HashMap, key: &[u8; 32]) -> String let mut out = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); out.extend_from_slice(&nonce_bytes); out.extend_from_slice(&ciphertext); - BASE64_STANDARD.encode(out) + format!("v1:{}", BASE64_STANDARD.encode(out)) } Err(_) => String::new(), } @@ -531,26 +552,35 @@ fn encrypt_headers(headers: &HashMap, key: &[u8; 32]) -> String /// (bad base64, wrong key, truncated data, or legacy/incompatible ciphertext) /// yields an empty map rather than an error. fn decrypt_headers(encrypted: &str, key: &[u8; 32]) -> HashMap { - let raw = match BASE64_STANDARD.decode(encrypted) { + decrypt_headers_checked(encrypted, key).unwrap_or_default() +} + +fn decrypt_headers_checked(encrypted: &str, key: &[u8; 32]) -> Option> { + if encrypted.is_empty() { + return Some(HashMap::new()); + } + let Some(encoded) = encrypted.strip_prefix("v1:") else { + return None; + }; + let raw = match BASE64_STANDARD.decode(encoded) { Ok(b) => b, - Err(_) => return HashMap::new(), + Err(_) => return None, }; if raw.len() < 12 { - return HashMap::new(); + return None; } let (nonce_bytes, ciphertext) = raw.split_at(12); let cipher = Aes256Gcm::new(&Key::::from(*key)); let nonce_arr: [u8; 12] = match nonce_bytes.try_into() { Ok(n) => n, - Err(_) => return HashMap::new(), + Err(_) => return None, }; let nonce = Nonce::from(nonce_arr); match cipher.decrypt(&nonce, ciphertext) { - Ok(plaintext) => match String::from_utf8(plaintext) { - Ok(s) => serde_json::from_str(&s).unwrap_or_default(), - Err(_) => HashMap::new(), - }, - Err(_) => HashMap::new(), + Ok(plaintext) => String::from_utf8(plaintext) + .ok() + .and_then(|value| serde_json::from_str(&value).ok()), + Err(_) => None, } } @@ -585,6 +615,7 @@ mod tests { label: "Test Hook".to_string(), url: "https://example.com/hook".to_string(), headers_encrypted: String::new(), + enabled: true, created_by: None, created_at: "2024-01-01 00:00:00".to_string(), updated_at: "2024-01-01 00:00:00".to_string(), @@ -958,6 +989,7 @@ mod tests { label: "Hook".to_string(), url: "https://example.com".to_string(), headers_encrypted: encrypted, + enabled: true, created_by: None, created_at: "2024-01-01 00:00:00".to_string(), updated_at: "2024-01-01 00:00:00".to_string(), diff --git a/apps/backend/src/test_helpers.rs b/apps/backend/src/test_helpers.rs index 810b9b30..63adb064 100644 --- a/apps/backend/src/test_helpers.rs +++ b/apps/backend/src/test_helpers.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -21,23 +20,6 @@ pub fn now_timestamp() -> String { chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() } -/// Synchronize tests that mutate `VCMS_HOME` (process-global env var). -/// The single static `Mutex` here is shared by all callers across modules, -/// preventing races between `paths::tests` and `secrets::tests` etc. -pub fn with_home(value: &Path, f: impl FnOnce() -> T) -> T { - static LOCK: Mutex<()> = Mutex::new(()); - let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let previous = std::env::var_os(crate::paths::CMS_HOME_ENV); - // SAFETY: guarded by LOCK so no other test reads/writes the env concurrently. - unsafe { std::env::set_var(crate::paths::CMS_HOME_ENV, value) }; - let result = f(); - match previous { - Some(v) => unsafe { std::env::set_var(crate::paths::CMS_HOME_ENV, v) }, - None => unsafe { std::env::remove_var(crate::paths::CMS_HOME_ENV) }, - } - result -} - #[derive(Clone)] pub struct InMemoryUserRepository { users: Arc>>, @@ -1271,6 +1253,7 @@ impl crate::repository::traits::WebhookRepository for InMemoryWebhookRepository label: label.to_string(), url: url.to_string(), headers_encrypted: headers_encrypted.to_string(), + enabled: true, created_by: created_by.map(|s| s.to_string()), created_at: now_timestamp(), updated_at: now_timestamp(), From 2ea20cc457e899ef4de9faaca988e87ccd806219 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:23:48 +0530 Subject: [PATCH 08/34] feat(backup)!: exclude access_tokens, add recovery report Remove access_tokens from backup table registry (security-sensitive). Add instance_settings to backup registry. BackupService gains with_settings() for runtime destination swap. Restore returns RecoveryReport with recovery instructions. Credential blobs reloaded post-restore. Dump scrubs webhook secrets. --- apps/backend/src/handlers/backup_handler.rs | 4 +- apps/backend/src/services/backup/mod.rs | 79 ++++++++++++++++++--- apps/backend/src/services/backup/schema.rs | 47 +++++++----- 3 files changed, 100 insertions(+), 30 deletions(-) diff --git a/apps/backend/src/handlers/backup_handler.rs b/apps/backend/src/handlers/backup_handler.rs index dded2a7b..61c7de1c 100644 --- a/apps/backend/src/handlers/backup_handler.rs +++ b/apps/backend/src/handlers/backup_handler.rs @@ -220,7 +220,7 @@ async fn run_restore( }) .await { - Ok(()) => { + Ok(report) => { // The search index is derived data excluded from backups, so rebuild // the affected scope after a restore to reflect the restored content. if let Some(search) = search { @@ -241,7 +241,7 @@ async fn run_restore( tracing::warn!("Post-restore search reindex failed: {}", e); } } - StatusCode::NO_CONTENT.into_response() + (StatusCode::OK, Json(report)).into_response() } Err(e) => err_response(e), } diff --git a/apps/backend/src/services/backup/mod.rs b/apps/backend/src/services/backup/mod.rs index 051bfb9d..50e9e418 100644 --- a/apps/backend/src/services/backup/mod.rs +++ b/apps/backend/src/services/backup/mod.rs @@ -14,6 +14,7 @@ pub mod schema; use std::collections::{HashMap, HashSet}; use std::io::Read; use std::sync::Arc; +use std::sync::RwLock; use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Key, Nonce}; @@ -105,6 +106,13 @@ pub struct Manifest { pub compression: String, pub tables: Vec, pub files: Vec, + #[serde(default)] + pub recovery_required: Vec, +} + +#[derive(Serialize)] +pub struct RecoveryReport { + pub recovery_required: Vec, } // --- Request/options types -------------------------------------------------- @@ -155,9 +163,11 @@ pub struct RestoreRequest { pub struct BackupService { pool: DbPool, storage: Arc, - destination: Arc, + destination: Arc>>, + filesystem_destination: Option>, encryption_key: Option<[u8; 32]>, zstd_level: i32, + settings: Option, } impl BackupService { @@ -168,15 +178,42 @@ impl BackupService { config: &Config, ) -> Self { let encryption_key = config.backup_encryption_key.as_deref().and_then(parse_key_hex); + let filesystem_destination = config + .backup_local_path + .as_deref() + .and_then(|path| FileSystemStorage::new(path).ok()) + .map(|provider| Arc::new(provider) as Arc); Self { pool, storage, - destination, + destination: Arc::new(RwLock::new(destination)), + filesystem_destination, encryption_key, zstd_level: config.backup_zstd_level, + settings: None, } } + pub fn with_settings(mut self, settings: crate::services::settings::SettingsService) -> Self { + self.settings = Some(settings); + self + } + + pub fn scheduler_enabled(&self) -> bool { + self.settings + .as_ref() + .map(|service| service.current().backups.enabled) + .unwrap_or(true) + } + + pub fn set_destination(&self, destination: Arc) { + *self.destination.write().expect("backup destination poisoned") = destination; + } + + fn destination_snapshot(&self) -> Arc { + self.destination.read().expect("backup destination poisoned").clone() + } + pub fn pool(&self) -> &DbPool { &self.pool } @@ -228,6 +265,11 @@ impl BackupService { compression: "zstd".to_string(), tables: table_manifest, files: file_manifest, + recovery_required: vec![ + "Recreate API access tokens".into(), + "Review disabled webhooks and restore secret headers".into(), + "Reconfigure storage and backup credentials when using S3".into(), + ], }; // tar + zstd + AES-GCM are CPU-bound; keep them off the async runtime @@ -306,7 +348,8 @@ impl BackupService { Scope::Instance => format!("instance/{id}.cmsbak"), Scope::Site(sid) => format!("site/{sid}/{id}.cmsbak"), }; - self.destination + let destination = self.destination_snapshot(); + destination .put(&key, bytes::Bytes::from(bytes.clone()), "application/octet-stream") .await .map_err(|e| BackupError::Storage(e.to_string()))?; @@ -318,7 +361,7 @@ impl BackupService { if let Some(row) = meta::get_backup(&self.pool, id).await? && let Some(key) = &row.destination_key { - let _ = self.destination.delete(key).await; + let _ = self.destination_snapshot().delete(key).await; } meta::delete_backup_row(&self.pool, id).await } @@ -420,7 +463,7 @@ impl BackupService { /// [`delete_destination`](Self::delete_destination) after the restore. pub async fn stage_upload(&self, bytes: Vec) -> Result { let key = format!("{TEMP_RESTORE_PREFIX}{}.cmsbak", new_id()); - self.destination + self.destination_snapshot() .put(&key, bytes::Bytes::from(bytes), "application/octet-stream") .await .map_err(|e| BackupError::Storage(e.to_string()))?; @@ -429,13 +472,13 @@ impl BackupService { /// Best-effort delete of a destination object (used to clean up staged uploads). pub async fn delete_destination(&self, key: &str) { - let _ = self.destination.delete(key).await; + let _ = self.destination_snapshot().delete(key).await; } /// Read a backup artifact from the configured destination. pub async fn read_destination(&self, key: &str) -> Result, BackupError> { let bytes = self - .destination + .destination_snapshot() .get(key) .await .map_err(|e| BackupError::Storage(e.to_string()))?; @@ -444,7 +487,7 @@ impl BackupService { /// Restore from a backup artifact, replacing all data within the chosen scope /// in a single transaction. - pub async fn restore(&self, req: RestoreRequest) -> Result<(), BackupError> { + pub async fn restore(&self, req: RestoreRequest) -> Result { let bytes = match &req.source { RestoreSource::Bytes(b) => b.clone(), RestoreSource::Destination(key) => self.read_destination(key).await?, @@ -495,9 +538,21 @@ impl BackupService { schema::apply_restore(&self.pool, &plan).await?; + // Credential blobs are intentionally absent from backups. Drop any old + // process-held providers before publishing restored non-secret settings. + self.storage.remove("s3"); + if let Some(destination) = &self.filesystem_destination { + self.set_destination(destination.clone()); + } + if let Some(settings) = &self.settings { + settings.reload().await.map_err(BackupError::Invalid)?; + } + // Restore file blobs (best-effort: a missing blob is non-fatal). self.restore_files(&manifest, &file_blobs).await; - Ok(()) + Ok(RecoveryReport { + recovery_required: manifest.recovery_required, + }) } /// Build a merged, atomic restore plan for one or more sites extracted from the @@ -689,7 +744,7 @@ pub fn build_backup_destination(config: &Config) -> Result Result schema::RestorePlan { let deletes = vec![ + schema::Statement { + sql: "DELETE FROM instance_settings".into(), + rows: vec![], + }, schema::Statement { sql: "DELETE FROM sites".into(), rows: vec![], diff --git a/apps/backend/src/services/backup/schema.rs b/apps/backend/src/services/backup/schema.rs index 5749a6cd..b53344e9 100644 --- a/apps/backend/src/services/backup/schema.rs +++ b/apps/backend/src/services/backup/schema.rs @@ -56,6 +56,16 @@ use ColType::{Bool, Int, Json, Text, Timestamp}; /// All dumped tables, in FK parent→child order (the order restore inserts in). pub static TABLES: &[TableSpec] = &[ + TableSpec { + name: "instance_settings", + site_where: SiteWhere::InstanceOnly, + columns: &[ + col("id", Int), + col("version", Int), + col("settings_json", Text), + col("updated_at", Timestamp), + ], + }, TableSpec { name: "users", site_where: SiteWhere::InstanceOnly, @@ -161,24 +171,6 @@ pub static TABLES: &[TableSpec] = &[ col("change_summary", Text), ], }, - TableSpec { - name: "access_tokens", - site_where: SiteWhere::SiteId, - columns: &[ - col("id", Text), - col("site_id", Text), - col("name", Text), - col("token_hash", Text), - col("token_prefix", Text), - col("token_hmac", Text), - col("permission", Text), - col("created_by_user_id", Text), - col("last_used_at", Timestamp), - col("created_at", Timestamp), - col("expires_at", Timestamp), - col("revoked_at", Timestamp), - ], - }, TableSpec { name: "site_webhooks", site_where: SiteWhere::SiteId, @@ -210,6 +202,25 @@ pub static TABLES: &[TableSpec] = &[ }, ]; +#[cfg(test)] +mod registry_tests { + use super::*; + + #[test] + fn backup_registry_excludes_tokens_and_encrypted_credentials() { + assert!(table_spec("access_tokens").is_none()); + let settings = table_spec("instance_settings").unwrap(); + assert!( + !settings + .columns + .iter() + .any(|column| column.name == "credentials_encrypted") + ); + let webhooks = table_spec("site_webhooks").unwrap(); + assert!(webhooks.columns.iter().any(|column| column.name == "enabled")); + } +} + pub fn table_spec(name: &str) -> Option<&'static TableSpec> { TABLES.iter().find(|t| t.name == name) } From 439924d8665c667ddcd5d693084c3e2e69915789 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:24:31 +0530 Subject: [PATCH 09/34] feat(service)!: cross-platform native service entrypoint vcms service run is now the cross-platform installed-service entrypoint, no longer Windows-only. Adds is_installed() detection per platform. Portable mode prints startup banner with mode, data path, and endpoints. --- apps/backend/src/server.rs | 23 ++++++++++++++- apps/backend/src/service/mod.rs | 22 ++++++++++++-- apps/backend/src/service/status.rs | 45 +++++++++++++++++++++++++++++ apps/backend/src/service/windows.rs | 20 +++---------- 4 files changed, 91 insertions(+), 19 deletions(-) diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index 23a67c78..f5396d9e 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -140,9 +140,30 @@ pub async fn run( let grpc_addr: SocketAddr = config .grpc_bind_address .parse() - .map_err(|e| format!("Invalid GRPC_BIND_ADDRESS '{}': {e}", config.grpc_bind_address))?; + .map_err(|e| format!("Invalid server.grpc_address '{}': {e}", config.grpc_bind_address))?; info!("gRPC server running on {}", grpc_addr); + if runtime.mode == crate::paths::RuntimeMode::Portable { + println!( + "\nVelopulent CMS\n\ + Mode portable\n\ + Data {}\n\ + Dashboard http://{}/dashboard\n\ + REST http://{}/api/v1\n\ + GraphQL http://{}/api/graphql\n\ + gRPC {}\n\ + MCP http://{}/mcp\n\ + Logs {}\n", + runtime.paths.root().display(), + addr, + addr, + addr, + grpc_addr, + addr, + runtime.paths.logs_dir().display(), + ); + } + // Bind both listeners *before* declaring readiness (and before the serve loops // spawn): a bind failure — the classic "port already taken" — must surface as a // startup error, not as a background task that dies after we claimed to be up. diff --git a/apps/backend/src/service/mod.rs b/apps/backend/src/service/mod.rs index 6f77067b..9322b3fe 100644 --- a/apps/backend/src/service/mod.rs +++ b/apps/backend/src/service/mod.rs @@ -14,7 +14,25 @@ mod windows; pub async fn run_service(action: &ServiceAction, _cli: &Cli) -> Result<(), Box> { match action { ServiceAction::Status => status::print(), - #[cfg(windows)] - ServiceAction::Run => windows::dispatch(action, _cli), + ServiceAction::Run => { + #[cfg(windows)] + { + windows::dispatch(action, _cli) + } + #[cfg(not(windows))] + { + let context = crate::runtime::RuntimeContext::initialize(crate::paths::RuntimeMode::Installed)?; + crate::server::run(context, crate::server::shutdown_signal(), || {}).await + } + } } } + +pub fn is_installed() -> Result> { + status::is_installed() +} + +#[cfg(windows)] +pub fn run_service_sync(action: &ServiceAction, cli: &Cli) -> Result<(), Box> { + windows::dispatch(action, cli) +} diff --git a/apps/backend/src/service/status.rs b/apps/backend/src/service/status.rs index 96bd74af..b84bc282 100644 --- a/apps/backend/src/service/status.rs +++ b/apps/backend/src/service/status.rs @@ -24,6 +24,51 @@ pub fn print() -> Result<(), Box> { Ok(()) } +/// Detect registration, not runtime state. Data-directory existence is never used. +pub fn is_installed() -> Result> { + #[cfg(target_os = "linux")] + { + if [ + "/etc/systemd/system/vcms.service", + "/lib/systemd/system/vcms.service", + "/usr/lib/systemd/system/vcms.service", + ] + .iter() + .any(|path| std::path::Path::new(path).is_file()) + { + return Ok(true); + } + } + #[cfg(target_os = "macos")] + { + return Ok(std::path::Path::new("/Library/LaunchDaemons/com.velopulent.vcms.plist").is_file()); + } + + let (manager, mut command) = native_command(); + let output = command + .output() + .map_err(|error| format!("cannot query {manager} service registration: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let text = format!("{stdout} {stderr}").to_ascii_lowercase(); + if text.contains("could not be found") + || text.contains("not-found") + || text.contains("no such process") + || text.contains("failed to get unit file state") + || text.contains("1060") + { + return Ok(false); + } + if output.status.success() || text.contains("loaded") || text.contains("running") || text.contains("stopped") { + return Ok(true); + } + Err(format!( + "{manager} could not determine whether vcms is installed: {}", + stderr.trim() + ) + .into()) +} + fn normalized_state(success: bool, stdout: &str, stderr: &str) -> &'static str { let text = format!("{stdout} {stderr}").to_ascii_lowercase(); if text.contains("running") || text.trim() == "active" { diff --git a/apps/backend/src/service/windows.rs b/apps/backend/src/service/windows.rs index be2c563b..56c50209 100644 --- a/apps/backend/src/service/windows.rs +++ b/apps/backend/src/service/windows.rs @@ -21,11 +21,9 @@ use windows_service::{define_windows_service, service_dispatcher}; use super::SERVICE_NAME; use crate::cli::{Cli, ServiceAction}; -/// Fixed home for the LocalSystem service (Windows convention is ProgramData). -/// The service host sets this before runtime startup so direct SCM launches always -/// use the machine-wide data directory. +/// Fixed home for the native service (Windows convention is ProgramData). fn service_home() -> std::path::PathBuf { - crate::paths::system_home().expect("system_home is always set on Windows") + crate::paths::system_root().expect("system_root is always set on Windows") } pub fn dispatch(action: &ServiceAction, _cli: &Cli) -> Result<(), Box> { @@ -39,17 +37,6 @@ define_windows_service!(ffi_service_main, service_main); /// Entry point for `vcms service run`: hand the thread to the SCM dispatcher. fn run_dispatcher() -> Result<(), Box> { - // Pin the service to the ProgramData home so it doesn't fall back to the - // LocalSystem profile. Set before any thread/runtime reads the environment. - if std::env::var_os(crate::paths::CMS_HOME_ENV).is_none() { - // SAFETY: called once at process start, before tokio/threads spin up. - unsafe { - std::env::set_var(crate::paths::CMS_HOME_ENV, service_home()); - } - } - // A service has no console, so the default stdout logging goes nowhere. Default - // to file output (lands in the home's logs/ — where install tells the user to - // look); an explicit LOG_OUTPUT (env or .env) still wins. service_dispatcher::start(SERVICE_NAME, ffi_service_main)?; Ok(()) } @@ -150,7 +137,8 @@ fn run_service_session() -> Result<(), Box> { // a fresh runtime here is safe — no nested-runtime panic. let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; let shutdown = async move { notify.notified().await }; - let result = runtime.block_on(crate::server::run(&Cli::default(), shutdown, on_ready)); + let context = crate::runtime::RuntimeContext::initialize(crate::paths::RuntimeMode::Installed)?; + let result = runtime.block_on(crate::server::run(context, shutdown, on_ready)); if let Err(e) = &result { log_service_error(&format!("server startup/run failed: {e}")); From ae86e3eb185c1ef7b219c3dba271de11f502b043 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:24:55 +0530 Subject: [PATCH 10/34] build(packaging)!: update service files and xtask for new entrypoint Service definitions use 'vcms service run' instead of 'serve'. Remove environment variable overrides from service files. Windows MSI adds WiX Util extension for fine-grained ACLs. xtask config samples updated for new TOML structure. Release workflow adds WiX extension install. --- .github/workflows/release.yml | 1 + apps/backend/project.json | 5 ++-- packaging/linux/vcms.service | 3 +-- packaging/macos/com.velopulent.vcms.plist | 4 +-- packaging/windows/vcms.wxs.template | 11 ++++++-- xtask/README.md | 3 ++- xtask/src/linux.rs | 11 +++++--- xtask/src/macos.rs | 5 ++-- xtask/src/portable.rs | 2 +- xtask/src/shared.rs | 19 ++++---------- xtask/src/windows.rs | 32 +++++++++++++++++++---- 11 files changed, 60 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e50498c7..ade7e813 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,6 +103,7 @@ jobs: dotnet tool install --global wix --version 7.* wix eula accept wix7 wix extension add --global WixToolset.UI.wixext/7.0.0 + wix extension add --global WixToolset.Util.wixext/7.0.0 # Build cargo directly (dist is already present from the artifact); keep the # default `embed-dashboard` feature so rust-embed bakes in apps/dashboard/dist. diff --git a/apps/backend/project.json b/apps/backend/project.json index 7a619b04..86b8a01c 100644 --- a/apps/backend/project.json +++ b/apps/backend/project.json @@ -28,7 +28,7 @@ "executor": "nx:run-commands", "dependsOn": ["build-dev"], "options": { - "command": "cargo run --locked", + "command": "cargo run --locked -- serve", "env": { "SKIP_DASHBOARD_BUILD": "1" } }, "cache": false @@ -37,8 +37,7 @@ "executor": "nx:run-commands", "dependsOn": [], "options": { - "command": "cargo run --locked --no-default-features", - "env": { "DEV_MODE": "1" } + "command": "cargo run --locked --no-default-features -- serve" }, "cache": false }, diff --git a/packaging/linux/vcms.service b/packaging/linux/vcms.service index 51ae9bd2..2c4390eb 100644 --- a/packaging/linux/vcms.service +++ b/packaging/linux/vcms.service @@ -8,8 +8,7 @@ Wants=network-online.target Type=simple User=vcms Group=vcms -Environment=VCMS_HOME=/var/lib/vcms -ExecStart=/usr/bin/vcms serve +ExecStart=/usr/bin/vcms service run Restart=on-failure RestartSec=5s TimeoutStartSec=90s diff --git a/packaging/macos/com.velopulent.vcms.plist b/packaging/macos/com.velopulent.vcms.plist index 5d87916d..15dc6f3b 100644 --- a/packaging/macos/com.velopulent.vcms.plist +++ b/packaging/macos/com.velopulent.vcms.plist @@ -4,9 +4,7 @@ Labelcom.velopulent.vcms ProgramArguments - /usr/local/bin/vcmsserve--config/Library/Application Support/vcms/config.toml - EnvironmentVariables - VCMS_HOME/Library/Application Support/vcms + /usr/local/bin/vcmsservicerun UserName_vcms GroupName_vcms RunAtLoad diff --git a/packaging/windows/vcms.wxs.template b/packaging/windows/vcms.wxs.template index e1e96f54..7ce08c0e 100644 --- a/packaging/windows/vcms.wxs.template +++ b/packaging/windows/vcms.wxs.template @@ -1,5 +1,5 @@ - + @@ -7,6 +7,9 @@ + + + @@ -18,7 +21,11 @@ - + + + + + diff --git a/xtask/README.md b/xtask/README.md index 11eb2f62..3de0d24b 100644 --- a/xtask/README.md +++ b/xtask/README.md @@ -104,11 +104,12 @@ Native package tools are also required: MSI automation uses WiX v7's direct `-acceptEula wix7` build switch. Running an MSI build therefore accepts the WiX v7 OSMF EULA for that invocation. -Install the matching UI extension once before local MSI builds: +Install the matching UI and Util extensions once before local MSI builds: ```console wix eula accept wix7 wix extension add --global WixToolset.UI.wixext/7.0.0 +wix extension add --global WixToolset.Util.wixext/7.0.0 ``` Real native packages must be built on their matching OS. `--target-os` selects and diff --git a/xtask/src/linux.rs b/xtask/src/linux.rs index d453766e..951a1b16 100644 --- a/xtask/src/linux.rs +++ b/xtask/src/linux.rs @@ -139,11 +139,12 @@ mod tests { use super::*; #[test] - fn service_is_hardened_and_uses_journal() { + fn service_is_hardened_and_uses_installed_entrypoint() { let unit = systemd_service(); assert!(unit.contains("ProtectSystem=strict")); assert!(unit.contains("CapabilityBoundingSet=")); - assert!(!unit.contains("LOG_OUTPUT=file")); + assert!(unit.contains("ExecStart=/usr/bin/vcms service run")); + assert!(!unit.contains("VCMS_HOME")); } #[test] @@ -161,7 +162,11 @@ mod tests { let spec = include_str!("../../packaging/rpm/vcms.spec.template"); assert!(spec.contains("%config(noreplace) /var/lib/vcms/config.toml.sample")); - assert!(!spec.lines().any(|line| line == "%config(noreplace) /var/lib/vcms/config.toml")); + assert!( + !spec + .lines() + .any(|line| line == "%config(noreplace) /var/lib/vcms/config.toml") + ); assert!(spec.contains("cp /var/lib/vcms/config.toml.sample /var/lib/vcms/config.toml")); } } diff --git a/xtask/src/macos.rs b/xtask/src/macos.rs index d1a3a916..06d0fb36 100644 --- a/xtask/src/macos.rs +++ b/xtask/src/macos.rs @@ -72,10 +72,11 @@ mod tests { use super::*; #[test] - fn plist_uses_dedicated_identity_and_paths() { + fn plist_uses_dedicated_identity_and_installed_entrypoint() { let plist = launchd_plist(); assert!(plist.contains("UserName_vcms")); - assert!(plist.contains("/Library/Application Support/vcms")); + assert!(plist.contains("servicerun")); + assert!(!plist.contains("VCMS_HOME")); assert!(plist.contains("StandardErrorPath")); } diff --git a/xtask/src/portable.rs b/xtask/src/portable.rs index c7afd315..14aad108 100644 --- a/xtask/src/portable.rs +++ b/xtask/src/portable.rs @@ -26,7 +26,7 @@ pub fn build(context: &Context) -> Result { write_file( &stage.join("README.txt"), format!( - "Velopulent CMS portable package\n\nRun `{APP_NAME}` directly. No service is registered. Runtime files use platform user directories unless VCMS_HOME is set.\nTarget OS: {}\n", + "Velopulent CMS portable package\n\nRun `{APP_NAME} serve`. No service is registered. Runtime files are created in ./vcms_data under your working directory.\nTarget OS: {}\n", context.target_os.as_str() ), )?; diff --git a/xtask/src/shared.rs b/xtask/src/shared.rs index 7e7ac000..46e0c9df 100644 --- a/xtask/src/shared.rs +++ b/xtask/src/shared.rs @@ -56,21 +56,12 @@ pub fn find_file(root: &Path, extension: &str) -> Result { } pub fn config_sample() -> &'static str { - r#"bind_address = "0.0.0.0:3000" -grpc_bind_address = "0.0.0.0:50051" -max_upload_size_mb = 50 -cookie_secure = false -session_lifetime_hours = 24 -db_max_connections = 10 -rate_limit_max_requests = 100 -mcp_enabled = true -mcp_allowed_hosts = ["localhost", "127.0.0.1"] + r#"[server] +http_address = "127.0.0.1:3000" +grpc_address = "127.0.0.1:50051" [log] -level = "cms=info,vcms=info,tower_http=info,axum=info" -output = "stdout" -format = "json" -annotations = false -dir = "logs" +level = "cms=info,vcms=info" +output = "file" "# } diff --git a/xtask/src/windows.rs b/xtask/src/windows.rs index d31822b2..715e7a32 100644 --- a/xtask/src/windows.rs +++ b/xtask/src/windows.rs @@ -24,7 +24,8 @@ pub fn build(context: &Context) -> Result { fs::write(&artifact, b"dry-run msi\n")?; } else { let package = root.join("vcms.msi"); - let ui_extension = wix_ui_extension()?; + let ui_extension = wix_extension("WixToolset.UI.wixext")?; + let util_extension = wix_extension("WixToolset.Util.wixext")?; run_cmd( Command::new("wix") .arg("build") @@ -33,6 +34,8 @@ pub fn build(context: &Context) -> Result { .args(["-pdbtype", "none"]) .arg("-ext") .arg(ui_extension) + .arg("-ext") + .arg(util_extension) .arg(root.join("vcms.wxs")) .arg("-out") .arg(&package) @@ -45,14 +48,17 @@ pub fn build(context: &Context) -> Result { Ok(artifact) } -fn wix_ui_extension() -> Result { +fn wix_extension(name: &str) -> Result { let profile = env::var_os("USERPROFILE").ok_or("USERPROFILE is not set")?; - let extension = - PathBuf::from(profile).join(".wix/extensions/WixToolset.UI.wixext/7.0.0/wixext7/WixToolset.UI.wixext.dll"); + let extension = PathBuf::from(profile) + .join(".wix/extensions") + .join(name) + .join("7.0.0/wixext7") + .join(format!("{name}.dll")); if extension.is_file() { Ok(extension) } else { - Err("WiX UI extension missing; run `wix extension add --global WixToolset.UI.wixext/7.0.0`".into()) + Err(format!("WiX extension missing; run `wix extension add --global {name}/7.0.0`").into()) } } @@ -132,10 +138,26 @@ mod tests { r#""# )); assert!(source.contains("ProgramDataVcms")); + assert!(source.contains(r#"xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util""#)); + assert!(source.contains(r#""#)); + assert!(source.contains( + r#""# + )); + assert!(!source.contains(""#)); assert!(source.contains(r#"ProductCode=""#)); assert!(!source.contains("@PRODUCT_CODE@")); assert!(source.contains(r#""#)); assert!(source.contains(r#" Date: Sat, 18 Jul 2026 23:25:23 +0530 Subject: [PATCH 11/34] feat(dashboard): add instance settings UI Add General, Security, Storage, and Backup settings panels for owner-only instance configuration. Switch toggle component. Settings routes with owner-only visibility. API types and functions for instance settings CRUD. Route tree updated with new settings routes. --- .../components/instance/settings-forms.tsx | 619 ++++++++++++++++++ apps/dashboard/src/components/ui/switch.tsx | 30 + apps/dashboard/src/lib/api.ts | 55 ++ apps/dashboard/src/routeTree.gen.ts | 44 ++ .../src/routes/_admin/_shell/settings.tsx | 48 +- .../routes/_admin/_shell/settings/backups.tsx | 10 +- .../routes/_admin/_shell/settings/index.tsx | 53 +- .../_admin/_shell/settings/security.tsx | 15 + .../routes/_admin/_shell/settings/storage.tsx | 15 + 9 files changed, 834 insertions(+), 55 deletions(-) create mode 100644 apps/dashboard/src/components/instance/settings-forms.tsx create mode 100644 apps/dashboard/src/components/ui/switch.tsx create mode 100644 apps/dashboard/src/routes/_admin/_shell/settings/security.tsx create mode 100644 apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx diff --git a/apps/dashboard/src/components/instance/settings-forms.tsx b/apps/dashboard/src/components/instance/settings-forms.tsx new file mode 100644 index 00000000..ba25613e --- /dev/null +++ b/apps/dashboard/src/components/instance/settings-forms.tsx @@ -0,0 +1,619 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { InputGroup, InputGroupInput } from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + getInstanceSettings, + type InstanceSettings, + updateInstanceSettingsSection, +} from "@/lib/api"; + +export function SettingsLoading() { + return ( + + + + + + + + + + + + ); +} + +function useSettings() { + return useQuery({ + queryKey: ["instance-settings"], + queryFn: getInstanceSettings, + }); +} + +function useSave(section: "general" | "security" | "storage" | "backups") { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: unknown) => updateInstanceSettingsSection(section, data), + onSuccess: (data) => { + queryClient.setQueryData(["instance-settings"], data); + toast.success("Settings saved"); + }, + onError: (error: Error) => toast.error(error.message), + }); +} + +function ToggleField({ + label, + description, + checked, + onCheckedChange, +}: { + label: string; + description: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( + +
+ {label} + {description} +
+ +
+ ); +} + +function TextField({ + label, + value, + onChange, + placeholder, + type = "text", +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: string; +}) { + return ( + + {label} + onChange(event.target.value)} + /> + + ); +} + +export function GeneralSettingsPanel() { + const query = useSettings(); + if (query.isLoading) return ; + if (!query.data) + return

Unable to load settings.

; + return ( + + ); +} + +function GeneralForm({ settings }: { settings: InstanceSettings }) { + const [form, setForm] = useState(settings.general); + const save = useSave("general"); + return ( + + + General + + Public behavior and user-facing runtime limits. + + + + + + setForm({ ...form, public_url: value || null }) + } + /> +
+ + setForm({ ...form, session_lifetime_hours: Number(value) }) + } + /> + + setForm({ ...form, upload_limit_mb: Number(value) }) + } + /> +
+ + setForm({ ...form, public_registration: value }) + } + /> + + setForm({ ...form, mcp_enabled: value }) + } + /> +
+
+ + + +
+ ); +} + +export function SecuritySettingsPanel() { + const query = useSettings(); + if (query.isLoading) return ; + if (!query.data) + return

Unable to load settings.

; + return ( + + ); +} + +function lines(value: string) { + return value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function SecurityForm({ settings }: { settings: InstanceSettings }) { + const initial = settings.security; + const [form, setForm] = useState(initial); + const save = useSave("security"); + return ( + + + Security + + Browser, proxy, webhook, and MCP trust boundaries. + + + + + + setForm({ ...form, allowed_origins: lines(value) }) + } + /> + + setForm({ ...form, mcp_allowed_hosts: lines(value) }) + } + /> + + setForm({ ...form, mcp_allowed_origins: lines(value) }) + } + /> + + setForm({ ...form, secure_cookies: value }) + } + /> + + setForm({ ...form, trusted_proxy_headers: value }) + } + /> + + setForm({ ...form, private_webhook_targets: value }) + } + /> + + + + + + + ); +} + +type ProviderSection = + | InstanceSettings["storage"] + | InstanceSettings["backups"]; + +function Credentials({ + configured, + masked, + accessKey, + secretKey, + setAccessKey, + setSecretKey, +}: { + configured: boolean; + masked: string | null; + accessKey: string; + secretKey: string; + setAccessKey: (value: string) => void; + setSecretKey: (value: string) => void; +}) { + return ( +
+ +
+ Access key ID + {configured && Configured {masked}} +
+ + setAccessKey(event.target.value)} + /> + +
+ + Secret access key + + setSecretKey(event.target.value)} + /> + + +
+ ); +} + +function ProviderFields({ + section, + onChange, +}: { + section: ProviderSection; + onChange: (next: ProviderSection) => void; +}) { + const provider = + "provider" in section ? section.provider : section.destination; + const setProvider = (value: "filesystem" | "s3") => + onChange( + "provider" in section + ? { ...section, provider: value } + : { ...section, destination: value }, + ); + return ( + <> + + Provider + + + {provider === "s3" && ( +
+ + onChange({ ...section, bucket: value || null }) + } + /> + + onChange({ ...section, region: value || null }) + } + /> + + onChange({ ...section, endpoint: value || null }) + } + /> + {"public_url" in section && ( + + onChange({ ...section, public_url: value || null }) + } + /> + )} +
+ )} + + ); +} + +export function StorageSettingsPanel() { + const query = useSettings(); + if (query.isLoading) return ; + if (!query.data) + return

Unable to load settings.

; + return ( + + ); +} + +function StorageForm({ settings }: { settings: InstanceSettings }) { + const [form, setForm] = useState(settings.storage); + const [accessKey, setAccessKey] = useState(""); + const [secretKey, setSecretKey] = useState(""); + const [confirming, setConfirming] = useState(false); + const save = useSave("storage"); + const targetChanged = + settings.storage.provider === "s3" && + form.provider === "s3" && + (settings.storage.bucket !== form.bucket || + settings.storage.endpoint !== form.endpoint); + const submit = (confirmed = false) => + save.mutate({ + ...form, + access_key_id: accessKey || null, + secret_access_key: secretKey || null, + confirm_target_change: confirmed, + }); + return ( + <> + + + Storage + + One global provider. Filesystem paths stay fixed under the data + root. + + + + + setForm(next as InstanceSettings["storage"])} + /> + {form.provider === "s3" && ( + + )} + + + + + + + submit(true)} + /> + + ); +} + +function ConfirmTarget({ + open, + onOpenChange, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + return ( + + + + Change S3 target? + + Existing objects are not moved. Confirm only after arranging + migration or accepting that old files may be unavailable. + + + + Cancel + + Change target + + + + + ); +} + +export function BackupSettingsPanel() { + const query = useSettings(); + if (query.isLoading) return ; + if (!query.data) + return

Unable to load settings.

; + return ( + + ); +} + +function BackupForm({ settings }: { settings: InstanceSettings }) { + const [form, setForm] = useState(settings.backups); + const [accessKey, setAccessKey] = useState(""); + const [secretKey, setSecretKey] = useState(""); + const [confirming, setConfirming] = useState(false); + const save = useSave("backups"); + const targetChanged = + settings.backups.destination === "s3" && + form.destination === "s3" && + (settings.backups.bucket !== form.bucket || + settings.backups.endpoint !== form.endpoint); + const submit = (confirmed = false) => + save.mutate({ + ...form, + access_key_id: accessKey || null, + secret_access_key: secretKey || null, + confirm_target_change: confirmed, + }); + return ( + <> + + + Backup destination + + Scheduled backup policy and its separate destination. + + + + + setForm({ ...form, enabled: value })} + /> + + setForm({ ...form, retention: Number(value) }) + } + /> + setForm(next as InstanceSettings["backups"])} + /> + {form.destination === "s3" && ( + + )} + + + + + + + submit(true)} + /> + + ); +} diff --git a/apps/dashboard/src/components/ui/switch.tsx b/apps/dashboard/src/components/ui/switch.tsx new file mode 100644 index 00000000..add5028f --- /dev/null +++ b/apps/dashboard/src/components/ui/switch.tsx @@ -0,0 +1,30 @@ +import { Switch as SwitchPrimitive } from "@base-ui/react/switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 9c589e7f..21d34847 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -106,6 +106,61 @@ export function isOperator(role: InstanceRole | null | undefined): boolean { return role === "instance_owner" || role === "instance_admin"; } +export interface InstanceSettings { + version: number; + general: { + public_url: string | null; + public_registration: boolean; + session_lifetime_hours: number; + upload_limit_mb: number; + mcp_enabled: boolean; + }; + security: { + secure_cookies: boolean; + allowed_origins: string[]; + trusted_proxy_headers: boolean; + private_webhook_targets: boolean; + mcp_allowed_hosts: string[]; + mcp_allowed_origins: string[]; + }; + storage: { + provider: "filesystem" | "s3"; + bucket: string | null; + region: string | null; + endpoint: string | null; + public_url: string | null; + }; + backups: { + enabled: boolean; + destination: "filesystem" | "s3"; + retention: number; + bucket: string | null; + region: string | null; + endpoint: string | null; + }; + storage_credentials: CredentialState; + backup_credentials: CredentialState; +} + +export interface CredentialState { + configured: boolean; + masked_access_key_id: string | null; +} + +export function getInstanceSettings() { + return api("/instance/settings"); +} + +export function updateInstanceSettingsSection( + section: "general" | "security" | "storage" | "backups", + data: unknown, +) { + return api(`/instance/settings/${section}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + /** Human label for an instance role, used in settings/user lists. */ export function instanceRoleLabel( role: InstanceRole | null | undefined, diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index d6c7ab78..3f0beafc 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -24,6 +24,8 @@ import { Route as AdminSitesSiteIdSettingsRouteImport } from './routes/_admin/si import { Route as AdminSitesSiteIdFilesRouteImport } from './routes/_admin/sites.$siteId/files' import { Route as AdminSitesSiteIdCollectionsRouteImport } from './routes/_admin/sites.$siteId/collections' import { Route as AdminShellSettingsUsersRouteImport } from './routes/_admin/_shell/settings/users' +import { Route as AdminShellSettingsStorageRouteImport } from './routes/_admin/_shell/settings/storage' +import { Route as AdminShellSettingsSecurityRouteImport } from './routes/_admin/_shell/settings/security' import { Route as AdminShellSettingsBackupsRouteImport } from './routes/_admin/_shell/settings/backups' import { Route as AdminSitesSiteIdSettingsIndexRouteImport } from './routes/_admin/sites.$siteId/settings/index' import { Route as AdminSitesSiteIdSingletonsSlugRouteImport } from './routes/_admin/sites.$siteId/singletons.$slug' @@ -111,6 +113,18 @@ const AdminShellSettingsUsersRoute = AdminShellSettingsUsersRouteImport.update({ path: '/users', getParentRoute: () => AdminShellSettingsRoute, } as any) +const AdminShellSettingsStorageRoute = + AdminShellSettingsStorageRouteImport.update({ + id: '/storage', + path: '/storage', + getParentRoute: () => AdminShellSettingsRoute, + } as any) +const AdminShellSettingsSecurityRoute = + AdminShellSettingsSecurityRouteImport.update({ + id: '/security', + path: '/security', + getParentRoute: () => AdminShellSettingsRoute, + } as any) const AdminShellSettingsBackupsRoute = AdminShellSettingsBackupsRouteImport.update({ id: '/backups', @@ -187,6 +201,8 @@ export interface FileRoutesByFullPath { '/settings': typeof AdminShellSettingsRouteWithChildren '/sites/$siteId': typeof AdminSitesSiteIdRouteWithChildren '/settings/backups': typeof AdminShellSettingsBackupsRoute + '/settings/security': typeof AdminShellSettingsSecurityRoute + '/settings/storage': typeof AdminShellSettingsStorageRoute '/settings/users': typeof AdminShellSettingsUsersRoute '/sites/$siteId/collections': typeof AdminSitesSiteIdCollectionsRoute '/sites/$siteId/files': typeof AdminSitesSiteIdFilesRoute @@ -211,6 +227,8 @@ export interface FileRoutesByTo { '/sites': typeof AdminSitesRouteWithChildren '/account': typeof AdminShellAccountRoute '/settings/backups': typeof AdminShellSettingsBackupsRoute + '/settings/security': typeof AdminShellSettingsSecurityRoute + '/settings/storage': typeof AdminShellSettingsStorageRoute '/settings/users': typeof AdminShellSettingsUsersRoute '/sites/$siteId/collections': typeof AdminSitesSiteIdCollectionsRoute '/sites/$siteId/files': typeof AdminSitesSiteIdFilesRoute @@ -238,6 +256,8 @@ export interface FileRoutesById { '/_admin/sites/$siteId': typeof AdminSitesSiteIdRouteWithChildren '/_admin/_shell/': typeof AdminShellIndexRoute '/_admin/_shell/settings/backups': typeof AdminShellSettingsBackupsRoute + '/_admin/_shell/settings/security': typeof AdminShellSettingsSecurityRoute + '/_admin/_shell/settings/storage': typeof AdminShellSettingsStorageRoute '/_admin/_shell/settings/users': typeof AdminShellSettingsUsersRoute '/_admin/sites/$siteId/collections': typeof AdminSitesSiteIdCollectionsRoute '/_admin/sites/$siteId/files': typeof AdminSitesSiteIdFilesRoute @@ -266,6 +286,8 @@ export interface FileRouteTypes { | '/settings' | '/sites/$siteId' | '/settings/backups' + | '/settings/security' + | '/settings/storage' | '/settings/users' | '/sites/$siteId/collections' | '/sites/$siteId/files' @@ -290,6 +312,8 @@ export interface FileRouteTypes { | '/sites' | '/account' | '/settings/backups' + | '/settings/security' + | '/settings/storage' | '/settings/users' | '/sites/$siteId/collections' | '/sites/$siteId/files' @@ -316,6 +340,8 @@ export interface FileRouteTypes { | '/_admin/sites/$siteId' | '/_admin/_shell/' | '/_admin/_shell/settings/backups' + | '/_admin/_shell/settings/security' + | '/_admin/_shell/settings/storage' | '/_admin/_shell/settings/users' | '/_admin/sites/$siteId/collections' | '/_admin/sites/$siteId/files' @@ -447,6 +473,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminShellSettingsUsersRouteImport parentRoute: typeof AdminShellSettingsRoute } + '/_admin/_shell/settings/storage': { + id: '/_admin/_shell/settings/storage' + path: '/storage' + fullPath: '/settings/storage' + preLoaderRoute: typeof AdminShellSettingsStorageRouteImport + parentRoute: typeof AdminShellSettingsRoute + } + '/_admin/_shell/settings/security': { + id: '/_admin/_shell/settings/security' + path: '/security' + fullPath: '/settings/security' + preLoaderRoute: typeof AdminShellSettingsSecurityRouteImport + parentRoute: typeof AdminShellSettingsRoute + } '/_admin/_shell/settings/backups': { id: '/_admin/_shell/settings/backups' path: '/backups' @@ -529,12 +569,16 @@ declare module '@tanstack/react-router' { interface AdminShellSettingsRouteChildren { AdminShellSettingsBackupsRoute: typeof AdminShellSettingsBackupsRoute + AdminShellSettingsSecurityRoute: typeof AdminShellSettingsSecurityRoute + AdminShellSettingsStorageRoute: typeof AdminShellSettingsStorageRoute AdminShellSettingsUsersRoute: typeof AdminShellSettingsUsersRoute AdminShellSettingsIndexRoute: typeof AdminShellSettingsIndexRoute } const AdminShellSettingsRouteChildren: AdminShellSettingsRouteChildren = { AdminShellSettingsBackupsRoute: AdminShellSettingsBackupsRoute, + AdminShellSettingsSecurityRoute: AdminShellSettingsSecurityRoute, + AdminShellSettingsStorageRoute: AdminShellSettingsStorageRoute, AdminShellSettingsUsersRoute: AdminShellSettingsUsersRoute, AdminShellSettingsIndexRoute: AdminShellSettingsIndexRoute, } diff --git a/apps/dashboard/src/routes/_admin/_shell/settings.tsx b/apps/dashboard/src/routes/_admin/_shell/settings.tsx index dd269cc6..7cbd6f8d 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings.tsx @@ -28,9 +28,13 @@ function InstanceSettingsLayout() { const isOwner = me?.instance_role === "instance_owner"; const active = pathname.endsWith("/settings/users") ? "users" - : pathname.endsWith("/settings/backups") - ? "backups" - : "general"; + : pathname.endsWith("/settings/security") + ? "security" + : pathname.endsWith("/settings/storage") + ? "storage" + : pathname.endsWith("/settings/backups") + ? "backups" + : "general"; return (
@@ -41,15 +45,17 @@ function InstanceSettingsLayout() {

- - - } - > - General - + + + {isOwner && ( + } + > + General + + )} Users + {isOwner && ( + } + > + Security + + )} + {isOwner && ( + } + > + Storage + + )} {isOwner && ( ; + return ( +
+ + +
+ ); } diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx index 98c11461..07aad7f8 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx @@ -1,44 +1,15 @@ -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { getMe, instanceRoleLabel } from "@/lib/api"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { GeneralSettingsPanel } from "@/components/instance/settings-forms"; +import { getMe } from "@/lib/api"; export const Route = createFileRoute("/_admin/_shell/settings/")({ - component: GeneralInstanceSettings, + beforeLoad: async ({ context }) => { + const me = await context.queryClient.ensureQueryData({ + queryKey: ["me"], + queryFn: getMe, + }); + if (me.instance_role !== "instance_owner") + throw redirect({ to: "/settings/users" }); + }, + component: GeneralSettingsPanel, }); - -function Row({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function GeneralInstanceSettings() { - const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe }); - - return ( - - - General - - Overview of this CMS installation. More instance-wide settings will - live here. - - - - - - - - - ); -} diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/security.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/security.tsx new file mode 100644 index 00000000..06831a36 --- /dev/null +++ b/apps/dashboard/src/routes/_admin/_shell/settings/security.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { SecuritySettingsPanel } from "@/components/instance/settings-forms"; +import { getMe } from "@/lib/api"; + +export const Route = createFileRoute("/_admin/_shell/settings/security")({ + beforeLoad: async ({ context }) => { + const me = await context.queryClient.ensureQueryData({ + queryKey: ["me"], + queryFn: getMe, + }); + if (me.instance_role !== "instance_owner") + throw redirect({ to: "/settings/users" }); + }, + component: SecuritySettingsPanel, +}); diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx new file mode 100644 index 00000000..d7a80de8 --- /dev/null +++ b/apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { StorageSettingsPanel } from "@/components/instance/settings-forms"; +import { getMe } from "@/lib/api"; + +export const Route = createFileRoute("/_admin/_shell/settings/storage")({ + beforeLoad: async ({ context }) => { + const me = await context.queryClient.ensureQueryData({ + queryKey: ["me"], + queryFn: getMe, + }); + if (me.instance_role !== "instance_owner") + throw redirect({ to: "/settings/users" }); + }, + component: StorageSettingsPanel, +}); From e5a29fd1e427e5436ace3853841094e051f1a06a Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:25:46 +0530 Subject: [PATCH 12/34] test: update test infrastructure for new config and settings Test harness updated with domain-separated key fields, StorageRegistry no longer needs mut, SettingsService wired into create_router. Add settings integration tests for owner-only access and field validation. Remove stale env var references from MCP proxy tests. --- apps/backend/tests/common/grpc.rs | 10 +- apps/backend/tests/common/server.rs | 10 +- apps/backend/tests/mcp/stdio_proxy_tests.rs | 5 +- apps/backend/tests/rest/main.rs | 1 + apps/backend/tests/rest/settings_tests.rs | 116 ++++++++++++++++++++ 5 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 apps/backend/tests/rest/settings_tests.rs diff --git a/apps/backend/tests/common/grpc.rs b/apps/backend/tests/common/grpc.rs index 5fe2ff24..d6340a2c 100644 --- a/apps/backend/tests/common/grpc.rs +++ b/apps/backend/tests/common/grpc.rs @@ -43,7 +43,9 @@ impl GrpcTestContext { let config = Config { database_url: "sqlite::memory:".to_string(), - hmac_secret: "test-hmac-secret-integration".to_string(), + token_index_key: "test-token-index-key".to_string(), + session_auth_key: "test-session-auth-key".to_string(), + signed_upload_key: "test-signed-upload-key".to_string(), storage_fs_path: Some(storage_path.clone()), cookie_secure: false, mcp_enabled: false, @@ -69,7 +71,7 @@ impl GrpcTestContext { seed_admin(&repository).await; - let mut storage_registry = StorageRegistry::new(); + let storage_registry = StorageRegistry::new(); let fs_storage = cms::storage::FileSystemStorage::new(&storage_path).expect("Failed to init filesystem storage"); storage_registry.register(STORAGE_KIND_FILESYSTEM, Arc::new(fs_storage)); @@ -94,6 +96,9 @@ impl GrpcTestContext { backup_destination, &config, )); + let settings = cms::services::settings::SettingsService::load(pool.clone(), &"11".repeat(32)) + .await + .expect("Failed to init instance settings"); let app = create_router( pool.clone(), @@ -102,6 +107,7 @@ impl GrpcTestContext { storage_registry.clone(), services.clone(), backup_service, + settings, ); let (axum_shutdown_tx, axum_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); diff --git a/apps/backend/tests/common/server.rs b/apps/backend/tests/common/server.rs index 909eb6ba..94134bf9 100644 --- a/apps/backend/tests/common/server.rs +++ b/apps/backend/tests/common/server.rs @@ -51,7 +51,9 @@ impl TestServer { let config = Config { database_url, - hmac_secret: "test-hmac-secret-integration".to_string(), + token_index_key: "test-token-index-key".to_string(), + session_auth_key: "test-session-auth-key".to_string(), + signed_upload_key: "test-signed-upload-key".to_string(), storage_fs_path: Some(storage_path.clone()), cookie_secure: false, // Real config defaults this to 24h; `Config::default()` leaves it 0, which @@ -96,7 +98,7 @@ impl TestServer { seed_admin(&repository).await; - let mut storage_registry = StorageRegistry::new(); + let storage_registry = StorageRegistry::new(); let fs_storage = cms::storage::FileSystemStorage::new(&storage_path).expect("Failed to init filesystem storage"); storage_registry.register(STORAGE_KIND_FILESYSTEM, Arc::new(fs_storage)); @@ -124,6 +126,9 @@ impl TestServer { backup_destination, &config, )); + let settings = cms::services::settings::SettingsService::load(pool.clone(), &"11".repeat(32)) + .await + .expect("Failed to init instance settings"); let app = create_router( pool.clone(), @@ -132,6 +137,7 @@ impl TestServer { storage_registry, services, backup_service, + settings, ); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); diff --git a/apps/backend/tests/mcp/stdio_proxy_tests.rs b/apps/backend/tests/mcp/stdio_proxy_tests.rs index ff58ddaf..45dbd986 100644 --- a/apps/backend/tests/mcp/stdio_proxy_tests.rs +++ b/apps/backend/tests/mcp/stdio_proxy_tests.rs @@ -20,13 +20,10 @@ struct StdioClient { impl StdioClient { /// Spawn the proxy the way an MCP client does: it knows only the server URL and a - /// site token — no `DATABASE_URL`, `HMAC_SECRET`, or `VCMS_HOME`. + /// site token and no server configuration. async fn start(url: &str, token: &str) -> Self { let mut child = Command::new(env!("CARGO_BIN_EXE_vcms")) .args(["mcp", "stdio"]) - .env_remove("DATABASE_URL") - .env_remove("HMAC_SECRET") - .env_remove("VCMS_HOME") .env("VCMS_MCP_URL", url) .env("VCMS_MCP_TOKEN", token) .env("RUST_LOG", "warn") diff --git a/apps/backend/tests/rest/main.rs b/apps/backend/tests/rest/main.rs index 719c70b6..8dfc9151 100644 --- a/apps/backend/tests/rest/main.rs +++ b/apps/backend/tests/rest/main.rs @@ -9,6 +9,7 @@ mod entries_tests; mod files_tests; mod health_tests; mod roles_tests; +mod settings_tests; mod singletons_tests; mod sites_tests; mod webhooks_tests; diff --git a/apps/backend/tests/rest/settings_tests.rs b/apps/backend/tests/rest/settings_tests.rs new file mode 100644 index 00000000..cbd898b3 --- /dev/null +++ b/apps/backend/tests/rest/settings_tests.rs @@ -0,0 +1,116 @@ +use serde_json::{Value, json}; + +use crate::common::{ + TestServer, + auth::{auth_header, extract_cookies}, +}; + +async fn owner_session(server: &TestServer, client: &reqwest::Client) -> (String, String) { + let response = server.login_user(client, "admin@cms.local", "admin").await; + assert_eq!(response.status(), 200); + extract_cookies(&response) +} + +#[tokio::test] +async fn owner_can_read_redacted_settings_and_admin_cannot() { + let server = TestServer::start().await; + let client = reqwest::Client::new(); + let (token, csrf) = owner_session(&server, &client).await; + let response = client + .get(format!("{}/api/dashboard/instance/settings", server.base_url)) + .headers(auth_header(&token, &csrf)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["general"]["upload_limit_mb"], 50); + assert!(body["storage_credentials"]["configured"].is_boolean()); + let serialized = body.to_string(); + assert!(!serialized.contains("secret_access_key")); + assert!(!serialized.contains("master_key")); + + let create = client + .post(format!("{}/api/dashboard/instance/users", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "name": "settings admin", "email": "settings-admin@example.com", + "temporary_password": "password123", "instance_role": "instance_admin" + })) + .send() + .await + .unwrap(); + assert!(create.status().is_success()); + let login = server + .login_user(&client, "settings-admin@example.com", "password123") + .await; + let (admin_token, admin_csrf) = extract_cookies(&login); + let forbidden = client + .get(format!("{}/api/dashboard/instance/settings", server.base_url)) + .headers(auth_header(&admin_token, &admin_csrf)) + .send() + .await + .unwrap(); + assert_eq!(forbidden.status(), 403); +} + +#[tokio::test] +async fn general_update_applies_registration_immediately() { + let server = TestServer::start().await; + let client = reqwest::Client::new(); + let (token, csrf) = owner_session(&server, &client).await; + let response = client + .put(format!("{}/api/dashboard/instance/settings/general", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "public_url": null, + "public_registration": true, + "session_lifetime_hours": 24, + "upload_limit_mb": 50, + "mcp_enabled": true + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + + let register = client + .post(format!("{}/api/auth/register", server.base_url)) + .json(&json!({"name": "New User", "email": "new@example.com", "password": "password123"})) + .send() + .await + .unwrap(); + assert_eq!(register.status(), 201); +} + +#[tokio::test] +async fn settings_updates_reject_unknown_fields_and_invalid_limits() { + let server = TestServer::start().await; + let client = reqwest::Client::new(); + let (token, csrf) = owner_session(&server, &client).await; + let response = client + .put(format!("{}/api/dashboard/instance/settings/general", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "public_url": null, "public_registration": false, + "session_lifetime_hours": 24, "upload_limit_mb": 0, + "mcp_enabled": true, "mystery": true + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 422); + + let invalid = client + .put(format!("{}/api/dashboard/instance/settings/general", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "public_url": null, "public_registration": false, + "session_lifetime_hours": 24, "upload_limit_mb": 0, + "mcp_enabled": true + })) + .send() + .await + .unwrap(); + assert_eq!(invalid.status(), 400); +} From f9c61fbcb3cb725f95ef6a83bdbe5dcf9881ac4c Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:26:15 +0530 Subject: [PATCH 13/34] docs: rewrite for new configuration architecture Update AGENTS.md, CLAUDE.md, and README.md to reflect strict TOML config, portable/installed runtime modes, domain-separated keys, instance settings, and cross-platform service entrypoint. --- AGENTS.md | 153 ++++++++++++------------------------------------------ CLAUDE.md | 149 +++++++++++----------------------------------------- README.md | 42 ++++++--------- 3 files changed, 80 insertions(+), 264 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 97c16426..732f4741 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,14 +71,13 @@ bun run format # Format all projects ## CLI -The backend binary (`vcms`) is a clap CLI. With no subcommand it runs the server (back-compat with `cargo run`). +The backend binary (`vcms`) is a clap CLI. With no subcommand it prints help. ```bash -vcms # run the server (alias for `vcms serve`) -vcms serve # run the server -vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) -vcms config show # print effective merged config (secrets redacted) -vcms config path # print resolved config file + search order +vcms # print help +vcms serve # run portable mode from ./vcms_data +vcms config show # print mode, root, bootstrap, and redacted secrets +vcms secrets reset --yes # replace trust root and invalidate credentials vcms admin reset-password --email U --password P vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] vcms backup list # list recorded backups @@ -96,10 +95,7 @@ and requires `--yes`. JSON-RPC between stdin/stdout and the server's `/mcp` Streamable-HTTP endpoint, reading only `VCMS_MCP_TOKEN` (bearer) and `VCMS_MCP_URL` (default `http://127.0.0.1:3000`). So it works even when the data is owned by the OS-service -account. The installed service pins `VCMS_HOME` to a system dir so the daemon stores -everything under one owned root. - -Global flags (highest precedence): `--config `, `--bind `, `--database-url `, `--log-level `. +account. The installed service always uses its fixed system root. The server auto-migrates the database on every startup; there is no separate migrate command. @@ -114,135 +110,54 @@ The server auto-migrates the database on every startup; there is no separate mig ## Configuration -Non-secret settings live in a TOML config file; secrets stay in the environment (or `.env`). -Layers merge with precedence: **CLI flag > env var > config file > built-in default**. - -Config file search order (first existing wins; missing is fine): -1. `--config` flag / `VCMS_CONFIG` env -2. `./vcms.toml` (current dir) -3. the platform config dir (`config.toml`; `$VCMS_HOME/config.toml` in single-dir mode) — where `vcms config init` writes -4. `/etc/vcms/config.toml` +Bootstrap addresses and logging live in strict `config.toml`. The master key, +backup key, and optional database URL live in strict `secrets.toml`. There are no +server env overrides, CLI config overrides, search paths, or `.env` loading. ## Data directory -Resolution lives in `apps/backend/src/paths.rs` and has two layouts: - -**Split (default, interactive installs)** — files land in the platform-conventional -per-type directories via the `directories` crate (`ProjectDirs`): - -| File(s) | Dir | Linux | macOS | Windows | -|---------|-----|-------|-------|---------| -| `config.toml`, `secrets.toml`, `.env` | config | `~/.config/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\config` | -| `vcms.db`, `storage/`, `backups/` | data | `~/.local/share/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\data` | -| `search/` (derived, rebuildable) | cache | `~/.cache/vcms` | `~/Library/Caches/vcms` | `%LOCALAPPDATA%\vcms\cache` | -| `logs/` | state | `~/.local/state/vcms` | `~/Library/Application Support/vcms` | `%LOCALAPPDATA%\vcms\data` | - -(`logs/` uses the state dir where the platform has one — Linux — else the local data dir.) +Mode is selected only by native service registration. Installed mode uses +`/var/lib/vcms`, `/Library/Application Support/vcms`, or `C:\ProgramData\vcms`. +Without a registered service, portable mode uses `/vcms_data`. Detection +errors fail closed; directory existence and legacy paths never select a mode. -**Single** — everything nests under one root. Chosen (in precedence order) when: -1. **`$VCMS_HOME` is set** — forces the root explicitly. -2. **the system service home dir exists** — Linux `/var/lib/vcms`, macOS - `/Library/Application Support/vcms`, Windows `C:\ProgramData\vcms`. The - platform installer creates it (and leaves it behind on uninstall), so a plain - `vcms serve`/`admin`/`backup` **follows the service's data instead of forking to a - per-user split store**. This path is defined once in `paths::system_home()` and - imported by the Windows SCM host. -3. **a legacy `~/.vcms` exists** — an existing install keeps working untouched. - -Otherwise (dev/eval boxes with no service) files use the platform split dirs. +Both modes share one root layout: ```text -$VCMS_HOME/ # or system home, or ~/.vcms (legacy) - config.toml secrets.toml .env - vcms.db (+ -wal / -shm) logs/ storage/ backups/ search/ +/ + config.toml secrets.toml vcms.db + storage/ backups/ logs/ search/ ``` -When the active home is the system service home (owned by SYSTEM/root), the -data-touching commands (`serve`/`admin`/`backup`/`restore`) require elevation — a -non-elevated invocation **fails fast** with an "Administrator/root" hint (in -`paths::ensure`'s preflight) rather than silently forking to a second store. - -Secrets: on first `serve`/`admin`, a random `HMAC_SECRET` is generated and persisted to -`secrets.toml` (`apps/backend/src/secrets.rs`), then loaded by the server processes. -`vcms mcp stdio` does **not** load secrets, the database, or any data-dir file: it is a -thin HTTP proxy to the running server's `/mcp` (see CLI), forwarding `VCMS_MCP_TOKEN` as -the bearer; the server owns all disk I/O. +Fresh roots and strict files are auto-created. Existing malformed files are never +overwritten or silently repaired. A missing `secrets.toml` beside an existing +database is fatal. -Env-only secrets (never read from `config.toml` by convention, omitted from -`config init`): `DATABASE_URL`, `HMAC_SECRET`, `S3_ACCESS_KEY_ID`, -`S3_SECRET_ACCESS_KEY`, `BACKUP_S3_ACCESS_KEY_ID`, `BACKUP_S3_SECRET_ACCESS_KEY`, -`BACKUP_ENCRYPTION_KEY`. (`HMAC_SECRET` and a random backup encryption -key are auto-persisted to `secrets.toml`; the others remain env-only.) - -Sample `config.toml` (generate with `vcms config init`): +Sample `config.toml`: ```toml -bind_address = "0.0.0.0:3000" -grpc_bind_address = "0.0.0.0:50051" -max_upload_size_mb = 50 -cookie_secure = false -session_lifetime_hours = 24 -db_max_connections = 10 -rate_limit_max_requests = 100 -mcp_enabled = true -mcp_allowed_hosts = ["localhost", "127.0.0.1"] +[server] +http_address = "127.0.0.1:3000" +grpc_address = "127.0.0.1:50051" [log] -level = "cms=debug,vcms=debug,tower_http=debug,axum=debug" -output = "stdout" # stdout | file -format = "pretty" # pretty | json -annotations = false -dir = "logs" +level = "cms=info,vcms=info" +output = "file" ``` +`secrets.toml` contains `master_key`, `backup_encryption_key`, and optional +`database_url`. Owner settings and encrypted integration credentials live in the +database. Fixed filesystem paths and internal tuning constants are not configurable. + ## Environment Variables -Every non-secret key below can also be set in `config.toml` (env still overrides the file). -Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→`log.output`, -`LOG_FORMAT`→`log.format`, `LOG_ANNOTATIONS`→`log.annotations`, `LOG_DIR`→`log.dir`. +The server ignores environment configuration. Only the diskless MCP stdio client +reads environment variables: | Variable | Default | Description | |----------|---------|-------------| -| `VCMS_CONFIG` | - | Explicit config file path (same as `--config`) | -| `VCMS_HOME` | - | If set, forces single-dir mode: db/config/secrets/logs/storage all nest under this root (else files use the platform split dirs) | -| `VCMS_MCP_TOKEN` | - | `vcms_site_*` access token forwarded by `vcms mcp stdio` as the bearer credential (required for stdio) | -| `VCMS_MCP_URL` | `http://127.0.0.1:3000` | Running server's base URL that `vcms mcp stdio` proxies to (`{url}/mcp`) | -| `DATABASE_URL` | `sqlite:///vcms.db` | Database URL: `sqlite:path`, `postgres://...`, `mysql://...` | -| `HMAC_SECRET` | auto | HMAC key for token lookup (required; auto-generated to `secrets.toml`, env overrides) | -| `BIND_ADDRESS` | `0.0.0.0:3000` | REST API listen address | -| `GRPC_BIND_ADDRESS` | `0.0.0.0:50051` | gRPC server listen address | -| `STORAGE_FS_PATH` | - | Filesystem storage path | -| `S3_ACCESS_KEY_ID` | - | S3 access key | -| `S3_SECRET_ACCESS_KEY` | - | S3 secret key | -| `S3_BUCKET` | - | S3 bucket name | -| `S3_REGION` | `us-east-1` | S3 region | -| `S3_ENDPOINT` | - | S3 endpoint (for S3-compatible services) | -| `S3_PUBLIC_URL` | - | Public URL for S3 assets | -| `BACKUP_ENABLED` | `true` | Run the scheduled-backup poller / allow backups | -| `BACKUP_DESTINATION` | `filesystem` | Backup destination: `filesystem` or `s3` | -| `BACKUP_LOCAL_PATH` | `/backups` | Local backup dir (when destination is filesystem) | -| `BACKUP_ZSTD_LEVEL` | `12` | zstd compression level for backups | -| `BACKUP_DEFAULT_RETENTION` | `7` | Default "keep last N" for new schedules | -| `BACKUP_S3_BUCKET` / `_REGION` / `_ENDPOINT` / `_PUBLIC_URL` | - | S3 backup destination (non-secret parts) | -| `BACKUP_S3_ACCESS_KEY_ID` | - | S3 backup access key (secret, env-only) | -| `BACKUP_S3_SECRET_ACCESS_KEY` | - | S3 backup secret key (secret, env-only) | -| `BACKUP_ENCRYPTION_KEY` | auto | AES-256 backup key (hex); auto-generated to `secrets.toml` | -| `MAX_UPLOAD_SIZE_MB` | `50` | Max upload size in MB | -| `UPLOAD_TOKEN_EXPIRY_SECS` | `900` | Signed upload URL lifetime (seconds) | -| `COOKIE_SECURE` | `false` | Require HTTPS cookies | -| `DB_MAX_CONNECTIONS` | `10` | Max DB connections | -| `DB_MIN_CONNECTIONS` | `2` | Min DB connections | -| `RATE_LIMIT_MAX_REQUESTS` | `100` | Rate limit per window | -| `RATE_LIMIT_WINDOW_SECS` | `60` | Rate limit window | -| `VCMS_ENV` | - | `production` enables production security checks | -| `RUST_LOG` | `cms=debug,vcms=debug,tower_http=debug,axum=debug` | Log filter (`[log] level`) | -| `LOG_OUTPUT` | `stdout` | `stdout` or `file` (`[log] output`) | -| `LOG_FORMAT` | `pretty` | `pretty` or `json` (`[log] format`) | -| `LOG_ANNOTATIONS` | `false` | Include file + line numbers (`[log] annotations`) | -| `LOG_DIR` | `/logs` | Log directory when `output = file` (`[log] dir`) | - -**Note**: `HMAC_SECRET` is auto-generated and persisted to the config dir's -`secrets.toml` on first run. Set it explicitly via env to override. +| `VCMS_MCP_TOKEN` | required | Site access token forwarded as bearer auth | +| `VCMS_MCP_URL` | `http://127.0.0.1:3000` | Running server base URL | ## Proto Compilation diff --git a/CLAUDE.md b/CLAUDE.md index 93eebca1..86dfeb27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,163 +64,74 @@ bun run format # Format all projects ## CLI -The backend binary (`vcms`) is a clap CLI. With no subcommand it runs the server (back-compat with `cargo run`). +The backend binary (`vcms`) is a clap CLI. With no subcommand it prints help. ```bash -vcms # run the server (alias for `vcms serve`) -vcms serve # run the server -vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) -vcms config show # print effective merged config (secrets redacted) -vcms config path # print resolved config file + search order +vcms serve # run portable mode; refuse when native service is installed +vcms config show # print mode, root, bootstrap, secret presence, DB settings +vcms secrets reset # destructive trust-root reset and recovery report vcms admin reset-password --email U --password P vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] vcms backup list # list recorded backups vcms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes +vcms mcp stdio # diskless proxy to a running server +vcms service status +vcms doctor ``` `backup`/`restore` run offline (no HTTP server) against the configured database — the disaster-recovery path when the instance won't boot. `restore` is destructive and requires `--yes`. -Global flags (highest precedence): `--config `, `--bind `, `--database-url `, `--log-level `. - The server auto-migrates the database on every startup; there is no separate migrate command. ## Configuration -Non-secret settings live in a TOML config file; secrets stay in the environment (or `.env`). -Layers merge with precedence: **CLI flag > env var > config file > built-in default**. - -Config file search order (first existing wins; missing is fine): -1. `--config` flag / `VCMS_CONFIG` env -2. `./vcms.toml` (current dir) -3. the platform config dir (`config.toml`; `$VCMS_HOME/config.toml` in single-dir mode) — where `vcms config init` writes -4. `/etc/vcms/config.toml` +Bootstrap settings live only in strict `/config.toml`; trust-root secrets and +the optional database URL live only in strict `/secrets.toml`. There is no +server environment or CLI override layer. ## Data directory -Resolution lives in `apps/backend/src/paths.rs` and has two layouts: - -**Split (default, interactive installs)** — files land in the platform-conventional -per-type directories via the `directories` crate (`ProjectDirs`): - -| File(s) | Dir | Linux | macOS | Windows | -|---------|-----|-------|-------|---------| -| `config.toml`, `secrets.toml`, `.env` | config | `~/.config/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\config` | -| `vcms.db`, `storage/`, `backups/` | data | `~/.local/share/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\data` | -| `search/` (derived, rebuildable) | cache | `~/.cache/vcms` | `~/Library/Caches/vcms` | `%LOCALAPPDATA%\vcms\cache` | -| `logs/` | state | `~/.local/state/vcms` | `~/Library/Application Support/vcms` | `%LOCALAPPDATA%\vcms\data` | - -(`logs/` uses the state dir where the platform has one — Linux — else the local data dir.) - -**Single** — everything nests under one root. Chosen (in precedence order) when: -1. **`$VCMS_HOME` is set** — forces the root explicitly. -2. **the system service home dir exists** — Linux `/var/lib/vcms`, macOS - `/Library/Application Support/vcms`, Windows `C:\ProgramData\vcms`. The - platform installer creates it (and leaves it behind on uninstall), so a plain - `vcms serve`/`admin`/`backup` **follows the service's data instead of forking to a - per-user split store**. This path is defined once in `paths::system_home()` and - imported by the Windows SCM host. -3. **a legacy `~/.vcms` exists** — an existing install keeps working untouched. - -Otherwise (dev/eval boxes with no service) files use the platform split dirs. +Resolution lives in `apps/backend/src/paths.rs`. Native service registration selects +installed mode and its fixed OS root. Without a registered service, portable mode +uses `/vcms_data`. Detection failures stop startup. ```text -$VCMS_HOME/ # or system home, or ~/.vcms (legacy) - config.toml secrets.toml .env +/ + config.toml secrets.toml vcms.db (+ -wal / -shm) logs/ storage/ backups/ search/ ``` -`vcms serve`/`admin` create the dirs they need on first run; `vcms mcp stdio` creates -nothing. When the active home is the system service home (owned by SYSTEM/root), the -data-touching commands (`serve`/`admin`/`backup`/`restore`) require elevation — a -non-elevated invocation **fails fast** with an "Administrator/root" hint (in -`paths::ensure`'s preflight) rather than silently forking to a second store. - -Secrets: on first `serve`/`admin`, a random `HMAC_SECRET` is generated -and persisted to `secrets.toml` (`apps/backend/src/secrets.rs`), then loaded by -the server processes. `vcms mcp stdio` does **not** load secrets, the database, or -any home-dir file: it is a thin HTTP proxy (see below) and the server it forwards to -owns all of those. +Installed roots are `/var/lib/vcms`, `/Library/Application Support/vcms`, and +`C:\ProgramData\vcms`. `vcms mcp stdio` creates nothing. -Env-only secrets (never read from `config.toml` by convention, omitted from -`config init`): `DATABASE_URL`, `HMAC_SECRET`, `S3_ACCESS_KEY_ID`, -`S3_SECRET_ACCESS_KEY`, `BACKUP_S3_ACCESS_KEY_ID`, `BACKUP_S3_SECRET_ACCESS_KEY`, -`BACKUP_ENCRYPTION_KEY`. (`HMAC_SECRET` and a random backup encryption -key are auto-persisted to `secrets.toml`; the others remain env-only.) +Fresh roots atomically create a master key and backup encryption key in +`secrets.toml`. An existing database without its secrets file is a hard error. +Domain-separated keys are derived for token indexing, webhook encryption, and +instance-setting encryption. Owner-managed settings and provider credentials live +in the database. -Sample `config.toml` (generate with `vcms config init`): +Bootstrap `config.toml`: ```toml -bind_address = "0.0.0.0:3000" -grpc_bind_address = "0.0.0.0:50051" -max_upload_size_mb = 50 -cookie_secure = false -session_lifetime_hours = 24 -db_max_connections = 10 -rate_limit_max_requests = 100 -search_enabled = true -mcp_enabled = true -mcp_allowed_hosts = ["localhost", "127.0.0.1"] +[server] +http_address = "127.0.0.1:3000" +grpc_address = "127.0.0.1:50051" [log] -level = "cms=debug,vcms=debug,tower_http=debug,axum=debug" -output = "stdout" # stdout | file -format = "pretty" # pretty | json -annotations = false -dir = "logs" +level = "cms=info,vcms=info" +output = "file" ``` ## Environment Variables -Every non-secret key below can also be set in `config.toml` (env still overrides the file). -Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→`log.output`, -`LOG_FORMAT`→`log.format`, `LOG_ANNOTATIONS`→`log.annotations`, `LOG_DIR`→`log.dir`. - | Variable | Default | Description | |----------|---------|-------------| -| `VCMS_CONFIG` | - | Explicit config file path (same as `--config`) | -| `VCMS_HOME` | - | If set, forces single-dir mode: db/config/secrets/logs/storage all nest under this root (else files use the platform split dirs) | | `VCMS_MCP_TOKEN` | - | `vcms_site_*` access token forwarded by `vcms mcp stdio` as the bearer credential (required for stdio) | | `VCMS_MCP_URL` | `http://127.0.0.1:3000` | Running server's base URL that `vcms mcp stdio` proxies to (`{url}/mcp`) | -| `DATABASE_URL` | `sqlite:///vcms.db` | Database URL: `sqlite:path`, `postgres://...`, `mysql://...` | -| `HMAC_SECRET` | auto | HMAC key for token lookup (required; auto-generated to `secrets.toml`, env overrides) | -| `BIND_ADDRESS` | `0.0.0.0:3000` | REST API listen address | -| `GRPC_BIND_ADDRESS` | `0.0.0.0:50051` | gRPC server listen address | -| `STORAGE_FS_PATH` | - | Filesystem storage path | -| `S3_ACCESS_KEY_ID` | - | S3 access key | -| `S3_SECRET_ACCESS_KEY` | - | S3 secret key | -| `S3_BUCKET` | - | S3 bucket name | -| `S3_REGION` | `us-east-1` | S3 region | -| `S3_ENDPOINT` | - | S3 endpoint (for S3-compatible services) | -| `S3_PUBLIC_URL` | - | Public URL for S3 assets | -| `BACKUP_ENABLED` | `true` | Run the scheduled-backup poller / allow backups | -| `BACKUP_DESTINATION` | `filesystem` | Backup destination: `filesystem` or `s3` | -| `BACKUP_LOCAL_PATH` | `/backups` | Local backup dir (when destination is filesystem) | -| `BACKUP_ZSTD_LEVEL` | `12` | zstd compression level for backups | -| `BACKUP_DEFAULT_RETENTION` | `7` | Default "keep last N" for new schedules | -| `BACKUP_S3_BUCKET` / `_REGION` / `_ENDPOINT` / `_PUBLIC_URL` | - | S3 backup destination (non-secret parts) | -| `BACKUP_S3_ACCESS_KEY_ID` | - | S3 backup access key (secret, env-only) | -| `BACKUP_S3_SECRET_ACCESS_KEY` | - | S3 backup secret key (secret, env-only) | -| `BACKUP_ENCRYPTION_KEY` | auto | AES-256 backup key (hex); auto-generated to `secrets.toml` | -| `SEARCH_ENABLED` | `true` | Build/use the Tantivy full-text index for entry search (else SQL `LIKE`) | -| `SEARCH_INDEX_PATH` | `/search` | Directory for the Tantivy search index | -| `MAX_UPLOAD_SIZE_MB` | `50` | Max upload size in MB | -| `UPLOAD_TOKEN_EXPIRY_SECS` | `900` | Signed upload URL lifetime (seconds) | -| `COOKIE_SECURE` | `false` | Require HTTPS cookies | -| `DB_MAX_CONNECTIONS` | `10` | Max DB connections | -| `DB_MIN_CONNECTIONS` | `2` | Min DB connections | -| `RATE_LIMIT_MAX_REQUESTS` | `100` | Rate limit per window | -| `RATE_LIMIT_WINDOW_SECS` | `60` | Rate limit window | -| `VCMS_ENV` | - | `production` enables production security checks | -| `RUST_LOG` | `cms=debug,vcms=debug,tower_http=debug,axum=debug` | Log filter (`[log] level`) | -| `LOG_OUTPUT` | `stdout` | `stdout` or `file` (`[log] output`) | -| `LOG_FORMAT` | `pretty` | `pretty` or `json` (`[log] format`) | -| `LOG_ANNOTATIONS` | `false` | Include file + line numbers (`[log] annotations`) | -| `LOG_DIR` | `/logs` | Log directory when `output = file` (`[log] dir`) | - -**Note**: `HMAC_SECRET` is auto-generated and persisted to the config dir's -`secrets.toml` on first run. Set it explicitly via env to override. + +No server-side environment variables are supported. ## Proto Compilation diff --git a/README.md b/README.md index ec28f7a3..c5bfc0ce 100644 --- a/README.md +++ b/README.md @@ -119,39 +119,29 @@ serve` instance must be running. MCP protocol messages use stdout; logs use stde ### Data directory -By default, runtime files go to the platform-conventional per-type directories -(resolved cross-platform via the `directories` crate): +Runtime mode is deterministic. If the native service is registered, commands use +the installed root. Otherwise `vcms serve` is portable and uses +`/vcms_data`. Directory existence never selects a mode. -| File(s) | Linux | macOS | Windows | -|---------|-------|-------|---------| -| `config.toml`, `secrets.toml`, `.env` | `~/.config/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\config` | -| `vcms.db`, `storage/`, `backups/` | `~/.local/share/vcms` | `~/Library/Application Support/vcms` | `%APPDATA%\vcms\data` | -| `search/` (rebuildable index) | `~/.cache/vcms` | `~/Library/Caches/vcms` | `%LOCALAPPDATA%\vcms\cache` | -| `logs/` | `~/.local/state/vcms` | `~/Library/Application Support/vcms` | `%LOCALAPPDATA%\vcms\data` | +| Mode | Root | +|------|------| +| Portable | `/vcms_data` | +| Installed Linux | `/var/lib/vcms` | +| Installed macOS | `/Library/Application Support/vcms` | +| Installed Windows | `C:\ProgramData\vcms` | -Set **`$VCMS_HOME`** to keep everything under a single root instead. When several -locations exist, the first match wins: **`$VCMS_HOME`** → a legacy `~/.vcms` -(honored automatically, so upgrades don't move your data) → the split per-type -defaults above. - -The platform service installers store daemon data under one system dir — -`/var/lib/vcms` (Linux), `/Library/Application Support/vcms` (macOS), or -`C:\ProgramData\vcms` (Windows). When this directory exists, the binary uses it -automatically. Data-touching commands require an elevated shell when service-owned -permissions restrict access. - -`vcms serve` creates what it needs on first run and generates `secrets.toml` if -absent. Environment variables (`DATABASE_URL`, `HMAC_SECRET`, `STORAGE_FS_PATH`, -S3 settings, …) still override these defaults. +Both modes use one layout: `config.toml`, `secrets.toml`, `vcms.db`, `storage/`, +`backups/`, `logs/`, and `search/` beneath the root. Fresh roots are created +automatically. Existing malformed files are rejected, not repaired. Server env +overrides, config search paths, and legacy layouts are unsupported. ### Installed service operations -Native packages register `vcms` with systemd, launchd, or Windows SCM, but never -start a fresh installation automatically. Validate configuration and inspect state -with `vcms doctor` and `vcms service status`, then start it through the native service -manager. +Native packages register the hidden `vcms service run` entrypoint with systemd, +launchd, or Windows SCM. Inspect it with `vcms doctor`, `vcms config show`, and +`vcms service status`, then control it through the native service manager. `vcms doctor` checks resolved configuration, directory access, current database schema, listener availability, and execution identity without creating or migrating From d2c3a8f73d2ece5290bac86126e223ab47c3a590 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:26:49 +0530 Subject: [PATCH 14/34] refactor(server): apply remaining config and storage refactor changes Remove Cli dependency, use RuntimeContext directly. StorageRegistry::new() no longer needs mut. Update error messages and storage path defaults. --- apps/backend/src/server.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index f5396d9e..1c1aa944 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -14,7 +14,6 @@ use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::cli::Cli; use crate::config::Config; use crate::database::init_db_with_config; use crate::grpc::server::spawn_grpc_server; @@ -35,16 +34,11 @@ const ADMIN_EMAIL: &str = "admin@cms.local"; /// database open + migrated and both listeners bound — so a service host (the /// Windows SCM runner) can report `Running` truthfully instead of optimistically. pub async fn run( - cli: &Cli, + runtime: crate::runtime::RuntimeContext, shutdown: impl Future + Send + 'static, on_ready: impl FnOnce(), ) -> Result<(), Box> { - // Each early step gets a context prefix: a bare io error ("Access is denied. - // (os error 5)") from a service host is undebuggable without knowing which - // file/step produced it. - crate::paths::ensure().map_err(|e| format!("preparing data directories: {e}"))?; - crate::secrets::ensure().map_err(|e| format!("initializing secrets.toml: {e}"))?; - let config = Config::load(cli).map_err(|e| format!("loading configuration: {e}"))?; + let mut config = runtime.bootstrap.clone(); let _guard = crate::tracing::init_tracing(&config); @@ -129,7 +123,7 @@ pub async fn run( let addr: SocketAddr = config .bind_address .parse() - .map_err(|e| format!("Invalid BIND_ADDRESS '{}': {e}", config.bind_address))?; + .map_err(|e| format!("Invalid server.http_address '{}': {e}", config.bind_address))?; info!("Dashboard UI available at http://{}/dashboard", addr); info!("REST API server running on http://{}", addr); info!("GraphQL endpoint at http://{}/api/graphql", addr); @@ -272,13 +266,13 @@ pub async fn shutdown_signal() { /// Register the configured storage backends (filesystem and/or S3). pub fn initialize_storage(config: &Config) -> Arc { - let mut storage_registry = StorageRegistry::new(); + let storage_registry = StorageRegistry::new(); // Use an explicit filesystem path if set; otherwise default to the data dir's // storage/ so uploads work out of the box — unless S3 is configured and takes over. let fs_path = match (&config.storage_fs_path, config.has_s3()) { (Some(path), _) => Some(path.clone()), - (None, false) => Some(crate::paths::storage_dir().to_string_lossy().into_owned()), + (None, false) => None, (None, true) => None, }; @@ -310,7 +304,7 @@ pub fn initialize_storage(config: &Config) -> Arc { } if storage_registry.get(STORAGE_KIND_FILESYSTEM).is_none() && storage_registry.get(STORAGE_KIND_S3).is_none() { - warn!("No storage providers configured. Set STORAGE_FS_PATH or S3_* env vars."); + warn!("No storage providers configured"); } Arc::new(storage_registry) From dcf2ff904505a5d5f492bfad6875ab40fc38cde0 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:36:44 +0530 Subject: [PATCH 15/34] refactor(config)!: pass PersistedSecrets to Config::load Config::load now takes &PersistedSecrets as a parameter instead of loading secrets internally. Secrets reset now loads config with fresh secrets before replacing, invalidates credentials first, then replaces secrets atomically. --- apps/backend/src/config.rs | 15 ++++++--------- apps/backend/src/main.rs | 4 ++-- apps/backend/src/runtime.rs | 2 +- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/config.rs b/apps/backend/src/config.rs index 33cfc5cb..d4443a1e 100644 --- a/apps/backend/src/config.rs +++ b/apps/backend/src/config.rs @@ -119,18 +119,15 @@ pub struct Config { } impl Config { - pub fn load(paths: &RuntimePaths) -> Result> { + pub fn load( + paths: &RuntimePaths, + persisted: &secrets::PersistedSecrets, + ) -> Result> { let bootstrap = load_bootstrap(paths)?; - let persisted = secrets::load(paths)?.ok_or_else(|| { - format!( - "{} is missing; run `vcms serve` for a fresh instance", - paths.secrets_file().display() - ) - })?; validate_bootstrap(&bootstrap)?; Ok(Self { - database_url: persisted.database_url.unwrap_or_else(|| paths.database_url()), + database_url: persisted.database_url.clone().unwrap_or_else(|| paths.database_url()), bind_address: bootstrap.server.http_address, grpc_bind_address: bootstrap.server.grpc_address, storage_fs_path: Some(paths.storage_dir().to_string_lossy().into_owned()), @@ -151,7 +148,7 @@ impl Config { backup_s3_region: None, backup_s3_endpoint: None, backup_s3_public_url: None, - backup_encryption_key: Some(persisted.backup_encryption_key), + backup_encryption_key: Some(persisted.backup_encryption_key.clone()), max_upload_size_bytes: DEFAULT_MAX_UPLOAD_BYTES, upload_token_expiry_secs: crate::signed_upload::DEFAULT_UPLOAD_TOKEN_EXPIRY_SECS, cookie_secure: false, diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 99fc7f85..eee7e823 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -129,10 +129,10 @@ async fn run_secrets(action: &SecretsAction) -> Result<(), Box> { } let old_database_url = existing.database_url; let fresh = cms::secrets::fresh(old_database_url); - cms::secrets::replace(&paths, &fresh)?; - let config = cms::config::Config::load(&paths)?; + let config = cms::config::Config::load(&paths, &fresh)?; let pool = init_db_with_config(&config).await?; let (tokens, webhooks, s3_sites) = invalidate_credentials(&pool).await?; + cms::secrets::replace(&paths, &fresh)?; println!("Trust root replaced. Recovery report:"); println!("- {tokens} API access token(s) invalidated"); println!("- Active dashboard sessions invalidated"); diff --git a/apps/backend/src/runtime.rs b/apps/backend/src/runtime.rs index 73f1a591..4b5a404f 100644 --- a/apps/backend/src/runtime.rs +++ b/apps/backend/src/runtime.rs @@ -18,7 +18,7 @@ impl RuntimeContext { .map_err(|error| format!("preparing {}: {error}", paths.root().display()))?; crate::config::ensure_bootstrap(&paths)?; let secrets = crate::secrets::ensure(&paths)?; - let bootstrap = Config::load(&paths)?; + let bootstrap = Config::load(&paths, &secrets)?; Ok(Self { mode, paths, From de18f7a20e2ec41dbfe8b81ae6dab46fd2b119c9 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:37:17 +0530 Subject: [PATCH 16/34] fix(settings): improve S3 target-change detection and probe Detect provider switching (e.g. filesystem to S3), not just S3-to-S3. Include region in change comparison. S3 probe continues cleanup even if read fails. Clear credential fields on successful save. Add RestoreReport type for restore endpoint responses. --- apps/backend/src/handlers/settings_handler.rs | 18 +++++-- .../components/instance/settings-forms.tsx | 54 ++++++++++++------- apps/dashboard/src/lib/api.ts | 5 +- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/apps/backend/src/handlers/settings_handler.rs b/apps/backend/src/handlers/settings_handler.rs index 146fbd02..8f9b7d0a 100644 --- a/apps/backend/src/handlers/settings_handler.rs +++ b/apps/backend/src/handlers/settings_handler.rs @@ -296,8 +296,10 @@ async fn probe_s3(settings: &StorageSettings, credentials: &CredentialPair) -> R .put(&key, Bytes::from_static(b"vcms"), "application/octet-stream") .await .map_err(|error| error.to_string())?; - let read = storage.get(&key).await.map_err(|error| error.to_string())?; - storage.delete(&key).await.map_err(|error| error.to_string())?; + let read = storage.get(&key).await.map_err(|error| error.to_string()); + let cleanup = storage.delete(&key).await.map_err(|error| error.to_string()); + let read = read?; + cleanup?; if read.as_ref() != b"vcms" { return Err("probe read returned unexpected content".into()); } @@ -305,11 +307,19 @@ async fn probe_s3(settings: &StorageSettings, credentials: &CredentialPair) -> R } fn target_changed(old: &StorageSettings, new: &StorageSettings) -> bool { - old.provider == "s3" && new.provider == "s3" && (old.bucket != new.bucket || old.endpoint != new.endpoint) + (old.provider == "s3" || new.provider == "s3") + && (old.provider != new.provider + || old.bucket != new.bucket + || old.region != new.region + || old.endpoint != new.endpoint) } fn backup_target_changed(old: &BackupSettings, new: &BackupSettings) -> bool { - old.destination == "s3" && new.destination == "s3" && (old.bucket != new.bucket || old.endpoint != new.endpoint) + (old.destination == "s3" || new.destination == "s3") + && (old.destination != new.destination + || old.bucket != new.bucket + || old.region != new.region + || old.endpoint != new.endpoint) } async fn s3_site_count(pool: &DbPool) -> Result { diff --git a/apps/dashboard/src/components/instance/settings-forms.tsx b/apps/dashboard/src/components/instance/settings-forms.tsx index ba25613e..db762eae 100644 --- a/apps/dashboard/src/components/instance/settings-forms.tsx +++ b/apps/dashboard/src/components/instance/settings-forms.tsx @@ -441,17 +441,26 @@ function StorageForm({ settings }: { settings: InstanceSettings }) { const [confirming, setConfirming] = useState(false); const save = useSave("storage"); const targetChanged = - settings.storage.provider === "s3" && - form.provider === "s3" && - (settings.storage.bucket !== form.bucket || + (settings.storage.provider === "s3" || form.provider === "s3") && + (settings.storage.provider !== form.provider || + settings.storage.bucket !== form.bucket || + settings.storage.region !== form.region || settings.storage.endpoint !== form.endpoint); const submit = (confirmed = false) => - save.mutate({ - ...form, - access_key_id: accessKey || null, - secret_access_key: secretKey || null, - confirm_target_change: confirmed, - }); + save.mutate( + { + ...form, + access_key_id: accessKey || null, + secret_access_key: secretKey || null, + confirm_target_change: confirmed, + }, + { + onSuccess: () => { + setAccessKey(""); + setSecretKey(""); + }, + }, + ); return ( <> @@ -548,17 +557,26 @@ function BackupForm({ settings }: { settings: InstanceSettings }) { const [confirming, setConfirming] = useState(false); const save = useSave("backups"); const targetChanged = - settings.backups.destination === "s3" && - form.destination === "s3" && - (settings.backups.bucket !== form.bucket || + (settings.backups.destination === "s3" || form.destination === "s3") && + (settings.backups.destination !== form.destination || + settings.backups.bucket !== form.bucket || + settings.backups.region !== form.region || settings.backups.endpoint !== form.endpoint); const submit = (confirmed = false) => - save.mutate({ - ...form, - access_key_id: accessKey || null, - secret_access_key: secretKey || null, - confirm_target_change: confirmed, - }); + save.mutate( + { + ...form, + access_key_id: accessKey || null, + secret_access_key: secretKey || null, + confirm_target_change: confirmed, + }, + { + onSuccess: () => { + setAccessKey(""); + setSecretKey(""); + }, + }, + ); return ( <> diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 21d34847..363c3a2f 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -755,8 +755,10 @@ export function backupDownloadUrl( return `${BASE_URL}${backupScopePrefix(scope)}/backups/${backupId}/download`; } +export type RestoreReport = { recovery_required: string[] }; + export async function restoreBackup(scope: BackupScope, input: RestoreInput) { - return api(`${backupScopePrefix(scope)}/restore`, { + return api(`${backupScopePrefix(scope)}/restore`, { method: "POST", body: JSON.stringify(input), }); @@ -796,6 +798,7 @@ export async function restoreBackupUpload( body, ); } + return (await res.json()) as RestoreReport; } /** Inspect a stored backup (by id or key) to list the sites it contains. */ From cc1b77c26e2b6153a98042c63be41ce3f0c708a3 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:37:50 +0530 Subject: [PATCH 17/34] fix(backup): scope storage reset to instance restore only Only reset storage providers and switch to filesystem destination on whole-instance restore, not site restore. Update integration tests to assert RecoveryReport (200 + recovery_required array) instead of 204. --- apps/backend/src/services/backup/mod.rs | 8 +++++--- apps/backend/tests/rest/backups_tests.rs | 12 ++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/services/backup/mod.rs b/apps/backend/src/services/backup/mod.rs index 50e9e418..37c3d330 100644 --- a/apps/backend/src/services/backup/mod.rs +++ b/apps/backend/src/services/backup/mod.rs @@ -540,9 +540,11 @@ impl BackupService { // Credential blobs are intentionally absent from backups. Drop any old // process-held providers before publishing restored non-secret settings. - self.storage.remove("s3"); - if let Some(destination) = &self.filesystem_destination { - self.set_destination(destination.clone()); + if matches!(&req.target, RestoreTarget::WholeInstance) { + self.storage.remove("s3"); + if let Some(destination) = &self.filesystem_destination { + self.set_destination(destination.clone()); + } } if let Some(settings) = &self.settings { settings.reload().await.map_err(BackupError::Invalid)?; diff --git a/apps/backend/tests/rest/backups_tests.rs b/apps/backend/tests/rest/backups_tests.rs index 8cfe16b3..fc30247d 100644 --- a/apps/backend/tests/rest/backups_tests.rs +++ b/apps/backend/tests/rest/backups_tests.rs @@ -146,7 +146,8 @@ async fn site_backup_and_restore_round_trip() { .send() .await .unwrap(); - assert_eq!(resp.status(), 204, "restore site"); + assert_eq!(resp.status(), 200, "restore site"); + assert!(resp.json::().await.unwrap()["recovery_required"].is_array()); // The entry is back. assert_eq!(get_entry_status(&server, &token, &csrf, &site_id, &entry_id).await, 200); @@ -215,7 +216,8 @@ async fn encrypted_backup_round_trips() { .send() .await .unwrap(); - assert_eq!(resp.status(), 204); + assert_eq!(resp.status(), 200); + assert!(resp.json::().await.unwrap()["recovery_required"].is_array()); assert_eq!(get_entry_status(&server, &token, &csrf, &site_id, &entry_id).await, 200); } @@ -439,7 +441,8 @@ async fn inspect_lists_sites_and_multi_site_restore_round_trips() { .send() .await .unwrap(); - assert_eq!(resp.status(), 204, "multi-site restore"); + assert_eq!(resp.status(), 200, "multi-site restore"); + assert!(resp.json::().await.unwrap()["recovery_required"].is_array()); assert_eq!(get_entry_status(&server, &token, &csrf, &site_a, &entry_a).await, 200); assert_eq!(get_entry_status(&server, &token, &csrf, &site_b, &entry_b).await, 200); @@ -518,7 +521,8 @@ async fn instance_backup_and_restore_round_trip() { .send() .await .unwrap(); - assert_eq!(resp.status(), 204, "restore instance"); + assert_eq!(resp.status(), 200, "restore instance"); + assert!(resp.json::().await.unwrap()["recovery_required"].is_array()); // Instance restore wipes sessions; re-login, then verify the data is back. let (token2, csrf2) = login(&server, "admin", "admin").await; From 31d6166cef10312e1e8aa670afa1d97b667ee03c Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:38:09 +0530 Subject: [PATCH 18/34] refactor(main)!: dedup invalidate_credentials, wire settings in CLI Consolidate per-backend invalidate_credentials into a macro_rules! with transactions. CLI backup/restore now loads instance settings and applies to config. Better secrets-reset warning about backup encryption. Fix em-dash encoding in CLI help. Clean env vars in stdio proxy tests. --- apps/backend/src/cli.rs | 2 +- apps/backend/src/main.rs | 85 +++++++-------------- apps/backend/tests/mcp/stdio_proxy_tests.rs | 3 + 3 files changed, 31 insertions(+), 59 deletions(-) diff --git a/apps/backend/src/cli.rs b/apps/backend/src/cli.rs index 0b7e39fd..90fe97a4 100644 --- a/apps/backend/src/cli.rs +++ b/apps/backend/src/cli.rs @@ -7,7 +7,7 @@ use clap::{Parser, Subcommand}; name = "vcms", version, about = "Velopulent CMS", - long_about = "Velopulent CMS — self-hosted headless content management system.\n\n`vcms serve` runs a portable instance from ./vcms_data. When the native service is installed, operational commands use its fixed system data root.", + long_about = "Velopulent CMS — self-hosted headless content management system.\n\n`vcms serve` runs a portable instance from ./vcms_data. When the native service is installed, operational commands use its fixed system data root.", after_help = "DOCUMENTATION:\n https://cms.velopulent.com/docs" )] pub struct Cli { diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index eee7e823..66662c14 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -112,7 +112,7 @@ async fn run_secrets(action: &SecretsAction) -> Result<(), Box> { SecretsAction::Reset { yes } => { if !yes { return Err( - "Secret reset invalidates API tokens and integration credentials. Re-run with --yes.".into(), + "Secret reset invalidates API tokens and integration credentials, generates a new backup encryption key, and makes existing encrypted backups unreadable. Re-run with --yes.".into(), ); } let mode = if cms::service::is_installed()? { @@ -148,67 +148,32 @@ async fn run_secrets(action: &SecretsAction) -> Result<(), Box> { async fn invalidate_credentials(pool: &cms::database::pool::DbPool) -> Result<(i64, i64, i64), Box> { use cms::database::pool::DbPool; - match pool { - DbPool::Postgres(pool) => { + macro_rules! invalidate { + ($pool:expr, $disabled:expr) => {{ + let mut tx = $pool.begin().await?; let tokens = sqlx::query_scalar("SELECT COUNT(*) FROM access_tokens") - .fetch_one(pool) + .fetch_one(&mut *tx) .await?; let webhooks = sqlx::query_scalar("SELECT COUNT(*) FROM site_webhooks WHERE headers_encrypted <> ''") - .fetch_one(pool) + .fetch_one(&mut *tx) .await?; let sites = sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") - .fetch_one(pool) - .await?; - sqlx::query("DELETE FROM access_tokens").execute(pool).await?; - sqlx::query("DELETE FROM sessions").execute(pool).await?; - sqlx::query("UPDATE site_webhooks SET headers_encrypted = '', enabled = FALSE") - .execute(pool) + .fetch_one(&mut *tx) .await?; + sqlx::query("DELETE FROM access_tokens").execute(&mut *tx).await?; + sqlx::query("DELETE FROM sessions").execute(&mut *tx).await?; + sqlx::query($disabled).execute(&mut *tx).await?; sqlx::query("UPDATE instance_settings SET credentials_encrypted = NULL") - .execute(pool) - .await?; - return Ok((tokens, webhooks, sites)); - } - DbPool::MySql(pool) => { - let tokens = sqlx::query_scalar("SELECT COUNT(*) FROM access_tokens") - .fetch_one(pool) - .await?; - let webhooks = sqlx::query_scalar("SELECT COUNT(*) FROM site_webhooks WHERE headers_encrypted <> ''") - .fetch_one(pool) + .execute(&mut *tx) .await?; - let sites = sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") - .fetch_one(pool) - .await?; - sqlx::query("DELETE FROM access_tokens").execute(pool).await?; - sqlx::query("DELETE FROM sessions").execute(pool).await?; - sqlx::query("UPDATE site_webhooks SET headers_encrypted = '', enabled = FALSE") - .execute(pool) - .await?; - sqlx::query("UPDATE instance_settings SET credentials_encrypted = NULL") - .execute(pool) - .await?; - return Ok((tokens, webhooks, sites)); - } - DbPool::Sqlite(pool) => { - let tokens = sqlx::query_scalar("SELECT COUNT(*) FROM access_tokens") - .fetch_one(pool) - .await?; - let webhooks = sqlx::query_scalar("SELECT COUNT(*) FROM site_webhooks WHERE headers_encrypted <> ''") - .fetch_one(pool) - .await?; - let sites = sqlx::query_scalar("SELECT COUNT(*) FROM sites WHERE storage_provider = 's3'") - .fetch_one(pool) - .await?; - sqlx::query("DELETE FROM access_tokens").execute(pool).await?; - sqlx::query("DELETE FROM sessions").execute(pool).await?; - sqlx::query("UPDATE site_webhooks SET headers_encrypted = '', enabled = 0") - .execute(pool) - .await?; - sqlx::query("UPDATE instance_settings SET credentials_encrypted = NULL") - .execute(pool) - .await?; - return Ok((tokens, webhooks, sites)); - } + tx.commit().await?; + Ok((tokens, webhooks, sites)) + }}; + } + match pool { + DbPool::Postgres(pool) => invalidate!(pool, "UPDATE site_webhooks SET headers_encrypted = '', enabled = FALSE"), + DbPool::MySql(pool) => invalidate!(pool, "UPDATE site_webhooks SET headers_encrypted = '', enabled = FALSE"), + DbPool::Sqlite(pool) => invalidate!(pool, "UPDATE site_webhooks SET headers_encrypted = '', enabled = 0"), } } @@ -236,11 +201,13 @@ async fn run_backup(action: &BackupAction) -> Result<(), Box> { use cms::services::backup::{BackupService, CreateBackupOptions, build_backup_destination, meta}; let context = cms::runtime::RuntimeContext::initialize_default()?; - let config = context.bootstrap; + let mut config = context.bootstrap; let pool = init_db_with_config(&config).await?; + let settings = cms::services::settings::SettingsService::load(pool.clone(), &context.secrets.master_key).await?; + settings.apply_to_config(&mut config).await; let storage_registry = cms::server::initialize_storage(&config); let destination = build_backup_destination(&config)?; - let service = BackupService::new(pool.clone(), storage_registry, destination, &config); + let service = BackupService::new(pool.clone(), storage_registry, destination, &config).with_settings(settings); match action { BackupAction::Create { @@ -317,11 +284,13 @@ async fn run_restore( } let context = cms::runtime::RuntimeContext::initialize_default()?; - let config = context.bootstrap; + let mut config = context.bootstrap; let pool = init_db_with_config(&config).await?; + let settings = cms::services::settings::SettingsService::load(pool.clone(), &context.secrets.master_key).await?; + settings.apply_to_config(&mut config).await; let storage_registry = cms::server::initialize_storage(&config); let destination = build_backup_destination(&config)?; - let service = BackupService::new(pool, storage_registry, destination, &config); + let service = BackupService::new(pool, storage_registry, destination, &config).with_settings(settings); let target = match scope { "instance" => RestoreTarget::WholeInstance, "site" => RestoreTarget::Site { diff --git a/apps/backend/tests/mcp/stdio_proxy_tests.rs b/apps/backend/tests/mcp/stdio_proxy_tests.rs index 45dbd986..d9e4e958 100644 --- a/apps/backend/tests/mcp/stdio_proxy_tests.rs +++ b/apps/backend/tests/mcp/stdio_proxy_tests.rs @@ -26,6 +26,9 @@ impl StdioClient { .args(["mcp", "stdio"]) .env("VCMS_MCP_URL", url) .env("VCMS_MCP_TOKEN", token) + .env_remove("DATABASE_URL") + .env_remove("HMAC_SECRET") + .env_remove("VCMS_HOME") .env("RUST_LOG", "warn") .stdin(Stdio::piped()) .stdout(Stdio::piped()) From ecfdddf8c0b184e5ddc47c493f0226de80e6ac3f Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:38:34 +0530 Subject: [PATCH 19/34] fix: correctness and hardening improvements - diagnostics: use floor_char_boundary for safe string truncation - paths: always apply permissions/ACLs on directory creation - service/status: handle macOS metadata errors gracefully - router: apply dynamic runtime policy to MCP routes - server: validate security config after settings load, conditional MCP line in startup banner - services/settings: validate settings on load from database --- apps/backend/src/diagnostics.rs | 3 ++- apps/backend/src/paths.rs | 15 +++------------ apps/backend/src/router/mod.rs | 4 ++-- apps/backend/src/server.rs | 12 ++++++++++-- apps/backend/src/service/status.rs | 7 ++++++- apps/backend/src/services/settings.rs | 4 +++- 6 files changed, 26 insertions(+), 19 deletions(-) diff --git a/apps/backend/src/diagnostics.rs b/apps/backend/src/diagnostics.rs index 5345a42b..d91cf1c5 100644 --- a/apps/backend/src/diagnostics.rs +++ b/apps/backend/src/diagnostics.rs @@ -93,7 +93,8 @@ fn report(name: &str, ok: bool, detail: String, failed: &mut bool) { fn sanitize(message: &str) -> String { if message.len() > 240 { - format!("{}…", &message[..240]) + let end = message.floor_char_boundary(240); + format!("{}…", &message[..end]) } else { message.to_owned() } diff --git a/apps/backend/src/paths.rs b/apps/backend/src/paths.rs index 5276bb37..6dc2334c 100644 --- a/apps/backend/src/paths.rs +++ b/apps/backend/src/paths.rs @@ -92,12 +92,7 @@ impl RuntimePaths { } pub fn ensure(&self) -> std::io::Result<()> { - let root_existed = self.root.exists(); create_private_dir(&self.root)?; - #[cfg(windows)] - if !root_existed { - harden_windows_acl(&self.root, true)?; - } for path in [ self.storage_dir(), self.backups_dir(), @@ -113,18 +108,14 @@ impl RuntimePaths { #[cfg(unix)] fn create_private_dir(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; - let existed = path.exists(); std::fs::create_dir_all(path)?; - if existed { - Ok(()) - } else { - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) - } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) } #[cfg(windows)] fn create_private_dir(path: &Path) -> std::io::Result<()> { - std::fs::create_dir_all(path) + std::fs::create_dir_all(path)?; + harden_windows_acl(path, true) } #[cfg(windows)] diff --git a/apps/backend/src/router/mod.rs b/apps/backend/src/router/mod.rs index 9f9575ae..dc9a759e 100644 --- a/apps/backend/src/router/mod.rs +++ b/apps/backend/src/router/mod.rs @@ -147,7 +147,7 @@ pub fn create_router( .layer(Extension(storage_registry.clone())) .layer(Extension(services)) .layer(Extension(backup)) - .layer(Extension(settings)) + .layer(Extension(settings.clone())) .layer(Extension(pool)) .layer(Extension(rate_limiter)); @@ -159,7 +159,7 @@ pub fn create_router( storage_registry, mcp_ct, ); - router = router.merge(mcp_router); + router = router.merge(mcp_router.layer(from_fn_with_state(settings, dynamic_runtime_policy))); router } diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index 1c1aa944..dc7ba134 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -54,6 +54,9 @@ pub async fn run( .await .map_err(|error| format!("loading instance settings: {error}"))?; settings.apply_to_config(&mut config).await; + config + .validate_security() + .map_err(|error| format!("Invalid persisted security configuration: {error}"))?; let repository = Repository::new(&pool); @@ -138,6 +141,11 @@ pub async fn run( info!("gRPC server running on {}", grpc_addr); if runtime.mode == crate::paths::RuntimeMode::Portable { + let mcp_line = if config.mcp_enabled { + format!(" MCP http://{addr}/mcp\n") + } else { + String::new() + }; println!( "\nVelopulent CMS\n\ Mode portable\n\ @@ -146,14 +154,14 @@ pub async fn run( REST http://{}/api/v1\n\ GraphQL http://{}/api/graphql\n\ gRPC {}\n\ - MCP http://{}/mcp\n\ + {}\ Logs {}\n", runtime.paths.root().display(), addr, addr, addr, grpc_addr, - addr, + mcp_line, runtime.paths.logs_dir().display(), ); } diff --git a/apps/backend/src/service/status.rs b/apps/backend/src/service/status.rs index b84bc282..8e0c52b6 100644 --- a/apps/backend/src/service/status.rs +++ b/apps/backend/src/service/status.rs @@ -41,7 +41,12 @@ pub fn is_installed() -> Result> { } #[cfg(target_os = "macos")] { - return Ok(std::path::Path::new("/Library/LaunchDaemons/com.velopulent.vcms.plist").is_file()); + let path = std::path::Path::new("/Library/LaunchDaemons/com.velopulent.vcms.plist"); + return match std::fs::metadata(path) { + Ok(metadata) => Ok(metadata.is_file()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + }; } let (manager, mut command) = native_command(); diff --git a/apps/backend/src/services/settings.rs b/apps/backend/src/services/settings.rs index 80b81cba..d4946781 100644 --- a/apps/backend/src/services/settings.rs +++ b/apps/backend/src/services/settings.rs @@ -135,7 +135,9 @@ impl SettingsService { if version != SETTINGS_VERSION as i64 { return Err(format!("unsupported instance settings version {version}")); } - let settings = serde_json::from_str(&settings_json).map_err(|error| error.to_string())?; + let settings: InstanceSettings = + serde_json::from_str(&settings_json).map_err(|error| error.to_string())?; + validate(&settings)?; let credentials = match encrypted.as_deref() { Some(value) => decrypt_credentials(&key, value).unwrap_or_else(|error| { tracing::error!( From 7f9e47cbdf57f6c2c91b751ab2f8a0304c530d9b Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:10:27 +0530 Subject: [PATCH 20/34] style(windows): clean up let-chain formatting in service control handler --- apps/backend/src/service/windows.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/service/windows.rs b/apps/backend/src/service/windows.rs index 56c50209..2bf5df34 100644 --- a/apps/backend/src/service/windows.rs +++ b/apps/backend/src/service/windows.rs @@ -85,18 +85,18 @@ fn run_service_session() -> Result<(), Box> { let status_handle = service_control_handler::register(SERVICE_NAME, move |control| match control { ServiceControl::Stop | ServiceControl::Shutdown => { - if let Ok(guard) = handle_cell.lock() { - if let Some(h) = guard.as_ref() { - let _ = h.set_service_status(ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::StopPending, - controls_accepted: ServiceControlAccept::empty(), - exit_code: ServiceExitCode::Win32(0), - checkpoint: 1, - wait_hint: Duration::from_secs(30), - process_id: None, - }); - } + if let Ok(guard) = handle_cell.lock() + && let Some(h) = guard.as_ref() + { + let _ = h.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::StopPending, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 1, + wait_hint: Duration::from_secs(30), + process_id: None, + }); } handler_notify.notify_one(); ServiceControlHandlerResult::NoError From ce82a319882e08385d444dc4461caf4475f014e6 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:10:36 +0530 Subject: [PATCH 21/34] style(backup): move registry_tests to end of schema module --- apps/backend/src/services/backup/schema.rs | 38 +++++++++++----------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/backend/src/services/backup/schema.rs b/apps/backend/src/services/backup/schema.rs index b53344e9..0597718d 100644 --- a/apps/backend/src/services/backup/schema.rs +++ b/apps/backend/src/services/backup/schema.rs @@ -202,25 +202,6 @@ pub static TABLES: &[TableSpec] = &[ }, ]; -#[cfg(test)] -mod registry_tests { - use super::*; - - #[test] - fn backup_registry_excludes_tokens_and_encrypted_credentials() { - assert!(table_spec("access_tokens").is_none()); - let settings = table_spec("instance_settings").unwrap(); - assert!( - !settings - .columns - .iter() - .any(|column| column.name == "credentials_encrypted") - ); - let webhooks = table_spec("site_webhooks").unwrap(); - assert!(webhooks.columns.iter().any(|column| column.name == "enabled")); - } -} - pub fn table_spec(name: &str) -> Option<&'static TableSpec> { TABLES.iter().find(|t| t.name == name) } @@ -453,3 +434,22 @@ pub async fn site_exists(pool: &DbPool, site_id: &str) -> Result Date: Sun, 19 Jul 2026 22:10:49 +0530 Subject: [PATCH 22/34] style(settings): remove unnecessary lifetime on validate_http_url --- apps/backend/src/services/settings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/services/settings.rs b/apps/backend/src/services/settings.rs index d4946781..8d469a79 100644 --- a/apps/backend/src/services/settings.rs +++ b/apps/backend/src/services/settings.rs @@ -304,7 +304,7 @@ pub fn validate(settings: &InstanceSettings) -> Result<(), String> { Ok(()) } -fn validate_http_url<'a>(value: &'a str, name: &str) -> Result { +fn validate_http_url(value: &str, name: &str) -> Result { let parsed = url::Url::parse(value).map_err(|error| format!("{name} is invalid: {error}"))?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { return Err(format!("{name} must be an absolute HTTP(S) URL")); From c9befa84fb37883a566553133f308feeadcfa5ef Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:11:00 +0530 Subject: [PATCH 23/34] style(webhook): simplify strip_prefix with ? operator --- apps/backend/src/services/webhook.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/src/services/webhook.rs b/apps/backend/src/services/webhook.rs index a92801b1..48aa5a22 100644 --- a/apps/backend/src/services/webhook.rs +++ b/apps/backend/src/services/webhook.rs @@ -559,9 +559,7 @@ fn decrypt_headers_checked(encrypted: &str, key: &[u8; 32]) -> Option b, Err(_) => return None, From 603a56d469df6918dc0b1f0f8b9201e46e7968e2 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:11:12 +0530 Subject: [PATCH 24/34] test(files): rename TEST_HMAC_SECRET to TEST_SIGNED_UPLOAD_KEY --- apps/backend/tests/rest/files_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/tests/rest/files_tests.rs b/apps/backend/tests/rest/files_tests.rs index b921a904..0c39af1d 100644 --- a/apps/backend/tests/rest/files_tests.rs +++ b/apps/backend/tests/rest/files_tests.rs @@ -348,7 +348,7 @@ async fn test_upload_file_invalid_mime_type() { // Tokens are minted directly with the lib (same HMAC secret as TestServer), so // these tests cover the PUT endpoint without an MCP round-trip. -const TEST_HMAC_SECRET: &str = "test-hmac-secret-integration"; +const TEST_HMAC_SECRET: &str = "test-signed-upload-key"; fn mint_upload_url(server: &TestServer, site_id: &str, filename: &str, mime: &str, expiry_secs: i64) -> String { let (_, encoded) = cms::signed_upload::SignedUploadToken::generate_with_storage_provider( From fdcea5e0e73097fe6795eb2d9e4f9857611c0d7b Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:37:17 +0530 Subject: [PATCH 25/34] test(gRPC): spawn search indexer in test infrastructure Add search_index_path to test config. Mirror server startup by spawning the single-writer search index consumer so gRPC tests can exercise full-text search. --- apps/backend/tests/common/grpc.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/backend/tests/common/grpc.rs b/apps/backend/tests/common/grpc.rs index d6340a2c..a15038da 100644 --- a/apps/backend/tests/common/grpc.rs +++ b/apps/backend/tests/common/grpc.rs @@ -60,6 +60,7 @@ impl GrpcTestContext { bcrypt_cost: bcrypt::DEFAULT_COST, webhook_allow_private_targets: true, backup_local_path: Some(format!("{storage_path}/backups")), + search_index_path: Some(format!("{storage_path}/search")), ..Default::default() }; @@ -81,6 +82,18 @@ impl GrpcTestContext { let repository_arc = Arc::new(repository.clone()); let services = Services::new(repository_arc.clone(), &pool, &config); + // Mirror server startup: gRPC writes enqueue search updates, so tests need + // the single index consumer running against an isolated per-test index. + if let (Some(search), Some(queue)) = (services.search.clone(), services.search_queue.clone()) { + let repo = repository_arc.clone(); + tokio::spawn(async move { + if search.is_empty() { + let _ = search.rebuild_all(&repo).await; + } + cms::services::search::indexer::run(search, queue, repo).await; + }); + } + // Start Axum server for REST-based seeding let axum_listener = TcpListener::bind("127.0.0.1:0") .await From 538cca976a175b41dde31b565bcc27aa654054c4 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:37:29 +0530 Subject: [PATCH 26/34] test(gRPC): add search index isolation test and async polling Use poll-based assertions for search tests since indexing is asynchronous. Add test_search_indexes_are_isolated_across_concurrent_contexts to verify Tantivy indexes don't leak between isolated test contexts. --- apps/backend/tests/grpc/entries_tests.rs | 97 +++++++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/apps/backend/tests/grpc/entries_tests.rs b/apps/backend/tests/grpc/entries_tests.rs index 68753e97..23e1d1b2 100644 --- a/apps/backend/tests/grpc/entries_tests.rs +++ b/apps/backend/tests/grpc/entries_tests.rs @@ -131,17 +131,28 @@ async fn test_list_entries_with_search() { .await .unwrap(); - let resp = client - .list_entries(tonic::Request::new(ListEntriesRequest { - collection_id: Some(collection_id), - status: None, - search: Some("Unique".into()), - page: 1, - per_page: 10, - })) - .await - .unwrap() - .into_inner(); + // Search indexing is asynchronous in production. Poll briefly until the + // queue consumer publishes the committed document to the reader. + let mut resp = None; + for _ in 0..50 { + let current = client + .list_entries(tonic::Request::new(ListEntriesRequest { + collection_id: Some(collection_id.clone()), + status: None, + search: Some("Unique".into()), + page: 1, + per_page: 10, + })) + .await + .unwrap() + .into_inner(); + if current.items.iter().any(|item| item.slug == "searchable") { + resp = Some(current); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let resp = resp.unwrap_or_default(); let slugs: Vec<&str> = resp.items.iter().map(|i| i.slug.as_str()).collect(); assert!( @@ -151,6 +162,70 @@ async fn test_list_entries_with_search() { ); } +#[tokio::test] +async fn test_search_indexes_are_isolated_across_concurrent_contexts() { + let ((ctx_a, _site_a, token_a, collection_a), (ctx_b, _site_b, token_b, collection_b)) = + tokio::join!(setup(), setup()); + + let channel_a = ctx_a.connect().await; + let channel_b = ctx_b.connect().await; + let mut client_a = EntryServiceClient::with_interceptor(channel_a, auth_interceptor(&token_a)); + let mut client_b = EntryServiceClient::with_interceptor(channel_b, auth_interceptor(&token_b)); + + for (client, collection_id, slug) in [ + (&mut client_a, collection_a.clone(), "context-a"), + (&mut client_b, collection_b.clone(), "context-b"), + ] { + let created = client + .create_entry(tonic::Request::new(CreateEntryRequest { + collection_id, + data: r#"{"title":"Running"}"#.into(), + slug: slug.into(), + })) + .await + .unwrap() + .into_inner(); + client + .publish_entry(tonic::Request::new(PublishEntryRequest { id: created.id })) + .await + .unwrap(); + } + + for (client, collection_id, expected_slug, other_slug) in [ + (&mut client_a, collection_a, "context-a", "context-b"), + (&mut client_b, collection_b, "context-b", "context-a"), + ] { + let mut found = None; + for _ in 0..50 { + let response = client + .list_entries(tonic::Request::new(ListEntriesRequest { + collection_id: Some(collection_id.clone()), + status: None, + // Stemmed match proves Tantivy served this result: SQL LIKE + // cannot match "run" against the stored word "Running". + search: Some("run".into()), + page: 1, + per_page: 10, + })) + .await + .unwrap() + .into_inner(); + if response.items.iter().any(|item| item.slug == expected_slug) { + found = Some(response); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + + let response = found.unwrap_or_else(|| panic!("{expected_slug} never became searchable")); + let slugs: Vec<&str> = response.items.iter().map(|item| item.slug.as_str()).collect(); + assert!( + !slugs.contains(&other_slug), + "search index leaked across contexts: {slugs:?}" + ); + } +} + #[tokio::test] async fn test_update_entry() { let (ctx, _site_id, token, collection_id) = setup().await; From c5c69cacdab290d9f56ece391170a71913dcb04d Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:07:27 +0530 Subject: [PATCH 27/34] feat(windows): use ReplaceFileW for atomic secrets file replacement Replace the non-atomic copy+delete with ReplaceFileW via windows-sys to ensure crash-safe atomic replacement of secrets.toml on Windows. --- Cargo.lock | 1 + apps/backend/Cargo.toml | 1 + apps/backend/src/secrets.rs | 30 ++++++++++++++++++++++++++++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f2d872e8..2195f592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -840,6 +840,7 @@ dependencies = [ "utoipa-scalar", "uuid", "windows-service", + "windows-sys 0.61.2", "wiremock", "zstd", ] diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 3358057b..4de900f3 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -80,6 +80,7 @@ tantivy = "0.26" # Windows: native Service Control Manager host for `vcms service run`. [target.'cfg(windows)'.dependencies] windows-service = "0.8" +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } [build-dependencies] tonic-build = "0.14" diff --git a/apps/backend/src/secrets.rs b/apps/backend/src/secrets.rs index f5f441bd..13c06e97 100644 --- a/apps/backend/src/secrets.rs +++ b/apps/backend/src/secrets.rs @@ -68,13 +68,39 @@ pub fn replace(paths: &RuntimePaths, secrets: &PersistedSecrets) -> Result<(), B std::fs::rename(&temp, &target)?; #[cfg(windows)] { - std::fs::copy(&temp, &target)?; + replace_file_windows(&target, &temp)?; restrict_permissions(&target)?; - std::fs::remove_file(&temp)?; } Ok(()) } +#[cfg(windows)] +fn replace_file_windows(target: &std::path::Path, replacement: &std::path::Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + + let target: Vec = target.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); + let replacement: Vec = replacement + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let replaced = unsafe { + windows_sys::Win32::Storage::FileSystem::ReplaceFileW( + target.as_ptr(), + replacement.as_ptr(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + if replaced == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + fn persist_new(paths: &RuntimePaths, secrets: &PersistedSecrets) -> Result<(), Box> { write_restricted(&paths.secrets_file(), &toml::to_string_pretty(secrets)?, true) } From 165928e45d02640545b4c28fb44a1638f0aa2b71 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:07:48 +0530 Subject: [PATCH 28/34] feat(settings): add optimistic locking to prevent concurrent publish conflicts Add a write_lock mutex and compare-and-swap pattern in publish(): the caller must supply the expected current snapshot, and the method verifies it matches the persisted row before writing. This prevents two concurrent admin UI saves from silently overwriting each other. --- apps/backend/src/services/settings.rs | 80 +++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/services/settings.rs b/apps/backend/src/services/settings.rs index 8d469a79..95d94fc4 100644 --- a/apps/backend/src/services/settings.rs +++ b/apps/backend/src/services/settings.rs @@ -5,7 +5,7 @@ use aes_gcm::{Aes256Gcm, Key, Nonce}; use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; use rand::Rng; use serde::{Deserialize, Serialize}; -use tokio::sync::{RwLock, watch}; +use tokio::sync::{Mutex, RwLock, watch}; use crate::database::pool::DbPool; @@ -63,14 +63,14 @@ pub struct BackupSettings { pub endpoint: Option, } -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CredentialPair { pub access_key_id: String, pub secret_access_key: String, } -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct EncryptedCredentials { pub storage: Option, @@ -121,6 +121,7 @@ pub struct SettingsService { key: Arc<[u8; 32]>, snapshot: watch::Sender>, credentials: Arc>, + write_lock: Arc>, } impl SettingsService { @@ -163,6 +164,7 @@ impl SettingsService { key: Arc::new(key), snapshot, credentials: Arc::new(RwLock::new(credentials)), + write_lock: Arc::new(Mutex::new(())), }) } @@ -178,8 +180,31 @@ impl SettingsService { self.credentials.read().await.clone() } - pub async fn publish(&self, settings: InstanceSettings, credentials: EncryptedCredentials) -> Result<(), String> { + pub async fn publish( + &self, + expected: &InstanceSettings, + expected_credentials: &EncryptedCredentials, + settings: InstanceSettings, + credentials: EncryptedCredentials, + ) -> Result<(), String> { + let _guard = self.write_lock.lock().await; validate(&settings)?; + let Some((version, settings_json, encrypted)) = + load_row(&self.pool).await.map_err(|error| error.to_string())? + else { + return Err("instance settings row is missing".into()); + }; + if version != SETTINGS_VERSION as i64 { + return Err(format!("unsupported instance settings version {version}")); + } + let persisted: InstanceSettings = serde_json::from_str(&settings_json).map_err(|error| error.to_string())?; + let persisted_credentials = match encrypted.as_deref() { + Some(value) => decrypt_credentials(&self.key, value)?, + None => EncryptedCredentials::default(), + }; + if &persisted != expected || &persisted_credentials != expected_credentials { + return Err("instance settings changed; reload and retry".into()); + } let encrypted = if credentials.storage.is_some() || credentials.backups.is_some() { Some(encrypt_credentials(&self.key, &credentials)?) } else { @@ -194,6 +219,7 @@ impl SettingsService { } pub async fn reload(&self) -> Result<(), String> { + let _guard = self.write_lock.lock().await; let Some((version, settings_json, encrypted)) = load_row(&self.pool).await.map_err(|error| error.to_string())? else { @@ -419,4 +445,50 @@ mod tests { ); assert!(decrypt_credentials(&[8_u8; 32], &encrypted).is_err()); } + + #[tokio::test] + async fn concurrent_publish_rejects_stale_snapshot() { + let pool = crate::database::init_db("sqlite::memory:").await.unwrap(); + let service = SettingsService::load(pool, &"11".repeat(32)).await.unwrap(); + let expected = service.current(); + let expected_credentials = service.credentials().await; + + let mut general_update = (*expected).clone(); + general_update.general.public_registration = true; + let mut security_update = (*expected).clone(); + security_update.security.private_webhook_targets = true; + + let first = service.clone(); + let first_expected = expected.clone(); + let first_credentials = expected_credentials.clone(); + let second = service.clone(); + let second_expected = expected.clone(); + let second_credentials = expected_credentials.clone(); + let (general_result, security_result) = tokio::join!( + async move { + first + .publish( + &first_expected, + &first_credentials, + general_update, + first_credentials.clone(), + ) + .await + }, + async move { + second + .publish( + &second_expected, + &second_credentials, + security_update, + second_credentials.clone(), + ) + .await + } + ); + + assert_ne!(general_result.is_ok(), security_result.is_ok()); + let stale = general_result.err().or_else(|| security_result.err()).unwrap(); + assert!(stale.contains("changed")); + } } From 717e17526a8e9bbc43897f049641be7e2a8018ff Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:07:53 +0530 Subject: [PATCH 29/34] refactor(settings): pass expected current snapshot through publish call chain Update all publish() call sites to capture the current settings and credentials snapshot before mutating, then pass both expected and new values to the new optimistic-locking publish API. --- apps/backend/src/handlers/settings_handler.rs | 67 ++++++++++++++----- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/handlers/settings_handler.rs b/apps/backend/src/handlers/settings_handler.rs index 8f9b7d0a..60a16fba 100644 --- a/apps/backend/src/handlers/settings_handler.rs +++ b/apps/backend/src/handlers/settings_handler.rs @@ -15,12 +15,12 @@ use crate::error::AppError; use crate::middleware::auth::{AuthContext, require_instance_action}; use crate::models::authorization::Action; use crate::repository::Repository; -use crate::services::backup::BackupService; +use crate::services::backup::{BackupDestination, BackupService}; use crate::services::settings::{ BackupSettings, CredentialPair, EncryptedCredentials, GeneralSettings, InstanceSettings, SecuritySettings, SettingsService, StorageSettings, }; -use crate::storage::{FileSystemStorage, S3Storage, StorageProvider, StorageRegistry}; +use crate::storage::{S3Storage, StorageRegistry}; #[derive(Serialize)] pub struct SettingsResponse { @@ -85,9 +85,11 @@ pub async fn update_general( if let Err(response) = owner(&auth, &repository).await { return response; } - let mut next = (*settings.current()).clone(); + let current = settings.current(); + let credentials = settings.credentials().await; + let mut next = (*current).clone(); next.general = section; - publish(&settings, next, settings.credentials().await).await + publish(&settings, ¤t, &credentials, next, credentials.clone()).await } pub async fn update_security( @@ -99,9 +101,11 @@ pub async fn update_security( if let Err(response) = owner(&auth, &repository).await { return response; } - let mut next = (*settings.current()).clone(); + let current = settings.current(); + let credentials = settings.credentials().await; + let mut next = (*current).clone(); next.security = section; - publish(&settings, next, settings.credentials().await).await + publish(&settings, ¤t, &credentials, next, credentials.clone()).await } pub async fn update_storage( @@ -129,7 +133,8 @@ pub async fn update_storage( _ => {} } } - let mut credentials = settings.credentials().await; + let current_credentials = settings.credentials().await; + let mut credentials = current_credentials.clone(); let provider: Option = if update.settings.provider == "filesystem" { credentials.storage = None; None @@ -156,7 +161,10 @@ pub async fn update_storage( return AppError::Conflict("Storage settings changed while the S3 probe was running; review and retry".into()) .into_response(); } - if let Err(error) = settings.publish(next, credentials).await { + if let Err(error) = settings + .publish(¤t, ¤t_credentials, next, credentials) + .await + { return AppError::BadRequest(error).into_response(); } match provider { @@ -184,11 +192,17 @@ pub async fn update_backups( ) .into_response(); } - let mut credentials = settings.credentials().await; - let destination: Arc = if update.settings.destination == "filesystem" { + let current_credentials = settings.credentials().await; + let mut credentials = current_credentials.clone(); + let destination = if update.settings.destination == "filesystem" { credentials.backups = None; - match FileSystemStorage::new(config.backup_local_path.as_deref().unwrap_or("vcms_data/backups")) { - Ok(provider) => Arc::new(provider), + match BackupDestination::filesystem( + config + .backup_local_path + .clone() + .unwrap_or_else(|| "vcms_data/backups".into()), + ) { + Ok(destination) => destination, Err(error) => return AppError::Internal(error.to_string()).into_response(), } } else { @@ -209,8 +223,17 @@ pub async fn update_backups( Ok(provider) => provider, Err(error) => return AppError::BadRequest(format!("S3 probe failed: {error}")).into_response(), }; + let destination = BackupDestination::s3( + Arc::new(provider), + pair.access_key_id.clone(), + pair.secret_access_key.clone(), + storage.bucket.clone().unwrap_or_default(), + storage.region.clone().unwrap_or_else(|| "us-east-1".into()), + storage.endpoint.clone(), + storage.public_url.clone(), + ); credentials.backups = Some(pair); - Arc::new(provider) + destination } Err(error) => return AppError::BadRequest(error).into_response(), } @@ -221,15 +244,27 @@ pub async fn update_backups( return AppError::Conflict("Backup settings changed while the S3 probe was running; review and retry".into()) .into_response(); } - if let Err(error) = settings.publish(next, credentials).await { + if let Err(error) = settings + .publish(¤t, ¤t_credentials, next, credentials) + .await + { return AppError::BadRequest(error).into_response(); } backup.set_destination(destination); response(&settings).await.into_response() } -async fn publish(settings: &SettingsService, next: InstanceSettings, credentials: EncryptedCredentials) -> Response { - match settings.publish(next, credentials).await { +async fn publish( + settings: &SettingsService, + expected: &InstanceSettings, + expected_credentials: &EncryptedCredentials, + next: InstanceSettings, + credentials: EncryptedCredentials, +) -> Response { + match settings + .publish(expected, expected_credentials, next, credentials) + .await + { Ok(()) => response(settings).await.into_response(), Err(error) => AppError::BadRequest(error).into_response(), } From 75ca988c0bedc20440e1568006a57f860741871f Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:07:58 +0530 Subject: [PATCH 30/34] feat(backup): add encrypted destination locators and BackupDestination wrapper Wrap the raw StorageProvider with a BackupDestination that bundles a serializable spec (filesystem path or S3 credentials) alongside the provider. Destination references stored in the database are now encrypted dest:v1: locators that embed the spec, so staged uploads and recorded backups remain restorable even after the runtime destination changes. Also adds validate_restored_settings() to reject instance restores with invalid settings before committing. --- apps/backend/src/services/backup/mod.rs | 252 +++++++++++++++++++++--- 1 file changed, 222 insertions(+), 30 deletions(-) diff --git a/apps/backend/src/services/backup/mod.rs b/apps/backend/src/services/backup/mod.rs index 37c3d330..84f07e12 100644 --- a/apps/backend/src/services/backup/mod.rs +++ b/apps/backend/src/services/backup/mod.rs @@ -18,6 +18,7 @@ use std::sync::RwLock; use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::Engine; use chrono::Utc; use rand::Rng; use serde::{Deserialize, Serialize}; @@ -163,26 +164,75 @@ pub struct RestoreRequest { pub struct BackupService { pool: DbPool, storage: Arc, - destination: Arc>>, - filesystem_destination: Option>, + destination: Arc>, + filesystem_destination: Option, encryption_key: Option<[u8; 32]>, zstd_level: i32, settings: Option, } -impl BackupService { - pub fn new( - pool: DbPool, - storage: Arc, - destination: Arc, - config: &Config, +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "provider", rename_all = "lowercase")] +enum DestinationSpec { + Filesystem { + path: String, + }, + S3 { + access_key: String, + secret_key: String, + bucket: String, + region: String, + endpoint: Option, + public_url: Option, + }, +} + +#[derive(Clone)] +pub struct BackupDestination { + provider: Arc, + spec: DestinationSpec, +} + +impl BackupDestination { + pub fn filesystem(path: String) -> Result { + let provider = FileSystemStorage::new(&path).map_err(|error| BackupError::Storage(error.to_string()))?; + Ok(Self { + provider: Arc::new(provider), + spec: DestinationSpec::Filesystem { path }, + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn s3( + provider: Arc, + access_key: String, + secret_key: String, + bucket: String, + region: String, + endpoint: Option, + public_url: Option, ) -> Self { + Self { + provider, + spec: DestinationSpec::S3 { + access_key, + secret_key, + bucket, + region, + endpoint, + public_url, + }, + } + } +} + +impl BackupService { + pub fn new(pool: DbPool, storage: Arc, destination: BackupDestination, config: &Config) -> Self { let encryption_key = config.backup_encryption_key.as_deref().and_then(parse_key_hex); let filesystem_destination = config .backup_local_path - .as_deref() - .and_then(|path| FileSystemStorage::new(path).ok()) - .map(|provider| Arc::new(provider) as Arc); + .clone() + .and_then(|path| BackupDestination::filesystem(path).ok()); Self { pool, storage, @@ -206,11 +256,11 @@ impl BackupService { .unwrap_or(true) } - pub fn set_destination(&self, destination: Arc) { + pub fn set_destination(&self, destination: BackupDestination) { *self.destination.write().expect("backup destination poisoned") = destination; } - fn destination_snapshot(&self) -> Arc { + fn destination_snapshot(&self) -> BackupDestination { self.destination.read().expect("backup destination poisoned").clone() } @@ -350,10 +400,11 @@ impl BackupService { }; let destination = self.destination_snapshot(); destination + .provider .put(&key, bytes::Bytes::from(bytes.clone()), "application/octet-stream") .await .map_err(|e| BackupError::Storage(e.to_string()))?; - Ok((bytes, manifest, key)) + Ok((bytes, manifest, self.encode_destination(&destination.spec, &key)?)) } /// Delete a backup artifact and its row. @@ -361,7 +412,7 @@ impl BackupService { if let Some(row) = meta::get_backup(&self.pool, id).await? && let Some(key) = &row.destination_key { - let _ = self.destination_snapshot().delete(key).await; + let _ = self.delete_destination_object(key).await; } meta::delete_backup_row(&self.pool, id).await } @@ -463,28 +514,102 @@ impl BackupService { /// [`delete_destination`](Self::delete_destination) after the restore. pub async fn stage_upload(&self, bytes: Vec) -> Result { let key = format!("{TEMP_RESTORE_PREFIX}{}.cmsbak", new_id()); - self.destination_snapshot() + let destination = self.destination_snapshot(); + destination + .provider .put(&key, bytes::Bytes::from(bytes), "application/octet-stream") .await .map_err(|e| BackupError::Storage(e.to_string()))?; - Ok(key) + self.encode_destination(&destination.spec, &key) } /// Best-effort delete of a destination object (used to clean up staged uploads). pub async fn delete_destination(&self, key: &str) { - let _ = self.destination_snapshot().delete(key).await; + let _ = self.delete_destination_object(key).await; } /// Read a backup artifact from the configured destination. pub async fn read_destination(&self, key: &str) -> Result, BackupError> { - let bytes = self - .destination_snapshot() - .get(key) + let (provider, object_key) = self.resolve_destination(key)?; + let bytes = provider + .get(&object_key) .await .map_err(|e| BackupError::Storage(e.to_string()))?; Ok(bytes.to_vec()) } + fn encode_destination(&self, spec: &DestinationSpec, key: &str) -> Result { + let encryption_key = self.encryption_key.ok_or(BackupError::MissingKey)?; + let plaintext = serde_json::to_vec(&(spec, key)).map_err(|error| BackupError::Io(error.to_string()))?; + let cipher = Aes256Gcm::new(&Key::::from(encryption_key)); + let mut nonce_bytes = [0_u8; 12]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from(nonce_bytes); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_ref()) + .map_err(|_| BackupError::Crypto("destination encryption failed".into()))?; + let mut encoded = nonce_bytes.to_vec(); + encoded.extend(ciphertext); + Ok(format!( + "dest:v1:{}", + base64::engine::general_purpose::STANDARD.encode(encoded) + )) + } + + fn resolve_destination(&self, locator: &str) -> Result<(Arc, String), BackupError> { + let encoded = locator + .strip_prefix("dest:v1:") + .ok_or_else(|| BackupError::Invalid("invalid backup destination locator".into()))?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| BackupError::Invalid(error.to_string()))?; + if bytes.len() < 13 { + return Err(BackupError::Invalid("backup destination locator is truncated".into())); + } + let encryption_key = self.encryption_key.ok_or(BackupError::MissingKey)?; + let cipher = Aes256Gcm::new(&Key::::from(encryption_key)); + let nonce_bytes: [u8; 12] = bytes[..12] + .try_into() + .map_err(|_| BackupError::Invalid("backup destination nonce is invalid".into()))?; + let plaintext = cipher + .decrypt(&Nonce::from(nonce_bytes), &bytes[12..]) + .map_err(|_| BackupError::Crypto("backup destination locator cannot be decrypted".into()))?; + let (spec, key): (DestinationSpec, String) = + serde_json::from_slice(&plaintext).map_err(|error| BackupError::Invalid(error.to_string()))?; + let provider: Arc = match spec { + DestinationSpec::Filesystem { path } => { + Arc::new(FileSystemStorage::new(&path).map_err(|error| BackupError::Storage(error.to_string()))?) + } + DestinationSpec::S3 { + access_key, + secret_key, + bucket, + region, + endpoint, + public_url, + } => Arc::new( + S3Storage::new( + &access_key, + &secret_key, + &bucket, + ®ion, + endpoint.as_deref(), + public_url.as_deref(), + ) + .map_err(|error| BackupError::Storage(error.to_string()))?, + ), + }; + Ok((provider, key)) + } + + async fn delete_destination_object(&self, locator: &str) -> Result<(), BackupError> { + let (provider, key) = self.resolve_destination(locator)?; + provider + .delete(&key) + .await + .map_err(|error| BackupError::Storage(error.to_string())) + } + /// Restore from a backup artifact, replacing all data within the chosen scope /// in a single transaction. pub async fn restore(&self, req: RestoreRequest) -> Result { @@ -510,6 +635,9 @@ impl BackupService { } let tables = parse_all_tables(&tables_ndjson)?; + if matches!(&req.target, RestoreTarget::WholeInstance) { + validate_restored_settings(&tables)?; + } let plan = match &req.target { RestoreTarget::WholeInstance => { @@ -546,15 +674,20 @@ impl BackupService { self.set_destination(destination.clone()); } } - if let Some(settings) = &self.settings { - settings.reload().await.map_err(BackupError::Invalid)?; + let mut recovery_required = manifest.recovery_required.clone(); + if matches!(&req.target, RestoreTarget::WholeInstance) + && let Some(settings) = &self.settings + && let Err(error) = settings.reload().await + { + tracing::error!(%error, "restore committed but runtime settings reload failed"); + recovery_required.push(format!( + "Restore committed, but runtime settings could not be reloaded: {error}. Restart the service." + )); } // Restore file blobs (best-effort: a missing blob is non-fatal). self.restore_files(&manifest, &file_blobs).await; - Ok(RecoveryReport { - recovery_required: manifest.recovery_required, - }) + Ok(RecoveryReport { recovery_required }) } /// Build a merged, atomic restore plan for one or more sites extracted from the @@ -718,7 +851,7 @@ impl BackupService { /// Build the backup destination storage provider from config (independent of the /// site-files storage). Falls back to a local `backups/` directory. -pub fn build_backup_destination(config: &Config) -> Result, BackupError> { +pub fn build_backup_destination(config: &Config) -> Result { if config.backup_destination == "s3" && config.has_backup_s3() { let access_key = config .backup_s3_access_key_id @@ -741,14 +874,21 @@ pub fn build_backup_destination(config: &Config) -> Result; type Tables = HashMap>; +fn validate_restored_settings(tables: &Tables) -> Result<(), BackupError> { + let row = tables + .get("instance_settings") + .and_then(|rows| rows.first()) + .ok_or_else(|| BackupError::Invalid("instance backup is missing instance settings".into()))?; + let json = str_field(row, "settings_json") + .ok_or_else(|| BackupError::Invalid("restored instance settings are missing settings_json".into()))?; + let settings: crate::services::settings::InstanceSettings = serde_json::from_str(json) + .map_err(|error| BackupError::Invalid(format!("invalid restored settings: {error}")))?; + crate::services::settings::validate(&settings) + .map_err(|error| BackupError::Invalid(format!("invalid restored settings: {error}"))) +} + /// A decoded backup artifact: the manifest, the per-table NDJSON bytes (keyed by /// table name), and the uploaded file blobs (keyed by storage key). type ArtifactBundle = (Manifest, HashMap>, HashMap>); @@ -1153,3 +1306,42 @@ fn parse_key_hex(hex_str: &str) -> Option<[u8; 32]> { key.copy_from_slice(&bytes); Some(key) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn staged_artifact_remains_bound_to_original_destination() { + let pool = crate::database::init_db("sqlite::memory:").await.unwrap(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let mut config = Config { + backup_local_path: Some(first.path().to_string_lossy().into_owned()), + ..Config::default() + }; + let service = BackupService::new( + pool, + Arc::new(StorageRegistry::new()), + build_backup_destination(&config).unwrap(), + &config, + ); + + let locator = service.stage_upload(b"bound-to-first".to_vec()).await.unwrap(); + config.backup_local_path = Some(second.path().to_string_lossy().into_owned()); + service.set_destination(build_backup_destination(&config).unwrap()); + + assert_eq!(service.read_destination(&locator).await.unwrap(), b"bound-to-first"); + service.delete_destination(&locator).await; + assert!(service.read_destination(&locator).await.is_err()); + } + + #[test] + fn whole_instance_restore_rejects_invalid_settings_before_commit() { + let mut row = Row::new(); + row.insert("settings_json".into(), serde_json::Value::String("{}".into())); + let mut tables = Tables::new(); + tables.insert("instance_settings".into(), vec![row]); + assert!(validate_restored_settings(&tables).is_err()); + } +} From 7841148cfa5613f2482ff5e5b55e0aa8414c914c Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:08:04 +0530 Subject: [PATCH 31/34] feat(files): enforce upload limit from live settings instead of config constant Inject SettingsService into upload handlers to read the live upload_limit_mb from instance settings. The RequestBodyLimitLayer middleware is removed since the limit is now enforced at the handler level after settings are loaded. The StreamingUploadRequest gains a max_bytes field so the signed upload path can use the same budget as the multipart path. --- apps/backend/src/handlers/file_handler.rs | 15 +++++++++++---- apps/backend/src/router/files.rs | 14 +++++--------- apps/backend/src/services/file.rs | 6 +++++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/handlers/file_handler.rs b/apps/backend/src/handlers/file_handler.rs index 214693f3..2ec68c6c 100644 --- a/apps/backend/src/handlers/file_handler.rs +++ b/apps/backend/src/handlers/file_handler.rs @@ -25,6 +25,7 @@ use crate::repository::Repository; use crate::repository::traits::ListFilesParams; use crate::services::Services; use crate::services::file::StreamingUploadRequest; +use crate::services::settings::SettingsService; use crate::signed_upload::{SignedUploadError, SignedUploadToken}; use crate::storage::{StorageProvider, StorageRegistry}; @@ -125,12 +126,13 @@ pub async fn list_files( security(("bearer" = []), ("access_token" = [])), tag = "files" )] -#[instrument(skip(repository, services, ctx, multipart, storage_registry))] +#[instrument(skip(repository, services, settings, ctx, multipart, storage_registry))] pub async fn upload_file( ctx: RequestContext, Extension(repository): Extension, Extension(services): Extension, Extension(storage_registry): Extension>, + Extension(settings): Extension, mut multipart: Multipart, ) -> Response { if let Err((status, err)) = require_site_action(&ctx, &repository, Action::FilesWrite).await { @@ -149,6 +151,7 @@ pub async fn upload_file( Err(status) => return (status, Json(json!({"error": "Storage not configured"}))).into_response(), }; let created_by = ctx.auth.actor.user_id().map(String::from); + let max_bytes = settings.current().general.upload_limit_mb * 1024 * 1024; // Stream the "file" field straight into storage (constant memory); size cap // and content sniffing are enforced by the service while streaming. @@ -184,6 +187,7 @@ pub async fn upload_file( created_by: created_by.as_deref(), storage: storage.clone(), storage_provider: &storage_provider, + max_bytes, }, stream, ) @@ -213,13 +217,14 @@ pub async fn upload_file( ), tag = "files" )] -#[instrument(skip(services, config, storage_registry, headers, body))] +#[instrument(skip(services, config, settings, storage_registry, headers, body))] pub async fn upload_via_signed_url( Path(token): Path, headers: HeaderMap, Extension(services): Extension, Extension(config): Extension, Extension(storage_registry): Extension>, + Extension(settings): Extension, body: Body, ) -> Response { // The token is the auth: HMAC-signed by the server, time-limited, single-use. @@ -234,16 +239,17 @@ pub async fn upload_via_signed_url( }; // Fast reject when the client announces an oversized body upfront. + let max_bytes = settings.current().general.upload_limit_mb * 1024 * 1024; if let Some(len) = headers .get(header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) - && len > config.max_upload_size_bytes as u64 + && len > max_bytes as u64 { return ( StatusCode::PAYLOAD_TOO_LARGE, Json(json!({ - "error": format!("File too large. Maximum size is {}MB", config.max_upload_size_bytes / (1024 * 1024)) + "error": format!("File too large. Maximum size is {}MB", max_bytes / (1024 * 1024)) })), ) .into_response(); @@ -286,6 +292,7 @@ pub async fn upload_via_signed_url( created_by: None, storage, storage_provider: &token.storage_provider, + max_bytes, }, stream, ) diff --git a/apps/backend/src/router/files.rs b/apps/backend/src/router/files.rs index 257fac30..72a8d0ce 100644 --- a/apps/backend/src/router/files.rs +++ b/apps/backend/src/router/files.rs @@ -3,7 +3,6 @@ use axum::{ Router, routing::{delete, get, post, put}, }; -use tower_http::limit::RequestBodyLimitLayer; use crate::handlers::file_handler::{ batch_delete_files, batch_permanent_delete_files, batch_restore_files, delete_file_handler, get_file, @@ -12,14 +11,13 @@ use crate::handlers::file_handler::{ }; /// Public API CRUD routes (mounted at /api/v1) -pub fn public_routes(max_upload_bytes: usize) -> Router { +pub fn public_routes(_max_upload_bytes: usize) -> Router { Router::new() .route("/files", get(list_files)) .merge( Router::new() .route("/files", post(upload_file)) - .layer(DefaultBodyLimit::disable()) - .layer(RequestBodyLimitLayer::new(max_upload_bytes)), + .layer(DefaultBodyLimit::disable()), ) .route("/files/batch-delete", post(batch_delete_files)) .route("/files/batch-restore", post(batch_restore_files)) @@ -40,22 +38,20 @@ pub fn file_serve_routes() -> Router { /// Signed-URL upload — standalone, no auth middleware: the HMAC token in the /// path is the credential (minted by the MCP `create_upload_url` tool). /// Registered at the literal path the tool advertises. -pub fn signed_upload_routes(max_upload_bytes: usize) -> Router { +pub fn signed_upload_routes(_max_upload_bytes: usize) -> Router { Router::new() .route("/api/v1/files/upload/{token}", put(upload_via_signed_url)) .layer(DefaultBodyLimit::disable()) - .layer(RequestBodyLimitLayer::new(max_upload_bytes)) } /// Dashboard routes (mounted under /api/dashboard/sites/{site_id}) -pub fn dashboard_routes(max_upload_bytes: usize) -> Router { +pub fn dashboard_routes(_max_upload_bytes: usize) -> Router { Router::new() .route("/files", get(list_files)) .merge( Router::new() .route("/files", post(upload_file)) - .layer(DefaultBodyLimit::disable()) - .layer(RequestBodyLimitLayer::new(max_upload_bytes)), + .layer(DefaultBodyLimit::disable()), ) .route("/files/batch-delete", post(batch_delete_files)) .route("/files/batch-restore", post(batch_restore_files)) diff --git a/apps/backend/src/services/file.rs b/apps/backend/src/services/file.rs index e3ed38ac..842c1207 100644 --- a/apps/backend/src/services/file.rs +++ b/apps/backend/src/services/file.rs @@ -55,6 +55,7 @@ pub struct StreamingUploadRequest<'a> { pub created_by: Option<&'a str>, pub storage: Arc, pub storage_provider: &'a str, + pub max_bytes: usize, } /// RAII claim on an in-flight pre-generated file id; released on drop. @@ -204,6 +205,7 @@ impl FileService { created_by, storage, storage_provider, + max_bytes: self.config.max_upload_size_bytes, }, stream, ) @@ -231,6 +233,7 @@ impl FileService { created_by, storage, storage_provider, + max_bytes, } = req; info!( @@ -279,7 +282,7 @@ impl FileService { }; let storage_key = format!("s_{}/f_{}/{}", site_id, file_id, generated_filename); let mime_type = content_type.to_string(); - let max = self.config.max_upload_size_bytes; + let max = max_bytes; let upload = storage.start_multipart(&storage_key).await.map_err(|e| { error!("Failed to start multipart upload: key={}, error={}", storage_key, e); @@ -877,6 +880,7 @@ mod tests { created_by: None, storage, storage_provider: "filesystem", + max_bytes: 1024, }; let first = service From a749cb9bfd2263dc393d679f8ca2ffe5dfa2281c Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:08:08 +0530 Subject: [PATCH 32/34] fix(search): make search index path optional without default fallback Remove the hardcoded vcms_data/search default so search is disabled when no explicit search_index_path is configured, rather than silently falling back to an implicit path. --- apps/backend/src/services/mod.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/services/mod.rs b/apps/backend/src/services/mod.rs index 7acf7b4c..50e79fb3 100644 --- a/apps/backend/src/services/mod.rs +++ b/apps/backend/src/services/mod.rs @@ -127,12 +127,8 @@ enum IndexAccess { ReadOnly, } -fn search_index_path(config: &Config) -> PathBuf { - config - .search_index_path - .clone() - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("vcms_data/search")) +fn search_index_path(config: &Config) -> Option { + config.search_index_path.clone().map(PathBuf::from) } /// Open the search index for queries. Returns `None` when search is disabled or the @@ -143,7 +139,10 @@ fn build_search(config: &Config, access: IndexAccess) -> Option SearchService::open(&path), IndexAccess::ReadOnly => SearchService::open_read_only(&path), From 9c30b99df2b2e06a582f4bde266051efca1fea38 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:08:13 +0530 Subject: [PATCH 33/34] test(grpc): extract wait_for_search helper and fix indexer lifecycle Extract the async polling loop into wait_for_search() to reduce duplication between test_search_indexes_are_isolated_across_concurrent_contexts and test_list_entries_with_search. Also store the indexer JoinHandle in GrpcTestContext with a Drop impl so the indexer task is properly aborted when the context is dropped. --- apps/backend/tests/common/grpc.rs | 20 ++++-- apps/backend/tests/grpc/entries_tests.rs | 80 +++++++++++------------- 2 files changed, 51 insertions(+), 49 deletions(-) diff --git a/apps/backend/tests/common/grpc.rs b/apps/backend/tests/common/grpc.rs index a15038da..56efca67 100644 --- a/apps/backend/tests/common/grpc.rs +++ b/apps/backend/tests/common/grpc.rs @@ -33,9 +33,18 @@ pub struct GrpcTestContext { pub rest_base_url: String, _shutdown: tokio::sync::oneshot::Sender<()>, _grpc_shutdown: tokio::sync::oneshot::Sender<()>, + indexer: Option>, _storage_dir: tempfile::TempDir, } +impl Drop for GrpcTestContext { + fn drop(&mut self) { + if let Some(indexer) = self.indexer.take() { + indexer.abort(); + } + } +} + impl GrpcTestContext { pub async fn start() -> Self { let storage_dir = tempfile::tempdir().expect("Failed to create temp storage dir"); @@ -84,15 +93,17 @@ impl GrpcTestContext { // Mirror server startup: gRPC writes enqueue search updates, so tests need // the single index consumer running against an isolated per-test index. - if let (Some(search), Some(queue)) = (services.search.clone(), services.search_queue.clone()) { + let indexer = if let (Some(search), Some(queue)) = (services.search.clone(), services.search_queue.clone()) { let repo = repository_arc.clone(); - tokio::spawn(async move { + Some(tokio::spawn(async move { if search.is_empty() { let _ = search.rebuild_all(&repo).await; } cms::services::search::indexer::run(search, queue, repo).await; - }); - } + })) + } else { + None + }; // Start Axum server for REST-based seeding let axum_listener = TcpListener::bind("127.0.0.1:0") @@ -199,6 +210,7 @@ impl GrpcTestContext { rest_base_url, _shutdown: axum_shutdown_tx, _grpc_shutdown: grpc_shutdown_tx, + indexer, _storage_dir: storage_dir, } } diff --git a/apps/backend/tests/grpc/entries_tests.rs b/apps/backend/tests/grpc/entries_tests.rs index 23e1d1b2..7442dd96 100644 --- a/apps/backend/tests/grpc/entries_tests.rs +++ b/apps/backend/tests/grpc/entries_tests.rs @@ -2,8 +2,8 @@ use cms::grpc::cms::v1::collection_service_client::CollectionServiceClient; use cms::grpc::cms::v1::entry_service_client::EntryServiceClient; use cms::grpc::cms::v1::{ CreateCollectionRequest, CreateEntryRequest, DeleteEntryRequest, GetEntryRequest, GetEntryRevisionRequest, - ListEntriesRequest, ListEntryRevisionsRequest, PublishEntryRequest, RestoreEntryRevisionRequest, - UnpublishEntryRequest, UpdateEntryRequest, + ListEntriesRequest, ListEntriesResponse, ListEntryRevisionsRequest, PublishEntryRequest, + RestoreEntryRevisionRequest, UnpublishEntryRequest, UpdateEntryRequest, }; use crate::common::{GrpcTestContext, grpc::auth_interceptor}; @@ -29,6 +29,36 @@ async fn setup() -> (GrpcTestContext, String, String, String) { (ctx, site_id, token, collection.id) } +async fn wait_for_search( + client: &mut EntryServiceClient>, + collection_id: &str, + search: &str, + expected_slug: &str, +) -> ListEntriesResponse +where + I: tonic::service::Interceptor + Clone, +{ + for _ in 0..50 { + let response = client + .list_entries(tonic::Request::new(ListEntriesRequest { + collection_id: Some(collection_id.to_owned()), + status: None, + search: Some(search.to_owned()), + page: 1, + per_page: 10, + })) + .await + .unwrap() + .into_inner(); + if response.items.iter().any(|item| item.slug == expected_slug) { + return response; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + + panic!("{expected_slug} never became searchable"); +} + #[tokio::test] async fn test_create_entry() { let (ctx, _site_id, token, collection_id) = setup().await; @@ -133,26 +163,7 @@ async fn test_list_entries_with_search() { // Search indexing is asynchronous in production. Poll briefly until the // queue consumer publishes the committed document to the reader. - let mut resp = None; - for _ in 0..50 { - let current = client - .list_entries(tonic::Request::new(ListEntriesRequest { - collection_id: Some(collection_id.clone()), - status: None, - search: Some("Unique".into()), - page: 1, - per_page: 10, - })) - .await - .unwrap() - .into_inner(); - if current.items.iter().any(|item| item.slug == "searchable") { - resp = Some(current); - break; - } - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } - let resp = resp.unwrap_or_default(); + let resp = wait_for_search(&mut client, &collection_id, "Unique", "searchable").await; let slugs: Vec<&str> = resp.items.iter().map(|i| i.slug.as_str()).collect(); assert!( @@ -195,29 +206,8 @@ async fn test_search_indexes_are_isolated_across_concurrent_contexts() { (&mut client_a, collection_a, "context-a", "context-b"), (&mut client_b, collection_b, "context-b", "context-a"), ] { - let mut found = None; - for _ in 0..50 { - let response = client - .list_entries(tonic::Request::new(ListEntriesRequest { - collection_id: Some(collection_id.clone()), - status: None, - // Stemmed match proves Tantivy served this result: SQL LIKE - // cannot match "run" against the stored word "Running". - search: Some("run".into()), - page: 1, - per_page: 10, - })) - .await - .unwrap() - .into_inner(); - if response.items.iter().any(|item| item.slug == expected_slug) { - found = Some(response); - break; - } - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } - - let response = found.unwrap_or_else(|| panic!("{expected_slug} never became searchable")); + // Exercise Tantivy's stemming rather than an exact stored-value match. + let response = wait_for_search(client, &collection_id, "run", expected_slug).await; let slugs: Vec<&str> = response.items.iter().map(|item| item.slug.as_str()).collect(); assert!( !slugs.contains(&other_slug), From 1f8cccd142bb6c59039d1a32df55bdc25eee4b28 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:08:17 +0530 Subject: [PATCH 34/34] test(files): add test for chunked upload respecting live instance limit Verifies that reducing upload_limit_mb via the settings API immediately takes effect for signed chunked uploads, not just multipart. --- apps/backend/tests/rest/files_tests.rs | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/backend/tests/rest/files_tests.rs b/apps/backend/tests/rest/files_tests.rs index 0c39af1d..1c5840d5 100644 --- a/apps/backend/tests/rest/files_tests.rs +++ b/apps/backend/tests/rest/files_tests.rs @@ -493,6 +493,41 @@ async fn test_signed_upload_oversize_rejected() { } } +#[tokio::test] +async fn test_signed_chunked_upload_uses_live_instance_limit() { + let server = TestServer::start().await; + let (token, csrf, site_id) = setup(&server).await; + let client = reqwest::Client::new(); + let update = client + .put(format!("{}/api/dashboard/instance/settings/general", server.base_url)) + .headers(auth_header(&token, &csrf)) + .json(&json!({ + "public_url": null, + "public_registration": false, + "session_lifetime_hours": 24, + "upload_limit_mb": 1, + "mcp_enabled": true + })) + .send() + .await + .unwrap(); + assert_eq!(update.status(), 200); + + let url = mint_upload_url(&server, &site_id, "chunked.txt", "text/plain", 900); + let chunks = futures_util::stream::iter([ + Ok::<_, std::io::Error>(bytes::Bytes::from(vec![b'a'; 700_000])), + Ok(bytes::Bytes::from(vec![b'b'; 700_000])), + ]); + let response = client + .put(url) + .header("Content-Type", "text/plain") + .body(reqwest::Body::wrap_stream(chunks)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 413); +} + /// Multipart path enforces the same sniffing as the signed path. #[tokio::test] async fn test_multipart_upload_magic_byte_mismatch() {