diff --git a/openapi.json b/openapi.json index cb43771..07b4db6 100644 --- a/openapi.json +++ b/openapi.json @@ -348,6 +348,27 @@ } } }, + "/capabilities": { + "get": { + "tags": [ + "system" + ], + "summary": "Describe this instance's enabled chains and features.", + "operationId": "capabilities", + "responses": { + "200": { + "description": "Enabled chains and features", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CapabilitiesResponse" + } + } + } + } + } + } + }, "/health": { "get": { "tags": [ @@ -1670,6 +1691,71 @@ } } }, + "CapabilitiesResponse": { + "type": "object", + "required": [ + "version", + "chains", + "features" + ], + "properties": { + "chains": { + "$ref": "#/components/schemas/ChainCapabilities" + }, + "features": { + "$ref": "#/components/schemas/FeatureCapabilities" + }, + "version": { + "type": "string", + "description": "Backend version (Cargo package version)." + } + } + }, + "ChainCapabilities": { + "type": "object", + "required": [ + "btc", + "ltc", + "xmr", + "wow", + "grin" + ], + "properties": { + "btc": { + "$ref": "#/components/schemas/ChainCapability" + }, + "grin": { + "$ref": "#/components/schemas/ChainCapability" + }, + "ltc": { + "$ref": "#/components/schemas/ChainCapability" + }, + "wow": { + "$ref": "#/components/schemas/ChainCapability" + }, + "xmr": { + "$ref": "#/components/schemas/ChainCapability" + } + } + }, + "ChainCapability": { + "type": "object", + "description": "Per-chain availability. `network` is the configured network for UTXO chains\n(so the wallet derives addresses for the right one); `null` for chains whose\nnetwork isn't a backend setting.", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "network": { + "type": [ + "string", + "null" + ] + } + } + }, "CheckRestoreRequest": { "type": "object", "description": "Ask whether a wallet (by seed fingerprint) was created on this backend, and\nwhether the submitted keys match.", @@ -1868,6 +1954,33 @@ } } }, + "FeatureCapabilities": { + "type": "object", + "required": [ + "grin_relay", + "prices", + "nostr_identity", + "tips" + ], + "properties": { + "grin_relay": { + "type": "boolean", + "description": "Grin async slatepack relay mailbox." + }, + "nostr_identity": { + "type": "boolean", + "description": "Nostr-native identity (NIP-98 login/link, NIP-05 directory)." + }, + "prices": { + "type": "boolean", + "description": "Fiat price feed." + }, + "tips": { + "type": "boolean", + "description": "Tipping (parked)." + } + } + }, "FeeRequest": { "type": "object", "description": "A fee-estimate request.", diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs new file mode 100644 index 0000000..8fcd1f7 --- /dev/null +++ b/src/api/capabilities.rs @@ -0,0 +1,102 @@ +//! 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::AppState; + +/// 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, +} + +#[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, + /// Fiat price feed. + pub prices: bool, + /// Nostr-native identity (NIP-98 login/link, NIP-05 directory). + pub nostr_identity: bool, + /// Tipping (parked). + pub tips: bool, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct CapabilitiesResponse { + /// Backend version (Cargo package version). + pub version: String, + pub chains: ChainCapabilities, + pub features: FeatureCapabilities, +} + +/// 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>) -> Json { + let cfg = &state.config; + let chains = &cfg.features.chains; + Json(CapabilitiesResponse { + version: env!("CARGO_PKG_VERSION").to_string(), + chains: ChainCapabilities { + btc: ChainCapability { + enabled: chains.btc, + network: Some(cfg.chains.btc.network.clone()), + }, + ltc: ChainCapability { + enabled: chains.ltc, + network: Some(cfg.chains.ltc.network.clone()), + }, + xmr: ChainCapability { + enabled: chains.xmr, + network: None, + }, + wow: ChainCapability { + enabled: chains.wow, + network: None, + }, + grin: ChainCapability { + enabled: chains.grin, + network: None, + }, + }, + features: FeatureCapabilities { + grin_relay: cfg.features.grin_relay, + prices: cfg.features.prices, + nostr_identity: cfg.features.nostr_identity, + tips: cfg.features.tips, + }, + }) +} + +/// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth). +pub fn routes() -> Router> { + Router::new().route("/capabilities", get(capabilities)) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 69e434f..0011409 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,7 @@ //! HTTP API layer: handlers and the OpenAPI assembly. pub mod auth; +pub mod capabilities; pub mod health; pub mod middleware; pub mod nip05; diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 020861c..9ec438a 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -16,6 +16,7 @@ use utoipa::OpenApi; ), paths( crate::api::health::health, + crate::api::capabilities::capabilities, crate::api::auth::pow_challenge, crate::api::auth::extension_register, crate::api::auth::check_restore, @@ -59,6 +60,10 @@ use utoipa::OpenApi; ), components(schemas( crate::api::health::HealthResponse, + crate::api::capabilities::CapabilitiesResponse, + crate::api::capabilities::ChainCapabilities, + crate::api::capabilities::ChainCapability, + crate::api::capabilities::FeatureCapabilities, crate::api::auth::AuthResponse, crate::api::auth::UserInfo, crate::api::auth::AssetPublicKey, diff --git a/src/lib.rs b/src/lib.rs index 01ec89b..e5bb349 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -95,6 +95,7 @@ pub fn build_router(state: Arc) -> Router { .merge( api::users::routes() .merge(api::wallet::routes()) + .merge(api::capabilities::routes()) .layer(normal), ); diff --git a/tests/capabilities.rs b/tests/capabilities.rs new file mode 100644 index 0000000..fe8ccd7 --- /dev/null +++ b/tests/capabilities.rs @@ -0,0 +1,32 @@ +//! L1 integration: the public capabilities endpoint. + +mod common; + +use axum::http::StatusCode; + +#[tokio::test] +async fn capabilities_is_public_and_well_shaped() { + let app = require_app!(); + // No token required — the wallet reads this before/without login. + let (status, body) = app.request("GET", "/api/v1/capabilities", None, None).await; + assert_eq!(status, StatusCode::OK); + + assert!(body["version"].as_str().is_some(), "version present"); + // Every chain reports an `enabled` boolean. + for chain in ["btc", "ltc", "xmr", "wow", "grin"] { + assert!( + body["chains"][chain]["enabled"].is_boolean(), + "chain {chain} reports enabled" + ); + } + // UTXO chains carry a network; CryptoNote/Grin don't. + assert!(body["chains"]["btc"]["network"].as_str().is_some()); + assert!(body["chains"]["xmr"]["network"].is_null()); + // Feature flags are present. + for feat in ["grin_relay", "prices", "nostr_identity", "tips"] { + assert!( + body["features"][feat].is_boolean(), + "feature {feat} reports a boolean" + ); + } +}