docs+fix: delete the legacy env compat layer; sync every doc that described it

Code (src/config.rs 521->394, src/main.rs 232->200):
- Config::from_env() + required_secret / required_nonblank / env_bool deleted.
  config.rs now performs no std::env read; all business fields come from Redis
  business config (BusinessConfigWire -> validate_business_wire -> validate_nonblank).
- RUN_MODE deleted: variable, Config.run_mode field and the main.rs validation
  block. The two modes never changed runtime behaviour -- webhook and reconcile
  share one router, POST /reconcile is a standalone endpoint.
- jmap_password() accessor deleted; the field name is kept in
  BusinessConfig / BusinessConfigWire because it is real data, not a stub.
- Config.port and Config.redis_url deleted: both had zero readers, and port was
  hardcoded to 8080 in both constructors while the listener binds straight from
  the PORT env var, so the field could silently disagree with the real port.
  Config is now 6 fields: telegram / jmap / account_id / llm / auth / worker_token.
- redis_only() and from_business(value) signatures simplified accordingly.
- #[expect(dead_code)] count in src/ is now 0.

Test coverage: the from_env()-driven startup_rejects_blank_auth_secret case is
gone with the path it tested. The non-leak assertion it carried was reattached
to the real validation route (BusinessConfigWire -> TryFrom -> validate_nonblank)
with a stronger third assertion that the rejected value is not echoed either.

Docs: design.md 7.1 re-scoped to the Redis business config table; reference.md
5.1/5.3 rewritten (no legacy env path); deployment.md 5 and the runtime-mode
table rewritten; retired.md gains section 4 registering every deletion;
README x2, .env.example, charter.md updated.

GATE-P0: fmt / check / clippy -D warnings green, 61 passed / 0 failed / 1
ignored (was 62; the deleted case). GATE-DOCS: PASS 5/5, ANCHOR 247 line-refs /
0, SECTION-REF 216 / 0.
This commit is contained in:
anonsaber 2026-09-29 02:02:44 +08:00
parent 6334ae541d
commit 2da11b9f92
10 changed files with 140 additions and 249 deletions

View File

@ -2,13 +2,13 @@
# #
# The process requires only these two variables. CONFIG_ENCRYPTION_KEY is the SPA # 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 # 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. # Never commit or log a real URL/password/key.
REDIS_URL=redis://:REPLACE_WITH_REDIS_ACL_PASSWORD@redis.example.com:6379/0 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. # 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 CONFIG_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHAR_RANDOM_KEY
# PORT is an optional platform override (default: 8080). RUN_MODE is accepted but # PORT is an optional platform override (default: 8080). There is no RUN_MODE: the
# currently has no effect — both modes share one router (default: webhook). Business # legacy env parser was removed, and webhook plus reconcile traffic share one router.
# secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap; # 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. # they are then persisted in Redis and are never echoed by the API.

View File

@ -52,18 +52,17 @@ read-only setup router over in-memory state instead. See §3.
Three layers. They are not interchangeable. Three layers. They are not interchangeable.
**Boot-time environment — 2 required, 2 optional.** **Boot-time environment — 2 required, 1 optional.**
| Variable | Required | Default | | Variable | Required | Default |
|---|---|---| |---|---|---|
| `REDIS_URL` | yes | — | | `REDIS_URL` | yes | — |
| `CONFIG_ENCRYPTION_KEY` | yes | — | | `CONFIG_ENCRYPTION_KEY` | yes | — |
| `PORT` | no | `8080` | | `PORT` | no | `8080` |
| `RUN_MODE` | no | `webhook` |
`RUN_MODE` accepts `webhook` or `reconcile`, but **both currently produce identical There is no `RUN_MODE`: the legacy environment parser was removed, and webhook plus
behavior** (the two modes share one router), so it is a forward placeholder: keep the reconcile traffic share one router (`POST /reconcile` is a standalone endpoint).
default `webhook`. `.env.example` ships only the two required `.env.example` ships only the two required
variables; the rest are documented defaults. variables; the rest are documented defaults.
**Business configuration — Redis, written by the browser.** **Business configuration — Redis, written by the browser.**

View File

