# API 文档

人类可读的 API 端点参考。完整机器可读规范见 [`api/openapi.yaml`](../api/openapi.yaml)（OpenAPI 3.0）。

---

## 1. 入口分组

| 前缀 | 用途 | 鉴权 |
|---|---|---|
| `/v1/public/*` | 注册 / 登录 / 健康检查 / 支付 webhook | 无 |
| `/v1/user/*` | user-web 控制台 BFF | user JWT cookie / Bearer |
| `/v1/admin/*` | admin-web 操作台 BFF | admin JWT + RBAC |
| `/v1/api/*` | **用户客户端 API**（SDK 调这里） | API Key Bearer + 余额校验 |
| `/v1/internal/agent/*` | Gateway 反向连入（不在 SDK 范围）| install_token / agent JWT / ed25519 challenge |

服务器：默认 `:8080`；上线时前置 nginx/caddy + TLS。

---

## 2. 鉴权

### 2.1 user JWT

注册 / 登录后返：

```json
{
  "access_token":  "eyJhbGc...",     // 默认 15min TTL
  "refresh_token": "32B random b64", // 30 天 TTL
  "user": { ... }
}
```

后续请求带 `Authorization: Bearer <access_token>`；浏览器自动设 `gw_at` cookie。

过期前调 `/v1/public/auth/refresh` 拿新 access_token；refresh_token 是 sha256 入 `user_sessions` 表的；一次性 logout 撤销。

### 2.2 admin JWT

`/v1/admin/auth/login` 同上，cookie 名 `gw_admin_at`，TTL 1 小时。

### 2.3 API Key

格式 `ak_<key_id>.<secret>`。**完整 secret 仅创建时返回一次**。
SDK 调用 `/v1/api/*` 时 `Authorization: Bearer ak_xxx.yyy`。
中枢 middleware `AuthAPIKey`：拆 → 查 `api_keys` → 验 sha256(secret) == DB → 注入 `user_id` / `api_key_id`。

### 2.4 Agent token（内部）

agent install_token 一次性消费 → POST `/v1/internal/agent/online` → 拿 agent_token (短期 JWT)
agent 用 ed25519 私钥签 challenge → POST `/v1/internal/agent/token/refresh` → 续期

---

## 3. 错误模型

非 2xx 返：

```json
{ "error": "<code>", "message": "<可显示文案>" }
```

| HTTP | error code | 说明 |
|---|---|---|
| 400 | invalid_body / invalid_id / version_required / ... | 参数错 |
| 401 | unauthorized / invalid_token / missing_agent_token / ... | 鉴权 |
| 402 | insufficient_balance | 余额不足（仅 `/v1/api/*`）|
| 403 | host_banned / forbidden | 拒绝 |
| 404 | not_found / host_not_found / version_not_found | 找不到 |
| 409 | host_offline / version_yanked / version_exists | 冲突 |
| 423 | account_locked | 多次失败被锁 |
| 504 | agent_timeout | wshub.SendCmd 超时 |

---

## 4. `/v1/public/*`（无鉴权）

### 4.1 健康检查

```
GET /healthz                    # k8s liveness（进程在跑就 ok，不探下游）
→ 200 { "status": "ok" }

GET /readyz                     # k8s readiness（探 PG + Redis；任一不健康 503）
→ 200 { "status": "ok",  "pg": "ok",  "redis": "ok" }
→ 503 { "status": "fail","pg": "fail: ...", ... }

GET /v1/public/health           # 业务版健康（同 healthz）
→ 200 { "status": "ok" }
```

### 4.2 注册 / 登录

```
POST /v1/public/auth/register
{ "email": "...", "password": "..." }
→ 200 { access_token, refresh_token, access_ttl_s, user }

POST /v1/public/auth/login
{ "email": "...", "password": "..." }
→ 200 同上 / 401 invalid_credentials / 423 account_locked

POST /v1/public/auth/refresh
{ "refresh_token": "..." }
→ 200 同上

POST /v1/public/auth/logout
{ "refresh_token": "..." }
→ 200 { "ok": true }
```

### 4.3 支付 webhook

