# 客户端联动契约（CLIENT-CONTRACT）

> 数据源：本任务（`infra/domains/`）产出的签名端点文档与公告文档。
> 消费方：app 服务层（doc/01 §2 服务层 / §4 客户端断网弹性、doc/06 §5 断网矩阵）。
> 本文件是客户端与分发面之间的**接口约定**。改 schema / 验签规则 / 兜底链顺序须双方评审。

## 1. 文档类型与 schema

两类文档共用同一个**签名信封**（`sign.Envelope`），只是 `payload` 不同。

### 1.1 信封（envelope）

```jsonc
{
  "version":   3,                       // uint64，单调递增（防回滚）
  "issued_at": "2026-06-13T00:00:00Z",  // RFC3339 UTC，仅供展示/审计
  "key_id":    "ed25519-2026q2",        // 选择验签公钥
  "payload":   { /* 见下 */ },
  "sig":       "base64(ed25519 签名)"
}
```

- 签名覆盖 **去掉 `sig` 后** 的规范化 JSON（键按字典序排序、无多余空白、数组顺序保留）。客户端验签前必须用同样的规范化算法重算签名字节。规范化定义见 `tools/internal/sign`（`Canonicalize`）。
- 客户端**只内置公钥**，绝不内置私钥（doc/06 §3）。

### 1.2 端点文档（`endpoints.v1.json`）payload

```jsonc
{
  "api_domains": ["api-a.example.com", "api-b.example.net"], // 必填，≥1，已去重排序
  "mirror_urls": ["https://.../endpoints.v1.json", "..."],   // 必填，≥1，下一份文档的镜像
  "emergency_nodes_hint": ["builtin"],                       // 可选，指向应急节点参数的不敏感线索（非参数本身）
  "notice": { /* 见 1.3，可选内联公告 */ },
  "channel": ""                                              // 可选，渠道标识（见 §6）
}
```

### 1.3 公告文档（`notices.v1.json`）payload（/v1/notices 的静态镜像版）

```jsonc
{
  "notices": [
    {
      "id": "2026-q2-mirror-move",   // 必填，唯一
      "level": "warning",            // info | warning | critical
      "title_zh": "…", "title_en": "…",   // 必填（双语）
      "body_zh": "…",  "body_en": "…",
      "url": "https://…",            // 可选
      "published_at": "2026-06-13T00:00:00Z"
    }
  ]
}
```

`notice` 既可内联进端点文档（随端点更新一起到达），也可作为独立 `notices.v1.json` 单独发布。两者 schema 相同。

## 2. 验签规则（客户端必须实现）

客户端接受一份文档**当且仅当**全部通过：

1. **签名有效**：`key_id` 命中内置公钥环中的某个公钥，且签名校验通过。
2. **防回滚**：`version` **严格大于**客户端当前已信任的 `version`（首次为 0）。等于或更小一律拒绝（防降级 / 重放攻击）。
3. **schema 校验**：端点文档 `api_domains≥1`、`mirror_urls≥1`、域名 / URL 合法；公告 `level` 合法、双语标题与 `published_at` 齐备、`id` 唯一。

任一不过 → 丢弃该文档、保留当前已信任版本、记审计。参考实现：`tools/internal/endpoint`（`VerifyDocument`）。

## 3. 公钥内置与轮换（双公钥过渡）

- 客户端内置一个**公钥环** `key_id → 公钥`（可含多把）。
- 轮换时（doc/06 §6 每季）：先发版让客户端**同时内置新旧两把公钥**；签名侧切到新 `key_id` 出文档；旧 key 在所有存量客户端都已带新公钥后再退役。过渡期内新旧 `key_id` 的文档都能验签。
- `key_id` 不命中公钥环 → 视为验签失败（拒绝）。

## 4. 合并策略

验签通过后并入本地端点池：

- `api_domains`：与本地池**并集去重**；保留客户端「成功端点置顶记忆」（doc/01 §4）——历史可用端点优先，再追加新域名。
- `mirror_urls`：替换为文档值（镜像列表以最新签名文档为准）。
- `emergency_nodes_hint`：覆盖式更新。
- `notice`：按 `id` 去重入公告位；`critical` 置顶。
- 整体只有 `version` 更大的文档才会触发合并（见 §2.2）。

## 5. 客户端兜底链顺序（断网弹性，doc/01 §4 + doc/06 §5）

服务层按下面顺序逐级递降，**每一层的信任锚都在安装包内离线可用**：

```
1. 缓存目录        ——上次成功的节点目录 + 端点池（带 version/时间戳），启动即用
2. 内置端点池      ——安装包内置 api_domains + IP 直连兜底（按渠道分包，§6）
3. DoH 多提供方    ——域名解析优先 DoH 轮询，绕本地 DNS 污染
4. IP 直连兜底     ——DoH 仍失败时用内置 IP 直连
5. 签名端点更新    ——从 mirror_urls（≥3 镜像）拉新文档，验签（§2）后合并（§4），刷新端点池/域名
6. 应急节点        ——emergency_nodes_hint 指向的内置应急参数，低速仅够拉新目录
7. TG 频道         ——最终人工广播渠道，「联系我们」离线可见
```

要点：

- 上层失败才降到下层；任一层成功即停止下降并回升。
- 第 5 层取文档时按 `mirror_urls` 顺序**故障转移**：单镜像失效仍从其余镜像取（与 `tools/publish-mirrors` 的 `Fetch` 行为一致）。
- 第 1～4、6、7 层都不依赖「正在故障的那个组件」自身恢复。

## 6. channel 字段（敏感参数按渠道分包轮换）

- `channel` 标识分发渠道（如应用商店 / 官网直装 / 某分发包）。空字符串 = 适用所有渠道。
- 用途：敏感内置参数（IP 子集、应急节点）按渠道分包轮换，**泄露一个渠道不烧全部**（doc/06 §3 客户端 / doc/01 §4 注意框）。
- 客户端行为：携带自身 `channel` 标识；只接受 `channel` 为空或与自身匹配的文档（端点合并时按此过滤）。

## 7. 域名被墙检测（接口需求，本任务不实现）

复用 **#15 探针体系**做域名级 DNS 污染 / TCP 阻断探测（doc/05 §3）。客户端 / 控制面只需消费探测结论触发兜底链与启用流程；本任务只登记接口需求，不实现探针。详见 `domains.md` §4。

## 8. 评审

本契约需与**客户端任务负责人**评审对齐后冻结。冻结后任何 schema / 验签 / 兜底链顺序变更都要双方签字并升 `version` 语义说明。
