zc2 0.0.29

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! `zc token create` / `zc join` — headless node enrollment via one-time join tokens.
//!
//! Contract (docs/superpowers/specs/2026-07-06-headless-node-enrollment-design.md):
//!   POST /api/auth/cli/token  (auth: X-Broker-Api-Key)   → { join_token, expires_at }
//!   POST /api/auth/cli/join   {"join_token": "zj_..."}   → 200 { api_key } | 410 | 404 | 400

pub enum JoinDecision {
    Approved(String),
    Failed(String),
}

pub enum MintDecision {
    Minted { token: String, expires_at: String },
    Failed(String),
}

/// `zj_` + 32 lowercase hex chars.
pub fn valid_join_token(t: &str) -> bool {
    match t.strip_prefix("zj_") {
        Some(rest) => {
            rest.len() == 32
                && rest
                    .chars()
                    .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
        }
        None => false,
    }
}

pub fn decide_join(status: u16, body: &str) -> JoinDecision {
    match status {
        200 => match serde_json::from_str::<serde_json::Value>(body)
            .ok()
            .and_then(|v| v["api_key"].as_str().map(|s| s.to_string()))
        {
            Some(k) => JoinDecision::Approved(k),
            None => JoinDecision::Failed("joined but no api_key in response".into()),
        },
        410 => JoinDecision::Failed(
            "token expired or already used — ask the owner to run `zc token create` again".into(),
        ),
        404 => JoinDecision::Failed("unknown token".into()),
        400 => JoinDecision::Failed("malformed token".into()),
        s => JoinDecision::Failed(format!("unexpected status {s}")),
    }
}

pub fn decide_mint(status: u16, body: &str) -> MintDecision {
    match status {
        200 => {
            let v: Option<serde_json::Value> = serde_json::from_str(body).ok();
            let token = v
                .as_ref()
                .and_then(|v| v["join_token"].as_str())
                .map(|s| s.to_string());
            let expires_at = v
                .as_ref()
                .and_then(|v| v["expires_at"].as_str())
                .map(|s| s.to_string());
            match (token, expires_at) {
                (Some(token), Some(expires_at)) => MintDecision::Minted { token, expires_at },
                _ => MintDecision::Failed("minted but response missing fields".into()),
            }
        }
        401 | 403 => MintDecision::Failed(
            "not authorized — sign in first (`zc init`) or set ZAKURO_API_KEY".into(),
        ),
        s => MintDecision::Failed(format!("unexpected status {s}")),
    }
}

fn api_url() -> String {
    crate::credentials::default_api_url()
}

fn post_json(url: &str, api_key: Option<&str>, payload: &str) -> Result<(u16, String), String> {
    let mut req = ureq::post(url)
        .config()
        .http_status_as_error(false)
        .build()
        .header("Content-Type", "application/json");
    if let Some(k) = api_key {
        req = req.header("X-Broker-Api-Key", k);
    }
    match req.send(payload) {
        Ok(r) => {
            let s = r.status().as_u16();
            let b = r.into_body().read_to_string().unwrap_or_default();
            Ok((s, b))
        }
        Err(e) => Err(e.to_string()),
    }
}

/// `zc token create [--ttl <secs>] [--label <name>]`
pub fn run_token_create(args: &[String]) -> i32 {
    crate::credentials::load_into_env();
    let api_key = match std::env::var("ZAKURO_API_KEY") {
        Ok(k) => k,
        Err(_) => {
            eprintln!("Not signed in. Run `zc init` or set ZAKURO_API_KEY.");
            return 1;
        }
    };
    let mut ttl: Option<u64> = None;
    let mut label: Option<String> = None;
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--ttl" => ttl = it.next().and_then(|v| v.parse().ok()),
            "--label" => label = it.next().cloned(),
            _ => {}
        }
    }
    let mut body = serde_json::Map::new();
    if let Some(t) = ttl {
        body.insert("ttl".into(), t.into());
    }
    if let Some(l) = label {
        body.insert("label".into(), l.into());
    }
    let url = format!("{}/api/auth/cli/token", api_url().trim_end_matches('/'));
    let payload = serde_json::Value::Object(body).to_string();
    match post_json(&url, Some(&api_key), &payload) {
        Ok((status, resp)) => match decide_mint(status, &resp) {
            MintDecision::Minted { token, expires_at } => {
                println!("{token}");
                eprintln!("expires: {expires_at}");
                eprintln!("On the new node, run:  zc join {token}");
                0
            }
            MintDecision::Failed(msg) => {
                eprintln!("{msg}");
                1
            }
        },
        Err(e) => {
            eprintln!("Could not reach {}: {e}", api_url());
            1
        }
    }
}