```
POST /v1/public/webhooks/payment/{provider}
provider ∈ { dummy, fourthparty }
body: dummy=JSON / fourthparty=form-encoded
→ 200 文本 "SUCCESS"（fourthparty 协议）/ 200 JSON ok
```

支付 webhook 走 `RechargeStore.ApplyPaid` 跨表事务；幂等：双重投递只加一次余额。

---

## 5. `/v1/user/*`（user JWT）

### 5.1 用户

```
GET /v1/user/me               → 200 User
PUT /v1/user/me               { display_name } → 200 User
```

### 5.2 主机

```
GET    /v1/user/hosts                     → 200 { items: HostListItem[] }
                                                  HostListItem 含 online: bool（wshub 实时态，保留向后兼容）
POST   /v1/user/hosts                     { name, listen_port? } → 200 Host
GET    /v1/user/hosts/{id}                → 200 { host: Host, online: bool }
PUT    /v1/user/hosts/{id}                { name?, listen_port? } → 200 Host
DELETE /v1/user/hosts/{id}                → 200 ok（软删）

POST   /v1/user/hosts/{id}/install-token  → 200 { install_token, expires_at }
GET    /v1/user/hosts/{id}/installer      → 200 text/plain（流式 install.ps1，一次性消费 install_token）
POST   /v1/user/hosts/{id}/rotate-secret  → 200 Host（旋转 secretKey + ed25519 keypair；旧 install 立即失效）

POST   /v1/user/hosts/{id}/terminal/ticket → 200 { ticket, expires_in }
                                              （颁发 30s 一次性 ws ticket）
GET    /v1/user/hosts/{id}/terminal/ws    → 101 Upgrade ws（自鉴权 ?ticket=<32B>；
                                              中枢 GETDEL 一次性消费）

# M7-01：主机级 L1 配置编辑（每台主机独立一组 {bid, secretKey, url}）
PUT    /v1/user/hosts/{id}/l1-config      { bid, secret_key, url } → 200 L1ConfigPutResp
                                              （不走 step-up；写库 + 入 webhook 队列 + 在线 agent 软重启）
GET    /v1/user/hosts/{id}/webhook-deliveries
                                          → 200 { items: WebhookDeliveryRow[] }
                                              （最近 50 条该主机 webhook 推送历史）
```

### 5.3 API Key

```
GET    /v1/user/api-keys      → 200 { items: APIKey[] }
POST   /v1/user/api-keys      { name } → 200 APIKeyCreated（含完整 secret 仅本次显示）
DELETE /v1/user/api-keys/{id} → 200 ok
```

### 5.4 计费

```
GET  /v1/user/billing/balance      → 200 { tokens: number }
GET  /v1/user/billing/events       ?limit&offset → 200 PagedBillingEvents
POST /v1/user/billing/recharge     { amount_cny } → 200 { order_no, pay_url }
GET  /v1/user/billing/recharges    ?status&limit&offset → 200 PagedRecharges
```

---

## 6. `/v1/admin/*`（admin JWT）

