feat(capabilities): honest secret-presence downgrade + admin features view [operator §4]

Make /capabilities fail-closed and add the admin features surface.

- Secret-presence downgrade (§4.2): a chain whose flag is on but whose source is
  unconfigured (btc/ltc no electrum URL, xmr/wow no LWS admin key, grin no owner
  secret) now reports enabled:false — so /capabilities never advertises a chain
  that would 404, and a missing-secret downgrade is indistinguishable from a
  deliberately-off chain. grin_relay requires serviceable Grin; nostr_identity
  requires PUBLIC_API_URL. effective_capabilities() is the single explicit mapper.
- contract_version stamp on the public document (clients soft-notice a higher
  value, ignore unknown additive keys).
- GET /admin/features (admin_guard): the resolved effective state PLUS admin-only
  downgrade reasons (the public surface hides the reason, per §4.3).
- Golden test: the public capabilities JSON, built from a fully-secret-seeded
  config, contains no secret value (asserts no "integration-test-" substring).

openapi regenerated (contract_version). Admin features view stays off the public
OpenAPI.

Deferred (documented): runtime mutation — PUT /admin/features + a DB feature-
override table + resolve() precedence + Arc hot-swap + CLI `features set`. Flags
are env/config-driven for v1; runtime tuning (and its fleet propagation, itself a
deferred LB decision) is a later addition.
This commit is contained in:
jwinterm
2026-06-27 15:35:56 -04:00
parent 51a3371be1
commit 2d0077b2b9
4 changed files with 174 additions and 33 deletions
+7
View File
@@ -1719,6 +1719,7 @@
"type": "object",
"required": [
"version",
"contract_version",
"chains",
"features"
],
@@ -1726,6 +1727,12 @@
"chains": {
"$ref": "#/components/schemas/ChainCapabilities"
},
"contract_version": {
"type": "integer",
"format": "int32",
"description": "Capabilities contract version (additive changes do not bump it).",
"minimum": 0
},
"features": {
"$ref": "#/components/schemas/FeatureCapabilities"
},
+88
View File
@@ -651,6 +651,93 @@ pub async fn admin_keys_rotate(
}))
}
// ── features (read-only view) ────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct Downgrade {
pub feature: String,
pub reason: String,
}
#[derive(Debug, Serialize)]
pub struct AdminFeaturesResponse {
pub effective: crate::api::capabilities::CapabilitiesResponse,
/// Flags that are ON in config but serve as disabled (missing secret/URL).
/// Exposed only to the admin — the public capabilities hides the reason.
pub downgrades: Vec<Downgrade>,
}
/// Admin-only view of the resolved feature state + why anything is downgraded.
/// (Runtime mutation — PUT /admin/features + DB override — is deferred; flags are
/// env/config-driven for now.)
#[instrument(skip(state, headers))]
pub async fn admin_features(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<AdminFeaturesResponse>, AppError> {
admin_guard(&state, &headers).await?;
let cfg = &state.config;
let effective = crate::api::capabilities::effective_capabilities(cfg);
let mut downgrades = Vec::new();
let mut note = |on: bool, eff: bool, feature: &str, reason: &str| {
if on && !eff {
downgrades.push(Downgrade {
feature: feature.into(),
reason: reason.into(),
});
}
};
let c = &cfg.features.chains;
note(
c.btc,
effective.chains.btc.enabled,
"btc",
"BTC_ELECTRUM_URL/fallbacks unset",
);
note(
c.ltc,
effective.chains.ltc.enabled,
"ltc",
"LTC_ELECTRUM_URL/fallbacks unset",
);
note(
c.xmr,
effective.chains.xmr.enabled,
"xmr",
"XMR_LWS_ADMIN_KEY unset",
);
note(
c.wow,
effective.chains.wow.enabled,
"wow",
"WOW_LWS_ADMIN_KEY unset",
);
note(
c.grin,
effective.chains.grin.enabled,
"grin",
"GRIN_OWNER_API_SECRET unset",
);
note(
cfg.features.grin_relay,
effective.features.grin_relay,
"grin_relay",
"Grin chain not serviceable",
);
note(
cfg.features.nostr_identity,
effective.features.nostr_identity,
"nostr_identity",
"PUBLIC_API_URL unset",
);
Ok(Json(AdminFeaturesResponse {
effective,
downgrades,
}))
}
// ── router ───────────────────────────────────────────────────────────────────
/// Admin-plane routes. Mounted by [`crate::admin_router`] on the loopback socket.
@@ -661,6 +748,7 @@ pub fn routes() -> Router<Arc<AppState>> {
.route("/admin/auth/refresh", post(admin_refresh))
.route("/admin/auth/logout", post(admin_logout))
.route("/admin/me", get(admin_me))
.route("/admin/features", get(admin_features))
.route("/admin/keys", post(admin_keys_add).get(admin_keys_list))
.route("/admin/keys/:id", delete(admin_keys_revoke))
.route("/admin/keys/:id/rotate", post(admin_keys_rotate))
+66 -33
View File
@@ -12,8 +12,13 @@ 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.
@@ -48,10 +53,70 @@ pub struct FeatureCapabilities {
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,
}
/// 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.
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,
}
}
/// 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 needs Grin chain access to be serviceable.
grin_relay: config.features.grin_relay && chain_serviceable(config, "grin"),
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(),
tips: config.features.tips,
},
}
}
/// Describe this instance's enabled chains and features.
#[utoipa::path(
get,
@@ -61,39 +126,7 @@ pub struct CapabilitiesResponse {
)]
#[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,
},
})
Json(effective_capabilities(&state.config))
}
/// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth).
+13
View File
@@ -12,6 +12,19 @@ async fn capabilities_is_public_and_well_shaped() {
assert_eq!(status, StatusCode::OK);
assert!(body["version"].as_str().is_some(), "version present");
assert!(
body["contract_version"].as_u64().is_some(),
"contract_version present"
);
// Golden: no server secret may appear in the public capabilities JSON. The
// harness seeds every secret with the "integration-test-" prefix.
let raw = serde_json::to_string(&body).unwrap();
assert!(
!raw.contains("integration-test"),
"no secret leaks into public capabilities: {raw}"
);
// Every chain reports an `enabled` boolean.
for chain in ["btc", "ltc", "xmr", "wow", "grin"] {
assert!(