feat(notify): unify /ready not-ready 503 into the error envelope

- GET /ready: ready -> 200 + diagnostic report (unchanged success body);
  not ready -> 503 + {"error":"service_unavailable","request_id":id}
  + Retry-After: 30, consistent with the other public entry points
- drop the legacy {"status":"not_ready"} diagnostic block from the 503 body
  (backward compatibility not required)
- HTTP 200/503 semantics preserved, so Uptime Kuma HTTP(s) monitors
  (status-code based) keep working
- sync docs: README.md, README.zh-CN.md, docs/design.md, docs/deployment.md,
  AGENTS.md, docs/todo.md
This commit is contained in:
anonsaber 2026-09-27 13:11:29 +08:00
parent a8fad19daa
commit ed93358e29
7 changed files with 21 additions and 22 deletions

View File

@ -41,7 +41,7 @@
12. **状态仅外部 Redis(用户托管 + AOF)**:会话/去重/Redis Streams/熔断计数/sinceState 一律走外部 Redis(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`),禁止 SQLite/本地卷作持久层(`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);Redis 丢失由 JMAP 对账 `FLOW-RECONCILE` 重建 sinceState,**事实源在 JMAP**。
13. **正文仅在用户明确允许后才外发 AI**(`REQ-AI-EXTERNAL-CONSENT`):默认不外发;发送前须用户显式同意(与第 2 条叠加)。
14. **单账户**(`REQ-SINGLE-ACCOUNT`):一个实例只接一个 Stalwart 账户;多账户 = 多个 bot 实例,不做多账户单实例。
15. **三入口鉴权 fail-closed(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权——`/reconcile` 校验 `Authorization: Bearer RECONCILE_TOKEN`、`/webhook/tg` 校验 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`、`/push/jmap` 校验请求体 `pushSubscriptionId` + `verificationCode`——按 `pushSubscriptionId` 查 Redis 短期验证状态(值存 `session_digest` 摘要,见 `src/notify.rs` `jmap_push`),状态缺失或摘要不符即拒绝;`/reconcile`、`/webhook/tg` 的凭证比较用**常数时间**(`subtle`),失败 `401` 且**在鉴权通过前不得产生任何副作用**;三密钥必填,缺失即启动失败,**禁止任何"未配置则放行"降级**。`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**(`SAF-PROBE-PUBLIC`:无鉴权、仅健康状态、不含敏感信息);**`/ready` 已做基础探测**(`ARCH-READY-BASELINE`):配置完整性 + Redis 可达性,不就绪返 `503`;**仍不做** JMAP session / TG getMe 等端到端探测(余下范围见 `GATE-READY-DEPS`)——**禁止**声称 `/ready` 已验证端到端依赖。
15. **三入口鉴权 fail-closed(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权——`/reconcile` 校验 `Authorization: Bearer RECONCILE_TOKEN`、`/webhook/tg` 校验 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`、`/push/jmap` 校验请求体 `pushSubscriptionId` + `verificationCode`——按 `pushSubscriptionId` 查 Redis 短期验证状态(值存 `session_digest` 摘要,见 `src/notify.rs` `jmap_push`),状态缺失或摘要不符即拒绝;`/reconcile`、`/webhook/tg` 的凭证比较用**常数时间**(`subtle`),失败 `401` 且**在鉴权通过前不得产生任何副作用**;三密钥必填,缺失即启动失败,**禁止任何"未配置则放行"降级**。`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**(`SAF-PROBE-PUBLIC`:无鉴权、仅健康状态、不含敏感信息);**`/ready` 已做基础探测**(`ARCH-READY-BASELINE`):配置完整性 + Redis 可达性,不就绪返 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`;Uptime Kuma 按状态码 200/503 监控,不受响应体影响);**仍不做** JMAP session / TG getMe 等端到端探测(余下范围见 `GATE-READY-DEPS`)——**禁止**声称 `/ready` 已验证端到端依赖。
16. **多实例高可用(`ARCH-LB-WORKER`)**:允许同一镜像跨多个 serverless 平台实例化、前置免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`),共享同一 Redis(`C-REDIS-ONLY-STATE`)。**信任模型为透传(`SAF-LB-PASSTHRU`)——后端鉴权不可省**(小平台无防火墙/ACL,后端入口可能被公网直连);多实例**必须共享同一组 `SAF-AUTH-*` secret**(`C-LB-SHARED-SECRETS`)。`/reconcile` **禁止多实例并发**,用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`);Streams 用**同一消费组**分摊(`MOD-STREAMS-GROUP`);Worker 可提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。业务代码无需为 LB 改动。详见 deployment.md §10。
17. **生产红线(无数据库 / 无本地写入 / 标准输出日志,`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`)**:
- **不使用任何数据库**(`C-NO-DB`):无 SQLite / Postgres / MySQL / 嵌入式数据库;Redis 是唯一生产状态存储(`C-REDIS-ONLY-STATE`)。应用**不自建、不连接第二个数据库实例**。
@ -208,7 +208,7 @@
| `SAF-NO-SECRET-ECHO` | src/config.rs `SecretString` 字段与公共配置 API | 密钥字段不实现 `Debug` 且永不进入公共配置 API 响应;Redis 线格式类型私有,不得序列化进 HTTP 响应 | 安全 |
| `SAF-PROBE-PUBLIC` | AGENTS.md §2 / design.md §7.3 | `/healthz`、`/ready` 公开探针:无鉴权、无敏感信息 | 安全 |
| `ARCH-HEALTHZ` | docs/design.md §7.3/§10.0 | `/healthz` liveness(进程存活),语义长期稳定 | 架构 |
| `ARCH-READY-BASELINE` | docs/design.md §7.3 / src/notify.rs `ready` | `/ready` 基础探测:配置完整性 + Redis 可达性,不就绪返 `503`;**不**含 JMAP session / TG getMe 端到端探测(阶段0 交付时曾为占位 200,现已演进) | 架构 |
| `ARCH-READY-BASELINE` | docs/design.md §7.3 / src/notify.rs `ready` | `/ready` 基础探测:配置完整性 + Redis 可达性,就绪 `200`(就绪报告 JSON),不就绪 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`);**不**含 JMAP session / TG getMe 端到端探测(阶段0 交付时曾为占位 200,现已演进) | 架构 |
| `GATE-READY-DEPS` | docs/design.md §10.0 | 余下门禁:`/ready` 补 JMAP session / TG getMe 等**端到端**依赖探测(阶段3.5+ 的剩余部分,含其测试与部署接线) | 流程 |
| `GATE-P0` | docs/design.md §10.0 | 阶段0 P0 门禁(fmt/clippy/test 过 Debian 容器等 8 项) | 流程 |
| `BOUND-STAGE1` | docs/design.md §10.0 | 阶段1 推进边界(过 GATE-P0 才进;R1 入口鉴权已在阶段0 落地) | 流程 |

