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(())
}
}
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}"))
}