test: cover business config read-back and partial submit

Adds the missing test coverage for the GET /api/business-config read-back
and the partial-patch PUT, then syncs the docs to the behaviour the code
actually has.

src/notify.rs
- PUT response body now carries "revision" alongside the
  x-business-config-revision header, which the SPA surfaces in its status line.
- 4 test helpers (a router that returns (Router, MemoryState) so a config can be
  seeded first, a full valid wire value, a direct state writer that skips
  validation, and a wire value whose JMAP URL fails fast) plus 9 tests:
  - GET 401 leaks no business field at all (body is exactly error + request_id)
  - GET echoes the 10 non-secret values and returns secrets only as presence
    booleans; the serialised body is asserted to contain none of the 6 secret
    values (SAF-NO-SECRET-ECHO)
  - GET with nothing stored returns 200 configured:false revision:0, not 404
  - a first save with an incomplete patch is rejected and writes nothing
  - a partial submit replaces only the fields it carries, verified by reading
    the merged result back over the same path the SPA uses
  - validation runs on the merged result, so changing one field cannot bypass
    the rest, and a rejected submit neither persists nor bumps the revision
  - telegram_chat_id is accepted as a number or a numeric string and a garbage
    string is rejected without overwriting the stored value
  - a typo in a field name is rejected rather than silently dropped

src/config.rs
- Pure deserialisation test for de_i64_from_number_or_string, with no router and
  no store. This is the only place that separates "the custom deserializer
  refuses the string" from "a later validation step refused it" - a real
  distinction, since the SPA submits telegram_chat_id as a string on a partial
  submit while the read-back model returns a number.

cloudflare-worker/test/index.test.js
- The forwarded-route test asserted a fixed upstream origin, which is not stable:
  the LB rotates its start index between tests. Assert the origin is one of the
  configured backends and check the path and method exactly instead.

docs
- 17 spots bring the docs in line with the shipped contract: GET is now
  documented, PUT as a partial patch, the worker safelist at 16 routes, and the
  preflight path listed as forwarded.
- Corrections the docs had backwards: the response field is "configured"
  (revision is a u64, 0 when nothing is stored); Invalid returns 422, only
  Unreachable returns 503; there is no chat_allowlist_empty error code.
- "Blank keeps the stored value" is now stated as a client-side contract. The
  backend does not distinguish an omitted field from an empty string - apply()
  uses unwrap_or_else, so an explicitly submitted "" really does clear the
  secret (validation then rejects it). Saying it was server-side was wrong.
- POST /worker was listed as a forwarded route though it is not in SAFE_ROUTES,
  and the per-category counts did not match the lists they introduced.
- POST /api/bootstrap does not share the patch-merge contract; it deserialises a
  full BusinessConfigWire, which is the only path that can create a config from
  nothing.
- Re-anchored 9 src/notify.rs line refs that had drifted blank and raised the
  baseline to 86 passed / 0 failed / 2 ignored.

Gates: fmt, clippy -D warnings, test 86/0/2 ignored, worker 32/32, SPA 17/17,
GATE-DOCS 6/6 clean.
This commit is contained in:
anonsaber 2026-09-30 01:24:42 +08:00
parent 0cee50b4c9
commit e3924a53c1
10 changed files with 508 additions and 40 deletions

View File

@ -3,7 +3,7 @@
统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。
**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 **透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。
> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 15 条白名单、 > 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 16 条白名单、
> 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与 > 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与
> 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。 > 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。
@ -35,7 +35,7 @@
+ 相同 App Password(各后端实例的 `JMAP` 可不同,`docs/deployment.md` §10.2)。 + 相同 App Password(各后端实例的 `JMAP` 可不同,`docs/deployment.md` §10.2)。
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL) ### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/enabled`(业务总开关,SPA 服务卡片读写)、`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`、`GET|PUT /api/business-config`(读回 + 增量保存)、`POST /api/business-config/preflight`(保存前逐组件连通性预检)。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`。 - 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`。
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。 - 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
- method 不符 → **405**。 - method 不符 → **405**。

View File

