//! smirk-backend-core //! //! Open, self-hostable backend for the Smirk non-custodial multi-chain wallet: //! authentication, per-chain wallet access (Bitcoin, Litecoin, Monero, Wownero, //! Grin), the Grin slatepack relay, and Nostr-based identity. //! //! The HTTP contract is generated from the handlers (`utoipa`) into //! `openapi.json`, which is the single source of truth for the API and the //! wallet's generated TypeScript client. pub mod api; pub mod config; pub mod config_overlay; pub mod core; pub mod error; pub mod infra; pub mod models; pub mod tips; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use axum::extract::DefaultBodyLimit; use axum::http::{header, HeaderValue, Method}; use axum::{routing::get, Router}; use tokio::sync::RwLock; use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; use tower_http::compression::CompressionLayer; use tower_http::cors::{Any, CorsLayer}; use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; use crate::config::Config; use crate::core::admin_session::AdminSessionManager; use crate::core::session::{SessionManager, WebChallenge}; use crate::infra::chains::ChainClients; use crate::infra::db::Database; use crate::infra::prices::PriceSnapshot; /// Global request-body cap (backstop; handlers validate tighter per field). const MAX_BODY_BYTES: usize = 1024 * 1024; /// Global request deadline (backstop above the chain clients' own ~30s timeouts, /// so their errors surface first for chain calls; bounds any slow handler). const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); // Per-IP rate-limit tiers (one token replenished per period; `burst` = bucket). // STRICT throttles the unauthenticated, abuse-prone surface (auth + website); // NORMAL covers the authenticated wallet/identity API. Sane defaults; operator- // tunable config can follow. const STRICT_PERIOD_MS: u64 = 500; // ~2 req/s sustained const STRICT_BURST: u32 = 20; const NORMAL_PERIOD_MS: u64 = 100; // ~10 req/s sustained const NORMAL_BURST: u32 = 60; /// Shared application state injected into handlers via `State>`. #[derive(Clone)] pub struct AppState { /// Effective config = env-derived base + validated DB overlay. Hot-swappable: /// a runtime-safe operator edit stores a new `Arc` here (lock-free). /// Read it via [`AppState::cfg`] (`state.cfg().field`), never a stale copy. pub config: Arc>, /// Immutable env-only config (no overlay). Used to recompute the effective /// config on a settings change and to report a field's source (default/env/db). pub config_base: Arc, pub db: Database, pub sessions: SessionManager, /// Per-chain data-source clients (present only for enabled chains). pub chains: ChainClients, /// Payment processor backing the optional pay-to-register gate; `Some` only /// when `REGISTRATION_REQUIRE_PAYMENT` is on. pub payment: Option>, /// Optional first-party Nostr relay (messaging plane); `Some` only when /// `RELAY_ENABLED` is on. Advertised via `/capabilities` + NIP-05 hints. pub relay: Option>, /// In-memory website-auth challenges, keyed by nonce. Single-node store; /// a shared/stateless variant is the load-balanced-fleet path. pub web_challenges: Arc>>, /// Latest fetched fiat prices, refreshed by a background task when the price /// feed is enabled. Stays empty (and `/prices` 404s) otherwise. pub prices: Arc>, /// Admin token minter/verifier. `Some` only when the admin surface is /// enabled; the admin plane and guard refuse all requests otherwise. pub admin_sessions: Option, /// Signalled to trigger a graceful shutdown from INSIDE the app — e.g. an operator /// config change in `auto` restart mode. `main` awaits this alongside SIGTERM; /// systemd `Restart=always` brings the instance back with the new config applied. pub shutdown: Arc, } impl AppState { /// Load the current effective [`Config`] (lock-free, wait-free). The returned /// guard derefs to `&Config`, so `state.cfg().field` reads the live value and /// `&state.cfg()` passes to a `fn(&Config)` helper. Every config read goes /// through here so a runtime-safe operator edit is seen on the next request. #[inline] pub fn cfg(&self) -> arc_swap::Guard> { self.config.load() } } /// Assemble the full application router with every route mounted and state /// applied. Shared by `main` and the integration-test harness so both exercise /// the exact same wiring: `/health` and the NIP-05 directory at the root, the /// authenticated wallet/identity API nested under `/api/v1`. pub fn build_router(state: Arc) -> Router { let cors = cors_layer(&state.cfg()); // Per-IP rate limiting (PeerIpKeyExtractor reads ConnectInfo — `main` serves // with `into_make_service_with_connect_info`; the test harness injects it). // Tiered: stricter on the unauthenticated auth/website surface (closes the // website-challenge growth vector), looser on the wallet/identity API. let mut strict_cfg = GovernorConfigBuilder::default(); strict_cfg .per_millisecond(STRICT_PERIOD_MS) .burst_size(STRICT_BURST); let strict = GovernorLayer { config: Arc::new(strict_cfg.finish().expect("valid strict governor config")), }; let mut normal_cfg = GovernorConfigBuilder::default(); normal_cfg .per_millisecond(NORMAL_PERIOD_MS) .burst_size(NORMAL_BURST); let normal = GovernorLayer { config: Arc::new(normal_cfg.finish().expect("valid normal governor config")), }; let api_v1 = api::auth::routes() .merge(api::website::routes()) .layer(strict) .merge( api::users::routes() .merge(api::wallet::routes()) .merge(api::capabilities::routes()) .merge(api::prices::routes()) .merge(api::landing::routes()) .merge(api::erasure::routes()) .merge(api::premium::routes()) .merge(api::tips::routes()) .layer(normal), ); // The NIP-05 directory (+ health / landing) is PUBLIC, read-only, unauth'd // data that MUST be cross-origin fetchable for federation to work — any Nostr // client on any domain resolves `user@this-instance` by fetching the // well-known. So it ALWAYS serves `Access-Control-Allow-Origin: *`, regardless // of the operator's API CORS restriction (`CORS_ALLOWED_ORIGINS`). We apply the // two policies to two route groups rather than one outer layer, so a restrictive // API policy can't silently break NIP-05 resolution. let public_cors = CorsLayer::new() .allow_methods([Method::GET]) .allow_origin(Any); let public = Router::new() .route("/health", get(api::health::health)) .route("/", get(api::landing::root)) .merge(api::nip05::routes()) .layer(public_cors); Router::new() .merge(public) .nest("/api/v1", api_v1.layer(cors)) .with_state(state) // Layers apply outside-in (last added = outermost). Body cap + timeout // sit closest to the handlers; compression wraps them; tracing is // outermost so it observes every request (incl. rejected ones). CORS is // applied PER GROUP above (public → `*`, API → operator policy). // Per-IP rate limiting is layered separately (it needs ConnectInfo). .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .layer(TimeoutLayer::new(REQUEST_TIMEOUT)) .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) } /// Assemble the admin-plane router. Served on a SEPARATE loopback socket (see /// `main`), never merged into the public router: confidentiality is by socket, /// not middleware ordering. Deliberately carries NO CORS layer (a browser's /// cross-origin call to the loopback port gets no `Access-Control-Allow-Origin` /// and is blocked); per-route `admin_guard` is the load-bearing control. /// Subsystem 2 adds Host-allowlist + anti-clickjacking hardening on top. pub fn admin_router(state: Arc) -> Router { Router::new() .merge(api::admin::routes()) // Host allowlist (anti DNS-rebinding) + anti-clickjacking headers. .layer(axum::middleware::from_fn_with_state( state.clone(), api::admin::admin_plane_guard, )) .with_state(state) .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .layer(TimeoutLayer::new(REQUEST_TIMEOUT)) .layer(TraceLayer::new_for_http()) } /// Build the CORS layer. With no configured origins, allow any origin — safe /// because authentication is a Bearer token (no cookies), so a cross-origin /// request carries no ambient credentials. Credentials are never allowed. fn cors_layer(config: &Config) -> CorsLayer { let methods = [Method::GET, Method::POST, Method::OPTIONS]; let headers = [header::AUTHORIZATION, header::CONTENT_TYPE]; let base = CorsLayer::new() .allow_methods(methods) .allow_headers(headers); if config.cors_allowed_origins.is_empty() { base.allow_origin(Any) } else { let origins: Vec = config .cors_allowed_origins .iter() .filter_map(|o| o.parse().ok()) .collect(); base.allow_origin(origins) } }