/// `zc join <token> [--api-url <url>] [--force]`
pub fn run_join(args: &[String]) -> i32 {
    let force = args.iter().any(|a| a == "--force");
    let mut api_url_flag: Option<String> = None;
    let mut token: Option<String> = None;
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--api-url" => api_url_flag = it.next().cloned(),
            "--force" => {}
            t if t.starts_with("zj_") => token = Some(t.to_string()),
            _ => {}
        }
    }
    crate::credentials::load_into_env();
    if std::env::var("ZAKURO_API_KEY").is_ok() && !force {
        eprintln!("Already signed in. Use `zc join <token> --force` to re-enroll.");
        return 0;
    }
    let token = match token {
        Some(t) if valid_join_token(&t) => t,
        Some(_) => {
            eprintln!("Invalid token — expected zj_<32 hex chars>.");
            return 1;
        }
        None => {
            eprintln!("Usage: zc join <zj_token> [--api-url URL] [--force]");
            return 1;
        }
    };
    let base = api_url_flag.unwrap_or_else(api_url);
    let url = format!("{}/api/auth/cli/join", base.trim_end_matches('/'));
    let payload = serde_json::json!({ "join_token": token }).to_string();
    match post_json(&url, None, &payload) {
        Ok((status, resp)) => match decide_join(status, &resp) {
            JoinDecision::Approved(key) => {
                if let Err(e) = crate::credentials::save(&key, Some(&base)) {
                    eprintln!("joined but could not write credentials: {e}");
                    return 1;
                }
                println!("✓ Enrolled. Key saved to ~/.zakuro/credentials.");
                println!("Next: `zc vpn conf` to get the mesh config, then `zc up`.");
                0
            }
            JoinDecision::Failed(msg) => {
                eprintln!("{msg}");
                1
            }
        },
        Err(e) => {
            eprintln!("Could not reach {base}: {e}");
            eprintln!("Fallback: export ZAKURO_API_KEY=<key from the dashboard>");
            1
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn join_token_format() {
        assert!(valid_join_token("zj_0123456789abcdef0123456789abcdef"));
        assert!(!valid_join_token("zj_SHORT"));
        assert!(!valid_join_token("zk_0123456789abcdef0123456789abcdef"));
        assert!(!valid_join_token("zj_0123456789ABCDEF0123456789ABCDEF"));
    }

    #[test]
    fn decide_join_maps_statuses() {
        assert!(matches!(
            decide_join(200, r#"{"api_key":"zk_1_abc"}"#),
            JoinDecision::Approved(ref k) if k == "zk_1_abc"
        ));
        assert!(matches!(decide_join(200, "{}"), JoinDecision::Failed(_)));
        assert!(matches!(decide_join(410, ""), JoinDecision::Failed(ref m)
            if m.contains("expired or already used")));
        assert!(matches!(decide_join(404, ""), JoinDecision::Failed(ref m)
            if m.contains("unknown")));
        assert!(matches!(decide_join(500, ""), JoinDecision::Failed(_)));
    }

    #[test]
    fn decide_mint_maps_statuses() {
        assert!(matches!(
            decide_mint(200, r#"{"join_token":"zj_0123456789abcdef0123456789abcdef","expires_at":"2026-07-06T12:00:00Z"}"#),
            MintDecision::Minted { ref token, ref expires_at }
                if token == "zj_0123456789abcdef0123456789abcdef" && expires_at == "2026-07-06T12:00:00Z"
        ));
        assert!(matches!(decide_mint(200, "{}"), MintDecision::Failed(_)));
        assert!(matches!(decide_mint(401, ""), MintDecision::Failed(ref m)
            if m.contains("not authorized")));
        assert!(matches!(decide_mint(500, ""), MintDecision::Failed(_)));
    }

    use std::io::{Read, Write};
    use std::net::TcpListener;

    /// One-shot HTTP mock: accepts a single connection, returns `status` + `body`.
    fn mock_server(status: u16, body: &'static str) -> (std::thread::JoinHandle<String>, u16) {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        let h = std::thread::spawn(move || {
            let (mut s, _) = listener.accept().unwrap();
            // read until we have the headers plus the full Content-Length body
            // (a single read() can return before the body bytes arrive)
            let mut raw = Vec::new();
            let mut buf = [0u8; 4096];
            loop {
                let n = s.read(&mut buf).unwrap_or(0);
                if n == 0 {
                    break;
                }
                raw.extend_from_slice(&buf[..n]);
                let text = String::from_utf8_lossy(&raw);
                if let Some(hdr_end) = text.find("\r\n\r\n") {
                    let want: usize = text
                        .lines()
                        .find_map(|l| {
                            l.to_ascii_lowercase()
                                .strip_prefix("content-length:")
                                .map(|v| v.trim().parse().unwrap_or(0))
                        })
                        .unwrap_or(0);
                    if raw.len() >= hdr_end + 4 + want {
                        break;
                    }
                }
            }
            let req = String::from_utf8_lossy(&raw).to_string();
            let resp = format!(
                "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                body.len()
            );
            let _ = s.write_all(resp.as_bytes());
            req
        });
        (h, port)
    }

    #[test]
    fn join_end_to_end_writes_credentials() {
        let _env = crate::credentials::HOME_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let tmp = std::env::temp_dir().join(format!("zc-enroll-test-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();
        // isolate HOME so credentials::save writes into the temp dir
        let old_home = std::env::var_os("HOME");
        // ZAKURO_HOME outranks HOME in `credentials::dir()`, so an exported one
        // would send `credentials::save` somewhere real.
        let old_zh = std::env::var_os("ZAKURO_HOME");
        std::env::remove_var("ZAKURO_HOME");
        std::env::set_var("HOME", &tmp);
        std::env::remove_var("ZAKURO_API_KEY");

        let (h, port) = mock_server(200, r#"{"api_key":"zk_42_deadbeef"}"#);
        let code = run_join(&[
            "zj_0123456789abcdef0123456789abcdef".to_string(),
            "--api-url".to_string(),
            format!("http://127.0.0.1:{port}"),
            "--force".to_string(),
        ]);
        let req = h.join().unwrap();
        assert_eq!(code, 0);
        assert!(req.starts_with("POST /api/auth/cli/join"));
        assert!(req.contains("zj_0123456789abcdef0123456789abcdef"));

        let saved = std::fs::read_to_string(tmp.join(".zakuro/credentials")).unwrap();
        assert!(saved.contains("api_key=zk_42_deadbeef"));
        assert!(saved.contains(&format!("api_url=http://127.0.0.1:{port}")));

        match old_home {
            Some(v) => std::env::set_var("HOME", v),
            None => std::env::remove_var("HOME"),
        }
        if let Some(v) = old_zh {
            std::env::set_var("ZAKURO_HOME", v);
        }
        let _ = std::fs::remove_dir_all(&tmp);
    }
}