ai-cortex-sdk 0.1.0

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
# ai-cortex-sdk

Rust SDK for [AI Cortex](../../) server — authentication, authorization, and software store APIs.

## Installation

Add to your `Cargo.toml`:

```toml
[dependencies]
ai-cortex-sdk = { path = "../crates/ai-cortex-sdk" }
```

## Quick Start

```rust
use ai_cortex_sdk::{CortexClient, CortexConfig};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = CortexConfig::new("http://localhost:40404", "your-app-key");
    let mut client = CortexClient::new(config);

    // Login
    let user = client.login("admin", "password").await?;
    println!("Logged in as: {}", user.display_name);

    // Check authorization for an app
    let authorized = client.check_auth("target-app-key").await?;
    println!("Authorized: {}", authorized);

    // Browse software store
    let items = client.list_software().await?;
    for item in &items {
        println!("{}: {:?}", item.software.name, item.latest_version.as_ref().map(|v| &v.version));
    }

    // Download latest version
    if let Some(item) = items.first() {
        if let Some(version) = &item.latest_version {
            let download = client.download(&version.id).await?;
            println!("Download URL: {}", download.download_url);
        }
    }

    // Logout
    client.logout().await?;
    Ok(())
}
```

## Configuration

```rust
// server_url, PAT, software_id(软件身份,心跳/下载按该软件许可证校验)
let config = CortexConfig::new("http://localhost:40404", "actx_pat_...", "software-uuid");

// Custom timeout
let config = CortexConfig::new("http://localhost:40404", "actx_pat_...", "software-uuid")
    .with_timeout(60);
```

## API Reference

### Authentication

| Method | Description |
|--------|-------------|
| `client.login(username, password)` | Login and store session token |
| `client.logout()` | Logout and clear session |
| `client.is_logged_in()` | Check if currently authenticated |

### Authorization

| Method | Description |
|--------|-------------|
| `client.get_auth_info()` | Get current user's auth records and associated apps |
| `client.check_auth(app_key)` | Check if current user is authorized for a specific app |

### Software Store

| Method | Description |
|--------|-------------|
| `client.list_software()` | List all software with latest versions |
| `client.get_latest_version(software_id)` | Get latest version of a software |
| `client.download(version_id)` | Get download URL for a version (requires auth) |

### Auto Update (自动更新)

提供 **Pull(轮询)** 和 **Push(SSE 订阅)** 两种更新检测方式。两者都需对该 software 持有有效许可证。

| Method | Description |
|--------|-------------|
| `client.check_update(platform, current_version, channel)` | 主动检查更新(Pull),返回 `UpdateInfo` |
| `client.open_update_events(software_id, last_event_id)` | 打开更新事件 SSE 流(Push),返回 `UpdateEventStream` |

**Pull — `check_update`**

```rust
// 按 platform + channel 与 current_version 比较;channel 传 None 默认 "stable"
let info = client.check_update("linux", "1.0.0", None).await?;
// UpdateInfo { has_update, force_update, current_version, target_version: Option<VersionBrief> }
println!("has_update={}, force_update={}", info.has_update, info.force_update);
if let Some(t) = &info.target_version {
    println!("可升级到 {} (channel={}, {} bytes)", t.version, t.channel, t.file_size);
}
// force_update 阻塞语义:为 true 时客户端应阻止启动,引导用户升级后再放行
```

**Push — `open_update_events`(SSE 长连接)**

服务端通过 `GET /api/softwares/sdk-update-events`(`text/event-stream`,PAT 鉴权)主动推送新版本事件,
无需客户端轮询。返回的流需配合 `futures_util::StreamExt` 使用:

```rust
use futures_util::StreamExt;

// software_id 传 None 订阅全部;last_event_id 用于断线重连补播
let mut stream = client.open_update_events(Some("software-uuid"), None).await?;
while let Some(ev) = stream.next().await {
    match ev {
        Ok(e) => {
            // e.id 为单调递增序号;e.force_update 标记是否强制
            println!("update #{}: v{} force={}", e.id, e.version, e.force_update);
        }
        Err(e) => eprintln!("stream error: {e}"),
    }
}
```

说明:
- SSE 是长连接,内部使用**无读超时**的 HTTP 客户端(绕过 `CortexConfig.timeout` 的 30s 默认值),
  靠服务端 15s keepalive 注释帧保活。
