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 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) -> Result<String> {
let path = "/.well-known/client-node-origin";
let response = self
.agent
.get(&format!("{}{path}", self.base_url))
.call();
let value = Self::classify_response("GET", path, response).map_err(anyhow::Error::new)?;
string_field(&extract_data(value), "node_id")
}
pub fn probe_token(&self, token: &str) -> Result<()> {
self.authed_get("/api/v2/client-transport/candidates", token)
.map(|_| ())
}
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 = match self.refresh(&session.refresh_token) {
Ok(refreshed) => refreshed,
Err(server_refresh) => AuthSession {
token: reissue_from_instance_secret(session_path, &session.token).with_context(|| {
format!(
"POST /api/auth/refresh failed ({server_refresh:#}), and re-issuing the \
owner token from the instance's JWT_SECRET failed too"
)
})?,
refresh_token: session.refresh_token.clone(),
},
};
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)
}
}
pub(crate) fn reissue_from_instance_secret(
session_path: &std::path::Path,
expired: &str,
) -> Result<String> {
let env_file = session_path
.parent()
.and_then(|dev_dir| dev_dir.parent())
.map(|root| root.join("daemon.env"))
.ok_or_else(|| anyhow!("session path {} has no instance root", session_path.display()))?;
let contents = std::fs::read_to_string(&env_file)
.with_context(|| format!("read {}", env_file.display()))?;
let secret = first_env_value(&contents, "JWT_SECRET")
.ok_or_else(|| anyhow!("{} sets no JWT_SECRET", env_file.display()))?;
reissue_token(expired, &secret, chrono::Utc::now().timestamp())
}
const REISSUED_TOKEN_TTL_SECS: i64 = 900;
fn reissue_token(expired: &str, secret: &str, now: i64) -> Result<String> {
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = false;
validation.required_spec_claims.clear();
let mut claims = decode::<serde_json::Map<String, Value>>(
expired,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.context("the cached owner token does not verify under this instance's JWT_SECRET")?
.claims;
claims.insert("iat".into(), json!(now));
claims.insert("exp".into(), json!(now + REISSUED_TOKEN_TTL_SECS));
encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.context("sign the re-issued owner token")
}
fn first_env_value(contents: &str, wanted: &str) -> Option<String> {
contents.lines().find_map(|line| {
let line = line.trim();
if line.starts_with('#') {
return None;
}
let (key, value) = line.split_once('=')?;
let key = key.trim();
let key = key.strip_prefix("export ").unwrap_or(key);
if key != wanted {
return None;
}
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
Some(value.to_string()).filter(|v| !v.is_empty())
})
}
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 reissue_tests {
use super::*;
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
fn owner_token(secret: &str, iat: i64, exp: i64) -> String {
encode(
&Header::new(Algorithm::HS256),
&json!({
"sub": "user-1", "node_id": "02ab", "public_key_hash": "h",
"public_key": "04cd", "device_id": "d-1", "iat": iat, "exp": exp,
"is_primary_owner": true, "token_type": "access",
}),
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}
#[test]
fn a_reissued_token_keeps_every_claim_and_gets_a_fresh_expiry() {
let expired = owner_token("s3cret", 1_000, 1_900);
let fresh = reissue_token(&expired, "s3cret", 50_000).unwrap();
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = false;
let claims = decode::<Value>(&fresh, &DecodingKey::from_secret(b"s3cret"), &validation)
.unwrap()
.claims;
assert_eq!(claims["iat"], 50_000);
assert_eq!(claims["exp"], 50_000 + REISSUED_TOKEN_TTL_SECS);
for (key, value) in [("sub", "user-1"), ("device_id", "d-1"), ("token_type", "access")] {
assert_eq!(claims[key], value, "{key}");
}
assert_eq!(claims["is_primary_owner"], true);
}
#[test]
fn a_token_signed_with_another_secret_is_never_reissued() {
let foreign = owner_token("someone-else", 1_000, 1_900);
assert!(reissue_token(&foreign, "s3cret", 50_000).is_err());
}
#[test]
fn the_secret_is_read_the_way_node_server_reads_its_env_file() {
let contents = "# JWT_SECRET=commented\nexport JWT_SECRET=\"first\"\nJWT_SECRET=second\n";
assert_eq!(first_env_value(contents, "JWT_SECRET").as_deref(), Some("first"));
assert_eq!(first_env_value("JWT_SECRET=\n", "JWT_SECRET"), None);
assert_eq!(first_env_value("OTHER=1\n", "JWT_SECRET"), None);
}
#[test]
fn reissue_reads_the_secret_from_the_instance_daemon_env() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("dev-apps")).unwrap();
std::fs::write(root.path().join("daemon.env"), "JWT_SECRET=abc\nJWT_SECRET=ignored\n")
.unwrap();
let session = root.path().join("dev-apps/alice-agent-session.json");
let expired = owner_token("abc", 1_000, 1_900);
assert!(reissue_from_instance_secret(&session, &expired).is_ok());
let wrong = owner_token("ignored", 1_000, 1_900);
assert!(reissue_from_instance_secret(&session, &wrong).is_err());
}
}
#[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:?}"
);
}
}