use anyhow::{anyhow, bail, Context, Result};
use serde_json::{json, Value};
const AUTH_TIMEOUT_SECS: u64 = 30;
pub struct AgentHttpClient {
base_url: String,
agent: ureq::Agent,
}
pub struct OnboardingChallenge {
pub challenge_id: String,
pub challenge: String,
}
pub struct AuthSession {
pub token: String,
pub refresh_token: String,
}
impl AgentHttpClient {
pub fn new(base_url: impl Into<String>) -> Self {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(AUTH_TIMEOUT_SECS))
.build();
Self {
base_url: base_url.into(),
agent,
}
}
pub fn is_unowned(&self) -> Result<bool> {
let response = self
.agent
.get(&format!("{}/api/auth/onboarding-status", self.base_url))
.call();
match response {
Ok(r) => {
let value: Value = r.into_json().context("parse onboarding-status JSON")?;
let data = extract_data(value);
let onboarded = data
.get("is_onboarded")
.and_then(Value::as_bool)
.unwrap_or(false);
Ok(!onboarded)
}
Err(ureq::Error::Status(_, _)) => Ok(false),
Err(e) => Err(anyhow!("onboarding-status request failed: {e}")),
}
}
pub fn create_onboarding_challenge(&self) -> Result<OnboardingChallenge> {
let value: Value = post_json(
&self.agent,
&format!("{}/api/auth/onboarding-challenge", self.base_url),
None,
&json!({}),
)?;
let data = extract_data(value);
Ok(OnboardingChallenge {
challenge_id: string_field(&data, "challenge_id")?,
challenge: string_field(&data, "challenge")?,
})
}
pub fn complete_onboarding(
&self,
public_key_hex: &str,
challenge_id: &str,
signature_hex: &str,
username: &str,
) -> Result<AuthSession> {
let value: Value = post_json(
&self.agent,
&format!("{}/api/auth/complete-onboarding", self.base_url),
None,
&json!({
"public_key": public_key_hex,
"challenge_id": challenge_id,
"signature": signature_hex,
"username": username,
"first_name": "Agent",
"last_name": "Dev",
}),
)?;
let data = extract_data(value);
Ok(AuthSession {
token: string_field(&data, "token")?,
refresh_token: string_field(&data, "refresh_token")?,
})
}
pub fn create_login_challenge(&self, public_key_hex: &str) -> Result<OnboardingChallenge> {
let value: Value = post_json(
&self.agent,
&format!("{}/api/auth/challenge", self.base_url),
None,
&json!({ "public_key": public_key_hex }),
)?;
let data = extract_data(value);
Ok(OnboardingChallenge {
challenge_id: string_field(&data, "challenge_id")?,
challenge: string_field(&data, "challenge")?,
})
}
pub fn verify_login(
&self,
public_key_hex: &str,
challenge_id: &str,
signature_hex: &str,
) -> Result<AuthSession> {
let value: Value = post_json(
&self.agent,
&format!("{}/api/auth/verify", self.base_url),
None,
&json!({
"public_key": public_key_hex,
"challenge_id": challenge_id,
"signature": signature_hex,
}),
)?;
let data = extract_data(value);
Ok(AuthSession {
token: string_field(&data, "token")?,
refresh_token: string_field(&data, "refresh_token")?,
})
}
pub fn get_node_id(&self, token: &str) -> Result<String> {
let response = self
.agent
.get(&format!("{}/api/node/info", self.base_url))
.set("Authorization", &format!("Bearer {token}"))
.call()
.map_err(|e| anyhow!("GET /api/node/info failed: {e}"))?;
let value: Value = response.into_json().context("parse node info JSON")?;
let data = extract_data(value);
string_field(&data, "node_id")
}
pub fn seed_peer_endpoint(
&self,
token: &str,
peer_node_id: &str,
ip: &str,
port: u16,
) -> Result<()> {
let _: Value = post_json(
&self.agent,
&format!("{}/api/v2/internal/test/seed-peer", self.base_url),
Some(token),
&json!({
"node_id": peer_node_id,
"ip_address": ip,
"port": port,
}),
)?;
Ok(())
}
pub fn get_balance(&self, token: &str) -> Result<Value> {
let response = self
.agent
.get(&format!("{}/api/balance", self.base_url))
.set("Authorization", &format!("Bearer {token}"))
.call()
.map_err(|e| anyhow!("GET /api/balance failed: {e}"))?;
let value: Value = response.into_json().context("parse balance JSON")?;
Ok(extract_data(value))
}
pub fn list_channels(&self, token: &str) -> Result<Value> {
let response = self
.agent
.get(&format!("{}/api/channels", self.base_url))
.set("Authorization", &format!("Bearer {token}"))
.call()
.map_err(|e| anyhow!("GET /api/channels failed: {e}"))?;
let value: Value = response.into_json().context("parse channels JSON")?;
Ok(extract_data(value))
}
pub fn list_peers(&self, token: &str) -> Result<Value> {
let response = self
.agent
.get(&format!("{}/api/peers", self.base_url))
.set("Authorization", &format!("Bearer {token}"))
.call()
.map_err(|e| anyhow!("GET /api/peers failed: {e}"))?;
let value: Value = response.into_json().context("parse peers JSON")?;
Ok(extract_data(value))
}
pub fn new_onchain_address(&self, token: &str) -> Result<String> {
let response = self
.agent
.get(&format!("{}/api/onchain/address/new", self.base_url))
.set("Authorization", &format!("Bearer {token}"))
.call()
.map_err(|e| anyhow!("GET /api/onchain/address/new failed: {e}"))?;
let value: Value = response.into_json().context("parse onchain address JSON")?;
let data = extract_data(value);
string_field(&data, "address")
}
pub fn connect_peer(&self, token: &str, peer: &str) -> Result<Value> {
let value: Value = post_json(
&self.agent,
&format!("{}/api/peers/connect", self.base_url),
Some(token),
&json!({ "peer_info": peer }),
)?;
Ok(extract_data(value))
}
pub(crate) fn open_channel_body(peer: &str, sats: u64, push_msat: u64) -> Value {
json!({
"peer_pubkey_and_address": peer,
"channel_amount_sats": sats,
"push_to_counterparty_msat": push_msat,
})
}
pub fn open_channel(
&self,
token: &str,
peer: &str,
sats: u64,
push_msat: u64,
) -> Result<Value> {
let body = Self::open_channel_body(peer, sats, push_msat);
let value: Value = post_json(
&self.agent,
&format!("{}/api/channels/open", self.base_url),
Some(token),
&body,
)?;
Ok(extract_data(value))
}
pub fn create_invoice(
&self,
token: &str,
amount_msat: u64,
memo: &str,
) -> Result<String> {
let value: Value = post_json(
&self.agent,
&format!("{}/api/payments/invoices/create", self.base_url),
Some(token),
&json!({
"amount_msat": amount_msat,
"description": memo,
"expiry_secs": 3600u32,
}),
)?;
let data = extract_data(value);
string_field(&data, "invoice")
}
pub fn pay_invoice(&self, token: &str, bolt11: &str) -> Result<Value> {
let value: Value = post_json(
&self.agent,
&format!("{}/api/payments/send/invoice", self.base_url),
Some(token),
&json!({ "invoice": bolt11 }),
)?;
Ok(extract_data(value))
}
pub fn invoke_capability(
&self,
token: &str,
capability: &str,
payload: Value,
) -> Result<Value> {
let value: Value = post_json(
&self.agent,
&format!("{}/api/v2/system/capabilities/invoke", self.base_url),
Some(token),
&json!({
"capability": capability,
"payload": payload,
}),
)?;
Ok(extract_data(value))
}
pub fn post_raw_with_status(
&self,
token: &str,
route: &str,
body: &Value,
) -> Result<(u16, Value)> {
let url = format!("{}{}", self.base_url, route);
let request = self
.agent
.post(&url)
.set("Content-Type", "application/json")
.set("Authorization", &format!("Bearer {token}"));
match request.send_json(body.clone()) {
Ok(r) => {
let status = r.status();
let value: Value = r
.into_json()
.unwrap_or_else(|_| json!({}));
Ok((status, value))
}
Err(ureq::Error::Status(code, r)) => {
let body_str = r.into_string().unwrap_or_default();
let value: Value = serde_json::from_str(&body_str)
.unwrap_or_else(|_| json!({ "raw": body_str }));
Ok((code, value))
}
Err(e) => anyhow::bail!("POST {url} transport error: {e}"),
}
}
}
#[cfg(test)]
mod probe_tests {
use super::*;
#[test]
fn open_channel_body_has_expected_fields() {
let body = AgentHttpClient::open_channel_body("03aa@127.0.0.1:9536", 100_000, 10_000_000);
assert_eq!(body["peer_pubkey_and_address"], "03aa@127.0.0.1:9536");
assert_eq!(body["channel_amount_sats"], 100_000u64);
assert_eq!(body["push_to_counterparty_msat"], 10_000_000u64);
}
}
fn post_json(
agent: &ureq::Agent,
url: &str,
bearer: Option<&str>,
body: &Value,
) -> Result<Value> {
let mut request = agent.post(url).set("Content-Type", "application/json");
if let Some(token) = bearer {
request = request.set("Authorization", &format!("Bearer {token}"));
}
let response = match request.send_json(body.clone()) {
Ok(r) => r,
Err(ureq::Error::Status(code, r)) => {
let body = r.into_string().unwrap_or_default();
bail!("POST {url} returned HTTP {code}: {body}");
}
Err(e) => bail!("POST {url} transport error: {e}"),
};
response
.into_json::<Value>()
.with_context(|| format!("parse JSON response from POST {url}"))
}
fn extract_data(value: Value) -> Value {
match value {
Value::Object(ref obj) if obj.contains_key("success") && obj.contains_key("data") => {
obj.get("data").cloned().unwrap_or(Value::Null)
}
other => other,
}
}
fn string_field(value: &Value, key: &str) -> Result<String> {
value
.get(key)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| anyhow!("response missing '{key}' string field; got: {value}"))
}