ncheap 0.3.0

Namecheap registrar API CLI built for terminal and AI-agent operability
Documentation
pub mod xml;

use std::cell::Cell;
use std::thread;
use std::time::{Duration, Instant};

use crate::config::Profile;

/// Minimum spacing between API calls within this process. Namecheap's FAQ
/// has been observed stating the per-minute key-wide limit as both 20/min
/// and 50/min (700/hour and 8000/day are consistent across sources); 3100ms
/// spaces for the conservative reading. Concurrent processes do not
/// coordinate (a cross-process budget is planned, not built).
const MIN_SPACING: Duration = Duration::from_millis(3100);
/// Backoff before the single retry on HTTP 429/5xx. The API documents no
/// rate-limit error shape, so this is conservative, not tuned.
const RETRY_BACKOFF: Duration = Duration::from_secs(5);

/// Every read-only API method this tool may issue, lowercase canonical
/// (no "namecheap." prefix). Client::call refuses anything not listed
/// (fail-closed); mutating methods must go through Client::call_mut, which
/// carries the production gate and never auto-retries.
pub const READ_ONLY_COMMANDS: &[&str] = &[
    "domains.getlist",
    "domains.check",
    "domains.getregistrarlock",
    "domains.getinfo",
    "domains.getcontacts",
    "domains.gettldlist",
    "domains.dns.getlist",
    "domains.dns.gethosts",
    "whoisguard.getlist",
    "users.getbalances",
    "users.getpricing",
];

/// Lowercase canonical command name: "namecheap." prefix stripped.
pub fn canonical_command(command: &str) -> String {
    let stripped = command
        .strip_prefix("namecheap.")
        .or_else(|| command.strip_prefix("Namecheap."))
        .unwrap_or(command);
    stripped.to_ascii_lowercase()
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("{0}")]
    Config(#[from] crate::config::ConfigError),
    #[error("transport: {0}")]
    Transport(String),
    #[error("rate limited by Namecheap ({0}); retry later")]
    RateLimited(String),
    #[error("Namecheap API error {code}: {message}")]
    Api { code: String, message: String },
    #[error("unexpected API response: {0}")]
    Parse(String),
    #[error("{0}")]
    Usage(String),
    /// Refused by the safety policy (mutation gate); maps to the config
    /// kind/exit-code so the external contract stays unchanged.
    #[error("{0}")]
    Policy(String),
}

impl Error {
    pub fn exit_code(&self) -> u8 {
        match self {
            Error::Api { .. } | Error::Parse(_) => 1,
            Error::Usage(_) => 2,
            Error::Config(_) | Error::Policy(_) => 3,
            Error::Transport(_) => 4,
            Error::RateLimited(_) => 5,
        }
    }

    pub fn kind(&self) -> &'static str {
        match self {
            Error::Api { .. } => "api",
            // Split from "api" in envelope schema 2: a malformed response
            // is our problem or upstream drift, not a registrar verdict.
            Error::Parse(_) => "parse",
            Error::Usage(_) => "usage",
            Error::Config(_) | Error::Policy(_) => "config",
            Error::Transport(_) => "transport",
            Error::RateLimited(_) => "rate_limit",
        }
    }

    pub fn code(&self) -> Option<&str> {
        match self {
            Error::Api { code, .. } => Some(code),
            _ => None,
        }
    }
}

#[derive(Debug)]
pub enum TransportFailure {
    Status(u16),
    Other(String),
}

pub trait Transport {
    fn send(&self, endpoint: &str, params: &[(String, String)])
    -> Result<String, TransportFailure>;
}

pub struct HttpTransport {
    agent: ureq::Agent,
}

impl HttpTransport {
    pub fn new() -> Self {
        // https_only + no redirects: a credential-bearing request must never
        // be re-routed or downgraded by a server-side redirect. Debug builds
        // relax https_only so the NCHEAP_ENDPOINT test override can point at
        // a localhost mock; release builds always enforce it.
        let config = ureq::Agent::config_builder()
            .timeout_global(Some(Duration::from_secs(30)))
            .https_only(cfg!(not(debug_assertions)))
            .max_redirects(0)
            .build();
        Self {
            agent: config.into(),
        }
    }
}

impl Default for HttpTransport {
    fn default() -> Self {
        Self::new()
    }
}

impl Transport for HttpTransport {
    fn send(
        &self,
        endpoint: &str,
        params: &[(String, String)],
    ) -> Result<String, TransportFailure> {
        // POST with a form body keeps the ApiKey out of URLs (query strings
        // are exposed to proxies and intermediary logging; bodies are not).
        let form: Vec<(&str, &str)> = params
            .iter()
            .map(|(k, v)| (k.as_str(), v.as_str()))
            .collect();
        match self.agent.post(endpoint).send_form(form) {
            // With max_redirects(0), ureq hands a 3xx back as Ok; that is a
            // transport anomaly here, not an API response to parse.
            Ok(resp) if resp.status().is_redirection() => {
                Err(TransportFailure::Status(resp.status().as_u16()))
            }
            Ok(mut resp) => resp
                .body_mut()
                .read_to_string()
                .map_err(|e| TransportFailure::Other(e.to_string())),
            Err(ureq::Error::StatusCode(code)) => Err(TransportFailure::Status(code)),
            Err(e) => Err(TransportFailure::Other(e.to_string())),
        }
    }
}

