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

[![crates.io](https://img.shields.io/crates/v/ai-cortex-sdk.svg)](https://crates.io/crates/ai-cortex-sdk)
[![docs.rs](https://docs.rs/ai-cortex-sdk/badge.svg)](https://docs.rs/ai-cortex-sdk)
[![license](https://img.shields.io/crates/l/ai-cortex-sdk.svg)](./LICENSE)

Rust client SDK for the [AI Cortex](https://github.com/Liangdi/ai-station) server — PAT 鉴权、设备绑定、软件商店、**自动更新**(check-update + SSE)、**离线许可证**(Ed25519 签名)。

## Installation

```toml
[dependencies]
ai-cortex-sdk = "0.1"
```

## Quick Start

SDK 用**用户 PAT**(`actx_pat_...`)鉴权,PAT 由用户在服务端 web 控制台调 `POST /api/access-tokens` 创建。

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

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // server_url, PAT, software_id(SDK 以该软件身份心跳 / 下载 / 检查更新)
    let config = CortexConfig::new("http://localhost:40404", "actx_pat_...", "software-uuid");
    let client = CortexClient::new(config);

    // 软件商店:列出软件及其最新版本
    for item in client.list_software().await? {
        let latest = item.latest_version.as_ref().map(|v| v.version.as_str()).unwrap_or("-");
        println!("{} -> {}", item.software.name, latest);
    }

    // 检查自动更新(Pull)
    let info = client.check_update("linux", "1.0.0", None).await?;
    if info.has_update {
        println!("有新版本,强制升级: {}", info.force_update);
    }

    Ok(())
}
```

## Configuration

```rust
let config = CortexConfig::new("http://localhost:40404", "actx_pat_...", "software-uuid")
    .with_timeout(60)              // 请求超时(秒),默认 30
    .with_heartbeat_interval(60)   // 心跳间隔(秒);0 = 关闭,默认 60
    .without_heartbeat()           // 或直接关闭心跳
    .with_software_public_key("abcdef...32字节hex公钥"); // 钉扎离线许可证公钥
```

`CortexClient::new` 会自动启动后台心跳(首次即向服务端绑定当前设备,按该软件许可证的 `max_devices` 校验上限);`Drop` 时自动停止心跳。

## API Reference

### Software Store

| Method | Description |
|--------|-------------|
| `client.list_software()` | 列出所有软件及其最新活动版本 |
| `client.get_latest_version(software_id)` | 取某软件最新版本 |
| `client.download(version_id)` | 取某版本的下载地址(需许可证) |

### Auto Update(自动更新)

提供 **Pull(主动检查)** 与 **Push(SSE 订阅)** 两种检测方式,互补使用:SSE 做实时通知,Pull 做准确判定 + 断线兜底。两者都需对该 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
// 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> }
if info.force_update {
    // 强制升级:客户端应阻止旧版启动,引导用户升级后再放行
} else if info.has_update {
    let t = info.target_version.unwrap();
    println!("可升级到 {} (channel={}, {} bytes)", t.version, t.channel, t.file_size);
}
```

**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(Ok(ev)) = stream.next().await {
    // ev.id 单调递增;ev.force_update 标记是否强制
    println!("update #{}: v{} force={}", ev.id, ev.version, ev.force_update);
}
```

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

### Device(设备)

| Method | Description |
|--------|-------------|
| `device::collect()` | 采集当前设备指纹(`DeviceIdentity { fingerprint, info }`|
| `client.list_devices()` | 列出当前用户名下绑定的有效设备 |
| `client.unbind_device(device_id)` | 解绑设备(释放名额) |

## Error Handling

```rust
use ai_cortex_sdk::SdkError;

match client.check_update("linux", "1.0.0", None).await {
    Ok(info) => { /* ... */ }
    Err(SdkError::NotAuthenticated) => eprintln!("未认证 / PAT 失效"),
    Err(SdkError::Unauthorized(reason)) => eprintln!("无授权: {reason}"),
    Err(SdkError::RequestError(e)) => eprintln!("网络错误: {e}"),
    Err(SdkError::ServerError(msg)) => eprintln!("服务端错误: {msg}"),
    Err(e) => eprintln!("其它: {e}"),
}
```

## Examples

- [`basic.rs`]examples/basic.rs — 软件商店浏览 / 下载 / 设备列表
- [`auth_check.rs`]examples/auth_check.rs — 校验 PAT 是否有效
- [`offline_license.rs`]examples/offline_license.rs — 拉取并验签离线许可证
- [`auto_update.rs`]examples/auto_update.rs — 检查更新(Pull)+ 订阅 SSE 事件(Push)

```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`

## License

MIT(见 [LICENSE](./LICENSE))。