- 断线重连时把最后一次收到的 `event.id` 作为 `last_event_id` 传入,服务端补播 `id > last` 的事件。
- 服务端会下发 `: keepalive` / `: lagged` 注释帧,SDK 内部自动忽略。

## Error Handling

```rust
use ai_cortex_sdk::SdkError;

match client.login("user", "pass").await {
    Ok(info) => println!("Welcome {}", info.display_name),
    Err(SdkError::NotAuthenticated) => eprintln!("Not logged in"),
    Err(SdkError::Unauthorized(reason)) => eprintln!("Unauthorized: {}", reason),
    Err(SdkError::RequestError(e)) => eprintln!("Network error: {}", e),
    Err(SdkError::ServerError(msg)) => eprintln!("Server error: {}", msg),
}
```

## Examples

See [examples/](examples/) directory for complete usage examples.

- [`basic.rs`]examples/basic.rs — Software store browsing, download, device listing
- [`auth_check.rs`]examples/auth_check.rs — Verify a PAT is valid
- [`offline_license.rs`]examples/offline_license.rs — Fetch and verify an offline license
- [`auto_update.rs`]examples/auto_update.rs — Check update (Pull) + subscribe SSE events (Push)

Run an example:

```bash
cargo run --example basic
cargo run --example offline_license -- <PAT> <SOFTWARE_ID> [PINNED_PUBKEY_HEX]
cargo run --example auto_update -- <PAT> <SOFTWARE_ID> [stream]
```

## 离线许可证

服务端可在 PAT 鉴权下通过 `POST /api/offline-licenses/issue` 下发一份**签名许可证文件**,
让客户端在受限网络/断网环境下仍能本地证明自己持有有效授权。文件结构:

```json
{
  "payload":   "<LicensePayload 的 JSON 字符串>",
  "signature": "<hex 64 字节 Ed25519 签名>",
  "public_key":"<hex 32 字节 Ed25519 公钥>"
}
```

`payload` 是 `OfflineLicensePayload` 的 snake_case JSON,包含
`license_id / user_id / software_id / fingerprint / max_devices / expire_time / issued_at`。
签名是对 `payload` 字符串 UTF-8 字节的 Ed25519 签名。

### 流程

```rust
use ai_cortex_sdk::{verify_offline_license, CortexClient, CortexConfig};

// 1. 配置(可选钉扎公钥,增强防替换)
let config = CortexConfig::new("http://localhost:40404", "actx_pat_...", "software-uuid")
    .with_software_public_key("abcdef...32字节hex公钥");

let client = CortexClient::new(config);
let fp = ai_cortex_sdk::device::collect().fingerprint;

// 2. 在线拉取(需联网 + 有效 PAT + 有效软件授权)
let file = client.fetch_offline_license(&fp).await?;

// 3. 本地验签:Ed25519 验签 → 解析 payload → 校验 software_id/fingerprint → 校验未过期
let payload = verify_offline_license(&file, "software-uuid", &fp, Some("abcdef..."))?;
// 或用 client.verify_offline_license(&file, &fp) 自动带入配置的 software_id 与钉扎公钥
```

### 撤销语义与短 TTL

许可证的 `expire_time = issued_at + 86400`(1 天 TTL)。**撤销依赖在线刷新**:
许可证本身是自包含签名文件,服务端无法主动吊销已下发的离线文件;通过短 TTL 强制客户端
周期性联网 `fetch_offline_license` 重新换取。若服务端撤销了用户的软件授权或解绑了该设备指纹,
下一次 fetch 将失败,客户端即失去有效许可证,从而实现“软撤销”。

### 可选公钥钉扎

默认情况下客户端信任 issue 响应中下发的 `public_key`(依赖 TLS 保证传输不被篡改)。
对安全敏感场景,可通过 `CortexConfig::with_software_public_key(hex)` 钉扎一个预期公钥:
设置后 `verify_offline_license` 会要求下发公钥与之**严格相等**,否则直接判定为
`LicenseFieldMismatch`,可防御 TLS 被绕过/中间人替换公钥的攻击。不设置时则退化为信任下发公钥。

### 校验顺序

`verify_offline_license` 按以下顺序校验,任一失败立即返回对应 `SdkError`:

1. 公钥钉扎(若提供)—— `public_key` 必须与钉扎值相等
2. Ed25519 验签 —— `public_key``payload` 字节验签
3. 解析 payload JSON
4. 字段匹配 —— `software_id``fingerprint` 必须与期望值一致
5. 过期校验 —— `expire_time > now`