feat: GET /api/v1/capabilities (client feature discovery)

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, and 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 public network names.

Response: { version, chains: { btc/ltc/xmr/wow/grin: { enabled, network } },
features: { grin_relay, prices, nostr_identity, tips } }. UTXO chains carry
their configured network; CryptoNote/Grin report null. On the normal rate-limit
tier. Registered in openapi (system tag); 1 L1 test (public, well-shaped).
This commit is contained in:
jwinterm
2026-06-25 16:27:13 -04:00
parent c558e9d7bd
commit 1e2bdf3f16
6 changed files with 254 additions and 0 deletions
+113
View File
@@ -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.",
+102
View File
@@ -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<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,
/// 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<Arc<AppState>>) -> Json<CapabilitiesResponse> {
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<Arc<AppState>> {
Router::new().route("/capabilities", get(capabilities))
}
+1
View File
@@ -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;
+5
View File
@@ -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,
+1
View File
@@ -95,6 +95,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.merge(
api::users::routes()
.merge(api::wallet::routes())
.merge(api::capabilities::routes())
.layer(normal),
);
+32
View File
@@ -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"
);
}
}