mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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:
+15
-15
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user