```
POST /v1/admin/auth/login                 { username, password } → 200 AuthResp

GET  /v1/admin/dashboard/summary          → 200 DashboardSummary
                                                online_hosts: wshub.Count() 实时
                                                today_revenue_cny / today_calls

GET  /v1/admin/users                      ?q&status&limit&offset → 200 PagedUsers
GET  /v1/admin/users/{id}                 → 200 User
POST /v1/admin/users/{id}/suspend         → 200 ok（强制下线所有 session）
POST /v1/admin/users/{id}/resume          → 200 ok
POST /v1/admin/users/{id}/balance/adjust  { tokens, reason } → 200 { ok, balance_after }

GET  /v1/admin/pricing-rules              ?include_expired → 200 { items }
PUT  /v1/admin/pricing-rules/{id}         { token_amount } → 200 PricingRule（立即生效）
POST /v1/admin/pricing-rules              新增

GET  /v1/admin/recharges                  ?status&limit&offset → 200 PagedRecharges
POST /v1/admin/recharges/{id}/refund      M3+ 退款流程

GET  /v1/admin/gateway-versions           ?channel&include_yanked → 200 PagedGatewayVersions
POST /v1/admin/gateway-versions           PublishVersionReq → 200 GatewayVersion
POST /v1/admin/gateway-versions/{ver}/yank → 200 ok

GET    /v1/admin/hosts                    ?status&q&limit&offset → 200 { items, q }
                                            每行含 ws_connected（WS 连接级实时态，毫秒级）
                                            + online（deprecated 别名，同 ws_connected）
                                            + user_email + public_ip
                                            q 关键字模糊匹配 user.email / host.name / host.id / public_ip 任一
                                            注：status（生命周期态、90s+ 落库快照）与 ws_connected 可能
                                                短暂不一致（e.g. status=pending + ws_connected=true 表示
                                                已连上但还没首次心跳）。判断"现在能否下发指令"看 ws_connected。
GET    /v1/admin/hosts/{id}               → 200 { host, ws_connected, online (deprecated) }
POST   /v1/admin/hosts/{id}/push-upgrade  { version } → 200 { ok, version, agent: {...} }
DELETE /v1/admin/hosts/{id}               → 204（M7-02：软删 + 踢 hub 连接 + 写 audit）
GET    /v1/admin/hosts/{id}/metrics       ?limit → 200 { items: HostMetricsRow[] }
GET    /v1/admin/hosts/{id}/events        ?limit → 200 { items: HostEvent[] }

# M7-04 admin 远程终端
POST /v1/admin/hosts/{id}/terminal/ticket → 200 { ticket, expires_in } (admin 可开任意 host)
GET  /v1/admin/hosts/{id}/terminal/ws?ticket=  → 101 Upgrade ws (?ticket= 自鉴权)

# M7-05 全局免费模式开关
GET  /v1/admin/billing/mode               → 200 { mode, rules_total, rules_paused }
POST /v1/admin/billing/mode               { mode: "free"|"charging" } → 200 切换 (step-up + audit)

GET  /v1/admin/audit-logs                 ?admin_id&action&target_type&limit&offset → 200 PagedAuditLogs
```

**审计联动**：上面所有写操作（POST/PUT/DELETE 且 status<400）都自动落 `admin_audit_logs`：

| HTTP path 模板 | action |
|---|---|
| `POST /v1/admin/users/:id/suspend` | `user.suspend` |
| `POST /v1/admin/users/:id/resume` | `user.resume` |
| `POST /v1/admin/users/:id/balance/adjust` | `user.balance.adjust` |
| `PUT /v1/admin/pricing-rules/:id` | `pricing.update` |
| `POST /v1/admin/pricing-rules` | `pricing.create` |
| `POST /v1/admin/recharges/:id/refund` | `recharge.refund` |
| `POST /v1/admin/gateway-versions` | `gw_version.publish` |
| `POST /v1/admin/gateway-versions/:v/yank` | `gw_version.yank` |
| `POST /v1/admin/hosts/:id/push-upgrade` | `host.push_upgrade` |
| `DELETE /v1/admin/hosts/:id` | `host.delete` |
| `POST /v1/admin/hosts/:id/terminal/ticket` | `host.terminal_open` |
| `POST /v1/admin/billing/mode` | `billing.mode_change` |

---

## 7. `/v1/api/*`（API Key Bearer + 余额扣费）

> **SDK 调用入口**。所有路由先 `BillingCharge` middleware 按 `billing_pricing_rules` 表的 code 扣 token；不够 402；够则继续 wshub 转发。

### 7.1 主机查询

```
GET /v1/api/hosts             → 200 { items: HostListItem[] }
GET /v1/api/hosts/{id}        → 200 { ... }
```

### 7.1b 主机生命周期管理（M7-06）

用户业务系统直接创建 / 删除主机 + 颁发安装凭据。免计费（账户管理动作）；
鉴权用 API Key（同 `/v1/api/*`），核心逻辑与 `/v1/user/hosts/*` 一致。

```
POST   /v1/api/hosts                          { name, listen_port?, with_credentials? }
                                              → 201 { host, credentials? }
DELETE /v1/api/hosts/{id}                     → 204
POST   /v1/api/hosts/{id}/install-token       → 200 InstallCredentials
POST   /v1/api/hosts/{id}/rotate-secret       → 200 InstallCredentials
GET    /v1/api/hosts/{id}/installer?format=ps1|cmd|exe
                                              → 200 binary（attachment）
```

