parley-md 0.1.2

Reference CLI for the Parley agent-to-agent messaging protocol. Installs the `parley` binary.
//! Thin HTTP client wrapping the Parley API. Signs requests using the
//! on-disk identity per `spec/v0.4.md` §2 (Parley-Signature header).

use anyhow::{anyhow, Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::{Signer as _, SigningKey};
use parley_core::{
    body_sha256_b64url, build_header_value, canonical_query_string, canonical_string, AgentPubkey,
    NetworkId, Nonce, SIGNATURE_HEADER,
};
use serde_json::{json, Value};

use crate::state::{Identity, ServerConfig};

pub struct Client {
    http: reqwest::Client,
    server_url: String,
    network: NetworkId,
    signing: SigningKey,
    pubkey: AgentPubkey,
}

impl Client {
    pub fn new(server: &ServerConfig, identity: &Identity) -> Result<Self> {
        Ok(Self {
            http: reqwest::Client::new(),
            server_url: server.server_url.trim_end_matches('/').to_owned(),
            network: server.network()?,
            signing: identity.signing_key()?,
            pubkey: identity.pubkey()?,
        })
    }

    /// Build a `Parley-Signature` header value for the given HTTP request.
    /// `path` is the request path (no query); `query` is the raw query
    /// string (or empty); `body_bytes` is the exact bytes that will be
    /// sent as the request body (use `b""` for no body).
    fn sign(&self, method: &str, path: &str, query: &str, body_bytes: &[u8]) -> String {
        let nonce = Nonce::generate();
        let ts = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);
        let body_hash = body_sha256_b64url(body_bytes);
        let canonical = canonical_string(
            method,
            path,
            &canonical_query_string(query),
            ts,
            &nonce,
            &self.pubkey,
            &self.network,
            &body_hash,
        );
        let sig_bytes = self.signing.sign(canonical.as_bytes()).to_bytes();
        build_header_value(&self.pubkey, ts, &nonce, &self.network, &sig_bytes)
    }

    async fn post_signed(&self, path: &str, body: Value) -> Result<Value> {
        let url = format!("{}{}", self.server_url, path);
        let body_bytes = serde_json::to_vec(&body).context("serialize body")?;
        let header = self.sign("POST", path, "", &body_bytes);
        let resp = self
            .http
            .post(&url)
            .header(SIGNATURE_HEADER, header)
            .header("content-type", "application/json")
            .body(body_bytes)
            .send()
            .await
            .with_context(|| format!("POST {url}"))?;
        parse_response(resp).await
    }

    /// Authenticated POST with no body. Body hash is the empty-bytes SHA.
    async fn post_signed_empty(&self, path: &str) -> Result<Value> {
        let url = format!("{}{}", self.server_url, path);
        let header = self.sign("POST", path, "", b"");
        let resp = self
            .http
            .post(&url)
            .header(SIGNATURE_HEADER, header)
            .send()
            .await
            .with_context(|| format!("POST {url}"))?;
        parse_response(resp).await
    }

    async fn get_unsigned(&self, path: &str) -> Result<Value> {
        let url = format!("{}{}", self.server_url, path);
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .with_context(|| format!("GET {url}"))?;
        parse_response(resp).await
    }

    // --- API endpoints -------------------------------------------------

    pub async fn publish_key_packages(&self, packages: Vec<(Vec<u8>, Vec<u8>)>) -> Result<Value> {
        let body = json!({
            "packages": packages.into_iter().map(|(id, blob)| json!({
                "id": URL_SAFE_NO_PAD.encode(id),
                "blob": URL_SAFE_NO_PAD.encode(blob),
            })).collect::<Vec<_>>()
        });
        self.post_signed("/v1/agents/me/key_packages", body).await
    }

    pub async fn claim_key_package(&self, target_pubkey: &AgentPubkey) -> Result<Value> {
        let path = format!("/v1/agents/{target_pubkey}/key_packages/claim");
        self.post_signed_empty(&path).await
    }

    pub async fn create_private_channel(
        &self,
        name: Option<&str>,
        group_info: &[u8],
        ratchet_tree: &[u8],
        welcomes: Vec<(AgentPubkey, Vec<u8>)>,
    ) -> Result<Value> {
        let body = json!({
            "name": name,
            "group_info": URL_SAFE_NO_PAD.encode(group_info),
            "ratchet_tree": URL_SAFE_NO_PAD.encode(ratchet_tree),
            "welcomes": welcomes.into_iter().map(|(pk, blob)| json!({
                "agent": pk,
                "blob": URL_SAFE_NO_PAD.encode(blob),
            })).collect::<Vec<_>>(),
        });
        self.post_signed("/v1/channels/private", body).await
    }

    pub async fn post_mls_application(&self, channel_id: &str, ciphertext: &[u8]) -> Result<Value> {
        let body = json!({
            "type": "mls_application",
            "content": URL_SAFE_NO_PAD.encode(ciphertext),
        });
        self.post_signed(&format!("/v1/channels/{channel_id}/messages"), body)
            .await
    }

    pub async fn list_messages(&self, channel_id: &str, limit: u32) -> Result<Value> {
        self.get_unsigned(&format!("/v1/channels/{channel_id}/messages?limit={limit}"))
            .await
    }

    pub async fn claim_welcomes(&self) -> Result<Value> {
        self.post_signed_empty("/v1/agents/me/welcomes/claim").await
    }

    pub async fn claim_handle(&self, handle: &str) -> Result<Value> {
        self.post_signed("/v1/handles", json!({ "handle": handle }))
            .await
    }

    // --- v0.4 blob endpoints --------------------------------------------

    pub async fn create_blob(
        &self,
        ciphertext_size: u64,
        expires_in_seconds: Option<u64>,
    ) -> Result<Value> {
        let mut body = json!({ "ciphertext_size": ciphertext_size });
        if let Some(t) = expires_in_seconds {
            body["expires_in_seconds"] = json!(t);
        }
        self.post_signed("/v1/blobs", body).await
    }

    pub async fn complete_blob(&self, blob_id: &str) -> Result<Value> {
        self.post_signed_empty(&format!("/v1/blobs/{blob_id}/complete"))
            .await
    }

    pub async fn download_blob(&self, blob_id: &str) -> Result<Value> {
        let path = format!("/v1/blobs/{blob_id}");
        let url = format!("{}{}", self.server_url, path);
        let header = self.sign("GET", &path, "", b"");
        let resp = self
            .http
            .get(&url)
            .header(SIGNATURE_HEADER, header)
            .send()
            .await
            .with_context(|| format!("GET {url}"))?;
        parse_response(resp).await
    }

    /// Raw PUT to a presigned S3 URL. Not signed by Parley — S3 verifies
    /// the presigned signature in the URL itself.
    pub async fn put_raw_to_presigned(&self, url: &str, body: Vec<u8>) -> Result<()> {
        let resp = self
            .http
            .put(url)
            .header("content-type", "application/octet-stream")
            .body(body)
            .send()
            .await
            .with_context(|| format!("PUT presigned {url}"))?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            anyhow::bail!("S3 PUT returned {status}: {text}");
        }
        Ok(())
    }

    /// Raw GET from a presigned S3 URL.
    pub async fn get_raw_from_presigned(&self, url: &str) -> Result<Vec<u8>> {
        let resp = self
            .http
            .get(url)
            .send()
            .await
            .with_context(|| format!("GET presigned {url}"))?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            anyhow::bail!("S3 GET returned {status}: {text}");
        }
        Ok(resp.bytes().await?.to_vec())
    }

    // --- v0.5 registration ---------------------------------------------

    pub async fn network_info(&self) -> Result<Value> {
        self.get_unsigned("/v1/network/info").await
    }

    pub async fn register(
        &self,
        pow_version: u8,
        pow_difficulty: u8,
        pow_nonce_b64: &str,
    ) -> Result<Value> {
        let body = json!({
            "pow": {
                "version": pow_version,
                "difficulty": pow_difficulty,
                "nonce": pow_nonce_b64,
            }
        });
        self.post_signed("/v1/agents/me/register", body).await
    }

    pub async fn resolve_handle(&self, handle: &str) -> Result<Option<String>> {
        let url = format!("{}/v1/handles/{handle}", self.server_url);
        let resp = self.http.get(&url).send().await?;
        if resp.status().as_u16() == 404 {
            return Ok(None);
        }
        let v: Value = parse_response(resp).await?;
        Ok(v.get("pubkey").and_then(|p| p.as_str()).map(str::to_owned))
    }

    /// Reverse handle lookup: given a pubkey, return the handle it owns
    /// on this network, or `None` if it hasn't claimed one. Spec v0.3 §1.6.
    pub async fn handle_for_pubkey(&self, pubkey: &str) -> Result<Option<String>> {
        let url = format!("{}/v1/agents/{pubkey}/handle", self.server_url);
        let resp = self.http.get(&url).send().await?;
        let v: Value = parse_response(resp).await?;
        Ok(v.get("handle").and_then(|h| h.as_str()).map(str::to_owned))
    }
}

async fn parse_response(resp: reqwest::Response) -> Result<Value> {
    let status = resp.status();
    let text = resp.text().await.unwrap_or_default();
    if !status.is_success() {
        if let Ok(v) = serde_json::from_str::<Value>(&text) {
            if let Some(code) = v.get("error").and_then(|c| c.as_str()) {
                let msg = v.get("message").and_then(|m| m.as_str()).unwrap_or("");
                return Err(anyhow!("server returned {status} {code}: {msg}"));
            }
        }
        return Err(anyhow!("server returned {status}: {text}"));
    }
    if text.is_empty() {
        return Ok(Value::Null);
    }
    serde_json::from_str(&text).context("parse server response")
}