From b764d6dc30e41d27801004981920bbbfc3b89e93 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Wed, 30 Sep 2026 02:56:15 +0800 Subject: [PATCH] feat: reject stale business-config submits with a 409 revision becomes a request-body control field on BusinessConfigPatch. It is never a setting: apply() and is_complete() ignore it, so the stored wire never gains a revision key, and a PUT body that omits it keeps last-write-wins for callers that never read the revision back. The SPA records the revision it read, echoes it on every partial submit, and on a 409 re-reads the stored values into the form and tells the operator to recheck what they changed. Without that reload every retry would 409 forever; if the reload itself fails the SPA falls back to last-write-wins rather than trapping the operator. The control field is excluded from the empty-patch check so echoing it never counts as a change. The compare-then-write is deliberately not atomic. Without a Lua script an optimistic lock still has a residual window in which two requests racing inside the same tick both pass and the later one wins; the window stays small because a patch carries only the fields the operator actually changed. notify.rs gained 18 lines before line 700, so its anchors in deployment.md, opengaps.md and retired.md shifted by +18. Every one was checked against the real line content, since the anchor audit only flags blank targets. Gates: rust fmt/clippy clean, cargo test 87 passed 2 ignored, node --test web/config.test.mjs 18 passed, docs_check/run_all.sh 6/6. --- docs/deployment.md | 18 +++++----- docs/design.md | 4 +-- docs/opengaps.md | 10 +++--- docs/reference.md | 6 ++-- docs/retired.md | 4 +-- src/config.rs | 18 ++++++++-- src/notify.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++ web/config.js | 39 ++++++++++++++++++++-- web/config.test.mjs | 36 ++++++++++++++++++++ 9 files changed, 190 insertions(+), 26 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index abdf7cf..6a3db4f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -74,11 +74,11 @@ `hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在 `src/main.rs:217-264`。 -缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1724`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 +缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1742`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 **鉴权** -7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:496`),因此令牌是**常数时间比较**(`src/notify.rs:1512`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 +7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:496`),因此令牌是**常数时间比较**(`src/notify.rs:1530`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 **7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) @@ -99,7 +99,7 @@ **三条约束,部署时务必确认** - **绝不回显凭据值**:`debug_config`(`src/debug.rs:94`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。 -- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1518`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:233-236`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:241-246`)。 +- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1536`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:233-236`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:241-246`)。 - **不在 Worker 白名单内,只能直连 origin**:网关的 16 条安全路由(`cloudflare-worker/src/backends.js:9-26`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:82-85`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 > **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性 @@ -181,13 +181,13 @@ curl -fsS -X POST "https://<你的平台URL>/api/push/register" \ ``` 注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码, -后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1259`)。 +后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1277`)。 - `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request` - (`src/notify.rs:1271-1273`)。 + (`src/notify.rs:1289-1291`)。 - 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。 - 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁 - (`src/notify.rs:1283` / `src/notify.rs:1286`),撞锁返回 409 `conflict`。 + (`src/notify.rs:1301` / `src/notify.rs:1304`),撞锁返回 409 `conflict`。 - 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。 反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。 - 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart @@ -381,7 +381,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 ### 6.2 Redis Streams / worker(MOD-STREAMS) - Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞 -- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:381`,路由 `src/notify.rs:1705`)。全代码库仅两处 `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:1723`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。 - 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:398`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:479`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。 - 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底) @@ -394,8 +394,8 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 #### 6.3.1 用户侧调度示例(copy-paste 可用) -端点有两个:`POST /reconcile`(路由 `src/notify.rs:1704`)与 `POST /worker`(路由 -`src/notify.rs:1705`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、 +端点有两个:`POST /reconcile`(路由 `src/notify.rs:1722`)与 `POST /worker`(路由 +`src/notify.rs:1723`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、 再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用 `read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:411`。 **只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。 diff --git a/docs/design.md b/docs/design.md index 3219e01..c02fed5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -432,10 +432,10 @@ message-weave/ ``` - 三个 timeout 单位均为毫秒;默认值、取值范围与 `max_retries` 的硬上限以 `docs/reference.md` §6.1 为唯一权威(下表与上面的示例响应体只做示意,不重复数值)。`max_retries` 表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值对象;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。 - `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 因此只有一条代码路径,无需区分首次配置与修改配置。 -- `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`)。 +- `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` 头;请求体可带 `revision`(即 `GET` 返回的那个 `u64`)作为控制字段——它不参与 `apply`、不进 Wire、不作为已存设置,只用于判断本次提交是否基于最新版本,`409` 语义见下条。响应从不回显配置或密钥。密钥是"提交才替换":**省略**的密钥沿用已存值,因此由 `GET` 回填的表单只需提交要改的字段;但后端不区分"未提交"与"提交空字符串",显式提交 `""` 会真的清空该密钥 —— "留空即保留"是 SPA 在提交前丢弃空白密钥字段实现的客户端契约,不是服务端保证。`chat_allowlist` 必须为非空列表,空数组会被 `validate_business_wire` 拒绝(422)。增量语义只在**已有配置**之后成立:没有任何已存配置时没有可回退的值,补丁必须自带全部必填字段,否则返回 `422`(从零创建的入口是 `POST /api/bootstrap`)。 - `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` 之后。 - `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 和模型名。 +- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`(未授权)、`400`(JSON 无效)、`422`(补丁无法解析、已存配置不可解密、合并结果校验失败、首次保存时补丁不完整)、`409`(提交的 `revision` 已过期)、`500`(合并结果序列化失败)与 `503`(Redis 不可达、写入失败或 reload 提交失败);业务 GET 使用 `401`、`400`、`422`(已存配置不可解密)与 `503`(Redis 不可达)——注意 `Invalid` 是 422 不是 503,`Unreachable` 才是 503。**业务 PUT 有 `409`**:`revision` 是**请求体里的控制字段**(不是 header)——SPA 读配置时记下 `revision`,之后的每次增量提交都带回同一个值;已存 revision 与请求 revision 不等时返回 `409 conflict`,既不写入也不自增 revision,SPA 收到 `409` 会重新读回已存值再让操作者改,所以旧页面不会静默覆盖新的保存。请求体不带 `revision` 时保持 last-write-wins,兼容从不读 revision 的调用方。该比较是**先比较后写入**、非原子:两个请求若在同一瞬间竞争,两个都会通过、后到的覆盖先到的;没有 Lua 脚本时乐观锁的代价就是这个残留窗口,而补丁只带操作者实际改过的字段,窗口影响有限。`409 conflict` 的其余出现处是三把锁的竞争(`reconcile` 与 `register_push` / `register_worker_token` 的 `acquire_lock` 返回 false),与业务配置写入无关。依赖客户端构建失败**不**返回错误状态码,而是 `200` + warnings(见上条)。其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 ### 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` 为准。 diff --git a/docs/opengaps.md b/docs/opengaps.md index dde8271..15b51e9 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -18,7 +18,7 @@ 上一轮真机联调已经关掉了两条阻塞: - **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。真实 Telegram 流量已驱动过 `/webhook/tg`。 -- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1236`(JMAP 回调入队)与 `src/worker.rs:394`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。 +- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1254`(JMAP 回调入队)与 `src/worker.rs:394`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。 两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。 @@ -52,14 +52,14 @@ | --- | --- | --- | --- | | `lock:reconcile` | 对账锁 | 300s | `src/notify.rs:290` | | `lock:reconcile` | 对账锁心跳续租 | 90s | `src/notify.rs:303` | -| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1283` | -| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1192` | +| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1301` | +| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1210` | | `dedup:tg:{update_id}` | Telegram 更新去重 | 86_400s | `src/notify.rs:250` | -| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(回调入队) | 86_400s | `src/notify.rs:1231` | +| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(回调入队) | 86_400s | `src/notify.rs:1249` | | `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(对账增量入队) | 86_400s | `src/worker.rs:394` | | `delivery:inflight:{stream}:{message.id}` | 投递在途守卫 | 60s | `src/notify.rs:447` | | `delivery:committed:{stream}:{message.id}` | 投递幂等 | 604_800s | `src/notify.rs:459` | -| `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1408` | +| `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1426` | **B. 内嵌 Lua 脚本 — 1 个写入点 / 1 个值** diff --git a/docs/reference.md b/docs/reference.md index 9c3020c..055517d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -398,8 +398,10 @@ than a 503. The configuration is saved and the outage is reported instead of hid turn a valid configuration into a silent data loss. `PUT /api/business-config` and `POST /api/bootstrap` both follow this contract; the revision is returned in both cases as the `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. +response body, as `"revision"` (a `u64`). The SPA surfaces it in its status line and sends it +back in the request body as the `revision` control field, which the backend compares against the +stored revision and answers `409 conflict` on a mismatch. Bootstrap's +body carries no `revision` field, and a `PUT` body that omits it keeps last-write-wins. `POST /api/business-config/preflight` (`preflight_business_config`) runs the identical validation and client build against the submitted wire and returns the per-component verdict diff --git a/docs/retired.md b/docs/retired.md index 252ad12..e38b07d 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -75,8 +75,8 @@ |---|---|---|---| | 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 | | `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 | -| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1094`) | -| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1274-1278`) | +| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1112`) | +| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1292-1296`) | | `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: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`「已知边界」同句已删 | diff --git a/src/config.rs b/src/config.rs index 65a7f19..3701b8b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -224,9 +224,13 @@ impl From for BusinessConfigWire { /// does not name keeps the stored value, so an operator can change one thing without retyping /// the whole configuration. /// -/// The field list mirrors `BusinessConfigWire` field for field — `apply` builds a wire from these, -/// so adding a field to the wire without adding it here fails to compile instead of silently -/// freezing the new setting at its stored value. Secrets are replace-only: `validate_business_wire` +/// Every `BusinessConfigWire` field has a matching option here (`account_id` is nested, see +/// below), so adding a wire field later requires adding its option here too. `apply` merges each +/// one into the stored wire, so a field that is missing here silently keeps its stored value +/// forever and nothing in the type system reports it. `account_id` is nested because `null` must +/// mean "clear it", which a plain `Option` cannot express. +/// +/// Secrets are replace-only: `validate_business_wire` /// rejects a blank secret, so there is deliberately no way to clear one and no way for the SPA to /// learn what it is holding. #[derive(Debug, serde::Deserialize)] @@ -264,6 +268,14 @@ pub(crate) struct BusinessConfigPatch { pub reconcile_token: Option, #[serde(default)] pub worker_token: Option, + + /// Optional: the revision the client read before editing. When present and different from the + /// stored revision, `put_business_config` answers `409 conflict` instead of silently + /// overwriting what another tab stored meanwhile. It is a control field, never a setting: it + /// never reaches `BusinessConfigWire`, so `is_complete` and `apply` ignore it, and omitting it + /// keeps last-write-wins for callers that never read the revision back. + #[serde(default)] + pub revision: Option, } /// `telegram_chat_id` arrives as a JSON number on a full submit but as a numeric string on a diff --git a/src/notify.rs b/src/notify.rs index 3252c3e..ca5a589 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -692,6 +692,24 @@ async fn put_business_config( false, ); }; + // The client echoes back the revision it read before editing. A mismatch means another tab + // stored a newer configuration in the meantime; merging this patch would silently revert the + // fields it names, so refuse and make the operator re-read and retry. The compare-then-write + // is not atomic: two requests that race inside the same tick both pass and the later one + // wins. That residual is what an optimistic lock without a Lua script costs, and it stays + // small because a patch carries only the fields the operator actually changed. + if let Some(expected) = patch.revision { + match app.state.business_config_revision().await { + Ok(current) if current != expected => { + return error_response(StatusCode::CONFLICT, "conflict", false); + } + Ok(_) => {} + Err(_) => { + return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true) + } + } + } + // Merge first, then validate the result. A patch is never validated on its own: a // one-field edit must be rejected when the configuration it becomes is invalid. let merged = match read_stored_business_config(&app.state).await { @@ -2592,6 +2610,69 @@ mod tests { assert_eq!(body["secrets_present"]["llm_api_key"], json!(false)); } + /// A patch carries the revision the client read before editing. The first write moves it, so a + /// second patch still carrying the old revision lands nowhere and a stale tab cannot silently + /// revert a field another tab just saved. + #[tokio::test] + async fn business_config_put_rejects_a_patch_carried_by_a_stale_revision() { + 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", + "revision": 1, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let saved = json_body(response).await; + assert_eq!(saved["revision"], json!(2)); + + // The second tab still holds revision 1. + 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", + "revision": 1, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert!(response.headers().get(http::header::RETRY_AFTER).is_none()); + let body = json_body(response).await; + assert_eq!(body["error"], json!("conflict")); + + // Neither the configuration nor the revision moved. + assert_eq!(state.business_config_revision().await.unwrap(), 2); + let stored = state.get_business_config().await.unwrap().unwrap(); + assert_eq!(stored["timezone"], json!("Asia/Tokyo")); + // The control field never becomes a stored setting. + assert!(stored.get("revision").is_none()); + } + #[tokio::test] async fn business_config_partial_submit_cannot_skip_validation_of_the_merged_result() { let (router, state) = business_config_router(); diff --git a/web/config.js b/web/config.js index 528fd0c..73bba55 100644 --- a/web/config.js +++ b/web/config.js @@ -97,6 +97,7 @@ "business.saved": "Business configuration saved and new configuration hot-reloaded.", "business.savedToast": "Business configuration saved and hot-reloaded. Secret inputs cleared; re-fill on the next full replacement.", "business.dirty": "The form has unsubmitted content; only the fields you changed will be submitted.", + "business.conflict": "A newer configuration was stored meanwhile; the saved values were re-read.", "business.unsaved": "No save-success confirmation received; check the service status.", "business.submittingToast": "Submitting the full business configuration; the backend builds clients first, then saves and swaps the running instance…", "business.preflight": "Test connection", @@ -197,6 +198,7 @@ "berr.400": "The backend could not parse the submitted JSON (HTTP 400); check the fields and retry.", "berr.422": "Business configuration validation failed (HTTP 422). Check required fields, HTTPS URLs, LLM settings and the chat ID allowlist. The server does not return secrets or error details.", "berr.503": "The write could not be confirmed (HTTP 503): Redis could not persist the configuration or the hot reload could not be committed. A JMAP/LLM connection failure does not produce this error — those save anyway and are reported as warnings. The page keeps this input for review and retry.", + "berr.409": "A newer configuration was stored after this page read it (HTTP 409). Your last submit was discarded, and the stored values have been re-read into this form, so the input you were typing may now show the saved value instead. Review the fields you changed and submit again.", "berr.status": "Business configuration request failed (HTTP {status}). Retry shortly.", "berr.fail": "{message} The request result may be unconfirmed; after checking, re-submit the complete configuration.", "berr.failDefault": "Business configuration request failed.", @@ -294,6 +296,7 @@ "business.saved": "业务配置已保存,新配置已热加载。", "business.savedToast": "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。", "business.dirty": "表单包含未提交内容;只提交你修改过的字段。", + "business.conflict": "期间有人保存了更新的配置;已重新读取已保存的值。", "business.unsaved": "未收到保存成功确认;请检查服务状态。", "business.submittingToast": "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…", "business.preflight": "测试连接", @@ -394,6 +397,7 @@ "berr.400": "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。", "berr.422": "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。", "berr.503": "写入未能确认(HTTP 503):Redis 未能持久化配置,或热加载提交失败。JMAP/LLM 连接失败不会返回此错误——它们会照常保存并以下发告警的形式报告。页面保留本次输入供检查和重试。", + "berr.409": "本页读取配置之后,又有人保存了更新的版本(HTTP 409)。你刚才的提交已被丢弃,已把已保存的值重新读回表单,所以原本正在输入的内容现在可能显示为已保存的值。请核对修改过的字段后重新提交。", "berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。", "berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。", "berr.failDefault": "业务配置请求失败。", @@ -600,6 +604,9 @@ // (including nulls); `businessSecretPresence` is the `secrets_present` object. // Secrets themselves are never stored here — only their presence booleans. let businessBaseline = null; + // Revision the baseline was read at. Echoed back on a partial submit so the + // backend can reject this page as stale instead of overwriting a newer save. + let businessRevision = null; let businessSecretPresence = null; class ApiError extends Error { @@ -1017,6 +1024,7 @@ function businessErrorMessage(error) { if (error.status === 401) return t("berr.401"); if (error.status === 400) return t("berr.400"); + if (error.status === 409) return t("berr.409"); if (error.status === 422) return t("berr.422"); if (error.status === 503) return t("berr.503"); if (error.status !== undefined) return t("berr.status", { status: error.status }); @@ -1067,6 +1075,7 @@ function resetBusinessReadback() { businessBaseline = null; + businessRevision = null; businessSecretPresence = null; renderSecretMarkers(); } @@ -1089,6 +1098,9 @@ function applyBusinessReadback(readback) { businessBaseline = isRecord(readback && readback.values) ? readback.values : {}; + businessRevision = Number.isInteger(readback && readback.revision) + ? readback.revision + : null; businessSecretPresence = isRecord(readback && readback.secrets_present) ? readback.secrets_present : {}; @@ -1143,6 +1155,9 @@ clearCustomValidity(businessForm); const baseline = isRecord(businessBaseline) ? businessBaseline : {}; const patch = {}; + // Optimistic lock: which saved version this page read. It is a control + // field, so the empty-patch check below deliberately ignores it. + if (Number.isInteger(businessRevision)) patch.revision = businessRevision; try { for (const field of SECRET_FIELDS) { const input = document.getElementById(field.id); @@ -1400,7 +1415,10 @@ // fields are sent. Without one, every field is required as before. const patch = businessBaseline === null ? readBusinessConfig() : businessPatchFromForm(); if (!patch) return; - if (businessBaseline !== null && Object.keys(patch).length === 0) { + // `revision` is a control field, never a setting, so it never makes the + // page count as changed. + const settingKeys = Object.keys(patch).filter((key) => key !== "revision"); + if (businessBaseline !== null && settingKeys.length === 0) { businessSaveState.textContent = t("business.noChange"); businessSaveState.classList.remove("is-dirty"); return; @@ -1440,8 +1458,22 @@ expireSession(t("session.invalidBusiness")); return; } - businessSaveState.textContent = t("business.unsaved"); - showNotice("error", businessErrorMessage(error)); + if (error.status === 409) { + businessSaveState.textContent = t("business.conflict"); + showNotice("error", businessErrorMessage(error)); + // Re-read the stored configuration. Without this the page would keep + // submitting against a revision the backend no longer has, so every + // retry would fail the same way. + try { + await loadBusinessConfig({ toast: false }); + } catch (reloadError) { + businessRevision = null; + void reloadError; + } + } else { + businessSaveState.textContent = t("business.unsaved"); + showNotice("error", businessErrorMessage(error)); + } } finally { if (adminSession) setBusy(""); } @@ -1509,6 +1541,7 @@ renderSecretMarkers, resetBusinessReadback, get businessBaseline() { return businessBaseline; }, + get businessRevision() { return businessRevision; }, get businessSecretPresence() { return businessSecretPresence; }, fields: { secrets: SECRET_FIELDS, text: PLAIN_TEXT_FIELDS, bool: PLAIN_BOOL_FIELDS }, }; diff --git a/web/config.test.mjs b/web/config.test.mjs index 67e38a7..aba4157 100644 --- a/web/config.test.mjs +++ b/web/config.test.mjs @@ -523,3 +523,39 @@ test("flipping a boolean enters the patch and resetBusinessReadback clears the b assert.equal(elements.get("secret-marker-bot_token").textContent.includes("Not set"), true); }); + +test("a partial submit carries the revision the baseline was read at", async () => { + const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const mw = pageWindow.__mw; + mw.applyBusinessReadback({ + revision: 7, + values: { + jmap_session_url: "https://mail.example.com/jmap", + jmap_username: "bot@example.com", + timezone: "Asia/Tokyo", + chat_allowlist: ["123456789"], + telegram_chat_id: "123456789", + llm_enabled: false, + llm_allow_net: false, + }, + secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false }, + }); + + // The control field is carried on every partial submit. + assert.equal(mw.businessRevision, 7); + assertShape(mw.businessPatchFromForm(), { revision: 7 }); + + // but it never makes the page count as having changed settings, so the + // no-op guard still blocks a submit that only echoes the stored values. + elements.get("timezone").value = "Etc/UTC"; + assertShape(mw.businessPatchFromForm(), { revision: 7, timezone: "Etc/UTC" }); + + // A readback without a revision does not invent one, so the submit stays on + // last-write-wins instead of locking on a value the backend never sent. + mw.applyBusinessReadback({ values: {}, secrets_present: {} }); + assert.equal(mw.businessRevision, null); + assert.equal(mw.businessPatchFromForm().revision, undefined); + + mw.resetBusinessReadback(); + assert.equal(mw.businessRevision, null); +});