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.
This commit is contained in:
anonsaber 2026-09-30 02:56:15 +08:00
parent 33a0a8b8ba
commit b764d6dc30
9 changed files with 190 additions and 26 deletions

View File

@ -74,11 +74,11 @@
`hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在 `hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在
`src/main.rs:217-264`。 `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_TOKEN>`,统一走 `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_TOKEN>`,统一走 `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`,路径为字面量,无常量抽取) **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_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 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 - **不在 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` 与一次性 > **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性
@ -181,13 +181,13 @@ curl -fsS -X POST "https://<你的平台URL>/api/push/register" \
``` ```
注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码, 注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码,
后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1259`)。 后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1277`)。
- `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request` - `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request`
(`src/notify.rs:1271-1273`)。 (`src/notify.rs:1289-1291`)。
- 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。 - 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。
- 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁 - 注册期间持 `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 回来验证。 - 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。
反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。 反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。
- 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart - 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart
@ -381,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: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 内。 - 单次 `/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 二次兜底) - 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底)
@ -394,8 +394,8 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
#### 6.3.1 用户侧调度示例(copy-paste 可用) #### 6.3.1 用户侧调度示例(copy-paste 可用)
端点有两个:`POST /reconcile`(路由 `src/notify.rs:1704`)与 `POST /worker`(路由 端点有两个:`POST /reconcile`(路由 `src/notify.rs:1722`)与 `POST /worker`(路由
`src/notify.rs:1705`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、 `src/notify.rs:1723`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、
再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用 再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用
`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:411`。 `read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:411`。
**只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。 **只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。

View File

@ -432,10 +432,10 @@ 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`)。
- `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 因此只有一条代码路径,无需区分首次配置与修改配置。 - `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/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`,见上条)。 - `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(代码/依赖行为) ### 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

@ -18,7 +18,7 @@
上一轮真机联调已经关掉了两条阻塞: 上一轮真机联调已经关掉了两条阻塞:
- **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。真实 Telegram 流量已驱动过 `/webhook/tg`。 - **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,本文件不再跟踪它们。 两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。
@ -52,14 +52,14 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `lock:reconcile` | 对账锁 | 300s | `src/notify.rs:290` | | `lock:reconcile` | 对账锁 | 300s | `src/notify.rs:290` |
| `lock:reconcile` | 对账锁心跳续租 | 90s | `src/notify.rs:303` | | `lock:reconcile` | 对账锁心跳续租 | 90s | `src/notify.rs:303` |
| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1283` | | `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1301` |
| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1192` | | `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1210` |
| `dedup:tg:{update_id}` | Telegram 更新去重 | 86_400s | `src/notify.rs:250` | | `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` | | `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(对账增量入队) | 86_400s | `src/worker.rs:394` |
| `delivery:inflight:{stream}:{message.id}` | 投递在途守卫 | 60s | `src/notify.rs:447` | | `delivery:inflight:{stream}:{message.id}` | 投递在途守卫 | 60s | `src/notify.rs:447` |
| `delivery:committed:{stream}:{message.id}` | 投递幂等 | 604_800s | `src/notify.rs:459` | | `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 个值** **B. 内嵌 Lua 脚本 — 1 个写入点 / 1 个值**

View File