**`POST /v1/api/hosts` 行为**

- `name` 必填，≤ 64 字符
- `listen_port` 可选，默认 23456（agent 本地 L1 端口）
- `with_credentials=true` 时一次性把完整 `InstallCredentials` 一起返，免去再调 install-token 的 round trip（脚本化部署推荐）

**`POST /v1/api/hosts/{id}/install-token` 副作用**

- 旋转 `agent_pubkey`（旧 agent 后续 token refresh 失败）
- 失效旧 `install_token` 并生成新 token（默认 15 分钟过期，0.3.1 起；可通过 `INSTALL_TOKEN_TTL`<秒> 环境变量覆盖）
- 解 KEK 返 `secret_key` 明文（仅响应里出现，DB 仍只存密文）
- 通过 wshub 给在线 agent **不**下发任何控制帧（不重启 agent）

**`GET /v1/api/hosts/{id}/installer` 行为**

直接返渲染好的安装包二进制（Content-Disposition: attachment），免去调用方自己渲染 ps1 模板：

- `?format=ps1` → 返 `install-<host_id>.ps1`，`text/plain; charset=utf-8`；调用 `powershell -ExecutionPolicy Bypass -File` 即可装
- `?format=cmd` → 返 `install-<host_id>.cmd`，`application/octet-stream`；polyglot 嵌入 ps1，需 m2-gateway.exe 在线下载
- `?format=exe` 或缺省 → 返 `install-<host_id>.exe`，单文件双击 UAC 提权即装（默认推荐）

副作用与 `install-token` 一致（旋转 keypair + 失效旧 token + 新 token 默认 15 分钟过期）。
最常见用法：

```bash
# 创建 + 直接下 ps1 一气呵成
HOST_ID=$(curl -s -X POST -H "Authorization: Bearer $KEY" \
    -H "Content-Type: application/json" -d '{"name":"prod-pc-01"}' \
    https://api.gateway.wechas.com/v1/api/hosts | jq -r .host.id)

curl -s -H "Authorization: Bearer $KEY" \
    "https://api.gateway.wechas.com/v1/api/hosts/$HOST_ID/installer?format=ps1" \
    -o "install-$HOST_ID.ps1"
```

**`POST /v1/api/hosts/{id}/rotate-secret` 副作用**

除了上述全部还会：

- 生成新 32 字符 hex `secret_key` → KEK Seal → host_secrets 表 Rotate
- 任何持旧 secretKey 加签的 L1 报文立即失效（破坏性！）

**`InstallCredentials` 字段**

```json
{
  "host_id": "uuid",
  "bid": 4503599627370495,
  "secret_key": "0123456789abcdef0123456789abcdef",
  "url": "",                                  // host-level report_url；未配则空
  "install_token": "URL-safe base64 ~43 字符",
  "install_token_expires_at": "2026-05-14T...",
  "agent_privkey_seed": "base64 raw 32B ed25519 seed",
  "agent_pubkey": "ed25519:base64-of-pubkey",
  "api_endpoint": "https://api.gateway.wechas.com",
  "ws_url": "wss://api.gateway.wechas.com/v1/internal/agent/ws",
  "listen_port": 23456,
  "exe_url": "https://.../m2-gateway.exe",
  "exe_sha256": "hex64"
}
```

**错误码**

| HTTP | error | 含义 |
| ---- | ----- | --- |
| 400 | `invalid_body` / `invalid_name` / `name_too_long` / `invalid_port` / `invalid_id` | 入参校验失败 |
| 404 | `not_found` | host 不存在或不属于当前 API Key 持有人 |
| 409 | `bid_collision` | `(user_id, bid)` UNIQUE 冲突（极低概率，重试即可） |
| 503 | `exe_not_configured` | 中枢未配 `GATEWAY_EXE_URL`（联系运维） |

### 7.2 文件 / 目录（controls 1..128）

