fix(push): verification key should never expire (TTL 0)
User feedback: any finite TTL causes push to silently stop working eventually. Stalwart does not re-verify (RFC 8620 §7.2 sends the verification request once at subscription creation). The key is explicitly deleted by forget_push_subscription when the subscription is retired, so no TTL is needed. Changes: - PUSH_SUBSCRIPTION_SECONDS: WEEK → 0 (never expires) - remember_push_subscription: ttl_seconds=0 → SET without EX (Redis) / expiry=i64::MAX (MemoryState) - ttl_contract_is_pinned test: assert 0 - docs: reference/opengaps/design updated to 'never expires'
This commit is contained in:
parent
d452f95485
commit
7331cb7d31
|
|
@ -639,7 +639,8 @@ The Push event is consumed by Streams and delivered to Telegram. Its key path de
|
|||
|
||||
**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
|
||||
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`.
|
||||
(because Stalwart only sends the verification request once, at subscription creation, and never re-verifies). Now corrected to **never expire (TTL 0)**: the key is explicitly deleted by
|
||||
`forget_push_subscription` when the subscription is retired, so no TTL is needed.
|
||||
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:
|
||||
|
||||
|
|
@ -647,8 +648,9 @@ All values have now converged to the single authority table in `docs/reference.m
|
|||
(`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.
|
||||
- **Push subscription verification key has no TTL (0 = never expires)**: 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. Any finite TTL would cause push to silently stop working — Stalwart does not re-verify.
|
||||
The key is explicitly deleted by `forget_push_subscription` when the subscription is retired.
|
||||
- **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.
|
||||
|
||||
|
|
|
|||
|
|
@ -639,7 +639,8 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
|
|||
|
||||
**键与 TTL 的唯一权威来源是 `docs/reference.md`(Redis 键与 TTL 各节)。**本节不再重复表格——重复的表
|
||||
历史说明:`push:subscription:{id}` 此前在文档中标为 7d 但代码实为 300s——这是一个 bug,导致事件 push 在 5 分钟后全部 401 失败
|
||||
(因为 Stalwart 仅在订阅创建时发送一次验证请求,之后不再重验)。现已修正为 7d(`WEEK`),与 `PUSH_REGISTRATION_SECONDS` 对齐。
|
||||
(因为 Stalwart 仅在订阅创建时发送一次验证请求,之后不再重验)。现已修正为**永不过期(TTL 0)**:订阅被注销时由
|
||||
`forget_push_subscription` 显式删除,不靠 TTL 兜底。
|
||||
`push:registration:{...}` 曾被标为 360s 注册单飞锁,亦为误标;真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`。
|
||||
所有值现已收敛到 `docs/reference.md` 的单一权威表。本节保留影响设计判断的五条"为什么":
|
||||
|
||||
|
|
@ -647,8 +648,9 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
|
|||
(`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 不会重验。
|
||||
- **push 订阅验证键无 TTL(0 = 永不过期)**:Stalwart 仅在订阅创建时发送验证请求(RFC 8620 §7.2),事件 push(§7.3)不带
|
||||
`verificationCode`,依赖此键存在来确认已验证过。任何有限 TTL 都会导致 push 在某天静默失效——Stalwart 不会重验。
|
||||
订阅被注销时由 `forget_push_subscription` 显式删除。
|
||||
- **对账去重 24h 是有意设计**:同一封邮件 24h 内只通知一次(`MOD-DEDUP`,范围见本文 11.5)。
|
||||
- **配置键缺键即视为关闭(fail-closed)**,不进入业务路径。
|
||||
|
||||
|
|
|
|||
|
|
@ -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` | 604_800 | `push:subscription:{id}` |
|
||||
| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 0 (never expires) | `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`) |
|
||||
|
|
|
|||
|
|
@ -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` | 604_800 | `push:subscription:{id}` |
|
||||
| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 0(永不过期) | `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`) |
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ duplicate. Source comment:
|
|||
|
||||
| Key | TTL | Writer | Reader |
|
||||
|---|---|---|---|
|
||||
| `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 TTL (0 = never expires) | `remember_push_subscription`, called from `jmap_push` verification path | `push_subscription_verified`, `forget_push_subscription` |
|
||||
| `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` |
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本
|
|||
|
||||
| 键 | TTL | 写入方 | 读取方 |
|
||||
|---|---|---|---|
|
||||
| `push:subscription:{id}` | EX 604_800(调用方提供,最小 1)| `remember_push_subscription`,从 `jmap_push` 验证路径调用 | `push_subscription_verified` |
|
||||
| `push:subscription:{id}` | 无 TTL(0 = 永不过期) | `remember_push_subscription`,从 `jmap_push` 验证路径调用 | `push_subscription_verified`、`forget_push_subscription` |
|
||||
| `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` 删除 |
|
||||
|
|
|
|||
47
src/state.rs
47
src/state.rs
|
|
@ -69,11 +69,11 @@ pub(crate) mod ttl {
|
|||
/// `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;
|
||||
/// to confirm prior verification. **TTL 0 = never expires**: Stalwart
|
||||
/// does not re-verify, so any finite TTL would cause push to silently
|
||||
/// stop working. The key is explicitly deleted by `forget_push_subscription`
|
||||
/// when the subscription is retired.
|
||||
pub const PUSH_SUBSCRIPTION_SECONDS: u64 = 0;
|
||||
/// Callback-URL -> subscription-id registration window.
|
||||
pub const PUSH_REGISTRATION_SECONDS: u64 = WEEK;
|
||||
/// Orphan callback attribution window: a callback with an unknown
|
||||
|
|
@ -830,13 +830,24 @@ impl ReliableState for RedisState {
|
|||
ttl_seconds: u64,
|
||||
) -> Result<(), StateError> {
|
||||
let mut connection = self.connection.clone();
|
||||
redis::cmd("SET")
|
||||
.arg(format!("push:subscription:{subscription_id}"))
|
||||
.arg(crate::config::session_digest(verification_code))
|
||||
.arg("EX")
|
||||
.arg(ttl_seconds.max(1))
|
||||
.query_async::<()>(&mut connection)
|
||||
.await?;
|
||||
let key = format!("push:subscription:{subscription_id}");
|
||||
let value = crate::config::session_digest(verification_code);
|
||||
if ttl_seconds == 0 {
|
||||
// No expiration — Stalwart does not re-verify (RFC 8620 §7.2).
|
||||
redis::cmd("SET")
|
||||
.arg(&key)
|
||||
.arg(&value)
|
||||
.query_async::<()>(&mut connection)
|
||||
.await?;
|
||||
} else {
|
||||
redis::cmd("SET")
|
||||
.arg(&key)
|
||||
.arg(&value)
|
||||
.arg("EX")
|
||||
.arg(ttl_seconds.max(1))
|
||||
.query_async::<()>(&mut connection)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -1357,16 +1368,18 @@ impl ReliableState for MemoryState {
|
|||
verification_code: &str,
|
||||
ttl_seconds: u64,
|
||||
) -> Result<(), StateError> {
|
||||
let expiry = if ttl_seconds == 0 {
|
||||
i64::MAX
|
||||
} else {
|
||||
unix_now().saturating_add(ttl_seconds as i64)
|
||||
};
|
||||
self.inner
|
||||
.lock()
|
||||
.map_err(|_| StateError::Poisoned)?
|
||||
.push_subscriptions
|
||||
.insert(
|
||||
subscription_id.to_owned(),
|
||||
(
|
||||
crate::config::session_digest(verification_code),
|
||||
unix_now().saturating_add(ttl_seconds as i64),
|
||||
),
|
||||
(crate::config::session_digest(verification_code), expiry),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1624,7 +1637,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, 604_800);
|
||||
assert_eq!(ttl::PUSH_SUBSCRIPTION_SECONDS, 0);
|
||||
assert_eq!(ttl::PUSH_REGISTRATION_SECONDS, 604_800);
|
||||
assert_eq!(ttl::PUSH_ORPHAN_SECONDS, 604_800);
|
||||
assert_eq!(ttl::ADMIN_SESSION_SECONDS, 1_800);
|
||||
|
|
|
|||
Loading…
Reference in New Issue