fix(push): rate-limiter no longer blocks verification; sync stale docs
P1 (src/notify.rs): in jmap_push, move remember_push_subscription and
set_push_subscription_status BEFORE the push-verify rate limiter. The limiter
now gates only the outbound PushSubscription/verify JMAP API call
(claim_dedup == true -> call it; false -> skip), and the 429 branch is removed
entirely. A retry within the 30s window still records the key, so RFC 8620 §7.2
verification (the callback response) is never rejected by the limiter.
Test (src/notify.rs): jmap_push_requires_verification_code case 4 expected
SERVICE_UNAVAILABLE (503) -- pre-TTL=0 behaviour when remember failed. With
TTL=0 (commit 7331cb7) remember always succeeds and the hybrid payload
(verificationCode + accountId + emailId) falls through to event enqueue ->
NO_CONTENT (204). Expectation updated; comment added.
Comments: src/domain.rs drops phantom 'teloxide' (crate never imported);
src/config.rs SPLIT-EVAL reason corrected to real 1119-line size.
Docs (EN+ZH pairs, same round -- AGENTS 6.5/7.2):
- reference.* : ratelimit:push-verify row -> jmap_push, suppress-not-429;
drop 429 error row; src/{index,lb}.js -> cloudflare-worker/src/*.
- design.* : section 419 rate-limit scope; baseline 93 -> 104 (2026-10-13).
- charter.* : secrecy 0.10 -> 0.8 (matches Cargo.lock); baseline 93 -> 104.
- opengaps.* : baseline 94 -> 104.
- HANDOFF-push-401.md : test baseline 99 -> 104 (never true; red test fixed).
- AGENTS.md : fill version-baseline placeholder; surviving-anchor count
17 -> 21 (verified: opengaps 8 + retired 9 + design 4).
- .env.example : src/main.rs:40 line-anchor -> symbol reference.
GATE-P0 (docker rust:1-slim-bookworm): fmt/check/clippy/test -> 104 passed / 0 failed / 4 ignored.
GATE-DOCS (scripts/docs_check/run_all.sh): 6/6 clean (704 path-refs, 55 anchors, 459 sec-refs).
This commit is contained in:
parent
09a108bdd7
commit
3b36fe30ba
|
|
@ -2,7 +2,7 @@
|
||||||
#
|
#
|
||||||
# The process requires only these two variables. CONFIG_ENCRYPTION_KEY is the SPA
|
# 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
|
# 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.
|
# Never commit or log a real URL/password/key.
|
||||||
REDIS_URL=redis://:REPLACE_WITH_REDIS_ACL_PASSWORD@redis.example.com:6379/0
|
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.
|
# Required 32-byte random hex key (64 hex characters). Never store in Redis or logs.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
>
|
>
|
||||||
> 两者冲突时:**项目事实与安全不变量以 `docs/charter.md` 为准,质量规则以本文件为准。**
|
> 两者冲突时:**项目事实与安全不变量以 `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`)。
|
> 校验命令的具体可复制版本写在 `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` 并删除;
|
(`docs/deployment.md` 原为运维 runbook 例外,已于 2026-10 合并入 `README.md` 并删除;
|
||||||
README 一律用符号名引用代码,不再保留行号锚点。)
|
README 一律用符号名引用代码,不再保留行号锚点。)
|
||||||
`docs/reference.md` 的 56 个 `notify.rs` 锚点里约 40 个落在闭合括号、注释行或错误符号上,
|
`docs/reference.md` 的 56 个 `notify.rs` 锚点里约 40 个落在闭合括号、注释行或错误符号上,
|
||||||
而门禁报 0 错,2026-09-29 起全部删除、不再回补。opengaps / retired / design 里还剩 17 个
|
而门禁报 0 错,2026-09-29 起全部删除、不再回补。opengaps / retired / design 里还剩 21 个
|
||||||
存量锚点,**不再新增、也不刻意清理**——它们改动频率低,不值得为此动文档。
|
存量锚点,**不再新增、也不刻意清理**——它们改动频率低,不值得为此动文档。
|
||||||
- 需要定位到行时用 `grep` 符号名,不要用文档里的行号做判断依据。
|
- 需要定位到行时用 `grep` 符号名,不要用文档里的行号做判断依据。
|
||||||
- `scripts/docs_check/audit_anchor_semantics.py` 是**独立脚本**,没接进 `run_all.sh`,
|
- `scripts/docs_check/audit_anchor_semantics.py` 是**独立脚本**,没接进 `run_all.sh`,
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ if 带 verificationCode:
|
||||||
- **回调 URL**: `https://messageweave.motofans.club/push/jmap`
|
- **回调 URL**: `https://messageweave.motofans.club/push/jmap`
|
||||||
- **后端仓库**: `/home/okabe/Repo/messageweave`
|
- **后端仓库**: `/home/okabe/Repo/messageweave`
|
||||||
- **双 remote**: `origin`(motofans.club)+ `upstream`(GitHub fork)
|
- **双 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)
|
- **最新 commit**: `f9160c0`(已 push 双 remote)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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"]`) |
|
| 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 | — |
|
| Telegram | Self-built lightweight client (`src/channel.rs`), **not introducing** bot framework | — |
|
||||||
| HTTPS client | reqwest | 0.13 |
|
| HTTPS client | reqwest | 0.13 |
|
||||||
| key type | secrecy | 0.10 |
|
| key type | secrecy | 0.8 |
|
||||||
| encryption | ring | 0.17 |
|
| encryption | ring | 0.17 |
|
||||||
| Log | tracing + tracing-subscriber | 0.1 / 0.3 |
|
| Log | tracing + tracing-subscriber | 0.1 / 0.3 |
|
||||||
| Scheduling | External cron → `POST /reconcile` | — |
|
| 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'
|
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.
|
**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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
| JMAP 客户端 | jmap-client | 0.4.2(`default-features = false` + features `["async","rustls"]`) |
|
| JMAP 客户端 | jmap-client | 0.4.2(`default-features = false` + features `["async","rustls"]`) |
|
||||||
| Telegram | 自建轻量客户端(`src/channel.rs`),**不引入** bot 框架 | — |
|
| Telegram | 自建轻量客户端(`src/channel.rs`),**不引入** bot 框架 | — |
|
||||||
| HTTPS 客户端 | reqwest | 0.13 |
|
| HTTPS 客户端 | reqwest | 0.13 |
|
||||||
| 密钥类型 | secrecy | 0.10 |
|
| 密钥类型 | secrecy | 0.8 |
|
||||||
| 加密 | ring | 0.17 |
|
| 加密 | ring | 0.17 |
|
||||||
| 日志 | tracing + tracing-subscriber | 0.1 / 0.3 |
|
| 日志 | tracing + tracing-subscriber | 0.1 / 0.3 |
|
||||||
| 调度 | 外部 cron → `POST /reconcile` | — |
|
| 调度 | 外部 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'
|
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。
|
**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<token>/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":<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).
|
- `/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<token>/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":<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).
|
- **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).
|
- **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
|
### 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`).
|
- `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.
|
- 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.
|
- 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).
|
1. `cargo fmt --check` passes (no format difference).
|
||||||
2. `cargo clippy --all-targets -- -D warnings` passes (zero warnings; crate-level `allow` is disabled).
|
2. `cargo clippy --all-targets -- -D warnings` passes (zero warnings; crate-level `allow` is disabled).
|
||||||
3. `cargo test` passed (including routing/configuration minimum test).
|
3. `cargo test` passed (including routing/configuration minimum test).
|
||||||
|
|
|
||||||
|
|
@ -416,7 +416,7 @@ message-weave/
|
||||||
- `/ready` 做端到端探测:配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap`,带配置的 Basic 认证;`GET https://api.telegram.org/bot<token>/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`)。探针只读、只读配置状态,**不**触发邮件同步等业务副作用,也**不**回显 token 或第三方响应内容;`refresh_business_config` 仅读 Redis,无状态写入。因此 `/ready` 要求到 JMAP host 与 `api.telegram.org:443` 的出站 egress 可达(若该 egress 需要代理则 `/ready` 不可用,见 README.md §3.0)。
|
- `/ready` 做端到端探测:配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap`,带配置的 Basic 认证;`GET https://api.telegram.org/bot<token>/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<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/状态操作之前先校验白名单,拒绝即终止)。
|
- **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)。
|
- **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §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
|
### 7.3.1 配置管理 API
|
||||||
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
|
- `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 为准并同步本节。
|
- 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` 业务路径已实现。
|
- 当前不实现 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` 通过(无格式差异)。
|
1. `cargo fmt --check` 通过(无格式差异)。
|
||||||
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;禁 crate 级 `allow`)。
|
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;禁 crate 级 `allow`)。
|
||||||
3. `cargo test` 通过(含路由/配置最小测试)。
|
3. `cargo test` 通过(含路由/配置最小测试)。
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| 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) |
|
| 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
|
||||||
| 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `README.md` §3.7) |
|
| 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `README.md` §3.7) |
|
||||||
| 4 | `/worker` 未在部署文档里 | 已关闭(`README.md` §3.10 已补) |
|
| 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。
|
第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: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`) | — |
|
| `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` | **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
|
`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` | — |
|
| 404 | `push_subscription_not_found` | — |
|
||||||
| 409 | `conflict` | — |
|
| 409 | `conflict` | — |
|
||||||
| 422 | `invalid_configuration` | — |
|
| 422 | `invalid_configuration` | — |
|
||||||
| 429 | `push_verify_rate_limited` | `30` |
|
|
||||||
| 500 | `internal_error` | — |
|
| 500 | `internal_error` | — |
|
||||||
| 502 | `telegram_send_failed` (`/debug/*` only) | — |
|
| 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 |
|
| 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`,
|
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
|
||||||
> 19 entries, alongside `ROUTE_METHODS` (`index.js`)
|
> 19 entries, alongside `ROUTE_METHODS` (`index.js`)
|
||||||
> which fixes one method set per path. The worker entry point is `src/index.js`
|
> which fixes one method set per path. The worker entry point is `cloudflare-worker/src/index.js`
|
||||||
> (`wrangler.toml`); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`,
|
> (`wrangler.toml`); `cloudflare-worker/src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`,
|
||||||
> `C-NO-LONG-CONN`).
|
> `C-NO-LONG-CONN`).
|
||||||
|
|
||||||
The gate is **unconditional and fail-closed**. The worker reads no configuration switches at
|
The gate is **unconditional and fail-closed**. The worker reads no configuration switches at
|
||||||
|
|
|
||||||
|
|
@ -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: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”)|声明— |
|
| `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` | **无 EX** | `set_reconcile_state`(没有 `EX` 的 `SET`)| `get_reconcile_state` |
|
||||||
|
|
||||||
`state:jmap:since` 是唯一一个在没有显式声明的情况下无限期存在的状态键
|
`state:jmap:since` 是唯一一个在没有显式声明的情况下无限期存在的状态键
|
||||||
|
|
@ -190,7 +190,6 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本
|
||||||
| 404 | 404 `push_subscription_not_found` | — |
|
| 404 | 404 `push_subscription_not_found` | — |
|
||||||
| 409 | 409 `冲突` | — |
|
| 409 | 409 `冲突` | — |
|
||||||
| 422 | 422 `无效配置` | — |
|
| 422 | 422 `无效配置` | — |
|
||||||
| 429 | 429 `push_verify_rate_limited` | `30` |
|
|
||||||
| 500 | 500 `内部错误` | — |
|
| 500 | 500 `内部错误` | — |
|
||||||
| 502 | 502 `telegram_send_failed`(仅限`/debug/*`)| — |
|
| 502 | 502 `telegram_send_failed`(仅限`/debug/*`)| — |
|
||||||
| 503 | 503 `service_unavailable`、`disabled`、`reconcile_retry`、`push_verify_failed`、`push_state_unavailable`、`push_destroy_failed` |可重试时为“30”,或始终用于推送注册/禁用失败 |
|
| 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. 负载均衡器与后端路由对照表
|
## 4. 负载均衡器与后端路由对照表
|
||||||
|
|
||||||
> **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
|
> **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
|
||||||
> 共 19 条,并与 `ROUTE_METHODS`(`index.js`)一起定义每条路径允许的方法。Worker 入口文件是 `src/index.js`
|
> 共 19 条,并与 `ROUTE_METHODS`(`index.js`)一起定义每条路径允许的方法。Worker 入口文件是 `cloudflare-worker/src/index.js`
|
||||||
>(`wrangler.toml`);`src/lb.js` 实现请求转发和有界故障转移(`SAF-LB-PASSTHRU`,
|
>(`wrangler.toml`);`cloudflare-worker/src/lb.js` 实现请求转发和有界故障转移(`SAF-LB-PASSTHRU`,
|
||||||
> `C-NO-LONG-CONN`)。
|
> `C-NO-LONG-CONN`)。
|
||||||
|
|
||||||
该门是**无条件且失败时关闭的**。Worker 不读取任何配置开关:
|
该门是**无条件且失败时关闭的**。Worker 不读取任何配置开关:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义与 Redis 配置解析路径相互引用,拆出只增加跨文件跳转而不消除任何重复。
|
// SPLIT-EVAL: 已评估暂缓拆分——当前 1119 行(超 500 软上限较多),结构体定义与 Redis 配置解析路径相互引用,拆出只增加跨文件跳转而不消除任何重复,暂以内聚性优先保留。
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
|
||||||
use crate::error::BotError;
|
use crate::error::BotError;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
pub mod jmap;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1277,31 +1277,11 @@ async fn jmap_push(State(app): State<AppState>, body: Bytes) -> Response {
|
||||||
.await,
|
.await,
|
||||||
Ok(true)
|
Ok(true)
|
||||||
) {
|
) {
|
||||||
let limit_key = format!("ratelimit:push-verify:{subscription}");
|
// Persist the verification first so a retry within the
|
||||||
if matches!(
|
// push-verify rate window still finds the key (RFC 8620 §7.2
|
||||||
app.state
|
// verification is the callback response, which we always send).
|
||||||
.claim_dedup(&limit_key, ttl::PUSH_VERIFY_LIMIT_SECONDS)
|
// Ordering remember before the rate limiter means the key is
|
||||||
.await,
|
// written even when the outbound verify API call is suppressed.
|
||||||
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)");
|
|
||||||
}
|
|
||||||
if app
|
if app
|
||||||
.state
|
.state
|
||||||
.remember_push_subscription(subscription, code, ttl::PUSH_SUBSCRIPTION_SECONDS)
|
.remember_push_subscription(subscription, code, ttl::PUSH_SUBSCRIPTION_SECONDS)
|
||||||
|
|
@ -1322,6 +1302,28 @@ async fn jmap_push(State(app): State<AppState>, body: Bytes) -> Response {
|
||||||
ttl::PUSH_STATUS_VERIFIED_SECONDS,
|
ttl::PUSH_STATUS_VERIFIED_SECONDS,
|
||||||
)
|
)
|
||||||
.await;
|
.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 {
|
} else {
|
||||||
// Event push (RFC 8620 §7.3): no verificationCode; the subscription
|
// Event push (RFC 8620 §7.3): no verificationCode; the subscription
|
||||||
|
|
@ -2461,7 +2463,12 @@ mod tests {
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.await,
|
.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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue