Figment was prefixed with APP_, so APP__DATABASE__URL never mapped to database.url and the server kept logging in as the default app role. Also walk up to the workspace .env and log a redacted DSN.master
| @@ -2,9 +2,10 @@ | |||||
| APP_ENV=development | APP_ENV=development | ||||
| # Overrides config/*.toml. Nested keys use a double underscore. | |||||
| # Overrides config/*.toml. Nested keys use a double underscore after APP. | |||||
| # The running server reads APP__DATABASE__URL, not DATABASE_URL. | |||||
| # APP__SERVER__PORT=8080 | # APP__SERVER__PORT=8080 | ||||
| # APP__DATABASE__URL=postgres://app:app@127.0.0.1:5432/app | |||||
| APP__DATABASE__URL=postgres://app:app@127.0.0.1:5432/app | |||||
| # Used by sqlx-cli if you install it. | |||||
| # Used by sqlx-cli if you install it. The Axum process ignores this key. | |||||
| DATABASE_URL=postgres://app:app@127.0.0.1:5432/app | DATABASE_URL=postgres://app:app@127.0.0.1:5432/app | ||||
| @@ -12,7 +12,11 @@ use serde::{Deserialize, Serialize}; | |||||
| /// 1. struct defaults | /// 1. struct defaults | ||||
| /// 2. `config/default.toml` | /// 2. `config/default.toml` | ||||
| /// 3. `config/{APP_ENV}.toml` (`development` when unset) | /// 3. `config/{APP_ENV}.toml` (`development` when unset) | ||||
| /// 4. `APP__SECTION__KEY` environment variables | |||||
| /// 4. `config/local.toml` (gitignored) | |||||
| /// 5. `APP__SECTION__KEY` environment variables | |||||
| /// | |||||
| /// Figment prefix is `APP__` (two underscores). `APP__DATABASE__URL` maps to | |||||
| /// `database.url`. `APP_DATABASE__URL` does not. | |||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub struct AppConfig { | pub struct AppConfig { | ||||
| pub app: AppSection, | pub app: AppSection, | ||||
| @@ -106,14 +110,15 @@ impl Default for AppConfig { | |||||
| impl AppConfig { | impl AppConfig { | ||||
| pub fn load() -> Result<Self, Box<figment::Error>> { | pub fn load() -> Result<Self, Box<figment::Error>> { | ||||
| let _ = dotenvy::dotenv(); | |||||
| load_dotenv(); | |||||
| let env = current_env(); | let env = current_env(); | ||||
| let config_dir = discover_config_dir(); | let config_dir = discover_config_dir(); | ||||
| Figment::from(Serialized::defaults(Self::default())) | Figment::from(Serialized::defaults(Self::default())) | ||||
| .merge(Toml::file(config_dir.join("default.toml"))) | .merge(Toml::file(config_dir.join("default.toml"))) | ||||
| .merge(Toml::file(config_dir.join(format!("{env}.toml")))) | .merge(Toml::file(config_dir.join(format!("{env}.toml")))) | ||||
| .merge(Env::prefixed("APP_").split("__")) | |||||
| .merge(Toml::file(config_dir.join("local.toml"))) | |||||
| .merge(Env::prefixed("APP__").split("__")) | |||||
| .extract() | .extract() | ||||
| .map_err(Box::new) | .map_err(Box::new) | ||||
| } | } | ||||
| @@ -135,6 +140,39 @@ pub fn current_env() -> String { | |||||
| std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()) | std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()) | ||||
| } | } | ||||
| /// Walk from cwd / crate dir so RustRover can start the binary from `apps/server`. | |||||
| fn load_dotenv() { | |||||
| let mut candidates = Vec::new(); | |||||
| if let Ok(cwd) = std::env::current_dir() { | |||||
| candidates.extend(cwd.ancestors().map(|dir| dir.join(".env"))); | |||||
| } | |||||
| if let Ok(manifest) = std::env::var("CARGO_MANIFEST_DIR") { | |||||
| let manifest = PathBuf::from(manifest); | |||||
| candidates.push(manifest.join(".env")); | |||||
| candidates.push(manifest.join("../../.env")); | |||||
| } | |||||
| for path in candidates { | |||||
| if path.is_file() { | |||||
| let _ = dotenvy::from_path(&path); | |||||
| return; | |||||
| } | |||||
| } | |||||
| } | |||||
| /// Drop the password so logs can show which role/host we actually used. | |||||
| pub fn redact_db_url(url: &str) -> String { | |||||
| let Some(scheme_end) = url.find("://") else { | |||||
| return url.to_string(); | |||||
| }; | |||||
| let rest = &url[scheme_end + 3..]; | |||||
| let Some(at) = rest.find('@') else { | |||||
| return url.to_string(); | |||||
| }; | |||||
| let user = rest[..at].split(':').next().unwrap_or(""); | |||||
| format!("{}{user}{}", &url[..=scheme_end + 2], &rest[at..]) | |||||
| } | |||||
| /// Walks from cwd (and the server crate dir) until `config/default.toml` is found. | /// Walks from cwd (and the server crate dir) until `config/default.toml` is found. | ||||
| pub fn discover_config_dir() -> PathBuf { | pub fn discover_config_dir() -> PathBuf { | ||||
| if let Ok(explicit) = std::env::var("APP_CONFIG_DIR") { | if let Ok(explicit) = std::env::var("APP_CONFIG_DIR") { | ||||
| @@ -19,7 +19,11 @@ async fn main() -> anyhow::Result<()> { | |||||
| ); | ); | ||||
| // 3. Database + migrations | // 3. Database + migrations | ||||
| let pool = db::connect(&config).await?; | |||||
| let database = server::config::redact_db_url(&config.database.url); | |||||
| tracing::info!(%database, "connecting"); | |||||
| let pool = db::connect(&config) | |||||
| .await | |||||
| .map_err(|err| anyhow::anyhow!("database connect failed ({database}): {err}"))?; | |||||
| if config.database.run_migrations { | if config.database.run_migrations { | ||||
| db::migrate(&pool).await?; | db::migrate(&pool).await?; | ||||
| tracing::info!("migrations applied"); | tracing::info!("migrations applied"); | ||||
| @@ -1,4 +1,9 @@ | |||||
| use std::sync::Mutex; | |||||
| use server::AppConfig; | use server::AppConfig; | ||||
| use server::config::redact_db_url; | |||||
| static ENV_LOCK: Mutex<()> = Mutex::new(()); | |||||
| #[test] | #[test] | ||||
| fn default_config_binds_loopback() { | fn default_config_binds_loopback() { | ||||
| @@ -10,8 +15,30 @@ fn default_config_binds_loopback() { | |||||
| #[test] | #[test] | ||||
| fn layered_config_loads_from_workspace() { | fn layered_config_loads_from_workspace() { | ||||
| let _guard = ENV_LOCK.lock().expect("env lock"); | |||||
| let config = AppConfig::load().expect("load config from workspace"); | let config = AppConfig::load().expect("load config from workspace"); | ||||
| assert_eq!(config.server.host, "127.0.0.1"); | assert_eq!(config.server.host, "127.0.0.1"); | ||||
| assert_eq!(config.frontend.host, "127.0.0.1"); | assert_eq!(config.frontend.host, "127.0.0.1"); | ||||
| assert!(!config.database.url.is_empty()); | assert!(!config.database.url.is_empty()); | ||||
| } | } | ||||
| #[test] | |||||
| fn app_double_underscore_overrides_database_url() { | |||||
| let _guard = ENV_LOCK.lock().expect("env lock"); | |||||
| unsafe { | |||||
| std::env::set_var("APP__DATABASE__URL", "postgres://jared:secret@127.0.0.1:5432/mydb"); | |||||
| } | |||||
| let config = AppConfig::load().expect("load config with env override"); | |||||
| unsafe { | |||||
| std::env::remove_var("APP__DATABASE__URL"); | |||||
| } | |||||
| assert_eq!(config.database.url, "postgres://jared:secret@127.0.0.1:5432/mydb"); | |||||
| } | |||||
| #[test] | |||||
| fn redact_db_url_strips_password() { | |||||
| assert_eq!( | |||||
| redact_db_url("postgres://app:hunter2@127.0.0.1:5432/app"), | |||||
| "postgres://app@127.0.0.1:5432/app" | |||||
| ); | |||||
| } | |||||
| @@ -44,4 +44,11 @@ Vite also regenerates on dev/build if `packages/contracts/openapi.json` changes. | |||||
| Default: `postgres://app:app@127.0.0.1:5432/app` | Default: `postgres://app:app@127.0.0.1:5432/app` | ||||
| Override with `APP__DATABASE__URL` or `config/local.toml` (gitignored). | |||||
| That user only exists if you start the bundled compose file. For a local cluster, override with `APP__DATABASE__URL` (two underscores after `APP`) or `config/local.toml`: | |||||
| ```toml | |||||
| [database] | |||||
| url = "postgres://YOUR_USER:YOUR_PASS@127.0.0.1:5432/YOUR_DB" | |||||
| ``` | |||||
| `DATABASE_URL` is for sqlx-cli only. The server does not read it. | |||||