node-app-build 5.25.1

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! Minimal blocking HTTP client wrapping the daemon's auth endpoints.
//!
//! Mirrors the four calls in `tests/e2e/src/harness/auth.ts`:
//!   POST /api/auth/onboarding-challenge
//!   POST /api/auth/complete-onboarding
//!   POST /api/auth/challenge
//!   POST /api/auth/verify
//! plus the two helpers `tests/e2e/src/harness/test-harness.ts` calls:
//!   GET  /api/node/info                     (populate node_id)
//!   POST /api/v2/internal/test/seed-peer    (cross-seed peer IP)
//!
//! All daemon responses are wrapped in an `ApiResponse { success, data,
//! error }` envelope on most routes, but the v2 hexagonal routes return
//! domain objects directly. `extract_data` accepts both shapes — same
//! tolerance the e2e `unwrapResponseOrThrow` helper uses.

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,
        }
    }

    /// Returns true iff the node has not yet been onboarded by anyone.
    /// Tolerates schema drift: any non-200 is treated as "unknown — try
    /// onboarding and let the server reject" so we don't lock the agent
    /// out on an unrelated upstream change.
    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);
                // Field name is `is_onboarded` on the OnboardingStatusResponse
                // shape; missing == treat as fresh.
                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")
    }

    /// Seed `peer_node_id` at `ip:port` into this node's IP pool so future
    /// L402 / proxy calls can resolve the peer. Idempotent server-side
    /// (the test seed endpoint upserts). Any 4xx/5xx is bubbled up.
    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}"))
}

/// Accept both shapes the daemon returns:
///   { "success": true, "data": {...} }       (legacy v1 envelope)
///   {...}                                    (v2 hexagonal direct return)
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}"))
}