View File

@ -66,7 +66,7 @@ The "enable business processing" switch in the admin session is persisted in Red
## How to check that it is healthy after startup
- Open **`your-public-address/healthz`** in a browser or with `curl`: a normal response means the bot process is alive.
- `/ready` returns a JSON readiness report and checks configuration completeness and Redis reachability: 200 means the basic dependencies are ready, 503 means it should not receive traffic. It never calls JMAP or Telegram and never triggers a business side effect.
- `/ready` checks configuration completeness and Redis reachability. When ready it returns `200` with a JSON readiness report (`{"status":"ready","configured":...,...}`). When not ready it returns `503` with the standard error envelope `{"error":"service_unavailable","request_id":"<id>"}` and `Retry-After: 30`. It never calls JMAP or Telegram and never triggers a business side effect.
- For monitoring, Uptime Kuma is recommended: an HTTP liveness check on `/healthz` and an HTTP readiness check on `/ready` (expect 200, 30-second interval, 5-second timeout). The project ships no Prometheus exporter.
- `/reconcile` performs authentication, takes a Redis single-flight lock, and runs a bounded JMAP `Email/changes` reconciliation. Events are written to Redis Streams idempotently, and the Redis `sinceState` cursor is only advanced after all of them are queued; a temporary dependency failure returns `503` so cron can retry. Push events still go through the authenticated, de-duplicated enqueue path, so this must not be described as complete real-time synchronisation.
- The cold-start baseline also has page, message and time budgets. When a budget is exhausted the checkpoint is saved and the next `/reconcile` continues from it, so a large mailbox is never re-scanned from the beginning.

View File

