ai-cortex-sdk 0.1.1

Rust client SDK for AI Cortex server: PAT auth, device binding, software store, offline license (Ed25519), and auto-update (check-update pull + SSE push).
Documentation
use std::time::Duration;

use crate::config::CortexConfig;
use crate::error::{SdkError, SdkResult};
use crate::models::*;

/// AI Cortex SDK 客户端。
///
/// 鉴权方式:用户 PAT(`Authorization: Bearer actx_pat_...`),由配置直接提供。
/// 构造后自动启动心跳(除非 `heartbeat_interval == 0`),首次心跳即向服务端绑定当前设备。
pub struct CortexClient {
    config: CortexConfig,
    http: reqwest::Client,
    heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
}

impl CortexClient {
    pub fn new(config: CortexConfig) -> Self {
        let http = reqwest::Client::builder()
            .timeout(Duration::from_secs(config.timeout))
            .build()
            .expect("Failed to create HTTP client");
        let mut client = Self {
            config,
            http,
            heartbeat_handle: None,
        };
        client.start_heartbeat();
        client
    }

    fn bearer(&self) -> String {
        format!("Bearer {}", self.config.pat)
    }

    fn auth_get(&self, url: &str) -> reqwest::RequestBuilder {
        self.http.get(url).header("Authorization", self.bearer())
    }

    fn auth_post(&self, url: &str, body: &impl serde::Serialize) -> reqwest::RequestBuilder {
        self.http
            .post(url)
            .header("Authorization", self.bearer())
            .json(body)
    }

    async fn decode<T: serde::de::DeserializeOwned>(&self, resp: reqwest::Response) -> SdkResult<T> {
        let api_resp: ApiResponse<T> = resp.json().await?;
        if !api_resp.success {
            return Err(SdkError::ServerError(api_resp.message.unwrap_or_default()));
        }
        api_resp
            .data
            .ok_or_else(|| SdkError::ServerError("No data".to_string()))
    }

    // --- Software Store APIs ---

    pub async fn list_software(&self) -> SdkResult<Vec<SoftwareStoreItem>> {
        let url = format!("{}/api/softwares/store", self.config.server_url);
        let resp = self.auth_get(&url).send().await?;
        self.decode(resp).await
    }

    pub async fn get_latest_version(&self, software_id: &str) -> SdkResult<SoftwareVersion> {
        let url = format!(
            "{}/api/softwares/{}/latest-version",
            self.config.server_url, software_id
        );
        let resp = self.auth_get(&url).send().await?;
        self.decode(resp).await
    }

    pub async fn download(&self, version_id: &str) -> SdkResult<DownloadInfo> {
        let url = format!(
            "{}/api/softwares/sdk-download/{}",
            self.config.server_url, version_id
        );
        let resp = self.auth_post(&url, &serde_json::json!({})).send().await?;
        self.decode(resp).await
    }

    // --- Auto Update APIs ---

    /// 检查更新(Pull)。按配置的 software_id + 指定 platform/channel,与 current_version 比较。
    /// `channel` 传 None 默认 "stable"。需对该 software 持有有效许可证,否则返回 ServerError。
    pub async fn check_update(
        &self,
        platform: &str,
        current_version: &str,
        channel: Option<&str>,
    ) -> SdkResult<UpdateInfo> {
        let url = format!("{}/api/softwares/sdk-check-update", self.config.server_url);
        let body = serde_json::json!({
            "software_id": self.config.software_id,
            "platform": platform,
            "channel": channel.unwrap_or("stable"),
            "current_version": current_version,
        });
        let resp = self.auth_post(&url, &body).send().await?;
        self.decode(resp).await
    }

