mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +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:
Generated
+10
@@ -110,6 +110,15 @@ version = "1.0.103"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
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]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.42"
|
version = "0.4.42"
|
||||||
@@ -2929,6 +2938,7 @@ version = "0.3.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"altcha",
|
"altcha",
|
||||||
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ tower_governor = "0.4"
|
|||||||
governor = "0.6"
|
governor = "0.6"
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
|
arc-swap = "1"
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate", "json"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork", "migrate", "json"] }
|
||||||
ipnetwork = "0.20"
|
ipnetwork = "0.20"
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ tonic-build = "0.12"
|
|||||||
protoc-bin-vendored = "3"
|
protoc-bin-vendored = "3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
arc-swap = "1"
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
mockall = "0.12"
|
mockall = "0.12"
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -66,14 +66,14 @@ fn admin_manager(state: &AppState) -> Result<&AdminSessionManager, AppError> {
|
|||||||
fn admin_verify_url(state: &AppState) -> String {
|
fn admin_verify_url(state: &AppState) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}/admin/auth/verify",
|
"{}/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
|
/// A stable per-instance id bound into the signed action, so a challenge signed
|
||||||
/// for this instance cannot be relayed to another.
|
/// for this instance cannot be relayed to another.
|
||||||
fn admin_instance_id(state: &AppState) -> String {
|
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 {
|
fn pubkey_prefix(pubkey: &str) -> String {
|
||||||
@@ -121,7 +121,7 @@ pub async fn admin_plane_guard(
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
// Compute the response on both paths, then apply the security headers once so
|
// Compute the response on both paths, then apply the security headers once so
|
||||||
// the 403 Host-reject carries them too.
|
// 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
|
next.run(req).await
|
||||||
} else {
|
} else {
|
||||||
(StatusCode::FORBIDDEN, "forbidden").into_response()
|
(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
|
// Live, uncached allowlist re-check (MAC re-verified inside): must be active
|
||||||
// AND activated (a pending key is not yet authorized for protected routes).
|
// 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
|
let key = state
|
||||||
.db
|
.db
|
||||||
.get_active_admin_key(&info.pubkey, secret)
|
.get_active_admin_key(&info.pubkey, secret)
|
||||||
@@ -279,7 +279,7 @@ pub async fn admin_verify(
|
|||||||
) -> Result<Json<AdminTokenResponse>, AppError> {
|
) -> Result<Json<AdminTokenResponse>, AppError> {
|
||||||
let mgr = admin_manager(&state)?;
|
let mgr = admin_manager(&state)?;
|
||||||
let ip = client_ip(&state, &headers, peer);
|
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
|
// 1. Prove the signature + bindings (purpose/nonce/descriptor/instance). The
|
||||||
// descriptor binds the verify URL with an EMPTY body (the proof rides in 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 pair = mgr.create_token_pair(&pubkey, session_id)?;
|
||||||
let refresh_hash = hash_refresh_token(
|
let refresh_hash = hash_refresh_token(
|
||||||
&pair.refresh_token,
|
&pair.refresh_token,
|
||||||
&state.config.secrets.refresh_token_pepper,
|
&state.cfg().secrets.refresh_token_pepper,
|
||||||
);
|
);
|
||||||
let audit = NewAdminAudit {
|
let audit = NewAdminAudit {
|
||||||
action: "admin_login".into(),
|
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
|
// Re-authorize against the live allowlist (closes the "8h refresh is the real
|
||||||
// blast radius" gap).
|
// blast radius" gap).
|
||||||
let secret = &state.config.admin.key_integrity_secret;
|
let secret = &state.cfg().admin.key_integrity_secret;
|
||||||
let key = state
|
let key = state
|
||||||
.db
|
.db
|
||||||
.get_active_admin_key(&pubkey, secret)
|
.get_active_admin_key(&pubkey, secret)
|
||||||
@@ -403,7 +403,7 @@ pub async fn admin_refresh(
|
|||||||
.ok_or_else(admin_auth_fail)?;
|
.ok_or_else(admin_auth_fail)?;
|
||||||
let presented = hash_refresh_token(
|
let presented = hash_refresh_token(
|
||||||
&req.refresh_token,
|
&req.refresh_token,
|
||||||
&state.config.secrets.refresh_token_pepper,
|
&state.cfg().secrets.refresh_token_pepper,
|
||||||
);
|
);
|
||||||
let hash_ok: bool = presented
|
let hash_ok: bool = presented
|
||||||
.as_bytes()
|
.as_bytes()
|
||||||
@@ -435,7 +435,7 @@ pub async fn admin_logout(
|
|||||||
) -> Result<Json<OkResponse>, AppError> {
|
) -> Result<Json<OkResponse>, AppError> {
|
||||||
let ctx = admin_guard(&state, &headers).await?;
|
let ctx = admin_guard(&state, &headers).await?;
|
||||||
state.db.revoke_admin_session(ctx.session_id).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
|
let _ = state
|
||||||
.db
|
.db
|
||||||
.record_admin_audit(
|
.record_admin_audit(
|
||||||
@@ -520,7 +520,7 @@ fn pending_key(state: &AppState, pubkey: String, label: Option<String>) -> NewAd
|
|||||||
scope: "admin".into(),
|
scope: "admin".into(),
|
||||||
created_by_kind: "admin".into(),
|
created_by_kind: "admin".into(),
|
||||||
activation_deadline: Some(
|
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();
|
let pubkey = req.pubkey.to_lowercase();
|
||||||
validate_admin_pubkey(&pubkey)?;
|
validate_admin_pubkey(&pubkey)?;
|
||||||
|
|
||||||
let secret = &state.config.admin.key_integrity_secret;
|
let secret = &state.cfg().admin.key_integrity_secret;
|
||||||
let audit = NewAdminAudit {
|
let audit = NewAdminAudit {
|
||||||
action: "admin_key_added".into(),
|
action: "admin_key_added".into(),
|
||||||
actor_kind: "admin".into(),
|
actor_kind: "admin".into(),
|
||||||
@@ -552,7 +552,7 @@ pub async fn admin_keys_add(
|
|||||||
pending_key(&state, pubkey, req.label),
|
pending_key(&state, pubkey, req.label),
|
||||||
&audit,
|
&audit,
|
||||||
secret,
|
secret,
|
||||||
state.config.admin.max_keys as i64,
|
state.cfg().admin.max_keys as i64,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
@@ -593,7 +593,7 @@ pub async fn admin_keys_revoke(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<OkResponse>, AppError> {
|
) -> Result<Json<OkResponse>, AppError> {
|
||||||
let ctx = admin_guard(&state, &headers).await?;
|
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 {
|
let audit = NewAdminAudit {
|
||||||
action: "admin_key_revoked".into(),
|
action: "admin_key_revoked".into(),
|
||||||
actor_kind: "admin".into(),
|
actor_kind: "admin".into(),
|
||||||
@@ -632,7 +632,7 @@ pub async fn admin_keys_rotate(
|
|||||||
let ctx = admin_guard(&state, &headers).await?;
|
let ctx = admin_guard(&state, &headers).await?;
|
||||||
let pubkey = req.pubkey.to_lowercase();
|
let pubkey = req.pubkey.to_lowercase();
|
||||||
validate_admin_pubkey(&pubkey)?;
|
validate_admin_pubkey(&pubkey)?;
|
||||||
let secret = &state.config.admin.key_integrity_secret;
|
let secret = &state.cfg().admin.key_integrity_secret;
|
||||||
let audit = NewAdminAudit {
|
let audit = NewAdminAudit {
|
||||||
action: "admin_key_rotated".into(),
|
action: "admin_key_rotated".into(),
|
||||||
actor_kind: "admin".into(),
|
actor_kind: "admin".into(),
|
||||||
@@ -679,7 +679,7 @@ pub async fn admin_features(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<AdminFeaturesResponse>, AppError> {
|
) -> Result<Json<AdminFeaturesResponse>, AppError> {
|
||||||
admin_guard(&state, &headers).await?;
|
admin_guard(&state, &headers).await?;
|
||||||
let cfg = &state.config;
|
let cfg = &state.cfg();
|
||||||
let effective = crate::api::capabilities::effective_capabilities(cfg);
|
let effective = crate::api::capabilities::effective_capabilities(cfg);
|
||||||
|
|
||||||
let mut downgrades = Vec::new();
|
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
|
/// 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.
|
/// disabled we never call `verify_payload`, so the empty key is never used.
|
||||||
fn pow_applies(state: &AppState, pubkey_hash_lc: &str) -> bool {
|
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 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
|
/// the event's `u` tag). Built from `config.identity.public_api_url`, never the
|
||||||
/// request Host. Fail closed when unset (Nostr identity is disabled).
|
/// request Host. Fail closed when unset (Nostr identity is disabled).
|
||||||
fn nip98_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
fn nip98_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
||||||
let base = state
|
let cfg = state.cfg();
|
||||||
.config
|
let base = cfg
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -215,7 +215,7 @@ async fn issue_session(
|
|||||||
|
|
||||||
let refresh_token_hash = hash_refresh_token(
|
let refresh_token_hash = hash_refresh_token(
|
||||||
&pair.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();
|
let expires_at = Utc::now() + state.sessions.refresh_token_expiry();
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ async fn upsert_all_keys(
|
|||||||
pub async fn pow_challenge(
|
pub async fn pow_challenge(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> Result<Json<altcha::Challenge>, AppError> {
|
) -> 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))
|
Ok(Json(challenge))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,13 +622,13 @@ fn enforce_pow(
|
|||||||
.into(),
|
.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)");
|
info!(pow = "ok", "PoW solution accepted (new user)");
|
||||||
} else if let Some(solution) = solution {
|
} else if let Some(solution) = solution {
|
||||||
// Supplied but not required: verify anyway (clear error on malformed),
|
// Supplied but not required: verify anyway (clear error on malformed),
|
||||||
// but only if the feature is enabled so we never touch an empty key.
|
// but only if the feature is enabled so we never touch an empty key.
|
||||||
if state.config.pow.enabled {
|
if state.cfg().pow.enabled {
|
||||||
crate::core::pow::verify_payload(&state.config.pow, solution)?;
|
crate::core::pow::verify_payload(&state.cfg().pow, solution)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -723,15 +723,15 @@ async fn plan_gate_consume(
|
|||||||
.is_some_and(|s| !s.is_empty());
|
.is_some_and(|s| !s.is_empty());
|
||||||
|
|
||||||
match plan_gates(
|
match plan_gates(
|
||||||
state.config.registration.gate_mode,
|
state.cfg().registration.gate_mode,
|
||||||
state.config.registration.require_invite,
|
state.cfg().registration.require_invite,
|
||||||
state.config.registration.payment.require_payment,
|
state.cfg().registration.payment.require_payment,
|
||||||
has_invite,
|
has_invite,
|
||||||
has_payment,
|
has_payment,
|
||||||
) {
|
) {
|
||||||
GatePlan::Open => Ok(GatedRegistration { invite_code_hash: None, payment: None }),
|
GatePlan::Open => Ok(GatedRegistration { invite_code_hash: None, payment: None }),
|
||||||
GatePlan::All => {
|
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)?)
|
Some(require_invite_hash(invite_code)?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -776,7 +776,7 @@ async fn verify_payment_settled(
|
|||||||
pubkey_hash: &str,
|
pubkey_hash: &str,
|
||||||
payment_invoice_id: Option<&str>,
|
payment_invoice_id: Option<&str>,
|
||||||
) -> Result<Option<String>, AppError> {
|
) -> Result<Option<String>, AppError> {
|
||||||
if returning || !state.config.registration.payment.require_payment {
|
if returning || !state.cfg().registration.payment.require_payment {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Gate on but no provider built — config validation prevents this, so it is
|
// 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>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<PaymentInvoiceRequest>,
|
Json(req): Json<PaymentInvoiceRequest>,
|
||||||
) -> Result<Json<PaymentInvoiceResponse>, AppError> {
|
) -> Result<Json<PaymentInvoiceResponse>, AppError> {
|
||||||
let cfg = &state.config.registration.payment;
|
let cfg = &state.cfg().registration.payment;
|
||||||
if !cfg.require_payment {
|
if !cfg.require_payment {
|
||||||
return Err(AppError::ValidationError(
|
return Err(AppError::ValidationError(
|
||||||
"This instance does not require registration payment.".into(),
|
"This instance does not require registration payment.".into(),
|
||||||
@@ -1175,7 +1175,7 @@ pub async fn refresh_token(
|
|||||||
|
|
||||||
let token_hash = hash_refresh_token(
|
let token_hash = hash_refresh_token(
|
||||||
&req.refresh_token,
|
&req.refresh_token,
|
||||||
&state.config.secrets.refresh_token_pepper,
|
&state.cfg().secrets.refresh_token_pepper,
|
||||||
);
|
);
|
||||||
let session = state
|
let session = state
|
||||||
.db
|
.db
|
||||||
@@ -1256,7 +1256,7 @@ pub async fn logout(
|
|||||||
) -> Result<Json<LogoutResponse>, AppError> {
|
) -> Result<Json<LogoutResponse>, AppError> {
|
||||||
let token_hash = hash_refresh_token(
|
let token_hash = hash_refresh_token(
|
||||||
&req.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? {
|
if let Some(session) = state.db.get_session_by_token_hash(&token_hash).await? {
|
||||||
let _ = state.db.revoke_session(session.id).await;
|
let _ = state.db.revoke_session(session.id).await;
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ fn tips_advertised(config: &Config) -> bool {
|
|||||||
)]
|
)]
|
||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
pub async fn capabilities(State(state): State<Arc<AppState>>) -> Json<CapabilitiesResponse> {
|
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).
|
/// 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 ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
if state.config.retention.erasure_enabled {
|
if state.cfg().retention.erasure_enabled {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::NotFound("erasure is not enabled".into()))
|
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
|
/// The signed-action `u`-tag base for `path` (e.g. `/account/erasure`), from
|
||||||
/// `PUBLIC_API_URL` (which already includes the `/api/v1` prefix).
|
/// `PUBLIC_API_URL` (which already includes the `/api/v1` prefix).
|
||||||
fn erasure_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
fn erasure_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
||||||
let base = state
|
let cfg = state.cfg();
|
||||||
.config
|
let base = cfg
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -64,8 +64,8 @@ fn subject_hash(npub: &str) -> String {
|
|||||||
hex::encode(Sha256::digest(npub.as_bytes()))
|
hex::encode(Sha256::digest(npub.as_bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn integrity_secret(state: &AppState) -> &str {
|
fn integrity_secret(state: &AppState) -> String {
|
||||||
&state.config.admin.key_integrity_secret
|
state.cfg().admin.key_integrity_secret.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -191,7 +191,7 @@ pub async fn request_erasure(
|
|||||||
Json(body): Json<ProofRequest>,
|
Json(body): Json<ProofRequest>,
|
||||||
) -> Result<Json<ErasureRequestResponse>, AppError> {
|
) -> Result<Json<ErasureRequestResponse>, AppError> {
|
||||||
ensure_enabled(&state)?;
|
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?;
|
let (pubkey, user) = prove(&state, &body, PURPOSE_REQUEST, "/account/erasure", None).await?;
|
||||||
|
|
||||||
// Constant-shape across present/absent AND across repeated calls: a STABLE
|
// Constant-shape across present/absent AND across repeated calls: a STABLE
|
||||||
@@ -209,13 +209,13 @@ pub async fn request_erasure(
|
|||||||
.db
|
.db
|
||||||
.record_admin_audit(
|
.record_admin_audit(
|
||||||
&audit("account_erasure_requested", &req.id),
|
&audit("account_erasure_requested", &req.id),
|
||||||
integrity_secret(&state),
|
&integrity_secret(&state),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
info!(erasure_id = %req.id, "erasure requested");
|
info!(erasure_id = %req.id, "erasure requested");
|
||||||
req.id.to_string()
|
req.id.to_string()
|
||||||
}
|
}
|
||||||
None => synthetic_erasure_id(&pubkey, integrity_secret(&state)),
|
None => synthetic_erasure_id(&pubkey, &integrity_secret(&state)),
|
||||||
};
|
};
|
||||||
Ok(Json(ErasureRequestResponse {
|
Ok(Json(ErasureRequestResponse {
|
||||||
erasure_id,
|
erasure_id,
|
||||||
@@ -244,7 +244,7 @@ pub async fn confirm_erasure(
|
|||||||
.await?;
|
.await?;
|
||||||
let user = user.ok_or_else(|| AppError::NotFound("no such erasure request".into()))?;
|
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
|
let req = state
|
||||||
.db
|
.db
|
||||||
.confirm_erasure_request(id, user.id, grace)
|
.confirm_erasure_request(id, user.id, grace)
|
||||||
@@ -256,7 +256,7 @@ pub async fn confirm_erasure(
|
|||||||
.db
|
.db
|
||||||
.record_admin_audit(
|
.record_admin_audit(
|
||||||
&audit("account_erasure_confirmed", &req.id),
|
&audit("account_erasure_confirmed", &req.id),
|
||||||
integrity_secret(&state),
|
&integrity_secret(&state),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
info!(erasure_id = %req.id, "erasure confirmed");
|
info!(erasure_id = %req.id, "erasure confirmed");
|
||||||
@@ -295,7 +295,7 @@ pub async fn cancel_erasure(
|
|||||||
.db
|
.db
|
||||||
.record_admin_audit(
|
.record_admin_audit(
|
||||||
&audit("account_erasure_cancelled", &req.id),
|
&audit("account_erasure_cancelled", &req.id),
|
||||||
integrity_secret(&state),
|
&integrity_secret(&state),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
Ok(Json(StatusResponse {
|
Ok(Json(StatusResponse {
|
||||||
@@ -359,7 +359,7 @@ pub async fn export(
|
|||||||
details: None,
|
details: None,
|
||||||
ip_address: None,
|
ip_address: None,
|
||||||
},
|
},
|
||||||
integrity_secret(&state),
|
&integrity_secret(&state),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
Ok(Json(ExportResponse {
|
Ok(Json(ExportResponse {
|
||||||
@@ -388,11 +388,11 @@ fn audit(action: &str, erasure_id: &Uuid) -> NewAdminAudit {
|
|||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
pub async fn run_erasure_sweep(state: &Arc<AppState>, batch: i64) -> Result<u64, AppError> {
|
pub async fn run_erasure_sweep(state: &Arc<AppState>, batch: i64) -> Result<u64, AppError> {
|
||||||
let due = state.db.due_erasure_requests(batch).await?;
|
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 secret = integrity_secret(state);
|
||||||
let mut done = 0;
|
let mut done = 0;
|
||||||
for req in due {
|
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,
|
Ok(true) => done += 1,
|
||||||
// false = another node/sweep already claimed it, or it was cancelled.
|
// false = another node/sweep already claimed it, or it was cancelled.
|
||||||
Ok(false) => {}
|
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
|
/// is off (matching an unmatched route — the `{error,code}` envelope would itself
|
||||||
/// hint the route exists, so it is deliberately not used here).
|
/// hint the route exists, so it is deliberately not used here).
|
||||||
pub async fn server_info(State(state): State<Arc<AppState>>) -> Response {
|
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();
|
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.
|
/// `GET /` — minimal HTML rendered from the read model; bare `404` when off.
|
||||||
pub async fn root(State(state): State<Arc<AppState>>) -> Response {
|
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();
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
}
|
}
|
||||||
let title = state
|
let cfg = state.cfg();
|
||||||
.config
|
let title = cfg
|
||||||
.landing
|
.landing
|
||||||
.title
|
.title
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ pub fn client_ip(state: &AppState, headers: &HeaderMap, peer: SocketAddr) -> IpA
|
|||||||
let peer_ip = peer.ip();
|
let peer_ip = peer.ip();
|
||||||
|
|
||||||
let peer_is_trusted = state
|
let peer_is_trusted = state
|
||||||
.config
|
.cfg()
|
||||||
.trusted_proxies
|
.trusted_proxies
|
||||||
.iter()
|
.iter()
|
||||||
.any(|net| net.contains(peer_ip));
|
.any(|net| net.contains(peer_ip));
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ pub async fn well_known_nostr(
|
|||||||
let name = raw.to_lowercase();
|
let name = raw.to_lowercase();
|
||||||
if let Some(user) = state.db.get_user_by_username(&name).await? {
|
if let Some(user) = state.db.get_user_by_username(&name).await? {
|
||||||
if let Some(pubkey) = user.nostr_pubkey {
|
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);
|
names.insert(name, pubkey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -52,7 +52,7 @@ pub struct StatusResp {
|
|||||||
|
|
||||||
/// Reject when the premium tier is not enabled on this instance.
|
/// Reject when the premium tier is not enabled on this instance.
|
||||||
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
if state.config.premium.enabled {
|
if state.cfg().premium.enabled {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::ValidationError(
|
Err(AppError::ValidationError(
|
||||||
@@ -83,8 +83,8 @@ pub async fn invoice(
|
|||||||
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
||||||
ensure_enabled(&state)?;
|
ensure_enabled(&state)?;
|
||||||
|
|
||||||
let plan = state
|
let cfg = state.cfg();
|
||||||
.config
|
let plan = cfg
|
||||||
.premium
|
.premium
|
||||||
.plans
|
.plans
|
||||||
.iter()
|
.iter()
|
||||||
@@ -106,8 +106,8 @@ pub async fn invoice(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::Internal("payment provider not configured".into()))?;
|
.ok_or_else(|| AppError::Internal("payment provider not configured".into()))?;
|
||||||
|
|
||||||
let pay = &state.config.registration.payment;
|
let pay = &state.cfg().registration.payment;
|
||||||
let currency = state.config.premium.currency.clone();
|
let currency = state.cfg().premium.currency.clone();
|
||||||
let invoice = provider
|
let invoice = provider
|
||||||
.create_invoice(&InvoiceRequest {
|
.create_invoice(&InvoiceRequest {
|
||||||
amount: plan.amount.clone(),
|
amount: plan.amount.clone(),
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ pub struct PricesResponse {
|
|||||||
)]
|
)]
|
||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
pub async fn prices(State(state): State<Arc<AppState>>) -> Result<Json<PricesResponse>, AppError> {
|
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(
|
return Err(AppError::NotFound(
|
||||||
"price feed is not enabled on this server".into(),
|
"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.
|
/// Reject when tips are not enabled on this instance.
|
||||||
fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> {
|
fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::ValidationError(
|
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.
|
/// Whether `asset` is a tip-capable chain enabled on this instance.
|
||||||
fn supported_tip_asset(state: &AppState, asset: &str) -> bool {
|
fn supported_tip_asset(state: &AppState, asset: &str) -> bool {
|
||||||
let c = &state.config.features.chains;
|
let c = &state.cfg().features.chains;
|
||||||
match asset {
|
match asset {
|
||||||
"btc" => c.btc,
|
"btc" => c.btc,
|
||||||
"ltc" => c.ltc,
|
"ltc" => c.ltc,
|
||||||
@@ -382,7 +382,7 @@ pub async fn create_social_tip(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let share_url = state
|
let share_url = state
|
||||||
.config
|
.cfg()
|
||||||
.tip_share_base
|
.tip_share_base
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id));
|
.map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id));
|
||||||
@@ -573,7 +573,7 @@ pub async fn attach_funding(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let share_url = state
|
let share_url = state
|
||||||
.config
|
.cfg()
|
||||||
.tip_share_base
|
.tip_share_base
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|base| format!("{}/{}", base.trim_end_matches('/'), tip.id));
|
.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.
|
// grin-wallet fallback, so it is enforced before either scan path is chosen.
|
||||||
if let Some(h) = req.start_height {
|
if let Some(h) = req.start_height {
|
||||||
let tip = grin_tip(&state).await?;
|
let tip = grin_tip(&state).await?;
|
||||||
state.config.restore.enforce("grin", h, tip)?;
|
state.cfg().restore.enforce("grin", h, tip)?;
|
||||||
state.config.restore.enforce_restore_pow(
|
state.cfg().restore.enforce_restore_pow(
|
||||||
"grin",
|
"grin",
|
||||||
&req.rewind_hash,
|
&req.rewind_hash,
|
||||||
h,
|
h,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const RELAY_TTL_DAYS: i64 = 7;
|
|||||||
|
|
||||||
/// 400 unless the relay feature is enabled on this server.
|
/// 400 unless the relay feature is enabled on this server.
|
||||||
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
if state.config.features.grin_relay {
|
if state.cfg().features.grin_relay {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::NotFound(
|
Err(AppError::NotFound(
|
||||||
|
|||||||
@@ -320,8 +320,8 @@ pub async fn register(
|
|||||||
// Restore: gate the scan depth against this instance's policy (the
|
// Restore: gate the scan depth against this instance's policy (the
|
||||||
// backfill cost lands on our LWS). `None` (create) needs no check.
|
// backfill cost lands on our LWS). `None` (create) needs no check.
|
||||||
let tip = client.get_blockchain_height().await?;
|
let tip = client.get_blockchain_height().await?;
|
||||||
state.config.restore.enforce(&asset, h, tip)?;
|
state.cfg().restore.enforce(&asset, h, tip)?;
|
||||||
state.config.restore.enforce_restore_pow(
|
state.cfg().restore.enforce_restore_pow(
|
||||||
&asset,
|
&asset,
|
||||||
&req.address,
|
&req.address,
|
||||||
h,
|
h,
|
||||||
|
|||||||
+1
-1
@@ -297,7 +297,7 @@ pub async fn website_verify(
|
|||||||
.create_token_pair(user.id, Platform::Web, session_id)?;
|
.create_token_pair(user.id, Platform::Web, session_id)?;
|
||||||
let refresh_token_hash = hash_refresh_token(
|
let refresh_token_hash = hash_refresh_token(
|
||||||
&pair.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();
|
let expires_at = Utc::now() + state.sessions.refresh_token_expiry();
|
||||||
state
|
state
|
||||||
|
|||||||
+19
-2
@@ -56,7 +56,13 @@ const NORMAL_BURST: u32 = 60;
|
|||||||
/// Shared application state injected into handlers via `State<Arc<AppState>>`.
|
/// Shared application state injected into handlers via `State<Arc<AppState>>`.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
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 db: Database,
|
||||||
pub sessions: SessionManager,
|
pub sessions: SessionManager,
|
||||||
/// Per-chain data-source clients (present only for enabled chains).
|
/// Per-chain data-source clients (present only for enabled chains).
|
||||||
@@ -78,12 +84,23 @@ pub struct AppState {
|
|||||||
pub admin_sessions: Option<AdminSessionManager>,
|
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
|
/// Assemble the full application router with every route mounted and state
|
||||||
/// applied. Shared by `main` and the integration-test harness so both exercise
|
/// 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
|
/// the exact same wiring: `/health` and the NIP-05 directory at the root, the
|
||||||
/// authenticated wallet/identity API nested under `/api/v1`.
|
/// authenticated wallet/identity API nested under `/api/v1`.
|
||||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
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
|
// Per-IP rate limiting (PeerIpKeyExtractor reads ConnectInfo — `main` serves
|
||||||
// with `into_make_service_with_connect_info`; the test harness injects it).
|
// 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();
|
tracing_subscriber::fmt::init();
|
||||||
let _ = dotenvy::dotenv();
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
// Fail-closed: aborts on a weak/missing/inconsistent secret.
|
// Fail-closed: aborts on a weak/missing/inconsistent secret. This is the env-only
|
||||||
let config = Config::from_env()?;
|
// truth; the effective config below layers the validated DB overlay on top.
|
||||||
tracing::info!(environment = %config.environment, "configuration loaded");
|
let config_base = Config::from_env()?;
|
||||||
|
tracing::info!(environment = %config_base.environment, "configuration loaded");
|
||||||
|
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(20)
|
.max_connections(20)
|
||||||
.connect(&config.database_url)
|
.connect(&config_base.database_url)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
|
|
||||||
let db = Database::new(
|
let db = Database::new(
|
||||||
pool,
|
pool,
|
||||||
config.secrets.seed_fingerprint_pepper.clone(),
|
config_base.secrets.seed_fingerprint_pepper.clone(),
|
||||||
config.secrets.ip_salt.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 sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours);
|
||||||
let chains = ChainClients::from_config(&config)?;
|
let chains = ChainClients::from_config(&config)?;
|
||||||
// Payment processor for the pay-to-register gate (None unless it's enabled).
|
// 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
|
.enabled
|
||||||
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
|
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
config,
|
config: Arc::new(arc_swap::ArcSwap::from_pointee(config)),
|
||||||
|
config_base: Arc::new(config_base),
|
||||||
db,
|
db,
|
||||||
sessions,
|
sessions,
|
||||||
chains,
|
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
|
// 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
|
// UPDATE guard; this sweep flips past-TTL rows to Expired so the table stays
|
||||||
// bounded and the lifecycle reflects reality.
|
// bounded and the lifecycle reflects reality.
|
||||||
if state.config.features.grin_relay {
|
if state.cfg().features.grin_relay {
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600));
|
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
|
// Background erasure execution: confirmed requests past their grace window
|
||||||
// are deleted (per-table policy + cascade + hash-chained audit, one tx each).
|
// 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();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600));
|
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
|
// fetch the configured feeds and replace the snapshot; a failure logs and
|
||||||
// keeps the last good values rather than blanking them. The first interval
|
// keeps the last good values rather than blanking them. The first interval
|
||||||
// tick fires immediately, so prices populate at startup.
|
// tick fires immediately, so prices populate at startup.
|
||||||
if state.config.features.prices {
|
if state.cfg().features.prices {
|
||||||
let f = &state.config.features;
|
let f = &state.cfg().features;
|
||||||
match prices::PriceClient::new(&f.prices_provider, &f.prices_currency, &f.prices_assets) {
|
match prices::PriceClient::new(&f.prices_provider, &f.prices_currency, &f.prices_assets) {
|
||||||
Ok(client) if !client.is_empty() => {
|
Ok(client) if !client.is_empty() => {
|
||||||
let cache = state.prices.clone();
|
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
|
// not middleware ordering). The fail-closed non-loopback bind guard and the
|
||||||
// browser/Host hardening land with the admin-posture subsystem; for now bind
|
// 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.
|
// to the configured (loopback-default) address and warn if it isn't local.
|
||||||
if state.config.admin.enabled {
|
if state.cfg().admin.enabled {
|
||||||
let admin_addr = state.config.admin.bind.clone();
|
let admin_addr = state.cfg().admin.bind.clone();
|
||||||
// Fail-closed bind guard: refuse a non-loopback admin bind unless the
|
// Fail-closed bind guard: refuse a non-loopback admin bind unless the
|
||||||
// operator explicitly opts in (confidentiality is by socket). Resolve the
|
// operator explicitly opts in (confidentiality is by socket). Resolve the
|
||||||
// address (so "localhost" works) and require EVERY resolved addr to be
|
// 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,
|
Err(_) => false,
|
||||||
};
|
};
|
||||||
if !is_loopback && !state.config.admin.allow_public_bind {
|
if !is_loopback && !state.cfg().admin.allow_public_bind {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"ADMIN_BIND {admin_addr} is not loopback; set ADMIN_ALLOW_PUBLIC_BIND=true to override \
|
"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)"
|
(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;
|
use smirk_backend_core::infra::relay::WritePolicy;
|
||||||
if relay.write_policy() != WritePolicy::Open {
|
if relay.write_policy() != WritePolicy::Open {
|
||||||
let addr: std::net::SocketAddr = state
|
let addr: std::net::SocketAddr = state
|
||||||
.config
|
.cfg()
|
||||||
.messaging
|
.messaging
|
||||||
.relay
|
.relay
|
||||||
.admission_bind
|
.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
|
// 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)
|
// single pass is caught + backed off so the loop survives (visible in logs)
|
||||||
// rather than silently dying and stranding every pending tip.
|
// rather than silently dying and stranding every pending tip.
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
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
|
// 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
|
// settling anything that confirmed while we were down) then every 60s. Same
|
||||||
// catch_unwind guard as the money-in worker.
|
// catch_unwind guard as the money-in worker.
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
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
|
// 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
|
// 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.
|
// 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;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
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
|
// Public-tips draft GC: hourly, cancel `draft` rows abandoned mid-flow (> 7
|
||||||
// days, never funded-attached) and warn per cancelled row. Same catch_unwind
|
// days, never funded-attached) and warn per cancelled row. Same catch_unwind
|
||||||
// guard as the money-in/out workers.
|
// guard as the money-in/out workers.
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
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`
|
// Public-tips lifecycle GC: every 5 min, cancel stuck `pending_confirmation`
|
||||||
// rows (> 7 days) and warn-only-scan stuck `claiming` rows (> 15 min). Same
|
// rows (> 7 days) and warn-only-scan stuck `claiming` rows (> 15 min). Same
|
||||||
// catch_unwind guard.
|
// catch_unwind guard.
|
||||||
if state.config.features.tips {
|
if state.cfg().features.tips {
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ async fn admin_login_guard_refresh_logout_flow() {
|
|||||||
|
|
||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let admin = app.admin_router();
|
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.
|
// Seed a PENDING admin key for our signer.
|
||||||
let sk = random_signer();
|
let sk = random_signer();
|
||||||
|
|||||||
+3
-2
@@ -89,7 +89,8 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option<TestApp> {
|
|||||||
.enabled
|
.enabled
|
||||||
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
|
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
config,
|
config: Arc::new(arc_swap::ArcSwap::from_pointee(config.clone())),
|
||||||
|
config_base: Arc::new(config),
|
||||||
db,
|
db,
|
||||||
sessions,
|
sessions,
|
||||||
chains,
|
chains,
|
||||||
@@ -258,7 +259,7 @@ impl TestApp {
|
|||||||
.expect("mint token pair");
|
.expect("mint token pair");
|
||||||
let hash = hash_refresh_token(
|
let hash = hash_refresh_token(
|
||||||
&pair.refresh_token,
|
&pair.refresh_token,
|
||||||
&self.state.config.secrets.refresh_token_pepper,
|
&self.state.cfg().secrets.refresh_token_pepper,
|
||||||
);
|
);
|
||||||
self.state
|
self.state
|
||||||
.db
|
.db
|
||||||
|
|||||||
+2
-2
@@ -89,7 +89,7 @@ async fn erasure_request_confirm_execute_flow() {
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
@@ -221,7 +221,7 @@ async fn erasure_request_confirm_execute_flow() {
|
|||||||
assert!(body["erasure_id"].as_str().is_some());
|
assert!(body["erasure_id"].as_str().is_some());
|
||||||
assert_eq!(body["status"], "pending");
|
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:
|
// [HIGH regression] a request CANCELLED after confirmation is NOT executed:
|
||||||
// execute claims `WHERE status='confirmed'`, so a cancelled row is skipped.
|
// execute claims `WHERE status='confirmed'`, so a cancelled row is skipped.
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ async fn link_challenge_then_link_resolves_and_rejects_replay_and_wrong_user() {
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async fn register_creates_user_by_npub_and_is_idempotent() {
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
@@ -120,7 +120,7 @@ async fn register_replays_are_rejected() {
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
@@ -160,7 +160,7 @@ async fn register_refuses_to_bind_npub_onto_existing_seed_fingerprint_row_withou
|
|||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
let base = app
|
let base = app
|
||||||
.state
|
.state
|
||||||
.config
|
.cfg()
|
||||||
.identity
|
.identity
|
||||||
.public_api_url
|
.public_api_url
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async fn is_registered_npub_membership() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admission_inbox_outbox_end_to_end() {
|
async fn admission_inbox_outbox_end_to_end() {
|
||||||
let app = require_app!();
|
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 author = hex64("b1"); // registered author (outbox)
|
||||||
let recipient = hex64("b2"); // registered recipient (inbox)
|
let recipient = hex64("b2"); // registered recipient (inbox)
|
||||||
|
|||||||
Reference in New Issue
Block a user