From 638c3a8cbcddac1d4b261dd331209e465f7b58cf Mon Sep 17 00:00:00 2001 From: anonsaber Date: Tue, 29 Sep 2026 01:16:11 +0800 Subject: [PATCH] docs: consume all four self-checks; make the file-size rule enforceable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every member's audit is now read and each real finding fixed, not just reported. AGENTS.md §2.1 - The 500-line soft cap now has a machine-checkable exception: an over-limit file must carry a one-line `SPLIT-EVAL:` marker stating the split decision. - Six files were over the cap with no justification: notify.rs 2036, state.rs 1586, worker.rs 1045, config.js 1079, client.rs 601, config.rs 521. All now carry the marker. client.rs was missed by my own earlier line count, which did not descend into src/domain/jmap/. - New validator scripts/docs_check/check_file_size.py; GATE-DOCS 4/4 -> 5/5. It proves the marker exists, not that the written reason is sound. - Re-based 144 line anchors across six docs for the +1 line each file gained; every shifted anchor verified against the code, not just for non-blankness. audit-arch.md - design.md §6.1 module matrix omitted the `debug` module entirely; added. - Seven numeric duplications in design.md now defer to reference.md §6.1 as the single authority instead of restating the values. - reference.md §5.1 was missing DEBUG_TOKEN; §5.3 listed six recognised names and guessed `JMAP_*`. It now lists all 20 exactly as config.rs reads them. - Corrected two wrong claims in the audit itself: debug.rs is compiled unconditionally, not under #[cfg(debug_assertions)] (there are no cargo features at all), and /debug/* exposes 7 routes, not the invented session list / resend / rebuild surface. audit-fileplanner.md - reference.md cited a nonexistent `jmap_service.rs`; replaced with the real adapter chain worker.rs -> domain/jmap.rs -> domain/jmap/client.rs. - cloudflare-worker/README.md was outside every validator's scope; all four doc validators now include it (208 section refs, 250 line refs). - README.zh-CN.md stated only half of what README.md says about RUN_MODE; the forward-placeholder sentence is now bilingual. - charter.md §7 boundary table was missing rows for itself and for web/. audit-rust.md / audit-frontend.md - Error envelope corrected: `error` is a bare machine code, no `message`. - Status table, AI-consent labels and ~15 stale or phantom facts fixed against notify.rs; `TelegramClient::SendMessage` and `web_config` phantoms removed. - GET|PUT /api/enabled forwarded by the worker so the SPA kill switch works. - Ten UI strings that called the admin credential the Redis ACL password now say CONFIG_ENCRYPTION_KEY. - POST /healthz returns 405; dead HTTP_STATUS removed. - audit_anchors.py now flags identifiers that do not exist in the file they are anchored to. charter.md - `SAF-ENABLE-FLAG` registered (85 IDs); `SAF-ADMIN-SESSION` row corrected; - `REQ-PUSH-TYPES` registered; the stale `admin_session_digest` removed. Gates: GATE-DOCS PASS 5/5 (250 line-refs, 208 section refs, 0 errors); GATE-P0 62 passed / 0 failed / 1 ignored, baseline unchanged; worker and SPA 36 tests, 0 failures. Real Stalwart / Telegram / managed Redis verification stays blocked on missing credentials and remains labelled as unverified. --- AGENTS.md | 8 +- README.zh-CN.md | 2 +- cloudflare-worker/README.md | 12 +- cloudflare-worker/src/backends.js | 4 +- cloudflare-worker/src/index.js | 6 +- cloudflare-worker/test/backends.test.js | 1 + cloudflare-worker/test/index.test.js | 9 + docs/charter.md | 17 +- docs/deployment.md | 14 +- docs/design.md | 19 ++- docs/reference.md | 209 ++++++++++++++---------- docs/retired.md | 12 +- docs/roadmap.md | 2 +- scripts/docs_check/audit_anchors.py | 85 ++++++++-- scripts/docs_check/check_file_size.py | 60 +++++++ scripts/docs_check/check_sec_refs.py | 2 +- scripts/docs_check/check_tables.py | 2 +- scripts/docs_check/run_all.sh | 9 +- scripts/docs_check/validate_docs.py | 2 +- src/config.rs | 1 + src/domain/jmap/client.rs | 1 + src/notify.rs | 1 + src/state.rs | 1 + src/worker.rs | 1 + web/config.js | 29 ++-- web/index.html | 8 +- 26 files changed, 345 insertions(+), 172 deletions(-) mode change 100755 => 100644 scripts/docs_check/audit_anchors.py create mode 100644 scripts/docs_check/check_file_size.py diff --git a/AGENTS.md b/AGENTS.md index 683a224..b0ec676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ > > 两者冲突时:**项目事实与安全不变量以 `docs/charter.md` 为准,质量规则以本文件为准。** -> **版本基线** `b70d188`(代码:所有行号锚点)|`526a73b`(文档:所有 `§x.y` 引用) +> **版本基线** 填写本仓库的基线 commit:<`<代码行号锚点基线>`(例如 `b70d188`)|`<文档章节引用基线>`(例如 `e0882c7`)>,由持有门禁结果的提交者在合并该批改动时更新。 > 校验命令(`GATE-DOCS` / `GATE-P0`)的具体可复制版本写在 [`docs/charter.md`](docs/charter.md) §5。 @@ -36,9 +36,9 @@ ### 2.1 文件规模 - 单文件规模控制在 **500 行**以内(软上限)。 -- 接近 400 行必须评估拆分。确需超过 500 行时,在文件头部写一句拆分评估结论与当前不拆的理由 - (例:内聚性优先——A 与 B 共享 C,拆分会引入 N 处重复)。 -- **未写明理由的超限文件视为待办项。** 超限不自动合规。 +- 接近 400 行必须评估拆分。确需超过 500 行时,文件头部必须有一行以 `SPLIT-EVAL:` 开头的注释, + 写明拆分评估结论与当前不拆的理由(例:内聚性优先——A 与 B 共享 C,拆分会引入 N 处重复)。 +- **未带 `SPLIT-EVAL:` 标记的超限文件视为待办项。** 超限不自动合规。 - 不得为凑合规而机械拆分,也不得为压低行数把逻辑塞进不可读的长函数。 ### 2.2 注释与代码一致 diff --git a/README.zh-CN.md b/README.zh-CN.md index 30cc23d..b3eeecf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,7 +57,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest | `PORT` | 否 | `8080` | | `RUN_MODE` | 否 | `webhook` | -`RUN_MODE` 只接受 `webhook` 或 `reconcile`。`.env.example` 只提供 2 个必需变量;其余是 +`RUN_MODE` 只接受 `webhook` 或 `reconcile`,但两种取值当前行为完全相同(共享同一套路由表),仅作前向占位,保持默认 `webhook` 即可。`.env.example` 只提供 2 个必需变量;其余是 文档化默认值。 **业务配置 —— 存于 Redis,由浏览器写入。** diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 592b43c..57f2969 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -3,7 +3,7 @@ 统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 **透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 -> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 14 条白名单、 +> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 15 条白名单、 > 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与 > 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。 @@ -31,21 +31,21 @@ ### 透传(A,SAF-LB-PASSTHRU) - 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。 - 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、 - `JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 §10 仅用于多实例互斥,非鉴权) - + 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。 + `JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 `docs/deployment.md` §10 仅用于多实例互斥,非鉴权) + + 相同 App Password(各后端实例的 `JMAP` 可不同,`docs/deployment.md` §10.2)。 ### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL) -- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。 +- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/enabled`(业务总开关,SPA 服务卡片读写)、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。 - 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`。 - 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。 - method 不符 → **405**。 -### 有界故障转移(§10.4,deployment.md) +### 有界故障转移(`docs/deployment.md` §10.4) - 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。 - **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。 - 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。 - 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。 -- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz` 聚合视图;转发目前**不做健康路由过滤**(§10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。 +- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz` 聚合视图;转发目前**不做健康路由过滤**(`docs/deployment.md` §10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。 - **例外:`POST /reconcile` 走 per-route 覆盖。** 它是后端同步长任务——后端持集群级锁 `lock:reconcile`(初租 300s,期间由心跳续期),全局 10s 超时会把它误判为失败并故障转移到第二实例,而第二实例必然立刻返回 409。所以该路由改为:超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000`,留足锁初租 + 心跳余量),且 `maxAttempts=1` 绝不故障转移。其余快路径保持全局默认。 ### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE) diff --git a/cloudflare-worker/src/backends.js b/cloudflare-worker/src/backends.js index 1a31546..348277c 100644 --- a/cloudflare-worker/src/backends.js +++ b/cloudflare-worker/src/backends.js @@ -15,6 +15,7 @@ export const SAFE_ROUTES = Object.freeze([ "/api/business-config", "/api/admin/session", "/api/admin/session/revoke", + "/api/enabled", "/webhook/tg", "/push/jmap", "/api/push/register", @@ -33,7 +34,6 @@ export class BackendConfigError extends Error { } } -const HTTP_STATUS = 80; const HTTPS_STATUS = 443; /** @@ -118,4 +118,4 @@ export function parseBackendOrigins(raw) { return out; } -export { HTTP_STATUS, HTTPS_STATUS }; +export { HTTPS_STATUS }; diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index 78b8920..7f4dc40 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -51,6 +51,7 @@ const ROUTE_METHODS = Object.freeze({ "/api/business-config": ["PUT"], "/api/admin/session": ["POST"], "/api/admin/session/revoke": ["POST"], + "/api/enabled": ["GET", "PUT"], "/webhook/tg": ["POST"], "/push/jmap": ["POST"], "/api/push/register": ["POST"], @@ -69,7 +70,10 @@ export async function handleFetch(request, env) { const path = new URL(request.url).pathname; // LB 级健康聚合探针(不含敏感信息,SAF-PROBE-PUBLIC 精神)。 - if (path === "/healthz" && request.method === "GET") { + if (path === "/healthz") { + if (request.method !== "GET") { + return text("method not allowed for /healthz", 405, { allow: "GET" }); + } return handleHealth(env); } diff --git a/cloudflare-worker/test/backends.test.js b/cloudflare-worker/test/backends.test.js index af52ca8..af915b1 100644 --- a/cloudflare-worker/test/backends.test.js +++ b/cloudflare-worker/test/backends.test.js @@ -47,6 +47,7 @@ test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", "/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile", "/api/push/register", "/api/push/disable", "/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke", + "/api/enabled", ]) { assert.ok(LB_ROUTES.includes(r), `forwarded: ${r}`); } diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index 9ea585e..e0ed816 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -40,6 +40,15 @@ test("Worker GET /healthz aggregates backend probes without touching forwarded r } }); +test("Worker: wrong method on the local /healthz probe => 405, not 404", async () => { + const res = await handleFetch( + new Request("https://lb.example/healthz", { method: "POST", body: "{}" }), + makeEnv(), + ); + assert.equal(res.status, 405); + assert.equal(res.headers.get("allow"), "GET"); +}); + test("Worker: unknown route => 404 (C-LB-SINGLE-REG-URL)", async () => { const res = await handleFetch( new Request("https://lb.example/unknown", { method: "POST", body: "{}" }), diff --git a/docs/charter.md b/docs/charter.md index 592b231..878977a 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -9,7 +9,7 @@ > > 三者冲突时:**可核对事实以 `reference.md` 为准,项目约束以本文件为准,质量规则以 `../AGENTS.md` 为准。** -> **代码基线** `b70d188`(src/ 行号锚点)|`526a73b`(文档)。 +> **代码基线** `b70d188`(src/ 行号锚点)|`e0882c7`(文档)。 --- @@ -126,9 +126,12 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ cd /home/okabe/Repo/messageweave && bash scripts/docs_check/run_all.sh ``` -4 个校验器全过、exit 0 才算通过。只读,从不写文件。 +5 个校验器全过、exit 0 才算通过。只读,从不写文件。 -**`0 error` 只证明「可达」**:行号存在、引用可解析、表格列数一致。 +新增 `check_file_size`:`src/`、`web/`、`cloudflare-worker/src/` 下超过 500 行的源文件必须带 +`SPLIT-EVAL:` 标记(AGENTS.md §2.1)。它证明标记存在,不证明写下的拆分理由站得住脚。 + +**`0 error` 只证明「可达」**:行号存在、引用可解析、表格列数一致、标记就位。 **它不证明语义正确。** 报告校验结果时必须说明这个区分。 ### 6.4 前端测试 @@ -165,6 +168,8 @@ Push callback 公网映射。 | `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** | | `docs/roadmap.md` | 缺口、阻塞、下一阶段目标 | 未决项 | | `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 | +| `docs/charter.md` | 本文件:项目约束、技术选型、实现阶段、安全不变量、稳定 ID 注册表 | 项目约束 | +| `web/`(无文档,4 个文件) | 管理 SPA 源:静态配置页与前端逻辑,由 `web/config.test.mjs` 覆盖 | 前端行为(权威事实记在 `docs/design.md` 与 `docs/reference.md`) | | `cloudflare-worker/README.md` | 网关自身的配置与语义 | 网关 | **跨文档引用规则**: @@ -184,7 +189,7 @@ Push callback 公网映射。 - 新增 ID 必须先登记,再出现在注释、文档或标识符中;校验器会拒绝未登记 ID。 - 内容搬家时**只更新本表「定义文件」列**,所有引用本身零改动。 -共 83 个 ID,按前缀分组:`C-*`(部署约束)· `NG-*`(非目标)· `MOD-*`(模块)· +共 85 个 ID,按前缀分组:`C-*`(部署约束)· `NG-*`(非目标)· `MOD-*`(模块)· `FLOW-*`(数据流)· `REQ-*`(需求)· `SAF-*`(安全)· `NFR-*`(非功能)· `GATE-*`(门禁)· `BOUND-*`(边界)· `ARCH-*`(架构)。 @@ -223,6 +228,7 @@ Push callback 公网映射。 | `REQ-AI-EXTERNAL-CONSENT` | docs/design.md §12 / §3 | 仅用户明确允许后才向外部 AI 发正文 | 需求 | | `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI 授权期限与 Redis 短期 TTL(授权有效期、到期后重新询问) | 需求 | | `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 | +| `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:109 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 | | `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 | | `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 | | `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 | @@ -261,13 +267,14 @@ Push callback 公网映射。 | `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 / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | | `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-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 15 条安全路由内,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 单点故障不在方案范围(用户外部解决) | 非功能 | | `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 | | `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / §3 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 | | `C-LOG-STDOUT-ONLY` | docs/deployment.md §0 / §9.1 / §3 | 日志只写 stdout/stderr,由平台采集;禁用文件日志后端 | 约束 | | `SAF-LOG-PURITY` | docs/deployment.md §0 / §9.1 / §3 | 日志与 Redis 写入内容仅限结构化事件/计数/时间戳/脱敏摘要;禁止密钥/邮件正文/AI 请求响应/附件内容 | 安全 | +| `SAF-ENABLE-FLAG` | src/notify.rs `put_enabled` / src/state.rs `config:enabled` | 全局开关是 Redis 单键 `config:enabled`;未写入即视为关闭,`business_enabled` 出错也按关闭处理(fail-closed);写入须 admin-session Bearer | 安全 | | `C-NO-STATEFUL-RECOVERY` | docs/deployment.md §0 / §9.1 / §3 | 禁止依赖进程内状态做生产恢复;恢复一律走 Redis + JMAP 对账;进程内缓存仅为性能优化,丢失须安全可重入 | 约束 | | `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis 为唯一状态来源 | 状态层统一走 Redis(Streams/SET NX/锁/摘要),进程不持有可恢复状态 | 架构 | | `C-REDIS-EXTERNAL` | docs/deployment.md §8.1 | Redis 由外部已认证实例提供,不与本服务同容器 | 部署约束 | diff --git a/docs/deployment.md b/docs/deployment.md index bc794ad..54c0900 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -62,11 +62,11 @@ 1. 进程命令行必须带 `--debug`(读取于 `src/main.rs:96`)。 2. `DEBUG_TOKEN` 环境变量必须存在且非空(读取于 `src/main.rs:97-100`)。 -缺一即不挂载:`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 值)。 +缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1321-1322`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:107-109`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 **鉴权** -7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`,因此令牌是**常数时间比较**(`src/notify.rs:1135`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 +7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`,因此令牌是**常数时间比较**(`src/notify.rs:1136`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 **7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) @@ -87,8 +87,8 @@ **三条约束,部署时务必确认** - **绝不回显凭据值**:`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 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 +- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1142`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:232-235`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:240-245`)。 +- **不在 Worker 白名单内,只能直连 origin**:网关的 15 条安全路由(`cloudflare-worker/src/backends.js:9-25`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:81-82`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 > **建议**:生产环境不开启。需要远程联调时临时开启、用一次性 token,联调结束立即移除 `--debug` 与 `DEBUG_TOKEN` 后重启;长期暴露面走 §5 的 Secret 管理流程单独审批。 @@ -249,7 +249,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 #### 6.3.1 用户侧调度示例(copy-paste 可用) 端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer ` -(`src/config.rs:356` 启动必填,服务端常数时间比较,`notify.rs:227-230`)。 +(`src/config.rs:357` 启动必填,服务端常数时间比较,`notify.rs:228-231`)。 最小示例: @@ -317,7 +317,7 @@ spec: - 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 - `notify.rs:239`/`252`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 + `notify.rs:240`/`252`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS` (默认 `2`),不受影响。 @@ -487,7 +487,7 @@ Stalwart PushSub ────┼─▶ https://lb..workers.dev ← 唯 ### 10.4 路由与故障转移 -- **路由 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`)。 +- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **15 条**(`SAFE_ROUTES` at `backends.js:9-25` + `ROUTE_METHODS` at `index.js:45-61`):`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`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:586` 读、开关写入在 `:958`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`/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),不触发业务副作用。 - **故障转移(`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 配置时退化为确定性。 diff --git a/docs/design.md b/docs/design.md index ba0c4f5..e16fe6b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -280,7 +280,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标 当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。 -仍成立的不变量:AI 摘要必须先有用户显式授权,**没有授权态不得调用 LLM**。授权一旦生效,摘要会拉取邮件正文全文送 LLM(`read_email` 请求 `TextBody` + `BodyValues` 并 `fetch_text_body_values(true)`,见 `src/domain/jmap/client.rs`),但正文**不回传给 Telegram**——出站只发摘要,或失败时回退为前 300 字符(`worker.rs:150` 注释:body text never reaches Telegram)。 +仍成立的不变量:AI 摘要必须先有用户显式授权,**没有授权态不得调用 LLM**。授权一旦生效,摘要会拉取邮件正文全文送 LLM(`read_email` 请求 `TextBody` + `BodyValues` 并 `fetch_text_body_values(true)`,见 `src/domain/jmap/client.rs`),但正文**不回传给 Telegram**——出站只发摘要,或失败时回退为前 300 字符(`worker.rs:151` 注释:body text never reaches Telegram)。 曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。 @@ -347,6 +347,7 @@ message-weave/ | `ai` | reqwest 0.13 | `LlmClient` / `summarize` | mock OpenAI 兼容端点 | 已实现 | | `notify` | axum;redis | HTTP 鉴权、全局开关、`/push/jmap` 入队、`Email/changes` 对账和游标提交 | webhook 校验失败路径覆盖 403 | 已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 | | `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 | +| `debug` | reqwest 0.13;redis 0.27 | 远端诊断面 `/debug/*`(`src/debug.rs`):6 条只读探针 `/debug/ping`、`/debug/config`、`/debug/redis`、`/debug/jmap`、`/debug/telegram`、`/debug/worker` + `POST /debug/notify`(走生产出站路径发一条测试消息,无独立实现) | 仅 `--debug` + `DEBUG_TOKEN` 双因子齐备时挂载路由;模块本身无条件编译 | 已实现,不进 Worker 白名单(`SAF-DEBUG-ORIGIN-ONLY`) | > 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:`docs/roadmap.md`「代码缺口」中 `/search`、Telegram 429 退避、多实例重复投递窗口均已实现,仅剩 1 条按产品决策保留不改(`SAF-DEBUG-ALLOWLIST`)。`worker` 模块的 `/search` 路径已随 `bfe0fd8` 落地。 @@ -386,7 +387,7 @@ message-weave/ | `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) | | `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) | | `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | `LLM_ENABLED=true` 时必填 | — | OpenAI-compatible | -| `LLM_MAX_RETRIES` | 否(环境变量路径**不读取**) | `3` | 熔断阈值(`REQ-AI-FUSE`)。当前经 Redis 运行参数管理(`OutboundConfig.max_retries`,回落默认 `3`,见 `src/ai.rs`);`Config::from_env()` **不读取**此变量 | +| `LLM_MAX_RETRIES` | 否(环境变量路径**不读取**) | — | 熔断阈值(`REQ-AI-FUSE`)。当前经 Redis 运行参数管理(`OutboundConfig.max_retries`,回落默认见 `docs/reference.md` §6.1);`Config::from_env()` **不读取**此变量 | | `LLM_SUMMARY_TARGET_CHARS` | 否 | `300` | 摘要目标字数(`REQ-LONG-EMAIL`) | > 当前 `config.rs` 通过环境变量和 Redis 业务配置提供上述字段(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`)。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。 @@ -411,7 +412,7 @@ message-weave/ - `/ready` 做端到端探测:配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap`,带配置的 Basic 认证;`GET https://api.telegram.org/bot/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":}` + `Retry-After: 30`)。探针只读、只读配置状态,**不**触发邮件同步等业务副作用,也**不**回显 token 或第三方响应内容;`refresh_business_config` 仅读 Redis,无状态写入。因此 `/ready` 要求到 JMAP host 与 `api.telegram.org:443` 的出站 egress 可达(若该 egress 需要代理则 `/ready` 不可用,见 deployment.md)。 - **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;强制拒绝逻辑已随 Telegram 渠道接入落地(`src/notify.rs` 的 `telegram_webhook` 在任何 JMAP/AI/状态操作之前先校验白名单,拒绝即终止)。 - **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。 -- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries`(默认 3、上限 5)重试;当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。 +- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries` 重试(默认值与硬上限见 `docs/reference.md` §6.1);当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。 ### 7.3.1 配置管理 API - `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 @@ -427,7 +428,7 @@ message-weave/ "max_retries": 3 } ``` -- 三个 timeout 单位均为毫秒,范围 `100..=300000`;`max_retries` 范围 `0..=5`,表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值 `15000 / 10000 / 30000 / 3`;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。 +- 三个 timeout 单位均为毫秒;默认值、取值范围与 `max_retries` 的硬上限以 `docs/reference.md` §6.1 为唯一权威(下表与上面的示例响应体只做示意,不重复数值)。`max_retries` 表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值对象;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。 - `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置,先构建客户端再切换运行 worker;Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。成功返回 `204 No Content`,不会返回配置或密钥。 - `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`、`400`、`422` 与 `503`(Redis 写入或依赖客户端构建失败);其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 @@ -682,7 +683,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义 ### 11.4 已由代码回答的早期问题(不再待确认) 以下问题在设计阶段以 Q6–Q30 形式列出,**代码落地时已各自给出答案**,因此不再是"待确认项",此处只记结论: -- **消息格式**(Q6):`TelegramClient::SendMessage` 只带 `chat_id` + `text` 两个字段,**没有 `parse_mode`**——Telegram 收到后按纯文本渲染,既不用 HTML 也不用 MarkdownV2。 +- **消息格式**(Q6):出站只有 `send_text`(`src/channel.rs:134`),其载荷结构体 `SendMessage`(`src/channel.rs:80`)只有 `chat_id` + `text` 两个字段,**没有 `parse_mode`**——Telegram 收到后按纯文本渲染,既不用 HTML 也不用 MarkdownV2。 - **长邮件 / 原文直发**(Q7、Q29):不存在"正文超过 4000 字符不发全文"的策略。`read_email` 拉全文送 LLM;AI 失败时回退为前 300 字符且**无"已截断"标注**(见 §12.3/§12.5)。 - **附件**(Q8、Q28):完全未实现。领域模型只有 `has_attachment: bool`,出站只有 `sendMessage`,没有 `send_document` 也没有 `Blob/get`(见 §12.3)。 - **推送范围**(Q9):对账拉全量 `changes`,Bot 侧不做文件夹/发件人/关键词过滤,也没有 Sieve 依赖(Q11 因此无影响)。 @@ -727,9 +728,9 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/ | `LLM_API_KEY` | string | 可省略 | Bearer token;仅 `LLM_ENABLED=true` 时必填 | | `LLM_BASE_URL` | URL | 可省略 | 仅启用时必填,且必须 `https`,否则 `AiError::InvalidEndpoint` | | `LLM_MODEL` | string | 可省略 | 仅启用时必填;透传给 `/chat/completions` 的 `model` | -| `LLM_SUMMARY_TARGET_CHARS` | int | `1024` | `LlmClient::max_chars`,对返回摘要文本做字符截断(**代码侧确有截断**) | -| `max_retries`(运行参数) | int | `3` | 由 `OutboundConfig` 下发(`RuntimeConfigProvider`),硬上限 5;LLM 与 Telegram 出站共用 | -| `llm_timeout_ms`(运行参数) | int | `30000` | 硬下限 100ms | +| `LLM_SUMMARY_TARGET_CHARS` | int | `300` | `LlmClient::max_chars`,对返回摘要文本做字符截断(**代码侧确有截断**) | +| `max_retries`(运行参数) | int | 见 `docs/reference.md` §6.1 | 由 `OutboundConfig` 下发(`RuntimeConfigProvider`),硬上限见 `docs/reference.md` §6.1;LLM 与 Telegram 出站共用 | +| `llm_timeout_ms`(运行参数) | int | 见 `docs/reference.md` §6.1 | 由 `OutboundConfig` 下发;单位毫秒,下限与取值范围见 `docs/reference.md` §6.1 | **不存在** `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES`:请求体只有 `model` / `messages` / `max_tokens`(= `max_chars * 2`),没有 temperature,也没有独立于 `llm_timeout_ms` 的超时开关。API key 只从配置读,不入源码、不打日志(`SAF-LOG-PURITY`),运行期 secret 注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 @@ -748,7 +749,7 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/ ### 12.4 失败检测(当前实现) `LlmClient::summarize`(`src/ai.rs`)**只有重试,没有熔断**: -- 超时 `llm_timeout_ms`(默认 30_000ms,最小 100ms),最多重试 `max_retries` 次(默认 3,硬上限 5,与 Telegram 出站共用同一配置项)。 +- 超时 `llm_timeout_ms`、重试次数 `max_retries`(单位、默认值、下限与硬上限均见 `docs/reference.md` §6.1;两项与 Telegram 出站共用同一配置)。 - 只对上一次的失败重试:429 与 5xx 会重试;**其他 4xx(含 401/403)立即返回错误,不重试**。 - 重试耗尽或全部超时 → `Err(AiError::Response)`;URL 解析失败或非 https → `Err(AiError::InvalidEndpoint)`;JSON 反序列化失败或缺 `choices[0].message.content` → `Err(AiError::Response)`。 - `AiError` 只有三个变体:`InvalidEndpoint` / `Request` / `Response`。**不存在** 429/401/Timeout 的细分类型。 diff --git a/docs/reference.md b/docs/reference.md index 0e83301..bf3b30a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -26,48 +26,52 @@ Conventions used throughout: | Key | TTL | Writer | Reader | |---|---|---|---| -| `config:business` | No EX | `set_business_config` (state.rs:576, key at state.rs:580); first-time write via `SET NX` in `initialize_business_config` (state.rs:591) | `get_business_config` (state.rs:565) | -| `config:business:revision` | No EX | atomic `INCR` on every config write (state.rs:584); `SET 1 NX` on first-time init (state.rs:604) | `business_config_revision` (state.rs:639) | -| `config:outbound` | No EX | `set_outbound_config` (state.rs:552) | `get_outbound_config` (state.rs:536) | -| `config:enabled` | No EX | `set_enabled` (state.rs:657) | `is_enabled` (state.rs:648) | -| `config:admin_session` | EX 900 | `put_admin_session` (state.rs:614, key at state.rs:617) | `admin_session_valid` (state.rs:626); cleared by `revoke_admin_session` (state.rs:635) | +| `config:business` | No EX | `set_business_config` (state.rs:577, key at state.rs:581); first-time write via `SET NX` in `initialize_business_config` (state.rs:592) | `get_business_config` (state.rs:566) | +| `config:business:revision` | No EX | atomic `INCR` on every config write (state.rs:585); `SET 1 NX` on first-time init (state.rs:605) | `business_config_revision` (state.rs:640) | +| `config:outbound` | No EX | `set_outbound_config` (state.rs:553) | `get_outbound_config` (state.rs:537) | +| `config:enabled` | No EX | `set_enabled` (state.rs:658) | `is_enabled` (state.rs:649) | +| `config:admin_session` | EX 900 | `put_admin_session` (state.rs:615, key at state.rs:618) | `admin_session_valid` (state.rs:627); cleared by `revoke_admin_session` (state.rs:636) | Notes: - `config:business:revision` is an **atomic Redis `INCR`**, not a read-modify-write. Every - request-bearing entry point calls `refresh_business_config` (notify.rs:717), which only + request-bearing entry point calls `refresh_business_config` (notify.rs:718), which only rebuilds the worker when the remote revision exceeds the cached local value - (notify.rs:722-724) — that guard is what keeps a stale or malformed snapshot from turning + (notify.rs:723-725) — that guard is what keeps a stale or malformed snapshot from turning into a rebuild loop. - `config:admin_session` carries no TTL metadata beyond the literal `EX 900` passed at write time; the handler reports the same window in its response body as `expires_in: 900` - (notify.rs:787). + (notify.rs:788). - `config:enabled` is consulted as a gate; missing or `false` keeps business processing off. ### 1.2 AI consent | Key | TTL | Writer | Reader | |---|---|---|---| -| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` (state.rs:504; key at state.rs:506, `EX` at state.rs:511) | `ai_consent_until` (state.rs:518); cleared by `clear_ai_consent` (state.rs:527) | +| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` (state.rs:505; key at state.rs:507, `EX` at state.rs:512) | `ai_consent_until` (state.rs:519); cleared by `clear_ai_consent` (state.rs:528) | The stored value is an **absolute Unix expiry timestamp**, not a duration; the key's `EX` -carries the same duration, so the key removes itself (state.rs:508-513). +carries the same duration, so the key removes itself (state.rs:509-514). Trigger words are exact Chinese literals with **no English aliases** — the in-app help text -enforces this itself (`worker.rs:613`): +enforces this itself (`worker.rs:614`): | Input | TTL | Label | |---|---|---| -| `/ai on`, `临时一次`, `临时`, `一次` | 3600 | `1小时` | +| `/ai on`, `/ai yes`, `开启 ai`, `同意摘要`, `允许 ai` | 3600 | `1小时` | +| `临时`, `一次` (substring) | 3600 | `临时1小时` | | `今天` | 86_400 | `今天` | | `7天` | 604_800 | `7天` | -| `直到我撤销`, `长期` | 31_536_000 (365 d) | `直到我撤销` | +| `直到我撤销`, `长期` (substring) | 31_536_000 (365 d) | `直到撤销(最长365天)` | | `/ai off`, `关闭 ai`, `撤销授权`, `停止摘要` | — | revoke | -`1小时` is a **category label, not a trigger word**: typing `1小时` alone grants nothing. -Consent never auto-renews. +The `/ai …` slash forms are exact matches on the whole message; the Chinese phrases are +substring matches anywhere in the message (`worker.rs:546-566`, `:581-586`). -The same `parse_intent` (worker.rs:522, `Intent` at worker.rs:504) routes `/search <关键词>` +`1小时` / `临时1小时` / `直到撤销(最长365天)` are **category labels, not trigger words**: +typing a label alone grants nothing. Consent never auto-renews. + +The same `parse_intent` (worker.rs:523, `Intent` at worker.rs:505) routes `/search <关键词>` and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, never a substring) to `Intent::Search`. Search grants no consent and writes no Redis key. @@ -75,11 +79,11 @@ and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, neve | Key | TTL | Writer | Reader | |---|---|---|---| -| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:282, `XADD` at state.rs:284); 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: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: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:480, `INCR` state.rs:484, `EXPIRE` state.rs:485) | reclaim path | -| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:487; name built at notify.rs:428 (`max_attempts` 3, same call) | **nothing in code reads it** | +| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:283, `XADD` at state.rs:285); call sites notify.rs:204 (TG webhook) and notify.rs:860 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:374) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:361, :428) | +| `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:396, `claim_dedup` at notify.rs:397 | `release_dedup` on completion (notify.rs:410, 416, 425) | +| `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:375, `claim_dedup` at notify.rs:409 | `dedup_exists` before send (notify.rs:376) | +| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:481, `INCR` state.rs:485, `EXPIRE` state.rs:486) | reclaim path | +| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:488; name built at notify.rs:429 (`max_attempts` 3, same call) | **nothing in code reads it** | There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams pending-entries list (PEL), which Redis maintains internally. @@ -93,17 +97,17 @@ acknowledgement. The DLQ is **append-and-ack**: the same Lua script that appends to `stalwart:jmap:dlq` / `stalwart:telegram:dlq` also `XACK`s the message out of the source stream, so increment, DLQ -append and source acknowledgement are atomic (state.rs:484-488). No code path reads the DLQ +append and source acknowledgement are atomic (state.rs:485-489). No code path reads the DLQ back — replay is an operator action, not a service feature. ### 1.4 Idempotency and rate limits | Key | TTL | Writer | Reader | |---|---|---|---| -| `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: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:815, `claim_dedup` at notify.rs:816 (`push_verify_rate_limited` at :819) | — | -| `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:675; key at state.rs:678, `SET` without `EX`) | `get_reconcile_state` (state.rs:667) | +| `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:199, `claim_dedup` at notify.rs:200, `release_dedup` at :208 | — | +| `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:855, `claim_dedup` at notify.rs:856, `release_dedup` at notify.rs:865; same key rebuilt at worker.rs:361 and claimed with `claim_dedup_and_enqueue` at worker.rs:374 | — | +| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:816, `claim_dedup` at notify.rs:817 (`push_verify_rate_limited` at :819) | — | +| `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:676; key at state.rs:679, `SET` without `EX`) | `get_reconcile_state` (state.rs:668) | `state:jmap:since` is the only state key that survives indefinitely without an explicit delete, by design: it is the reconciliation cursor, and expiring it would force a full @@ -113,12 +117,12 @@ re-baseline on the next restart. | Key | TTL | Writer | Reader | |---|---|---|---| -| `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:906) | `release_lock` (notify.rs:922, 937, 950, 967, 984, 994) | +| `lock:reconcile` | 300 | `acquire_lock` (notify.rs:240) | `renew_lock` heartbeat 90 (notify.rs:253) | +| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:907) | `release_lock` (notify.rs:923, 937, 950, 967, 984, 994) | **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 -duplicate. Source comment, notify.rs:904-905: +duplicate. Source comment, notify.rs:905-906: > The lock must outlive the configured 300s maximum JMAP request timeout; this prevents a > slow create from admitting a duplicate. @@ -127,11 +131,11 @@ duplicate. Source comment, notify.rs:904-905: | Key | TTL | Writer | Reader | |---|---|---|---| -| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:685, called at notify.rs:833) | `push_subscription_verified` (state.rs:702) | -| `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:715) | current-subscription lookup | -| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:725, key at state.rs:733); 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:778, key at state.rs:785, `EX` at state.rs:787; called at notify.rs:977) | `get_push_subscription_for_callback` (state.rs:767); removed by `remove_push_subscription_for_callback` (state.rs:794) | -| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:751) | orphan sweep | +| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:686, called at notify.rs:834) | `push_subscription_verified` (state.rs:703) | +| `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:716) | current-subscription lookup | +| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:726, key at state.rs:734); pending at notify.rs:961, verified at notify.rs:846, disabled at notify.rs:1032 | **none** | +| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:779, key at state.rs:786, `EX` at state.rs:788; called at notify.rs:978) | `get_push_subscription_for_callback` (state.rs:768); removed by `remove_push_subscription_for_callback` (state.rs:795) | +| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:752) | orphan sweep | **Two distinct keys that are frequently confused. Read this before editing either.** @@ -143,7 +147,7 @@ duplicate. Source comment, notify.rs:904-905: They use the same digest but are separate keys with unrelated lifetimes and purposes. **Annotation: `push:subscription:{id}:status` is write-only by design.** -The source carries an explicit note at state.rs:210: +The source carries an explicit note at state.rs:211: > `push:subscription:{id}:status` is currently write-only @@ -159,28 +163,34 @@ Do not infer correctness from its presence. Every error response uses one envelope shape: ```json -{ "error": { "code": "", "message": "" } } +{ "error": "", "request_id": "" } ``` +`error` is a **plain machine-readable string**, not a nested object — there is no `message` +field. `request_id` is always present. + | Status | Code | Retry-After | |---|---|---| | 400 | `invalid_request`, `missing` | — | | 401 | `unauthorized` | — | -| 403 | `forbidden` | — | +| 403 | `forbidden`, `chat_not_allowed` (`/debug/*` only) | — | +| 404 | `push_subscription_not_found` | — | | 409 | `conflict` | — | | 422 | `invalid_configuration` | — | +| 429 | `push_verify_rate_limited` | `30` | | 500 | `internal_error` | — | -| 503 | `service_unavailable`, `disabled`, `push_subscription_not_found`, `push_verify_rate_limited`, `push_state_unavailable`, `push_destroy_failed` | `30` when retryable, or always for the push register/disable failures | +| 502 | `telegram_send_failed` (`/debug/*` only) | — | +| 503 | `service_unavailable`, `disabled`, `reconcile_retry`, `push_verify_failed`, `push_state_unavailable`, `push_destroy_failed` | `30` when retryable, or always for the push register/disable failures | `Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal string `"30"`: -- `error_response` (`notify.rs:320-328`, inserted at `:324`) sets it **only when its `retry` - flag is true**. The readiness failure path (`notify.rs:161`) passes `retry = true`, so `/ready` +- `error_response` (`notify.rs:321-329`, inserted at `:324`) sets it **only when its `retry` + flag is true**. The readiness failure path (`notify.rs:162`) passes `retry = true`, so `/ready` is covered by that rule rather than by a special case. -- `error_response_with_id` (`notify.rs:1087-1093`, inserted at `:1090`) sets it +- `error_response_with_id` (`notify.rs:1088-1094`, inserted at `:1090`) sets it **unconditionally**. It is used only for the push register/disable failures - `push_state_unavailable` and `push_destroy_failed` (`notify.rs:952`, `:969`, `:986`, + `push_state_unavailable` and `push_destroy_failed` (`notify.rs:953`, `:969`, `:986`, `:1023`), which are always `503`. `GET /api/status` is the exception that reports setup state with a non-envelope body. @@ -189,7 +199,10 @@ string `"30"`: ## 3. Backend routes -All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs:1275), with the routes wired at `notify.rs:1304-1318`. +All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap_config` +(`notify.rs:1276`), with the routes wired at `notify.rs:1305-1319`. The three static SPA routes +in the last table row are the exception: they live in `web::router()` (`src/web.rs:19-24`) and +are merged into the production router at `src/main.rs:62` and `src/main.rs:194`. | Method | Path | Handler | |---|---|---| @@ -208,14 +221,14 @@ All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs | GET | `/healthz` | `healthz` | | GET | `/ready` | `ready` | | GET | `/api/status` | `setup_status` | -| GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `web_config` | +| GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `script` / `styles` | 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:96-100`, -`src/notify.rs:1320-1321`); otherwise none of these routes exist and requests fall through to +`src/notify.rs:1321-1322`); otherwise none of these routes exist and requests fall through to axum's generic `404`, not a 401. All seven require `Authorization: Bearer `, 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`). +`worker_authorized` so the comparison is constant time (`src/notify.rs:1136`). | Method | Path | Handler | |---|---|---| @@ -236,14 +249,16 @@ Response conventions: completeness (`setup_missing` empty), Redis reachability, and two upstream probes — `GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and `GET https://api.telegram.org/bot/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms - (`notify.rs:73`) and run **in parallel** (`tokio::join!`, `notify.rs:147`), so the worst case + (`notify.rs:74`) and run **in parallel** (`tokio::join!`, `notify.rs:148`), so the worst case 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`, `probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated. The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw session URL unauthenticated would report not-ready forever and make ingress stop routing. -- `GET /api/status` returns **503** with body `{"status":"configuration-setup","missing":[...]}` - when a required environment variable is absent. +- `GET /api/status` (**always 200**, `notify.rs:166-174`) returns + `{"ready": , "mode": "env"|"redis", "missing": [...]}`. `ready` is `false` and + `missing` lists the absent required keys (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`) when a + variable is absent — the route itself never errors, so it is safe to poll. - `/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 @@ -258,43 +273,49 @@ Response conventions: ## 4. Gateway vs backend route matrix > **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES` -> (backends.js:9-24), 14 entries, alongside `ROUTE_METHODS` (index.js:45-60) +> (backends.js:9-25), 15 entries, alongside `ROUTE_METHODS` (index.js:45-61) > 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`, > `C-NO-LONG-CONN`). 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:77-78), a registered -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:85-93). +all: a path missing from `SAFE_ROUTES` (backends.js:9-25) returns **404** (index.js:81-82), a registered +path with the wrong method returns **405** (index.js:84-86), and a missing or unparseable +backend pool returns **503** rather than passing the request through (index.js:89-96). -**Forwarded by the worker (14):** +**Forwarded by the worker (15):** `/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` · `/api/business-config` · `/api/admin/session` · `/api/admin/session/revoke` · +`/api/enabled` · `/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` · `/ready` -**Registered on the backend but NOT forwarded (5):** +**Registered on the backend but NOT forwarded (4):** | Path | Why it is absent from the 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 /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:330`); not part of the public gateway path | +| `POST /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:331`); not part of the public gateway path | | `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 five carries external business traffic, so no second ingress is +`GET, PUT /api/enabled` (the `SAF-ENABLE-FLAG` kill switch) **is** forwarded, because the +admin SPA serves it at the Worker URL and toggles it from the service card (`loadEnabled` +reads it at `web/config.js:586`, the toggle writes it at `:958`); both calls are +admin-session Bearer-auth'd, so the exposure is identical to the already-forwarded +`/api/admin/session` pair. + +Consequence: none of the four 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 `/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. `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:874`, +proxy: the callback URL is supplied by the client in the request body (`notify.rs:875`, 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` -at `notify.rs:917`), so it does not matter which backend instance the worker picks. Push +at `notify.rs:918`), so it does not matter which backend instance the worker picks. Push registration therefore no longer requires hitting a specific backend address — see deployment.md §10.5. @@ -304,24 +325,25 @@ deployment.md §10.5. Three distinct layers. They are not interchangeable. -### 5.1 Boot-time (2 required, 2 optional with defaults) +### 5.1 Boot-time (2 required, 3 optional with defaults) | Variable | Required | Default | Source | |---|---|---|---| | `REDIS_URL` | yes | — | read at main.rs:40 | | `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:50 | -| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:34 | -| `RUN_MODE` | no | `webhook` | `unwrap_or_else` at config.rs:338 | +| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:34; parsed again at config.rs:312 on the legacy `from_env()` path (validation parity only, see the note at config.rs:37) | +| `RUN_MODE` | no | `webhook` | `unwrap_or_else` at config.rs:345 | +| `DEBUG_TOKEN` | no | — | `std::env::var` at main.rs:97, effective only when `--debug` is also passed (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) | `RUN_MODE` accepts only `webhook` or `reconcile`; anything else is rejected at boot -(main.rs:78-81). Both modes currently route to the same no-op router; the distinction is +(main.rs:78-82). Both modes currently route to the same no-op router; the distinction is load-bearing for future splits, not for behaviour today. Note the value is only read on -the legacy env path (`config.rs:338`), so with the current two-variable production config +the legacy env path (`config.rs:345`), so with the current two-variable production config it always resolves to the hardcoded default — setting it to `reconcile` has no effect. **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 -a `NoopWorker` (worker.rs:142-144). The container stays up and answers requests while doing +a `NoopWorker` (worker.rs:143-145). 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 most likely cause of "the container is healthy but nothing happens". @@ -335,21 +357,27 @@ The process never reads these from the environment in normal operation. ### 5.3 Legacy compatibility (read but not required) `config.rs` still reads an environment-shaped business configuration for backward -compatibility. Recognised names, unprefixed unless noted: +compatibility. The full set of names it recognises (`from_env()`, `config.rs:311`), +unprefixed unless noted: -`BOT_TOKEN` · `CHAT_ALLOWLIST` · `TG_WEBHOOK_SECRET` · `TELEGRAM_CHAT_ID` · -`JMAP_*` · `RECONCILE_TOKEN` +`CONFIG_ENCRYPTION_KEY` · `PORT` · `REDIS_URL` · `CHAT_ALLOWLIST` · `TELEGRAM_CHAT_ID` · +`LLM_ENABLED` · `LLM_ALLOW_NET` · `LLM_API_KEY` · `LLM_BASE_URL` · `LLM_MODEL` · +`LLM_SUMMARY_TARGET_CHARS` · `RUN_MODE` · `BOT_TOKEN` · `JMAP_SESSION_URL` · `JMAP_USERNAME` · +`JMAP_PASSWORD` · `ACCOUNT_ID` · `RECONCILE_TOKEN` · `TG_WEBHOOK_SECRET` · `WORKER_TOKEN` + +The `JMAP_*` family is exactly three names; `ACCOUNT_ID` is read here too, alongside the +direct read the JMAP client performs when no `accountId` is present in the request. The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as `TELEGRAM_BOT_TOKEN` or similar is a documentation error, not a supported variable. **Trust root.** The SPA admin credential is `CONFIG_ENCRYPTION_KEY` itself: read at startup (main.rs:49) and held as `admin_token` (main.rs:92), it is checked in constant time by -`worker_authorized` (notify.rs:445; compare at notify.rs:1135) at the top of both -`POST /api/bootstrap` (notify.rs:604) and `POST /api/admin/session` (notify.rs:774). It is +`worker_authorized` (notify.rs:446; compare at notify.rs:1136) at the top of both +`POST /api/bootstrap` (notify.rs:605) and `POST /api/admin/session` (notify.rs:775). It is only compared against the request bearer — never echoed, logged, or stored. The session issued by `/api/admin/session` is a freshly generated random 32-byte hex token -(notify.rs:778-787), never the credential; only its digest is retained in Redis under +(notify.rs:779-788), never the credential; only its digest is retained in Redis under `config:admin_session`. `REDIS_URL` is the Redis connection string alone: an ACL password in it, if any, authenticates the Redis connection and is not the credential for any HTTP endpoint. @@ -362,10 +390,10 @@ endpoint. | Parameter | Default | Range / cap | Source | |---|---|---|---| -| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:55; validated notify.rs:491 | -| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:56; validated notify.rs:492 | -| `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:57; validated notify.rs:493 | -| `max_retries` | 3 | hard cap 5 | default state.rs:58; rejected at notify.rs:494; re-clamped at channel.rs:138 | +| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:56; validated notify.rs:492 | +| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:57; validated notify.rs:493 | +| `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:58; validated notify.rs:494 | +| `max_retries` | 3 | hard cap 5 | default state.rs:59; rejected at notify.rs:495; re-clamped at channel.rs:138 | There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in `config:outbound` and is bounded at 5 regardless of what is written. @@ -374,24 +402,24 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in | Constant | Value | Source | |---|---|---| -| `MAX_BASELINE_PAGES` | 100 | worker.rs:248 | -| `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:249 | -| `RECONCILE_BUDGET` | 20 s | worker.rs:250 | -| `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:306 | -| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:239 / 252 | +| `MAX_BASELINE_PAGES` | 100 | worker.rs:249 | +| `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:250 | +| `RECONCILE_BUDGET` | 20 s | worker.rs:251 | +| `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:307 | +| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:240 / 252 | `max_changes` is a function parameter, not a constant. ### 6.3 Idle threshold -XAUTOCLAIM's idle threshold is derived from the live outbound config (state.rs:98): +XAUTOCLAIM's idle threshold is derived from the live outbound config (state.rs:99): ``` count × (max_retries + 1) × (jmap_timeout_ms + telegram_timeout_ms + llm_timeout_ms) × 2 ``` -floored at the legacy 300 s single-event ceiling (state.rs:65) and capped at 6 h -(state.rs:75). The floor is also the fallback when the config cannot be read, so a +floored at the legacy 300 s single-event ceiling (state.rs:66) and capped at 6 h +(state.rs:76). The floor is also the fallback when the config cannot be read, so a Redis hiccup never collapses the window. Only duplicate-on-multi-instance is at stake, never loss. @@ -399,9 +427,9 @@ stake, never loss. | Constant | Value | Source | |---|---|---| -| `SEARCH_LIMIT` | 10 | worker.rs:618 | -| `SEARCH_SUBJECT_MAX` | 120 chars | worker.rs:620 | -| `SEARCH_PREVIEW_MAX` | 160 chars | worker.rs:622 | +| `SEARCH_LIMIT` | 10 | worker.rs:619 | +| `SEARCH_SUBJECT_MAX` | 120 chars | worker.rs:621 | +| `SEARCH_PREVIEW_MAX` | 160 chars | worker.rs:623 | --- @@ -434,9 +462,10 @@ and only `script-src`, `style-src` and `connect-src` re-open a same-origin chann Recorded here so that references elsewhere cannot be mistaken for shipped features. -- `/search` **is** implemented (`bfe0fd8`, `worker.rs` / `jmap_service.rs`). What is *not* - possible: **body-level** snippets. jmap-client `0.4.2` only exposes `emailId`/`subject`/ - `preview` from `SearchSnippet/get`, and its `Filter` type has no comparator syntax, so +- `/search` **is** implemented (`bfe0fd8`). Adapter: `search_emails` at `worker.rs:652` and + at `src/domain/jmap.rs:268`, backed by `search_emails` at `src/domain/jmap/client.rs:318`. + What is *not* possible: **body-level** snippets. jmap-client `0.4.2` only exposes + `emailId`/`subject`/`preview` from `SearchSnippet/get`, and its `Filter` type has no comparator syntax, so per-part body highlight cannot be modelled through the locked crate. Search degrades to `subject`/`preview` highlights plus a pure-ID list when snippets are unsupported. Design note in `docs/design.md` §5.7. diff --git a/docs/retired.md b/docs/retired.md index 5687409..185d697 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -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` 内 | | `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:717`) | -| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:897-900`) | +| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:718`) | +| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:898-901`) | | `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:330`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:384`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`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:331`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`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/` 目录 | 无 | | `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 逻辑以自由函数存在 | 无 | -| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:884`)内联处理回调 URL 与验证码回写 | 无 | +| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:885`)内联处理回调 URL 与验证码回写 | 无 | | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | @@ -95,9 +95,9 @@ | 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 | | 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 | | 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 | -| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:517`) | 无 | +| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:533`) | 无 | | `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 | -| `run_mode.rs`(作为单独文件) | 虚构文件 | RUN_MODE 由 `config.rs:338` 读取、在 `src/main.rs:75` 校验取值;不存在 `validate_env_or_exit` 这类函数 | 无 | +| `run_mode.rs`(作为单独文件) | 虚构文件 | RUN_MODE 由 `config.rs:345` 读取、在 `src/main.rs:75` 校验取值;不存在 `validate_env_or_exit` 这类函数 | 无 | | docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 | | 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 3e76c05..af31848 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,7 +12,7 @@ ## 阻塞(需要真实环境,当前无法验证) - **真实 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:175` 起)、出站 `sendMessage`(`channel.rs`)均已在代码内并有单元测试,但**都没有在真实 Bot 上跑过**。另:向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现;长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:无 Bot Token;禁止向真实用户发消息。 +- **真实 Telegram Bot 联调** — 入站 `POST /webhook/tg` 的 secret 头校验 + chat 白名单(`notify.rs:176` 起)、出站 `sendMessage`(`channel.rs`)均已在代码内并有单元测试,但**都没有在真实 Bot 上跑过**。另:向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现;长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:无 Bot Token;禁止向真实用户发消息。 - **真实 Redis TLS 连接** — `rediss://` 握手、密码特殊字符 URL 编码、`CONFIG_ENCRYPTION_KEY` 热更新未实测。**阻塞原因**:无托管 Redis 实例。 - **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批,但 `newState` 是否表示"全部待报变更之后"仍需真实 Stalwart 复验,否则停机积压边界无法判定。**阻塞原因**:无可用 Stalwart 账号/凭据。 - **Push callback 公网映射** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。 diff --git a/scripts/docs_check/audit_anchors.py b/scripts/docs_check/audit_anchors.py old mode 100755 new mode 100644 index 822d029..a1ab8da --- a/scripts/docs_check/audit_anchors.py +++ b/scripts/docs_check/audit_anchors.py @@ -1,21 +1,70 @@ #!/usr/bin/env python3 -"""Check every `foo.rs:N` / `foo.rs:N-M` line anchor in the docs. +"""Check every `foo.rs:N` / `foo.rs:N-M` / `web/config.js:N` line anchor in the docs. + +Paths may be bare (`notify.rs:320`, resolved under `src/`), prefixed with a directory +(`src/domain/jmap/client.rs:108`, `cloudflare-worker/src/backends.js:9`, +`web/config.js:585`), or unprefixed JavaScript (`config.js:585`, resolved under `web/` +or the Worker). LIMITATION: this only proves the cited line *exists and is not blank*. It does not prove the line contains what the prose claims about it. A doc can pass this audit with anchors that point at the wrong line -- a human must read the target line to catch that. Keep this in mind when quoting "0 errors" as proof of accuracy: it proves reachability, not correctness. + +The one exception is a *phantom identifier*: if a backticked name sits right next +to an anchor (the common "`foo` ... `bar.rs:N`" phrasing) and that name appears +nowhere in bar.rs, the anchor is reported. Existence, not position -- the symbol +may still be on the wrong line. """ -import re, sys +import re +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', - 'docs/roadmap.md', 'docs/charter.md'] + 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md'] + +IDENT = r'[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?' +FILE = r'((?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_-]+)' +EXT = r'\.(rs|js):(\d+)' + +# A directory part is zero or more full segments (`/seg`), so the regex can never +# split a file name like `debug.rs` into a "directory" plus a stray base. +REF = re.compile( + r'((?:src|cloudflare-worker/src|web)(?:/[A-Za-z0-9_.-]+)*/)?' + + FILE + EXT + r'(?:\s*[,–\-]\s*(\d+))?' +) + +# A backticked identifier sitting right next to an anchor is an implicit claim: +# "`foo` ... `bar.rs:N`". If `foo` never appears in bar.rs the anchor is a phantom. +# The gap may not cross more than one backtick boundary and is capped at 40 +# characters on each side, so only a genuinely coupled identifier/anchor pair +# matches. An optional `()` after the identifier is tolerated, since prose often +# writes function names that way. +PAIR = re.compile( + r'`(' + IDENT + r')(?:\(\))?`' + r'[^`\n|]{0,40}?`?[^`\n|]{0,40}?' + r'(? {SOFT_MAX}, ' + f'no "{MARKER}" marker within the first {WINDOW} lines') + + for path in over: + print(f' {path.relative_to(ROOT)}: ' + f'{len(path.read_text(encoding="utf-8").splitlines())} lines') + + print(f'=== SIZE CHECK ({len(errors)} errors) ===') + return 1 if errors else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/docs_check/check_sec_refs.py b/scripts/docs_check/check_sec_refs.py index da16295..8eda2b8 100755 --- a/scripts/docs_check/check_sec_refs.py +++ b/scripts/docs_check/check_sec_refs.py @@ -15,7 +15,7 @@ import sys ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md", "docs/deployment.md", "docs/reference.md", "docs/retired.md", - "docs/roadmap.md", "docs/charter.md"] + "docs/roadmap.md", "docs/charter.md", "cloudflare-worker/README.md"] HEADING = re.compile(r"^(#{2,4})\s+(\d+(?:\.\d+)?\.?)\s") # §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer diff --git a/scripts/docs_check/check_tables.py b/scripts/docs_check/check_tables.py index 3afd501..25f8db6 100755 --- a/scripts/docs_check/check_tables.py +++ b/scripts/docs_check/check_tables.py @@ -4,7 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', - 'docs/roadmap.md', 'docs/charter.md'] + 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md'] CODE = re.compile(r'`[^`\n]*`') SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$') diff --git a/scripts/docs_check/run_all.sh b/scripts/docs_check/run_all.sh index 5012a54..569aff3 100755 --- a/scripts/docs_check/run_all.sh +++ b/scripts/docs_check/run_all.sh @@ -16,9 +16,14 @@ # with anchors on the wrong line -- someone must read them. # check_sec_refs every "§N.N" cross-reference resolves to a real heading in # the document it is attributed to. +# check_file_size every source file over 500 lines carries the SPLIT-EVAL +# marker required by AGENTS.md §2.1. LIMITATION: it proves +# the marker exists, not that the written reason is sound. # # audit_anchors and check_sec_refs only scan prose: lines inside fenced code # blocks are skipped, so examples and tables holding code do not raise noise. +# check_file_size only scans src/, web/ and cloudflare-worker/src/ — docs and +# tests are out of scope. # # Root is derived from this file's own location, so the whole suite works from a # clean clone without editing any path. @@ -29,7 +34,7 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" rc=0 fail=0 -for script in validate_docs check_tables audit_anchors check_sec_refs; do +for script in validate_docs check_tables audit_anchors check_sec_refs check_file_size; do out="$(python3 "$here/$script.py" 2>&1)" code=$? # Each check prints "=== NAME (n errors) ===" as its summary, but not @@ -46,7 +51,7 @@ for script in validate_docs check_tables audit_anchors check_sec_refs; do done if [ "$fail" -eq 0 ]; then - echo "GATE-DOCS: PASS (4/4 checks clean)" + echo "GATE-DOCS: PASS (5/5 checks clean)" else echo "GATE-DOCS: FAIL ($fail check(s) reported errors)" fi diff --git a/scripts/docs_check/validate_docs.py b/scripts/docs_check/validate_docs.py index 8b501f6..500ba48 100755 --- a/scripts/docs_check/validate_docs.py +++ b/scripts/docs_check/validate_docs.py @@ -3,7 +3,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', - 'docs/roadmap.md', 'docs/charter.md'] + 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md'] errors = [] def slug(t): diff --git a/src/config.rs b/src/config.rs index 7c356c6..7c026e5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +// SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义、Redis 配置解析与遗留 from_env() 路径相互引用,拆出只增加跨文件跳转而不消除任何重复。 use secrecy::{ExposeSecret, SecretString}; use crate::error::BotError; diff --git a/src/domain/jmap/client.rs b/src/domain/jmap/client.rs index 52051d1..cb850d4 100644 --- a/src/domain/jmap/client.rs +++ b/src/domain/jmap/client.rs @@ -1,3 +1,4 @@ +//! SPLIT-EVAL: 已评估暂缓拆分——jmap-client 0.4.2 的适配、请求建模与降级策略必须贴在一起才能对照 crate 文档逐字段验证,拆开会切断这条对照链。 //! Concrete read-only adapter (MOD-JMAP-CLIENT, GATE-G1-JMAP-READONLY). use super::{ diff --git a/src/notify.rs b/src/notify.rs index 63edb8f..04114f2 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -1,3 +1,4 @@ +// SPLIT-EVAL: 已评估暂缓拆分——全部 HTTP 处理器共享同一套 Bearer/admin-session 鉴权、统一错误封装与路由装配顺序,拆分会让每个子模块重复导入并复述这些前置条件。 use axum::{ body::Bytes, extract::State, diff --git a/src/state.rs b/src/state.rs index 47ad6f3..e0a8025 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,4 @@ +//! SPLIT-EVAL: 已评估暂缓拆分——会话令牌、幂等去重、可靠队列与限流共用同一组 Redis key 约定、加密封装与错误类型,拆分会让 key 命名规范分散到多处而失去单点审阅。 //! Redis-only reliability primitives (C-REDIS-ONLY-STATE, MOD-DEDUP, MOD-STREAMS). //! These APIs are deliberately short-request operations; no subscriptions or polling. diff --git a/src/worker.rs b/src/worker.rs index 1c34dac..d7ad094 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,3 +1,4 @@ +//! SPLIT-EVAL: 已评估暂缓拆分——搜索降级链、通知编排与出站重试共用同一份配置读取、错误映射表与日志字段集合,拆分会引入重复的错误翻译与日志规约。 //! Bounded metadata notification worker (MOD-TELEGRAM-NOTIFY). use crate::ai::LlmClient; use crate::channel::telegram::TelegramClient; diff --git a/web/config.js b/web/config.js index 606f16f..861c0d3 100644 --- a/web/config.js +++ b/web/config.js @@ -1,3 +1,4 @@ +// SPLIT-EVAL: 已评估暂缓拆分——i18n 词条字典与配置表单逻辑共享同一份 data-i18n key 命名,且 src/web.rs 只内嵌 config.js 与 styles.css,拆成多文件需先引入构建步骤。 (() => { "use strict"; @@ -16,7 +17,7 @@ "intro.eyebrow": "Service management", "intro.title": "Manage your service connections", "intro.copy": "Configure JMAP, Telegram, LLM, access allowlist and runtime parameters. Business configuration is stored in external Redis and hot-reloaded immediately after a successful save.", - "notice.bootstrap": "Create a short-lived admin session using Redis management credentials.", + "notice.bootstrap": "Create a short-lived admin session using the CONFIG_ENCRYPTION_KEY value.", "setup.eyebrow": "Setup guide", "setup.title": "Service startup configuration", "setup.checking": "Checking service startup configuration…", @@ -29,10 +30,10 @@ "setup.fetchError": "Unable to read the service startup status right now. To protect management credentials, the authorization entry stays hidden; confirm the service is available and re-check.", "auth.eyebrow": "Authorization required", "auth.title": "Create admin session", - "auth.bodyPre": "Enter the password of the Redis ACL user from the deployment ", - "auth.bodyPost": ". The server issues a 15-minute admin session; the password and session exist only in the current page memory.", - "auth.label": "Redis management credential", - "auth.placeholder": "Enter the ACL password from the Redis URL", + "auth.bodyPre": "Enter the value of the required startup key ", + "auth.bodyPost": ". The server issues a 15-minute admin session; the key and session exist only in the current page memory.", + "auth.label": "Business encryption key", + "auth.placeholder": "Enter the CONFIG_ENCRYPTION_KEY value", "auth.submit": "Create session", "auth.submitBusy": "Authorizing…", "auth.hint": "Never written to localStorage, sessionStorage or cookies. You must re-authorize after closing or refreshing the page.", @@ -172,11 +173,11 @@ "berr.status": "Business configuration request failed (HTTP {status}). Retry shortly.", "berr.fail": "{message} The request result may be unconfirmed; after checking, re-submit the complete configuration.", "berr.failDefault": "Business configuration request failed.", - "login.enterCred": "Enter the Redis ACL management credential. The input is never written to browser storage.", + "login.enterCred": "Enter the CONFIG_ENCRYPTION_KEY value. The input is never written to browser storage.", "login.creatingToast": "Creating a short-lived admin session…", "login.invalidResponse": "The admin endpoint returned an invalid session response.", "login.successToast": "Admin session created. Runtime parameters are being read; business configuration must be filled in again in full.", - "login.401": "The Redis management credential is invalid, or admin sessions are not enabled. Check the Redis URL ACL password.", + "login.401": "The CONFIG_ENCRYPTION_KEY value does not match the server, or admin sessions are not enabled. The Redis ACL password is not accepted here.", "login.503": "Redis unavailable or initialization incomplete; unable to create an admin session right now. Retry shortly.", "login.status": "Unable to create an admin session (HTTP {status}).", "login.fail": "Unable to connect to the admin service; check the network and retry.", @@ -189,7 +190,7 @@ "intro.eyebrow": "服务管理", "intro.title": "管理你的服务连接", "intro.copy": "配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。", - "notice.bootstrap": "使用 Redis 管理凭据创建短期管理会话。", + "notice.bootstrap": "使用 CONFIG_ENCRYPTION_KEY 的值创建短期管理会话。", "setup.eyebrow": "配置引导", "setup.title": "服务启动配置", "setup.checking": "正在检查服务启动配置…", @@ -202,10 +203,10 @@ "setup.fetchError": "暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。", "auth.eyebrow": "需要授权", "auth.title": "创建管理会话", - "auth.bodyPre": "输入部署时 ", - "auth.bodyPost": " 中 Redis ACL 用户的密码。服务端会签发有效期 15 分钟的管理会话;密码和会话只在当前页面内存中使用。", - "auth.label": "Redis 管理凭据", - "auth.placeholder": "输入 Redis URL 中的 ACL 密码", + "auth.bodyPre": "输入启动必填的环境变量 ", + "auth.bodyPost": " 的值。服务端会签发有效期 15 分钟的管理会话;该值与会话只在当前页面内存中使用。", + "auth.label": "业务加密密钥", + "auth.placeholder": "输入 CONFIG_ENCRYPTION_KEY 的值", "auth.submit": "创建会话", "auth.submitBusy": "正在授权…", "auth.hint": "不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权。", @@ -345,11 +346,11 @@ "berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。", "berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。", "berr.failDefault": "业务配置请求失败。", - "login.enterCred": "请输入 Redis ACL 管理凭据。输入不会写入浏览器存储。", + "login.enterCred": "请输入 CONFIG_ENCRYPTION_KEY 的值。输入不会写入浏览器存储。", "login.creatingToast": "正在创建短期管理会话…", "login.invalidResponse": "管理接口返回了无效的会话响应。", "login.successToast": "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。", - "login.401": "Redis 管理凭据无效,或服务尚未启用管理会话。请检查 Redis URL ACL 密码。", + "login.401": "CONFIG_ENCRYPTION_KEY 与服务器配置不一致,或服务尚未启用管理会话。此处不接受 Redis ACL 密码。", "login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。", "login.status": "无法创建管理会话(HTTP {status})。", "login.fail": "无法连接管理服务,请检查网络后重试。", diff --git a/web/index.html b/web/index.html index 10b036d..d523bee 100644 --- a/web/index.html +++ b/web/index.html @@ -29,7 +29,7 @@
@@ -52,11 +52,11 @@

Authorization required

Create admin session

-

Enter the password of the Redis ACL user from the deployment REDIS_URL. The server issues a 15-minute admin session; the password and session exist only in the current page memory.

+

Enter the value of the required startup key CONFIG_ENCRYPTION_KEY. The server issues a 15-minute admin session; the key and session exist only in the current page memory.

- +
- +

Never written to localStorage, sessionStorage or cookies. You must re-authorize after closing or refreshing the page.