    /// 打开更新事件 SSE 流(Push)。`software_id` 传 None 订阅全部;`last_event_id` 用于断线重连补播。
    /// 返回的流需配合 `futures_util::StreamExt` 使用:`while let Some(ev) = stream.next().await { ... }`。
    /// 注意:SSE 是长连接,内部使用无读超时的 HTTP 客户端;靠服务端 15s keepalive 保活。
    pub async fn open_update_events(
        &self,
        software_id: Option<&str>,
        last_event_id: Option<u64>,
    ) -> SdkResult<crate::sse::UpdateEventStream> {
        // 用一个无读超时的专用 client,避免长连接被 self.http 的 30s timeout 切断
        let stream_client = reqwest::Client::builder().build()?;
        let url = format!("{}/api/softwares/sdk-update-events", self.config.server_url);
        let mut req = stream_client
            .get(&url)
            .header("Authorization", self.bearer())
            .header("Accept", "text/event-stream");
        if let Some(sid) = software_id {
            req = req.query(&[("software_id", sid)]);
        }
        if let Some(last) = last_event_id {
            req = req.header("Last-Event-ID", last.to_string());
        }
        let resp = req.send().await?;
        if !resp.status().is_success() {
            return Err(SdkError::ServerError(format!(
                "SSE connect failed: HTTP {}",
                resp.status()
            )));
        }
        // 将 bytes chunk 转为 Vec<u8>,避免引入 bytes crate 依赖
        let stream = futures_util::StreamExt::map(resp.bytes_stream(), |r| {
            r.map(|b| b.to_vec())
        });
        Ok(crate::sse::UpdateEventStream::new(stream))
    }

    // --- Device APIs(user 维度,user 由 PAT 推断)---

    /// 列出当前用户名下绑定的有效设备。
    pub async fn list_devices(&self) -> SdkResult<Vec<DeviceRecord>> {
        let url = format!("{}/api/devices/active", self.config.server_url);
        let resp = self.auth_get(&url).send().await?;
        self.decode(resp).await
    }

    /// 解绑指定设备(将其 status 置 0,释放设备名额)。
    pub async fn unbind_device(&self, device_id: &str) -> SdkResult<()> {
        let url = format!("{}/api/devices/{}/unbind", self.config.server_url, device_id);
        let resp = self.auth_post(&url, &serde_json::json!({})).send().await?;
        let api_resp: ApiResponse<serde_json::Value> = resp.json().await?;
        if !api_resp.success {
            return Err(SdkError::ServerError(api_resp.message.unwrap_or_default()));
        }
        Ok(())
    }

    // --- Offline license ---

    /// 向服务端拉取离线许可证(需有效 PAT 与有效软件授权)。
    /// `fingerprint` 为当前设备指纹。返回未验签的许可证文件,需配合
    /// [`verify_offline_license`] 进行本地验签后才可信任。
    pub async fn fetch_offline_license(&self, fingerprint: &str) -> SdkResult<OfflineLicenseFile> {
        let url = format!("{}/api/offline-licenses/issue", self.config.server_url);
        let body = serde_json::json!({
            "softwareId": self.config.software_id,
            "fingerprint": fingerprint,
        });
        let resp = self.auth_post(&url, &body).send().await?;
        self.decode(resp).await
    }

    /// 使用配置中的 `software_public_key`(若设置)验签当前许可证并解析 payload。
    /// 若未钉扎公钥,则信任服务端下发公钥(依赖 TLS)。
    pub fn verify_offline_license(
        &self,
        file: &OfflineLicenseFile,
        expected_fingerprint: &str,
    ) -> SdkResult<OfflineLicensePayload> {
        verify_offline_license(
            file,
            &self.config.software_id,
            expected_fingerprint,
            self.config.software_public_key.as_deref(),
        )
    }

    /// 启动后台心跳任务:每隔 heartbeat_interval 秒上报设备指纹。
    /// 首次心跳即向服务端绑定设备(按 config.software_id 对应许可证的 max_devices 校验上限);
    /// 后续刷新 last_active_time。间隔为 0 时不启动;重复调用会先停掉旧任务。
    fn start_heartbeat(&mut self) {
        if let Some(h) = self.heartbeat_handle.take() {
            h.abort();
        }
        if self.config.heartbeat_interval == 0 {
            return;
        }
        let http = self.http.clone();
        let config = self.config.clone();
        let interval = self.config.heartbeat_interval;
        let handle = tokio::spawn(async move {
            let mut ticker = tokio::time::interval(Duration::from_secs(interval));
            loop {
                ticker.tick().await;
                let dev = crate::device::collect();
                let url = format!("{}/api/devices/heartbeat", config.server_url);
                // 心跳失败静默处理,不阻塞客户端主流程
                let _ = http
                    .post(&url)
                    .header("Authorization", format!("Bearer {}", config.pat))
                    .json(&serde_json::json!({
                        "softwareId": config.software_id,
                        "fingerprint": dev.fingerprint,
                        "deviceInfo": dev.info,
                    }))
                    .send()
                    .await;
            }
        });
        self.heartbeat_handle = Some(handle);
    }
}

