# Only in One · 控制面 API 契约(v1) > 本文以 `server/server.js` 的路由表为**唯一依据**逐条核对,不是设计稿。 > 代码改动导致本文过期时,以代码为准并同步更新本文。 > > **端点数**:`server.js` 中共 **10 条路由注册**,对应 **9 个逻辑端点** + > 1 个兼容别名(`/api/health` 与 `/api/v1/health` 由同一个 handler 双注册)。 > 复核命令:`grep -cE "^ app\.(get|post)\(" server/server.js` → `10`。 - 基础地址:`http://:`(默认 `0.0.0.0:3000`,见 `server/config.js`) - 版本前缀:`/api/v1` - 所有请求与响应均为 `application/json; charset=utf-8`,**只有一个例外**:308 重定向响应体是 `text/plain` --- ## 1. 版本前缀与 308 重定向 `/api/*` 会被 `legacyApiRedirect()` 中间件 **308 永久重定向**到 `/api/v1/*`: | 请求 | 响应 | |---|---| | `GET /api/devices` | `308`,`Location: /api/v1/devices`,body `Moved Permanently to /api/v1/devices` | | `POST /api/auth/login` | `308`,`Location: /api/v1/auth/login` | | `GET /api/v1/devices` | 直接命中路由(不重定向) | | `GET /api/health` | **不重定向**,由双注册直接处理 | | `GET /api/health?x=1`、`GET /api/health/` | 同样不重定向(前缀豁免) | 设计原因:容器探针与监控不应依赖跟随重定向。**客户端(启动器)一律直接打 `/api/v1`**,不做兼容(`launcher/src/api.ts` 的 `API_PREFIX`)。 > ⚠️ 308 会保留原始方法与请求体,但**中间件在 `express.json()` 之前**执行, > 因此重定向响应不会解析 body —— 这正是我们想要的行为(不重复消费流)。 --- ## 2. 统一响应信封 **成功**: ```json { "ok": true, "data": { } } ``` - `data` 为 `undefined` 时序列化为 `null`(`sendOk` 兜底),因此**永远有 `data` 字段**。 - 状态码默认 `200`,注册成功为 `201`。 **失败**: ```json { "ok": false, "error": { "code": "VALIDATION_FAILED", "message": "请求参数不正确", "hint": "检查输入后重试", "details": { "field": "device_name" } } } ``` 字段规则(由 `buildErrorBody()` 强制,键顺序固定): | 字段 | 何时出现 | 说明 | |---|---|---| | `code` | 总是 | 必须在错误码总表内;未知码回落 `INTERNAL` | | `message` | 总是 | 中文人话,由错误码表单向生成,业务代码不得手写 | | `hint` | 该码定义了处置建议时 | 中文处置建议 | | `details` | **仅 4xx** 且调用方传了非空对象 | 结构化补充(如 `{field:'password', reason:'密码不能为纯数字'}`) | | `requestId` | **仅 5xx** | 与响应头 `X-Request-Id` 一致,用于对日志 | 两条硬约束: 1. **401 响应体逐字节稳定**:`INVALID_CREDENTIALS` 不区分「用户不存在」与 「密码错误」,也不携带 `details` —— 否则等于给出账号枚举接口。 2. **5xx 绝不回显堆栈**,只给 `requestId`。 --- ## 3. 安全头、大小限制与 CORS `securityHeaders()` 对**所有**响应(含 404/500)设置: | 响应头 | 值 | |---|---| | `X-Request-Id` | 传入的 `x-request-id`(≤128 字符)或新 UUID | | `X-Content-Type-Options` | `nosniff` | | `X-Frame-Options` | `DENY` | | `Referrer-Policy` | `no-referrer` | | `Content-Security-Policy` | `default-src 'none'; frame-ancestors 'none'; base-uri 'none'` | | `Cache-Control` | `no-store` | 另外 `app.disable('x-powered-by')`(不暴露 Express 版本)。 - **请求体上限 64KB**:`express.json({ limit: '64kb' })`。超限返回 `413 PAYLOAD_TOO_LARGE`;非法 JSON 返回 `400 VALIDATION_FAILED` (`details.reason = "body 不是合法 JSON"`)。 - **CORS**:服务**不发送任何** `Access-Control-Allow-*` 头 ⇒ 浏览器默认拒绝跨源读取。 启动器是 Electron 主进程发请求(无同源限制),因此无需 CORS 配置。 - **代理头**:默认不信任 `X-Forwarded-For`。反向代理部署必须设 `TRUST_PROXY=1`, 否则限流统计全部落在 `127.0.0.1` 上(详见 `docs/security.md`)。 --- ## 4. 鉴权模型 | 项 | 值 | 依据 | |---|---|---| | 算法 | `HS256`(显式限定,防算法混淆) | `tokens.js:14` | | access token | 默认 **2 小时**(`ACCESS_TOKEN_TTL_SECONDS=7200`,允许 60–86400) | `config.js` | | refresh token | 默认 **30 天**(`REFRESH_TOKEN_TTL_SECONDS=2592000`,允许 3600–31536000) | `config.js` | | access payload | `{ sub, username, type:'access', jti, iat, exp }` | `tokens.js` | | refresh payload | `{ sub, type:'refresh', jti, iat, exp }` | `tokens.js` | | 吊销 | `token_revocations(jti PRIMARY KEY, user_id, revoked_at, expires_at)` | `migrations/002` | 传递方式:`Authorization: Bearer `。 校验顺序与对应错误码(`verify()`): 1. 不是非空字符串 → `401 UNAUTHORIZED` 2. 签名/格式错(`jsonwebtoken` 抛错但**不是** `TokenExpiredError`)→ `401 UNAUTHORIZED` 3. 已过期(`TokenExpiredError`)→ `401 TOKEN_EXPIRED` 4. `payload.type` 与期望不符(拿 refresh 当 access 用)→ `401 UNAUTHORIZED` 5. 缺 `sub` / 缺 `jti` → `401 UNAUTHORIZED` 6. `jti` 在吊销表中(查询失败按**已吊销**处理,fail-closed)→ `401 TOKEN_REVOKED` **refresh 一次性轮换**:`POST /auth/refresh` 在签发新 token 前先 `revoke(旧 jti)`, 因此**同一个 refreshToken 只能用一次**。并发刷新会有一个成功、另一个收到 `TOKEN_REVOKED`(这是预期行为,客户端应串行刷新)。 **登出语义**:`POST /auth/logout` 吊销**当前 access 的 jti**;若 body 里带了 `refreshToken` 且 `userId` 匹配,一并吊销它的 jti。refresh 已过期/已吊销都不影响 登出返回 200 —— 登出的用户预期是「无论如何我都不再是登录状态」。 --- ## 5. 端点清单(9 个逻辑端点) | # | 方法 | 路径 | 鉴权 | 一句话 | |---|---|---|---|---| | 1 | `GET` | `/api/v1/health`(别名 `/api/health`) | 无 | 存活与 DB 探活 | | 2 | `POST` | `/api/v1/auth/register` | 无 | 注册(成功 `201`) | | 3 | `POST` | `/api/v1/auth/login` | 无 | 登录,发 access + refresh | | 4 | `POST` | `/api/v1/auth/refresh` | 无(凭 body 里的 refreshToken) | 轮换令牌(一次性) | | 5 | `POST` | `/api/v1/auth/logout` | Bearer | 吊销当前 access(可选吊销 refresh) | | 6 | `POST` | `/api/v1/devices/heartbeat` | Bearer | 上报在线状态与 IP(设备注册入口) | | 7 | `GET` | `/api/v1/devices` | Bearer | 本账号设备列表(含在线判定) | | 8 | `POST` | `/api/v1/certs` | Bearer | 上传**公钥**证书(服务端算指纹) | | 9 | `GET` | `/api/v1/certs` | Bearer | 拉取本账号全部 peer 证书 | 以下逐条给出请求/响应/错误码/curl。 --- ### 5.1 `GET /api/v1/health` 无鉴权、无参数。用于容器探针与监控;`/api/health` 是同 handler 的别名。 **成功 `200`** ```json { "ok": true, "data": { "version": "0.1.0", "uptimeMs": 123456, "dbOk": true } } ``` - `version` 取自 `server/package.json` - `uptimeMs` 为进程内计时(`Date.now() - STARTED_AT`) - `dbOk`:`SELECT 1` 是否成功。**DB 挂了仍返回 200**,靠 `dbOk: false` 表达 (探针不该因为业务数据不可用而重启进程) **错误**:无(除全局 500)。 ```bash curl -s http://127.0.0.1:3000/api/v1/health curl -s http://127.0.0.1:3000/api/health # 别名,不重定向 ``` --- ### 5.2 `POST /api/v1/auth/register` **请求体** | 字段 | 类型 | 必填 | 校验规则 | |---|---|---|---| | `username` | string | ✅ | 3–32 位 `[A-Za-z0-9_-]`;大小写不敏感去重(`LOWER(username)` 唯一索引) | | `password` | string | ✅ | 10–200 位;不得纯数字、纯字母、全同字符;不在内置弱口令表内 | | `email` | string | ❌ | 非空时:≤254 字符、无控制字符、形如 `a@b.c`。缺省/空串视为「未提供」 | **成功 `201`** ```json { "ok": true, "data": { "userId": 7 } } ``` **失败** | 状态 | code | 触发条件 | `details` | |---|---|---|---| | 400 | `INVALID_USERNAME` | 用户名不合规 | `{field:'username'}` | | 400 | `WEAK_PASSWORD` | 密码不合规 | `{field:'password', reason:'密码不能为纯数字'}` 等 | | 400 | `VALIDATION_FAILED` | `email` 不合规 / body 非法 JSON / 不支持的编码 | `{field:'email'}` 或 `{reason:...}` | | 409 | `USER_EXISTS` | 用户名已存在(含并发插入命中 UNIQUE) | `{field:'username'}` | | 413 | `PAYLOAD_TOO_LARGE` | 请求体 > 64KB | — | | 500 | `INTERNAL` | 未预期异常 | — | ```bash curl -sS -X POST http://127.0.0.1:3000/api/v1/auth/register \ -H 'Content-Type: application/json' \ -d '{"username":"kou","password":"Only1nOne-Strong","email":"kou@example.com"}' ``` --- ### 5.3 `POST /api/v1/auth/login` **请求体** | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `username` | string | ✅ | 大小写不敏感 | | `password` | string | ✅ | 不落日志、不进 details | **限流前置**:同一 IP **或**同一用户名,60s 窗口内失败 ≥5 次 ⇒ 锁定 15 分钟。 锁定期间**连正确密码也返回 429**(`details.retryAfter` 为剩余秒数)。 **成功 `200`** ```json { "ok": true, "data": { "accessToken": "eyJ...", "refreshToken": "eyJ...", "expiresIn": 7200, "userId": 7, "username": "kou", "serverTime": 1730000000000 } } ``` `serverTime` 供客户端校正时钟偏移(在线三态判定用)。 **失败** | 状态 | code | 触发条件 | `details` | |---|---|---|---| | 401 | `INVALID_CREDENTIALS` | 用户不存在 **或** 密码错误 **或** 存储的哈希畸形(三种情况响应体**逐字节一致**) | — | | 429 | `RATE_LIMITED` | 已锁定 | `{retryAfter: 秒}` | | 400 | `VALIDATION_FAILED` | body 非法 JSON | `{reason:...}` | | 413 | `PAYLOAD_TOO_LARGE` | > 64KB | — | ```bash TOKEN_JSON=$(curl -sS -X POST http://127.0.0.1:3000/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"username":"kou","password":"Only1nOne-Strong"}') ACCESS=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).data.accessToken)" "$TOKEN_JSON") ``` --- ### 5.4 `POST /api/v1/auth/refresh` **请求体** | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `refreshToken` | string | ✅ | 必须是一次性的旧 refresh | **成功 `200`**(**同时返回新的 refreshToken**,旧的立即失效) ```json { "ok": true, "data": { "accessToken": "eyJ...", "refreshToken": "eyJ...", "expiresIn": 7200, "serverTime": 1730000000000 } } ``` **失败** | 状态 | code | 触发条件 | |---|---|---| | 401 | `TOKEN_EXPIRED` | refresh 自然过期 | | 401 | `TOKEN_REVOKED` | 该 refresh 已被用过(一次性轮换)或已被登出 | | 401 | `UNAUTHORIZED` | 空/格式错/签名错/**传了 access 而不是 refresh**/用户已被删除 | > 客户端约定:`TOKEN_EXPIRED` 且**没有** refreshToken ⇒ 无法自救 ⇒ 触发登出 > (`launcher/src/api.ts#detectSessionInvalid`)。 ```bash curl -sS -X POST http://127.0.0.1:3000/api/v1/auth/refresh \ -H 'Content-Type: application/json' \ -d "{\"refreshToken\":\"$REFRESH\"}" ``` --- ### 5.5 `POST /api/v1/auth/logout` **鉴权**:`Authorization: Bearer ` 必填。 **请求体**(可选,可为空 body `{}`) | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `refreshToken` | string | ❌ | 一并吊销该 refresh 的 jti;校验失败静默忽略 | **成功 `200`**:`{ "ok": true, "data": {} }` **失败**:`401 UNAUTHORIZED` / `401 TOKEN_EXPIRED` / `401 TOKEN_REVOKED`(Bearer 校验)。 ```bash curl -sS -X POST http://127.0.0.1:3000/api/v1/auth/logout \ -H "Authorization: Bearer $ACCESS" \ -H 'Content-Type: application/json' \ -d "{\"refreshToken\":\"$REFRESH\"}" ``` --- ### 5.6 `POST /api/v1/devices/heartbeat` **鉴权**:Bearer 必填。**这是设备注册的唯一入口**(没有独立的 `POST /devices`)。 **请求体** | 字段 | 类型 | 必填 | 校验规则 / 非法时的行为 | |---|---|---|---| | `device_name` | string | ✅ | `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`:**首字符必须是字母或数字**,其后可含 `.` `_` `-`,总长 1–64。**非法直接 400** | | `device_id` | string | ❌ | `[A-Za-z0-9-]{8,64}`(UUID 或同类稳定标识;**与 launcher `DEVICE_ID_PATTERN` 逐字一致**)。**非法则忽略**(保留上次值)。该值是**对端证书文件名的主体**(`moonlight-.pem`),因此**不能为空、不能短于 8 位**:为空的设备被跳过写入(`missing_device_id`),格式不合法的被跳过(`invalid_device_id`),两者的证书都不会落到对端 | | `platform` | string | ❌ | 枚举 `win32`/`darwin`/`linux`。**非法则忽略** | | `lan_ip` | string | ❌ | 必须是**可路由** IPv4(拒回环/链路本地/组播/`.0`/`.255`)。**非法则忽略** | | `tailscale_ip` | string | ❌ | 同上。**非法则忽略** | > **与客户端的一致性(D1 缺陷的教训)**:本规则与 launcher 把设备名拼成磁盘文件名时 > 用的 `SAFE_NAME_RE` **首字符口径完全一致**(都必须字母/数字开头)。两端一旦不一致, > 服务端会接受一个客户端永远写不出文件的设备名(如 `-dash`),后果是该设备每次串流 > 都要手输 PIN,而日志里只有一条 warn —— 现场几乎排查不出来。 > 收紧方向只能是「服务端对齐客户端」,**不得**反过来放宽客户端白名单。 > **老数据兜底规则**(重要):`device_id` / `platform` / `lan_ip` / `tailscale_ip` > 四个字段非法或缺失时**不报错**,一律「忽略并保留数据库里的上一次值」。 > 原因:老客户端不知道这些字段,若是新校验把它们打挂,升级服务端就等于全量掉线。 > 唯一强制的是 `device_name`(没有它无法定位设备行)。 **插入/更新语义** - 按 `(user_id, device_name)` 查行:存在则 `UPDATE`(`last_seen = now`),否则 `INSERT`。 - 并发下同名设备同时插入会命中 `UNIQUE(user_id, device_name)`,服务端**重查一次** 按更新处理(不会给客户端返回 500)。 - **`device_id` 撞唯一约束时降级为 `null` 写回/写入**(R10)。`devices` 上有 `UNIQUE INDEX idx_devices_uuid(user_id, device_id)`:某台客户端上报了与他人重复的 `device_id`(克隆虚拟机、复制了旧机器的 `settings.json`)时,写库会抛异常。 服务端**不返回 500**,而是把这一行的 `device_id` 写成 `null` 并记一条 `warn` —— 保住「这台设备在线」这个事实(心跳的核心价值),代价是该设备本轮拿不到文件名键, 客户端按 `missing_device_id` 跳过写入,下一轮心跳拿到新 id 即自愈。 两条写路径都要兜: - **UPDATE 分支**(同名设备已存在,例如「复制了 settings.json 但没改名」); - **INSERT 分支**(新设备名 + 已被占用的 `device_id`,例如「复制后又改了名」)。 INSERT 兜底时若降级插入再次撞 `UNIQUE(user_id, device_name)`(并发同名), 要退回 UPDATE 分支而不是继续上抛 —— 否则降级路径会在并发下重新变成 500。 **INSERT 漏兜的后果比 UPDATE 更重**:行建不出来 ⇒ 下一次心跳仍然走 INSERT、 仍然 500,是永久性的,而不是一次性抖动。 - **客户端必须读这个 `null` 并换 id,否则「降级」等于「永久无 id」**: `launcher/src/agent.ts` 的心跳在 `data.device_id === null` 时会调 `regenerateDeviceId()` 重新生成并持久化一个新 UUID,下一轮心跳(≤30s)即带新 id。 判据严格用 `=== null`:字段**缺失**(`undefined`)表示老服务端根本不回这个值, 与「被降级」无关,不能误触发(否则会每轮换 id,永远撞不完)。 **成功 `200`** ```json { "ok": true, "data": { "deviceId": 3, "device_id": "9f1c...", "serverTime": 1730000000000 } } ``` `deviceId` 是**数据库自增主键**(int),`device_id` 是客户端稳定 UUID(可能为 `null`)。 两者不要混用。响应里的 `device_id` 是**本次实际写入库的值**:正常情况等于请求体里的值, `device_id` 撞唯一约束时为 `null`(见上方 R10 的降级规则,UPDATE / INSERT 两条路径都会降级), 请求体未带时为库里上一次的值。 **失败** | 状态 | code | 触发条件 | `details` | |---|---|---|---| | 400 | `VALIDATION_FAILED` | `device_name` 非法 | `{field:'device_name', reason:'需匹配 [A-Za-z0-9][A-Za-z0-9._-]{0,63}(首字符须为字母或数字)'}` | | 401 | `UNAUTHORIZED` / `TOKEN_EXPIRED` / `TOKEN_REVOKED` | Bearer 校验 | — | ```bash curl -sS -X POST http://127.0.0.1:3000/api/v1/devices/heartbeat \ -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ -d '{"device_name":"kou-mbp","device_id":"9f1c2b7e-2a4d-4c6b-9f0e-1a2b3c4d5e6f","platform":"darwin","lan_ip":"192.168.1.20"}' ``` --- ### 5.7 `GET /api/v1/devices` **鉴权**:Bearer 必填。无查询参数。 **成功 `200`** ```json { "ok": true, "data": { "devices": [ { "id": 3, "device_id": "9f1c...", "device_name": "kou-mbp", "platform": "darwin", "lan_ip": "192.168.1.20", "tailscale_ip": null, "last_seen": 1730000000000, "online": true } ] } } ``` - 排序:`last_seen DESC, id DESC` - `online`:`now - last_seen <= 90_000`(`ONLINE_THRESHOLD_MS`,与客户端常量一致) - **客户端不使用 `online` 字段**,而是用 `last_seen` + 客户端自己的 `clockOffset` 做三态判定(`online` 90s / `recent` 7d / `stale`)—— 服务端时钟不可假定可信 **失败**:仅 401 系列。 ```bash curl -sS http://127.0.0.1:3000/api/v1/devices -H "Authorization: Bearer $ACCESS" ``` --- ### 5.8 `POST /api/v1/certs` **鉴权**:Bearer 必填。 **请求体** | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `device_name` | string | ✅ | 同 heartbeat 规则;不存在则**自动创建一条 device 行** | | `cert_pem` | string | ✅ | Moonlight **公钥**证书 PEM。1B–8KB;必须 `BEGIN/END CERTIFICATE`;**含 `PRIVATE KEY` 一律拒绝**;必须能被 `X509Certificate` 解析 | | `device_id` | string | ❌ | **服务端忽略本请求体里的值**(客户端会带,仅作兼容):证书与设备的关联靠 `device_name`,证书条目的 `device_id` 取自该 **device 行**(由 heartbeat 写入),因此客户端想让它有值,**必须先在 heartbeat 里上报** | | `fingerprint` | string | ❌ | **服务端无条件忽略**:指纹永远由服务端 `sha256hex(归一化后的 pem)` 重算 | > 这也是「盲协调器但不盲信」的落地:客户端提交什么指纹都不影响入库值, > 客户端侧再独立重算一次比对(`launcher/src/certs.ts#fingerprintMatches`)。 **成功 `200`** ```json { "ok": true, "data": { "deviceId": 3, "fingerprint": "b1946ac92492d2347c6235b4d2611184..." } } ``` `fingerprint` = 64 位小写 hex(SHA-256)。同一 `(user_id, device_id)` 重复上传为 UPSERT(覆盖 PEM 与指纹、刷新 `created_at`)。 **失败** | 状态 | code | 触发条件 | `details` | |---|---|---|---| | 400 | `VALIDATION_FAILED` | `device_name` 非法,或 `cert_pem` 为空 | `{field:'device_name'|'cert_pem', reason:...}` | | 400 | `INVALID_CERT` | 边界不合格 / 含私钥块 / 超 8KB / X509 解析失败 | `{maxBytes:8192}` 或 `{reason:'无法被 X509 解析器识别'}` | | 413 | `PAYLOAD_TOO_LARGE` | 整个请求体 > 64KB | — | | 401 | 401 系列 | Bearer 校验 | — | ```bash # 上传本机公钥证书。注意本地路径:D3 之后 peer 证书文件按 device_id 命名, # 所以 Sunshine 凭据目录里的文件名形如 moonlight-.pem # (老版本留下的 moonlight-.pem 仍可读,只是不再由本工具生成)。 curl -sS -X POST http://127.0.0.1:3000/api/v1/certs \ -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \ -d "$(node -e "const fs=require('fs');process.stdout.write(JSON.stringify({device_name:'kou-mbp',cert_pem:fs.readFileSync(process.argv[1],'utf8')}))" ~/.config/sunshine/credentials/moonlight-9f1c2b7e-2a4d-4c6b-9f0e-1a2b3c4d5e6f.pem)" ``` --- ### 5.9 `GET /api/v1/certs` **鉴权**:Bearer 必填。返回**本账号**全部设备的证书(含自己的)。 **成功 `200`** ```json { "ok": true, "data": { "certs": [ { "device_id": "9f1c...", "device_name": "windesk", "platform": "win32", "lan_ip": "192.168.1.50", "cert_pem": "-----BEGIN CERTIFICATE-----\n...", "fingerprint": "b1946ac9...", "updated_at": 1730000000000 } ] } } ``` - 排序:`created_at DESC` - `device_id` / `platform` / `lan_ip` / `fingerprint` / `updated_at` 可能为 `null`(老数据) - **不会**返回私钥(服务端从不接收私钥) > **客户端如何消费这份快照**(D3/D4 之后的完整规则) > > | 步骤 | 判定 | 不通过时的 `reason` | > |---|---|---| > | ① 跳过自己 | `entry.device_id === 本机 device_id` | `skipped_self` | > | ② 键可用性 | `device_id` 非空 | `missing_device_id` | > | ③ 键格式 | 匹配 `[A-Za-z0-9-]{8,64}` | `invalid_device_id` | > | ④ 指纹自校验 | 本地 `sha256(cert_pem)` 与服务端 `fingerprint` 一致 | `fingerprint_mismatch` | > | ⑤ 路径内敛 | `safeJoin(credDir, 'moonlight-.pem')` 落在凭据目录内 | `path_escape` | > | ⑥ 原子写 | 同目录 `*.tmp-` → `rename` | `io_error`(带 `code`) | > > **文件名主体是 `device_id` 而不是 `device_name`**,这不是风格问题: > `device_name` 是用户可改的展示字段,用它做文件名会让两端各维护一套白名单 > (`[A-Za-z0-9._-]` vs `[A-Za-z0-9-]`),任何一处改动都会产生「服务端接受但客户端 > 写不出」的静默故障;且 `Peer-A` / `peer-a` 在大小写不敏感的文件系统上会互相覆盖 > (服务端 `device_name` 是 BINARY collation,认为它们是两台设备)。 > > 清理(删除服务端已不存在的对端证书)在同一份快照上做,共五层防护,其中最后一层 > 与本文档直接相关:**只有当「本该落盘的文件」全部确实在目录里时,才允许删除多余文件**, > 否则整轮跳过(`expected_missing`)。这条不变量保证了「快照有、本地没有」这种 > 不可信状态下不会误删唯一可用的副本;同时让旧版本留下的 > `moonlight-.pem` 可以自然共存 —— 因此**升级不需要迁移脚本**。 **失败**:仅 401 系列。 ```bash curl -sS http://127.0.0.1:3000/api/v1/certs -H "Authorization: Bearer $ACCESS" ``` --- ## 6. 错误码总表(15 条,与 `server/errors.js#ERROR_TABLE` 逐条对齐) | # | code | HTTP | message | hint | |---|---|---|---|---| | 1 | `VALIDATION_FAILED` | 400 | 请求参数不正确 | 检查输入后重试 | | 2 | `INVALID_USERNAME` | 400 | 用户名需为 3–32 位字母、数字、下划线或短横线 | 更换用户名 | | 3 | `WEAK_PASSWORD` | 400 | 密码需至少 10 位,且不能是纯数字或纯字母 | 使用更长的混合密码 | | 4 | `INVALID_CERT` | 400 | 证书格式不正确,请只上传 Moonlight 公钥证书(.pem/.crt) | 确认未上传含私钥的文件 | | 5 | `INVALID_ARGUMENT` | 400 | 参数格式不合法 | 按提示格式输入 | | 6 | `UNAUTHORIZED` | 401 | 未登录或登录已失效 | 重新登录 | | 7 | `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 检查大小写后重试 | | 8 | `TOKEN_EXPIRED` | 401 | 登录已过期 | 自动续期中,若持续失败请重新登录 | | 9 | `TOKEN_REVOKED` | 401 | 该登录已被登出 | 重新登录 | | 10 | `FORBIDDEN` | 403 | 无权访问该资源 | 联系管理员 | | 11 | `NOT_FOUND` | 404 | 资源不存在 | 刷新后重试 | | 12 | `USER_EXISTS` | 409 | 该用户名已被注册 | 更换用户名或直接登录 | | 13 | `PAYLOAD_TOO_LARGE` | 413 | 请求内容过大 | 请检查证书文件大小(≤8KB) | | 14 | `RATE_LIMITED` | 429 | 尝试过于频繁,请 15 分钟后再试 | 等待后重试,或联系管理员清除锁定 | | 15 | `INTERNAL` | 500 | 服务器内部错误 | 记录错误码并联系管理员 | 复核命令: ```bash node -e "import('./server/errors.js').then(m=>console.log(m.ERROR_CODES.length, m.ERROR_CODES.join(',')))" # 15 VALIDATION_FAILED,INVALID_USERNAME,WEAK_PASSWORD,INVALID_CERT,INVALID_ARGUMENT, # UNAUTHORIZED,INVALID_CREDENTIALS,TOKEN_EXPIRED,TOKEN_REVOKED,FORBIDDEN,NOT_FOUND, # USER_EXISTS,PAYLOAD_TOO_LARGE,RATE_LIMITED,INTERNAL ``` > `FORBIDDEN`、`INVALID_ARGUMENT`、`NOT_FOUND` 在当前 9 个端点里**没有业务触发点** > (`NOT_FOUND` 由 404 兜底中间件触发,`FORBIDDEN` 预留给 V2 的多租户/角色), > 保留在表内是为了让客户端映射表 100% 覆盖、避免出现「未知码」。 ### 6.1 端点 × 错误码矩阵 | 端点 | 400 | 401 | 404 | 409 | 413 | 429 | 500 | |---|---|---|---|---|---|---|---| | `GET /health` | — | — | — | — | — | — | ✅ | | `POST /auth/register` | `INVALID_USERNAME` `WEAK_PASSWORD` `VALIDATION_FAILED` | — | — | `USER_EXISTS` | ✅ | — | ✅ | | `POST /auth/login` | `VALIDATION_FAILED` | `INVALID_CREDENTIALS` | — | — | ✅ | ✅ | ✅ | | `POST /auth/refresh` | `VALIDATION_FAILED` | `TOKEN_EXPIRED` `TOKEN_REVOKED` `UNAUTHORIZED` | — | — | ✅ | — | ✅ | | `POST /auth/logout` | `VALIDATION_FAILED` | `UNAUTHORIZED` `TOKEN_EXPIRED` `TOKEN_REVOKED` | — | — | ✅ | — | ✅ | | `POST /devices/heartbeat` | `VALIDATION_FAILED` | 401 系列 | — | — | ✅ | — | ✅ | | `GET /devices` | — | 401 系列 | — | — | — | — | ✅ | | `POST /certs` | `VALIDATION_FAILED` `INVALID_CERT` | 401 系列 | — | — | ✅ | — | ✅ | | `GET /certs` | — | 401 系列 | — | — | — | — | ✅ | | 任意未匹配路径 | — | — | `NOT_FOUND` `{path}` | — | — | — | ✅ | `404 NOT_FOUND` 由 `notFoundHandler()` 触发,`details` 形如 `{path:'/api/v1/nope'}`。 --- ## 7. 客户端实现对照(`launcher/src/api.ts`) | 客户端方法 | 端点 | |---|---| | `login()` | `POST /auth/login` | | `register()` | `POST /auth/register` | | `refresh()` | `POST /auth/refresh` | | `logout()` | `POST /auth/logout` | | `heartbeat()` | `POST /devices/heartbeat` | | `listDevices()` | `GET /devices`(把 `data.devices` 摊平成数组) | | `listCerts()` | `GET /certs`(把 `data.certs` 摊平成数组) | | `uploadCert()` | `POST /certs`(**不传 fingerprint**) | 三条客户端约定: 1. **超时 8s**(`AbortController` + `AbortSignal`),超时/不可达 → `NETWORK_UNREACHABLE`, 绝不永久挂起。 2. **`device_name` 一律取 `os.hostname()`**,两端(heartbeat 与 certs)必须同名, 否则证书会关联到另一条设备行。 3. **落盘文件名只由 `device_id` 决定**(D3/D4):`moonlight-.pem`。 `device_name` 是纯展示字段,不参与任何路径推导。由此派生的三条客户端行为: - `device_id` 为空 ⇒ 跳过写入,`reason = missing_device_id`(该设备从未心跳过, 因此没有 `lan_ip`、本来就不可能被一键连接;≤30s 后下一轮心跳自愈); - `device_id` 不合 `[A-Za-z0-9-]{8,64}` ⇒ 跳过,`reason = invalid_device_id`; - 「跳过自己」只按 `device_id` 比对,**不**回落到名字比对 (同名不同机会被误判成自己)。 4. **401 处理**:`TOKEN_REVOKED`,或 `TOKEN_EXPIRED` 且无 refreshToken ⇒ 触发统一登出(`reason: 'revoked' | 'expired'`),并广播 `auth:logged-out`。 --- ## 8. 变更与兼容策略 - 路径版本号 `/v1` 只在**破坏性变更**时递增;新增可选字段不升版本。 - 新增字段一律**可选**,服务端必须容忍缺失(`heartbeat` 的四个可选字段就是范例)。 - 删除/收紧校验(把「忽略」改成「报错」)视为破坏性变更 —— 会让老客户端全量掉线。 - 客户端对未知错误码的兜底:按 HTTP 状态码映射(`launcher/src/api.ts#codeFromStatus`), 保证服务端加了新码也不会把 UI 打挂。