mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-22 01:34:59 +02:00
566 lines
32 KiB
Markdown
566 lines
32 KiB
Markdown
# Push notifications
|
||
|
||
How push notifications work in this project: the storage and registration model,
|
||
the delivery pipeline, the Web Push browser client, the notification settings UI,
|
||
and how a native (Capacitor) client integrates through the same API.
|
||
|
||
The backend (all three transports — APNs / FCM / Web Push), the
|
||
device-registration API, device management, the Web Push browser/PWA client and
|
||
the native (Capacitor) iOS/Android client are implemented. The native client
|
||
(`src/frontend/src/features/native/push.ts`) registers through the same
|
||
`POST /users/me/channels/` and is served by the same senders; the per-platform
|
||
project config (APNs entitlement, loc-key strings, Android permission,
|
||
conditional google-services) ships in the `ios/` / `android/` projects, and
|
||
operators supply the per-instance credentials (§3, §12).
|
||
|
||
---
|
||
|
||
## 1. Storage & registration
|
||
|
||
- A device is a **user-scoped** `Channel(type="push")`. The opaque token lives
|
||
encrypted in `encrypted_settings.token` (+ `keys` for Web Push); `settings`
|
||
holds `platform` and `app_version`; the dedup/reclaim key is the indexed,
|
||
globally-unique `Channel.lookup_hash` column (sha256 of the `push:`-prefixed token).
|
||
- **Register / refresh:** `POST /api/v1.0/users/me/channels/` with `{type:
|
||
"push", platform, token, keys?, name?, app_version?}`. The collection POST
|
||
routes `type=push` to an idempotent upsert (201 first time, 200 on refresh);
|
||
reclaims a token from another user on account switch. 404 when `PUSH_ENABLED`
|
||
is off. Throttled per user.
|
||
- **List / revoke:** the normal `GET`/`DELETE /api/v1.0/users/me/channels/`.
|
||
Push channels are blocked from create/PATCH through the generic endpoint.
|
||
- **`platform` is a transport, not an OS:** `apns` / `fcm` / `web`. The OS label
|
||
is a frontend concern carried in the channel `name`, never inferred from `platform`.
|
||
- **Payloads carry no message content:** only `{type, thread_id, message_id,
|
||
mailbox_id, unread_count}` (routing ids + badge). The device wakes and
|
||
refetches over its authenticated session — the push never carries
|
||
subject/body/sender. Note only Web Push is end-to-end encrypted (RFC 8291); for
|
||
APNs/FCM those routing ids and the count are visible to Apple/Google in
|
||
transit, the content never is.
|
||
- Push is the **app-closed** half of notifications; the realtime SSE relay is
|
||
the **app-open** half. They must cooperate (see §6).
|
||
|
||
---
|
||
|
||
## 2. The core idea: one frontend, three transports
|
||
|
||
The same React app runs in three runtimes; each yields a different transport
|
||
that maps 1:1 onto a backend sender:
|
||
|
||
| Runtime | Token source | `platform` | Backend sender |
|
||
|---|---|---|---|
|
||
| Capacitor **iOS** (native) | `@capacitor/push-notifications` → **APNs** device token | `apns` | `send_apns` |
|
||
| Capacitor **Android** (native) | `@capacitor/push-notifications` → **FCM** registration token | `fcm` | `send_fcm` |
|
||
| **Browser / installed PWA** | Web Push API (`PushManager.subscribe`) | `web` | `send_webpush` |
|
||
|
||
Pick the path at runtime:
|
||
|
||
```ts
|
||
import { Capacitor } from "@capacitor/core";
|
||
|
||
const transport = Capacitor.isNativePlatform()
|
||
? (Capacitor.getPlatform() === "ios" ? "apns" : "fcm")
|
||
: "web";
|
||
```
|
||
|
||
Notes:
|
||
- `@capacitor/push-notifications` returns the **APNs** token on iOS and the
|
||
**FCM** token on Android out of the box — which is exactly our two native
|
||
transports. (Don't reach for `@capacitor-firebase/messaging` unless you want
|
||
FCM on iOS too; we don't.)
|
||
- **iOS WKWebView does not support the Web Push API.** Inside the native iOS
|
||
shell you must use the native plugin (`apns`); `web` is only for real
|
||
browsers / installed PWAs. Android WebView likewise: use the native plugin.
|
||
|
||
---
|
||
|
||
## 3. Configuration & client requirements
|
||
|
||
### `/config` exposes push capability + the VAPID public key
|
||
`/config` exposes:
|
||
- `PUSH_ENABLED` (bool) — so the UI shows/hides notification controls instead
|
||
of probing the registration endpoint and reacting to a 404.
|
||
- `PUSH_VAPID_PUBLIC_KEY` (base64url string) — **required** for the browser to
|
||
call `PushManager.subscribe({ applicationServerKey })`. It's public by
|
||
definition. Derive it once from `PUSH_VAPID_PRIVATE_KEY` with the
|
||
`derive_vapid_public_key` management command and pin the printed value in this
|
||
env var — `/config` serves it verbatim and never derives it on the request
|
||
path (that would force the web worker to import the push/crypto graph). Without
|
||
it, web push cannot work. See §12 for the full operator checklist.
|
||
|
||
Native (`apns`/`fcm`) needs neither — the OS plugins carry their own creds
|
||
(APNs entitlement, bundled `google-services.json`).
|
||
|
||
### Client-side localization strings (native client)
|
||
The senders emit a **visible, high-priority, content-free** alert (it survives
|
||
force-quit, where a silent background push would be throttled and dropped).
|
||
Because the push carries only localization *keys* — never message content — the
|
||
native apps ship the matching strings so the OS can render the banner:
|
||
- **iOS:** the alert carries `alert.loc-key = "NEW_MESSAGE"` + the unread badge;
|
||
the matching `Localizable.strings` entries ship in
|
||
`ios/App/App/{en,fr}.lproj/`.
|
||
- **Android:** the FCM message carries an OS-localized `notification` block
|
||
(`title_loc_key` / `body_loc_key`); the matching `new_message_*` entries ship
|
||
in `android/app/src/main/res/values{,-fr}/strings.xml`. The OS displays it
|
||
automatically even when the app is killed. It renders on the `new_messages`
|
||
notification channel — created at HIGH importance by the app
|
||
(`ensureAndroidNotificationChannel`, features/native/push.ts), targeted by
|
||
`channel_id` in the message (`FCM_ANDROID_CHANNEL_ID`, fcm.py) and declared
|
||
as manifest default, with the monochrome `ic_stat_notification` status icon.
|
||
Without it Android 8+ would fall back to the SDK's "Miscellaneous" channel at
|
||
DEFAULT importance (no heads-up).
|
||
|
||
No server toggle is involved — visible alerts are the built-in behavior; if the
|
||
loc-key strings are missing the banner renders blank. (The Web Push client needs
|
||
no such strings: the service worker renders the banner text itself.)
|
||
|
||
### Native app project config (native client)
|
||
- iOS — in the repo: the Push Notifications capability
|
||
(`ios/App/App/App.entitlements`, `aps-environment = development` — Xcode's
|
||
distribution export rewrites it to `production`) and Background Modes (remote
|
||
notifications, `Info.plist`). Operator-supplied: an APNs auth key (`.p8`) →
|
||
`PUSH_APNS_KEY/_KEY_ID/_TEAM_ID`, bundle id → `PUSH_APNS_BUNDLE_ID` (the App
|
||
ID must have the Push capability enabled in the Apple developer portal).
|
||
`PUSH_APNS_USE_SANDBOX` must match the build's signing: `True` for
|
||
development-signed builds (`aps-environment = development`), `False` for
|
||
distribution.
|
||
- Android — in the repo: the `POST_NOTIFICATIONS` permission and a conditional
|
||
`com.google.gms.google-services` apply (skipped with a log when the file is
|
||
absent, so push-less builds still work). Operator-supplied: the Firebase
|
||
project's `google-services.json` dropped into `android/app/` (gitignored,
|
||
per-instance like `MOBILE_APP_ID`) — it must contain a client whose package
|
||
name equals the build's `applicationId` (`MOBILE_APP_ID`), or the build
|
||
fails at the google-services step; service-account JSON →
|
||
`PUSH_FCM_CREDENTIALS`, project id → `PUSH_FCM_PROJECT_ID`. (No sandbox
|
||
switch — staging is a separate Firebase project.)
|
||
|
||
Developer-facing walkthrough (dev Firebase project, sandbox pairing, smoke
|
||
test): [mobile.md](./mobile.md), *Push notifications in dev*.
|
||
|
||
### Auth inside the WebView (native client — not push-specific, but a dependency)
|
||
The app authenticates via OIDC and calls the API with cookies + CSRF. In a
|
||
Capacitor shell the app origin is `capacitor://localhost` / `https://localhost`
|
||
while the API is remote, so session cookies are cross-site: they need
|
||
`SameSite=None; Secure`, and the OAuth round-trip must go through the system
|
||
browser (`ASWebAuthenticationSession` / Chrome Custom Tabs) with a deep-link
|
||
redirect back. This is a prerequisite for the native client, independent of push.
|
||
|
||
---
|
||
|
||
## 4. Registration lifecycle (client)
|
||
|
||
1. **Ask for permission contextually** — not on first launch. Tie it to a value
|
||
moment or an explicit "Enable notifications" toggle (§5). Browsers penalize
|
||
prompt-on-load; iOS only lets you ask once.
|
||
2. **Get the token:**
|
||
- native: `PushNotifications.register()` → `registration` event → token.
|
||
- web: register the service worker, then
|
||
`reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })`
|
||
where the key is `PUSH_VAPID_PUBLIC_KEY` from `/config`. Send the
|
||
subscription `endpoint` as `token` and `{p256dh, auth}` as `keys`.
|
||
3. **Register:** `POST /users/me/channels/` with `{type: "push", platform,
|
||
token, keys?, name, app_version}`. Use a human device name
|
||
(`@capacitor/device` → model). **Store the registered *token* locally** —
|
||
device sign-out (§7) recognises this device's server row by comparing
|
||
`sha256("push:" + token)` to the row's `token_hash`. Nothing needs storing
|
||
for logout: revocation there is server-side (step 5).
|
||
4. **Re-register** whenever the token rotates (`registration` again / web
|
||
`pushsubscriptionchange`), and on launch as a cheap idempotent refresh. Note
|
||
this is a client convention, not an OS guarantee — the platforms don't *force*
|
||
a per-launch round-trip; re-registering is just the most reliable way to catch
|
||
a silently-rotated token (strongest on iOS, optional on Android/Web).
|
||
5. **On logout:** handled *server-side*. Each registration stamps the channel
|
||
with a hash of the registering Django session; the `user_logged_out` receiver
|
||
(`core/signals.py`) deletes the channels bound to the session being destroyed.
|
||
So a **voluntary logout** silences exactly that device — even if no client
|
||
code runs — while a session that merely **expires** never reaches the logout
|
||
view authenticated, so its channels survive and notifications keep flowing
|
||
(content-free banner until the next login re-authenticates enrichment).
|
||
Clients therefore do NOT need to `DELETE` or `unsubscribe()` at logout; the
|
||
re-registration on next login (step 4) transparently resumes delivery. The
|
||
full decision tree (incl. shared-computer takeover) is diagrammed in §12 →
|
||
*Web Push specifics* → *Web session lifecycle — flow*.
|
||
|
||
---
|
||
|
||
## 5. Receiving, displaying, deep-linking
|
||
|
||
- **Tap → deep link:** the payload carries `thread_id` / `message_id`; route to
|
||
the thread on notification open. Register the tap handler
|
||
(`pushNotificationActionPerformed` / SW `notificationclick`).
|
||
- **Badge:** from `unread_count` (set as `aps.badge` on the iOS alert path; app
|
||
sets it on Android/Web where supported).
|
||
- **Richer content without weakening privacy (fetch-to-enrich):** the thin push +
|
||
`mutable-content: 1` supports a **fetch-to-enrich** flow — the client fetches
|
||
the message over the authenticated session and *rewrites* the banner with
|
||
sender/subject, keeping content off the push transport. The **Web Push service
|
||
worker does this today** (it fetches `/api/v1.0/messages/{id}` and falls back to
|
||
a generic banner if the fetch fails). The native equivalent — an iOS
|
||
Notification Service Extension / Android data handler — is the same pattern for
|
||
the native client.
|
||
|
||
---
|
||
|
||
## 6. Cooperation with the realtime SSE relay
|
||
|
||
When the app is **open** and in front of the user, it surfaces the mail on its
|
||
own (today the 30s mailbox poll; the SSE relay when it lands) — a banner for it
|
||
is noise. So no transport alerts while the app holds the foreground; only the
|
||
badge tracks.
|
||
- **Native (iOS):** the foreground-presentation handler is limited to the badge
|
||
(`presentationOptions: ["badge"]` in `capacitor.config.ts`) — no banner, no
|
||
sound.
|
||
- **Native (Android):** nothing to configure. FCM only auto-displays the
|
||
`notification` block when the app is backgrounded; in foreground it routes to
|
||
the app instead, which does not display it.
|
||
- **Web:** the service worker skips `showNotification` (and the app badge) when
|
||
`clients.matchAll()` reports a focused window **displaying the mailbox the
|
||
message landed in**, read from the window's URL (`/mailbox/{mailboxId}`). A
|
||
focused window on another mailbox or page still gets the banner: the 30s
|
||
poll only refreshes the mailbox being viewed, so nothing else would surface
|
||
the message. This is the one case
|
||
`userVisibleOnly: true` tolerates a silent push — browsers only substitute
|
||
their own "site updated in the background" notice when no window of the
|
||
origin is visible.
|
||
- Coordinate de-dup on `message_id` so a message that arrives via both SSE and
|
||
push updates the UI once.
|
||
|
||
---
|
||
|
||
## 7. The Integrations / Notifications UI
|
||
|
||
**Devices do not belong in the existing Integrations view.** That view is
|
||
per-*mailbox* (`/mailboxes/{id}/channels/`) for widget/webhook/api_key/CalDAV —
|
||
"connect an external system to this mailbox." Push devices are per-*user*
|
||
(`/users/me/channels/`), auto-registered by the OS permission flow, and are
|
||
"the phone/browser I read mail on." Different scope, different lifecycle,
|
||
different mental model. See §8 for the full rationale.
|
||
|
||
### New account-level "Notifications" section
|
||
- **Capability-gated** on `config.PUSH_ENABLED` (hide entirely otherwise).
|
||
- **This device:** a primary toggle "Enable notifications on this device" that
|
||
drives the OS permission prompt + device-registration `POST` / `DELETE`. Handle the
|
||
three states: granted, denied (link out to OS settings — you can't re-prompt),
|
||
unsupported (e.g. web push in an iOS WKWebView).
|
||
- **Your devices:** list every registered device — `name`, a platform icon
|
||
(derived from `platform`, or a stored OS hint), and **"Added on"** (the
|
||
date). Note: `last_used_at` is set at *registration*, not on each push, so
|
||
label it "Added", not "Last used" (or update `last_used_at` on send if you
|
||
want a true value). Highlight the current device. Per-row "Sign out this
|
||
device" → `DELETE`.
|
||
- **Empty state:** "No devices yet — enable notifications above."
|
||
- **Future:** granularity (mentions only / per-mailbox / quiet hours),
|
||
per-device rename.
|
||
|
||
### The existing (mailbox) Integrations view
|
||
No change needed — it lists mailbox-scoped channels and never queries
|
||
`/users/me/channels/`, so push devices can't leak in. If a unified "Integrations"
|
||
landing is ever built from `/users/me/channels/`, **filter out `type=push`**
|
||
there and surface devices only under Notifications.
|
||
|
||
---
|
||
|
||
## 8. Should "mobile apps" be a user-facing integration type? — No
|
||
|
||
- **Scope mismatch.** Integrations are mailbox-scoped; devices are user-scoped.
|
||
They live on different endpoints and don't share a list.
|
||
- **Mental model.** Nobody thinks of their own phone as an "integration." Apple
|
||
and Google call this "Notifications" / "Devices."
|
||
- **Lifecycle.** Integrations are deliberately *created* (mint a key, paste a
|
||
URL). Devices are *auto-registered* by the OS permission flow — there's no
|
||
"Add" form to show.
|
||
- **Actions.** Integrations: create / rotate / delete. Devices: toggle, rename,
|
||
sign out.
|
||
|
||
Keep `Channel(type="push")` as the storage mechanism (it gives us device
|
||
management for free), but present it as **Notifications settings**, not an
|
||
integration. The transport (`apns`/`fcm`/`web`) stays an implementation detail;
|
||
the user sees "iPhone / Android phone / this browser."
|
||
|
||
---
|
||
|
||
## 9. Security model — push-token reclaim
|
||
|
||
Device registration reclaims a token from another user on account switch
|
||
(`push.py`, `register_push_device`). `Channel.lookup_hash` (sha256 of the
|
||
`push:`-prefixed token) is globally unique. A re-registration by the **same** user updates that user's row
|
||
in place; a registration of a token currently owned by a **different** user
|
||
**deletes** that user's row and creates a **fresh** channel for the caller — it
|
||
does *not* reassign the existing row, so the new owner never inherits the previous
|
||
owner's channel id, `created_at` or device label. This is a **privacy guard**, not
|
||
a convenience — if user A logs out and user B logs in on the same physical device,
|
||
the OS may hand the app the same push token; without reclaim, A's private
|
||
notifications would keep flowing to a device now showing B's account.
|
||
|
||
**Known, accepted risk.** Reclaim is authorized purely by presenting a raw token
|
||
that hashes to the victim's stored `lookup_hash` — there's no proof the registrant
|
||
controls the device. So an authenticated user who obtains *another user's raw
|
||
push token* can take over that user's device channel.
|
||
|
||
Why we accept it (rather than the recency-at-send-time or proof-of-control
|
||
alternatives):
|
||
|
||
- **The stored hash is not the key.** `lookup_hash` is `sha256("push:" + token)`
|
||
and is preimage-resistant, so a DB/column leak does **not** enable the attack. The
|
||
attacker needs the *raw* token, which lives only encrypted in
|
||
`encrypted_settings.token` and on the device itself.
|
||
- **Impact is a self-healing notification DoS.** The victim silently stops
|
||
receiving pushes until their real device re-registers (every launch — step §4.4
|
||
re-registers idempotently), which recreates their channel and wins back the token.
|
||
- **No content disclosure.** The attacker does not hold the device, so they
|
||
receive nothing; payloads are content-free regardless (§1).
|
||
- Raw push tokens are device-held routing identifiers, not credentials issued to
|
||
other users — obtaining a victim's raw token already implies a meaningful
|
||
compromise (device access, malicious SDK, client log exfiltration).
|
||
|
||
If the threat model tightens (tokens treated as semi-public, or DoS on
|
||
notifications becomes unacceptable), the fix is to stop deleting another user's
|
||
row at registration and instead resolve token conflicts at **send time by
|
||
recency** (push only to the most recently registered channel sharing a
|
||
`lookup_hash`) — same privacy guarantee, non-destructive, trivially reversible.
|
||
|
||
---
|
||
|
||
## 10. Extending: differentiated priority / importance by label
|
||
|
||
Today every push is sent visible + high-priority (the senders hardcode it —
|
||
there is deliberately **no** deployment-wide priority/alert toggle; an
|
||
instance-wide flag is one value for the whole server, so it could never express
|
||
"label X matters more than label Y" anyway). When we want per-message
|
||
importance — e.g. high-priority for a VIP/important label, normal for the rest —
|
||
the seam is already there and the change is small.
|
||
|
||
**Why it's easy: priority has the same granularity as `collapse_key`.**
|
||
`send_push_for_message` (`push.py`) runs **once per message**, and it already
|
||
derives a per-message `collapse_key` next to where the `message` (hence its
|
||
labels/thread) is in scope, then threads it into every sender as an argument.
|
||
Per-message priority is the same shape, so it follows the same three steps:
|
||
|
||
1. **Derive** it where `collapse_key` is derived — a `priority_for_message(message)`
|
||
helper that inspects the message's labels/importance. This is the
|
||
content/importance decision, and it belongs here (upstream, near the
|
||
compose/fan-out), **not** in the transport sender.
|
||
2. **Thread** it as a second context arg alongside `collapse_key`:
|
||
`sender(items, collapse_key, priority)`.
|
||
3. **Map** it per transport inside each sender:
|
||
- **FCM** (`android.priority`) and **Web Push** (`urgency` header) already
|
||
build their priority value *inside* the per-item loop — read the arg
|
||
instead of the `"high"` literal.
|
||
- **APNs** (`apns-priority` header) is computed once before the loop, exactly
|
||
like `apns-collapse-id` is from `collapse_key` — compute it from the arg.
|
||
|
||
Since a sender call is one message's fan-out, all its `items` share the message's
|
||
priority — so this is a per-*call* parameter, not per-item; no per-device state.
|
||
|
||
**Graded intrusiveness, not a binary.** If "low importance" should mean *less
|
||
intrusive* rather than just *normal urgency*, prefer the modern per-notification
|
||
primitives over reviving the old visible/silent binary (which had iOS
|
||
throttling/force-quit problems): iOS `interruption-level` / `relevance-score`,
|
||
Android notification-channel importance, Web `urgency`. All are per-notification
|
||
fields that thread through the **same** per-message seam.
|
||
|
||
This is really one facet of the user-facing **importance/content-filter**
|
||
feature (§ "which messages notify"): the label decides importance; importance
|
||
maps to transport priority here.
|
||
|
||
---
|
||
|
||
## 11. Not yet built / future work
|
||
|
||
- **Native fetch-to-enrich.** The Web Push service worker already enriches
|
||
banners (§5); the iOS Notification Service Extension / Android data handler
|
||
equivalent is not built — native banners stay at the generic loc-key text.
|
||
- **Differentiated priority / importance by label** (§10) — the seam exists;
|
||
wiring it to a label/importance signal is future work.
|
||
- **Per-device granularity** (mentions only / per-mailbox / quiet hours) and
|
||
per-device rename, beyond the current enable/list/sign-out (§7).
|
||
|
||
---
|
||
|
||
## 12. Delivery & operations (as implemented)
|
||
|
||
### Task model — one Celery task per notification
|
||
`enqueue_push_notifications` (on commit) schedules `send_push_for_message`, the
|
||
**orchestrator**: it resolves recipients and does the per-recipient DB work once
|
||
(devices, badge counts, deep-link mailbox — a handful of batched queries),
|
||
builds each user's thin payload, then dispatches **one `send_push_notification`
|
||
task per device**. The orchestrator never touches a gateway, so a flaky provider
|
||
can't stall resolution.
|
||
|
||
Each `send_push_notification` is the **independently-retryable atomic unit**: it
|
||
re-fetches its one channel (skips if the device was un-associated since
|
||
dispatch), sends one push, and on a *transient* failure (429 / 5xx / network)
|
||
retries **just that notification** (`autoretry_for=PushTransientError`,
|
||
exponential backoff, `max_retries=5`). Retries are idempotent on-device — the
|
||
per-thread collapse key / Web Push `Topic` coalesces a re-send onto the same
|
||
notification. `acks_late=True` means a worker crash re-runs that one push, not
|
||
the whole fan-out. Dead-token devices are deleted; permanent rejections end the
|
||
task.
|
||
|
||
**Why per-notification, not batched:** the gateways have **no multi-device batch
|
||
API** — APNs is one HTTP/2 request per token, FCM v1 is one request per token
|
||
(legacy multicast is removed), Web Push is one request per subscription. So
|
||
parallelism, not batching, is the lever, and the Celery worker pool provides it.
|
||
|
||
### Scale
|
||
Mailboxes are bounded (~50–100 members; not distribution lists), so the common
|
||
case is "internal email to ~100 recipients × ~2 devices" ≈ **200 pushes/message**
|
||
(upper bound ~500). That's well within the per-notification-task model.
|
||
|
||
### Gateway efficiency
|
||
- **Cached auth tokens** (shared via the redis cache, refreshed inside their
|
||
validity): the APNs ES256 provider token (Apple throttles re-minting —
|
||
`TooManyProviderTokenUpdates`) and the FCM OAuth access token. So the many
|
||
per-notification tasks don't each re-authenticate.
|
||
- **Process-global HTTP clients** for APNs (HTTP/2, multiplexed) and FCM
|
||
(HTTP/1.1, keep-alive), reused across a worker's tasks and closed on worker
|
||
shutdown — avoids a TLS handshake per push and Apple's rapid-connect/disconnect
|
||
abuse heuristic. Web Push can't share a client (per-subscription host, delivered
|
||
through a per-request SSRF-IP-pinned session).
|
||
|
||
### Stale-device deletion (two guards)
|
||
Only **unambiguous** dead-token signals delete a channel: APNs `410 Unregistered`
|
||
(NOT `BadDeviceToken` — that's usually a wrong-env `PUSH_APNS_USE_SANDBOX`),
|
||
FCM `UNREGISTERED` / `NOT_FOUND` (NOT `INVALID_ARGUMENT` — also a bad-request
|
||
signal), Web Push `404` / `410`. On top of the narrow codes:
|
||
- a **per-batch ratio breaker** (refuse if ≥50% of a ≥4-device batch is "stale"), and
|
||
- a **rolling per-platform window** (cap of 500 deletions/60s) that covers the
|
||
per-notification path, where there's no batch to ratio-check — so a systemic
|
||
fault can't wipe a fleet one task at a time.
|
||
|
||
Genuinely-bad tokens that never 410 are still reclaimed by re-registration
|
||
(same-user upsert replaces the row), the per-user device cap (LRU eviction), or
|
||
manual removal in device management.
|
||
|
||
### Web Push specifics
|
||
- **VAPID keys must stay paired.** The browser subscribes with
|
||
`PUSH_VAPID_PUBLIC_KEY` as its `applicationServerKey`; the push service then
|
||
verifies every notification against the JWT the private key signs. A mismatch
|
||
fails *all* web push silently (403). The public key is deterministic from the
|
||
private key — derive it with `python manage.py derive_vapid_public_key` and pin
|
||
it; `--verify` checks the configured pair. `/config` serves the env var
|
||
verbatim and never derives it (keeps the push/crypto graph off the request
|
||
path). Rotating the private key orphans every existing web subscription.
|
||
- `PUSH_VAPID_SUBJECT` must be a `mailto:` / `https:` URI (else 401); a malformed
|
||
subject disables web push with a logged warning.
|
||
- TTL is 1 day (the payload is just a refetch trigger; a week-old trigger is
|
||
noise), `Urgency: high`, `Topic` = a 32-char hash of the collapse key.
|
||
- The service worker re-alerts per new message in a thread (`renotify`).
|
||
- **Self-healing subscriptions:** the SW's `pushsubscriptionchange` re-subscribes
|
||
and re-registers (CSRF via `cookieStore` on Chromium); the app also
|
||
re-registers the existing subscription on load (CSRF-correct path) so a rotated
|
||
endpoint doesn't silently go dark without the user revisiting settings.
|
||
- **VAPID key rotation is detected client-side:** `enableWebPush` /
|
||
`refreshWebPushSubscription` compare the existing subscription's
|
||
`applicationServerKey` to the current `PUSH_VAPID_PUBLIC_KEY` and, on a
|
||
mismatch, `unsubscribe()` + re-`subscribe()` with the new key (the push service
|
||
otherwise rejects the stale subscription with 401/403 forever, which the
|
||
backend can't prune — it only prunes on 404/410). The current key is injected
|
||
into the SW script URL (`?vapid=`) so `pushsubscriptionchange` re-subscribes
|
||
with it rather than the old key.
|
||
- **Voluntary logout vs session expiry:** the product rule is *"a device stops
|
||
receiving on explicit logout, keeps receiving across session expiry, and
|
||
resumes transparently on the next login"*. It is enforced **server-side**: the
|
||
channel is stamped with `sha256("sess:" + session_key)` at registration
|
||
(`settings.session_hash`), and the `user_logged_out` receiver deletes the
|
||
channels bound to the logging-out session. The 401/expiry funnel reaches the
|
||
logout view *anonymous* (the session is already gone), so it matches nothing —
|
||
the distinction needs no client code and works even when the browser is
|
||
closed. The browser subscription itself is never destroyed at logout.
|
||
- **Per-user opt-in marker (`localStorage`):** because the browser push
|
||
subscription and `localStorage` are per-*origin*, not per-*user*, a live
|
||
subscription is NOT proof the *current* user opted in (it may be a previous
|
||
user's leftover on a shared computer). Enabling push stores
|
||
`messages_push-opt-in.<userId>`; the on-load refresh (re)registers only when
|
||
that marker is present for the current user — or, for users who opted in
|
||
before the marker existed, when the server device list proves they own the
|
||
endpoint (`token_hash` match, which also migrates them onto the marker). This
|
||
is what recreates the channel after a voluntary logout *for the returning
|
||
user only*, and never enrolls a different user on the same machine.
|
||
- **Different user takes over the browser (expired session):** an expired
|
||
session deletes nothing server-side, so on a shared computer the previous
|
||
user's channel would keep alerting. The refresh closes this: when the
|
||
authenticated user is *not* entitled to the live subscription (no marker, no
|
||
`token_hash` ownership), it `unsubscribe()`s it — a browser-local call that
|
||
needs no rights on the other user's channel. The endpoint dies at the push
|
||
service (nothing is delivered anymore) and the orphaned channel self-prunes on
|
||
its next send (404/410 → stale). The previous user's opt-in marker survives,
|
||
so their own next login here re-subscribes them fresh.
|
||
- **Sign-out of a device (settings):** the durable per-device opt-out. When the
|
||
signed-out row is the current browser (matched via `token_hash`, the
|
||
server-exposed `sha256("push:" + endpoint)`), the client `unsubscribe()`s locally and
|
||
clears the user's opt-in marker before the `DELETE`, so the on-load refresh
|
||
can't recreate it.
|
||
|
||
### Web session lifecycle — flow
|
||
|
||
The scenario matrix the bullets above implement:
|
||
|
||
| Scenario | Behaviour | Mechanism |
|
||
|---|---|---|
|
||
| A logs out voluntarily | A's notifications stop instantly, this device only | server: `user_logged_out` → delete session-stamped channel |
|
||
| A's session expires (A alone on the machine) | notifications **continue** (content-free banner) | 401 funnel reaches logout anonymous → receiver no-ops |
|
||
| A logs back in (after logout or expiry) | resumes **automatically** | opt-in marker → on-load re-registration |
|
||
| A's session expires, **B** logs in | A's notifications stop at B's first load; B is **not** enrolled | refresh: B not entitled → `unsubscribe()` → channel self-prunes (404/410) |
|
||
| A returns after B | resumes automatically for A | A's marker survived → fresh subscription |
|
||
| B explicitly enables push | clean takeover | cross-user reclaim in `register_push_device` |
|
||
|
||
What happens when a session ends, and how delivery resumes:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
START(["User A's session ends"]) --> HOW{How?}
|
||
|
||
HOW -- "voluntary logout<br/>(UI button → /logout/)" --> SIG["user_logged_out receiver:<br/>delete A's channels stamped<br/>with THIS session"]
|
||
SIG --> QUIET["Device stops receiving instantly.<br/>Browser subscription and A's<br/>opt-in marker survive"]
|
||
|
||
HOW -- "expiry / 401 funnel" --> NOOP["Logout view reached anonymous:<br/>receiver matches nothing.<br/>Channel survives — pushes continue<br/>(content-free banner)"]
|
||
|
||
QUIET --> NEXT{"Next login<br/>in this browser"}
|
||
NOOP --> NEXT
|
||
|
||
NEXT -- "same user A" --> RESUME["On-load refresh re-registers<br/>(marker / token_hash ownership).<br/>Notifications resume, no action"]
|
||
NEXT -- "different user B" --> TAKE["B is not entitled — no marker,<br/>no ownership → unsubscribe():<br/>endpoint dies at the push service"]
|
||
TAKE --> PRUNE["A's orphaned channel self-prunes<br/>on its next send (404/410 → stale).<br/>B is NOT enrolled"]
|
||
PRUNE --> BACK["A's next login here:<br/>marker survived → fresh<br/>subscription, resumes"]
|
||
```
|
||
|
||
The on-load `refreshWebPushSubscription` decision tree that enforces the
|
||
client-side half (never prompts, never enrolls a non-entitled user):
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
LOAD(["App load, authenticated user"]) --> SUP{"Web Push supported and<br/>permission granted?"}
|
||
SUP -- no --> S1(["stop — never prompt"])
|
||
SUP -- yes --> REG{"SW registration<br/>exists?"}
|
||
REG -- no --> S2(["stop — push never<br/>enabled in this browser"])
|
||
REG -- yes --> RR["re-register SW<br/>(refresh ?api= and ?vapid=)"]
|
||
RR --> SUB{"Live<br/>subscription?"}
|
||
|
||
SUB -- no --> MK1{"Opt-in marker for<br/>current user?"}
|
||
MK1 -- no --> S3(["stop — stay passive"])
|
||
MK1 -- yes --> NEW["subscribe() with the<br/>current VAPID key"]
|
||
|
||
SUB -- yes --> MK2{"Opt-in marker for<br/>current user?"}
|
||
MK2 -- yes --> ROT
|
||
MK2 -- no --> OWN{"Server owns this endpoint?<br/>(device list token_hash match)"}
|
||
OWN -- yes --> ROT{"Subscription key matches<br/>current VAPID key?"}
|
||
OWN -- no --> TEAR["unsubscribe() — previous user's<br/>leftover; orphaned channel<br/>self-prunes on next send"]
|
||
ROT -- no --> RESUB["unsubscribe() + subscribe()<br/>with the current key<br/>(VAPID rotation self-heal)"]
|
||
ROT -- yes --> POST
|
||
RESUB --> POST
|
||
NEW --> POST["POST /users/me/channels/ type=push<br/>(server stamps session_hash)"]
|
||
POST --> MARK(["set opt-in marker<br/>for current user"])
|
||
```
|
||
|
||
### Operator setup checklist (Web Push)
|
||
1. `python manage.py generate_vapid_private_key` → prints a fresh keypair; set
|
||
`PUSH_VAPID_PRIVATE_KEY` (base64url single line) and its matching
|
||
`PUSH_VAPID_PUBLIC_KEY` from the printed guidance, plus `PUSH_VAPID_SUBJECT`
|
||
(`mailto:…`). `PUSH_VAPID_PRIVATE_KEY` also accepts a PEM block if you already
|
||
have one (e.g. from `web-push`/`vapid`).
|
||
2. Already have only the private key? `python manage.py derive_vapid_public_key`
|
||
→ set the output as `PUSH_VAPID_PUBLIC_KEY`.
|
||
3. `python manage.py derive_vapid_public_key --verify` to confirm the pair.
|
||
4. `PUSH_ENABLED=true`. (APNs/FCM need their own credential env vars; each
|
||
transport no-ops until its credentials are present.)
|