From 4c83e5383900c3c7b9e43bfcefc04e2934818e1f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 18 Jul 2026 06:00:16 -0400 Subject: [PATCH] feat(config): effective config via ArcSwap + DB overlay at boot (Stage 2) Wire the settings overlay into the live config path: - AppState.config: Config -> Arc> (hot-swappable effective config) + config_base: Arc (immutable env-only). New AppState::cfg() accessor; every config read goes through it (state.cfg().field), so a runtime-safe operator edit is seen on the next request. One Config type, no parallel path. - Boot (main.rs): load_settings_overlay -> Config::apply_overlay(validate) -> build every client/worker/router from the EFFECTIVE config. A persisted overlay that no longer validates is ignored (boot env-only + loud log), never bricks. Env-only when ADMIN_KEY_INTEGRITY_SECRET is unset. - Migrated ~59 state.config.X reads to state.cfg().X across api/*; bound guards where a reference outlives the statement; integrity_secret returns owned String. - Test harness builds AppState with ArcSwap + config_base (arc-swap dev-dep). Behavior-preserving: with no operator_settings rows, effective == env config. cargo check --all-targets clean; lib tests 256/0. --- Cargo.lock | 10 ++++++ Cargo.toml | 2 ++ src/api/admin.rs | 30 ++++++++--------- src/api/auth.rs | 32 +++++++++---------- src/api/capabilities.rs | 2 +- src/api/erasure.rs | 28 ++++++++-------- src/api/landing.rs | 10 +++--- src/api/middleware.rs | 2 +- src/api/nip05.rs | 2 +- src/api/premium.rs | 10 +++--- src/api/prices.rs | 2 +- src/api/tips.rs | 8 ++--- src/api/wallet/grin.rs | 4 +-- src/api/wallet/grin_relay.rs | 2 +- src/api/wallet/xmr_wow.rs | 4 +-- src/api/website.rs | 2 +- src/lib.rs | 21 ++++++++++-- src/main.rs | 62 ++++++++++++++++++++++++------------ tests/admin_auth.rs | 2 +- tests/common/mod.rs | 5 +-- tests/erasure.rs | 4 +-- tests/nostr_auth.rs | 2 +- tests/nostr_register.rs | 6 ++-- tests/relay_admission.rs | 2 +- 24 files changed, 153 insertions(+), 101 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f183ae3..20cda91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -2929,6 +2938,7 @@ version = "0.3.0" dependencies = [ "aes-gcm", "altcha", + "arc-swap", "async-trait", "axum", "axum-extra", diff --git a/Cargo.toml b/Cargo.toml index 13f4588..c15d243 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ tower_governor = "0.4" governor = "0.6" # Database +arc-swap = "1" sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate", "json"] } ipnetwork = "0.20" @@ -119,6 +120,7 @@ tonic-build = "0.12" protoc-bin-vendored = "3" [dev-dependencies] +arc-swap = "1" tokio-test = "0.4" mockall = "0.12" diff --git a/src/api/admin.rs b/src/api/admin.rs index 6106358..8d81dc4 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -66,14 +66,14 @@ fn admin_manager(state: &AppState) -> Result<&AdminSessionManager, AppError> { fn admin_verify_url(state: &AppState) -> String { format!( "{}/admin/auth/verify", - state.config.admin.public_url.trim_end_matches('/') + state.cfg().admin.public_url.trim_end_matches('/') ) } /// A stable per-instance id bound into the signed action, so a challenge signed /// for this instance cannot be relayed to another. fn admin_instance_id(state: &AppState) -> String { - hex::encode(Sha256::digest(state.config.admin.public_url.as_bytes()))[..16].to_string() + hex::encode(Sha256::digest(state.cfg().admin.public_url.as_bytes()))[..16].to_string() } fn pubkey_prefix(pubkey: &str) -> String { @@ -121,7 +121,7 @@ pub async fn admin_plane_guard( .unwrap_or(""); // Compute the response on both paths, then apply the security headers once so // the 403 Host-reject carries them too. - let mut resp = if host_allowed(host, state.config.admin.onion.as_deref()) { + let mut resp = if host_allowed(host, state.cfg().admin.onion.as_deref()) { next.run(req).await } else { (StatusCode::FORBIDDEN, "forbidden").into_response() @@ -190,7 +190,7 @@ pub async fn admin_guard( // Live, uncached allowlist re-check (MAC re-verified inside): must be active // AND activated (a pending key is not yet authorized for protected routes). - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let key = state .db .get_active_admin_key(&info.pubkey, secret) @@ -279,7 +279,7 @@ pub async fn admin_verify( ) -> Result, AppError> { let mgr = admin_manager(&state)?; let ip = client_ip(&state, &headers, peer); - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; // 1. Prove the signature + bindings (purpose/nonce/descriptor/instance). The // descriptor binds the verify URL with an EMPTY body (the proof rides in the @@ -335,7 +335,7 @@ pub async fn admin_verify( let pair = mgr.create_token_pair(&pubkey, session_id)?; let refresh_hash = hash_refresh_token( &pair.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let audit = NewAdminAudit { action: "admin_login".into(), @@ -386,7 +386,7 @@ pub async fn admin_refresh( // Re-authorize against the live allowlist (closes the "8h refresh is the real // blast radius" gap). - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let key = state .db .get_active_admin_key(&pubkey, secret) @@ -403,7 +403,7 @@ pub async fn admin_refresh( .ok_or_else(admin_auth_fail)?; let presented = hash_refresh_token( &req.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let hash_ok: bool = presented .as_bytes() @@ -435,7 +435,7 @@ pub async fn admin_logout( ) -> Result, AppError> { let ctx = admin_guard(&state, &headers).await?; state.db.revoke_admin_session(ctx.session_id).await?; - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let _ = state .db .record_admin_audit( @@ -520,7 +520,7 @@ fn pending_key(state: &AppState, pubkey: String, label: Option) -> NewAd scope: "admin".into(), created_by_kind: "admin".into(), activation_deadline: Some( - Utc::now() + Duration::days(state.config.admin.pending_key_ttl_days as i64), + Utc::now() + Duration::days(state.cfg().admin.pending_key_ttl_days as i64), ), } } @@ -536,7 +536,7 @@ pub async fn admin_keys_add( let pubkey = req.pubkey.to_lowercase(); validate_admin_pubkey(&pubkey)?; - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let audit = NewAdminAudit { action: "admin_key_added".into(), actor_kind: "admin".into(), @@ -552,7 +552,7 @@ pub async fn admin_keys_add( pending_key(&state, pubkey, req.label), &audit, secret, - state.config.admin.max_keys as i64, + state.cfg().admin.max_keys as i64, ) .await? { @@ -593,7 +593,7 @@ pub async fn admin_keys_revoke( Path(id): Path, ) -> Result, AppError> { let ctx = admin_guard(&state, &headers).await?; - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let audit = NewAdminAudit { action: "admin_key_revoked".into(), actor_kind: "admin".into(), @@ -632,7 +632,7 @@ pub async fn admin_keys_rotate( let ctx = admin_guard(&state, &headers).await?; let pubkey = req.pubkey.to_lowercase(); validate_admin_pubkey(&pubkey)?; - let secret = &state.config.admin.key_integrity_secret; + let secret = &state.cfg().admin.key_integrity_secret; let audit = NewAdminAudit { action: "admin_key_rotated".into(), actor_kind: "admin".into(), @@ -679,7 +679,7 @@ pub async fn admin_features( headers: HeaderMap, ) -> Result, AppError> { admin_guard(&state, &headers).await?; - let cfg = &state.config; + let cfg = &state.cfg(); let effective = crate::api::capabilities::effective_capabilities(cfg); let mut downgrades = Vec::new(); diff --git a/src/api/auth.rs b/src/api/auth.rs index 9dd6a16..eb58b98 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -170,15 +170,15 @@ fn validate_username(username: &str) -> Result<(), AppError> { /// validation only requires `ALTCHA_HMAC_KEY` when the feature is enabled). When /// disabled we never call `verify_payload`, so the empty key is never used. fn pow_applies(state: &AppState, pubkey_hash_lc: &str) -> bool { - state.config.pow.enabled && crate::core::pow::required_for(&state.config.pow, pubkey_hash_lc) + state.cfg().pow.enabled && crate::core::pow::required_for(&state.cfg().pow, pubkey_hash_lc) } /// The canonical absolute URL a NIP-98 token must bind for `path` (the value of /// the event's `u` tag). Built from `config.identity.public_api_url`, never the /// request Host. Fail closed when unset (Nostr identity is disabled). fn nip98_url(state: &AppState, path: &str) -> Result { - let base = state - .config + let cfg = state.cfg(); + let base = cfg .identity .public_api_url .as_deref() @@ -215,7 +215,7 @@ async fn issue_session( let refresh_token_hash = hash_refresh_token( &pair.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let expires_at = Utc::now() + state.sessions.refresh_token_expiry(); @@ -276,7 +276,7 @@ async fn upsert_all_keys( pub async fn pow_challenge( State(state): State>, ) -> Result, AppError> { - let challenge = crate::core::pow::issue_challenge(&state.config.pow)?; + let challenge = crate::core::pow::issue_challenge(&state.cfg().pow)?; Ok(Json(challenge)) } @@ -622,13 +622,13 @@ fn enforce_pow( .into(), ) })?; - crate::core::pow::verify_payload(&state.config.pow, solution)?; + crate::core::pow::verify_payload(&state.cfg().pow, solution)?; info!(pow = "ok", "PoW solution accepted (new user)"); } else if let Some(solution) = solution { // Supplied but not required: verify anyway (clear error on malformed), // but only if the feature is enabled so we never touch an empty key. - if state.config.pow.enabled { - crate::core::pow::verify_payload(&state.config.pow, solution)?; + if state.cfg().pow.enabled { + crate::core::pow::verify_payload(&state.cfg().pow, solution)?; } } Ok(()) @@ -723,15 +723,15 @@ async fn plan_gate_consume( .is_some_and(|s| !s.is_empty()); match plan_gates( - state.config.registration.gate_mode, - state.config.registration.require_invite, - state.config.registration.payment.require_payment, + state.cfg().registration.gate_mode, + state.cfg().registration.require_invite, + state.cfg().registration.payment.require_payment, has_invite, has_payment, ) { GatePlan::Open => Ok(GatedRegistration { invite_code_hash: None, payment: None }), GatePlan::All => { - let invite_code_hash = if state.config.registration.require_invite { + let invite_code_hash = if state.cfg().registration.require_invite { Some(require_invite_hash(invite_code)?) } else { None @@ -776,7 +776,7 @@ async fn verify_payment_settled( pubkey_hash: &str, payment_invoice_id: Option<&str>, ) -> Result, AppError> { - if returning || !state.config.registration.payment.require_payment { + if returning || !state.cfg().registration.payment.require_payment { return Ok(None); } // Gate on but no provider built — config validation prevents this, so it is @@ -932,7 +932,7 @@ pub async fn payment_invoice( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let cfg = &state.config.registration.payment; + let cfg = &state.cfg().registration.payment; if !cfg.require_payment { return Err(AppError::ValidationError( "This instance does not require registration payment.".into(), @@ -1175,7 +1175,7 @@ pub async fn refresh_token( let token_hash = hash_refresh_token( &req.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let session = state .db @@ -1256,7 +1256,7 @@ pub async fn logout( ) -> Result, AppError> { let token_hash = hash_refresh_token( &req.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); if let Some(session) = state.db.get_session_by_token_hash(&token_hash).await? { let _ = state.db.revoke_session(session.id).await; diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index d64d885..0481d1c 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -354,7 +354,7 @@ fn tips_advertised(config: &Config) -> bool { )] #[instrument(skip(state))] pub async fn capabilities(State(state): State>) -> Json { - Json(effective_capabilities(&state.config)) + Json(effective_capabilities(&state.cfg())) } /// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth). diff --git a/src/api/erasure.rs b/src/api/erasure.rs index 42b0174..94fc8a5 100644 --- a/src/api/erasure.rs +++ b/src/api/erasure.rs @@ -41,7 +41,7 @@ const PURPOSE_EXPORT: &str = "erasure_export"; // ── helpers ────────────────────────────────────────────────────────────────── fn ensure_enabled(state: &AppState) -> Result<(), AppError> { - if state.config.retention.erasure_enabled { + if state.cfg().retention.erasure_enabled { Ok(()) } else { Err(AppError::NotFound("erasure is not enabled".into())) @@ -51,8 +51,8 @@ fn ensure_enabled(state: &AppState) -> Result<(), AppError> { /// The signed-action `u`-tag base for `path` (e.g. `/account/erasure`), from /// `PUBLIC_API_URL` (which already includes the `/api/v1` prefix). fn erasure_url(state: &AppState, path: &str) -> Result { - let base = state - .config + let cfg = state.cfg(); + let base = cfg .identity .public_api_url .as_deref() @@ -64,8 +64,8 @@ fn subject_hash(npub: &str) -> String { hex::encode(Sha256::digest(npub.as_bytes())) } -fn integrity_secret(state: &AppState) -> &str { - &state.config.admin.key_integrity_secret +fn integrity_secret(state: &AppState) -> String { + state.cfg().admin.key_integrity_secret.clone() } #[derive(Debug, Deserialize)] @@ -191,7 +191,7 @@ pub async fn request_erasure( Json(body): Json, ) -> Result, AppError> { ensure_enabled(&state)?; - let grace = state.config.retention.grace_period_hours as i64; + let grace = state.cfg().retention.grace_period_hours as i64; let (pubkey, user) = prove(&state, &body, PURPOSE_REQUEST, "/account/erasure", None).await?; // Constant-shape across present/absent AND across repeated calls: a STABLE @@ -209,13 +209,13 @@ pub async fn request_erasure( .db .record_admin_audit( &audit("account_erasure_requested", &req.id), - integrity_secret(&state), + &integrity_secret(&state), ) .await; info!(erasure_id = %req.id, "erasure requested"); req.id.to_string() } - None => synthetic_erasure_id(&pubkey, integrity_secret(&state)), + None => synthetic_erasure_id(&pubkey, &integrity_secret(&state)), }; Ok(Json(ErasureRequestResponse { erasure_id, @@ -244,7 +244,7 @@ pub async fn confirm_erasure( .await?; let user = user.ok_or_else(|| AppError::NotFound("no such erasure request".into()))?; - let grace = state.config.retention.grace_period_hours as i64; + let grace = state.cfg().retention.grace_period_hours as i64; let req = state .db .confirm_erasure_request(id, user.id, grace) @@ -256,7 +256,7 @@ pub async fn confirm_erasure( .db .record_admin_audit( &audit("account_erasure_confirmed", &req.id), - integrity_secret(&state), + &integrity_secret(&state), ) .await; info!(erasure_id = %req.id, "erasure confirmed"); @@ -295,7 +295,7 @@ pub async fn cancel_erasure( .db .record_admin_audit( &audit("account_erasure_cancelled", &req.id), - integrity_secret(&state), + &integrity_secret(&state), ) .await; Ok(Json(StatusResponse { @@ -359,7 +359,7 @@ pub async fn export( details: None, ip_address: None, }, - integrity_secret(&state), + &integrity_secret(&state), ) .await; Ok(Json(ExportResponse { @@ -388,11 +388,11 @@ fn audit(action: &str, erasure_id: &Uuid) -> NewAdminAudit { #[instrument(skip(state))] pub async fn run_erasure_sweep(state: &Arc, batch: i64) -> Result { let due = state.db.due_erasure_requests(batch).await?; - let purge = state.config.retention.purge_login_events; + let purge = state.cfg().retention.purge_login_events; let secret = integrity_secret(state); let mut done = 0; for req in due { - match state.db.execute_erasure(req.id, purge, secret).await { + match state.db.execute_erasure(req.id, purge, &secret).await { Ok(true) => done += 1, // false = another node/sweep already claimed it, or it was cancelled. Ok(false) => {} diff --git a/src/api/landing.rs b/src/api/landing.rs index b92642e..59cdf02 100644 --- a/src/api/landing.rs +++ b/src/api/landing.rs @@ -92,19 +92,19 @@ pub fn build_server_info(config: &Config) -> ServerInfo { /// is off (matching an unmatched route — the `{error,code}` envelope would itself /// hint the route exists, so it is deliberately not used here). pub async fn server_info(State(state): State>) -> Response { - if !state.config.landing.enabled { + if !state.cfg().landing.enabled { return StatusCode::NOT_FOUND.into_response(); } - Json(build_server_info(&state.config)).into_response() + Json(build_server_info(&state.cfg())).into_response() } /// `GET /` — minimal HTML rendered from the read model; bare `404` when off. pub async fn root(State(state): State>) -> Response { - if !state.config.landing.enabled { + if !state.cfg().landing.enabled { return StatusCode::NOT_FOUND.into_response(); } - let title = state - .config + let cfg = state.cfg(); + let title = cfg .landing .title .as_deref() diff --git a/src/api/middleware.rs b/src/api/middleware.rs index 47c589b..11de4c4 100644 --- a/src/api/middleware.rs +++ b/src/api/middleware.rs @@ -69,7 +69,7 @@ pub fn client_ip(state: &AppState, headers: &HeaderMap, peer: SocketAddr) -> IpA let peer_ip = peer.ip(); let peer_is_trusted = state - .config + .cfg() .trusted_proxies .iter() .any(|net| net.contains(peer_ip)); diff --git a/src/api/nip05.rs b/src/api/nip05.rs index 5c1856a..bad5d78 100644 --- a/src/api/nip05.rs +++ b/src/api/nip05.rs @@ -108,7 +108,7 @@ pub async fn well_known_nostr( let name = raw.to_lowercase(); if let Some(user) = state.db.get_user_by_username(&name).await? { if let Some(pubkey) = user.nostr_pubkey { - relays.insert(pubkey.clone(), relay_hints(&state.config)); + relays.insert(pubkey.clone(), relay_hints(&state.cfg())); names.insert(name, pubkey); } } diff --git a/src/api/premium.rs b/src/api/premium.rs index 9679d07..2e0f0d3 100644 --- a/src/api/premium.rs +++ b/src/api/premium.rs @@ -52,7 +52,7 @@ pub struct StatusResp { /// Reject when the premium tier is not enabled on this instance. fn ensure_enabled(state: &AppState) -> Result<(), AppError> { - if state.config.premium.enabled { + if state.cfg().premium.enabled { Ok(()) } else { Err(AppError::ValidationError( @@ -83,8 +83,8 @@ pub async fn invoice( let user_id = extract_user_id_from_token(&state, &headers).await?; ensure_enabled(&state)?; - let plan = state - .config + let cfg = state.cfg(); + let plan = cfg .premium .plans .iter() @@ -106,8 +106,8 @@ pub async fn invoice( .as_ref() .ok_or_else(|| AppError::Internal("payment provider not configured".into()))?; - let pay = &state.config.registration.payment; - let currency = state.config.premium.currency.clone(); + let pay = &state.cfg().registration.payment; + let currency = state.cfg().premium.currency.clone(); let invoice = provider .create_invoice(&InvoiceRequest { amount: plan.amount.clone(), diff --git a/src/api/prices.rs b/src/api/prices.rs index 7f1dee6..941c2ff 100644 --- a/src/api/prices.rs +++ b/src/api/prices.rs @@ -37,7 +37,7 @@ pub struct PricesResponse { )] #[instrument(skip(state))] pub async fn prices(State(state): State>) -> Result, AppError> { - if !state.config.features.prices { + if !state.cfg().features.prices { return Err(AppError::NotFound( "price feed is not enabled on this server".into(), )); diff --git a/src/api/tips.rs b/src/api/tips.rs index ab86119..91cced9 100644 --- a/src/api/tips.rs +++ b/src/api/tips.rs @@ -160,7 +160,7 @@ pub struct ClawbackTipResponse { /// Reject when tips are not enabled on this instance. fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> { - if state.config.features.tips { + if state.cfg().features.tips { Ok(()) } else { Err(AppError::ValidationError( @@ -171,7 +171,7 @@ fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> { /// Whether `asset` is a tip-capable chain enabled on this instance. fn supported_tip_asset(state: &AppState, asset: &str) -> bool { - let c = &state.config.features.chains; + let c = &state.cfg().features.chains; match asset { "btc" => c.btc, "ltc" => c.ltc, @@ -382,7 +382,7 @@ pub async fn create_social_tip( }; let share_url = state - .config + .cfg() .tip_share_base .as_ref() .map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id)); @@ -573,7 +573,7 @@ pub async fn attach_funding( .await?; let share_url = state - .config + .cfg() .tip_share_base .as_ref() .map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id)); diff --git a/src/api/wallet/grin.rs b/src/api/wallet/grin.rs index 509230c..51395dc 100644 --- a/src/api/wallet/grin.rs +++ b/src/api/wallet/grin.rs @@ -202,8 +202,8 @@ pub async fn scan( // grin-wallet fallback, so it is enforced before either scan path is chosen. if let Some(h) = req.start_height { let tip = grin_tip(&state).await?; - state.config.restore.enforce("grin", h, tip)?; - state.config.restore.enforce_restore_pow( + state.cfg().restore.enforce("grin", h, tip)?; + state.cfg().restore.enforce_restore_pow( "grin", &req.rewind_hash, h, diff --git a/src/api/wallet/grin_relay.rs b/src/api/wallet/grin_relay.rs index 2524747..a504b44 100644 --- a/src/api/wallet/grin_relay.rs +++ b/src/api/wallet/grin_relay.rs @@ -63,7 +63,7 @@ const RELAY_TTL_DAYS: i64 = 7; /// 400 unless the relay feature is enabled on this server. fn ensure_enabled(state: &AppState) -> Result<(), AppError> { - if state.config.features.grin_relay { + if state.cfg().features.grin_relay { Ok(()) } else { Err(AppError::NotFound( diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs index aa5e9eb..2c317f1 100644 --- a/src/api/wallet/xmr_wow.rs +++ b/src/api/wallet/xmr_wow.rs @@ -320,8 +320,8 @@ pub async fn register( // Restore: gate the scan depth against this instance's policy (the // backfill cost lands on our LWS). `None` (create) needs no check. let tip = client.get_blockchain_height().await?; - state.config.restore.enforce(&asset, h, tip)?; - state.config.restore.enforce_restore_pow( + state.cfg().restore.enforce(&asset, h, tip)?; + state.cfg().restore.enforce_restore_pow( &asset, &req.address, h, diff --git a/src/api/website.rs b/src/api/website.rs index f469d2d..91dde8d 100644 --- a/src/api/website.rs +++ b/src/api/website.rs @@ -297,7 +297,7 @@ pub async fn website_verify( .create_token_pair(user.id, Platform::Web, session_id)?; let refresh_token_hash = hash_refresh_token( &pair.refresh_token, - &state.config.secrets.refresh_token_pepper, + &state.cfg().secrets.refresh_token_pepper, ); let expires_at = Utc::now() + state.sessions.refresh_token_expiry(); state diff --git a/src/lib.rs b/src/lib.rs index b0446e4..afa0778 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,7 +56,13 @@ const NORMAL_BURST: u32 = 60; /// Shared application state injected into handlers via `State>`. #[derive(Clone)] pub struct AppState { - pub config: Config, + /// Effective config = env-derived base + validated DB overlay. Hot-swappable: + /// a runtime-safe operator edit stores a new `Arc` here (lock-free). + /// Read it via [`AppState::cfg`] (`state.cfg().field`), never a stale copy. + pub config: Arc>, + /// Immutable env-only config (no overlay). Used to recompute the effective + /// config on a settings change and to report a field's source (default/env/db). + pub config_base: Arc, pub db: Database, pub sessions: SessionManager, /// Per-chain data-source clients (present only for enabled chains). @@ -78,12 +84,23 @@ pub struct AppState { pub admin_sessions: Option, } +impl AppState { + /// Load the current effective [`Config`] (lock-free, wait-free). The returned + /// guard derefs to `&Config`, so `state.cfg().field` reads the live value and + /// `&state.cfg()` passes to a `fn(&Config)` helper. Every config read goes + /// through here so a runtime-safe operator edit is seen on the next request. + #[inline] + pub fn cfg(&self) -> arc_swap::Guard> { + self.config.load() + } +} + /// Assemble the full application router with every route mounted and state /// applied. Shared by `main` and the integration-test harness so both exercise /// the exact same wiring: `/health` and the NIP-05 directory at the root, the /// authenticated wallet/identity API nested under `/api/v1`. pub fn build_router(state: Arc) -> Router { - let cors = cors_layer(&state.config); + let cors = cors_layer(&state.cfg()); // Per-IP rate limiting (PeerIpKeyExtractor reads ConnectInfo — `main` serves // with `into_make_service_with_connect_info`; the test harness injects it). diff --git a/src/main.rs b/src/main.rs index 207e8fe..09f8b9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,21 +19,42 @@ async fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); let _ = dotenvy::dotenv(); - // Fail-closed: aborts on a weak/missing/inconsistent secret. - let config = Config::from_env()?; - tracing::info!(environment = %config.environment, "configuration loaded"); + // Fail-closed: aborts on a weak/missing/inconsistent secret. This is the env-only + // truth; the effective config below layers the validated DB overlay on top. + let config_base = Config::from_env()?; + tracing::info!(environment = %config_base.environment, "configuration loaded"); let pool = PgPoolOptions::new() .max_connections(20) - .connect(&config.database_url) + .connect(&config_base.database_url) .await?; sqlx::migrate!("./migrations").run(&pool).await?; let db = Database::new( pool, - config.secrets.seed_fingerprint_pepper.clone(), - config.secrets.ip_salt.clone(), + config_base.secrets.seed_fingerprint_pepper.clone(), + config_base.secrets.ip_salt.clone(), ); + + // Effective config = env-derived base + validated DB overlay (operator settings). + // Overlay rows are MAC'd under ADMIN_KEY_INTEGRITY_SECRET; without that anchor + // (admin disabled) we stay env-only. A persisted overlay that no longer validates + // is ignored (boot env-only + loud log) rather than bricking the instance. Every + // client/worker/router below is built from `config` (the effective view). + let overlay = if config_base.admin.key_integrity_secret.is_empty() { + smirk_backend_core::config_overlay::SettingsOverlay::default() + } else { + db.load_settings_overlay(&config_base.admin.key_integrity_secret) + .await? + }; + let config = match config_base.apply_overlay(&overlay) { + Ok(effective) => effective, + Err(e) => { + tracing::error!(error = %e, "persisted config overlay invalid; ignoring it, booting env-only"); + config_base.clone() + } + }; + let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours); let chains = ChainClients::from_config(&config)?; // Payment processor for the pay-to-register gate (None unless it's enabled). @@ -87,7 +108,8 @@ async fn main() -> Result<(), Box> { .enabled .then(|| AdminSessionManager::new(&config.admin.jwt_secret)); let state = Arc::new(AppState { - config, + config: Arc::new(arc_swap::ArcSwap::from_pointee(config)), + config_base: Arc::new(config_base), db, sessions, chains, @@ -116,7 +138,7 @@ async fn main() -> Result<(), Box> { // enabled). The respond/finalize paths already reject expired relays in their // UPDATE guard; this sweep flips past-TTL rows to Expired so the table stays // bounded and the lifecycle reflects reality. - if state.config.features.grin_relay { + if state.cfg().features.grin_relay { let db = state.db.clone(); tokio::spawn(async move { let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600)); @@ -146,7 +168,7 @@ async fn main() -> Result<(), Box> { // Background erasure execution: confirmed requests past their grace window // are deleted (per-table policy + cascade + hash-chained audit, one tx each). - if state.config.retention.erasure_enabled { + if state.cfg().retention.erasure_enabled { let state = state.clone(); tokio::spawn(async move { let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600)); @@ -165,8 +187,8 @@ async fn main() -> Result<(), Box> { // fetch the configured feeds and replace the snapshot; a failure logs and // keeps the last good values rather than blanking them. The first interval // tick fires immediately, so prices populate at startup. - if state.config.features.prices { - let f = &state.config.features; + if state.cfg().features.prices { + let f = &state.cfg().features; match prices::PriceClient::new(&f.prices_provider, &f.prices_currency, &f.prices_assets) { Ok(client) if !client.is_empty() => { let cache = state.prices.clone(); @@ -195,8 +217,8 @@ async fn main() -> Result<(), Box> { // not middleware ordering). The fail-closed non-loopback bind guard and the // browser/Host hardening land with the admin-posture subsystem; for now bind // to the configured (loopback-default) address and warn if it isn't local. - if state.config.admin.enabled { - let admin_addr = state.config.admin.bind.clone(); + if state.cfg().admin.enabled { + let admin_addr = state.cfg().admin.bind.clone(); // Fail-closed bind guard: refuse a non-loopback admin bind unless the // operator explicitly opts in (confidentiality is by socket). Resolve the // address (so "localhost" works) and require EVERY resolved addr to be @@ -209,7 +231,7 @@ async fn main() -> Result<(), Box> { } Err(_) => false, }; - if !is_loopback && !state.config.admin.allow_public_bind { + if !is_loopback && !state.cfg().admin.allow_public_bind { return Err(format!( "ADMIN_BIND {admin_addr} is not loopback; set ADMIN_ALLOW_PUBLIC_BIND=true to override \ (and front it with Tor client-auth / a trusted proxy)" @@ -238,7 +260,7 @@ async fn main() -> Result<(), Box> { use smirk_backend_core::infra::relay::WritePolicy; if relay.write_policy() != WritePolicy::Open { let addr: std::net::SocketAddr = state - .config + .cfg() .messaging .relay .admission_bind @@ -267,7 +289,7 @@ async fn main() -> Result<(), Box> { // pending_confirmation tips). Only runs when tips are enabled. A panic in a // single pass is caught + backed off so the loop survives (visible in logs) // rather than silently dying and stranding every pending tip. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { @@ -291,7 +313,7 @@ async fn main() -> Result<(), Box> { // an immediate startup pass (so a restart doesn't wait a full interval before // settling anything that confirmed while we were down) then every 60s. Same // catch_unwind guard as the money-in worker. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { @@ -312,7 +334,7 @@ async fn main() -> Result<(), Box> { // Public-tips reorg-check pass: re-probe settled tips and revert any whose // recorded sweep was orphaned by a chain reorg. Lowest-frequency, novel, and // recoverable, so it runs on a slower cadence (300s) than the confirm pass. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { @@ -333,7 +355,7 @@ async fn main() -> Result<(), Box> { // Public-tips draft GC: hourly, cancel `draft` rows abandoned mid-flow (> 7 // days, never funded-attached) and warn per cancelled row. Same catch_unwind // guard as the money-in/out workers. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { @@ -354,7 +376,7 @@ async fn main() -> Result<(), Box> { // Public-tips lifecycle GC: every 5 min, cancel stuck `pending_confirmation` // rows (> 7 days) and warn-only-scan stuck `claiming` rows (> 15 min). Same // catch_unwind guard. - if state.config.features.tips { + if state.cfg().features.tips { use futures::FutureExt; let state = state.clone(); tokio::spawn(async move { diff --git a/tests/admin_auth.rs b/tests/admin_auth.rs index 34761f7..c2ffd97 100644 --- a/tests/admin_auth.rs +++ b/tests/admin_auth.rs @@ -92,7 +92,7 @@ async fn admin_login_guard_refresh_logout_flow() { let app = require_app!(); let admin = app.admin_router(); - let secret = app.state.config.admin.key_integrity_secret.clone(); + let secret = app.state.cfg().admin.key_integrity_secret.clone(); // Seed a PENDING admin key for our signer. let sk = random_signer(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8b5766b..9066fb7 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -89,7 +89,8 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option { .enabled .then(|| AdminSessionManager::new(&config.admin.jwt_secret)); let state = Arc::new(AppState { - config, + config: Arc::new(arc_swap::ArcSwap::from_pointee(config.clone())), + config_base: Arc::new(config), db, sessions, chains, @@ -258,7 +259,7 @@ impl TestApp { .expect("mint token pair"); let hash = hash_refresh_token( &pair.refresh_token, - &self.state.config.secrets.refresh_token_pepper, + &self.state.cfg().secrets.refresh_token_pepper, ); self.state .db diff --git a/tests/erasure.rs b/tests/erasure.rs index f28606b..d0030d6 100644 --- a/tests/erasure.rs +++ b/tests/erasure.rs @@ -89,7 +89,7 @@ async fn erasure_request_confirm_execute_flow() { let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() @@ -221,7 +221,7 @@ async fn erasure_request_confirm_execute_flow() { assert!(body["erasure_id"].as_str().is_some()); assert_eq!(body["status"], "pending"); - let secret = app.state.config.admin.key_integrity_secret.clone(); + let secret = app.state.cfg().admin.key_integrity_secret.clone(); // [HIGH regression] a request CANCELLED after confirmation is NOT executed: // execute claims `WHERE status='confirmed'`, so a cancelled row is skipped. diff --git a/tests/nostr_auth.rs b/tests/nostr_auth.rs index 25de3e2..c091a31 100644 --- a/tests/nostr_auth.rs +++ b/tests/nostr_auth.rs @@ -78,7 +78,7 @@ async fn link_challenge_then_link_resolves_and_rejects_replay_and_wrong_user() { let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() diff --git a/tests/nostr_register.rs b/tests/nostr_register.rs index ba77cf8..1d8d4b8 100644 --- a/tests/nostr_register.rs +++ b/tests/nostr_register.rs @@ -88,7 +88,7 @@ async fn register_creates_user_by_npub_and_is_idempotent() { let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() @@ -120,7 +120,7 @@ async fn register_replays_are_rejected() { let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() @@ -160,7 +160,7 @@ async fn register_refuses_to_bind_npub_onto_existing_seed_fingerprint_row_withou let app = require_app!(); let base = app .state - .config + .cfg() .identity .public_api_url .clone() diff --git a/tests/relay_admission.rs b/tests/relay_admission.rs index 2322dda..b71740e 100644 --- a/tests/relay_admission.rs +++ b/tests/relay_admission.rs @@ -88,7 +88,7 @@ async fn is_registered_npub_membership() { #[tokio::test] async fn admission_inbox_outbox_end_to_end() { let app = require_app!(); - let svc = AdmissionService::new(inbox_outbox_relay(&app.state.config), app.state.db.clone()); + let svc = AdmissionService::new(inbox_outbox_relay(&app.state.cfg()), app.state.db.clone()); let author = hex64("b1"); // registered author (outbox) let recipient = hex64("b2"); // registered recipient (inbox)