@ -66,7 +66,7 @@ HostStack 使用根目录 `hoststack.yaml` 构建并运行 Rust 服务:执行
## 启动后怎么看它是否正常
- 浏览器或命令行访问 **`你的公网地址/healthz`**:能返回正常状态,说明机器人进程活着。
- `/ready` 返回 JSON 就绪状态并检查配置完整性与 Redis 可访问性;200 表示基础依赖就绪,503 表示不应接收业务流量。它不会调用 JMAP、Telegram 或触发任何业务副作用。
- `/ready` 检查配置完整性与 Redis 可访问性:就绪时返回 `200` 与 JSON 就绪报告(`{"status":"ready","configured":…,…}`);不就绪时返回 `503` 与标准错误 envelope `{"error":"service_unavailable","request_id":"<id>"}`,并带 `Retry-After: 30`。它不会调用 JMAP、Telegram 或触发任何业务副作用。
- 运行监控建议使用 Uptime Kuma:为 `/healthz` 建立存活 HTTP 检查,为 `/ready` 建立就绪 HTTP 检查(期望 200,间隔 30 秒、超时 5 秒)。项目不引入 Prometheus 或 exporter。
- `/reconcile` 执行鉴权、Redis 单飞锁与有界 JMAP `Email/changes` 对账;事件先幂等入 Redis Streams,全部成功后才推进 Redis `sinceState`,依赖暂时失败返回 `503` 以便 Cron 重试。Push 事件仍按鉴权、去重、入队路径处理,不能将其宣传为完整实时同步。
- 冷启动基线也有页数、邮件数和时间预算;超出预算时保存断点,下一次 `/reconcile` 从断点继续,避免大邮箱反复从头扫描。

View File

@ -183,7 +183,7 @@ POST /push/jmap Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body
POST /reconcile 外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN]
→ 对账补差 FLOW-RECONCILE
GET /healthz liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息)
GET /ready 公开 JSON 就绪探针;检查配置完整性与 Redis 可访问性,未就绪返回 503(无鉴权、无敏感信息)
GET /ready 公开就绪探针;检查配置完整性与 Redis 可访问性,就绪返回 200+就绪报告 JSON,未就绪返回 503+标准错误 envelope(无鉴权、无敏感信息)
```
`/reconcile` 使用 Redis owner-token 单飞锁并在长任务期间续租;每次最多处理 100 页、10,000 封基线邮件或 20 秒。已完成入队的 changes 页会提交最新游标;冷启动基线超出预算时把基线状态与位置编码后持久化,下一次从断点继续,不从头扫描,也不会提前跳过未列举邮件。
> **写入口鉴权(fail-closed,`SAF-AUTH-*`)**:Webhook、Push、Reconcile 以及 Push 注册接口都必须先通过鉴权,**失败返回 `401` 且不产生副作用**;比较使用常数时间(`subtle`,防时序侧信道)。secret 未配置 → 启动失败,**无"缺省放行"**。
@ -227,7 +227,7 @@ GET /ready 公开 JSON 就绪探针;检查配置完整性与 Redis
## 7. 健康检查(通用 HTTP 约定)
- `GET /healthz` → liveness(`ARCH-HEALTHZ`;进程存活,长期语义)
- `GET /ready` → 基础就绪探针(`ARCH-READY-BASELINE`):检查配置完整性与 Redis 可访问性,不就绪返回 `503`;**不代表** Redis/JMAP/TG 依赖已通过端到端检查。
- `GET /ready` → 基础就绪探针(`ARCH-READY-BASELINE`):检查配置完整性与 Redis 可访问性;就绪 `200`(就绪报告 JSON),不就绪 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`);**不代表** Redis/JMAP/TG 依赖已通过端到端检查。Uptime Kuma 按状态码(期望 200)监控,不受响应体变化影响。
- 两者均为**公开探针**(`SAF-PROBE-PUBLIC`):无鉴权、仅返回健康状态、**不含敏感信息**。
- `/ready` 已检查配置完整性与 Redis 可访问性,但不代表 JMAP/Telegram 已完成真实端到端验收;编排与 Uptime Kuma 可用它做基础就绪探测。
- Docker `HEALTHCHECK` 指向同一监听端口(app 内置 `health` 子命令或 wget 同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`)

View File

@ -434,7 +434,7 @@ message-weave/
比较使用**常数时间**算法(`subtle`,防时序侧信道);校验失败一律 `401` 且**在鉴权通过前不产生任何副作用/状态变更**。业务配置完成后,Webhook、Push、Reconcile 和 Push 注册接口的凭证必须有效;启动引导变量缺失时进入配置引导模式,不绕过鉴权(§7.1)。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。
- `/healthz` = liveness(进程存活),语义长期稳定。
- `/ready` 检查配置完整性与 Redis 可访问性;未就绪返回 `503`,不执行 JMAP/Telegram 请求,也不触发邮件同步等业务副作用。
- `/ready` 检查配置完整性与 Redis 可访问性;未就绪返回 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`),就绪返回 `200` 与就绪报告(`{"status":"ready","configured":...,...}`);不执行 JMAP/Telegram 请求,也不触发邮件同步等业务副作用。
- **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;强制拒绝逻辑已随 Telegram 渠道接入落地(`src/notify.rs` 的 `telegram_webhook` 在任何 JMAP/AI/状态操作之前先校验白名单,拒绝即终止)。
- **命令最小化**:仅暴露必要命令;`/send` 需二次确认。
- **速率**:出站侧未建本地令牌桶(未引入 `teloxide`);Telegram 出站发送按 Redis 运行参数 `max_retries`(默认 3、上限 5)重试;当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 不做专门的自动退避处理。
@ -551,7 +551,7 @@ pub enum BotError {
- 分析结果不落盘:处理后无新增磁盘/Redis 写入路径;
- AI 3 次失败 → 熔断确认 → 回退带"AI 不可用"徽标。
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`、`/webhook/tg`、`/push/jmap` 在**缺少或错误的**凭证下返回 `401`,并断言鉴权失败时**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200` 表示进程存活;`/ready` 以 `200/503` 表示配置与 Redis 是否就绪,响应体不含敏感信息。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200` 表示进程存活;`/ready` 以 `200/503` 表示配置与 Redis 是否就绪——就绪返回就绪报告 JSON,不就绪返回标准错误 envelope(`service_unavailable` + `Retry-After: 30`);响应体不含敏感信息。Uptime Kuma 按状态码(`/ready` 期望 200)监控,不受响应体变化影响。
- **渠道解耦**:领域模块(jmap/llm/intent/notify::core)编译不依赖 teloxide(crate 分层 + clippy/评审约束);`channel::telegram` 是唯一导入 teloxide 的模块。
- **JMAP session URL(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:基地址与完整 `…/.well-known/jmap` 两种输入**均接受且归一化结果一致**,传给 `Client::connect` 的 URL **不含重复 `/.well-known/jmap`**;`http://` 被拒绝;**内嵌凭据(`https://user:pass@host`)被拒绝**;危险 query 被拒绝。
- **JMAP 多 part 原文(`REQ-JMAP-RAW-MULTIPART`)**:`read_email` 对多 part 正文按 `text_body` 顺序拼接"有 `part_id` 且有 `bodyValue`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。
@ -584,7 +584,7 @@ pub enum BotError {
8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。
**运行监控(`GATE-UPTIME-KUMA`):**
- 使用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置/Redis 就绪),分别期望 HTTP 200。
- 使用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置/Redis 就绪),分别期望 HTTP 200;`/ready` 不就绪时返回 `503` 与标准错误 envelope(`service_unavailable` + `Retry-After: 30`),Uptime Kuma 仍按状态码判定,不受响应体变化影响。
- `/healthz` 保持纯 liveness;`/ready` 不执行 JMAP/Telegram 请求或业务副作用。
- 不引入 Prometheus、Exporter 或额外指标端口;真实 Stalwart/Telegram 端到端链路仍需单独联调。

View File

@ -29,6 +29,6 @@
## 本轮已收口(2026-09-26)
- **错误 envelope 统一**:全部公开写路由错误响应改为 `{"error":<code>,"request_id":<id>}`,可重试的 503 追加 `Retry-After: 30`;`GET /ready` 保留结构化诊断 JSON(Uptime Kuma 运行契约),不改 envelope。
- **错误 envelope 统一**:全部公开路由错误响应(含 `GET /ready` 不就绪时的 503)统一为 `{"error":<code>,"request_id":<id>}`,可重试的 503 追加 `Retry-After: 30`;`/ready` 就绪时 `200` 仍返回结构化诊断报告。Uptime Kuma 按 HTTP 状态码(`/ready` 期望 200)监控,不受响应体变化影响。
- **Web SPA i18n**:按浏览器语言判断 `zh`/`en`,默认英文,保持纯静态 SPA(`data-i18n` 属性 + 内联消息字典,无新增资源文件)。
- **文档**:`README.md` 改英文默认,新增 `README.zh-CN.md`,两版章节与关键事实 1:1 对齐;`docs/design.md`、`docs/deployment.md`、`AGENTS.md` 同步修正滞后描述。

View File

@ -67,19 +67,18 @@ async fn healthz() -> impl IntoResponse {
async fn ready(State(app): State<AppState>) -> Response {
let configured = app.setup_missing.is_empty();
let redis = app.state.is_enabled().await.is_ok();
let ready = configured && redis;
let body = axum::Json(serde_json::json!({
"status": if ready { "ready" } else { "not_ready" },
"configured": configured,
"redis": redis,
"jmap": configured,
"push": configured,
"telegram": configured,
}));
if ready {
(StatusCode::OK, body).into_response()
if configured && redis {
axum::Json(serde_json::json!({
"status": "ready",
"configured": configured,
"redis": redis,
"jmap": configured,
"push": configured,
"telegram": configured,
}))
.into_response()
} else {
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true)
}
}