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:
parent
33a0a8b8ba
commit
b764d6dc30
|
|
@ -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_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`,路径为字面量,无常量抽取)
|
||||
|
||||
|
|
@ -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`。
|
||||
**只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。
|
||||
|
|
|
|||
|
|
@ -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` 为准。
|
||||
|
|
|
|||
|
|
@ -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 个值**
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`「已知边界」同句已删 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue