use anyhow::{anyhow, bail, Context, Result};
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::Mutex;
const AUTH_TIMEOUT_SECS: u64 = 30;
pub struct AgentHttpClient {
base_url: String,
agent: ureq::Agent,
session_path: Option<PathBuf>,
current_token: Mutex<Option<String>>,
}
pub struct OnboardingChallenge {
pub challenge_id: String,
pub challenge: String,
}
pub struct AuthSession {
pub token: String,
pub refresh_token: String,
}
#[derive(Debug)]
pub enum ProbeError {
Unauthorized {
method: &'static str,
path: String,
detail: String,
},
Http {
method: &'static str,
path: String,
status: u16,
body: String,
},
Unreachable {
method: &'static str,
path: String,
detail: String,
},
Protocol {
method: &'static str,
path: String,
detail: String,
},
}
impl std::fmt::Display for ProbeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProbeError::Unauthorized { method, path, detail } => {
write!(f, "{method} {path} unauthorized: {detail}")
}
ProbeError::Http { method, path, status, body } => {
write!(f, "{method} {path} returned HTTP {status}: {body}")
}
ProbeError::Unreachable { method, path, detail } => {
write!(f, "{method} {path} unreachable: {detail}")
}
ProbeError::Protocol { method, path, detail } => {
write!(f, "{method} {path}: could not parse response JSON: {detail}")
}
}
}
}
impl std::error::Error for ProbeError {}
fn default_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(AUTH_TIMEOUT_SECS))
.build()
}
impl AgentHttpClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
agent: default_agent(),
session_path: None,
current_token: Mutex::new(None),
}
}
pub fn with_session(base_url: impl Into<String>, session_path: PathBuf) -> Self {
Self {
base_url: base_url.into(),
agent: default_agent(),
session_path: Some(session_path),
current_token: Mutex::new(None),
}
}
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 refresh(&self, refresh_token: &str) -> Result<AuthSession> {
let value: Value = post_json(
&self.agent,
&format!("{}/api/auth/refresh", self.base_url),
None,
&json!({ "refresh_token": refresh_token }),
)?;
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 data = extract_data(self.authed_get("/api/node/info", token)?);
string_field(&data, "node_id")
}
pub fn seed_peer_endpoint(
&self,
token: &str,
peer_node_id: &str,
ip: &str,
port: u16,
) -> Result<()> {
self.authed_post(
"/api/v2/internal/test/seed-peer",
token,
&json!({
"node_id": peer_node_id,
"ip_address": ip,
"port": port,
}),
)?;
Ok(())
}
pub fn get_balance(&self, token: &str) -> Result<Value> {
Ok(extract_data(self.authed_get("/api/balance", token)?))
}
pub fn list_channels(&self, token: &str) -> Result<Value> {
Ok(extract_data(self.authed_get("/api/channels", token)?))
}
pub fn list_peers(&self, token: &str) -> Result<Value> {
Ok(extract_data(self.authed_get("/api/peers", token)?))
}
pub fn new_onchain_address(&self, token: &str) -> Result<String> {
let data = extract_data(self.authed_get("/api/onchain/address/new", token)?);
string_field(&data, "address")
}
pub fn connect_peer(&self, token: &str, peer: &str) -> Result<Value> {
Ok(extract_data(self.authed_post(
"/api/peers/connect",
token,
&json!({ "peer_info": peer }),
)?))
}
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);
Ok(extract_data(self.authed_post("/api/channels/open", token, &body)?))
}
pub fn create_invoice(
&self,
token: &str,
amount_msat: u64,
memo: &str,
) -> Result<String> {
let data = extract_data(self.authed_post(
"/api/payments/invoices/create",
token,
&json!({
"amount_msat": amount_msat,
"description": memo,
"expiry_secs": 3600u32,
}),
)?);
string_field(&data, "invoice")
}
pub fn pay_invoice(&self, token: &str, bolt11: &str) -> Result<Value> {
Ok(extract_data(self.authed_post(
"/api/payments/send/invoice",
token,
&json!({ "invoice": bolt11 }),
)?))
}
pub fn invoke_capability(
&self,
token: &str,
capability: &str,
payload: Value,
) -> Result<Value> {
Ok(extract_data(self.authed_post(
"/api/v2/system/capabilities/invoke",
token,
&json!({
"capability": capability,
"payload": payload,
}),
)?))
}
pub(crate) fn confirm_did_device_body(device_id: &str) -> Value {
json!({ "device_id": device_id })
}
pub(crate) fn did_devices_from_response(value: Value) -> Result<Vec<Value>> {
let data = extract_data(value);
data.get("devices")
.and_then(Value::as_array)
.cloned()
.ok_or_else(|| anyhow!("device list response missing 'devices' array; got: {data}"))
}
pub fn list_did_devices(&self, token: &str) -> Result<Vec<Value>> {
let value = self.authed_get("/api/v2/did-devices", token)?;
Self::did_devices_from_response(value)
}
pub fn confirm_did_device(&self, token: &str, device_id: &str) -> Result<Value> {
Ok(extract_data(self.authed_post(
"/api/v2/did-devices/operations/add/confirm",
token,
&Self::confirm_did_device_body(device_id),
)?))
}
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}"),
}
}
fn effective_token(&self, caller_token: &str) -> String {
self.current_token
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| caller_token.to_string())
}
fn raw_get(&self, method: &'static str, path: &str, token: &str) -> Result<Value, ProbeError> {
let response = self
.agent
.get(&format!("{}{}", self.base_url, path))
.set("Authorization", &format!("Bearer {token}"))
.call();
Self::classify_response(method, path, response)
}
fn raw_post(
&self,
method: &'static str,
path: &str,
token: &str,
body: &Value,
) -> Result<Value, ProbeError> {
let response = self
.agent
.post(&format!("{}{}", self.base_url, path))
.set("Content-Type", "application/json")
.set("Authorization", &format!("Bearer {token}"))
.send_json(body.clone());
Self::classify_response(method, path, response)
}
fn classify_response(
method: &'static str,
path: &str,
response: std::result::Result<ureq::Response, ureq::Error>,
) -> Result<Value, ProbeError> {
match response {
Ok(r) => r.into_json::<Value>().map_err(|e| ProbeError::Protocol {
method,
path: path.to_string(),
detail: e.to_string(),
}),
Err(ureq::Error::Status(401, r)) => Err(ProbeError::Unauthorized {
method,
path: path.to_string(),
detail: r.into_string().unwrap_or_default(),
}),
Err(ureq::Error::Status(status, r)) => Err(ProbeError::Http {
method,
path: path.to_string(),
status,
body: r.into_string().unwrap_or_default(),
}),
Err(ureq::Error::Transport(t)) => Err(ProbeError::Unreachable {
method,
path: path.to_string(),
detail: t.to_string(),
}),
}
}
fn authed_get(&self, path: &str, token: &str) -> Result<Value> {
self.with_refresh(token, |t| self.raw_get("GET", path, t))
}
fn authed_post(&self, path: &str, token: &str, body: &Value) -> Result<Value> {
self.with_refresh(token, |t| self.raw_post("POST", path, t, body))
}
fn with_refresh(
&self,
token: &str,
attempt: impl Fn(&str) -> Result<Value, ProbeError>,
) -> Result<Value> {
let effective = self.effective_token(token);
match attempt(&effective) {
Ok(v) => Ok(v),
Err(ProbeError::Unauthorized { method, path, detail }) => {
match self.refresh_and_persist() {
Ok(new_token) => match attempt(&new_token) {
Ok(v) => Ok(v),
Err(ProbeError::Unauthorized { method, path, detail: second_detail }) => {
Err(anyhow::Error::new(ProbeError::Unauthorized {
method,
path,
detail: format!(
"still unauthorized after refreshing the access token — the \
refresh token itself may be invalid/expired/revoked. \
Response: {second_detail}"
),
}))
}
Err(other) => Err(anyhow::Error::new(other)),
},
Err(refresh_err) => Err(anyhow::Error::new(ProbeError::Unauthorized {
method,
path,
detail: format!(
"token rejected (401: {detail}), and refreshing it failed: {refresh_err:#}"
),
})),
}
}
Err(other) => Err(anyhow::Error::new(other)),
}
}
fn refresh_and_persist(&self) -> Result<String> {
let session_path = self.session_path.as_ref().ok_or_else(|| {
anyhow!(
"received 401 and this client has no bound session file to refresh from \
(constructed via AgentHttpClient::new, not with_session)"
)
})?;
let mut session =
crate::commands::dev::agent::session::AgentSession::load_from_path(session_path)
.with_context(|| format!("load session for refresh at {}", session_path.display()))?;
let refreshed = self
.refresh(&session.refresh_token)
.context("POST /api/auth/refresh")?;
session.token = refreshed.token.clone();
session.refresh_token = refreshed.refresh_token.clone();
session.last_login_at = chrono::Utc::now();
let dev_dir = session_path.parent().ok_or_else(|| {
anyhow!("session path {} has no parent directory", session_path.display())
})?;
session
.save(dev_dir)
.with_context(|| format!("persist refreshed session to {}", dev_dir.display()))?;
*self.current_token.lock().unwrap() = Some(refreshed.token.clone());
Ok(refreshed.token)
}
}
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}"))
}
#[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);
}
#[test]
fn confirm_did_device_body_contains_only_target_device() {
let body = AgentHttpClient::confirm_did_device_body("browser-device");
assert_eq!(body, json!({ "device_id": "browser-device" }));
assert!(body.get("current_device_id").is_none());
assert!(body.get("token").is_none());
}
#[test]
fn did_device_list_accepts_enveloped_shape() {
let devices = AgentHttpClient::did_devices_from_response(json!({
"success": true,
"data": {
"devices": [
{
"device_id": "browser-device",
"status": "pending",
"created_at": "2026-07-25T00:00:01Z"
}
]
}
}))
.unwrap();
assert_eq!(devices[0]["device_id"], "browser-device");
}
#[test]
fn did_device_list_rejects_missing_devices_array() {
let error = AgentHttpClient::did_devices_from_response(json!({
"success": true,
"data": {}
}))
.unwrap_err();
assert!(error.to_string().contains("devices"));
}
}
#[cfg(test)]
mod refresh_tests {
use super::*;
use crate::commands::dev::agent::session::AgentSession;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::time::Duration;
struct Canned {
status: u16,
body: String,
}
fn canned(status: u16, body: Value) -> Canned {
Canned { status, body: body.to_string() }
}
fn status_reason(status: u16) -> &'static str {
match status {
200 => "OK",
401 => "Unauthorized",
_ => "Status",
}
}
enum StubOutcome {
ServedAll,
GaveUpAfter { served: usize, expected: usize },
}
const STUB_DEADLINE: Duration = Duration::from_secs(10);
fn spawn_stub(responses: Vec<Canned>) -> (String, mpsc::Receiver<StubOutcome>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
listener
.set_nonblocking(true)
.expect("set stub listener non-blocking (needed to bound accept())");
let addr = listener.local_addr().expect("stub listener addr");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let expected = responses.len();
let deadline = std::time::Instant::now() + STUB_DEADLINE;
for (served, canned) in responses.into_iter().enumerate() {
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if std::time::Instant::now() >= deadline {
let _ = tx.send(StubOutcome::GaveUpAfter { served, expected });
return;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => {
let _ = tx.send(StubOutcome::GaveUpAfter { served, expected });
return;
}
}
};
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
let mut buf = [0u8; 8192];
let _ = stream.read(&mut buf); let response = format!(
"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
canned.status,
status_reason(canned.status),
canned.body.len(),
canned.body,
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
let _ = tx.send(StubOutcome::ServedAll);
});
(format!("http://{addr}"), rx)
}
fn await_stub(rx: &mpsc::Receiver<StubOutcome>) -> Result<(), String> {
match rx.recv_timeout(STUB_DEADLINE + Duration::from_secs(2)) {
Ok(StubOutcome::ServedAll) => Ok(()),
Ok(StubOutcome::GaveUpAfter { served, expected }) => Err(format!(
"stub server only saw {served}/{expected} scripted connections before its own \
{STUB_DEADLINE:?} deadline — the client made fewer requests than the scenario \
scripted (transient loopback flake or the client returned early)"
)),
Err(_) => Err(format!(
"stub server thread never reported an outcome within {:?}",
STUB_DEADLINE + Duration::from_secs(2)
)),
}
}
fn write_test_session(dir: &std::path::Path, token: &str, refresh_token: &str, base_url: &str) -> AgentSession {
let session = AgentSession {
instance: "alice".into(),
base_url: base_url.into(),
node_id: "03aa".into(),
public_key: "pub".into(),
secret_key_hex: "sec".into(),
mnemonic: "test mnemonic".into(),
token: token.into(),
refresh_token: refresh_token.into(),
onboarded_at: chrono::Utc::now(),
last_login_at: chrono::Utc::now(),
};
session.save(dir).expect("write test session");
session
}
fn retry_transient_env_flake(mut scenario: impl FnMut() -> Result<(), String>) {
for attempt in 1..=3 {
match scenario() {
Ok(()) => return,
Err(msg) if attempt < 3 => {
eprintln!(
"refresh_tests: retrying after a transient loopback-socket flake \
(attempt {attempt}/3; shared box under load, not a logic bug): {msg}"
);
}
Err(msg) => panic!("giving up after {attempt} attempts: {msg}"),
}
}
}
fn is_transient_env_flake(err: &anyhow::Error) -> bool {
let msg = format!("{err:#}");
(msg.contains("os error") || msg.contains("Network Error") || msg.contains("transport error"))
&& !msg.contains("still unauthorized after refreshing")
}
#[test]
fn a_401_triggers_refresh_and_retries_once() {
retry_transient_env_flake(|| {
let dir = tempfile::tempdir().expect("tempdir");
let (base_url, done_rx) = spawn_stub(vec![
canned(401, json!({ "error": "Invalid or expired token" })),
canned(200, json!({ "token": "new-access-token", "refresh_token": "new-refresh-token" })),
canned(200, json!({ "total_onchain_balance_sats": 42 })),
]);
let session = write_test_session(dir.path(), "stale-token", "still-valid-refresh", &base_url);
let session_path = AgentSession::file_path(dir.path(), &session.instance);
let client = AgentHttpClient::with_session(base_url, session_path.clone());
let result = client.get_balance("stale-token");
let stub_result = await_stub(&done_rx);
let balance = match result {
Ok(v) => v,
Err(e) if is_transient_env_flake(&e) => return Err(e.to_string()),
Err(e) => panic!("get_balance should recover via refresh: {e:#}"),
};
stub_result?;
assert_eq!(balance["total_onchain_balance_sats"], 42);
let persisted = AgentSession::load_from_path(&session_path).expect("reload session");
assert_eq!(persisted.token, "new-access-token");
assert_eq!(persisted.refresh_token, "new-refresh-token");
Ok(())
});
}
#[test]
fn a_second_401_after_refresh_is_reported_as_unauthorized_not_generic() {
retry_transient_env_flake(|| {
let dir = tempfile::tempdir().expect("tempdir");
let (base_url, done_rx) = spawn_stub(vec![
canned(401, json!({ "error": "Invalid or expired token" })),
canned(200, json!({ "token": "new-access-token", "refresh_token": "new-refresh-token" })),
canned(401, json!({ "error": "Invalid or expired token" })),
]);
let session = write_test_session(dir.path(), "stale-token", "dead-refresh-token", &base_url);
let session_path = AgentSession::file_path(dir.path(), &session.instance);
let client = AgentHttpClient::with_session(base_url, session_path);
let result = client.get_balance("stale-token");
let stub_result = await_stub(&done_rx);
let error = match result {
Err(e) => e,
Ok(v) => panic!("expected the second 401 to surface as an error, got: {v}"),
};
if is_transient_env_flake(&error) {
return Err(error.to_string());
}
stub_result?;
let probe_error = error
.downcast_ref::<ProbeError>()
.unwrap_or_else(|| panic!("error must be a classified ProbeError, not an opaque string: {error:#}"));
assert!(
matches!(probe_error, ProbeError::Unauthorized { .. }),
"still unauthorized after a successful refresh must stay classified as Unauthorized: {probe_error:?}"
);
assert!(
error.to_string().contains("still unauthorized after refreshing"),
"message must say the refresh itself didn't help: {error}"
);
Ok(())
});
}
#[test]
fn a_401_with_no_bound_session_cannot_refresh_and_says_so() {
let (base_url, done_rx) = spawn_stub(vec![canned(401, json!({ "error": "Invalid or expired token" }))]);
let client = AgentHttpClient::new(base_url);
let error = client.get_balance("stale-token").unwrap_err();
assert!(
error.downcast_ref::<ProbeError>().is_some(),
"even the unrecoverable case must stay a classified ProbeError"
);
assert!(
error.to_string().contains("no bound session"),
"message must explain why refresh could not be attempted: {error}"
);
await_stub(&done_rx).expect("stub should have served its single scripted response");
}
#[test]
fn an_unreachable_daemon_is_classified_distinctly_from_unauthorized() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind to find a free port");
let addr = listener.local_addr().expect("addr");
drop(listener);
let client = AgentHttpClient::new(format!("http://{addr}"));
let error = client.get_balance("token").unwrap_err();
let probe_error = error
.downcast_ref::<ProbeError>()
.expect("must be a classified ProbeError");
assert!(
matches!(probe_error, ProbeError::Unreachable { .. }),
"a connection failure must not be misreported as an auth failure: {probe_error:?}"
);
}
}