| 1 | # Web Push (VAPID) — ontwerp
|
|---|
| 2 |
|
|---|
| 3 | Achtergrond-notificaties naar de browser/PWA van de site-eigenaar: nieuwe
|
|---|
| 4 | follow, reply/mention, like, boost of DM, ook als de site dicht is. Zelf-gehost
|
|---|
| 5 | (RFC 8030 + VAPID, RFC 8292), payload end-to-end versleuteld naar de browser
|
|---|
| 6 | (RFC 8291): de push-dienst van de browser (Mozilla/Google/Apple) ziet alleen
|
|---|
| 7 | ciphertext.
|
|---|
| 8 |
|
|---|
| 9 | Dit is het model van Mastodon (`POST /api/v1/push/subscription`), aangepast aan
|
|---|
| 10 | Klonkt-conventies. Er is geen FEP voor achtergrond-push; FEP-3ab2 (SSE) dekt
|
|---|
| 11 | alleen live-terwijl-open en leunt op een cookie-ticket, wat niet bij Klonkt past.
|
|---|
| 12 |
|
|---|
| 13 | ## Beslissingen
|
|---|
| 14 |
|
|---|
| 15 | - **Dependency: `web-push` (npm), bewust.** De RFC 8291-payload-encryptie
|
|---|
| 16 | (ECDH + HKDF + aes128gcm) en de VAPID-JWT zijn precies de fiddly,
|
|---|
| 17 | security-gevoelige laag die je niet zelf naschrijft. Lazy import, zoals
|
|---|
| 18 | @simplewebauthn/server: een canary die autofollowt vóór `npm ci` mag nooit
|
|---|
| 19 | op boot crashen.
|
|---|
| 20 | - **VAPID-sleutels: env wint, anders auto-gegenereerd bestand.** Zelfde patroon
|
|---|
| 21 | als SESSION_SECRET/PAID_SECRET: `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`/
|
|---|
| 22 | `VAPID_SUBJECT` in env als gezet, anders `storage/.vapid` (JSON, 0600),
|
|---|
| 23 | eenmalig gegenereerd. NOOIT regenereren zolang het bestand bestaat: nieuwe
|
|---|
| 24 | keys maken alle bestaande abonnementen ongeldig. Back-up = hele storage/-map.
|
|---|
| 25 | - **Subject**: `PUBLIC_BASE_URL` als die er is (VAPID staat https-URL toe),
|
|---|
| 26 | anders `mailto:` fallback uit SMTP_FROM, anders een placeholder-mailto.
|
|---|
| 27 | - **Abonnement hangt aan de ingelogde gebruiker** (sessie op het eigen domein).
|
|---|
| 28 | Web Push zelf is cookieloos; alleen het aan/uitzetten is een ingelogde actie.
|
|---|
| 29 | - **Payload minimaal**: titel + korte body + doel-URL. Geen volledige teksten
|
|---|
| 30 | van DM's (de push-dienst ziet metadata, nooit meer inhoud dan nodig).
|
|---|
| 31 |
|
|---|
| 32 | ## Datamodel (additief)
|
|---|
| 33 |
|
|---|
| 34 | ```
|
|---|
| 35 | push_subscriptions
|
|---|
| 36 | endpoint TEXT PRIMARY KEY -- push-dienst-URL van de browser
|
|---|
| 37 | user_id TEXT NOT NULL -- wie dit abonnement aanzette
|
|---|
| 38 | p256dh TEXT NOT NULL -- client public key (RFC 8291)
|
|---|
| 39 | auth TEXT NOT NULL -- client auth secret (RFC 8291)
|
|---|
| 40 | alert_types TEXT -- JSON: {"follow":1,"reply":1,"like":0,"boost":0,"dm":1}
|
|---|
| 41 | ua_label TEXT -- vrije apparaat-omschrijving voor de lijst
|
|---|
| 42 | created_at DATETIME
|
|---|
| 43 | last_ok_at DATETIME -- laatste geslaagde push
|
|---|
| 44 | ```
|
|---|
| 45 |
|
|---|
| 46 | ## Flow
|
|---|
| 47 |
|
|---|
| 48 | 1. Eigenaar opent Beheer → Notificaties, klikt "Zet aan op dit apparaat".
|
|---|
| 49 | 2. Browser vraagt permissie (moet via user-gesture), `pushManager.subscribe`
|
|---|
| 50 | met de publieke VAPID-key → `POST /push/subscribe` slaat endpoint+keys op.
|
|---|
| 51 | 3. Er gebeurt iets (follow/reply/like/boost/DM in de S2S-inbox):
|
|---|
| 52 | `PushService.notifySite(slug, event)` → per abonnement van de eigenaar,
|
|---|
| 53 | gefilterd op alert_types, `web-push sendNotification` met versleutelde
|
|---|
| 54 | payload.
|
|---|
| 55 | 4. Service worker (`push`-event) toont de notificatie; klik opent de doel-URL.
|
|---|
| 56 | 5. 404/410 van de push-dienst → abonnement verwijderd (device weg/ingetrokken).
|
|---|
| 57 |
|
|---|
| 58 | ## Triggerpunten (S2S-inbox, ActivityPubService)
|
|---|
| 59 |
|
|---|
| 60 | - `Follow` (na Accept): "X volgt je nu".
|
|---|
| 61 | - `Create` Note/reply → interaction 'reply': "X reageerde op <post>".
|
|---|
| 62 | - `Like` / `Announce` op eigen post: "X vond <post> leuk" / "X boostte <post>".
|
|---|
| 63 | - Prutter-DM (direct note): "Nieuw bericht van X" (zonder inhoud).
|
|---|
| 64 |
|
|---|
| 65 | Elke trigger is fire-and-forget (`.catch` → log), mag delivery nooit blokkeren.
|
|---|
| 66 |
|
|---|
| 67 | ## Caveats
|
|---|
| 68 |
|
|---|
| 69 | - **iOS**: alleen voor een geïnstalleerde PWA (Add to Home Screen, iOS 16.4+),
|
|---|
| 70 | en alleen na een user-gesture. De UI toont die hint op iOS-Safari.
|
|---|
| 71 | - **Key-rotatie breekt alles**: storage/.vapid is heilig, zie boven.
|
|---|
| 72 | - **Shaer-native** (APNs/FCM/UnifiedPush-relay) valt buiten dit pad; de
|
|---|
| 73 | payload-vorm (JSON title/body/url/type) is er alvast op voorbereid.
|
|---|
| 74 |
|
|---|
| 75 | ## Slices
|
|---|
| 76 |
|
|---|
| 77 | - 0: dit document
|
|---|
| 78 | - 1: web-push dep + VAPID-sleutelbeheer + GET /push/vapid
|
|---|
| 79 | - 2: tabel + subscribe/unsubscribe + SW-handlers + Beheer-pagina + testknop
|
|---|
| 80 | - 3: echte triggers (follow/reply/like/boost/DM) met per-type voorkeuren
|
|---|
| 81 | - 4: pruning-bevestiging, throttle, iOS-hint-polish
|
|---|