@ -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 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 `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 `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 response body, as `"revision"` (a `u64`). The SPA surfaces it in its status line and sends it
body carries no `revision` field. 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 `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 validation and client build against the submitted wire and returns the per-component verdict

View File

@ -75,8 +75,8 @@
|---|---|---|---| |---|---|---|---|
| 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 | | 把 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` 的投递流水线键一节 | | `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`) | | `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:1274-1278`) | | `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`) | 无需配置开关,白名单恒生效 | | `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`「已知边界」同句已删 | | `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

@ -224,9 +224,13 @@ impl From<BusinessConfig> for BusinessConfigWire {
/// does not name keeps the stored value, so an operator can change one thing without retyping /// does not name keeps the stored value, so an operator can change one thing without retyping
/// the whole configuration. /// the whole configuration.
/// ///
/// The field list mirrors `BusinessConfigWire` field for field — `apply` builds a wire from these, /// Every `BusinessConfigWire` field has a matching option here (`account_id` is nested, see
/// so adding a field to the wire without adding it here fails to compile instead of silently /// below), so adding a wire field later requires adding its option here too. `apply` merges each
/// freezing the new setting at its stored value. Secrets are replace-only: `validate_business_wire` /// 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 /// 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. /// learn what it is holding.
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
@ -264,6 +268,14 @@ pub(crate) struct BusinessConfigPatch {
pub reconcile_token: Option<String>, pub reconcile_token: Option<String>,
#[serde(default)] #[serde(default)]
pub worker_token: Option<String>, pub worker_token: Option<String>,
/// 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<u64>,
} }
/// `telegram_chat_id` arrives as a JSON number on a full submit but as a numeric string on a /// `telegram_chat_id` arrives as a JSON number on a full submit but as a numeric string on a

View File

@ -692,6 +692,24 @@ async fn put_business_config(
false, 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 // 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. // one-field edit must be rejected when the configuration it becomes is invalid.
let merged = match read_stored_business_config(&app.state).await { 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)); 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] #[tokio::test]
async fn business_config_partial_submit_cannot_skip_validation_of_the_merged_result() { async fn business_config_partial_submit_cannot_skip_validation_of_the_merged_result() {
let (router, state) = business_config_router(); let (router, state) = business_config_router();

View File

@ -97,6 +97,7 @@
"business.saved": "Business configuration saved and new configuration hot-reloaded.", "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.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.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.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.submittingToast": "Submitting the full business configuration; the backend builds clients first, then saves and swaps the running instance…",
"business.preflight": "Test connection", "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.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.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.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.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.fail": "{message} The request result may be unconfirmed; after checking, re-submit the complete configuration.",
"berr.failDefault": "Business configuration request failed.", "berr.failDefault": "Business configuration request failed.",
@ -294,6 +296,7 @@
"business.saved": "业务配置已保存,新配置已热加载。", "business.saved": "业务配置已保存,新配置已热加载。",
"business.savedToast": "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。", "business.savedToast": "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。",
"business.dirty": "表单包含未提交内容;只提交你修改过的字段。", "business.dirty": "表单包含未提交内容;只提交你修改过的字段。",
"business.conflict": "期间有人保存了更新的配置;已重新读取已保存的值。",
"business.unsaved": "未收到保存成功确认;请检查服务状态。", "business.unsaved": "未收到保存成功确认;请检查服务状态。",
"business.submittingToast": "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…", "business.submittingToast": "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…",
"business.preflight": "测试连接", "business.preflight": "测试连接",
@ -394,6 +397,7 @@
"berr.400": "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。", "berr.400": "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。",
"berr.422": "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。", "berr.422": "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。",
"berr.503": "写入未能确认(HTTP 503):Redis 未能持久化配置,或热加载提交失败。JMAP/LLM 连接失败不会返回此错误——它们会照常保存并以下发告警的形式报告。页面保留本次输入供检查和重试。", "berr.503": "写入未能确认(HTTP 503):Redis 未能持久化配置,或热加载提交失败。JMAP/LLM 连接失败不会返回此错误——它们会照常保存并以下发告警的形式报告。页面保留本次输入供检查和重试。",
"berr.409": "本页读取配置之后,又有人保存了更新的版本(HTTP 409)。你刚才的提交已被丢弃,已把已保存的值重新读回表单,所以原本正在输入的内容现在可能显示为已保存的值。请核对修改过的字段后重新提交。",
"berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。", "berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。",
"berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。", "berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。",
"berr.failDefault": "业务配置请求失败。", "berr.failDefault": "业务配置请求失败。",
@ -600,6 +604,9 @@
// (including nulls); `businessSecretPresence` is the `secrets_present` object. // (including nulls); `businessSecretPresence` is the `secrets_present` object.
// Secrets themselves are never stored here — only their presence booleans. // Secrets themselves are never stored here — only their presence booleans.
let businessBaseline = null; 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; let businessSecretPresence = null;
class ApiError extends Error { class ApiError extends Error {
@ -1017,6 +1024,7 @@
function businessErrorMessage(error) { function businessErrorMessage(error) {
if (error.status === 401) return t("berr.401"); if (error.status === 401) return t("berr.401");
if (error.status === 400) return t("berr.400"); 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 === 422) return t("berr.422");
if (error.status === 503) return t("berr.503"); if (error.status === 503) return t("berr.503");
if (error.status !== undefined) return t("berr.status", { status: error.status }); if (error.status !== undefined) return t("berr.status", { status: error.status });
@ -1067,6 +1075,7 @@
function resetBusinessReadback() { function resetBusinessReadback() {
businessBaseline = null; businessBaseline = null;
businessRevision = null;
businessSecretPresence = null; businessSecretPresence = null;
renderSecretMarkers(); renderSecretMarkers();
} }
@ -1089,6 +1098,9 @@
function applyBusinessReadback(readback) { function applyBusinessReadback(readback) {
businessBaseline = isRecord(readback && readback.values) ? readback.values : {}; businessBaseline = isRecord(readback && readback.values) ? readback.values : {};
businessRevision = Number.isInteger(readback && readback.revision)
? readback.revision
: null;
businessSecretPresence = isRecord(readback && readback.secrets_present) businessSecretPresence = isRecord(readback && readback.secrets_present)
? readback.secrets_present ? readback.secrets_present
: {}; : {};
@ -1143,6 +1155,9 @@
clearCustomValidity(businessForm); clearCustomValidity(businessForm);
const baseline = isRecord(businessBaseline) ? businessBaseline : {}; const baseline = isRecord(businessBaseline) ? businessBaseline : {};
const patch = {}; 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 { try {
for (const field of SECRET_FIELDS) { for (const field of SECRET_FIELDS) {
const input = document.getElementById(field.id); const input = document.getElementById(field.id);
@ -1400,7 +1415,10 @@
// fields are sent. Without one, every field is required as before. // fields are sent. Without one, every field is required as before.
const patch = businessBaseline === null ? readBusinessConfig() : businessPatchFromForm(); const patch = businessBaseline === null ? readBusinessConfig() : businessPatchFromForm();
if (!patch) return; 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.textContent = t("business.noChange");
businessSaveState.classList.remove("is-dirty"); businessSaveState.classList.remove("is-dirty");
return; return;
@ -1440,8 +1458,22 @@
expireSession(t("session.invalidBusiness")); expireSession(t("session.invalidBusiness"));
return; return;
} }
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"); businessSaveState.textContent = t("business.unsaved");
showNotice("error", businessErrorMessage(error)); showNotice("error", businessErrorMessage(error));
}
} finally { } finally {
if (adminSession) setBusy(""); if (adminSession) setBusy("");
} }
@ -1509,6 +1541,7 @@
renderSecretMarkers, renderSecretMarkers,
resetBusinessReadback, resetBusinessReadback,
get businessBaseline() { return businessBaseline; }, get businessBaseline() { return businessBaseline; },
get businessRevision() { return businessRevision; },
get businessSecretPresence() { return businessSecretPresence; }, get businessSecretPresence() { return businessSecretPresence; },
fields: { secrets: SECRET_FIELDS, text: PLAIN_TEXT_FIELDS, bool: PLAIN_BOOL_FIELDS }, fields: { secrets: SECRET_FIELDS, text: PLAIN_TEXT_FIELDS, bool: PLAIN_BOOL_FIELDS },
}; };

View File

@ -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); 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);
});