diff --git a/docs/design.md b/docs/design.md index 707aea9..a619097 100644 --- a/docs/design.md +++ b/docs/design.md @@ -638,14 +638,17 @@ Not implemented, see `docs/retired.md`. The Push event is consumed by Streams and delivered to Telegram. Its key path delivery semantics are as follows: **The only authoritative source for keys and TTL is `docs/reference.md` (section Redis Keys and TTL). **The tables will not be repeated in this section - repeated tables -Will be modified: `push:subscription:{id}` was previously marked as 7d, and `push:registration:{...}` was marked as 360s registration solo lock. -Both are wrong, and the real 360s solo lock `lock:push-register:{sha256(callback_url)}` was missing the entire line. has now converged to -Single authority table, this section only retains four "whys" that affect design judgment: +Historical note: `push:subscription:{id}` was previously marked as 7d in docs but the code used 300s — a bug that caused event pushes to fail with 401 after 5 minutes +(because Stalwart only sends the verification request once, at subscription creation, and never re-verifies). Now corrected to 7d (`WEEK`), aligned with `PUSH_REGISTRATION_SECONDS`. +The `push:registration:{...}` was marked as 360s registration solo lock, which was also wrong; the real 360s solo lock is `lock:push-register:{sha256(callback_url)}`. +All values have now converged to the single authority table in `docs/reference.md`. This section retains five "whys" that affect design judgment: - **Reconciliation lock TTL 60s, with heartbeat renewal every 10s to 30s during active processing**, ensuring a crashed process releases the lock within 60s instead of blocking reconciliation (`REQ-RECONCILE-IDEMPOTENCY`, `SAF-RECONCILE-LOCK`). - **push registration solo lock TTL 360s, deliberately larger than the upper limit of 300s for a single outbound request**, otherwise a slow registration will cause repeated requests (`SAF-AUTH-JMAP-PUSH`). +- **Push subscription verification key TTL 7d (`WEEK`)**: Stalwart sends the verification request (RFC 8620 §7.2) once at subscription creation; event pushes (§7.3) arrive without + `verificationCode` and rely on this key existing to confirm prior verification. The key must outlast any gap between subscription creation and events, and survive restarts — Stalwart does not re-verify. - **Reconciliation deduplication 24h is intentionally designed**: the same email will only be notified once within 24h (`MOD-DEDUP`, see 11.5 of this article for the scope). - **If the configuration key is missing, it will be regarded as failed (fail-closed)** and the business path will not be entered. diff --git a/docs/design.zh-CN.md b/docs/design.zh-CN.md index a137905..0eb61f1 100644 --- a/docs/design.zh-CN.md +++ b/docs/design.zh-CN.md @@ -638,14 +638,17 @@ pub enum BotError { Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义如下: **键与 TTL 的唯一权威来源是 `docs/reference.md`(Redis 键与 TTL 各节)。**本节不再重复表格——重复的表 -会被改坏:此前曾把 `push:subscription:{id}` 标成 7d、把 `push:registration:{...}` 标成 360s 注册单飞锁, -两处都错,而真正的 360s 单飞锁 `lock:push-register:{sha256(callback_url)}` 当时整行缺失。现已收敛到 -单一权威表,本节只保留影响设计判断的四条"为什么": +历史说明:`push:subscription:{id}` 此前在文档中标为 7d 但代码实为 300s——这是一个 bug,导致事件 push 在 5 分钟后全部 401 失败 +(因为 Stalwart 仅在订阅创建时发送一次验证请求,之后不再重验)。现已修正为 7d(`WEEK`),与 `PUSH_REGISTRATION_SECONDS` 对齐。 +`push:registration:{...}` 曾被标为 360s 注册单飞锁,亦为误标;真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`。 +所有值现已收敛到 `docs/reference.md` 的单一权威表。本节保留影响设计判断的五条"为什么": - **对账锁 TTL 60s,活跃处理期间每 10s 心跳续期至 30s**,确保崩溃进程在 60s 内释放锁、不阻塞对账 (`REQ-RECONCILE-IDEMPOTENCY`、`SAF-RECONCILE-LOCK`)。 - **push 注册单飞锁 TTL 360s,刻意大于单条出站请求上限 300s**,否则一次慢注册会放进重复请求 (`SAF-AUTH-JMAP-PUSH`)。 +- **push 订阅验证键 TTL 7d(`WEEK`)**:Stalwart 仅在订阅创建时发送验证请求(RFC 8620 §7.2),事件 push(§7.3)不带 + `verificationCode`,依赖此键存在来确认已验证过。TTL 必须长于订阅创建到首条事件之间的任何间隔,且能跨重启存活——Stalwart 不会重验。 - **对账去重 24h 是有意设计**:同一封邮件 24h 内只通知一次(`MOD-DEDUP`,范围见本文 11.5)。 - **配置键缺键即视为关闭(fail-closed)**,不进入业务路径。 diff --git a/docs/opengaps.md b/docs/opengaps.md index 2b0caa1..1e6a9c8 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -57,7 +57,7 @@ Contract baseline (see `docs/reference.md` §1 for the complete semantics of key | `ttl::PUSH_DISABLED_SECONDS` | 86_400 | `push:subscription:{id}:status` (`disabled`) | | `ttl::PUSH_STATUS_PENDING_SECONDS` | 900 | `push:subscription:{id}:status` (`pending`) | | `ttl::PUSH_STATUS_VERIFIED_SECONDS` | 300 | `push:subscription:{id}:status` (`verified`) | -| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 300 | `push:subscription:{id}` / `push:subscription-code:{code}` | +| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 604_800 | `push:subscription:{id}` | | `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` | | `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` | | `ttl::ADMIN_SESSION_SECONDS` | 1,800 | `admin-session:{token}` (also the `expires_in` returned by `/admin/session`) | diff --git a/docs/opengaps.zh-CN.md b/docs/opengaps.zh-CN.md index 749174c..e45a1fd 100644 --- a/docs/opengaps.zh-CN.md +++ b/docs/opengaps.zh-CN.md @@ -57,7 +57,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1), | `ttl::PUSH_DISABLED_SECONDS` | 86_400 | `push:subscription:{id}:status`(`disabled`) | | `ttl::PUSH_STATUS_PENDING_SECONDS` | 900 | `push:subscription:{id}:status`(`pending`) | | `ttl::PUSH_STATUS_VERIFIED_SECONDS` | 300 | `push:subscription:{id}:status`(`verified`) | -| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 300 | `push:subscription:{id}` / `push:subscription-code:{code}` | +| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 604_800 | `push:subscription:{id}` | | `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` | | `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` | | `ttl::ADMIN_SESSION_SECONDS` | 1,800 | `admin-session:{token}`(同时是 `/admin/session` 返回的 `expires_in`) | diff --git a/docs/reference.md b/docs/reference.md index 2638c09..f6dc445 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -139,7 +139,7 @@ duplicate. Source comment: | Key | TTL | Writer | Reader | |---|---|---|---| -| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription`, called from `register_push` | `push_subscription_verified` | +| `push:subscription:{id}` | EX 604_800 (caller-supplied, min 1) | `remember_push_subscription`, called from `jmap_push` verification path | `push_subscription_verified` | | `push:subscription:id` | No EX | `remember_push_subscription_id` | current-subscription lookup | | `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status`; `pending` on verification request, `verified` on success, `disabled` on disable | **none** | | `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback`, called from `register_push` | `get_push_subscription_for_callback`; removed by `remove_push_subscription_for_callback` | diff --git a/docs/reference.zh-CN.md b/docs/reference.zh-CN.md index b536e6f..6d1e9bd 100644 --- a/docs/reference.zh-CN.md +++ b/docs/reference.zh-CN.md @@ -138,12 +138,12 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本 | 键 | TTL | 写入方 | 读取方 | |---|---|---|---| -| `push:subscription:{id}` | EX 300(调用方提供,最小 1)| `remember_push_subscription`,从 `register_push` 调用 | `push_subscription_verified` | +| `push:subscription:{id}` | EX 604_800(调用方提供,最小 1)| `remember_push_subscription`,从 `jmap_push` 验证路径调用 | `push_subscription_verified` | | `push:subscription:id` | 无 EX | `remember_push_subscription_id` | 当前订阅查询 | | `push:subscription:{id}:status` | 900 / 300 / 86_400(强制最小 1)| `set_push_subscription_status`;验证请求时为 `pending`,成功时为 `verified`,禁用时为 `disabled` | **无** | | `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback`,从 `register_push` 调用 | `get_push_subscription_for_callback`;通过 `remove_push_subscription_for_callback` 删除 | -| `push:registration:current` |前 604_800 | `set_push_registration_current`,从 `register_push` 调用 | `get_push_registration_current`;通过 `clear_push_registration_current` 清除,`disable_push` 经 `clear_push_registration_current_if` 清除 | -| `push:orphan:{subscription_id}` |前 604_800 | `record_push_orphan` |孤儿扫荡| +| `push:registration:current` | EX 604_800 | `set_push_registration_current`,从 `register_push` 调用 | `get_push_registration_current`;通过 `clear_push_registration_current` 清除,`disable_push` 经 `clear_push_registration_current_if` 清除 | +| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` |孤儿扫荡| **三个不同的键经常被混淆。在编辑之前请阅读本文。** diff --git a/src/state.rs b/src/state.rs index 8f0bef6..ed1711c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -65,8 +65,15 @@ pub(crate) mod ttl { pub const PUSH_STATUS_PENDING_SECONDS: u64 = 900; /// How long a `verified` push subscription stays trusted before re-check. pub const PUSH_STATUS_VERIFIED_SECONDS: u64 = 300; - /// Push subscription code and its link to the subscription id. - pub const PUSH_SUBSCRIPTION_SECONDS: u64 = 300; + /// Push subscription verification key: `push:subscription:{id}` = + /// `session_digest(verification_code)`. Stalwart sends the verification + /// request (RFC 8620 §7.2) once at subscription creation; event pushes + /// (§7.3) arrive without `verificationCode` and rely on this key existing + /// to confirm prior verification. TTL must outlast any realistic gap + /// between subscription creation and the first event, and survive across + /// restarts — Stalwart does not re-verify. Aligned with + /// `PUSH_REGISTRATION_SECONDS` (1 week). + pub const PUSH_SUBSCRIPTION_SECONDS: u64 = WEEK; /// Callback-URL -> subscription-id registration window. pub const PUSH_REGISTRATION_SECONDS: u64 = WEEK; /// Orphan callback attribution window: a callback with an unknown @@ -1617,7 +1624,7 @@ mod tests { assert_eq!(ttl::PUSH_STATUS_PENDING_SECONDS, 900); assert_eq!(ttl::PUSH_STATUS_VERIFIED_SECONDS, 300); - assert_eq!(ttl::PUSH_SUBSCRIPTION_SECONDS, 300); + assert_eq!(ttl::PUSH_SUBSCRIPTION_SECONDS, 604_800); assert_eq!(ttl::PUSH_REGISTRATION_SECONDS, 604_800); assert_eq!(ttl::PUSH_ORPHAN_SECONDS, 604_800); assert_eq!(ttl::ADMIN_SESSION_SECONDS, 1_800);