Files
lasuite-messages/docs/push-notifications.md
T

566 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (~50100 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.)