docs: ④ /debug/* 配套文档 + 全量锚点重锚 + AGENTS v3 版本基线(01a0e6de)

## 锚点重锚(afa6cec 之后)

④ 对 notify.rs 的编辑使一批 `notify.rs:N` 引用整体失效。全量重扫 8 个 md、
165 个 src 锚点,0 硬错误(越界 / 区间全空 / 单行指空行三类全查)。修正要点:
- reference.md(30+ 处):dedup/committed/inflight/ratelimit 键调用点、
  error_response 320-328、error_response_with_id 1087-1093、readiness 161、
  router 1275 + routes 1304-1318、worker 330、main.rs 40/50/34/78-81、
  NoopWorker 由 notify.rs 更正为 worker.rs:142-144、
  PROBE_TIMEOUT 73 与 tokio::join! 147、state.rs 639。
- deployment.md:149-152→227-230、159/172→239/252、index.js 40-55→45-60。
- retired.md:704→717、887-890→897-900、326→330、873→884。
- roadmap.md:95→175;design.md:main.rs 109→128。
- cloudflare-worker 侧 4 处 stale:ROUTE_METHODS 45-60、404 77-78、
  405 80-83、503 85-93。

## /debug/* 文档

- deployment.md 新增 §2.1:双因子门禁、7 条路由状态码表、/debug/config 字段清单、
  三条部署约束、生产建议;§5 env 表补 --debug / DEBUG_TOKEN。
- README.md / README.zh-CN.md 各加一段「远程联调面(可选,默认关闭)」,指向 §2.1。
- design.md 新增 §7.6「为何默认绝对关闭」:双因子挂载、404 而非 401
  (401 会泄漏路由存在性)、无默认开启回退、不在网关白名单内 fail-closed。

## 文档纠错(与任务书相反,已按源码核实)

- /debug/worker 是 GET 不是 POST(debug.rs:79)。
- /debug/config 不是「只出布尔」:明文回 jmap.{session_url,username}、
  telegram.chat_id、llm.{base_url,model} 与全部 timeout/retry,
  仅 5 个凭据字段出 *_configured。文档措辞改为「不含凭据,不是全脱敏」。
- 门禁位置更正:--debug 读 main.rs:99、DEBUG_TOKEN 读 main.rs:100-103、
  merge 在 notify.rs:1320-1321(无 DEBUG_BASE 常量,路径为字面量)。

## AGENTS.md

- 版本基线 v3(代码 afa6cec / 文档 e502c40),三处文档基线号 c5bc7b8→afa6cec。
- 新增 2 个 ID:SAF-DEBUG-ORIGIN-ONLY(不在网关 14 条白名单,Worker 404
  route not forwarded,只能直连 origin)、SAF-DEBUG-ALLOWLIST。
- §6 deployment.md 行、§7 六行 ID 定义点回填文档位置。

## 未决项登记

- roadmap.md 新增:SAF-DEBUG-ALLOWLIST —— debug.rs:232-235 的白名单判定
  「非空且不含」导致业务白名单为空时 /debug/notify 的 chat_id 不拦截。
  本轮不改代码(该面默认关闭且需 Bearer,空白名单下主产品本就接受任意 chat);
  若要收紧需改为「空白名单则拒绝」+ 补测试 + 跑 gate。

## 校验

文档校验器 0 error;负向对照断链/死锚点/奇数反引号/页内锚点 4 类注入全命中。
48 张 markdown 表 0 处单元格数不符。行数合计 2415。纯文档,未触碰 src/ web/
cloudflare-worker/。代码基线仍 afa6cec,gate 49 passed / 0 failed / 1 ignored。
This commit is contained in:
anonsaber 2026-09-28 16:20:18 +08:00
parent e502c40a9e
commit 399b26918e
8 changed files with 143 additions and 53 deletions

View File

@ -7,6 +7,10 @@
> 三份文档职责分离,改动时请遵循 [§6 文档边界](#6-文档边界与引用关系),避免内容重复堆砌与跨文档冲突。 > 三份文档职责分离,改动时请遵循 [§6 文档边界](#6-文档边界与引用关系),避免内容重复堆砌与跨文档冲突。
> >
> **跨文档引用一律用稳定 ID**(`C-` / `NG-` / `MOD-` / `FLOW-` / `REQ-` 等),**不用章节号(§x.y)**;定义点位置与一句话说明见 [§7 跨文档引用索引](#7-跨文档引用索引表)。 > **跨文档引用一律用稳定 ID**(`C-` / `NG-` / `MOD-` / `FLOW-` / `REQ-` 等),**不用章节号(§x.y)**;定义点位置与一句话说明见 [§7 跨文档引用索引](#7-跨文档引用索引表)。
>
> **版本基线 v3(2026-09-26,本轮 ④+⑤):代码 `afa6cec`,文档 `e502c40`。**
> 本轮新增 `src/debug.rs` 远程联调面与 6 个 `*DEBUG*` ID(§2 第 19 条、§5 测试清单、§7 六行),文档侧同步落位
> `docs/deployment.md` §2.1、`docs/reference.md` §3/§4、`README` 中英双版;各文档的行号锚点以该基线为准,代码变动后须同步重锚。
--- ---
@ -150,7 +154,7 @@
| `README.md` / `README.zh-CN.md` | 面向最终用户:是什么 / 五分钟跑起来 / 配置入口 / 安全边界一句话 / 去哪读更多(英文版 / 中文版,内容须对等,顶部互链) | 用户可见行为或流程变更时 | | `README.md` / `README.zh-CN.md` | 面向最终用户:是什么 / 五分钟跑起来 / 配置入口 / 安全边界一句话 / 去哪读更多(英文版 / 中文版,内容须对等,顶部互链) | 用户可见行为或流程变更时 |
| `docs/reference.md` | **可核对事实的唯一权威来源**:Redis 键与 TTL、错误 envelope 与错误码、后端路由与网关白名单矩阵、环境变量三层、出站与预算常量 | 公共 API 或 Redis 键名/TTL 变更时(**必更**) | | `docs/reference.md` | **可核对事实的唯一权威来源**:Redis 键与 TTL、错误 envelope 与错误码、后端路由与网关白名单矩阵、环境变量三层、出站与预算常量 | 公共 API 或 Redis 键名/TTL 变更时(**必更**) |
| `docs/design.md` | 产品行为、架构、模块接口、状态机、数据流、错误处理、测试、实施阶段、渠道抽象(Channel/Notifier/MessageAdapter)、历史问题与决策归档(§11,产品/架构类,均已收口) | 行为/接口变更时 | | `docs/design.md` | 产品行为、架构、模块接口、状态机、数据流、错误处理、测试、实施阶段、渠道抽象(Channel/Notifier/MessageAdapter)、历史问题与决策归档(§11,产品/架构类,均已收口) | 行为/接口变更时 |
| `docs/deployment.md` | 通用 HTTPS-only Docker、Debian、构建/运行时、Secrets、Redis(状态唯一载体)、Webhook/Push/对账短请求路由、**多实例 LB/HA(Worker 前置,§10)**、健康检查、CI、外部调度示例、已确认决策表(`Q-DEP-A`/`Q-DEP-B` 已决策归档) | 部署/发布变更时 | | `docs/deployment.md` | 通用 HTTPS-only Docker、Debian、构建/运行时、Secrets、Redis(状态唯一载体)、**可选远程联调面 `/debug/*`(§2.1,`SAF-DEBUG-GATE`)**、Webhook/Push/对账短请求路由、**多实例 LB/HA(Worker 前置,§10)**、健康检查、CI、外部调度示例、已确认决策表(`Q-DEP-A`/`Q-DEP-B` 已决策归档) | 部署/发布变更时 |
| `docs/roadmap.md` | **只放缺口、阻塞与阶段目标**(取代 `docs/todo.md`,已删除) | 缺口或阻塞项增减时 | | `docs/roadmap.md` | **只放缺口、阻塞与阶段目标**(取代 `docs/todo.md`,已删除) | 缺口或阻塞项增减时 |
| `docs/retired.md` | **已废弃 / 未采用路线与虚构条目**:每条含「类型 / 原因 / 替代或现状」;记录 teloxide 未采用、FSM 未落地、幽灵文件与虚构键、已删文档。**只写结论与原因,不写当前事实** | 新增废弃项或删除文档时 | | `docs/retired.md` | **已废弃 / 未采用路线与虚构条目**:每条含「类型 / 原因 / 替代或现状」;记录 teloxide 未采用、FSM 未落地、幽灵文件与虚构键、已删文档。**只写结论与原因,不写当前事实** | 新增废弃项或删除文档时 |
| `AGENTS.md`(本文件) | 目标、硬性安全边界、实现顺序、禁止事项、测试验收、文档引用关系、**跨文档引用索引(§7)** | 安全边界/流程变更时 | | `AGENTS.md`(本文件) | 目标、硬性安全边界、实现顺序、禁止事项、测试验收、文档引用关系、**跨文档引用索引(§7)** | 安全边界/流程变更时 |
@ -249,10 +253,12 @@
| `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` 不扇出,Redis 锁保证单实例执行,避免重复对账 | 安全 | | `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` 不扇出,Redis 锁保证单实例执行,避免重复对账 | 安全 |
| `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | 多实例用同一 Streams 消费组名,Redis 自动分摊(at-least-once 不重复处理) | 组件 | | `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | 多实例用同一 Streams 消费组名,Redis 自动分摊(at-least-once 不重复处理) | 组件 |
| `MOD-HEALTH-AGG` | docs/deployment.md §10.6 | Worker 聚合健康视图,报告各后端存活供外部监控 | 组件 | | `MOD-HEALTH-AGG` | docs/deployment.md §10.6 | Worker 聚合健康视图,报告各后端存活供外部监控 | 组件 |
| `MOD-DEBUG` | src/debug.rs / AGENTS.md §2 | 远程联调只读表面:`--debug` + `DEBUG_TOKEN` 双因子开启后挂载 `/debug/*`,否则不挂载 | 组件 | | `MOD-DEBUG` | src/debug.rs / docs/deployment.md §2.1 / docs/design.md §7.6 / docs/reference.md §3 | 远程联调只读表面:`--debug` + `DEBUG_TOKEN` 双因子开启后挂载 `/debug/*`,否则不挂载 | 组件 |
| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs | 双因子门禁:启动带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭 | 安全 | | `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:启动带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭 | 安全 |
| `SAF-DEBUG-AUTH` | src/debug.rs | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | | `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 |
| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / AGENTS.md §2 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文 | 需求 | | `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 |
| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 14 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 |
| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 |
| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 | | `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 |
| `C-NO-DB` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 | | `C-NO-DB` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 |
| `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 | | `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 |

View File

@ -76,6 +76,14 @@ Telegram `getMe`, 3s each) are reachable. Probe `/ready`, not `/healthz`, when d
whether to route traffic — but `/ready` is the heavier probe (~3s worst case), so a monitor whether to route traffic — but `/ready` is the heavier probe (~3s worst case), so a monitor
that must stay cheap should watch the gateway's aggregated `/healthz`. that must stay cheap should watch the gateway's aggregated `/healthz`.
**Remote debug (opt-in, off by default).**
`/debug/*` is an optional remote-debug surface: live JMAP and Telegram probes, the current
business config, and a single Telegram send. It sits behind a two-factor gate — the process
must be started with `--debug` **and** `DEBUG_TOKEN` must be set; miss either and the routes
do not exist at all (requests fall through to a generic 404). Nothing under `/debug/*` is on
the gateway allowlist, so it is reachable only on the backend origin itself. See
`docs/deployment.md` §2.1.
## 4. Security boundary, in one sentence ## 4. Security boundary, in one sentence
> All state lives in Redis; the process reads only two environment variables and writes > All state lives in Redis; the process reads only two environment variables and writes

View File

@ -70,6 +70,12 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要 流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要
保持轻量的监控请看网关聚合的 `/healthz`。 保持轻量的监控请看网关聚合的 `/healthz`。
**远程联调面(可选,默认关闭)。**
`/debug/*` 是一套可选的远程联调面:JMAP 与 Telegram 实时探针、当前业务配置,以及单条
Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **且** 已设置 `DEBUG_TOKEN`;
任一缺失则这些路由完全不存在(请求落到通用 404)。`/debug/*` 不在网关白名单内,因此只能
直连后端 origin 访问。见 `docs/deployment.md` §2.1。
## 4. 安全边界,一句话 ## 4. 安全边界,一句话
> 所有状态都在 Redis 里;进程只读两个环境变量,不写任何磁盘。 > 所有状态都在 Redis 里;进程只读两个环境变量,不写任何磁盘。

View File

@ -53,6 +53,45 @@
(保留既有内容:debian:bookworm-slim 的运行基础、非 root 用户、时区、CA 证书等通用约定) (保留既有内容:debian:bookworm-slim 的运行基础、非 root 用户、时区、CA 证书等通用约定)
### 2.1 可选远程联调面 `/debug/*`(`SAF-DEBUG-GATE`)
生产入口默认**绝对关闭**:`/debug/*` 是一套只读探测 + 单条出站通知的远程联调面,只有部署者主动开启才会出现。
**双因子启用条件(`SAF-DEBUG-GATE`,两者必须同时成立)**
1. 进程命令行必须带 `--debug`(读取于 `src/main.rs:99`)。
2. `DEBUG_TOKEN` 环境变量必须存在且非空(读取于 `src/main.rs:100-103`)。
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1320-1321`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:107-109`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
**鉴权**
7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`,因此令牌是**常数时间比较**(`src/notify.rs:1135`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。
**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取)
| 路由 | 成功 | 失败 |
|---|---|---|
| `GET /debug/ping` | `200 {"ok":true}` | 401 |
| `GET /debug/config` | `200` 见下段 | 401 |
| `GET /debug/redis` | `200 {"reachable":true,"global_enabled":…}` | 401;Redis 探活失败**仍为 200** `{"reachable":false,"detail":"redis_probe_failed"}`,不返回 503 |
| `GET /debug/jmap` | `200 {"ok":true}` | 401;探针失败**仍为 200** `{"ok":false,"detail":…}`(与 `/ready` 共用同一探针) |
| `GET /debug/telegram` | `200 {"ok":true}` | 401;同上,探针失败返回 200 + `ok:false` |
| `GET /debug/worker` | `200`(`revision`、`reconcile_cursor`、`outbound` 预算) | 401 |
| `POST /debug/notify` | `200 {"ok":true,"result":{…}}` | 401;业务配置未加载或无出站客户端 → `503 service_unavailable` + `Retry-After: 30`(`src/debug.rs:56-58`);`chat_id` 不在白名单 → `403 chat_not_allowed`(`src/debug.rs:60-62`);Telegram 发送失败 → `502 telegram_send_failed`(`src/debug.rs:64-66`) |
`/debug/config` 的响应字段(`src/debug.rs:110-148`):恒有 `revision`、`setup_missing`、`business_configured`、`allowlist_size`;业务配置存在时再加 `jmap.{session_url,username,account_id}`、`telegram.{chat_id,webhook_secret_configured}`、`worker.{worker_token_configured,reconcile_token_configured}`、`llm.{enabled,allow_net,api_key_configured,base_url,model}`、`outbound.{jmap_timeout_ms,telegram_timeout_ms,llm_timeout_ms,max_retries}`;配置缺失时只回 `business_configured:false` + `allowlist_size`。
方法不匹配先于鉴权判定(例如 `GET /debug/notify` 返回 `405`)。
**三条约束,部署时务必确认**
- **绝不回显凭据值**:`debug_config`(`src/debug.rs:94`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。
- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1141`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:232-235`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:240-245`)。
- **不在 Worker 白名单内,只能直连 origin**:网关的 14 条安全路由(`cloudflare-worker/src/backends.js:9-24`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:77-78`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。
> **建议**:生产环境不开启。需要远程联调时临时开启、用一次性 token,联调结束立即移除 `--debug` 与 `DEBUG_TOKEN` 后重启;长期暴露面走 §5 的 Secret 管理流程单独审批。
--- ---
## 3. Rust 多阶段构建与运行 ## 3. Rust 多阶段构建与运行
@ -113,6 +152,7 @@ HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查
| `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer <token>` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此变量为**必填**(`SecretString`) | | `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer <token>` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此变量为**必填**(`SecretString`) |
| `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session | | `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session |
| `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) | | `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) |
| `--debug` + `DEBUG_TOKEN` | 可选,默认关闭 | 远程联调面 `/debug/*` 的**双因子开关**(`SAF-DEBUG-GATE`):命令行必须带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载 7 条路由,缺一即路由不存在、请求走通用 `404`。令牌为 `SecretString`,常数时间比较、不进日志(`SAF-LOG-PURITY`)。生产环境不配置,见 §2.1 |
| Push registration/verification | 已接入,需显式注册 | `POST /api/push/register` 接受 HTTPS callback URL 并调用 `PushSubscription/set create`;相同 callback URL 重复请求幂等复用;`/push/jmap` 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态 | | Push registration/verification | 已接入,需显式注册 | `POST /api/push/register` 接受 HTTPS callback URL 并调用 `PushSubscription/set create`;相同 callback URL 重复请求幂等复用;`/push/jmap` 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态 |
| `LLM_*` | 可选 | OpenAI-compatible 环境变量(design.md `REQ-LLM-OPENAI-COMPAT`);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) | | `LLM_*` | 可选 | OpenAI-compatible 环境变量(design.md `REQ-LLM-OPENAI-COMPAT`);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) |
| `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 | | `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 |
@ -209,7 +249,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
#### 6.3.1 用户侧调度示例(copy-paste 可用) #### 6.3.1 用户侧调度示例(copy-paste 可用)
端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer <RECONCILE_TOKEN>` 端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer <RECONCILE_TOKEN>`
(`src/config.rs:356` 启动必填,服务端常数时间比较,`notify.rs:149-152`)。 (`src/config.rs:356` 启动必填,服务端常数时间比较,`notify.rs:227-230`)。
最小示例: 最小示例:
@ -277,7 +317,7 @@ spec:
- 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。 - 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。
- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
`LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见
`notify.rs:159`/`172`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 `notify.rs:239`/`252`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回
`409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS` `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS`
(默认 `2`),不受影响。 (默认 `2`),不受影响。
@ -447,7 +487,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
### 10.4 路由与故障转移 ### 10.4 路由与故障转移
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **14 条**(`backends.js:9-24` 的 `SAFE_ROUTES` + `index.js:40-55` 的 `ROUTE_METHODS`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 条不在 safelist**:`GET|PUT /api/enabled`(内部开关)、`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)——**这 4 条都不承载外部业务流量,因此后端实例前不需要第二道入口**。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 - **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **14 条**(`backends.js:9-24` 的 `SAFE_ROUTES` + `index.js:45-60` 的 `ROUTE_METHODS`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 5 类不在 safelist**:`GET|PUT /api/enabled`(内部开关)、`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 5 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 - **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。
- **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。 - **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。

View File

@ -436,6 +436,10 @@ message-weave/
**验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。 **验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。
### 7.6 远程联调面为何默认绝对关闭
该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠网关挡」:进程同时带 `--debug` 且设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。再叠一层位置约束:它不在网关的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、网关不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。
--- ---
## 8. 错误处理与可观测性 ## 8. 错误处理与可观测性
@ -705,7 +709,7 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/
| 变量 | 类型 | 默认 | 用途 | | 变量 | 类型 | 默认 | 用途 |
|---|---|---|---| |---|---|---|---|
| `LLM_ENABLED` | bool | `true` | 总开关 | | `LLM_ENABLED` | bool | `true` | 总开关 |
| `LLM_ALLOW_NET` | bool | `true` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则用 `LlmClient::noop()`(`src/main.rs:109`) | | `LLM_ALLOW_NET` | bool | `true` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则用 `LlmClient::noop()`(`src/main.rs:128`) |
| `LLM_API_KEY` | string | 必填 | Bearer token;缺失即视为未启用 | | `LLM_API_KEY` | string | 必填 | Bearer token;缺失即视为未启用 |
| `LLM_BASE_URL` | URL | 必填 | 必须 `https`,否则 `AiError::InvalidEndpoint` | | `LLM_BASE_URL` | URL | 必填 | 必须 `https`,否则 `AiError::InvalidEndpoint` |
| `LLM_MODEL` | string | 必填 | 透传给 `/chat/completions` 的 `model` | | `LLM_MODEL` | string | 必填 | 透传给 `/chat/completions` 的 `model` |

View File

@ -3,7 +3,7 @@
> **This file is the single source of truth for verifiable facts.** > **This file is the single source of truth for verifiable facts.**
> When any other document disagrees with this one, this one wins. > When any other document disagrees with this one, this one wins.
> >
> **Verified against commit `c5bc7b8`.** Line numbers in this file were read from that > **Verified against commit `afa6cec`.** Line numbers in this file were read from that
> commit with the working tree clean. > commit with the working tree clean.
> >
> **Maintenance responsibility.** Any change to the public API of `src/config.rs`, > **Maintenance responsibility.** Any change to the public API of `src/config.rs`,
@ -71,11 +71,11 @@ Consent never auto-renews.
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:241, `XADD` at state.rs:243); call sites notify.rs:123 (TG webhook) and notify.rs:772 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:373) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:265-266, :280) | | `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:241, `XADD` at state.rs:243); call sites notify.rs:203 (TG webhook) and notify.rs:859 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:373) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:360, :428) |
| `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:315, `claim_dedup` at notify.rs:316 | `release_dedup` on completion (notify.rs:329) | | `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:395, `claim_dedup` at notify.rs:396 | `release_dedup` on completion (notify.rs:409, 416, 425) |
| `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:294, `claim_dedup` at notify.rs:328 | `dedup_exists` before send (notify.rs:295) | | `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:374, `claim_dedup` at notify.rs:408 | `dedup_exists` before send (notify.rs:375) |
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:434, `INCR` state.rs:438, `EXPIRE` state.rs:439) | reclaim path | | `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:434, `INCR` state.rs:438, `EXPIRE` state.rs:439) | reclaim path |
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:441; name built at notify.rs:348, `max_attempts` 3 at notify.rs:349 | **nothing in code reads it** | | `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:441; name built at notify.rs:428 (`max_attempts` 3, same call) | **nothing in code reads it** |
There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams
pending-entries list (PEL), which Redis maintains internally. pending-entries list (PEL), which Redis maintains internally.
@ -96,9 +96,9 @@ back — replay is an operator action, not a service feature.
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:118, `claim_dedup` at notify.rs:119 | — | | `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:198, `claim_dedup` at notify.rs:199, `release_dedup` at :208 | — |
| `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:767, `claim_dedup` at notify.rs:768; same key rebuilt at worker.rs:360 and claimed with `claim_dedup_and_enqueue` at worker.rs:373 | — | | `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:854, `claim_dedup` at notify.rs:855, `release_dedup` at :864; same key rebuilt at worker.rs:360 and claimed with `claim_dedup_and_enqueue` at worker.rs:373 | — |
| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:728, `claim_dedup` at notify.rs:729 | — | | `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:815, `claim_dedup` at notify.rs:816 (`push_verify_rate_limited` at :819) | — |
| `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:629; key at state.rs:632, `SET` without `EX`) | `get_reconcile_state` (state.rs:621) | | `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:629; key at state.rs:632, `SET` without `EX`) | `get_reconcile_state` (state.rs:621) |
`state:jmap:since` is the only state key that survives indefinitely without an explicit `state:jmap:since` is the only state key that survives indefinitely without an explicit
@ -109,12 +109,12 @@ re-baseline on the next restart.
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `lock:reconcile` | 300 | `acquire_lock` (notify.rs:159) | `renew_lock` heartbeat 90 (notify.rs:172) | | `lock:reconcile` | 300 | `acquire_lock` (notify.rs:239) | `renew_lock` heartbeat 90 (notify.rs:252) |
| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:819) | `release_lock` (notify.rs:835, 907) | | `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:906) | `release_lock` (notify.rs:922, 937, 950, 967, 984, 994) |
**Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest **Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest
configured JMAP request timeout (300 s), otherwise a slow registration could admit a configured JMAP request timeout (300 s), otherwise a slow registration could admit a
duplicate. Source comment, notify.rs:816: duplicate. Source comment, notify.rs:904-905:
> The lock must outlive the configured 300s maximum JMAP request timeout; this prevents a > The lock must outlive the configured 300s maximum JMAP request timeout; this prevents a
> slow create from admitting a duplicate. > slow create from admitting a duplicate.
@ -123,10 +123,10 @@ duplicate. Source comment, notify.rs:816:
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:647, called at notify.rs:746) | `push_subscription_verified` (state.rs:663) | | `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:639, called at notify.rs:833) | `push_subscription_verified` (state.rs:663) |
| `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:672) | current-subscription lookup | | `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:672) | current-subscription lookup |
| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:679, key at state.rs:687); pending at notify.rs:873, verified at notify.rs:758, disabled at notify.rs:944 | **none** | | `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:679, key at state.rs:687); pending at notify.rs:960, verified at notify.rs:845, disabled at notify.rs:1031 | **none** |
| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:732, key at state.rs:739, `EX` at state.rs:742; called at notify.rs:890) | `get_push_subscription_for_callback` (state.rs:721); removed by `remove_push_subscription_for_callback` (state.rs:748) | | `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:732, key at state.rs:739, `EX` at state.rs:742; called at notify.rs:977) | `get_push_subscription_for_callback` (state.rs:721); removed by `remove_push_subscription_for_callback` (state.rs:748) |
| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:712) | orphan sweep | | `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:712) | orphan sweep |
**Two distinct keys that are frequently confused. Read this before editing either.** **Two distinct keys that are frequently confused. Read this before editing either.**
@ -171,13 +171,13 @@ Every error response uses one envelope shape:
`Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal `Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal
string `"30"`: string `"30"`:
- `error_response` (`notify.rs:316-324`, inserted at `:320`) sets it **only when its `retry` - `error_response` (`notify.rs:320-328`, inserted at `:324`) sets it **only when its `retry`
flag is true**. The readiness failure path (`notify.rs:157`) passes `retry = true`, so `/ready` flag is true**. The readiness failure path (`notify.rs:161`) passes `retry = true`, so `/ready`
is covered by that rule rather than by a special case. is covered by that rule rather than by a special case.
- `error_response_with_id` (`notify.rs:1076-1084`, inserted at `:1082`) sets it - `error_response_with_id` (`notify.rs:1087-1093`, inserted at `:1090`) sets it
**unconditionally**. It is used only for the push register/disable failures **unconditionally**. It is used only for the push register/disable failures
`push_state_unavailable` and `push_destroy_failed` (`notify.rs:941`, `:958`, `:975`, `push_state_unavailable` and `push_destroy_failed` (`notify.rs:952`, `:969`, `:986`,
`:1012`), which are always `503`. `:1023`), which are always `503`.
`GET /api/status` is the exception that reports setup state with a non-envelope body. `GET /api/status` is the exception that reports setup state with a non-envelope body.
@ -185,7 +185,7 @@ string `"30"`:
## 3. Backend routes ## 3. Backend routes
All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs:1250-1277). All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs:1275), with the routes wired at `notify.rs:1304-1318`.
| Method | Path | Handler | | Method | Path | Handler |
|---|---|---| |---|---|---|
@ -206,6 +206,23 @@ All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs
| GET | `/api/status` | `setup_status` | | GET | `/api/status` | `setup_status` |
| GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `web_config` | | GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `web_config` |
The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the process is started
with `--debug` and `DEBUG_TOKEN` is non-empty** (`SAF-DEBUG-GATE`, `src/main.rs:99-103`,
`src/notify.rs:1320-1321`); otherwise none of these routes exist and requests fall through to
axum's generic `404`, not a 401. All seven require `Authorization: Bearer <DEBUG_TOKEN>`,
checked by `debug_authorized` (`src/debug.rs:45`), which delegates to the production
`worker_authorized` so the comparison is constant time (`src/notify.rs:1135`).
| Method | Path | Handler |
|---|---|---|
| GET | `/debug/ping` | `debug_ping` |
| GET | `/debug/config` | `debug_config` |
| GET | `/debug/redis` | `debug_redis` |
| GET | `/debug/jmap` | `debug_jmap` |
| GET | `/debug/telegram` | `debug_telegram` |
| GET | `/debug/worker` | `debug_worker` |
| POST | `/debug/notify` | `debug_notify` |
Response conventions: Response conventions:
- `GET /healthz` returns **200 unconditionally**. It is a liveness probe and must not be - `GET /healthz` returns **200 unconditionally**. It is a liveness probe and must not be
@ -215,7 +232,7 @@ Response conventions:
completeness (`setup_missing` empty), Redis reachability, and two upstream probes — completeness (`setup_missing` empty), Redis reachability, and two upstream probes —
`GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and `GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and
`GET https://api.telegram.org/bot<token>/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms `GET https://api.telegram.org/bot<token>/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms
(`notify.rs:69`) and run **in parallel** (`tokio::join!`, `notify.rs:143`), so the worst case (`notify.rs:73`) and run **in parallel** (`tokio::join!`, `notify.rs:147`), so the worst case
is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields
are real probe results. The probes are plain reusable functions (`probe_jmap_session`, are real probe results. The probes are plain reusable functions (`probe_jmap_session`,
`probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated. `probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated.
@ -223,6 +240,13 @@ Response conventions:
session URL unauthenticated would report not-ready forever and make ingress stop routing. session URL unauthenticated would report not-ready forever and make ingress stop routing.
- `GET /api/status` returns **503** with body `{"status":"configuration-setup","missing":[...]}` - `GET /api/status` returns **503** with body `{"status":"configuration-setup","missing":[...]}`
when a required environment variable is absent. when a required environment variable is absent.
- `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business
config is not loaded or there is no outbound client — `service_unavailable` with
`Retry-After: 30` (`src/debug.rs:56-58`). The three probe endpoints instead report failure
**inside a 200 body** (`{"ok":false,"detail":...}`), so an unreachable upstream never looks
like an outage. `POST /debug/notify` also returns `403 chat_not_allowed` when the chat is
outside a *non-empty* allowlist (`src/debug.rs:232-235`) and `502 telegram_send_failed` on
a send failure (`src/debug.rs:64-66`).
- Static assets are served with a strict CSP; see §7. - Static assets are served with a strict CSP; see §7.
--- ---
@ -230,15 +254,15 @@ Response conventions:
## 4. Gateway vs backend route matrix ## 4. Gateway vs backend route matrix
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES` > **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`
> (backends.js:9-24), 14 entries, alongside `ROUTE_METHODS` (index.js:40-55) > (backends.js:9-24), 14 entries, alongside `ROUTE_METHODS` (index.js:45-60)
> which fixes one method set per path. The worker entry point is `src/index.js` > which fixes one method set per path. The worker entry point is `src/index.js`
> (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`, > (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`,
> `C-NO-LONG-CONN`). > `C-NO-LONG-CONN`).
The gate is **unconditional and fail-closed**. The worker reads no configuration switches at The gate is **unconditional and fail-closed**. The worker reads no configuration switches at
all: a path missing from `SAFE_ROUTES` returns **404** (index.js:72-73), a registered all: a path missing from `SAFE_ROUTES` returns **404** (index.js:77-78), a registered
path with the wrong method returns **405** (index.js:76-77), and a missing or unparseable path with the wrong method returns **405** (index.js:80-83), and a missing or unparseable
backend pool returns **503** rather than passing the request through (index.js:84-87). backend pool returns **503** rather than passing the request through (index.js:85-93).
**Forwarded by the worker (14):** **Forwarded by the worker (14):**
@ -247,25 +271,26 @@ backend pool returns **503** rather than passing the request through (index.js:8
`/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` · `/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` ·
`/ready` `/ready`
**Registered on the backend but NOT forwarded (4):** **Registered on the backend but NOT forwarded (5):**
| Path | Why it is absent from the gateway | | Path | Why it is absent from the gateway |
|---|---| |---|---|
| `GET, PUT /api/enabled` | Internal operational switch, not exposed through the public gateway | | `GET, PUT /api/enabled` | Internal operational switch, not exposed through the public gateway |
| `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path | | `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path |
| `POST /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:326`); not part of the public gateway path | | `POST /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:330`); not part of the public gateway path |
| `GET /healthz` | Liveness is aggregated by the gateway itself | | `GET /healthz` | Liveness is aggregated by the gateway itself |
| `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly |
Consequence: none of the four carries external business traffic, so no second ingress is Consequence: none of the five carries external business traffic, so no second ingress is
needed in front of the backend instances. The SPA's first-boot flow still cannot drive needed in front of the backend instances. The SPA's first-boot flow still cannot drive
`/api/bootstrap` through the worker — bootstrap must be performed against the backend origin `/api/bootstrap` through the worker — bootstrap must be performed against the backend origin
directly, or the bootstrap path must be added to the gateway allowlist. directly, or the bootstrap path must be added to the gateway allowlist.
`POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to `POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to
proxy: the callback URL is supplied by the client in the request body (`notify.rs:851`, proxy: the callback URL is supplied by the client in the request body (`notify.rs:874`,
validated as a URL at :868), and every push subscription record is written to and read back validated as a URL at :891), and every push subscription record is written to and read back
from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback` from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback`
at `notify.rs:894`), so it does not matter which backend instance the worker picks. Push at `notify.rs:917`), so it does not matter which backend instance the worker picks. Push
registration therefore no longer requires hitting a specific backend address — see registration therefore no longer requires hitting a specific backend address — see
deployment.md §10.5. deployment.md §10.5.
@ -279,18 +304,18 @@ Three distinct layers. They are not interchangeable.
| Variable | Required | Default | Source | | Variable | Required | Default | Source |
|---|---|---|---| |---|---|---|---|
| `REDIS_URL` | yes | — | read at main.rs:39 | | `REDIS_URL` | yes | — | read at main.rs:40 |
| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:46 | | `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:50 |
| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:33 | | `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:34 |
| `RUN_MODE` | no | `webhook` | `unwrap_or_else` at config.rs:338 | | `RUN_MODE` | no | `webhook` | `unwrap_or_else` at config.rs:338 |
`RUN_MODE` accepts only `webhook` or `reconcile`; anything else is rejected at boot `RUN_MODE` accepts only `webhook` or `reconcile`; anything else is rejected at boot
(main.rs:71-78). Both modes currently route to the same no-op router; the distinction is (main.rs:78-81). Both modes currently route to the same no-op router; the distinction is
load-bearing for future splits, not for behaviour today. load-bearing for future splits, not for behaviour today.
**Missing a required variable does not crash the process.** It logs a warning and serves **Missing a required variable does not crash the process.** It logs a warning and serves
`router_configuration_setup`, which builds a router over `MemoryState` with empty tokens and `router_configuration_setup`, which builds a router over `MemoryState` with empty tokens and
a `NoopWorker` (notify.rs:1097-1109). The container stays up and answers requests while doing a `NoopWorker` (worker.rs:142-144). The container stays up and answers requests while doing
no business work. This is a deliberate fail-closed-to-setup posture, and it is the single no business work. This is a deliberate fail-closed-to-setup posture, and it is the single
most likely cause of "the container is healthy but nothing happens". most likely cause of "the container is healthy but nothing happens".
@ -346,7 +371,7 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
| `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:249 | | `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:249 |
| `RECONCILE_BUDGET` | 20 s | worker.rs:250 | | `RECONCILE_BUDGET` | 20 s | worker.rs:250 |
| `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:306 | | `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:306 |
| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:159 / 172 | | Lock TTL / heartbeat | 300 s / 90 s | notify.rs:239 / 252 |
`max_changes` is a function parameter, not a constant. `max_changes` is a function parameter, not a constant.

View File

@ -4,7 +4,7 @@
> 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。 > 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。
> 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/roadmap.md`;当前设计见 `docs/design.md`。 > 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/roadmap.md`;当前设计见 `docs/design.md`。
> 本文**没有行动项**。若某条变成要做的事,去 `docs/roadmap.md` 登记,本文条目保留为决策依据。 > 本文**没有行动项**。若某条变成要做的事,去 `docs/roadmap.md` 登记,本文条目保留为决策依据。
> 验证基线:`c5bc7b8`。 > 验证基线:`afa6cec`。
--- ---
@ -74,10 +74,10 @@
|---|---|---|---| |---|---|---|---|
| `channel/telegram/{mod,commands,session,render}.rs` | 虚构文件 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 | | `channel/telegram/{mod,commands,session,render}.rs` | 虚构文件 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 | | `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:704`) | | `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:717`) |
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:887-890`) | | `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:897-900`) |
| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 | | `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 |
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:326`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:343`)经 `?` 把 Redis 错误原样上抛(:379),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:376);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | | `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:330`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:343`)经 `?` 把 Redis 错误原样上抛(:379),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:376);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
--- ---
@ -86,7 +86,7 @@
| `channel/mod.rs` | 虚构文件 | 渠道层就是 `src/channel.rs`,不是 `channel/` 目录 | 无 | | `channel/mod.rs` | 虚构文件 | 渠道层就是 `src/channel.rs`,不是 `channel/` 目录 | 无 |
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 | | `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 | | `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:873`)内联处理回调 URL 与验证码回写 | 无 | | `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:884`)内联处理回调 URL 与验证码回写 | 无 |
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |

View File

@ -2,7 +2,7 @@
> 只放**缺口**、**阻塞**与**阶段目标**。已实现能力见 `README.md`、`docs/design.md`、`docs/deployment.md`; > 只放**缺口**、**阻塞**与**阶段目标**。已实现能力见 `README.md`、`docs/design.md`、`docs/deployment.md`;
> 可核对的事实(Redis 键、TTL、错误码、路由)见 `docs/reference.md`。 > 可核对的事实(Redis 键、TTL、错误码、路由)见 `docs/reference.md`。
> 验证基线:`c5bc7b8`。门禁命令见 `docs/deployment.md` 的 Gate 一节。 > 验证基线:`afa6cec`。门禁命令见 `docs/deployment.md` 的 Gate 一节。
## 阶段目标 ## 阶段目标
@ -12,7 +12,7 @@
## 阻塞(需要真实环境,当前无法验证) ## 阻塞(需要真实环境,当前无法验证)
- **真实 Stalwart 邮箱联调** — 未做任何真实邮箱端到端集成。JMAP adapter 代码与配置校验已完成并通过本地 `cargo test`,但 `Email/changes`、`PushSubscription` 路径未在真实账号上验证。`REQ-JMAP-SESSION-URL` 已实现(`D-G1-1`)但待真机验证。**阻塞原因**:无可用 Stalwart 账号/凭据;禁止使用测试账号或伪造结果。 - **真实 Stalwart 邮箱联调** — 未做任何真实邮箱端到端集成。JMAP adapter 代码与配置校验已完成并通过本地 `cargo test`,但 `Email/changes`、`PushSubscription` 路径未在真实账号上验证。`REQ-JMAP-SESSION-URL` 已实现(`D-G1-1`)但待真机验证。**阻塞原因**:无可用 Stalwart 账号/凭据;禁止使用测试账号或伪造结果。
- **真实 Telegram Bot 联调** — 入站 `POST /webhook/tg` 的 secret 头校验 + chat 白名单(`notify.rs:95` 起)、出站 `sendMessage`(`channel.rs`)均已在代码内并有单元测试,但**都没有在真实 Bot 上跑过**。另:向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现;长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:无 Bot Token;禁止向真实用户发消息。 - **真实 Telegram Bot 联调** — 入站 `POST /webhook/tg` 的 secret 头校验 + chat 白名单(`notify.rs:175` 起)、出站 `sendMessage`(`channel.rs`)均已在代码内并有单元测试,但**都没有在真实 Bot 上跑过**。另:向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现;长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:无 Bot Token;禁止向真实用户发消息。
- **真实 Redis TLS 连接** — `rediss://` 握手、密码特殊字符 URL 编码、`CONFIG_ENCRYPTION_KEY` 热更新未实测。**阻塞原因**:无托管 Redis 实例。 - **真实 Redis TLS 连接** — `rediss://` 握手、密码特殊字符 URL 编码、`CONFIG_ENCRYPTION_KEY` 热更新未实测。**阻塞原因**:无托管 Redis 实例。
- **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批,但 `newState` 是否表示"全部待报变更之后"仍需真实 Stalwart 复验,否则停机积压边界无法判定。**阻塞原因**:无可用 Stalwart 账号/凭据。 - **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批,但 `newState` 是否表示"全部待报变更之后"仍需真实 Stalwart 复验,否则停机积压边界无法判定。**阻塞原因**:无可用 Stalwart 账号/凭据。
- **Push callback 公网映射** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。 - **Push callback 公网映射** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。
@ -22,6 +22,7 @@
- **`/search` + `SearchSnippet/get` 高亮** — 未实现。`src/` 中 `SearchSnippet` / `search_snippet` 零命中,worker 的 `Intent` 枚举也无 `Search` 变体,因此该路径无法进入。设计文档中的接口表已标注「(未实现)」。 - **`/search` + `SearchSnippet/get` 高亮** — 未实现。`src/` 中 `SearchSnippet` / `search_snippet` 零命中,worker 的 `Intent` 枚举也无 `Search` 变体,因此该路径无法进入。设计文档中的接口表已标注「(未实现)」。
- **Telegram 429 退避实测** — `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)不针对 Telegram 服务端 30 msg/s 限流做专门退避;429 行为无实测数据。需真实 Bot 压测后再决定是否引入指数退避。 - **Telegram 429 退避实测** — `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)不针对 Telegram 服务端 30 msg/s 限流做专门退避;429 行为无实测数据。需真实 Bot 压测后再决定是否引入指数退避。
- **多实例重复投递窗口(残余)** — XAUTOCLAIM 空闲阈值已按批大小缩放(批大小 × 单条上限 300s);仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领(仅重复不丢)。单实例不受影响。 - **多实例重复投递窗口(残余)** — XAUTOCLAIM 空闲阈值已按批大小缩放(批大小 × 单条上限 300s);仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领(仅重复不丢)。单实例不受影响。
- **`/debug/notify` 空白名单不拦截**(`SAF-DEBUG-ALLOWLIST`)— 白名单校验只在白名单**非空**时才生效(`src/debug.rs:232-235`),因此业务白名单未配置时 `chat_id` 可为任意值。**已定位、已文档化、本轮不改代码**:该面默认关闭且需 Bearer,且业务白名单为空时主产品本身即接受任意 chat,沿用同一语义不构成额外泄露面。若日后要收紧,需把语义改为「空白名单则拒绝」,并补单元测试、跑 `GATE-P0`。
## 验收待办 ## 验收待办