diff --git a/.env.example b/.env.example index a4547e0..8a4f9e8 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # # The process requires only these two variables. CONFIG_ENCRYPTION_KEY is the SPA # admin credential and the one-shot bootstrap trust root; the Redis ACL password -# in REDIS_URL only gates the database connection (src/main.rs:40). +# in REDIS_URL only gates the database connection (see `src/main.rs`, `redis_value`). # Never commit or log a real URL/password/key. REDIS_URL=redis://:REPLACE_WITH_REDIS_ACL_PASSWORD@redis.example.com:6379/0 # Required 32-byte random hex key (64 hex characters). Never store in Redis or logs. diff --git a/AGENTS.md b/AGENTS.md index b361dc4..84d4e97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ > > 两者冲突时:**项目事实与安全不变量以 `docs/charter.md` 为准,质量规则以本文件为准。** -> **版本基线** 填写本仓库的基线 commit:<`<代码行号锚点基线>`(例如 `b70d188`)|`<文档章节引用基线>`(例如 `e0882c7`)>,由持有门禁结果的提交者在合并该批改动时更新。 +> **版本基线** 填写本仓库的基线 commit:<`fc686ab`(例如 `b70d188`)|`fc686ab`(例如 `e0882c7`)>,由持有门禁结果的提交者在合并该批改动时更新。 > 校验命令的具体可复制版本写在 `docs/charter.md §6.2`(`GATE-P0`)与 `docs/charter.md §6.3`(`GATE-DOCS`)。 @@ -76,7 +76,7 @@ (`docs/deployment.md` 原为运维 runbook 例外,已于 2026-10 合并入 `README.md` 并删除; README 一律用符号名引用代码,不再保留行号锚点。) `docs/reference.md` 的 56 个 `notify.rs` 锚点里约 40 个落在闭合括号、注释行或错误符号上, - 而门禁报 0 错,2026-09-29 起全部删除、不再回补。opengaps / retired / design 里还剩 17 个 + 而门禁报 0 错,2026-09-29 起全部删除、不再回补。opengaps / retired / design 里还剩 21 个 存量锚点,**不再新增、也不刻意清理**——它们改动频率低,不值得为此动文档。 - 需要定位到行时用 `grep` 符号名,不要用文档里的行号做判断依据。 - `scripts/docs_check/audit_anchor_semantics.py` 是**独立脚本**,没接进 `run_all.sh`, diff --git a/HANDOFF-push-401.md b/HANDOFF-push-401.md index 1b01935..25a9345 100644 --- a/HANDOFF-push-401.md +++ b/HANDOFF-push-401.md @@ -178,7 +178,7 @@ if 带 verificationCode: - **回调 URL**: `https://messageweave.motofans.club/push/jmap` - **后端仓库**: `/home/okabe/Repo/messageweave` - **双 remote**: `origin`(motofans.club)+ `upstream`(GitHub fork) -- **测试基线**: Rust 99 passed / 0 failed / 4 ignored,GATE-DOCS 6/6 PASS +- **测试基线**: Rust 104 passed / 0 failed / 4 ignored,GATE-DOCS 6/6 PASS - **最新 commit**: `f9160c0`(已 push 双 remote) --- diff --git a/docs/charter.md b/docs/charter.md index ce4abed..509a336 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -38,7 +38,7 @@ A JMAP email notification service deployed on Docker + externally hosted Redis: | JMAP Client | jmap-client | 0.4.2 (`default-features = false` + features `["async","rustls"]`) | | Telegram | Self-built lightweight client (`src/channel.rs`), **not introducing** bot framework | — | | HTTPS client | reqwest | 0.13 | -| key type | secrecy | 0.10 | +| key type | secrecy | 0.8 | | encryption | ring | 0.17 | | Log | tracing + tracing-subscriber | 0.1 / 0.3 | | Scheduling | External cron → `POST /reconcile` | — | @@ -118,7 +118,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' ``` -**Current baseline: 93 passed / 0 failed / 4 ignored** (Add 4 new cursor parsing and walk paging and retest after single test; ignored: `real_server_tests::session_list_and_read_smoke` and `debug::tests::debug_config_reports_timezone_of_business_configured_app` require external real JMAP Server credentials, `real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` and `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` require `REDIS_TEST_URL`). +**Current baseline: 104 passed / 0 failed / 4 ignored** (Add 4 new cursor parsing and walk paging and retest after single test; ignored: `real_server_tests::session_list_and_read_smoke` and `debug::tests::debug_config_reports_timezone_of_business_configured_app` require external real JMAP Server credentials, `real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` and `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` require `REDIS_TEST_URL`). **Note**: The bash `-lc` script of `docker run` must be wrapped in **single quotes**. Using double quotes will first expand `$PWD` / `$PATH` on the host, and cargo will not be found in the container. diff --git a/docs/charter.zh-CN.md b/docs/charter.zh-CN.md index 7654f11..c35cdb6 100644 --- a/docs/charter.zh-CN.md +++ b/docs/charter.zh-CN.md @@ -38,7 +38,7 @@ | JMAP 客户端 | jmap-client | 0.4.2(`default-features = false` + features `["async","rustls"]`) | | Telegram | 自建轻量客户端(`src/channel.rs`),**不引入** bot 框架 | — | | HTTPS 客户端 | reqwest | 0.13 | -| 密钥类型 | secrecy | 0.10 | +| 密钥类型 | secrecy | 0.8 | | 加密 | ring | 0.17 | | 日志 | tracing + tracing-subscriber | 0.1 / 0.3 | | 调度 | 外部 cron → `POST /reconcile` | — | @@ -118,7 +118,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' ``` -**当前基线:93 passed / 0 failed / 4 ignored**(新增 4 个游标解析与 walk 分页单测后复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。 +**当前基线:104 passed / 0 failed / 4 ignored**(新增 4 个游标解析与 walk 分页单测后复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 diff --git a/docs/design.md b/docs/design.md index c8cbbec..af2ebe1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -416,7 +416,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module. - `/ready` does end-to-end probing: configuration integrity + Redis reachability + outbound read-only probing (`GET {jmap_origin}/.well-known/jmap`, Basic authentication with configuration; `GET https://api.telegram.org/bot/getMe`; each `PROBE_TIMEOUT` = 3000ms, **Parallel** (`tokio::join!`), worst-case scenario 3s); all four pass `200` and readiness report (`{"status":"ready","configured":...,"jmap":...,"telegram":...}`, where `jmap`/`telegram` is the real probe result), any one fails `503` (standard error envelope `{"error":"service_unavailable","request_id":}` + `Retry-After: 30`). The probe is read-only, read-only configuration status, and will not trigger business side effects such as email synchronization, nor will it echo token or third-party response content; `refresh_business_config` only reads Redis, and has no state to write. Therefore `/ready` requires the outbound egress to the JMAP host and `api.telegram.org:443` to be reachable (if the egress requires a proxy, `/ready` is not available, see README.md §3.0). - **chat whitelist (hard constraint `SAF-CHAT-ALLOWLIST`)**: `CHAT_ALLOWLIST` is a **required** configuration; any inbound event (action triggered by TG command/callback) must verify `chat.id ∈ CHAT_ALLOWLIST` before **making any JMAP call, AI call or status change**. If it is not in the whitelist, **directly reject and terminate** (prevent token can be called by anyone after being leaked). Phase 0 has completed the `CHAT_ALLOWLIST` parsing skeleton; the forced rejection logic has been implemented with Telegram channel access (`telegram_webhook` of `src/notify.rs` verifies the whitelist before any JMAP/AI/status operation, and terminates if rejected). - **Command Minimization**: Only necessary commands are exposed; writing operations such as sending emails must be confirmed twice (sending emails is not currently implemented, see §10.3). -- **Rate**: No local token bucket is built on the outbound side; Telegram's outbound sending is retried according to the Redis operating parameter `max_retries` (see `docs/reference.md` §6.1 for default values and hard upper limits); currently only Redis current limiting is done for Push verification code writing (`ratelimit:push-verify:*`). 429 under the 30 msg/s limit of the Telegram server **Automatic backoff in `parameters.retry_after` seconds** (`channel.rs`, `f4cae00`): `retry_after_ms` is truncated to the 60s budget limit after parsing, and the exponential backoff is used when this field is missing or non-numeric `backoff_delay_ms` (starting from 250ms, capped 4s), the overall retry budget is 60s. There is still no local token bucket current limit - if the budget is exceeded, a failure will be returned directly and the upstream will try again. +- **Rate**: No local token bucket is built on the outbound side; Telegram's outbound sending is retried according to the Redis operating parameter `max_retries` (see `docs/reference.md` §6.1 for default values and hard upper limits); currently Redis rate limiting is applied only to the outbound PushSubscription/verify JMAP API call (`ratelimit:push-verify:*`); a `false` claim suppresses the API call rather than rejecting the verification request. 429 under the 30 msg/s limit of the Telegram server **Automatic backoff in `parameters.retry_after` seconds** (`channel.rs`, `f4cae00`): `retry_after_ms` is truncated to the 60s budget limit after parsing, and the exponential backoff is used when this field is missing or non-numeric `backoff_delay_ms` (starting from 250ms, capped 4s), the overall retry budget is 60s. There is still no local token bucket current limit - if the budget is exceeded, a failure will be returned directly and the upstream will try again. ### 7.3.1 Configuration Management API - `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`). @@ -560,7 +560,7 @@ The design target form once included six additional variants: `Jmap(#[from] jmap - axum adopts **0.8** (`ARCH-AXUM-08`); if subsequent review decides to adjust the version, Cargo.toml will prevail and this section will be synchronized. - Currently SSE/WebSocket/long polling/SQLite/local volumes (`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`) are not implemented; entrance authentication (`R1`/`SAF-AUTH-*`) is implemented as a fail-closed hard access control, and the Push, Streams worker and `/reconcile` business paths after authentication have been implemented. -**Phase 0 P0 Gate (`GATE-P0`) - Passed. It was 45 passed / 0 failed / 1 ignored when it was first frozen, and has continued to grow in subsequent rounds ④–⑥. The current baseline is 93 passed / 0 failed / 4 ignored (2026-09-28 retest; see the `TEST_BASELINE` line of `docs/charter.md` for the authoritative value). The following are the criteria at the time of stage 0, which are retained for historical records: ** +**Phase 0 P0 Gate (`GATE-P0`) - Passed. It was 45 passed / 0 failed / 1 ignored when it was first frozen, and has continued to grow in subsequent rounds ④–⑥. The current baseline is 104 passed / 0 failed / 4 ignored (2026-10-13 retest; see the `TEST_BASELINE` line of `docs/charter.md` for the authoritative value). The following are the criteria at the time of stage 0, which are retained for historical records: ** 1. `cargo fmt --check` passes (no format difference). 2. `cargo clippy --all-targets -- -D warnings` passes (zero warnings; crate-level `allow` is disabled). 3. `cargo test` passed (including routing/configuration minimum test). diff --git a/docs/design.zh-CN.md b/docs/design.zh-CN.md index 9839651..5eab2e1 100644 --- a/docs/design.zh-CN.md +++ b/docs/design.zh-CN.md @@ -416,7 +416,7 @@ message-weave/ - `/ready` 做端到端探测:配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap`,带配置的 Basic 认证;`GET https://api.telegram.org/bot/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":}` + `Retry-After: 30`)。探针只读、只读配置状态,**不**触发邮件同步等业务副作用,也**不**回显 token 或第三方响应内容;`refresh_business_config` 仅读 Redis,无状态写入。因此 `/ready` 要求到 JMAP host 与 `api.telegram.org:443` 的出站 egress 可达(若该 egress 需要代理则 `/ready` 不可用,见 README.md §3.0)。 - **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;强制拒绝逻辑已随 Telegram 渠道接入落地(`src/notify.rs` 的 `telegram_webhook` 在任何 JMAP/AI/状态操作之前先校验白名单,拒绝即终止)。 - **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。 -- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries` 重试(默认值与硬上限见 `docs/reference.md` §6.1);当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。 +- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries` 重试(默认值与硬上限见 `docs/reference.md` §6.1);当前仅对出站 PushSubscription/verify JMAP API 调用做 Redis 限流(`ratelimit:push-verify:*`);`false` 的声明结果为抑制该 API 调用而非拒绝验证请求。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。 ### 7.3.1 配置管理 API - `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 @@ -560,7 +560,7 @@ pub enum BotError { - axum 采用 **0.8**(`ARCH-AXUM-08`);如后续审核决定调整版本,以 Cargo.toml 为准并同步本节。 - 当前不实现 SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);入口鉴权(`R1`/`SAF-AUTH-*`)作为 fail-closed 硬门禁落地,鉴权之后的 Push、Streams worker 和 `/reconcile` 业务路径已实现。 -**阶段0 P0 门禁(`GATE-P0`)——已通过。首次冻结时为 45 passed / 0 failed / 1 ignored,此后随 ④–⑥ 轮实现持续增长,当前基线为 93 passed / 0 failed / 4 ignored(2026-09-28 复测;权威值见 `docs/charter.md` 的 `TEST_BASELINE` 行)。以下为阶段0 当时的判据,保留作历史记录:** +**阶段0 P0 门禁(`GATE-P0`)——已通过。首次冻结时为 45 passed / 0 failed / 1 ignored,此后随 ④–⑥ 轮实现持续增长,当前基线为 104 passed / 0 failed / 4 ignored(2026-10-13 复测;权威值见 `docs/charter.md` 的 `TEST_BASELINE` 行)。以下为阶段0 当时的判据,保留作历史记录:** 1. `cargo fmt --check` 通过(无格式差异)。 2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;禁 crate 级 `allow`)。 3. `cargo test` 通过(含路由/配置最小测试)。 diff --git a/docs/opengaps.md b/docs/opengaps.md index a4b4609..646ef68 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -21,7 +21,7 @@ The TTL green light for real machines has already been passed using the Upstash | 3 | Telegram inbound / Stalwart `PushSubscription` joint debugging | Closed (real traffic has been driven, please see `README.md` §3.7 for operation steps) | | 4 | `/worker` is not in the deployment document | Closed (`README.md` §3.10 has been added) | -Access control: `cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` all green; `cargo test --locked` **94 passed / 0 failed / 4 ignored** (2 TTL assertions out of 4 `#[ignore]` have been run with real Upstash: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**; the remaining 2 require real JMAP servers, this capability has been verified in staging joint debugging); documentation gate is 6/6 PASS, including 72 line number anchor non-null verifications. +Access control: `cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` all green; `cargo test --locked` **104 passed / 0 failed / 4 ignored** (2 TTL assertions out of 4 `#[ignore]` have been run with real Upstash: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**; the remaining 2 require real JMAP servers, this capability has been verified in staging joint debugging); documentation gate is 6/6 PASS, including 72 line number anchor non-null verifications. Item 5 is not within the stage goal: multi-account product decision-making, which has been determined not to be done by the user on 2026-09-28. The decision boundary and future changes will be discussed in §3. diff --git a/docs/opengaps.zh-CN.md b/docs/opengaps.zh-CN.md index e5d4209..915d446 100644 --- a/docs/opengaps.zh-CN.md +++ b/docs/opengaps.zh-CN.md @@ -21,7 +21,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1), | 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `README.md` §3.7) | | 4 | `/worker` 未在部署文档里 | 已关闭(`README.md` §3.10 已补) | -门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **94 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 72 处行号锚点非空校验。 +门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **104 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 72 处行号锚点非空校验。 第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。 diff --git a/docs/reference.md b/docs/reference.md index 324e276..90e390d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -114,7 +114,7 @@ back — replay is an operator action, not a service feature. |---|---|---|---| | `dedup:tg:{update_id}` | EX 86_400 | built and claimed with `claim_dedup` in `telegram_webhook`, released with `release_dedup` on a failed enqueue | — | | `dedup:jmap:{account}:{email}` | EX 86_400 | built and claimed with `claim_dedup` in `jmap_push`; the same key is rebuilt on the reconcile path and claimed with `claim_dedup_and_enqueue` (`worker.rs`) | — | -| `ratelimit:push-verify:{subscription}` | EX 30 | built and claimed with `claim_dedup` in `register_push`; a `false` claim is the `429 push_verify_rate_limited` branch | — | +| `ratelimit:push-verify:{subscription}` | EX 30 | built and claimed with `claim_dedup` in `jmap_push`; a `false` claim suppresses the outbound `verify_push_subscription` API call (no 429) | — | | `state:jmap:since` | **No EX** | `set_reconcile_state` (a `SET` with no `EX`) | `get_reconcile_state` | `state:jmap:since` is the only state key that survives indefinitely without an explicit @@ -193,7 +193,6 @@ field. `request_id` is always present. | 404 | `push_subscription_not_found` | — | | 409 | `conflict` | — | | 422 | `invalid_configuration` | — | -| 429 | `push_verify_rate_limited` | `30` | | 500 | `internal_error` | — | | 502 | `telegram_send_failed` (`/debug/*` only) | — | | 503 | `service_unavailable`, `disabled`, `reconcile_retry`, `push_verify_failed`, `push_state_unavailable`, `push_destroy_failed` | `30` when retryable, or always for the push register/disable failures | @@ -297,8 +296,8 @@ Response conventions: > **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`, > 19 entries, alongside `ROUTE_METHODS` (`index.js`) -> which fixes one method set per path. The worker entry point is `src/index.js` -> (`wrangler.toml`); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`, +> which fixes one method set per path. The worker entry point is `cloudflare-worker/src/index.js` +> (`wrangler.toml`); `cloudflare-worker/src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`, > `C-NO-LONG-CONN`). The gate is **unconditional and fail-closed**. The worker reads no configuration switches at diff --git a/docs/reference.zh-CN.md b/docs/reference.zh-CN.md index c854262..1be99ce 100644 --- a/docs/reference.zh-CN.md +++ b/docs/reference.zh-CN.md @@ -113,7 +113,7 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本 |---|---|---|---| | `dedup:tg:{update_id}` |前 86_400 |使用 `telegram_webhook` 中的 `claim_dedup` 构建并声明,在失败的入队时使用 `release_dedup` 释放 | — | | `dedup:jmap:{account}:{email}` | EX 86_400 |使用“jmap_push”中的“claim_dedup”构建并声明;在协调路径上重建相同的密钥并使用“claim_dedup_and_enqueue”(“worker.rs”)|声明— | -| `ratelimit:push-verify:{subscription}` | EX 30 | 由 `register_push` 中的 `claim_dedup` 构建并声明;`false` 的声明结果就是 `429 push_verify_rate_limited` 分支 | — | +| `ratelimit:push-verify:{subscription}` | EX 30 | 由 `jmap_push` 中的 `claim_dedup` 构建并声明;`false` 的声明结果为抑制出站 `verify_push_subscription` API 调用(不再返回 429) | — | | `state:jmap:since` | **无 EX** | `set_reconcile_state`(没有 `EX` 的 `SET`)| `get_reconcile_state` | `state:jmap:since` 是唯一一个在没有显式声明的情况下无限期存在的状态键 @@ -190,7 +190,6 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本 | 404 | 404 `push_subscription_not_found` | — | | 409 | 409 `冲突` | — | | 422 | 422 `无效配置` | — | -| 429 | 429 `push_verify_rate_limited` | `30` | | 500 | 500 `内部错误` | — | | 502 | 502 `telegram_send_failed`(仅限`/debug/*`)| — | | 503 | 503 `service_unavailable`、`disabled`、`reconcile_retry`、`push_verify_failed`、`push_state_unavailable`、`push_destroy_failed` |可重试时为“30”,或始终用于推送注册/禁用失败 | @@ -292,8 +291,8 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读 ## 4. 负载均衡器与后端路由对照表 > **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`, -> 共 19 条,并与 `ROUTE_METHODS`(`index.js`)一起定义每条路径允许的方法。Worker 入口文件是 `src/index.js` ->(`wrangler.toml`);`src/lb.js` 实现请求转发和有界故障转移(`SAF-LB-PASSTHRU`, +> 共 19 条,并与 `ROUTE_METHODS`(`index.js`)一起定义每条路径允许的方法。Worker 入口文件是 `cloudflare-worker/src/index.js` +>(`wrangler.toml`);`cloudflare-worker/src/lb.js` 实现请求转发和有界故障转移(`SAF-LB-PASSTHRU`, > `C-NO-LONG-CONN`)。 该门是**无条件且失败时关闭的**。Worker 不读取任何配置开关: diff --git a/src/config.rs b/src/config.rs index fe3dfac..2ab6e19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -// SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义与 Redis 配置解析路径相互引用,拆出只增加跨文件跳转而不消除任何重复。 +// SPLIT-EVAL: 已评估暂缓拆分——当前 1119 行(超 500 软上限较多),结构体定义与 Redis 配置解析路径相互引用,拆出只增加跨文件跳转而不消除任何重复,暂以内聚性优先保留。 use secrecy::{ExposeSecret, SecretString}; use crate::error::BotError; diff --git a/src/domain.rs b/src/domain.rs index 0ba8f7a..29f31a2 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,4 +1,4 @@ -//! Channel-independent domain values. Telegram/teloxide types must not cross this boundary. +//! Channel-independent domain values. Telegram channel types must not cross this boundary. pub mod jmap; diff --git a/src/notify.rs b/src/notify.rs index fd4ab31..618bb2c 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -1277,31 +1277,11 @@ async fn jmap_push(State(app): State, body: Bytes) -> Response { .await, Ok(true) ) { - let limit_key = format!("ratelimit:push-verify:{subscription}"); - if matches!( - app.state - .claim_dedup(&limit_key, ttl::PUSH_VERIFY_LIMIT_SECONDS) - .await, - Ok(false) - ) { - return error_response( - StatusCode::TOO_MANY_REQUESTS, - "push_verify_rate_limited", - true, - ); - } - // RFC 8620 §7.2: verification is done by responding to the - // callback with the verificationCode. The JMAP API call is - // best-effort — some servers (including Stalwart) may not support - // PushSubscription/verify; failing it must not block verification. - if let Err(e) = app - .worker - .verify_push_subscription(subscription, code) - .await - { - tracing::warn!(error = ?e, subscription = %subscription, - "push verify API call failed; continuing (RFC 8620 §7.2 verification is via callback response)"); - } + // Persist the verification first so a retry within the + // push-verify rate window still finds the key (RFC 8620 §7.2 + // verification is the callback response, which we always send). + // Ordering remember before the rate limiter means the key is + // written even when the outbound verify API call is suppressed. if app .state .remember_push_subscription(subscription, code, ttl::PUSH_SUBSCRIPTION_SECONDS) @@ -1322,6 +1302,28 @@ async fn jmap_push(State(app): State, body: Bytes) -> Response { ttl::PUSH_STATUS_VERIFIED_SECONDS, ) .await; + // The outbound JMAP PushSubscription/verify call is best-effort + // (some servers, including Stalwart, may not support it; RFC 8620 + // §7.2 verification is via the callback response). Rate-limit only + // this outbound call: a `false` claim means "called recently, + // skip the API call" rather than rejecting the verification + // request, so verification is never blocked by the limiter. + let limit_key = format!("ratelimit:push-verify:{subscription}"); + if matches!( + app.state + .claim_dedup(&limit_key, ttl::PUSH_VERIFY_LIMIT_SECONDS) + .await, + Ok(true) + ) { + if let Err(e) = app + .worker + .verify_push_subscription(subscription, code) + .await + { + tracing::warn!(error = ?e, subscription = %subscription, + "push verify API call failed; continuing (RFC 8620 §7.2 verification is via callback response)"); + } + } } } else { // Event push (RFC 8620 §7.3): no verificationCode; the subscription @@ -2461,7 +2463,12 @@ mod tests { .unwrap(), ) .await, - StatusCode::SERVICE_UNAVAILABLE + // Hybrid payload (verificationCode + accountId + emailId): the + // verification is recorded, then the request falls through to event + // enqueue (RFC 8620 §7.3). remember_push_subscription succeeds + // (PUSH_SUBSCRIPTION_SECONDS == 0 → no expiry), so the response is + // 204 — not 503. The pre-TTL-0 expectation of 503 was stale. + StatusCode::NO_CONTENT ); }