```
POST /v1/api/hosts/{id}/files/dir       { controls: 1|2, path }
POST /v1/api/hosts/{id}/files/text      { controls: 4|8|16|32|64, path, data }
POST /v1/api/hosts/{id}/files/zip       { controls: 128, path, data }
POST /v1/api/hosts/{id}/files/download  { controls: 256, path, data: { url } }
POST /v1/api/hosts/{id}/files/upload    501 not_implemented（待 MinIO/S3 部署后接入中枢代理）
POST /v1/api/hosts/{id}/files/stream    501 not_implemented（大文件分块流；占位）
```

### 7.3 进程（controls 1024..8192）

```
POST /v1/api/hosts/{id}/process/exec    { controls: 1024,  path: "...", data: "args" } → { pid }
POST /v1/api/hosts/{id}/process/shell   { controls: 8192,  path, data: { args, timeout_s } } → { stdout, stderr, exit_code, ... }
POST /v1/api/hosts/{id}/process/kill    { controls: 4096,  path: "exe.exe", data: { timeout_s } }
POST /v1/api/hosts/{id}/process/list    { controls: 4096+1?, ... }
```

### 7.4 终端 shell session（controls 1<<16 ..1<<20）

```
POST /v1/api/hosts/{id}/shell/session/open               → { stream_id }
POST /v1/api/hosts/{id}/shell/session/{sid}/write        { input }
POST /v1/api/hosts/{id}/shell/session/{sid}/read         { max? } → { data: base64, eof: bool }
POST /v1/api/hosts/{id}/shell/session/{sid}/resize       { cols, rows }
POST /v1/api/hosts/{id}/shell/session/{sid}/close
```

注：浏览器用 `/v1/user/hosts/{id}/terminal/ws` 走中枢中转拉模式（拉模式封装在中枢内部，不出 `/v1/api/*`）。

### 7.5 系统信息（controls 1<<24..1<<28）

```
POST /v1/api/hosts/{id}/system/info        → SystemInfo
POST /v1/api/hosts/{id}/system/metrics     → MetricsResult（CPU/Mem/Disk/Net/TCP/Uptime）
POST /v1/api/hosts/{id}/service/ctl
POST /v1/api/hosts/{id}/registry/rw
POST /v1/api/hosts/{id}/scheduled-task/ctl
```

### 7.6 编排（mir2，controls 16384/32768）

```
POST /v1/api/hosts/{id}/orchestrate/start?base_path=<game_dir>
POST /v1/api/hosts/{id}/orchestrate/shutdown
```

启动入参（详见 `api/openapi.yaml::OrchestrateStartReq`，0.2.3+ 实装）：

| 字段 | 类型 | 说明 |
|---|---|---|
| `controller_exe` | string | 默认 `GameCenter.exe`；GOM 系传 `GameOfMir引擎控制器.exe` |
| `resolve_game_dir` | bool | true 时在 `base_path` 下三层探测含 controller_exe 的子目录 |
| `spawn_subprocesses` | bool | true 时按 manifest 顺序 spawn DBServer/LoginSrv/LogServer/M2Server/RunGate×N/SelGate/LoginGate |
| `spawn_delay_ms` | int | 两子进程间隔（默认 3000）|
| `enabled` | map<str,bool> | 每个 section 的 GetStart 覆盖 |
| `run_gate_count` | int 1..16 | RunGate 实例数 |
| `ports` | map<`Section.Key`,int> | 任意 INI 字段覆盖（端口 / 任意整数字段）|

行为：write_game_center_config 用 **GBK + CRLF** merge 写 `<game_dir>\Config.ini`，
保留所有未覆盖字段 → start_game_center 用 `CREATE_NEW_PROCESS_GROUP | CREATE_NEW_CONSOLE`
spawn 控制器（CWD = game_dir）→ 按 manifest spawn 子进程。

关停三段式：先 hard kill 控制器（防 TimerCheckRun 重拉）→ 反向发 `GS_QUIT = 2000`
（WM_COPYDATA dwData = `GS_QUIT << 16`，所有 mir2 子进程 Grobal2.pas 通用约定）→
等 per-process timeout（DBServer/M2Server=60s，Log/LoginServer=30s，*Gate=20s）→
WM_CLOSE → TerminateProcess 兜底。可用 `timeout_override_s` 全局覆盖。