pub struct Client<T: Transport> {
    transport: T,
    profile: Profile,
    last_call: Cell<Option<Instant>>,
    calls: Cell<u32>,
    spacing: Duration,
    retry_backoff: Duration,
}

impl<T: Transport> Client<T> {
    pub fn new(transport: T, profile: Profile) -> Self {
        Self {
            transport,
            profile,
            last_call: Cell::new(None),
            calls: Cell::new(0),
            spacing: MIN_SPACING,
            retry_backoff: RETRY_BACKOFF,
        }
    }

    /// Override throttle spacing and retry backoff (tests use zero so the
    /// retry and pagination paths run without real sleeps).
    pub fn set_timing(&mut self, spacing: Duration, retry_backoff: Duration) {
        self.spacing = spacing;
        self.retry_backoff = retry_backoff;
    }

    pub fn profile(&self) -> &Profile {
        &self.profile
    }

    pub fn transport(&self) -> &T {
        &self.transport
    }

    pub fn calls(&self) -> u32 {
        self.calls.get()
    }

    /// Issue one read-only API call. `command` may be short
    /// ("domains.getList") or "namecheap."-prefixed. Fail-closed: any
    /// command not on READ_ONLY_COMMANDS is refused here — mutations must
    /// use call_mut, which carries the production gate. Returns the raw
    /// XML body. Retries once on HTTP 429/5xx (reads are idempotent).
    pub fn call(&self, command: &str, params: &[(&str, &str)]) -> Result<String, Error> {
        let canonical = canonical_command(command);
        if !READ_ONLY_COMMANDS.contains(&canonical.as_str()) {
            return Err(Error::Policy(format!(
                "{command:?} is not a known read-only command; \
                 mutating commands must use the mutation path"
            )));
        }
        self.dispatch(&canonical, params, true)
    }

    /// Issue one mutating API call. Never auto-retries (an ambiguous
    /// failure after a mutation must surface, not double-submit), and is
    /// gated: refused against production unless the profile explicitly
    /// sets allow_production_mutations.
    pub fn call_mut(&self, command: &str, params: &[(&str, &str)]) -> Result<String, Error> {
        if !self.profile.sandbox && !self.profile.allow_production_mutations {
            return Err(Error::Policy(
                "mutations against production are disabled; use a sandbox profile \
                 or set allow_production_mutations = true in this profile"
                    .into(),
            ));
        }
        self.dispatch(&canonical_command(command), params, false)
    }

    fn dispatch(
        &self,
        canonical: &str,
        params: &[(&str, &str)],
        retry: bool,
    ) -> Result<String, Error> {
        let mut all: Vec<(String, String)> = vec![
            ("ApiUser".into(), self.profile.api_user.clone()),
            ("ApiKey".into(), self.profile.api_key.expose().to_owned()),
            ("UserName".into(), self.profile.username.clone()),
            ("Command".into(), format!("namecheap.{canonical}")),
            ("ClientIp".into(), self.profile.client_ip.clone()),
        ];
        all.extend(
            params
                .iter()
                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())),
        );

        // Counts logical API commands; a retried attempt is still one call.
        self.calls.set(self.calls.get() + 1);
        self.throttle();
        let mut attempt = self.transport.send(self.profile.endpoint(), &all);
        if retry
            && matches!(attempt, Err(TransportFailure::Status(code)) if code == 429 || code >= 500)
        {
            thread::sleep(self.retry_backoff);
            self.throttle();
            attempt = self.transport.send(self.profile.endpoint(), &all);
        }
        match attempt {
            Ok(body) => Ok(body),
            Err(TransportFailure::Status(429)) => Err(Error::RateLimited("HTTP 429".into())),
            Err(TransportFailure::Status(code)) => {
                Err(Error::Transport(format!("HTTP status {code}")))
            }
            // Last-line defense: no error string leaves this layer containing
            // the key, regardless of what the HTTP library embeds.
            Err(TransportFailure::Other(msg)) => Err(Error::Transport(
                msg.replace(self.profile.api_key.expose(), "<redacted>"),
            )),
        }
    }

    fn throttle(&self) {
        if let Some(prev) = self.last_call.get()
            && prev.elapsed() < self.spacing
        {
            thread::sleep(self.spacing - prev.elapsed());
        }
        self.last_call.set(Some(Instant::now()));
    }
}