Files
kapteins-daagbok/docs/push-notifications-plan.md
elpatron dea33e3f00 feat(security): Session-Cookies statt X-User-Id und API-Härtung
Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach
WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und
ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:47:24 +02:00

425 lines
15 KiB
Markdown
Raw Permalink 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.
# Implementierungsplan: Push-Benachrichtigungen für Logbuch-Owner
**Ziel:** Der Owner eines Logbuchs soll per Web Push informiert werden, wenn ein eingeladenes Crewmitglied (Collaborator mit WRITE) Änderungen synchronisiert — auch wenn die App geschlossen ist.
**Stand Codebase:** Push MVP ist implementiert (`web-push`, Prisma-Modelle, `routes/push.ts`, `pushNotify.ts`, Custom SW `sw.ts`, Settings-UI). API-Auth erfolgt über **HttpOnly-Session-Cookie** (`daagbok_session`) nach WebAuthn-Login — nicht mehr über `X-User-Id`.
---
## 1. Anforderungen
### Funktional (MVP)
| ID | Anforderung |
|----|-------------|
| N-01 | Owner kann Push-Benachrichtigungen global aktivieren/deaktivieren (Opt-in). |
| N-02 | Bei erfolgreichem Sync-Push durch einen **Nicht-Owner-Collaborator** erhält der Owner **eine** zusammengefasste Benachrichtigung pro Logbuch und Request (nicht pro Queue-Item). |
| N-03 | Klick auf die Benachrichtigung öffnet die App auf dem betroffenen Logbuch (Deep-Link `/logbook/:id` o. ä.). |
| N-04 | Benachrichtigungstext ist **generisch** (Zero-Knowledge: Server kann Titel/Inhalt nicht lesen). |
| N-05 | DE/EN über i18n-Keys; Sprache aus Browser/`Accept-Language` oder gespeicherter App-Sprache in Subscription-Metadaten. |
| N-06 | Abgelaufene/ungültige Subscriptions werden beim Fehlerversand gelöscht (410 Gone). |
### Nicht im MVP (später)
- Push an Collaborators bei Owner-Änderungen (bidirektional).
- Pro-Logbuch Ein/Aus (nur global reicht zunächst).
- Inhaltliche Details („Eintrag #3 bearbeitet“) — würde Klartext auf dem Server erfordern.
- E-Mail/SMS als Fallback.
- „Quiet hours“ / Do-not-disturb-Zeiten.
### Akzeptanzkriterien (UAT)
1. Owner aktiviert Push in den Einstellungen → Browser fragt Berechtigung → Subscription liegt in DB.
2. Collaborator bearbeitet Eintrag, App des Collaborators synct → Owner erhält Push innerhalb weniger Sekunden (Gerät online, Berechtigung erteilt).
3. Owner mit deaktivierten Push-Einstellungen erhält nichts.
4. Bulk-Sync (10 Items) → genau **eine** Push-Nachricht.
5. Klick öffnet installierte PWA oder Browser-Tab mit korrektem Logbuch.
---
## 2. Architektur
```mermaid
sequenceDiagram
participant Crew as Crew-Client
participant API as Express API
participant DB as PostgreSQL
participant Push as web-push (VAPID)
participant SW as Service Worker (Owner)
participant Owner as Owner-Gerät
Crew->>API: POST /api/sync/push (Session-Cookie)
API->>DB: Payloads speichern
API->>API: collaborator change? → notify owner
API->>DB: PushSubscriptions (owner)
API->>Push: sendNotification (pro Endpoint)
Push->>SW: Push Event
SW->>Owner: System-Benachrichtigung
Owner->>SW: notificationclick
SW->>Owner: openWindow(/logbook/:id)
```
### Komponenten
| Schicht | Neu/Geändert | Aufgabe |
|---------|--------------|---------|
| **Prisma** | Neu | `PushSubscription`, optional `UserNotificationPrefs` |
| **Server** | Neu | `routes/push.ts`, `services/pushNotify.ts`, Env VAPID |
| **sync.ts** | Änderung | Nach erfolgreichem Collaborator-Push Owner benachrichtigen |
| **Client SW** | Neu | Custom SW (`injectManifest`) mit `push` + `notificationclick` |
| **Client UI** | Neu | Einstellungen: Toggle, Permission-Flow, Status |
| **Client Service** | Neu | `pushNotifications.ts` — subscribe, unsubscribe, sync mit API |
---
## 3. Plattform- und Produkt-Hinweise
| Thema | Auswirkung |
|-------|------------|
| **iOS** | Web Push für installierte PWAs ab **iOS 16.4+**. Nutzer müssen App zum Home Screen hinzufügen und Push erlauben. |
| **Android / Desktop** | Chrome/Edge/Firefox: gut unterstützt; PWA installiert empfohlen. |
| **HTTPS** | Web Push nur über HTTPS (Produktion erfüllt das). |
| **Zero-Knowledge** | Text z. B. „Neue Änderung in einem Ihrer Logbücher“ + `logbookId` nur im `data`-Payload (nicht im sichtbaren Titel nötig). |
| **Datenschutz** | Push-Endpoints sind personenbezogen → in Datenschutzerklärung erwähnen; Löschung bei Account-Löschung (Cascade). |
---
## 4. Datenmodell (Prisma)
```prisma
model PushSubscription {
id String @id @default(uuid())
userId String
endpoint String @unique
p256dh String // keys.p256dh (base64url)
auth String // keys.auth (base64url)
userAgent String? // optional, Debugging
locale String? // "de" | "en" — für Notification-Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model UserNotificationPrefs {
userId String @id
collaboratorChangesEnabled Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
```
`User`-Relationen ergänzen: `pushSubscriptions`, `notificationPrefs`.
**Migration:** `npx prisma migrate dev --name add_push_subscriptions`
---
## 5. Server-Implementierung
### 5.1 Abhängigkeit & Umgebung
```bash
npm install web-push --workspace=server
```
`.env` (Beispiel):
```env
ORIGIN=https://kapteins-daagbok.eu
SESSION_SECRET=... # min. 32 Zeichen, Pflicht in Produktion
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
```
Keys einmalig erzeugen:
```bash
npx web-push generate-vapid-keys
```
Öffentlichen Key zusätzlich als `VITE_VAPID_PUBLIC_KEY` für den Client (nur Public Key).
### 5.2 API-Routen (`/api/push`)
| Methode | Pfad | Auth | Beschreibung |
|---------|------|------|--------------|
| `GET` | `/vapid-public-key` | nein | Liefert Public Key für `pushManager.subscribe` |
| `PUT` | `/subscription` | Session-Cookie | Upsert Subscription (endpoint + keys) |
| `DELETE` | `/subscription` | Session-Cookie | Body: `{ endpoint }` — Gerät abmelden |
| `GET` | `/prefs` | Session-Cookie | Liest `collaboratorChangesEnabled` |
| `PUT` | `/prefs` | Session-Cookie | Body: `{ collaboratorChangesEnabled: boolean }` |
`requireUser` in `server/src/middleware/auth.ts` — liest und verifiziert `daagbok_session` (HMAC-signiert). Client sendet `credentials: 'include'` (`client/src/services/api.ts`).
### 5.3 Benachrichtigungs-Service
**Datei:** `server/src/services/pushNotify.ts`
```ts
// Pseudocode — Kernlogik
export async function notifyOwnerOfCollaboratorChanges(
logbookId: string,
ownerUserId: string,
actorUserId: string,
changeCount: number
): Promise<void>
```
Ablauf:
1. `UserNotificationPrefs`: wenn `collaboratorChangesEnabled !== true` → return.
2. Alle `PushSubscription` für `ownerUserId` laden.
3. Payload (Web Push JSON):
```json
{
"title": "Kapteins Daagbok",
"body": "Neue Änderung in einem Ihrer Logbücher.",
"tag": "logbook-change-{logbookId}",
"renotify": false,
"data": { "url": "/logbook/{logbookId}", "logbookId": "{logbookId}", "changeCount": 3 }
}
```
4. `webpush.sendNotification(subscription, payload, options)` parallel mit `Promise.allSettled`.
5. Bei Status **410** oder **404**: Subscription aus DB löschen.
6. Fehler loggen, Sync-Response **nicht** fehlschlagen lassen (Push ist Best-Effort).
**Deduplizierung / Rate-Limit (empfohlen):**
- In-Memory-Map `ownerId:logbookId → lastSentAt` mit TTL 25 Minuten, **oder**
- Redis/DB-Tabelle `NotificationThrottle` mit `lastSentAt`.
Verhindert Push-Spam bei großen Offline-Queues.
### 5.4 Hook in `sync.ts`
Nach der Schleife über `items` (oder innerhalb, mit Sammellogik):
```ts
// Pro Request sammeln:
const ownerNotifications = new Map<string, { logbookId: string; count: number }>()
// Bei jedem erfolgreichen Item:
if (res.status === 'success' && !isOwner && isCollaborator) {
if (action === 'create' || action === 'update') {
const ownerId = logbook.userId
const key = `${ownerId}:${logbookId}`
const prev = ownerNotifications.get(key) ?? { logbookId, count: 0 }
prev.count++
ownerNotifications.set(key, prev)
}
}
// Nach der Schleife, async fire-and-forget:
for (const [key, { logbookId, count }] of ownerNotifications) {
const ownerId = key.split(':')[0]
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
}
```
**Wichtig:** Owner, der selbst als „Crew“ irrtümlich synct, ist `isOwner` — kein Push.
**Optional später:** auch `delete`-Aktionen einbeziehen (gleiche Logik).
### 5.5 `index.ts`
```ts
import pushRouter from './routes/push.js'
app.use('/api/push', pushRouter)
```
---
## 6. Client-Implementierung
### 6.1 Service Worker (Custom `injectManifest`)
`vite.config.ts` anpassen:
```ts
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: 'auto',
// manifest unverändert
})
```
**Datei:** `client/src/sw.ts`
- `precacheAndRoute` von Workbox importieren (wie vite-plugin-pwa-Doku).
- `self.addEventListener('push', …)`:
- `event.data.json()` parsen
- `self.registration.showNotification(title, { body, tag, data, icon: '/logo.png' })`
- `notificationclick`:
- `event.notification.close()`
- `clients.openWindow(data.url || '/')` — absolute URL mit `self.location.origin`
**i18n im SW:** MVP mit serverseitigem `locale` in Subscription; alternativ nur EN/DE-Body vom Server senden.
### 6.2 Client-Service `pushNotifications.ts`
| Funktion | Beschreibung |
|----------|--------------|
| `isPushSupported()` | `'serviceWorker' in navigator && 'PushManager' in window` |
| `getPermissionState()` | `Notification.permission` |
| `subscribeToPush()` | SW ready → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })``PUT /api/push/subscription` |
| `unsubscribeFromPush()` | `subscription.unsubscribe()` + `DELETE` API |
| `syncPrefs(enabled)` | `PUT /api/push/prefs` |
| `ensureSubscriptionOnLogin()` | Wenn Prefs an und Permission granted, Subscription erneuern (Key-Rotation) |
`applicationServerKey`: VAPID Public Key von `GET /api/push/vapid-public-key` oder Build-Time `import.meta.env.VITE_VAPID_PUBLIC_KEY`.
### 6.3 UI (Settings)
**Ort:** `SettingsForm.tsx` (nur für Owner sichtbar, nicht bei `readOnly` / Crew-Logbuch).
Ablauf beim Einschalten:
1. `Notification.requestPermission()` — bei `denied` Hinweis + Link zu Browser-Einstellungen.
2. `subscribeToPush()` + `syncPrefs(true)`.
3. Bei Erfolg: grüner Status „Push aktiv“.
Beim Ausschalten:
1. `syncPrefs(false)` + optional `unsubscribeFromPush()` auf diesem Gerät.
**Hinweis-Banner** wenn `!isPushSupported()` oder iOS & nicht installiert → Verweis auf `PwaInstallPrompt`.
### 6.4 Deep-Link beim Öffnen
In `App.tsx` oder Router: beim Start `url` aus `notificationclick` via `clients.matchAll` nicht nötig — SW öffnet direkt.
Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route) existiert und Auth-Gate passiert.
### 6.5 Bestehenden SW-Update-Flow
`usePwaUpdate.ts` bleibt kompatibel mit `injectManifest`, sofern `virtual:pwa-register` weiter registriert wird — vite-plugin-pwa-Doku für `injectManifest` + React beachten.
---
## 7. Sicherheit
| Risiko | Maßnahme |
|--------|----------|
| Fremde subscriben mit fremder `userId` | Session-Cookie nach WebAuthn; `userId` kommt aus verifiziertem Token, nicht aus Client-Header. |
| Push an falschen User | `notifyOwner` nur mit `logbook.userId` aus DB, nie aus Client-Body. |
| Endpoint-Injection | `endpoint` muss HTTPS-URL sein; Länge begrenzen. |
| Spam durch Crew | Rate-Limit + nur `create`/`update` im MVP. |
| VAPID Private Key | Nur Server-Env, nie im Client. |
---
## 8. Implementierungsphasen
### Phase 1 — Infrastruktur (12 Tage)
- [ ] VAPID-Keys für Dev/Prod
- [ ] Prisma-Modelle + Migration
- [ ] `web-push` + `pushNotify.ts` + Unit-Test mit Mock-Subscription
- [ ] Routen `/api/push/*`
- [ ] `GET /vapid-public-key`
### Phase 2 — Service Worker (1 Tag)
- [ ] Umstellung auf `injectManifest` + `sw.ts`
- [ ] `push` / `notificationclick` Handler
- [ ] Manueller Test: `web-push` CLI oder kleines Admin-Skript sendet Test-Push
### Phase 3 — Trigger & Client-Anbindung (12 Tage)
- [ ] Hook in `sync.ts` mit Aggregation
- [ ] `pushNotifications.ts`
- [ ] Settings-UI + i18n (`de.json` / `en.json`)
- [ ] Plausible-Event optional: `push_enabled`, `push_denied`
### Phase 4 — Härtung (1 Tag)
- [ ] Rate-Limit / `tag`-basierte Ersetzung gleicher Logbuch-Pushes
- [ ] 410-Cleanup
- [ ] README + Datenschutz-Hinweis
- [ ] E2E-Manual-Testmatrix (iOS PWA, Android Chrome, Desktop)
### Phase 5 — Deployment
- [ ] Env-Variablen in Produktion (Docker/Hosting)
- [ ] Nginx: `sw.js` weiterhin `no-cache` (bereits in `nginx.conf`)
- [ ] Smoke-Test nach Deploy
**Geschätzter Gesamtaufwand:** 46 Entwicklertage für MVP.
---
## 9. Testplan
| # | Szenario | Erwartung |
|---|----------|-----------|
| T1 | Push nicht unterstützt (alter Browser) | UI zeigt „nicht verfügbar“, kein Fehler |
| T2 | Permission denied | Toggle aus, erklärender Hinweis |
| T3 | Owner aktiviert, Crew synct 1 Eintrag | 1 Push |
| T4 | Crew synct 5 Einträge in einem Request | 1 Push |
| T5 | Owner Prefs aus | Kein Push |
| T6 | Ungültige Subscription | 410 → DB-Eintrag weg, nächster Push an andere Geräte ok |
| T7 | notificationclick | App öffnet richtiges Logbuch |
| T8 | Owner ändert selbst | Kein Push an sich selbst |
**Dev-Test ohne zweites Gerät:** Zwei Browser-Profile (Owner + Crew), Crew-Einladung wie in Produktion.
---
## 10. Offene Entscheidungen (vor Start klären)
1. **Nur Owner oder auch andere Collaborators?** — MVP: nur Owner.
2. **Rate-Limit-Dauer:** 2 min vs. 5 min — Empfehlung: **3 min** pro Logbuch.
3. **Mehrere Geräte des Owners:** alle Subscriptions benachrichtigen — ja (Standard).
4. ~~**Auth verbessern**~~ — erledigt: HttpOnly-Session-Cookie für alle geschützten Routen inkl. Push.
---
## 11. Referenzen
- [web-push (npm)](https://www.npmjs.com/package/web-push)
- [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
- [vite-plugin-pwa: injectManifest](https://vite-pwa-org.netlify.app/guide/inject-manifest.html)
- [Apple: Web Push for PWAs (iOS 16.4+)](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/)
---
## 12. Datei-Checkliste (neu/geändert)
```
server/
prisma/schema.prisma # PushSubscription, UserNotificationPrefs
prisma/migrations/.../
src/routes/push.ts # neu
src/services/pushNotify.ts # neu
src/routes/sync.ts # Hook notifyOwner
src/index.ts # Router mount
package.json # web-push
client/
src/sw.ts # neu (injectManifest)
vite.config.ts # strategies: injectManifest
src/services/pushNotifications.ts # neu
src/components/PushNotificationSettings.tsx # neu (optional)
src/components/SettingsForm.tsx # Integration
src/i18n/locales/de.json, en.json
.env.example # VITE_VAPID_PUBLIC_KEY
src/services/api.ts # apiFetch (credentials: include)
server/
src/session.ts # Session-Cookie signieren/verifizieren
src/middleware/auth.ts # requireUser, requireReauth
docs/
push-notifications-plan.md # dieses Dokument
README.md # Auth/Session, Env-Hinweise
```