impl Drop for CortexClient {
    fn drop(&mut self) {
        if let Some(h) = self.heartbeat_handle.take() {
            h.abort();
        }
    }
}

/// 验证离线许可证并解析 payload,校验顺序:
///
/// 1. **公钥钉扎**:若 `pinned_public_key` 为 `Some`,要求 `file.public_key` 与之严格相等,
///    否则直接拒绝(防中间人替换公钥)。
/// 2. **Ed25519 验签**:用 `public_key`(hex → 32 字节)对 `payload` 的 UTF-8 字节验签。
/// 3. **解析 payload**:将 `payload` JSON 反序列化为 [`OfflineLicensePayload`]。
/// 4. **字段匹配**:`payload.software_id == expected_software_id` 且
///    `payload.fingerprint == expected_fingerprint`。
/// 5. **过期校验**:`payload.expire_time > now`(now 取 `SystemTime::now` 的 Unix 秒)。
///
/// 全部通过返回 [`OfflineLicensePayload`],任一失败返回对应的 [`SdkError`]。
pub fn verify_offline_license(
    file: &OfflineLicenseFile,
    expected_software_id: &str,
    expected_fingerprint: &str,
    pinned_public_key: Option<&str>,
) -> SdkResult<OfflineLicensePayload> {
    // 1. 公钥钉扎
    if let Some(pinned) = pinned_public_key {
        if pinned.trim() != file.public_key.trim() {
            return Err(SdkError::LicenseFieldMismatch(format!(
                "public_key mismatch: pinned {:?} got {:?}",
                pinned, file.public_key
            )));
        }
    }

    // 2. Ed25519 验签
    let pub_bytes = hex::decode(&file.public_key).map_err(|e| {
        SdkError::Signature(format!("invalid public_key hex: {e}"))
    })?;
    if pub_bytes.len() != 32 {
        return Err(SdkError::Signature(format!(
            "public_key must be 32 bytes, got {}",
            pub_bytes.len()
        )));
    }
    let mut pk_arr = [0u8; 32];
    pk_arr.copy_from_slice(&pub_bytes);
    let vk = ed25519_dalek::VerifyingKey::from_bytes(&pk_arr)
        .map_err(|e| SdkError::Signature(format!("invalid verifying key: {e}")))?;

    let sig_bytes = hex::decode(&file.signature).map_err(|e| {
        SdkError::Signature(format!("invalid signature hex: {e}"))
    })?;
    if sig_bytes.len() != 64 {
        return Err(SdkError::Signature(format!(
            "signature must be 64 bytes, got {}",
            sig_bytes.len()
        )));
    }
    let sig = ed25519_dalek::Signature::from_slice(&sig_bytes)
        .map_err(|e| SdkError::Signature(format!("invalid signature: {e}")))?;

    use ed25519_dalek::Verifier;
    vk
        .verify(file.payload.as_bytes(), &sig)
        .map_err(|_| SdkError::Signature("signature verification failed".to_string()))?;

    // 3. 解析 payload
    let payload: OfflineLicensePayload = serde_json::from_str(&file.payload).map_err(|e| {
        SdkError::Signature(format!("invalid payload json: {e}"))
    })?;

    // 4. 字段匹配
    if payload.software_id != expected_software_id {
        return Err(SdkError::LicenseFieldMismatch(format!(
            "software_id mismatch: expected {expected_software_id:?} got {:?}",
            payload.software_id
        )));
    }
    if payload.fingerprint != expected_fingerprint {
        return Err(SdkError::LicenseFieldMismatch(format!(
            "fingerprint mismatch: expected {expected_fingerprint:?} got {:?}",
            payload.fingerprint
        )));
    }

    // 5. 过期校验
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    if payload.expire_time <= now {
        return Err(SdkError::LicenseExpired);
    }

    Ok(payload)
}