@ -55,9 +55,8 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
| `REDIS_URL` | 是 | — | | `REDIS_URL` | 是 | — |
| `CONFIG_ENCRYPTION_KEY` | 是 | — | | `CONFIG_ENCRYPTION_KEY` | 是 | — |
| `PORT` | 否 | `8080` | | `PORT` | 否 | `8080` |
| `RUN_MODE` | 否 | `webhook` |
`RUN_MODE` 只接受 `webhook` 或 `reconcile`,但两种取值当前行为完全相同(共享同一套路由表),仅作前向占位,保持默认 `webhook` 即可。`.env.example` 只提供 2 个必需变量;其余是 没有 `RUN_MODE`:遗留的环境变量解析器已删除,webhook 与 reconcile 流量共享同一套路由表(`POST /reconcile` 是独立端点)。`.env.example` 只提供 2 个必需变量;其余是
文档化默认值。 文档化默认值。
**业务配置 —— 存于 Redis,由浏览器写入。** **业务配置 —— 存于 Redis,由浏览器写入。**

View File

@ -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' 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。 **注意**:`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-ONLY-STATE` | docs/deployment.md §0 | 状态仅外部 Redis,不用 SQLite/本地卷 | 部署约束 |
| `C-REDIS-MANAGED-AOF` | docs/deployment.md §0 | Redis 用户托管 + 开启 AOF 持久化 | 部署约束 | | `C-REDIS-MANAGED-AOF` | docs/deployment.md §0 | Redis 用户托管 + 开启 AOF 持久化 | 部署约束 |
| `C-PORT` | docs/deployment.md §0 | 通用 PORT 约定 | 部署约束 | | `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-POLLING-SSE` | docs/deployment.md §1 | EventSource/SSE 长连接,非目标 | 非目标 |
| `NG-LONG-POLLING` | docs/deployment.md §1 | Telegram 长轮询,非目标 | 非目标 | | `NG-LONG-POLLING` | docs/deployment.md §1 | Telegram 长轮询,非目标 | 非目标 |
| `NG-SQLITE-PERSIST` | docs/deployment.md §1 | SQLite 持久化,非目标 | 非目标 | | `NG-SQLITE-PERSIST` | docs/deployment.md §1 | SQLite 持久化,非目标 | 非目标 |

View File