### 7.7 通用 raw control

```
POST /v1/api/hosts/{id}/controls/raw   { controls: <int64>, path, data }
```

高级用户可绕开 SDK 已封方法直接发任意 controls 值。

兼容旧 m2-gateway 16 个操作码（位字段 `1<<0` ~ `1<<15`），均已在 Rust agent 实装：

| controls | 名称 | 说明 |
|---|---|---|
| `1` (1<<0) | DIR_MK | 创建目录 |
| `2` (1<<1) | DIR_RM | 删除目录 |
| `4` (1<<2) | FILE_MK | 创建文件 |
| `8` (1<<3) | FILE_RM | 删除文件 |
| `16` (1<<4) | STR_AD | 追加字符串到文件 |
| `32` (1<<5) | STR_RD | 读字符串 |
| `64` (1<<6) | STR_RM | 删除字符串 |
| `128` (1<<7) | **ZIP_2_DIR** | 解压 zip 到目录（mir2 引擎包部署用）|
| `256` (1<<8) | **DOWN_FILE** | 让 VM 下载 URL 到本地（mir2 引擎包拉取用）|
| `512` (1<<9) | UPLOAD_FILE | 上传文件到中枢（M4 实装中）|
| `1024` (1<<10) | EXEC_PROGRAM | 异步启进程（fire-and-forget；返 pid）|
| `2048` (1<<11) | CHANGE_WATCH_DIR | 改 watch 目录 |
| `4096` (1<<12) | KILL_PROCESS | 软 kill → 硬 kill 兜底 |
| `8192` (1<<13) | EXEC_SHELL | 同步执行 shell + 捕获 stdout/stderr/exit_code |
| `16384` (1<<14) | ORCHESTRATED_START | mir2 引擎启动（mir2 feature）|
| `32768` (1<<15) | ORCHESTRATED_SHUTDOWN | mir2 引擎关停（mir2 feature）|

L2 新增（M3+，`1<<16` 起）：见 `gateway/src/controls/mod.rs::dispatch`。

### 7.8 L1 配置编辑（M7-01）

用户业务系统主动改某台主机的 `{bid, secretKey, 反向上报 URL}`。同套 commit 逻辑与
`/v1/user/hosts/:id/l1-config` 完全一致——区别只是入口（API Key + 按 token 计费）。

```
PUT /v1/api/hosts/{id}/l1-config   { bid, secret_key, url }
                                  → 200 L1ConfigPutResp
```

- 计费 1 token (`control.update_l1`)
- 写库成功后，agent 在线时**异步**下发 SettingsUpdate control（1<<30）让本地 setup.conf 软重启生效
- 同时入 `webhook_deliveries_v2` 队列异步推送变更通知到 host.report_url（见 §12）

---

## 8. 计费规则

按 `billing_pricing_rules` 表的 code 扣，admin 可改，立即生效。

