diff --git a/.env.example b/.env.example index c40fbac..a4547e0 100644 --- a/.env.example +++ b/.env.example @@ -2,13 +2,13 @@ # # The process requires only these two variables. CONFIG_ENCRYPTION_KEY is the SPA # admin credential and the one-shot bootstrap trust root; the Redis ACL password -# in REDIS_URL only gates the database connection (src/main.rs:88-91). +# in REDIS_URL only gates the database connection (src/main.rs:40). # Never commit or log a real URL/password/key. REDIS_URL=redis://:REPLACE_WITH_REDIS_ACL_PASSWORD@redis.example.com:6379/0 # Required 32-byte random hex key (64 hex characters). Never store in Redis or logs. CONFIG_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHAR_RANDOM_KEY -# PORT is an optional platform override (default: 8080). RUN_MODE is accepted but -# currently has no effect — both modes share one router (default: webhook). Business -# secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap; +# PORT is an optional platform override (default: 8080). There is no RUN_MODE: the +# legacy env parser was removed, and webhook plus reconcile traffic share one router. +# Business secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap; # they are then persisted in Redis and are never echoed by the API. diff --git a/README.md b/README.md index 478f90c..ed3a785 100644 --- a/README.md +++ b/README.md @@ -52,18 +52,17 @@ read-only setup router over in-memory state instead. See §3. Three layers. They are not interchangeable. -**Boot-time environment — 2 required, 2 optional.** +**Boot-time environment — 2 required, 1 optional.** | Variable | Required | Default | |---|---|---| | `REDIS_URL` | yes | — | | `CONFIG_ENCRYPTION_KEY` | yes | — | | `PORT` | no | `8080` | -| `RUN_MODE` | no | `webhook` | -`RUN_MODE` accepts `webhook` or `reconcile`, but **both currently produce identical -behavior** (the two modes share one router), so it is a forward placeholder: keep the -default `webhook`. `.env.example` ships only the two required +There is no `RUN_MODE`: the legacy environment parser was removed, and webhook plus +reconcile traffic share one router (`POST /reconcile` is a standalone endpoint). +`.env.example` ships only the two required variables; the rest are documented defaults. **Business configuration — Redis, written by the browser.** diff --git a/README.zh-CN.md b/README.zh-CN.md index b3eeecf..18229ef 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -55,9 +55,8 @@ docker run --env-file .env -p 8080:8080 messageweave:latest | `REDIS_URL` | 是 | — | | `CONFIG_ENCRYPTION_KEY` | 是 | — | | `PORT` | 否 | `8080` | -| `RUN_MODE` | 否 | `webhook` | -`RUN_MODE` 只接受 `webhook` 或 `reconcile`,但两种取值当前行为完全相同(共享同一套路由表),仅作前向占位,保持默认 `webhook` 即可。`.env.example` 只提供 2 个必需变量;其余是 +没有 `RUN_MODE`:遗留的环境变量解析器已删除,webhook 与 reconcile 流量共享同一套路由表(`POST /reconcile` 是独立端点)。`.env.example` 只提供 2 个必需变量;其余是 文档化默认值。 **业务配置 —— 存于 Redis,由浏览器写入。** diff --git a/docs/charter.md b/docs/charter.md index 64f2e05..00b1516 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -116,7 +116,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' ``` -**当前基线:62 passed / 0 failed / 1 ignored**(唯一 ignored:`real_server_tests::session_list_and_read_smoke`,需外部真实 JMAP 服务器)。 +**当前基线:61 passed / 0 failed / 1 ignored**(唯一 ignored:`real_server_tests::session_list_and_read_smoke`,需外部真实 JMAP 服务器)。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 @@ -207,7 +207,7 @@ Push callback 公网映射。 | `C-REDIS-ONLY-STATE` | docs/deployment.md §0 | 状态仅外部 Redis,不用 SQLite/本地卷 | 部署约束 | | `C-REDIS-MANAGED-AOF` | docs/deployment.md §0 | Redis 用户托管 + 开启 AOF 持久化 | 部署约束 | | `C-PORT` | docs/deployment.md §0 | 通用 PORT 约定 | 部署约束 | -| `NG-SERVER-MODE` | docs/deployment.md §1 | `RUN_MODE=server` 常驻,非目标 | 非目标 | +| `NG-SERVER-MODE` | docs/deployment.md §1 | `RUN_MODE=server` 常驻,非目标(该变量已随 `Config::from_env()` 删除,代码中已无此标识符) | 非目标 | | `NG-POLLING-SSE` | docs/deployment.md §1 | EventSource/SSE 长连接,非目标 | 非目标 | | `NG-LONG-POLLING` | docs/deployment.md §1 | Telegram 长轮询,非目标 | 非目标 | | `NG-SQLITE-PERSIST` | docs/deployment.md §1 | SQLite 持久化,非目标 | 非目标 | diff --git a/docs/deployment.md b/docs/deployment.md index 54c0900..7696a6c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -40,7 +40,7 @@ | ID | 非目标 | 说明 | |---|---|---| -| **NG-SERVER-MODE** | `RUN_MODE=server` 常驻运行模式 | 早期 EventSource+长轮询 常驻设计,已删除 | +| **NG-SERVER-MODE** | 常驻长连接运行模式(历史名 `RUN_MODE=server`) | 早期 EventSource+长轮询 常驻设计,已删除;`RUN_MODE` 变量本身也已随 `Config::from_env()` 一并删除,代码中已无该标识符 | | **NG-POLLING-SSE** | JMAP EventSource/SSE 长连接订阅 | 与 `C-NO-LONG-CONN` 冲突;实时通道只用 Push 回调 | | **NG-LONG-POLLING** | Telegram 长轮询运行模式 | 与 `C-NO-LONG-CONN` 冲突;只用 Webhook | | **NG-SQLITE-PERSIST** | SQLite 会话 / sinceState 本地持久化 | 状态只走外部 Redis(`C-REDIS-ONLY-STATE`) | @@ -103,7 +103,7 @@ ### 3.2 运行阶段(收敛为单端口) - `EXPOSE 8080`(遵循 `C-NO-TCP-EXPOSE`,不再暴露 9191 admin 端口) -- `ENV PORT=8080`、`RUN_MODE=webhook`(默认) +- `ENV PORT=8080`(可选,缺省即 8080;`REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY` 必须由运行期注入,不写入镜像) - 健康检查走同一 HTTP 监听(见 §7),不依赖独立端口 - **不声明 VOLUME**,无 `/app/data` 本地卷(`NG-LOCAL-VOLUME`) @@ -135,11 +135,11 @@ HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查 `/reconcile`、`/worker` 返回 503,不执行入队、ACK 或业务处理;SPA、`/api/status`、管理 会话和配置 API 保持可用,打开后立即生效。 -> 迁移后生产环境仅需 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`;下表中的业务环境变量是历史阶段说明(走 `Config::from_env()` 环境变量引导路径时),**不应再注入生产容器**,表内 ✅ 也不代表当前生产必须配置。业务密钥通过受保护的 `/api/bootstrap` 或管理员 PUT 写入 Redis,并在成功后热重建客户端。 +> 迁移后生产环境仅需 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`。下表字段**已不再从环境变量读取**(`Config::from_env()` 及其助手 `required_secret`/`required_nonblank`/`env_bool` 已删除,`config.rs` 零 `std::env` 读取;登记见 `docs/retired.md`),全部经 Redis 业务配置由 `/api/bootstrap` 或管理员 PUT 写入并在成功后热重建客户端。表内 ✅ 指 **Redis 业务配置**的 fail-closed 必填(`validate_nonblank`,空白即拒绝),不代表需要注入生产容器。**不要把下表变量注入容器**:注入也不会被读取。 -(保留既有 Secrets 注入约定:禁入镜像、`secrecy` 包裹、日志屏蔽;新增运行模式相关变量) +(保留既有 Secrets 注入约定:禁入镜像、`secrecy` 包裹、日志屏蔽) -| 变量 | 必填 | 说明 | +| Redis 业务配置字段 | 必填 | 说明 | |---|---|---| | `BOT_TOKEN` | ✅ | Telegram Bot Token | | `TELEGRAM_CHAT_ID` | ✅ | Worker 元数据通知目标 chat id(与入站白名单分离) | @@ -148,13 +148,12 @@ HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查 | `CHAT_ALLOWLIST` | ✅ | 聊天白名单(**硬约束 `SAF-CHAT-ALLOWLIST`**):逗号分隔整数 chat id;处理任何事件前先校验,非白名单直接拒绝 | | `REDIS_URL` | ✅ | **外部 Redis(用户托管 + AOF)**(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`):session / dedup / Streams / fuse / sinceState 全部在此;支持 `redis://` 与带默认 ACL 用户的 `rediss://default:@host:6379/0`,Redis 进程不在此 compose 内 | | `PORT` | 默认 8080 | 单监听端口(`C-NO-TCP-EXPOSE`) | -| `RUN_MODE` | 默认 `webhook` | `webhook` / `reconcile`(`NG-SERVER-MODE` 已删除);**两种取值当前行为完全相同**(共享同一套路由表),仅作前向占位,保持默认即可 | -| `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer ` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此变量为**必填**(`SecretString`) | +| `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer ` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此字段为**必填**(`SecretString`) | | `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`) | | `--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 和短期验证状态 | -| `LLM_*` | 可选 | OpenAI-compatible 环境变量(design.md `REQ-LLM-OPENAI-COMPAT`);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) | +| `LLM_*` | 可选 | OpenAI-compatible 业务配置字段(`REQ-LLM-OPENAI-COMPAT`);其中 `LLM_MAX_RETRIES` **不是**业务配置字段而是 Redis 运行参数(回落默认见 `docs/reference.md` §6.1);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) | | `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 | AI 授权期限由用户选择(临时一次、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。 @@ -202,12 +201,7 @@ TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链 并应校验可信转发头后才允许 bootstrap。容器端口不得直接暴露公网。SPA 仅在当前页面 内存保存 opaque admin session,不使用 localStorage、sessionStorage 或 Cookie 持久化。 -> 仅两种运行模式;无长连接(`C-NO-LONG-CONN`)。 - -| RUN_MODE | 命令 | 说明 | -|---|---|---| -| `webhook`(默认) | `message-weave webhook`(或单一入口按 env 分派) | 单进程:TG Webhook handler + JMAP Push 回调 handler + Redis Streams worker + `/reconcile` + health | -| `reconcile` | `message-weave reconcile` | 一次性对账补差后 exit,由外部调度器/容器任务触发 | +> **单一运行形态**:进程只有单条服务命令 `./target/release/message-weave`,webhook handler、JMAP Push 回调、Redis Streams worker 与 `/reconcile` 同处一个进程;对账是一次性 HTTP 端点 `POST /reconcile`(由外部调度器/容器任务调用),**不是**独立子命令,也**没有** `RUN_MODE` 变量(该标识符已随 `Config::from_env()` 删除,见 `docs/retired.md`)。无长连接(`C-NO-LONG-CONN`)。 ### 6.1 入口路由(单端口 `PORT`,全部经平台 HTTPS URL 入站) ``` @@ -249,7 +243,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 #### 6.3.1 用户侧调度示例(copy-paste 可用) 端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer ` -(`src/config.rs:357` 启动必填,服务端常数时间比较,`notify.rs:228-231`)。 +(`src/config.rs:147` fail-closed 必填,服务端常数时间比较,`notify.rs:228-231`)。 最小示例: diff --git a/docs/design.md b/docs/design.md index e16fe6b..8f2668e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -310,8 +310,8 @@ message-weave/ ├── Dockerfile # debian:bookworm-slim + ca-certificates + tini(C-DEBIAN-SLIM) ├── hoststack.yaml # 网关编排清单,非应用代码 ├── src/ -│ ├── main.rs # tokio main:校验 RUN_MODE 后启动 webhook HTTP 入口(两模式共用同一入口,无 CLI 子命令) -│ ├── config.rs # 环境变量解析(ARCH-CONFIG-ENV:std::env,非 figment/TOML) +│ ├── main.rs # tokio main:读 PORT/REDIS_URL/CONFIG_ENCRYPTION_KEY 后启动 webhook HTTP 入口(单入口,无 CLI 子命令) +│ ├── config.rs # Redis 业务配置反序列化(serde → BusinessConfigWire → Config);CONFIG_ENCRYPTION_KEY 解析 │ ├── error.rs # BotError 统一错误(见 §8.1) │ ├── domain.rs # 领域层根(渠道中立,见 §5.2) │ ├── domain/jmap.rs # JmapBackend trait + MockBackend(契约测试不联网) @@ -338,7 +338,7 @@ message-weave/ | 模块 | 依赖 | 输出 | 可测性 | 现状 | |---|---|---|---|---| -| `config` | 标准库 `env`(`ARCH-CONFIG-ENV`) | `Config` 结构 | 纯函数,易测 | 已实现(手工 `from_env`,无 figment) | +| `config` | secrecy;ring(`session_digest` SHA-256) | `Config` 结构(6 字段,全部有真实读取方) | 纯函数,易测 | 已实现(Redis 业务配置 serde 反序列化 + `validate_nonblank` fail-closed;启动期 env 由 `main.rs` 直读,无 figment/TOML) | | `error` | — | `BotError`(§8.1,4 个变体) | 单测 | 已实现 | | `domain` + `domain::jmap::client` | **jmap-client 0.4.2** | JMAP 只读语义;`client` = 真实只读 adapter(`MOD-JMAP-CLIENT`) | mock JMAP 响应 + `#[ignore]` 真机测试 | 代码已实现,待真实 Stalwart 端到端验证(`cargo test -- --ignored jmap::`) | | `state` | redis 0.27 | Redis 读写:配置/开关/会话/TTL 键(`C-REDIS-ONLY-STATE`) | Redis mock | 已实现 | @@ -366,14 +366,19 @@ message-weave/ > 使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,阶段0列表仅作为历史 > 兼容说明。 -> **实际实现**(`ARCH-CONFIG-ENV`):阶段0 起配置**只从环境变量读取**,由 `config::Config::from_env()` 手工解析(`std::env::var`),**不使用 figment、不使用 TOML 配置文件**。缺失必填项即启动失败;布尔值接受 `true/1/yes` 与 `false/0/no`。 +> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(`src/main.rs:31` / `:40` / `:49`, +> 另加可选 `DEBUG_TOKEN` 于 `:83`),直接 `std::env::var`,**不使用 figment、不使用 TOML/配置文件**。 +> **业务配置不再从环境变量读取**:下表字段全部经 Redis 业务配置由 `config` 层以 serde 反序列化装载, +> 并经 `validate_business_wire` / `validate_nonblank` 做 fail-closed 校验。 +> 历史上存在的 `Config::from_env()` 完整 env 解析路径**已删除**(登记见 `docs/retired.md`), +> 因此**当前代码对下表变量名零读取**;`RUN_MODE` 变量本身也已整体删除(代码中已无该标识符), +> `webhook` 与 `reconcile` 本就共享同一套路由表,`NG-SERVER-MODE` 作为设计非目标保留在 `docs/charter.md` §8。 -> ⚠️ **下表是 `Config::from_env()` 遗留/引导路径的完整变量表**(阶段0 口径,保留作兼容参考)。**当前生产部署只需要 `REDIS_URL` + `CONFIG_ENCRYPTION_KEY` 两个启动变量**(`src/main.rs`);其余业务字段已迁移到 Redis 业务配置(`PUT /api/business-config` 热加载,`C-REDIS-ONLY-STATE`)。表中的「必填」仅指**走环境变量引导路径时**必填,不代表当前生产必须配置。 +> ⚠️ **下表是业务配置字段的完整变量表**(变量名沿用原环境变量命名,便于与部署文档对照,但**代码中已无任何读取方**)。**当前生产部署只需要 `REDIS_URL` + `CONFIG_ENCRYPTION_KEY` 两个启动变量**(`src/main.rs`);其余业务字段全部经 Redis 业务配置由 `PUT /api/business-config` 写入(`C-REDIS-ONLY-STATE`)。表中「必填」指**Redis 业务配置**的 fail-closed 约束(`validate_nonblank`,空白即拒绝),不再是环境变量引导路径的必填。 | 变量 | 必填 | 默认 | 说明 | |---|---|---|---| | `PORT` | 否 | `8080` | 单监听端口(`C-NO-TCP-EXPOSE`) | -| `RUN_MODE` | 否 | `webhook` | `webhook` / `reconcile`(`NG-SERVER-MODE` 已删除);**两种取值当前不改变任何运行时行为**(两模式共享同一套路由表),仅作前向占位 | | `BOT_TOKEN` | 是 | — | Telegram Bot Token(`SecretString`) | | `TG_WEBHOOK_SECRET` | 是 | — | `/webhook/tg` 鉴权:请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`;`SecretString`) | | `CHAT_ALLOWLIST` | 是 | — | 逗号分隔整数 chat id;**白名单硬约束**(`SAF-CHAT-ALLOWLIST`) | @@ -387,16 +392,16 @@ 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` | 否(环境变量路径**不读取**) | — | 熔断阈值(`REQ-AI-FUSE`)。当前经 Redis 运行参数管理(`OutboundConfig.max_retries`,回落默认见 `docs/reference.md` §6.1);`Config::from_env()` **不读取**此变量 | -| `LLM_SUMMARY_TARGET_CHARS` | 否 | `300` | 摘要目标字数(`REQ-LONG-EMAIL`) | +| `LLM_MAX_RETRIES` | 否 | — | 熔断阈值(`REQ-AI-FUSE`)。**非**环境变量:经 Redis 运行参数管理(`OutboundConfig.max_retries`,回落默认见 `docs/reference.md` §6.1) | +| `LLM_SUMMARY_TARGET_CHARS` | 否(**常量**) | `300` | 摘要目标字数(`REQ-LONG-EMAIL`)。**非**可配置项:`config` 层在两个构造器内硬编码为 300,未进入业务配置 wire | -> 当前 `config.rs` 通过环境变量和 Redis 业务配置提供上述字段(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`)。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。 +> 当前 `config.rs` 从 Redis 业务配置提供上述字段(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`);进程级启动变量由 `src/main.rs` 直读,不经过 `config` 层。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。 > **入口鉴权密钥(`TG_WEBHOOK_SECRET`/`RECONCILE_TOKEN`)在业务配置完成后必须有效**(fail-closed,`SAF-AUTH-*`)。JMAP Push 的验证码由 Stalwart 在订阅创建后动态生成,不属于业务配置。启动引导变量缺失时服务进入配置引导模式并保持 SPA 可访问;不会因为缺少启动变量而伪造业务成功,也不会绕过入口鉴权。 > **JMAP session URL 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:`JMAP_SESSION_URL` 既接受**服务基地址**(`https://mail.example.com`)也接受**完整 session URL**(`https://mail.example.com/.well-known/jmap`)。代码在调用 `jmap_client::Client::connect` **之前统一归一化为 origin/base**——`jmap-client` 会自动追加 `/.well-known/jmap`,故**不会出现重复路径**(如 `…/.well-known/jmap/.well-known/jmap`)。约束:**仅 HTTPS**(http 拒绝);**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`,`C-AUTH-APP-BASIC`);拒绝危险 query。(状态:D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**。) ### 7.2 密钥管理 -- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置走环境变量,`ARCH-CONFIG-ENV`)。 -- 环境变量由 `config` 层直接读取(`std::env::var`,`ARCH-CONFIG-ENV`);缺失必填项即启动失败(**无** `${VAR}` 插值/figment)。 +- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置不进仓库,`ARCH-CONFIG-ENV`)。 +- 启动期只读 2 个进程级变量(`REDIS_URL`、`CONFIG_ENCRYPTION_KEY`,由 `src/main.rs` 直接 `std::env::var`;缺任一即降级到只读 setup 模式);业务配置从 Redis 经 `config` 层反序列化(**无** `${VAR}` 插值/figment/配置文件,`ARCH-CONFIG-ENV`)。 - 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。 - 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。 - 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 @@ -493,7 +498,7 @@ pub enum BotError { ### 8.3 可观测性 - `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。 -- `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。**当前只记录启动期事件**:`src/main.rs` 共 7 处 `info!`/`warn!`(`RUN_MODE` 取值横幅×2、启动变量缺失降级到 setup 模式、启动横幅、debug 端点开启、JMAP 服务不可用降级×2)。请求级事件(Push 回调、Reconcile 拉取、渠道推送、LLM 耗时)**尚未实现**——`src/` 里除 `main.rs` 外没有任何 tracing 调用,也没有命名 span(无 `#[instrument]` / `span!`)。 +- `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。**当前只记录启动期事件**:`src/main.rs` 共 5 处 `info!`/`warn!`(启动变量缺失降级到 setup 模式、启动横幅、debug 端点开启、JMAP 服务不可用降级×2)。请求级事件(Push 回调、Reconcile 拉取、渠道推送、LLM 耗时)**尚未实现**——`src/` 里除 `main.rs` 外没有任何 tracing 调用,也没有命名 span(无 `#[instrument]` / `span!`)。 - 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。**当前未接入任何指标后端**(`metrics` 不在 `Cargo.toml`)。 - 优雅退出:**当前未实现**——`src/` 里没有信号处理(无 `tokio::signal` / `ctrl_c`),也没有 Streams pending 条目的 flush 逻辑;容器停机即终止进程。Redis 侧 `C-NO-STATEFUL-RECOVERY` 保证重启后从 Redis 重建,不依赖进程内状态。 - **可靠性目标与策略**(Streams ACK/retry、幂等去重、Push 重试、对账恢复、指标/告警、**≥99.9% 通知可用性及边界**)见 deployment.md §6.4/§6.5(`NFR-NOTIFY-SLA`)。 @@ -559,7 +564,14 @@ pub enum BotError { 3. `cargo test` 通过(含路由/配置最小测试)。 4. 上述三条**在 Debian `rust:1-slim-bookworm` 容器内**执行通过(`C-DEBIAN-SLIM`)。 5. 配置读取为 env-only(无 figment/TOML);`SecretString` 包裹秘密且 `Debug` 不泄密;`JMAP_PASSWORD` 安全访问器保留。 -6. `RUN_MODE` **不**参与路由分派:`webhook` 与 `reconcile` 共享同一套路由表,`reconcile` 作为独立 HTTP 端点 `POST /reconcile` 提供。`RUN_MODE` 仅在启动时读取并校验取值(`src/main.rs:75`,只接受 `webhook`/`reconcile`),**当前不影响任何运行时行为**,留待后续阶段在此挂载差异化副作用(见 §12.2 的运行模式说明)。 +6. `RUN_MODE` **不**参与路由分派:`webhook` 与 `reconcile` 共享同一套路由表,`reconcile` 作为独立 HTTP 端点 `POST /reconcile` 提供。`RUN_MODE` 仅在启动时读取并校验取值,**当前不影响任何运行时行为**,留待后续阶段在此挂载差异化副作用(见 §12.2 的运行模式说明)。 + +> **阶段0 之后已演进的判据(当前口径,取代上述第 5、6 条的具体实现描述)**: +> - 配置读取收窄为 **2 个进程级变量**(`REDIS_URL`、`CONFIG_ENCRYPTION_KEY`)由 `src/main.rs` 直读; +> 业务配置改由 Redis 经 `config` 层反序列化,`Config::from_env()` 与全部遗留 env 解析器已删除(§7.1)。 +> - `RUN_MODE` **变量本身已删除**,代码中已无该标识符(登记见 `docs/retired.md`);第 6 条的 +> 「不参与路由分派」结论仍成立,但已无需变量承载。 +> - `JMAP_PASSWORD` 的**安全访问器** `jmap_password()` 已删除;`jmap_password` 作为业务配置字段名保留。 7. `.gitignore` 存在(排除 `target/` 等;**不**擅自初始化 git)。 8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。 @@ -585,7 +597,7 @@ pub enum BotError { > 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。 -- 依赖 `jmap-client 0.4.2`(**已引入**,`ARCH-DEPS-STAGE1`;版本/features 以 `Cargo.toml` 为准,**禁用 WebSocket feature**);`config.rs` 复用既有 env 骨架(**非** `bot.example.toml`,见 `ARCH-CONFIG-ENV`)。 +- 依赖 `jmap-client 0.4.2`(**已引入**,`ARCH-DEPS-STAGE1`;版本/features 以 `Cargo.toml` 为准,**禁用 WebSocket feature**);`config.rs` 复用既有 Redis 业务配置骨架(**非** `bot.example.toml`,见 `ARCH-CONFIG-ENV`)。 - `domain::jmap::client` = 真实只读 adapter(`MOD-JMAP-CLIENT`,**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行前不得声称真机通过**):已实现 = 包装 `Client::connect`(Basic 认证 `C-AUTH-APP-BASIC`)、**URL 归一化**(`JMAP_SESSION_URL` 接受服务基地址或完整 `…/.well-known/jmap`,归一化为 origin/base 后传入,避免重复路径;仅 HTTPS、禁内嵌凭据与 query,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、**account 选择**:`ACCOUNT_ID` 留空取 session 主账户、显式值校验后使用(`REQ-SINGLE-ACCOUNT`)。 - `domain::jmap::JmapService::list_folders / list_emails / read_email`(query/get;`list_emails` 含 `limit` 边界;`received_at` 解析;`read_email` 多 part 拼接见 `REQ-JMAP-RAW-MULTIPART`)。 - **R1 入口鉴权已就绪**:`/reconcile`/`/webhook/tg`/`/push/jmap` 及 `/api/push/register` 鉴权(`SAF-AUTH-*`)已落地并通过单测;后续改动不得放宽或绕过鉴权。 @@ -720,7 +732,10 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/ **不存在** `src/domain/llm/` 目录,也不存在其中的 `mod.rs` / `client.rs` / `config.rs` / `policy.rs` / `fallback.rs` / `audit.rs`——熔断、规则回退、审计 span 全部未实现(见 §12.4–12.7)。 调用方只有 `worker.rs` 的 `MetadataWorker`(已授权摘要)和 `notify.rs`(新邮件通知)。 -### 12.2 环境变量(当前实现) +### 12.2 LLM 变量清单(当前实现) + +> 下表 `LLM_*` 名称**已不是环境变量**:`Config::from_env()` 删除后代码对它们零读取, +> 全部经 Redis 业务配置装载(§7.1)。表中「默认」为 `config` 层构造器内的回落值。 | 变量 | 类型 | 默认 | 用途 | |---|---|---|---| | `LLM_ENABLED` | bool | `false` | 总开关;默认关闭,未启用时不校验 `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` | diff --git a/docs/reference.md b/docs/reference.md index bf3b30a..60bb01f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -325,21 +325,19 @@ deployment.md §10.5. Three distinct layers. They are not interchangeable. -### 5.1 Boot-time (2 required, 3 optional with defaults) +### 5.1 Boot-time (2 required, 2 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; 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`) | +| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:34. The listener binds it directly (main.rs:35-38); `Config` carries no port field | +| `DEBUG_TOKEN` | no | — | `std::env::var` at main.rs:83, 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-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:345`), so with the current two-variable production config -it always resolves to the hardcoded default — setting it to `reconcile` has no effect. +`RUN_MODE` **no longer exists**: the identifier was removed together with the legacy +`Config::from_env()` environment parser (registration in `docs/retired.md`). Both webhook +and reconcile traffic share one router and reconcile is a standalone `POST /reconcile` +endpoint, so the variable never changed any runtime behaviour — there is now nothing to set. **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 @@ -354,25 +352,29 @@ successful write creates the configuration, later writes hot-reload it. The same also exposed as a one-shot `POST /api/bootstrap` against the backend origin for automation. The process never reads these from the environment in normal operation. -### 5.3 Legacy compatibility (read but not required) +### 5.3 No legacy environment path -`config.rs` still reads an environment-shaped business configuration for backward -compatibility. The full set of names it recognises (`from_env()`, `config.rs:311`), -unprefixed unless noted: +`Config::from_env()` and its helpers (`required_secret`, `required_nonblank`, `env_bool`) are +**deleted** — `config.rs` performs no `std::env` read at all. The names below were the legacy +env surface and are listed only so a stale deployment script can be recognised as stale +(registration in `docs/retired.md`): -`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` +`RUN_MODE` · `CHAT_ALLOWLIST` · `TELEGRAM_CHAT_ID` · `LLM_ENABLED` · `LLM_ALLOW_NET` · +`LLM_API_KEY` · `LLM_BASE_URL` · `LLM_MODEL` · `LLM_SUMMARY_TARGET_CHARS` · `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 `JMAP_*` family is exactly three names. In production the account id arrives as the +`accountId` request-body field (notify.rs:1122) and is held on the client as +`account_id` (src/domain/jmap/client.rs:14); the only remaining environment reads of these +four names are inside the `#[ignore]` real-server smoke test at +src/domain/jmap/client.rs:499-511, which is not part of the production configuration surface. 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 +(main.rs:49) and held as `admin_token` (main.rs:78), it is checked in constant time by `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 diff --git a/docs/retired.md b/docs/retired.md index 185d697..2ade7f1 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -90,14 +90,14 @@ | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | -| `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES` 环境变量 | 虚构环境变量 | `src/` 零命中;真实有 6 个:`LLM_ENABLED` / `LLM_ALLOW_NET` / `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` / `LLM_SUMMARY_TARGET_CHARS`,加 `llm_timeout_ms` / `max_retries` 两个运行时参数 | 见 `docs/reference.md` AI 授权态一节 | -| `llm.call` tracing span | 虚构观测点 | `src/` 中除 `main.rs`(7 个事件)外**零 tracing 事件、零 span**;LLM 调用无任何日志 | 无 | +| `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES` 环境变量 | 虚构环境变量 | `src/` 零命中(`LLM_MAX_RETRIES` 是 Redis 运行参数 `max_retries`,非环境变量);真实业务配置字段有 6 个:`LLM_ENABLED` / `LLM_ALLOW_NET` / `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` / `LLM_SUMMARY_TARGET_CHARS`(后者的值在构造器内硬编码,未进入 wire),另有 `llm_timeout_ms` / `max_retries` 两个运行时参数 | 见 `docs/reference.md` AI 授权态一节 | +| `llm.call` tracing span | 虚构观测点 | `src/` 中除 `main.rs`(5 个事件)外**零 tracing 事件、零 span**;LLM 调用无任何日志 | 无 | | 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 | | 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 | | 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 | | `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:533`) | 无 | | `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 | -| `run_mode.rs`(作为单独文件) | 虚构文件 | RUN_MODE 由 `config.rs:345` 读取、在 `src/main.rs:75` 校验取值;不存在 `validate_env_or_exit` 这类函数 | 无 | +| `run_mode.rs`(作为单独文件) | 虚构文件 | 不存在该文件;`RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `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` 的「已由代码回答的早期问题」一节 | @@ -110,7 +110,30 @@ --- -## 4. 如何重启其中一条 +## 4. 已删除的兼容路径(代码曾存在、现已删除) + +与前两节不同:下列条目**曾经真实存在于代码**,既非虚构也非"未采用"。它们属于早期为兼容 +环境变量部署形态而保留的解析层,现已整体删除——项目早期无需任何兼容性承诺,删除比保留更便宜。 +删除后 `src/config.rs` 不含任何 `std::env` 读取:进程启动只读 2 个凭据加监听端口,其余业务字段 +全部走 Redis 业务配置(`/api/bootstrap` 或管理员 PUT → `validate_nonblank` fail-closed)。 + +| 条目 | 类型 | 删除原因 | 现状 | +|---|---|---|---| +| 原 `src/config.rs` 的 `Config::from_env()`(第 311 行) | 已删除函数 | 完整的 20 项环境变量解析器。凭据迁移到 Redis 业务配置后它成为唯一的兼容层,且已无任何调用方 | 启动期改由 `main.rs` 直读:`PORT`(:31)、`REDIS_URL`(:40)、`CONFIG_ENCRYPTION_KEY`(:49)、`DEBUG_TOKEN`(:83);业务字段由 `BusinessConfigWire` 承载 | +| `required_secret` / `required_nonblank` / `env_bool` | 已删除助手函数 | 仅被上述解析器调用,随其一并删除 | 必填语义改由 `validate_nonblank` 在 Redis 业务配置路径承担(8 处调用) | +| `RUN_MODE` 变量 + `Config.run_mode` 字段 + 启动校验块 | 已删除标识符 | 两种取值从未改变任何运行时行为:webhook 与 reconcile 共享同一套路由表,`POST /reconcile` 是独立端点。该变量只额外制造了一个必须被文档解释的分支 | 全仓零命中;`NG-SERVER-MODE` 作为设计非目标保留在 `docs/charter.md` | +| `jmap_password()` 安全访问器 | 已删除方法 | 为 `SecretString` 字段提供不泄密的读取入口,仅在 env 解析路径中有意义 | `jmap_password` 作为业务配置**字段名**保留(`BusinessConfigWire` / `BusinessConfig`),字段本身即 `SecretString` | +| `Config.port` / `Config.redis_url` 字段 | 已删除字段 | `port` 在两个构造器里硬编码 `8080`,而监听绑定直接从环境变量取值——字段值可与真实监听端口静默不一致,且全仓零读取方;`redis_url` 同样零读取 | `Config` 收窄为 6 字段:`telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token`,与 `BusinessConfig` 同构 | +| 两个构造器的 `redis_url` 参数 | 已删除参数 | 唯一读者(原 main.rs 第 72 行)在上一轮改造中被移除 | `redis_only()` 与 `from_business(value)`;包装器 `from_business_json` / `from_business_value` 同步去掉首参 | +| 19 个遗留环境变量名 | 已删除读取 | 见上;这些名字在代码中已无任何读取方 | 名单见 `docs/reference.md` §5.3;语义见 `docs/design.md` §7.1 | + +> 注意 `LLM_MAX_RETRIES` 与 `LLM_SUMMARY_TARGET_CHARS` 的区别:前者从来不是环境变量 +> (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量 +> (两个构造器内硬编码 300,未进入业务配置 wire)。 + +--- + +## 5. 如何重启其中一条 1. 先在 `docs/roadmap.md` 登记为缺口并归属阶段。 2. 若涉及 Redis 键、TTL、HTTP 路由或默认值,同轮更新 `docs/reference.md`。 diff --git a/src/config.rs b/src/config.rs index 7c026e5..6d6a48a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -// SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义、Redis 配置解析与遗留 from_env() 路径相互引用,拆出只增加跨文件跳转而不消除任何重复。 +// SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义与 Redis 配置解析路径相互引用,拆出只增加跨文件跳转而不消除任何重复。 use secrecy::{ExposeSecret, SecretString}; use crate::error::BotError; @@ -33,17 +33,8 @@ pub fn encryption_key_from_env() -> Result<[u8; 32], BotError> { /// Runtime-only settings. SecretString prevents accidental formatting/logging of credentials. /// C-AUTH-APP-BASIC and REQ-SINGLE-ACCOUNT are intentionally represented explicitly. pub struct Config { - // The listener binds from main.rs before Config exists, so the field is parsed - // here only for validation parity; main.rs reads PORT directly. - #[expect( - dead_code, - reason = "parsed in from_env() for validation parity; listener binds from main.rs" - )] - pub port: u16, - pub run_mode: String, pub telegram: TelegramConfig, pub jmap: JmapConfig, - pub redis_url: SecretString, pub account_id: Option, pub llm: LlmConfig, pub auth: AuthSecrets, @@ -221,11 +212,9 @@ pub(crate) fn validate_business_wire(wire: BusinessConfigWire) -> Result<(), Bot impl Config { /// Configuration-only startup used before Redis bootstrap. No business route can pass /// authentication with these empty sentinels; the process only serves SPA/bootstrap. - pub fn redis_only(redis_url: SecretString) -> Self { + pub fn redis_only() -> Self { let empty = || SecretString::new(String::new()); Self { - port: 8080, - run_mode: "webhook".into(), telegram: TelegramConfig { bot_token: empty(), chat_allowlist: Vec::new(), @@ -236,7 +225,6 @@ impl Config { username: String::new(), app_password: empty(), }, - redis_url, account_id: None, llm: LlmConfig { enabled: false, @@ -256,10 +244,8 @@ impl Config { /// Builds the runtime adapter configuration from the Redis business namespace. /// This path is used after bootstrap and intentionally does not consult process env. - pub fn from_business(redis_url: SecretString, value: BusinessConfig) -> Self { + pub fn from_business(value: BusinessConfig) -> Self { Self { - port: 8080, - run_mode: "webhook".into(), telegram: TelegramConfig { bot_token: value.bot_token, chat_allowlist: value.chat_allowlist, @@ -270,7 +256,6 @@ impl Config { username: value.jmap_username, app_password: value.jmap_password, }, - redis_url, account_id: value.account_id, llm: LlmConfig { enabled: value.llm_enabled, @@ -288,104 +273,15 @@ impl Config { } } - pub fn from_business_json( - redis_url: SecretString, - value: serde_json::Value, - ) -> Result { + pub fn from_business_json(value: serde_json::Value) -> Result { let wire: BusinessConfigWire = serde_json::from_value(value) .map_err(|_| BotError::Config("invalid Redis business configuration".into()))?; - Self::from_business_value(redis_url, wire) + Self::from_business_value(wire) } - fn from_business_value( - redis_url: SecretString, - wire: BusinessConfigWire, - ) -> Result { - Ok(Self::from_business(redis_url, wire.try_into()?)) + fn from_business_value(wire: BusinessConfigWire) -> Result { + Ok(Self::from_business(wire.try_into()?)) } - - /// 遗留兼容路径:从进程环境变量逐项组装完整配置(阶段0 口径)。 - /// 当前生产启动仅需 `REDIS_URL` + `CONFIG_ENCRYPTION_KEY`(见 `src/main.rs`), - /// 业务字段改由 Redis 业务配置(`config:business`,`PUT /api/business-config` 热加载)托管; - /// 本函数仅当相关环境变量实际存在时作为引导路径使用,未声明废弃、未计划删除。 - pub fn from_env() -> Result { - let port = std::env::var("PORT") - .unwrap_or_else(|_| "8080".into()) - .parse() - .map_err(|_| BotError::Config("PORT must be a valid u16".into()))?; - let allowlist = std::env::var("CHAT_ALLOWLIST") - .or_else(|_| std::env::var("TELEGRAM_CHAT_ID")) - .unwrap_or_default() - .split(',') - .filter(|v| !v.trim().is_empty()) - .map(|v| v.trim().parse::()) - .collect::, _>>() - .map_err(|_| BotError::Config("CHAT_ALLOWLIST must contain integer IDs".into()))?; - let enabled = env_bool("LLM_ENABLED", false)?; - let llm = LlmConfig { - enabled, - allow_net: env_bool("LLM_ALLOW_NET", false)?, - api_key: std::env::var("LLM_API_KEY").ok().map(SecretString::new), - base_url: std::env::var("LLM_BASE_URL").ok(), - model: std::env::var("LLM_MODEL").ok(), - summary_target_chars: std::env::var("LLM_SUMMARY_TARGET_CHARS") - .unwrap_or_else(|_| "300".into()) - .parse() - .map_err(|_| { - BotError::Config("LLM_SUMMARY_TARGET_CHARS must be an integer".into()) - })?, - }; - if enabled && (llm.api_key.is_none() || llm.base_url.is_none() || llm.model.is_none()) { - return Err(BotError::Config( - "LLM_ENABLED requires LLM_API_KEY, LLM_BASE_URL and LLM_MODEL".into(), - )); - } - Ok(Self { - port, - run_mode: std::env::var("RUN_MODE").unwrap_or_else(|_| "webhook".into()), - telegram: TelegramConfig { - bot_token: required_secret("BOT_TOKEN")?, - chat_allowlist: allowlist, - chat_id: std::env::var("TELEGRAM_CHAT_ID") - .map_err(|_| BotError::Config("missing TELEGRAM_CHAT_ID".into()))? - .parse() - .map_err(|_| BotError::Config("TELEGRAM_CHAT_ID must be an integer".into()))?, - }, - jmap: JmapConfig { - session_url: required_nonblank("JMAP_SESSION_URL")?, - username: required_nonblank("JMAP_USERNAME")?, - app_password: required_secret("JMAP_PASSWORD")?, - }, - redis_url: required_secret("REDIS_URL")?, - account_id: std::env::var("ACCOUNT_ID").ok().filter(|v| !v.is_empty()), - llm, - auth: AuthSecrets { - reconcile_token: required_secret("RECONCILE_TOKEN")?, - telegram_webhook_secret: required_secret("TG_WEBHOOK_SECRET")?, - }, - worker_token: required_secret("WORKER_TOKEN")?, - }) - } - - /// Exposes a secret only to the client adapter at the point of use; never log this value. - #[expect( - dead_code, - reason = "稳定ID+阶段0占位:JMAP adapter 后续使用密钥访问器" - )] - pub fn jmap_password(&self) -> &str { - self.jmap.app_password.expose_secret() - } -} - -fn required_nonblank(name: &str) -> Result { - let value = std::env::var(name).map_err(|_| BotError::Config(format!("missing {name}")))?; - validate_nonblank(name, value.as_str())?; - Ok(value) -} - -fn required_secret(name: &str) -> Result { - let value = required_nonblank(name)?; - Ok(SecretString::new(value)) } fn validate_nonblank(name: &str, value: &str) -> Result<(), BotError> { @@ -395,28 +291,41 @@ fn validate_nonblank(name: &str, value: &str) -> Result<(), BotError> { Ok(()) } -fn env_bool(name: &str, default: bool) -> Result { - match std::env::var(name).ok().as_deref() { - None => Ok(default), - Some("true" | "1" | "yes") => Ok(true), - Some("false" | "0" | "no") => Ok(false), - Some(_) => Err(BotError::Config(format!("{name} must be boolean"))), - } -} - #[cfg(test)] mod tests { use super::*; #[test] fn blank_auth_secret_is_rejected_without_exposing_value() { - let error = required_secret("S1_TEST_AUTH_SECRET").expect_err("unset must fail"); - assert!(error.to_string().contains("S1_TEST_AUTH_SECRET")); - assert!(!error.to_string().contains("secret")); - std::env::set_var("S1_TEST_AUTH_SECRET", " \t"); - let error = required_secret("S1_TEST_AUTH_SECRET").expect_err("blank must fail"); - assert!(error.to_string().contains("S1_TEST_AUTH_SECRET")); - std::env::remove_var("S1_TEST_AUTH_SECRET"); + // 断言语义沿用原 required_secret 用例:错误信息点名字段,但绝不回显取值。 + // required_secret 已随遗留 from_env 路径一并删除,改挂到 BusinessConfigWire + // 这条真实校验路径(validate_nonblank -> TryFrom)。 + const AUTH_SECRET_VALUE: &str = "s3cr3t-value-7f3a"; + let wire = BusinessConfigWire { + bot_token: "bot".into(), + telegram_chat_id: 1, + chat_allowlist: vec![1], + telegram_webhook_secret: "hook".into(), + jmap_session_url: "https://example.invalid".into(), + jmap_username: "u".into(), + jmap_password: AUTH_SECRET_VALUE.into(), + account_id: None, + llm_enabled: false, + llm_allow_net: false, + llm_api_key: None, + llm_base_url: None, + llm_model: None, + reconcile_token: " \t".into(), + worker_token: "w".into(), + }; + let error = match BusinessConfig::try_from(wire) { + Err(error) => error, + Ok(_) => panic!("blank secret must fail"), + }; + let message = error.to_string(); + assert!(message.contains("RECONCILE_TOKEN")); + assert!(!message.contains("secret")); + assert!(!message.contains(AUTH_SECRET_VALUE)); } #[test] @@ -482,40 +391,4 @@ mod tests { ); } } - - #[test] - fn startup_rejects_blank_auth_secret() { - for (name, value) in [ - ("BOT_TOKEN", "bot"), - ("JMAP_SESSION_URL", "https://mail.invalid/.well-known/jmap"), - ("JMAP_USERNAME", "user"), - ("JMAP_PASSWORD", "password"), - ("REDIS_URL", "redis://invalid"), - ("TG_WEBHOOK_SECRET", "telegram"), - ("TELEGRAM_CHAT_ID", "123"), - ] { - std::env::set_var(name, value); - } - std::env::set_var("RECONCILE_TOKEN", " \t"); - let error = match Config::from_env() { - Err(error) => error, - Ok(_) => panic!("blank auth secret must fail startup"), - }; - assert_eq!( - error.to_string(), - "configuration error: missing or blank RECONCILE_TOKEN" - ); - for name in [ - "BOT_TOKEN", - "JMAP_SESSION_URL", - "JMAP_USERNAME", - "JMAP_PASSWORD", - "REDIS_URL", - "RECONCILE_TOKEN", - "TG_WEBHOOK_SECRET", - "TELEGRAM_CHAT_ID", - ] { - std::env::remove_var(name); - } - } } diff --git a/src/main.rs b/src/main.rs index 7e3a188..e6973b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,24 +63,10 @@ async fn main() -> Result<(), error::BotError> { axum::serve(listener, app).await?; return Ok(()); } - // The Redis URL is the only bootstrap credential in the migration path. Until - // the bootstrap route is wired, retain the legacy parser solely to obtain it. - let bootstrap_config = match Config::from_env() { - Ok(config) => config, - Err(_) => Config::redis_only(secrecy::SecretString::new(redis_value.clone())), - }; - let redis_url = bootstrap_config.redis_url.clone(); - // RUN_MODE is intentionally explicit even while both modes share the stage-0 - // no-op router; later stages attach webhook/reconcile side effects here. - match bootstrap_config.run_mode.as_str() { - "webhook" => tracing::info!("RUN_MODE=webhook"), - "reconcile" => tracing::info!("RUN_MODE=reconcile"), - mode => { - return Err(error::BotError::Config(format!( - "RUN_MODE must be webhook or reconcile, got {mode}" - ))) - } - } + // REDIS_URL is the only bootstrap credential read from the environment; every + // business credential comes from Redis business config instead. + let redis_url = secrecy::SecretString::new(redis_value); + let bootstrap_config = Config::redis_only(); tracing::info!(port, "starting webhook HTTP entrypoint"); let redis = RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) @@ -114,7 +100,7 @@ async fn main() -> Result<(), error::BotError> { .await .map_err(|_| error::BotError::Config("business config unavailable".into()))? { - Some(value) => Config::from_business_json(redis_url.clone(), value)?, + Some(value) => Config::from_business_json(value)?, None => bootstrap_config, }; let outbound = worker_state