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<ArcSwap<Config>> (hot-swappable effective config) +
  config_base: Arc<Config> (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.
This commit is contained in:
jwinterm
2026-07-18 06:00:16 -04:00
parent 00968a9c05
commit 4c83e53839
24 changed files with 153 additions and 101 deletions
Generated
+10
View File
@@ -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",
+2
View File
@@ -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"
+15 -15
View File
@@ -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<Json<AdminTokenResponse>, 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<Json<OkResponse>, 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<String>) -> 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<Uuid>,
) -> Result<Json<OkResponse>, 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<Json<AdminFeaturesResponse>, 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();
+16 -16
View File
@@ -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<String, AppError> {
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<Arc<AppState>>,
) -> Result<Json<altcha::Challenge>, 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<Option<String>, 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<Arc<AppState>>,
Json(req): Json<PaymentInvoiceRequest>,
) -> Result<Json<PaymentInvoiceResponse>, 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<Json<LogoutResponse>, 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;
+1 -1
View File
@@ -354,7 +354,7 @@ fn tips_advertised(config: &Config) -> bool {
)]
#[instrument(skip(state))]
pub async fn capabilities(State(state): State<Arc<AppState>>) -> Json<CapabilitiesResponse> {
Json(effective_capabilities(&state.config))
Json(effective_capabilities(&state.cfg()))
}
/// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth).
+14 -14
View File
@@ -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<String, AppError> {
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<ProofRequest>,
) -> Result<Json<ErasureRequestResponse>, 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<AppState>, batch: i64) -> Result<u64, AppError> {
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) => {}
+5 -5
View File
@@ -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<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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()
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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);
}
}
+5 -5
View File
@@ -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(),
+1 -1
View File
@@ -37,7 +37,7 @@ pub struct PricesResponse {
)]
#[instrument(skip(state))]
pub async fn prices(State(state): State<Arc<AppState>>) -> Result<Json<PricesResponse>, AppError> {
if !state.config.features.prices {
if !state.cfg().features.prices {
return Err(AppError::NotFound(
"price feed is not enabled on this server".into(),
));
+4 -4
View File
@@ -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));
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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(
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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
+19 -2
View File
@@ -56,7 +56,13 @@ const NORMAL_BURST: u32 = 60;
/// Shared application state injected into handlers via `State<Arc<AppState>>`.
#[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<Config>` here (lock-free).
/// Read it via [`AppState::cfg`] (`state.cfg().field`), never a stale copy.
pub config: Arc<arc_swap::ArcSwap<Config>>,
/// 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<Config>,
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<AdminSessionManager>,
}
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<std::sync::Arc<Config>> {
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<AppState>) -> 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).
+42 -20
View File
@@ -19,21 +19,42 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
}
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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 {
+1 -1
View File
@@ -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();
+3 -2
View File
@@ -89,7 +89,8 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option<TestApp> {
.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
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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()
+3 -3
View File
@@ -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()
+1 -1
View File
@@ -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)