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:
parent
e502c40a9e
commit
399b26918e
16
AGENTS.md
16
AGENTS.md
|
|
@ -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 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 里;进程只读两个环境变量,不写任何磁盘。
|
||||||
|
|
|
||||||
|
|
@ -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 配置时退化为确定性。
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`) | 无 |
|
||||||
|
|
|
||||||
|
|
@ -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`。
|
||||||
|
|
||||||
## 验收待办
|
## 验收待办
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue