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

15 KiB
Raw Permalink Blame History

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

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)

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

npm install web-push --workspace=server

.env (Beispiel):

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:

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

// 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):
{
  "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 }
}
  1. webpush.sendNotification(subscription, payload, options) parallel mit Promise.allSettled.
  2. Bei Status 410 oder 404: Subscription aus DB löschen.
  3. 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):

// 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

import pushRouter from './routes/push.js'
app.use('/api/push', pushRouter)

6. Client-Implementierung

6.1 Service Worker (Custom injectManifest)

vite.config.ts anpassen:

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.

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


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