feat(notifications): cap push subscriptions per user across channels (LC-147) #180

Merged
nrupard merged 1 commit from feat/lc-147-cap-push-subscriptions into main 2026-05-22 18:19:49 +02:00
Owner

What

Bounds the number of active push subscriptions a single user can hold per channel, closing the unbounded-token-registration gap raised in the LC-91 review (LC-147).

Why

An authenticated user could POST many distinct tokens to /push/subscribe (Web Push), /push/apns, or /push/fcm, growing push_subscriptions / apns_subscriptions / fcm_subscriptions without bound. The upsert dedups by token, so a real device re-registering never grew the table; the abuse vector is a logged-in client minting distinct fake tokens (storage abuse + slower for_user fan-out lookups over time). It predates mobile push (Web Push had the same gap), so the fix is applied uniformly to all three channels.

How

  • MAX_PUSH_SUBSCRIPTIONS_PER_USER = 20 in db/mod.rs (per-user, per-channel; generous for real multi-device use, far below abuse).
  • Each insert_or_replace now calls a private evict_over_cap after the upsert: DELETE the user's rows whose id is not among the cap most-recently-seen (ORDER BY last_seen_at DESC, id DESC LIMIT cap). The row just upserted has last_seen_at = now, so it always survives; the id DESC tie-break makes a burst of same-second registrations evict deterministically (oldest id first).
  • Applied identically across Web Push, APNs, and FCM, mirroring how LC-91 dispatch treats the channels uniformly.

Acceptance criteria

  • Registering more than the cap distinct tokens keeps only the N most-recently-seen rows in that channel's table.
  • Enforced identically for Web Push, APNs, and FCM.
  • Re-registering an existing token upserts in place and does not consume a new slot.
  • Integration test per channel asserts the eviction (register N+2, assert N remain, two earliest evicted) and the re-register-in-place case.

Out of scope

  • Rate-limiting the register endpoints themselves (separate from the row cap).
  • Any change to dispatch fan-out behavior.

Test

just check, just test, just test-saas green (the routes_uploads upload-pipeline flake under concurrent-binary load is pre-existing and passes in isolation). Six new tests (two per channel). No schema change.

🤖 Generated with Claude Code

## What Bounds the number of active push subscriptions a single user can hold per channel, closing the unbounded-token-registration gap raised in the LC-91 review (LC-147). ## Why An authenticated user could POST many distinct tokens to `/push/subscribe` (Web Push), `/push/apns`, or `/push/fcm`, growing `push_subscriptions` / `apns_subscriptions` / `fcm_subscriptions` without bound. The upsert dedups by token, so a real device re-registering never grew the table; the abuse vector is a logged-in client minting distinct fake tokens (storage abuse + slower `for_user` fan-out lookups over time). It predates mobile push (Web Push had the same gap), so the fix is applied uniformly to all three channels. ## How - `MAX_PUSH_SUBSCRIPTIONS_PER_USER = 20` in `db/mod.rs` (per-user, per-channel; generous for real multi-device use, far below abuse). - Each `insert_or_replace` now calls a private `evict_over_cap` after the upsert: `DELETE` the user's rows whose id is not among the `cap` most-recently-seen (`ORDER BY last_seen_at DESC, id DESC LIMIT cap`). The row just upserted has `last_seen_at = now`, so it always survives; the `id DESC` tie-break makes a burst of same-second registrations evict deterministically (oldest id first). - Applied identically across Web Push, APNs, and FCM, mirroring how LC-91 dispatch treats the channels uniformly. ## Acceptance criteria - [x] Registering more than the cap distinct tokens keeps only the N most-recently-seen rows in that channel's table. - [x] Enforced identically for Web Push, APNs, and FCM. - [x] Re-registering an existing token upserts in place and does not consume a new slot. - [x] Integration test per channel asserts the eviction (register N+2, assert N remain, two earliest evicted) and the re-register-in-place case. ## Out of scope - Rate-limiting the register endpoints themselves (separate from the row cap). - Any change to dispatch fan-out behavior. ## Test `just check`, `just test`, `just test-saas` green (the `routes_uploads` upload-pipeline flake under concurrent-binary load is pre-existing and passes in isolation). Six new tests (two per channel). No schema change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(notifications): cap push subscriptions per user across channels (LC-147)
All checks were successful
check-secrets / Nosey parker (push) Successful in 6s
check-secrets / TruffleHog (push) Successful in 7s
check-secrets / Kingfisher (push) Successful in 9s
check-secrets / Nosey parker (pull_request) Successful in 3s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 1m25s
Create release / Create release from merged PR (pull_request) Has been skipped
995716f485
An authenticated user could register an unbounded number of distinct push tokens. The upsert dedups by token, so a real device re-registering does not grow the table, but a logged-in client posting many distinct fake tokens to /push/subscribe, /push/apns, or /push/fcm grew the rows without bound (storage abuse + slower per-user fan-out lookups over time). Surfaced in the LC-91 review; it predates mobile push (Web Push had the same gap), so the fix lands uniformly across all three channels.

Adds a per-user-per-channel cap (MAX_PUSH_SUBSCRIPTIONS_PER_USER = 20 in db/mod.rs). Each insert_or_replace now evicts the user's least-recently-seen rows beyond the cap after the upsert, ordered by last_seen_at DESC with an id DESC tie-break so a burst of same-second registrations evicts deterministically (oldest id first). The row just upserted has last_seen_at = now, so it always survives. Web Push, APNs, and FCM each get their own budget, mirroring how LC-91 dispatch already treats the channels uniformly.

Tests per channel: registering cap + 2 distinct tokens keeps exactly cap rows with the two earliest evicted; re-registering an existing token upserts in place without consuming a new slot or triggering eviction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-147-cap-push-subscriptions 2026-05-22 18:19:49 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!180
No description provided.