Files
smirk-backend-core/src/api/capabilities.rs
T
jwinterm 4c83e53839 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.
2026-07-18 06:00:16 -04:00

364 lines
16 KiB
Rust

//! Capability discovery.
//!
//! A public, unauthenticated description of what *this* deployment offers, so the
//! wallet can adapt to the instance it's pointed at (grey out disabled chains and
//! features, pick the right network). This is the client-facing half of the
//! open-core/feature-flag design: every capability is a config switch, surfaced
//! here. No secrets — only on/off flags and the public network names.
use std::sync::Arc;
use axum::{extract::State, routing::get, Json, Router};
use serde::Serialize;
use tracing::instrument;
use crate::config::Config;
use crate::AppState;
/// Capabilities contract version. Bumped only on a breaking shape change; clients
/// soft-notice a higher value and ignore unknown (additive) keys.
pub const CAPABILITIES_CONTRACT_VERSION: u32 = 1;
/// Per-chain availability. `network` is the configured network for UTXO chains
/// (so the wallet derives addresses for the right one); `null` for chains whose
/// network isn't a backend setting.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ChainCapability {
pub enabled: bool,
pub network: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ChainCapabilities {
pub btc: ChainCapability,
pub ltc: ChainCapability,
pub xmr: ChainCapability,
pub wow: ChainCapability,
pub grin: ChainCapability,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct FeatureCapabilities {
/// Grin async slatepack relay mailbox.
pub grin_relay: bool,
/// Grin light-wallet-server (grin-lws) scan path is available — fast balance
/// scans via grin-lws, with the authoritative grin-wallet scan as fallback.
pub grin_lws: bool,
/// Fiat price feed.
pub prices: bool,
/// Nostr-native identity (NIP-98 login/link, NIP-05 directory).
pub nostr_identity: bool,
/// npub-native registration: a wallet can register + authenticate from its
/// seed-derived Nostr key alone (POST /auth/nostr/register), no BTC signature.
/// The wallet uses this to choose the NIP-98 bootstrap over the legacy BTC one.
pub nostr_native_auth: bool,
/// First-party Nostr relay (encrypted DM inbox). See `messaging` for details.
pub nostr_relay: bool,
/// Paid premium tier for general Nostr posting to the relay. See `premium`.
pub premium_relay: bool,
/// Public curated Nostr feed for this instance. See `feed`.
pub feed: bool,
/// Tipping (parked).
pub tips: bool,
}
/// First-party Nostr relay details (present only when `features.nostr_relay`).
/// The wallet connects here as its DM inbox (alongside the public interop
/// relays) and adapts its UI to the policy.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct MessagingCapability {
/// The ws(s):// relay URL clients connect to.
pub relay_url: String,
/// Write policy: `inbox-outbox` | `author-allowlist` | `open` | `premium-post`.
pub write_policy: String,
/// NIP-13 PoW bits required on cross-ecosystem inbound (0 = off).
pub inbound_pow_bits: u8,
/// NIPs the relay speaks (e.g. `[1, 17, 44, 59]`).
pub supported_nips: Vec<u16>,
}
/// A single premium plan (discount tier): `days` of relay-posting access for
/// `amount` in [`PremiumCapability::currency`].
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct PremiumPlanInfo {
pub id: String,
pub days: i32,
pub amount: String,
}
/// Premium tier details (present only when `features.premium_relay`). The wallet
/// renders the plan tiers (discount visible) and gates general-Nostr posting to
/// the Smirk relay on the user's premium status; wallet events stay free.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct PremiumCapability {
pub currency: String,
pub plans: Vec<PremiumPlanInfo>,
/// The relay premium posting targets (mirrors `messaging.relay_url`).
pub relay_url: String,
}
/// Public curated feed details (present only when `features.feed`). A read-only
/// web feed (e.g. `feed.<domain>`) reads from the relay and shows posts per the
/// operator's curation knobs.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct FeedCapability {
/// The relay the feed reads from (mirrors `messaging.relay_url`).
pub relay_url: String,
/// Include the operator's own posts.
pub show_owner: bool,
/// Include premium members' posts (all general notes on the gated relay).
pub show_premium: bool,
/// The operator's npub for owner filtering + display; `null` when unset.
pub owner_npub: Option<String>,
/// Specific featured npubs to surface.
pub allowlist_npubs: Vec<String>,
/// Extra relays to also pull the allowlisted authors from.
pub extra_relays: Vec<String>,
}
/// This instance's wallet-restore (import) policy. The wallet uses it to adapt
/// its import UX — hide/grey the restore-height field under `create-only`, warn
/// when a chosen date exceeds the bound. `max_depth_days` is present only for
/// the `bounded` policy.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RestoreCapability {
/// `create-only` | `bounded` | `unlimited`.
pub policy: String,
pub max_depth_days: Option<u32>,
/// Restore PoW pricing curve: a restore depth (days) free of PoW, then `+1`
/// hashcash difficulty bit per `pow_days_per_bit` days beyond it
/// (`0` = pricing off), capped at `pow_max_bits`. The wallet computes its
/// required difficulty from this + the restore date and solves the hashcash.
pub pow_free_days: u32,
pub pow_days_per_bit: u32,
pub pow_max_bits: u32,
}
/// Registration gates this instance enforces for a NEW wallet (returning wallets
/// and self-hosting bypass them). The wallet uses these to shape onboarding —
/// prompt for an invite code, solve PoW, etc.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RegistrationCapability {
/// A valid operator-minted invite code is required to register.
pub invite_required: bool,
/// A proof-of-work solution is required to register.
pub pow_required: bool,
/// A settled payment invoice (from `/auth/payment-invoice`) is required to
/// register a new wallet.
pub payment_required: bool,
/// The registration price + currency — present only when `payment_required`,
/// so the wallet can show "registration costs X" before minting an invoice.
pub payment_amount: Option<String>,
pub payment_currency: Option<String>,
/// How the enabled gates combine: `"all"` (satisfy every gate; the default)
/// or `"any"` (the gates are alternatives; satisfy one). PoW is orthogonal
/// and applies regardless. The wallet routes onboarding on this: multiple
/// gates + `"any"` => "pick a method" buttons; `"all"` => satisfy each.
pub registration_mode: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct CapabilitiesResponse {
/// Backend version (Cargo package version).
pub version: String,
/// Capabilities contract version (additive changes do not bump it).
pub contract_version: u32,
pub chains: ChainCapabilities,
pub features: FeatureCapabilities,
/// Wallet restore (import) policy for this instance.
pub restore: RestoreCapability,
/// Registration gates for a new wallet on this instance.
pub registration: RegistrationCapability,
/// First-party Nostr relay details; present only when `features.nostr_relay`.
#[serde(skip_serializing_if = "Option::is_none")]
pub messaging: Option<MessagingCapability>,
/// Premium tier details; present only when `features.premium_relay`.
#[serde(skip_serializing_if = "Option::is_none")]
pub premium: Option<PremiumCapability>,
/// Public curated feed details; present only when `features.feed`.
#[serde(skip_serializing_if = "Option::is_none")]
pub feed: Option<FeedCapability>,
}
/// Whether an enabled chain can actually be served (its infra secret/URL is
/// present). A chain whose flag is on but whose source is unconfigured reports
/// `enabled:false` — so `/capabilities` never advertises a chain that 404s, and
/// a missing-secret downgrade is indistinguishable from a deliberately-off chain.
pub(crate) fn chain_serviceable(config: &Config, asset: &str) -> bool {
let f = &config.features.chains;
let c = &config.chains;
match asset {
"btc" => {
f.btc && (c.btc.electrum_primary.is_some() || !c.btc.electrum_fallbacks.is_empty())
}
"ltc" => {
f.ltc && (c.ltc.electrum_primary.is_some() || !c.ltc.electrum_fallbacks.is_empty())
}
"xmr" => f.xmr && !c.xmr.lws_admin_key.is_empty(),
"wow" => f.wow && !c.wow.lws_admin_key.is_empty(),
"grin" => f.grin && !c.grin.owner_api_secret.is_empty(),
_ => false,
}
}
/// Whether the grin-lws scan path is serviceable: the Grin chain is serviceable
/// AND a grin-lws URL is configured. grin-lws is an add-on to the Grin chain, so
/// it can never be advertised for an unserviceable Grin.
pub(crate) fn grin_lws_serviceable(config: &Config) -> bool {
chain_serviceable(config, "grin") && !config.chains.grin_lws.url.trim().is_empty()
}
/// Build the public capabilities projection with the secret-presence downgrade.
pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
let utxo_net = |on: bool, net: &str| ChainCapability {
enabled: on,
network: Some(net.to_string()),
};
CapabilitiesResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
contract_version: CAPABILITIES_CONTRACT_VERSION,
chains: ChainCapabilities {
btc: utxo_net(chain_serviceable(config, "btc"), &config.chains.btc.network),
ltc: utxo_net(chain_serviceable(config, "ltc"), &config.chains.ltc.network),
xmr: ChainCapability {
enabled: chain_serviceable(config, "xmr"),
network: None,
},
wow: ChainCapability {
enabled: chain_serviceable(config, "wow"),
network: None,
},
grin: ChainCapability {
enabled: chain_serviceable(config, "grin"),
network: None,
},
},
features: FeatureCapabilities {
// The relay is a non-custodial mailbox (the wallet broadcasts
// locally), so it is NOT coupled to this backend's Grin chain access.
grin_relay: config.features.grin_relay,
grin_lws: grin_lws_serviceable(config),
prices: config.features.prices,
// Nostr identity needs the canonical PUBLIC_API_URL.
nostr_identity: config.features.nostr_identity
&& config.identity.public_api_url.is_some(),
// npub-native register shares the same NIP-98 infra requirement.
nostr_native_auth: config.features.nostr_identity
&& config.identity.public_api_url.is_some(),
// Relay advertised only when enabled AND a URL is configured (config
// presence downgrade — never advertise a relay clients can't reach).
nostr_relay: relay_advertised(config),
premium_relay: premium_advertised(config),
feed: feed_advertised(config),
tips: tips_advertised(config),
},
restore: RestoreCapability {
policy: config.restore.policy.as_str().to_string(),
max_depth_days: match config.restore.policy {
crate::config::RestorePolicy::Bounded => Some(config.restore.max_depth_days),
_ => None,
},
pow_free_days: config.restore.pow_free_days,
pow_days_per_bit: config.restore.pow_days_per_bit,
pow_max_bits: config.restore.pow_max_bits,
},
registration: RegistrationCapability {
invite_required: config.registration.require_invite,
pow_required: config.pow.enabled && config.pow.required,
payment_required: config.registration.payment.require_payment,
payment_amount: config
.registration
.payment
.require_payment
.then(|| config.registration.payment.amount.clone()),
payment_currency: config
.registration
.payment
.require_payment
.then(|| config.registration.payment.currency.clone()),
registration_mode: config.registration.gate_mode.as_str().to_string(),
},
messaging: relay_advertised(config).then(|| {
let r = &config.messaging.relay;
MessagingCapability {
relay_url: r.advertised_url.clone(),
write_policy: r.write_policy.clone(),
inbound_pow_bits: r.inbound_pow_bits,
supported_nips: crate::infra::relay::SUPPORTED_NIPS.to_vec(),
}
}),
premium: premium_advertised(config).then(|| PremiumCapability {
currency: config.premium.currency.clone(),
plans: config
.premium
.plans
.iter()
.map(|p| PremiumPlanInfo {
id: p.id.clone(),
days: p.days,
amount: p.amount.clone(),
})
.collect(),
relay_url: config.messaging.relay.advertised_url.clone(),
}),
feed: feed_advertised(config).then(|| {
let f = &config.feed;
FeedCapability {
relay_url: config.messaging.relay.advertised_url.clone(),
show_owner: f.show_owner,
show_premium: f.show_premium,
owner_npub: (!f.owner_npub.is_empty()).then(|| f.owner_npub.clone()),
allowlist_npubs: f.allowlist_npubs.clone(),
extra_relays: f.extra_relays.clone(),
}
}),
}
}
/// Whether the relay is enabled AND reachable (a URL is configured). Mirrors the
/// chain/nostr-identity secret-presence downgrade: never advertise a relay the
/// client can't connect to.
fn relay_advertised(config: &Config) -> bool {
config.messaging.relay.enabled && !config.messaging.relay.advertised_url.trim().is_empty()
}
/// Whether the premium tier is enabled AND the relay it gates is advertised
/// (config validation already couples premium to relay + the premium-post policy).
fn premium_advertised(config: &Config) -> bool {
config.premium.enabled && relay_advertised(config)
}
/// Whether the public feed is enabled AND the relay it reads is advertised.
fn feed_advertised(config: &Config) -> bool {
config.feed.enabled && relay_advertised(config)
}
/// Whether public tips are actually serveable: the feature is on, a share-URL
/// base is configured, AND at least one supported tip chain (btc/ltc/xmr/wow/grin)
/// is serviceable. Mirrors the chain/relay presence-downgrade — never advertise
/// tips an instance can't escrow on or build share links for.
fn tips_advertised(config: &Config) -> bool {
config.features.tips
&& config.tip_share_base.is_some()
&& ["btc", "ltc", "xmr", "wow", "grin"]
.iter()
.any(|asset| chain_serviceable(config, asset))
}
/// Describe this instance's enabled chains and features.
#[utoipa::path(
get,
path = "/capabilities",
responses((status = 200, description = "Enabled chains and features", body = CapabilitiesResponse)),
tag = "system"
)]
#[instrument(skip(state))]
pub async fn capabilities(State(state): State<Arc<AppState>>) -> Json<CapabilitiesResponse> {
Json(effective_capabilities(&state.cfg()))
}
/// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth).
pub fn routes() -> Router<Arc<AppState>> {
Router::new().route("/capabilities", get(capabilities))
}