| code | 单价 | 路由 |
|---|---|---|
| `control.dir.per_call` | 1 token | files/dir |
| `control.file_read.per_call` | 0.5 token | files/text (read) |
| `control.file_write.per_call` | 1 token | files/text (write/append) |
| `control.exec_shell.per_call` | 5 token | process/shell |
| `control.exec_program.per_call` | 1 token | process/exec |
| `control.kill_process.per_call` | 2 token | process/kill |
| `control.system_info.per_call` | 0.5 token | system/info |
| `control.system_metrics.per_call` | 0.1 token | system/metrics |
| `control.shell_session.per_op` | 0.1 token | shell/session/* |
| `control.update_l1` | 1 token | PUT hosts/:id/l1-config（M7-01） |

充值汇率：**1 CNY = 100 token**。

扣费时机：**先扣后调**。`BillingCharge` middleware 在路由命中前扣；handler 失败也不退还（避免抖动套利；admin 可手工调余额补回）。

---

## 9. 数据类型（精简版）

### User
```
{ id: uuid, email, display_name, status: "active"|"suspended", created_at }
```

### Host
```
{ id, user_id, name, bid, listen_port, public_ip?, os?, installed_version?,
  report_url?,                                              // M7-01：L1 反向上报 + webhook 推送目标
  status: "pending"|"online"|"stale"|"offline"|"banned",
  last_seen_at?, last_online_at?, created_at }
```

`HostListItem = Host + { online: boolean }`（wshub 实时态）

admin 列表项额外含 `user_email`（join users 表，M7-02）。

### L1ConfigPutResp
```
{ bid: int64, has_secret_key: bool, url: string,
  agent_online: bool,         // 本次写库后 agent 是否在线（决定是否下发 SettingsUpdate）
  delivery_id?: uuid }        // 入队 webhook_deliveries_v2 的 row id
```

### WebhookDeliveryRow
```
{ id: uuid, event_type, status: "pending"|"retrying"|"success"|"dead_letter"|"skipped_no_url",
  attempts: int, target_url, http_status?, created_at, delivered_at? }
```

### APIKey
```
{ id, name, prefix: "ak_<key_id>", last_used_at?, created_at }
```

`APIKeyCreated = APIKey + { secret: "ak_<key_id>.<secret>" }`（完整 secret 仅一次）

### MetricsResult
```
{ cpu_pct: 0..100, mem_pct, disk_pct,
  net_rx_bps, net_tx_bps, tcp_conns, uptime_s }
```

### ShellResult
```
{ stdout, stderr, exit_code, duration_ms, timed_out }
```

### GatewayVersion
```
{ id, version: "0.3.0", channel: "stable"|"beta"|"canary",
  exe_url, exe_sha256: "64 hex", exe_size_bytes,
  min_supported_protocol: 1, changelog?,
  released_at, yanked_at? }
```

完整 schema：[`api/openapi.yaml`](../api/openapi.yaml) §components/schemas（24 个）。

---

## 10. Webhook 推送（M7-01）

中枢主动 POST 事件通知到用户业务系统。**复用 `hosts.report_url` 作为推送目标**——
不需要单独注册 webhook endpoint（每台主机一个 URL）。

### 10.1 入队触发

`PUT /v1/user/hosts/:id/l1-config` / `PUT /v1/api/hosts/:id/l1-config` /
`POST /v1/internal/agent/settings` 三入口任一成功后，事务内同时往
`webhook_deliveries_v2` 表入一条 pending 行。worker（gw-api 容器内 goroutine）
每 5s 扫表 → POST → 2xx 标 success；失败按 30s/1m/5m/30m/2h 退避，attempts ≥ 5 进 `dead_letter`。

### 10.2 报文格式（v1）

```http
POST <hosts.report_url> HTTP/1.1
Content-Type: application/json
X-Gateway-Event: host.l1_config_updated
X-Gateway-Delivery: <uuid>
X-Gateway-Signature: t=1778596800,v1=<hex_hmac_sha256>
User-Agent: gateway-webhook/1.0

{
  "event": "host.l1_config_updated",
  "delivery_id": "<uuid>",
  "user_id":     "<uuid>",
  "host_id":     "<uuid>",
  "source":      "web" | "agent" | "client_api",
  "occurred_at": "2026-05-13T03:08:00Z",
  "data": {
    "bid":        <int64>,
    "secret_key": "<32 char hex>",   // 新值
    "url":        "<https://...>"     // 新值（也是本次推送的 target_url）
  }
}
```

### 10.3 签名验证

签名头 `X-Gateway-Signature` 形如 `t=<unix_seconds>,v1=<hex64>`，其中：

```
v1 = hex(HMAC-SHA256(key = 旧 secret_key, message = "<t>." + raw_body))
```

**关键设计**：HMAC key 用**旧** `secret_key`，报文 data 内的 `secret_key` 是**新**值。

用户业务系统验签流程：

```python
def verify(headers, body, current_secret_key):
    sig = headers["X-Gateway-Signature"]               # "t=...,v1=..."
    t   = int(sig.split(",")[0][2:])
    v1  = sig.split(",")[1][3:]
    if abs(time.time() - t) > 300:                     # 拒收 5min 外的报文（防重放）
        return False
    expected = hmac.new(current_secret_key.encode(),
                        f"{t}.".encode() + body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

验通过 → 取 `body.data.secret_key` 替换本地存储的 secret_key（下次 L1 调用用新值）。
验不通过 → 拒收（防伪造）。

### 10.4 已支持事件类型

| event | 触发 | data schema |
|---|---|---|
| `host.l1_config_updated` | 任一入口修改 L1 配置成功 | `{ bid, secret_key, url }` |

### 10.5 用户端可见的投递状态

```
GET /v1/user/hosts/{id}/webhook-deliveries
→ 200 { items: WebhookDeliveryRow[] }
```

每行含 `status`、`attempts`、`http_status`、`created_at`、`delivered_at`，
方便用户业务系统排查（重试 / 死信）。

---

## 11. `/v1/internal/agent/*`（agent 内部 API，不在 SDK 范围）

```
POST /v1/internal/agent/online           one-shot 消费 install_token → 颁发 agent_token
GET  /v1/internal/agent/ws               升级 wss + 加入 hub（每次握手刷新 hosts.public_ip）
POST /v1/internal/agent/heartbeat        兜底心跳（WS 重连中）
POST /v1/internal/agent/token/refresh    ed25519 challenge-response 续 agent_token
POST /v1/internal/agent/settings         M7-01：agent 本地 GUI 改 bid/secretKey/url 后上报中枢
                                          body { bid, secret_key, url } → 200 { ok, delivery_id }
                                          中枢校验后写 hosts + host_secrets + 入 webhook 队列
```

详细 envelope（hello / cmd / cmd_resp / metrics / event）见
[`PROTOCOL-AGENT-WS.md`](./PROTOCOL-AGENT-WS.md)。

---

## 12. 协议

agent ↔ 中枢的 wss + JSON envelope + binary stream frame 详细规范见 [`PROTOCOL-AGENT-WS.md`](./PROTOCOL-AGENT-WS.md)。

L1 兼容协议（旧 m2-gateway 客户端可直接调）：

- HTTP `POST /gateway`
- AES-256-CBC + base64 + PKCS7
- secretKey 32 字符；IV = secretKey[:16]
- 16 个老 controls (1, 2, 4, ... 32768)
- 响应 `{code, data, msg}`，code=0/7

---

## 13. SDK 速查

三语言完全对齐——同方法名（按各语言惯例）/ 同参数顺序 / 同错误类型 / 同 controls 值。

| 操作 | Go | Python | TypeScript |
|---|---|---|---|
| 列主机 | `c.ListHosts(ctx)` | `c.list_hosts()` | `c.listHosts()` |
| 跑 shell | `c.ExecShell(ctx, hostID, ShellRequest{...})` | `c.exec_shell(host_id, ShellRequest(...))` | `c.execShell(hostId, {...})` |
| 异步启程序 | `c.ExecProgram(ctx, hostID, path, args)` | `c.exec_program(host_id, path, args)` | `c.execProgram(hostId, path, args)` |
| 杀进程 | `c.KillProcess(...)` | `c.kill_process(...)` | `c.killProcess(...)` |
| 性能采样 | `c.SystemMetrics(ctx, hostID)` | `c.system_metrics(host_id)` | `c.systemMetrics(hostId)` |
| 创建目录 | `c.MakeDir(...)` | `c.make_dir(...)` | `c.makeDir(...)` |
| 删目录 | `c.RemoveDir(...)` | `c.remove_dir(...)` | `c.removeDir(...)` |
| 任意 control | `c.RawControl(...)` | `c.raw_control(...)` | `c.rawControl(...)` |

错误判别 helper：

| 错误 | Go | Python | TypeScript |
|---|---|---|---|
| 余额不足（402） | `sdk.IsInsufficientBalance(err)` | `is_insufficient_balance(err)` | `isInsufficientBalance(err)` |
| 主机离线（409） | `sdk.IsHostOffline(err)` | `is_host_offline(err)` | `isHostOffline(err)` |

完整文档：

- [Go SDK](../sdk-clients/go/README.md)
- [Python SDK](../sdk-clients/python/README.md)
- [TypeScript SDK](../sdk-clients/typescript/README.md)
- 在线文档站：marketing-site `/docs/sdk-go` 等