@ -117,9 +117,17 @@ test("Worker: business config read-back GET and preflight POST are both forwarde
assert.equal(preflight.status, 200); assert.equal(preflight.status, 200);
assert.equal(seen.length, 2); assert.equal(seen.length, 2);
assert.equal(seen[0][0], "https://a.example/api/business-config"); // The LB rotates its start index across tests, so the origin is not stable;
// only the path and method are. Assert the origin is one of the configured
// backends (not arbitrary) and check the path/method exactly.
const backends = JSON.parse(makeEnv().BACKEND_ORIGINS_JSON);
for (const [url, method] of seen) {
const target = new URL(url);
assert.ok(backends.includes(target.origin), `${url} reached an unknown upstream`);
}
assert.equal(new URL(seen[0][0]).pathname, "/api/business-config");
assert.equal(seen[0][1], "GET"); assert.equal(seen[0][1], "GET");
assert.equal(seen[1][0], "https://a.example/api/business-config/preflight"); assert.equal(new URL(seen[1][0]).pathname, "/api/business-config/preflight");
assert.equal(seen[1][1], "POST"); assert.equal(seen[1][1], "POST");
} finally { } finally {
restore(); restore();

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'
``` ```
**当前基线:77 passed / 0 failed / 2 ignored**(2026-09-29 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app`,均需外部真实 JMAP 服务器凭据)。 **当前基线:86 passed / 0 failed / 2 ignored**(2026-09-30 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app`,均需外部真实 JMAP 服务器凭据)。
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。
@ -275,7 +275,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 | | `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 |
| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | | `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 |
| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 | | `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/*` 不在网关 15 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | | `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 16 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 |
| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 | | `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 |
| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 | | `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 |
| `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 | | `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 |

View File

@ -78,7 +78,7 @@
**鉴权** **鉴权**
7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:495`),因此令牌是**常数时间比较**(`src/notify.rs:1416`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:496`),因此令牌是**常数时间比较**(`src/notify.rs:1416`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。
**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) **7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取)
@ -307,7 +307,7 @@ AI 授权期限由用户选择(临时一次、今天、7天或直到撤销)
> 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。 > 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。
> 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。 > 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。
> **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 `CONFIG_ENCRYPTION_KEY` 后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。 > **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 `CONFIG_ENCRYPTION_KEY` 后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 通过 `GET /api/business-config` 回读已保存配置:返回 10 个非密钥字段的值 + 6 个密钥的存在性布尔(`SAF-NO-SECRET-ECHO`,不返回密钥值本身);`PUT` 接受部分补丁,只替换提交中出现的字段,省略的密钥沿用已存值,因此表单回读后只需修改要改的字段,无需重新填写密钥。但后端**不**把空字符串当作"未提交":显式提交 `""` 会真的清空该密钥,"留空即保留"这一保证由 SPA 在提交前丢弃空白密钥字段实现;`chat_allowlist` 提交空数组会被拒绝(422)。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。
--- ---
@ -332,7 +332,8 @@ AI 授权期限由用户选择(临时一次、今天、7天或直到撤销)
成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销 成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销
或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存 或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存
不可逆哈希和过期时间,重启后按 Redis TTL 恢复,进程不保存本地状态。GET 永不回显 不可逆哈希和过期时间,重启后按 Redis TTL 恢复,进程不保存本地状态。GET 永不回显
任何密钥,配置缺失或业务依赖未就绪时保持 HTTP/SPA 可用但业务路由返回未就绪。 任何密钥值,只返回密钥是否存在(presence 布尔),配置缺失或业务依赖未就绪时保持
HTTP/SPA 可用但业务路由返回未就绪。
`config:business` 使用 AES-256-GCM(版本、随机 nonce、认证 tag 均在密文 envelope 中) `config:business` 使用 AES-256-GCM(版本、随机 nonce、认证 tag 均在密文 envelope 中)
写入 Redis;应用启动时使用 `CONFIG_ENCRYPTION_KEY` 解密,密钥永不写 Redis、日志、响应 写入 Redis;应用启动时使用 `CONFIG_ENCRYPTION_KEY` 解密,密钥永不写 Redis、日志、响应
@ -358,7 +359,8 @@ GET /api/status 公开启动状态;只返回 ready/mode/missing
POST /api/admin/session Bearer CONFIG_ENCRYPTION_KEY;成功返回 900 秒 admin session POST /api/admin/session Bearer CONFIG_ENCRYPTION_KEY;成功返回 900 秒 admin session
POST /api/admin/session/revoke Bearer admin session;成功返回 204 POST /api/admin/session/revoke Bearer admin session;成功返回 204
GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503 GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;校验通过后必持久化, GET|PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;GET 回读非密钥值 + 6 个密钥存在性布尔(不回显密钥值),
PUT 接受部分补丁(省略的密钥沿用已存值),校验通过后必持久化,
客户端构建失败返回 200+warnings(不阻断保存),reload 成功才 runtime_applied=true 客户端构建失败返回 200+warnings(不阻断保存),reload 成功才 runtime_applied=true
POST /api/business-config/preflight Bearer admin session 或 WORKER_TOKEN;同样的校验与客户端构建, POST /api/business-config/preflight Bearer admin session 或 WORKER_TOKEN;同样的校验与客户端构建,
不写入、不换 worker;返回 200 + 逐组件结论 不写入、不换 worker;返回 200 + 逐组件结论
@ -379,7 +381,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
### 6.2 Redis Streams / worker(MOD-STREAMS) ### 6.2 Redis Streams / worker(MOD-STREAMS)
- Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞 - Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞
- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:380`,路由 `src/notify.rs:1608`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。 - worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:381`,路由 `src/notify.rs:1608`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。
- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:397`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:478`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。 - 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:397`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:478`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。
- 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底) - 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底)
@ -731,7 +733,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
### 10.4 路由与故障转移 ### 10.4 路由与故障转移
- **路由 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`(队列排空端点,由外部调度周期性调用而非人工触发,Bearer 鉴权,见 §6.3.1)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:676` 读、开关写入在 `:1048`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 - **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **16 条**(`SAFE_ROUTES` at `backends.js:9-26` + `ROUTE_METHODS` at `index.js:46-63`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`GET|PUT /api/business-config`、`POST /api/business-config/preflight`、`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`(队列排空端点,由外部调度周期性调用而非人工触发,Bearer 鉴权,见 §6.3.1)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:676` 读、开关写入在 `:1048`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`GET|PUT /api/business-config` 与 `POST /api/business-config/preflight` 同属此类必须透传的路由:SPA 由 Worker 服务,配置回读、提交前校验与保存都由它发起,缺了这两条,回读与预校验经网关部署时恒 404/405,整条配置管理页面只剩后端直连可用;两者同样强制 admin-session Bearer 鉴权,且回读只返回密钥存在性布尔(`SAF-NO-SECRET-ECHO`),不新增凭据暴露面。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 - **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。
- **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。 - **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。

View File

@ -431,10 +431,11 @@ message-weave/
} }
``` ```
- 三个 timeout 单位均为毫秒;默认值、取值范围与 `max_retries` 的硬上限以 `docs/reference.md` §6.1 为唯一权威(下表与上面的示例响应体只做示意,不重复数值)。`max_retries` 表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值对象;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`,完整替换加密保存的业务配置;Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。**校验是唯一写入闸门**:`validate_business_wire` 先跑,拒绝时返回真实的 `422`;Wire 合法则配置**一定**被持久化,之后才构建客户端并切换运行 worker。因此依赖连不通时返回 `200` + `{ "persisted": true, "runtime_applied": false, "warnings": [{"component", "step", "detail"}] }`,而不是 `503` —— 配置已保存、故障被如实上报,而不是被一个不可达的 JMAP 静默吞掉。`runtime_applied` 仅在 reload 提交成功时为 `true`;两种情况都返回 `x-business-config-revision` 头。响应从不回显配置或密钥。 - `GET /api/business-config` 回读已保存的配置供管理 SPA 预填表单:返回 `{ "configured", "revision", "values", "secrets_present" }`,其中 `values` 是 10 个非密钥字段的值,`secrets_present` 是 6 个密钥的存在性布尔(`bot_token`、`jmap_password`、`telegram_webhook_secret`、`reconcile_token`、`worker_token`、`llm_api_key`)——**从不返回密钥值本身**(`SAF-NO-SECRET-ECHO`)。尚未保存过时返回 `200` + `configured: false`、`revision: 0`、空 `values` 与全 false 的存在性布尔,SPA 因此只有一条代码路径,无需区分首次配置与修改配置。
- `POST /api/business-config/preflight` 对提交体执行完全相同的校验与客户端构建,返回逐组件结论,**不写入、不触碰运行 worker**:`{ "persisted": false, "validation": { "ok", "errors" }, "components": { "jmap": {...}, "llm": ... } }`。校验已失败时 `components` 为 `null`(被拒的 Wire 无需再探客户端);`llm_enabled`/`llm_allow_net` 为 false 时 `llm` 为 `null` 而非虚假的 `ok`。鉴权与管理会话门(`config_authorized`)一致,刻意不挂在 `DEBUG_TOKEN` 之后。 - `PUT /api/business-config` 接受 `BusinessConfigPatch`(完整或部分),**只替换提交体中出现的字段,其余沿用已存值**(`BusinessConfigPatch::apply`);Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。**校验是唯一写入闸门**,且校验对象是**合并后的**配置而非提交体本身:补丁从不被单独校验,已存的完整 Wire 也从不被丢弃。`validate_business_wire` 先跑,拒绝时返回真实的 `422`;合并结果合法则配置**一定**被持久化(持久化的是合并后的 Wire,不是请求体),之后才构建客户端并切换运行 worker。因此依赖连不通时返回 `200` + `{ "persisted": true, "runtime_applied": false, "warnings": [{"component", "step", "detail"}] }`,而不是 `503` —— 配置已保存、故障被如实上报,而不是被一个不可达的 JMAP 静默吞掉。`runtime_applied` 仅在 reload 提交成功时为 `true`;两种情况都返回 `x-business-config-revision` 头。响应从不回显配置或密钥。密钥是"提交才替换":**省略**的密钥沿用已存值,因此由 `GET` 回填的表单只需提交要改的字段;但后端不区分"未提交"与"提交空字符串",显式提交 `""` 会真的清空该密钥 —— "留空即保留"是 SPA 在提交前丢弃空白密钥字段实现的客户端契约,不是服务端保证。`chat_allowlist` 必须为非空列表,空数组会被 `validate_business_wire` 拒绝(422)。增量语义只在**已有配置**之后成立:没有任何已存配置时没有可回退的值,补丁必须自带全部必填字段,否则返回 `422`(从零创建的入口是 `POST /api/bootstrap`)。
- `POST /api/bootstrap` 与 PUT 共用上述契约:失败也持久化并回 `200` + warnings。 - `POST /api/business-config/preflight` 对提交体执行完全相同的校验与客户端构建,返回逐组件结论,**不写入、不触碰运行 worker**:`{ "persisted": false, "validation": { "ok", "errors" }, "components": { "jmap": {...}, "llm": ... } }`。与 PUT 不同,它的提交体必须是**完整的** `BusinessConfigWire`(不做补丁合并),所以 SPA 在预校验前要先把 `GET` 回读的值合并进本地表单。校验已失败时 `components` 为 `null`(被拒的 Wire 无需再探客户端);`llm_enabled`/`llm_allow_net` 为 false 时 `llm` 为 `null` 而非虚假的 `ok`。鉴权与管理会话门(`config_authorized`)一致,刻意不挂在 `DEBUG_TOKEN` 之后。
- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`(未授权)、`400`(JSON 无效)、`422`(Wire 校验失败)与 `503`(Redis 写入或 revision 读取失败);依赖客户端构建失败**不**返回错误状态码,而是 `200` + warnings(见上条)。其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 - `POST /api/bootstrap` 也接受**完整的** `BusinessConfigWire`(不做补丁合并),并共用上面「持久化与报错分离」的语义:校验失败是 `422`、持久化后构建失败仍是 `200` + warnings。因为不合并,它是唯一能**从零创建**业务配置的补丁路径(PUT 在已存配置缺失且补丁不全时返回 `422`,见上条)。
- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`(未授权)、`400`(JSON 无效)、`422`(补丁无法解析、已存配置不可解密、合并结果校验失败、首次保存时补丁不完整)、`500`(合并结果序列化失败)与 `503`(Redis 不可达、写入失败或 reload 提交失败);业务 GET 使用 `401`、`400`、`422`(已存配置不可解密)与 `503`(Redis 不可达)——注意 `Invalid` 是 422 不是 503,`Unreachable` 才是 503。**业务 PUT 没有 `409`**:`x-business-config-revision` 头只被发出、从不被校验,请求侧不读任何 header,两个并发管理会话保存业务配置会静默互相覆盖(SPA 目前只在状态栏显示 revision,不回传);`409 conflict` 只出现在 `refresh_business_config` 的锁竞争路径。依赖客户端构建失败**不**返回错误状态码,而是 `200` + warnings(见上条)。其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。
### 7.4 TLS(代码/依赖行为) ### 7.4 TLS(代码/依赖行为)
- TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。 - TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。

View File

@ -28,7 +28,7 @@
`docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。 `docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。
同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:331` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:380`。 同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:331` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:381`。
## 阻塞 ## 阻塞

View File

@ -185,10 +185,10 @@ field. `request_id` is always present.
`Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal `Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal
string `"30"`: string `"30"`:
- `error_response` (`notify.rs:370-378`, inserted at `:374`) sets it **only when its `retry` - `error_response` (`notify.rs:371-379`, inserted at `:375`) sets it **only when its `retry`
flag is true**. The readiness failure path (`notify.rs:210`) passes `retry = true`, so `/ready` flag is true**. The readiness failure path (`notify.rs:210`) passes `retry = true`, so `/ready`
is covered by that rule rather than by a special case. is covered by that rule rather than by a special case.
- `error_response_with_id` (`notify.rs:1368-1375`, inserted at `:1371`) sets it - `error_response_with_id` (`notify.rs:1464-1471`, inserted at `:1467`) sets it
**unconditionally**. It is used only for the push register/disable failures **unconditionally**. It is used only for the push register/disable failures
`push_state_unavailable` and `push_destroy_failed` (`notify.rs:1233`, `:1250`, `:1267`, `push_state_unavailable` and `push_destroy_failed` (`notify.rs:1233`, `:1250`, `:1267`,
`:1304`), which are always `503`. `:1304`), which are always `503`.
@ -217,7 +217,7 @@ only three of this table — `/healthz`, `/ready` and `/api/status` — and no b
| POST | `/worker` | `worker` | | POST | `/worker` | `worker` |
| GET, PUT | `/api/config` | `get_config` / `put_config` | | GET, PUT | `/api/config` | `get_config` / `put_config` |
| GET, PUT | `/api/enabled` | `get_enabled` / `put_enabled` | | GET, PUT | `/api/enabled` | `get_enabled` / `put_enabled` |
| PUT | `/api/business-config` | `put_business_config` | | GET, PUT | `/api/business-config` | `get_business_config` / `put_business_config` |
| POST | `/api/business-config/preflight` | `preflight_business_config` | | POST | `/api/business-config/preflight` | `preflight_business_config` |
| POST | `/api/bootstrap` | `bootstrap` | | POST | `/api/bootstrap` | `bootstrap` |
| POST | `/api/admin/session/revoke` | `revoke_admin_session` | | POST | `/api/admin/session/revoke` | `revoke_admin_session` |
@ -260,7 +260,7 @@ Response conventions:
`probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated. `probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated.
The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw 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. session URL unauthenticated would report not-ready forever and make ingress stop routing.
- `GET /api/status` (**always 200**, `notify.rs:214-223`) returns - `GET /api/status` (**always 200**, `notify.rs:215-224`) returns
`{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>"}`. `{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>"}`.
`ready` is `false` and `missing` lists the absent required keys (`REDIS_URL`, `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 `CONFIG_ENCRYPTION_KEY`) when a variable is absent — the route itself never errors, so it is
@ -280,7 +280,7 @@ Response conventions:
## 4. Gateway vs backend route matrix ## 4. Gateway vs backend route matrix
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES` > **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`
> (backends.js:9-25), 15 entries, alongside `ROUTE_METHODS` (index.js:45-61) > (backends.js:9-26), 16 entries, alongside `ROUTE_METHODS` (index.js:46-63)
> which fixes one method set per path. The worker entry point is `src/index.js` > which fixes one method set per path. The worker entry point is `src/index.js`
> (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`, > (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`,
> `C-NO-LONG-CONN`). > `C-NO-LONG-CONN`).
@ -290,10 +290,11 @@ all: a path missing from `SAFE_ROUTES` (backends.js:9-26) returns **404** (index
path with the wrong method returns **405** (index.js:86-89), and a missing or unparseable path with the wrong method returns **405** (index.js:86-89), and a missing or unparseable
backend pool returns **503** rather than passing the request through (index.js:91-99). backend pool returns **503** rather than passing the request through (index.js:91-99).
**Forwarded by the worker (15):** **Forwarded by the worker (16):**
`/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` · `/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` ·
`/api/business-config` · `/api/admin/session` · `/api/admin/session/revoke` · `/api/business-config` · `/api/business-config/preflight` · `/api/admin/session` ·
`/api/admin/session/revoke` ·
`/api/enabled` · `/api/enabled` ·
`/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` · `/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` ·
`/ready` `/ready`
@ -303,7 +304,7 @@ backend pool returns **503** rather than passing the request through (index.js:9
| Path | Why it is absent from the gateway | | Path | Why it is absent from the gateway |
|---|---| |---|---|
| `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path | | `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path |
| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd, `notify.rs:380`; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path | | `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd, `notify.rs:381`; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path |
| `GET /healthz` | Liveness is aggregated by the gateway itself | | `GET /healthz` | Liveness is aggregated by the gateway itself |
| `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly | | `/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 |
@ -359,14 +360,32 @@ is the single most likely cause of "the container is healthy but nothing happens
### 5.2 Redis-resident business configuration ### 5.2 Redis-resident business configuration
Written by the SPA through `PUT /api/business-config` (effective within 1 s); the first Read by the SPA through `GET /api/business-config` and written through
successful write creates the configuration, later writes hot-reload it. The same write is `PUT /api/business-config` (effective within 1 s); the first successful write creates the
also exposed as a one-shot `POST /api/bootstrap` against the backend origin for automation. configuration, later writes hot-reload it. `GET` returns four keys — `configured`, `revision`,
The process never reads these from the environment in normal operation. `values`, `secrets_present` — holding the 10 non-secret fields plus one presence boolean per
secret (`bot_token`, `jmap_password`, `telegram_webhook_secret`, `reconcile_token`,
`worker_token`, `llm_api_key`), never the secret values themselves (`SAF-NO-SECRET-ECHO`). With
nothing saved yet it returns `200`, `configured: false`, `revision: 0`, an empty `values` and
every flag false, so the SPA needs no special-case code path. The PUT body is a **partial
patch** that replaces only the fields it names and keeps the stored value for the rest (`apply`
in `config.rs`); a secret omitted from the patch keeps the stored secret, so prefilled form
values are safe to resubmit. An explicitly submitted empty string is stored as-is and really
clears the secret — "blank means unchanged" is a client-side contract, enforced by the SPA
dropping blank secret fields before it sends, not by the server.
**Persist-and-report, not persist-if-connectable.** Validation is the only gate on the write: The increment semantics only hold once a configuration exists. With nothing stored there is no
`validate_business_wire` runs first and a rejection is a genuine `422`. Once the wire is valid value to fall back to, so an incomplete patch is rejected with **422 `invalid_configuration`**
the configuration is *always* persisted, and only then are the clients built and the running — the first save has to be complete. `POST /api/bootstrap` against the backend origin is the
one-shot write path that creates a configuration from scratch, for automation. The process never
reads these from the environment in normal operation.
**Persist-and-report, not persist-if-connectable.** Validation is the only gate on the write,
and on a `PUT` it runs against the **merged** configuration, not the submitted body: the patch
is never validated on its own, and the full stored wire is never discarded (`apply` in
`config.rs`). `validate_business_wire` runs first and a rejection is a genuine `422`. Once
the merged wire is valid it is *always* persisted — the merged wire, not the request body — and
only then are the clients built and the running
worker swapped. A failed build therefore returns **200** with `persisted: true`, worker swapped. A failed build therefore returns **200** with `persisted: true`,
`runtime_applied: false` and a `warnings` array of `{component, step, detail}` objects, where `runtime_applied: false` and a `warnings` array of `{component, step, detail}` objects, where
`component` is `jmap` or `llm` and `step` is `connect`, `account`, `config` or `build`, rather `component` is `jmap` or `llm` and `step` is `connect`, `account`, `config` or `build`, rather
@ -374,12 +393,14 @@ than a 503. The configuration is saved and the outage is reported instead of hid
`runtime_applied` is `runtime_applied` is
`true` only when the reload was committed. This is deliberate: an unreachable JMAP must not `true` only when the reload was committed. This is deliberate: an unreachable JMAP must not
turn a valid configuration into a silent data loss. `PUT /api/business-config` and turn a valid configuration into a silent data loss. `PUT /api/business-config` and
`POST /api/bootstrap` both follow this contract; the revision header is still returned in both `POST /api/bootstrap` both follow this contract; the revision is returned in both cases as the
cases. `x-business-config-revision` header, and only `PUT /api/business-config` also returns it in the
response body, as `"revision"` (a `u64`), which the SPA surfaces in its status line. Bootstrap's
body carries no `revision` field.
`POST /api/business-config/preflight` (`notify.rs:808-871`) runs the identical validation and `POST /api/business-config/preflight` (`preflight_business_config`) runs the identical
client build against the submitted wire and returns the per-component verdict without writing validation and client build against the submitted wire and returns the per-component verdict
anything and without touching the running worker: without writing anything and without touching the running worker:
```json ```json
{ {
@ -421,7 +442,7 @@ The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as
**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:63) and held as `admin_token` (main.rs:92), it is checked in constant time by (main.rs:63) and held as `admin_token` (main.rs:92), it is checked in constant time by
`worker_authorized` (notify.rs:495; compare at notify.rs:1136) at the top of both `worker_authorized` (notify.rs:496; 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
issued by `/api/admin/session` is a freshly generated random 32-byte hex token issued by `/api/admin/session` is a freshly generated random 32-byte hex token

View File

@ -78,7 +78,7 @@
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:718`) | | `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`) | | `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`) | 无需配置开关,白名单恒生效 | | `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:380`),**不是后台循环**:全仓 `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/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | | `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:381`),**不是后台循环**:全仓 `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/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
--- ---