@ -40,7 +40,7 @@
| ID | 非目标 | 说明 | | 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-POLLING-SSE** | JMAP EventSource/SSE 长连接订阅 | 与 `C-NO-LONG-CONN` 冲突;实时通道只用 Push 回调 |
| **NG-LONG-POLLING** | Telegram 长轮询运行模式 | 与 `C-NO-LONG-CONN` 冲突;只用 Webhook | | **NG-LONG-POLLING** | Telegram 长轮询运行模式 | 与 `C-NO-LONG-CONN` 冲突;只用 Webhook |
| **NG-SQLITE-PERSIST** | SQLite 会话 / sinceState 本地持久化 | 状态只走外部 Redis(`C-REDIS-ONLY-STATE`) | | **NG-SQLITE-PERSIST** | SQLite 会话 / sinceState 本地持久化 | 状态只走外部 Redis(`C-REDIS-ONLY-STATE`) |
@ -103,7 +103,7 @@
### 3.2 运行阶段(收敛为单端口) ### 3.2 运行阶段(收敛为单端口)
- `EXPOSE 8080`(遵循 `C-NO-TCP-EXPOSE`,不再暴露 9191 admin 端口) - `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),不依赖独立端口 - 健康检查走同一 HTTP 监听(见 §7),不依赖独立端口
- **不声明 VOLUME**,无 `/app/data` 本地卷(`NG-LOCAL-VOLUME`) - **不声明 VOLUME**,无 `/app/data` 本地卷(`NG-LOCAL-VOLUME`)
@ -135,11 +135,11 @@ HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查
`/reconcile`、`/worker` 返回 503,不执行入队、ACK 或业务处理;SPA、`/api/status`、管理 `/reconcile`、`/worker` 返回 503,不执行入队、ACK 或业务处理;SPA、`/api/status`、管理
会话和配置 API 保持可用,打开后立即生效。 会话和配置 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 | | `BOT_TOKEN` | ✅ | Telegram Bot Token |
| `TELEGRAM_CHAT_ID` | ✅ | Worker 元数据通知目标 chat id(与入站白名单分离) | | `TELEGRAM_CHAT_ID` | ✅ | Worker 元数据通知目标 chat id(与入站白名单分离) |
@ -148,13 +148,12 @@ HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查
| `CHAT_ALLOWLIST` | ✅ | 聊天白名单(**硬约束 `SAF-CHAT-ALLOWLIST`**):逗号分隔整数 chat id;处理任何事件前先校验,非白名单直接拒绝 | | `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:<url-encoded-password>@host:6379/0`,Redis 进程不在此 compose 内 | | `REDIS_URL` | ✅ | **外部 Redis(用户托管 + AOF)**(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`):session / dedup / Streams / fuse / sinceState 全部在此;支持 `redis://` 与带默认 ACL 用户的 `rediss://default:<url-encoded-password>@host:6379/0`,Redis 进程不在此 compose 内 |
| `PORT` | 默认 8080 | 单监听端口(`C-NO-TCP-EXPOSE`) | | `PORT` | 默认 8080 | 单监听端口(`C-NO-TCP-EXPOSE`) |
| `RUN_MODE` | 默认 `webhook` | `webhook` / `reconcile`(`NG-SERVER-MODE` 已删除);**两种取值当前行为完全相同**(共享同一套路由表),仅作前向占位,保持默认即可 | | `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer <token>` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此字段为**必填**(`SecretString`) |
| `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer <token>` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此变量为**必填**(`SecretString`) |
| `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session | | `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session |
| `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) | | `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) |
| `--debug` + `DEBUG_TOKEN` | 可选,默认关闭 | 远程联调面 `/debug/*` 的**双因子开关**(`SAF-DEBUG-GATE`):命令行必须带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载 7 条路由,缺一即路由不存在、请求走通用 `404`。令牌为 `SecretString`,常数时间比较、不进日志(`SAF-LOG-PURITY`)。生产环境不配置,见 §2.1 | | `--debug` + `DEBUG_TOKEN` | 可选,默认关闭 | 远程联调面 `/debug/*` 的**双因子开关**(`SAF-DEBUG-GATE`):命令行必须带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载 7 条路由,缺一即路由不存在、请求走通用 `404`。令牌为 `SecretString`,常数时间比较、不进日志(`SAF-LOG-PURITY`)。生产环境不配置,见 §2.1 |
| Push registration/verification | 已接入,需显式注册 | `POST /api/push/register` 接受 HTTPS callback URL 并调用 `PushSubscription/set create`;相同 callback URL 重复请求幂等复用;`/push/jmap` 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态 | | Push registration/verification | 已接入,需显式注册 | `POST /api/push/register` 接受 HTTPS callback URL 并调用 `PushSubscription/set create`;相同 callback URL 重复请求幂等复用;`/push/jmap` 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态 |
| `LLM_*` | 可选 | OpenAI-compatible 环境变量(design.md `REQ-LLM-OPENAI-COMPAT`);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) | | `LLM_*` | 可选 | OpenAI-compatible 业务配置字段(`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/配置),不做多账户单实例 | | `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 |
AI 授权期限由用户选择(临时一次、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。 AI 授权期限由用户选择(临时一次、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。
@ -202,12 +201,7 @@ TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链
并应校验可信转发头后才允许 bootstrap。容器端口不得直接暴露公网。SPA 仅在当前页面 并应校验可信转发头后才允许 bootstrap。容器端口不得直接暴露公网。SPA 仅在当前页面
内存保存 opaque admin session,不使用 localStorage、sessionStorage 或 Cookie 持久化。 内存保存 opaque admin session,不使用 localStorage、sessionStorage 或 Cookie 持久化。
> 仅两种运行模式;无长连接(`C-NO-LONG-CONN`)。 > **单一运行形态**:进程只有单条服务命令 `./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`)。
| RUN_MODE | 命令 | 说明 |
|---|---|---|
| `webhook`(默认) | `message-weave webhook`(或单一入口按 env 分派) | 单进程:TG Webhook handler + JMAP Push 回调 handler + Redis Streams worker + `/reconcile` + health |
| `reconcile` | `message-weave reconcile` | 一次性对账补差后 exit,由外部调度器/容器任务触发 |
### 6.1 入口路由(单端口 `PORT`,全部经平台 HTTPS URL 入站) ### 6.1 入口路由(单端口 `PORT`,全部经平台 HTTPS URL 入站)
``` ```
@ -249,7 +243,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
#### 6.3.1 用户侧调度示例(copy-paste 可用) #### 6.3.1 用户侧调度示例(copy-paste 可用)
端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer <RECONCILE_TOKEN>` 端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer <RECONCILE_TOKEN>`
(`src/config.rs:357` 启动必填,服务端常数时间比较,`notify.rs:228-231`)。 (`src/config.rs:147` fail-closed 必填,服务端常数时间比较,`notify.rs:228-231`)。
最小示例: 最小示例:

View File

@ -310,8 +310,8 @@ message-weave/
├── Dockerfile # debian:bookworm-slim + ca-certificates + tini(C-DEBIAN-SLIM) ├── Dockerfile # debian:bookworm-slim + ca-certificates + tini(C-DEBIAN-SLIM)
├── hoststack.yaml # 网关编排清单,非应用代码 ├── hoststack.yaml # 网关编排清单,非应用代码
├── src/ ├── src/
│ ├── main.rs # tokio main:校验 RUN_MODE 后启动 webhook HTTP 入口(两模式共用同一入口,无 CLI 子命令) │ ├── main.rs # tokio main:读 PORT/REDIS_URL/CONFIG_ENCRYPTION_KEY 后启动 webhook HTTP 入口(单入口,无 CLI 子命令)
│ ├── config.rs # 环境变量解析(ARCH-CONFIG-ENV:std::env,非 figment/TOML) │ ├── config.rs # Redis 业务配置反序列化(serde → BusinessConfigWire → Config);CONFIG_ENCRYPTION_KEY 解析
│ ├── error.rs # BotError 统一错误(见 §8.1) │ ├── error.rs # BotError 统一错误(见 §8.1)
│ ├── domain.rs # 领域层根(渠道中立,见 §5.2) │ ├── domain.rs # 领域层根(渠道中立,见 §5.2)
│ ├── domain/jmap.rs # JmapBackend trait + MockBackend(契约测试不联网) │ ├── 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 个变体) | 单测 | 已实现 | | `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::`) | | `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 | 已实现 | | `state` | redis 0.27 | Redis 读写:配置/开关/会话/TTL 键(`C-REDIS-ONLY-STATE`) | Redis mock | 已实现 |
@ -366,14 +366,19 @@ message-weave/
> 使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,阶段0列表仅作为历史 > 使用新配置,失败保留旧实例。该迁移替代下述阶段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`) | | `PORT` | 否 | `8080` | 单监听端口(`C-NO-TCP-EXPOSE`) |
| `RUN_MODE` | 否 | `webhook` | `webhook` / `reconcile`(`NG-SERVER-MODE` 已删除);**两种取值当前不改变任何运行时行为**(两模式共享同一套路由表),仅作前向占位 |
| `BOT_TOKEN` | 是 | — | Telegram Bot Token(`SecretString`) | | `BOT_TOKEN` | 是 | — | Telegram Bot Token(`SecretString`) |
| `TG_WEBHOOK_SECRET` | 是 | — | `/webhook/tg` 鉴权:请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`;`SecretString`) | | `TG_WEBHOOK_SECRET` | 是 | — | `/webhook/tg` 鉴权:请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`;`SecretString`) |
| `CHAT_ALLOWLIST` | 是 | — | 逗号分隔整数 chat id;**白名单硬约束**(`SAF-CHAT-ALLOWLIST`) | | `CHAT_ALLOWLIST` | 是 | — | 逗号分隔整数 chat id;**白名单硬约束**(`SAF-CHAT-ALLOWLIST`) |
@ -387,16 +392,16 @@ message-weave/
| `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) | | `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) |
| `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) | | `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) |
| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | `LLM_ENABLED=true` 时必填 | — | OpenAI-compatible | | `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_MAX_RETRIES` | 否 | — | 熔断阈值(`REQ-AI-FUSE`)。**非**环境变量:经 Redis 运行参数管理(`OutboundConfig.max_retries`,回落默认见 `docs/reference.md` §6.1) |
| `LLM_SUMMARY_TARGET_CHARS` | 否 | `300` | 摘要目标字数(`REQ-LONG-EMAIL`) | | `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 可访问;不会因为缺少启动变量而伪造业务成功,也不会绕过入口鉴权。 > **入口鉴权密钥(`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::` 验证**。) > **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 密钥管理 ### 7.2 密钥管理
- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置走环境变量,`ARCH-CONFIG-ENV`)。 - **禁止**把 token/密码写入仓库或任何配置文件(本项目配置不进仓库,`ARCH-CONFIG-ENV`)。
- 环境变量由 `config` 层直接读取(`std::env::var`,`ARCH-CONFIG-ENV`);缺失必填项即启动失败(**无** `${VAR}` 插值/figment)。 - 启动期只读 2 个进程级变量(`REDIS_URL`、`CONFIG_ENCRYPTION_KEY`,由 `src/main.rs` 直接 `std::env::var`;缺任一即降级到只读 setup 模式);业务配置从 Redis 经 `config` 层反序列化(**无** `${VAR}` 插值/figment/配置文件,`ARCH-CONFIG-ENV`)。
- 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。 - 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。
- 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。 - 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。
- 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 - 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
@ -493,7 +498,7 @@ pub enum BotError {
### 8.3 可观测性 ### 8.3 可观测性
- `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。 - `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`)。 - 指标(可选 `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 重建,不依赖进程内状态。 - 优雅退出:**当前未实现**——`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`)。 - **可靠性目标与策略**(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` 通过(含路由/配置最小测试)。 3. `cargo test` 通过(含路由/配置最小测试)。
4. 上述三条**在 Debian `rust:1-slim-bookworm` 容器内**执行通过(`C-DEBIAN-SLIM`)。 4. 上述三条**在 Debian `rust:1-slim-bookworm` 容器内**执行通过(`C-DEBIAN-SLIM`)。
5. 配置读取为 env-only(无 figment/TOML);`SecretString` 包裹秘密且 `Debug` 不泄密;`JMAP_PASSWORD` 安全访问器保留。 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)。 7. `.gitignore` 存在(排除 `target/` 等;**不**擅自初始化 git)。
8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。 8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。
@ -585,7 +597,7 @@ pub enum BotError {
> 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。 > 前置:`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::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`)。 - `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-*`)已落地并通过单测;后续改动不得放宽或绕过鉴权。 - **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)。 **不存在** `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`(新邮件通知)。 调用方只有 `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` | | `LLM_ENABLED` | bool | `false` | 总开关;默认关闭,未启用时不校验 `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` |

View File

@ -325,21 +325,19 @@ deployment.md §10.5.
Three distinct layers. They are not interchangeable. 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 | | Variable | Required | Default | Source |
|---|---|---|---| |---|---|---|---|
| `REDIS_URL` | yes | — | read at main.rs:40 | | `REDIS_URL` | yes | — | read at main.rs:40 |
| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:50 | | `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) | | `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 |
| `RUN_MODE` | no | `webhook` | `unwrap_or_else` at config.rs:345 | | `DEBUG_TOKEN` | no | — | `std::env::var` at main.rs:83, effective only when `--debug` is also passed (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) |
| `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 `RUN_MODE` **no longer exists**: the identifier was removed together with the legacy
(main.rs:78-82). Both modes currently route to the same no-op router; the distinction is `Config::from_env()` environment parser (registration in `docs/retired.md`). Both webhook
load-bearing for future splits, not for behaviour today. Note the value is only read on and reconcile traffic share one router and reconcile is a standalone `POST /reconcile`
the legacy env path (`config.rs:345`), so with the current two-variable production config endpoint, so the variable never changed any runtime behaviour — there is now nothing to set.
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 **Missing a required variable does not crash the process.** It logs a warning and serves
`router_configuration_setup`, which builds a router over `MemoryState` with empty tokens and `router_configuration_setup`, which builds a router over `MemoryState` with empty tokens and
@ -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. 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. 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 `Config::from_env()` and its helpers (`required_secret`, `required_nonblank`, `env_bool`) are
compatibility. The full set of names it recognises (`from_env()`, `config.rs:311`), **deleted** — `config.rs` performs no `std::env` read at all. The names below were the legacy
unprefixed unless noted: 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` · `RUN_MODE` · `CHAT_ALLOWLIST` · `TELEGRAM_CHAT_ID` · `LLM_ENABLED` · `LLM_ALLOW_NET` ·
`LLM_ENABLED` · `LLM_ALLOW_NET` · `LLM_API_KEY` · `LLM_BASE_URL` · `LLM_MODEL` · `LLM_API_KEY` · `LLM_BASE_URL` · `LLM_MODEL` · `LLM_SUMMARY_TARGET_CHARS` · `BOT_TOKEN` ·
`LLM_SUMMARY_TARGET_CHARS` · `RUN_MODE` · `BOT_TOKEN` · `JMAP_SESSION_URL` · `JMAP_USERNAME` · `JMAP_SESSION_URL` · `JMAP_USERNAME` · `JMAP_PASSWORD` · `ACCOUNT_ID` · `RECONCILE_TOKEN` ·
`JMAP_PASSWORD` · `ACCOUNT_ID` · `RECONCILE_TOKEN` · `TG_WEBHOOK_SECRET` · `WORKER_TOKEN` `TG_WEBHOOK_SECRET` · `WORKER_TOKEN`
The `JMAP_*` family is exactly three names; `ACCOUNT_ID` is read here too, alongside the The `JMAP_*` family is exactly three names. In production the account id arrives as the
direct read the JMAP client performs when no `accountId` is present in the request. `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 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. `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 **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 `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 `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 only compared against the request bearer — never echoed, logged, or stored. The session

View File

@ -90,14 +90,14 @@
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |
| `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_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`(7 个事件)外**零 tracing 事件、零 span**;LLM 调用无任何日志 | 无 | | `llm.call` tracing span | 虚构观测点 | `src/` 中除 `main.rs`(5 个事件)外**零 tracing 事件、零 span**;LLM 调用无任何日志 | 无 |
| 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 | | 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 |
| 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 | | 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 |
| 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 | | 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 |
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:533`) | 无 | | `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:533`) | 无 |
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 | | `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 表 | | docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
| 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 | | 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / 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` 登记为缺口并归属阶段。 1. 先在 `docs/roadmap.md` 登记为缺口并归属阶段。
2. 若涉及 Redis 键、TTL、HTTP 路由或默认值,同轮更新 `docs/reference.md`。 2. 若涉及 Redis 键、TTL、HTTP 路由或默认值,同轮更新 `docs/reference.md`。

View File

@ -1,4 +1,4 @@
// SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义、Redis 配置解析与遗留 from_env() 路径相互引用,拆出只增加跨文件跳转而不消除任何重复。 // SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义与 Redis 配置解析路径相互引用,拆出只增加跨文件跳转而不消除任何重复。
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use crate::error::BotError; 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. /// Runtime-only settings. SecretString prevents accidental formatting/logging of credentials.
/// C-AUTH-APP-BASIC and REQ-SINGLE-ACCOUNT are intentionally represented explicitly. /// C-AUTH-APP-BASIC and REQ-SINGLE-ACCOUNT are intentionally represented explicitly.
pub struct Config { 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 telegram: TelegramConfig,
pub jmap: JmapConfig, pub jmap: JmapConfig,
pub redis_url: SecretString,
pub account_id: Option<String>, pub account_id: Option<String>,
pub llm: LlmConfig, pub llm: LlmConfig,
pub auth: AuthSecrets, pub auth: AuthSecrets,
@ -221,11 +212,9 @@ pub(crate) fn validate_business_wire(wire: BusinessConfigWire) -> Result<(), Bot
impl Config { impl Config {
/// Configuration-only startup used before Redis bootstrap. No business route can pass /// Configuration-only startup used before Redis bootstrap. No business route can pass
/// authentication with these empty sentinels; the process only serves SPA/bootstrap. /// 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()); let empty = || SecretString::new(String::new());
Self { Self {
port: 8080,
run_mode: "webhook".into(),
telegram: TelegramConfig { telegram: TelegramConfig {
bot_token: empty(), bot_token: empty(),
chat_allowlist: Vec::new(), chat_allowlist: Vec::new(),
@ -236,7 +225,6 @@ impl Config {
username: String::new(), username: String::new(),
app_password: empty(), app_password: empty(),
}, },
redis_url,
account_id: None, account_id: None,
llm: LlmConfig { llm: LlmConfig {
enabled: false, enabled: false,
@ -256,10 +244,8 @@ impl Config {
/// Builds the runtime adapter configuration from the Redis business namespace. /// Builds the runtime adapter configuration from the Redis business namespace.
/// This path is used after bootstrap and intentionally does not consult process env. /// 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 { Self {
port: 8080,
run_mode: "webhook".into(),
telegram: TelegramConfig { telegram: TelegramConfig {
bot_token: value.bot_token, bot_token: value.bot_token,
chat_allowlist: value.chat_allowlist, chat_allowlist: value.chat_allowlist,
@ -270,7 +256,6 @@ impl Config {
username: value.jmap_username, username: value.jmap_username,
app_password: value.jmap_password, app_password: value.jmap_password,
}, },
redis_url,
account_id: value.account_id, account_id: value.account_id,
llm: LlmConfig { llm: LlmConfig {
enabled: value.llm_enabled, enabled: value.llm_enabled,
@ -288,104 +273,15 @@ impl Config {
} }
} }
pub fn from_business_json( pub fn from_business_json(value: serde_json::Value) -> Result<Self, BotError> {
redis_url: SecretString,
value: serde_json::Value,
) -> Result<Self, BotError> {
let wire: BusinessConfigWire = serde_json::from_value(value) let wire: BusinessConfigWire = serde_json::from_value(value)
.map_err(|_| BotError::Config("invalid Redis business configuration".into()))?; .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( fn from_business_value(wire: BusinessConfigWire) -> Result<Self, BotError> {
redis_url: SecretString, Ok(Self::from_business(wire.try_into()?))
wire: BusinessConfigWire,
) -> Result<Self, BotError> {
Ok(Self::from_business(redis_url, wire.try_into()?))
} }
/// 遗留兼容路径:从进程环境变量逐项组装完整配置(阶段0 口径)。
/// 当前生产启动仅需 `REDIS_URL` + `CONFIG_ENCRYPTION_KEY`(见 `src/main.rs`),
/// 业务字段改由 Redis 业务配置(`config:business`,`PUT /api/business-config` 热加载)托管;
/// 本函数仅当相关环境变量实际存在时作为引导路径使用,未声明废弃、未计划删除。
pub fn from_env() -> Result<Self, BotError> {
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::<i64>())
.collect::<Result<Vec<_>, _>>()
.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<String, BotError> {
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<SecretString, BotError> {
let value = required_nonblank(name)?;
Ok(SecretString::new(value))
} }
fn validate_nonblank(name: &str, value: &str) -> Result<(), BotError> { fn validate_nonblank(name: &str, value: &str) -> Result<(), BotError> {
@ -395,28 +291,41 @@ fn validate_nonblank(name: &str, value: &str) -> Result<(), BotError> {
Ok(()) Ok(())
} }
fn env_bool(name: &str, default: bool) -> Result<bool, BotError> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn blank_auth_secret_is_rejected_without_exposing_value() { fn blank_auth_secret_is_rejected_without_exposing_value() {
let error = required_secret("S1_TEST_AUTH_SECRET").expect_err("unset must fail"); // 断言语义沿用原 required_secret 用例:错误信息点名字段,但绝不回显取值。
assert!(error.to_string().contains("S1_TEST_AUTH_SECRET")); // required_secret 已随遗留 from_env 路径一并删除,改挂到 BusinessConfigWire
assert!(!error.to_string().contains("secret")); // 这条真实校验路径(validate_nonblank -> TryFrom<BusinessConfigWire>)。
std::env::set_var("S1_TEST_AUTH_SECRET", " \t"); const AUTH_SECRET_VALUE: &str = "s3cr3t-value-7f3a";
let error = required_secret("S1_TEST_AUTH_SECRET").expect_err("blank must fail"); let wire = BusinessConfigWire {
assert!(error.to_string().contains("S1_TEST_AUTH_SECRET")); bot_token: "bot".into(),
std::env::remove_var("S1_TEST_AUTH_SECRET"); 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] #[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);
}
}
} }

View File

@ -63,24 +63,10 @@ async fn main() -> Result<(), error::BotError> {
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
return Ok(()); return Ok(());
} }
// The Redis URL is the only bootstrap credential in the migration path. Until // REDIS_URL is the only bootstrap credential read from the environment; every
// the bootstrap route is wired, retain the legacy parser solely to obtain it. // business credential comes from Redis business config instead.
let bootstrap_config = match Config::from_env() { let redis_url = secrecy::SecretString::new(redis_value);
Ok(config) => config, let bootstrap_config = Config::redis_only();
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}"
)))
}
}
tracing::info!(port, "starting webhook HTTP entrypoint"); tracing::info!(port, "starting webhook HTTP entrypoint");
let redis = let redis =
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
@ -114,7 +100,7 @@ async fn main() -> Result<(), error::BotError> {
.await .await
.map_err(|_| error::BotError::Config("business config unavailable".into()))? .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, None => bootstrap_config,
}; };
let outbound = worker_state let outbound = worker_state