View File

@ -860,4 +860,41 @@ mod tests {
"Asia/Kolkata" "Asia/Kolkata"
); );
} }
/// Pure deserialisation check for `de_i64_from_number_or_string`, with no router, no store and
/// no other field present. This is the only place that can tell "the field's custom
/// deserializer refuses the string" apart from a later validation step, which is a real
/// distinction: the SPA submits `telegram_chat_id` as a JSON string on a partial submit while
/// the read-back model returns it as a JSON number.
#[test]
fn business_config_patch_reads_a_chat_id_from_a_number_or_a_numeric_string() {
let parse = |body: serde_json::Value| -> Result<BusinessConfigPatch, serde_json::Error> {
serde_json::from_value(body)
};
assert_eq!(
parse(serde_json::json!({"telegram_chat_id": -987654321i64}))
.unwrap()
.telegram_chat_id,
Some(-987654321)
);
assert_eq!(
parse(serde_json::json!({"telegram_chat_id": "-987654321"}))
.unwrap()
.telegram_chat_id,
Some(-987654321)
);
// A string that does not parse to an i64 is rejected, not truncated to zero.
assert!(parse(serde_json::json!({"telegram_chat_id": "not-a-chat-id"})).is_err());
assert!(parse(serde_json::json!({"telegram_chat_id": 1.5})).is_err());
assert!(parse(serde_json::json!({"telegram_chat_id": "true"})).is_err());
// Absent means "keep what is stored", which the patch merge relies on.
let missing = parse(serde_json::json!({})).unwrap();
assert!(missing.telegram_chat_id.is_none());
assert!(parse(serde_json::json!({"timezone": "Etc/UTC"}))
.unwrap()
.telegram_chat_id
.is_none());
}
} }

View File

@ -779,6 +779,7 @@ async fn put_business_config(
[("x-business-config-revision", revision.to_string())], [("x-business-config-revision", revision.to_string())],
Json(json!({ Json(json!({
"persisted": true, "persisted": true,
"revision": revision,
"runtime_applied": runtime_applied, "runtime_applied": runtime_applied,
"warnings": warnings, "warnings": warnings,
})), })),
@ -1729,7 +1730,10 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
mod tests { mod tests {
use super::*; use super::*;
use crate::state::MemoryState; use crate::state::MemoryState;
use axum::{body::Body, http::Request}; use axum::{
body::Body,
http::{self, Request},
};
use tower::ServiceExt; use tower::ServiceExt;
fn test_router() -> Router { fn test_router() -> Router {
@ -2304,6 +2308,401 @@ mod tests {
assert_preflight_wrote_nothing(app).await; assert_preflight_wrote_nothing(app).await;
} }
/// Router wired for admin calls, plus a handle on the same state so a test can seed or inspect
/// the stored configuration without another HTTP round trip.
fn business_config_router() -> (Router, MemoryState) {
let state = MemoryState::enabled_for_tests();
let router = router_with_state(
AuthSecrets {
reconcile_token: SecretString::new("reconcile-secret".into()),
telegram_webhook_secret: SecretString::new("telegram-secret".into()),
},
SecretString::new("worker-secret".into()),
state.clone(),
HashSet::new(),
);
(router, state)
}
/// A full, valid configuration as it lands on the wire. The JMAP URL is refused immediately,
/// so a save that reaches `build_worker_report` fails there instead of waiting on a timeout.
fn stored_business_wire_value() -> serde_json::Value {
json!({
"bot_token": "bot-secret-value",
"telegram_chat_id": -5260770881i64,
"chat_allowlist": [1, 2],
"telegram_webhook_secret": "hook-secret-value",
"jmap_session_url": "https://mail.example.invalid/session",
"account_id": "account-7",
"jmap_username": "user@example.invalid",
"jmap_password": "jmap-secret-value",
"llm_enabled": false,
"llm_allow_net": false,
"llm_api_key": null,
"llm_base_url": null,
"llm_model": null,
"reconcile_token": "reconcile-secret-value",
"worker_token": "worker-secret-value",
"timezone": "Etc/UTC",
})
}
async fn seed_business_config(state: &MemoryState, wire: serde_json::Value) {
state.set_business_config(&wire).await.unwrap();
}
/// The stored configuration with a JMAP URL that fails the instant it is dialed, for tests
/// whose save proceeds far enough to reach `build_worker_report`.
fn immediate_fail_business_wire_value() -> serde_json::Value {
let mut wire = stored_business_wire_value();
wire["jmap_session_url"] = json!("https://127.0.0.1:1");
wire
}
#[tokio::test]
async fn business_config_get_without_session_returns_401_without_any_field() {
let (router, _) = business_config_router();
let response = router
.oneshot(
Request::builder()
.uri("/api/business-config")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = json_body(response).await;
assert_eq!(body["error"], json!("unauthorized"));
// `request_id` is minted per request, so assert its shape rather than its value.
let request_id = body["request_id"].as_str().unwrap_or("");
assert_eq!(request_id.len(), 32);
assert!(request_id.chars().all(|c| c.is_ascii_hexdigit()));
assert!(body.get("values").is_none());
assert!(body.get("secrets_present").is_none());
assert!(body.get("configured").is_none());
assert_eq!(body.as_object().unwrap().len(), 2);
}
#[tokio::test]
async fn business_config_get_echoes_plaintext_fields_and_secrets_as_presence_only() {
let (router, state) = business_config_router();
let mut wire = stored_business_wire_value();
// Populate the whole LLM block, so its plaintext fields and its secret flag are both
// exercised; this is the fully valid shape a real save can produce.
wire["timezone"] = json!("Asia/Tokyo");
wire["llm_enabled"] = json!(true);
wire["llm_base_url"] = json!("https://llm.example.invalid/v1");
wire["llm_model"] = json!("local-model");
wire["llm_api_key"] = json!("llm-secret-value");
seed_business_config(&state, wire).await;
let response = router
.oneshot(
Request::builder()
.method("GET")
.uri("/api/business-config")
.header(http::header::AUTHORIZATION, "Bearer worker-secret")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
assert_eq!(body["configured"], json!(true));
assert_eq!(body["revision"], json!(1));
let values = &body["values"];
assert_eq!(values["timezone"], json!("Asia/Tokyo"));
assert_eq!(
values["jmap_session_url"],
json!("https://mail.example.invalid/session")
);
assert_eq!(values["jmap_username"], json!("user@example.invalid"));
assert_eq!(values["account_id"], json!("account-7"));
assert_eq!(values["telegram_chat_id"], json!(-5260770881i64));
assert_eq!(values["chat_allowlist"], json!([1, 2]));
assert_eq!(values["llm_enabled"], json!(true));
assert_eq!(values["llm_allow_net"], json!(false));
assert_eq!(
values["llm_base_url"],
json!("https://llm.example.invalid/v1")
);
assert_eq!(values["llm_model"], json!("local-model"));
assert_eq!(body["secrets_present"]["bot_token"], json!(true));
assert_eq!(body["secrets_present"]["jmap_password"], json!(true));
assert_eq!(
body["secrets_present"]["telegram_webhook_secret"],
json!(true)
);
assert_eq!(body["secrets_present"]["reconcile_token"], json!(true));
assert_eq!(body["secrets_present"]["worker_token"], json!(true));
assert_eq!(body["secrets_present"]["llm_api_key"], json!(true));
// SAF-NO-SECRET-ECHO: presence is allowed, the credential value never is.
let body_text = serde_json::to_string(&body).unwrap();
for secret in [
"bot-secret-value",
"jmap-secret-value",
"hook-secret-value",
"reconcile-secret-value",
"worker-secret-value",
"llm-secret-value",
] {
assert!(
!body_text.contains(secret),
"{secret} leaked into the response"
);
}
}
#[tokio::test]
async fn business_config_get_without_any_stored_config_reports_absent_instead_of_404() {
let (router, _) = business_config_router();
let response = router
.oneshot(
Request::builder()
.method("GET")
.uri("/api/business-config")
.header(http::header::AUTHORIZATION, "Bearer worker-secret")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
assert_eq!(body["configured"], json!(false));
assert_eq!(body["revision"], json!(0));
assert_eq!(body["values"]["chat_allowlist"], json!([]));
assert_eq!(body["values"]["timezone"], json!("Asia/Shanghai"));
assert_eq!(body["values"]["jmap_session_url"], json!(""));
assert!(body["values"]["account_id"].is_null());
assert!(body["values"]["llm_base_url"].is_null());
assert!(body["values"]["llm_model"].is_null());
assert_eq!(body["values"]["llm_enabled"], json!(false));
for key in [
"bot_token",
"jmap_password",
"telegram_webhook_secret",
"reconcile_token",
"worker_token",
"llm_api_key",
] {
assert_eq!(body["secrets_present"][key], json!(false), "{key}");
}
}
#[tokio::test]
async fn business_config_first_save_rejects_a_patch_that_is_missing_fields() {
let (router, state) = business_config_router();
let response = router
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/business-config")
.header(http::header::AUTHORIZATION, "Bearer worker-secret")
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&json!({"timezone": "Asia/Tokyo"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = json_body(response).await;
assert_eq!(body["error"], json!("invalid_configuration"));
// The partial submit must not have been recorded as a one-field configuration.
assert!(state.get_business_config().await.unwrap().is_none());
}
#[tokio::test]
async fn business_config_partial_submit_replaces_only_the_fields_it_carries() {
let (router, state) = business_config_router();
seed_business_config(&state, immediate_fail_business_wire_value()).await;
let response = router
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/business-config")
.header(http::header::AUTHORIZATION, "Bearer worker-secret")
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&json!({"timezone": "Asia/Tokyo"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let saved = json_body(response).await;
assert_eq!(saved["persisted"], json!(true));
assert_eq!(saved["revision"], json!(2));
// Read it back over the wire rather than from the saved response: this is what the SPA does.
let response = router
.oneshot(
Request::builder()
.method("GET")
.uri("/api/business-config")
.header(http::header::AUTHORIZATION, "Bearer worker-secret")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
assert_eq!(body["configured"], json!(true));
assert_eq!(body["revision"], json!(2));
let values = &body["values"];
assert_eq!(values["timezone"], json!("Asia/Tokyo"));
// Every field the patch did not carry must still hold its stored value.
assert_eq!(values["jmap_session_url"], json!("https://127.0.0.1:1"));
assert_eq!(values["jmap_username"], json!("user@example.invalid"));
assert_eq!(values["account_id"], json!("account-7"));
assert_eq!(values["telegram_chat_id"], json!(-5260770881i64));
assert_eq!(values["chat_allowlist"], json!([1, 2]));
assert_eq!(values["llm_enabled"], json!(false));
assert_eq!(values["llm_allow_net"], json!(false));
assert!(values["llm_base_url"].is_null());
assert!(values["llm_model"].is_null());
for key in [
"bot_token",
"jmap_password",
"telegram_webhook_secret",
"reconcile_token",
"worker_token",
] {
assert_eq!(body["secrets_present"][key], json!(true), "{key}");
}
assert_eq!(body["secrets_present"]["llm_api_key"], json!(false));
}
#[tokio::test]
async fn business_config_partial_submit_cannot_skip_validation_of_the_merged_result() {
let (router, state) = business_config_router();
seed_business_config(&state, stored_business_wire_value()).await;
let response = router
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/business-config")
.header(http::header::AUTHORIZATION, "Bearer worker-secret")
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&json!({"timezone": "Europe/London"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = json_body(response).await;
assert_eq!(body["error"], json!("invalid_configuration"));
// Rejected submissions leave the stored configuration and its revision untouched.
let stored = state.get_business_config().await.unwrap().unwrap();
assert_eq!(stored["timezone"], json!("Etc/UTC"));
assert_eq!(state.business_config_revision().await.unwrap(), 1);
}
#[tokio::test]
async fn business_config_accepts_a_chat_id_submitted_as_a_number_or_a_string() {
let (router, state) = business_config_router();
seed_business_config(&state, immediate_fail_business_wire_value()).await;
let submitted = json!({"telegram_chat_id": "-987654321"});
let response = router
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/business-config")
.header(http::header::AUTHORIZATION, "Bearer worker-secret")
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&submitted).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["persisted"], json!(true));
let stored = state.get_business_config().await.unwrap().unwrap();
assert_eq!(stored["telegram_chat_id"], json!(-987654321i64));
// A chat id that parses to no integer is still rejected rather than coerced.
let response = router
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/business-config")
.header(http::header::AUTHORIZATION, "Bearer worker-secret")
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&json!({"telegram_chat_id": "not-a-chat-id"}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
json_body(response).await["error"],
json!("invalid_configuration")
);
assert_eq!(
state.get_business_config().await.unwrap().unwrap()["telegram_chat_id"],
json!(-987654321i64)
);
}
#[tokio::test]
async fn business_config_put_rejects_a_field_the_patch_type_does_not_declare() {
let (router, state) = business_config_router();
seed_business_config(&state, stored_business_wire_value()).await;
let response = router
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/business-config")
.header(http::header::AUTHORIZATION, "Bearer worker-secret")
.header(http::header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&json!({"timezone": "Etc/UTC", "typo_field": 1}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
json_body(response).await["error"],
json!("invalid_configuration")
);
assert_eq!(state.business_config_revision().await.unwrap(), 1);
}
#[tokio::test] #[tokio::test]
async fn business_config_preflight_reports_component_failure_and_writes_nothing() { async fn business_config_preflight_reports_component_failure_and_writes_nothing() {
let app = test_router(); let app = test_router();