Skip to content

Lokale Entwicklung ​

Voraussetzungen sind Docker (mit Compose v2), Node.js ≥ 22 und pnpm 10. PHP wird lokal nicht benötigt.

bash
make install   # Composer (im Container) und pnpm
make up        # Docker-Stack starten
make migrate   # Datenbankmigrationen

Nach Änderungen am PHP-Image (backend/docker/php/Dockerfile, z. B. neue Erweiterungen wie gd für die PNG-Ergebnis-Badges) einmal make build und make up ausführen.

DienstURL
Dashboardhttp://localhost:5173
API / OpenAPI-Dokuhttp://localhost:8480/api/docs
Mailpithttp://localhost:8482
Adminerhttp://localhost:8483

Der Dienst gotenberg (HTML → PDF für den PDF-Bericht) startet mit dem Worker und ist nur intern erreichbar (GOTENBERG_URL=http://gotenberg:3000). Ohne ihn schlagen nur PDF-Berichte fehl; bin/phpunit --group gotenberg testet gegen ihn und wird ohne ihn übersprungen. bin/console app:analytics:report-benchmark --no-debug --questions=20 misst einen Bericht mit 20 Fragen.

Demo-Daten ​

make fixtures setzt die Datenbank zurück und legt die Organisation „Lovely Apps“ mit je einem Nutzer pro Rolle an: owner@pollyou.local, admin@pollyou.local, editor@pollyou.local, analyst@pollyou.local, viewer@pollyou.local, Passwort jeweils pollyou-demo. Dazu kommen das Projekt „Demo“ mit einer Umfrage (Poll „Wohin geht der Teamausflug?“, Ergebnisse immer sichtbar) und einer mehrseitigen Befragung („Kundenzufriedenheit 2026“). Beide sind veröffentlicht (Dedup none) und haben je 30 abgeschlossene Demo-Antworten aus dem laufenden Monat.

Authentifizierung ​

  • POST /api/auth/login liefert ein Access-Token (JWT, 15 Minuten) und setzt das Refresh-Token als httpOnly-Cookie (Pfad /api/auth, SameSite=Strict).
  • POST /api/auth/refresh rotiert das Refresh-Token. Wird ein bereits verbrauchtes Token erneut vorgelegt, wird die gesamte Sitzung widerrufen.
  • POST /api/auth/logout beendet die Sitzung sofort; zugehörige Access-Tokens werden ab dann abgelehnt.
  • GET /api/auth/sessions, DELETE /api/auth/sessions/{id} und POST /api/auth/sessions/revoke-others verwalten die Sitzungen.
  • Passwort vergessen: POST /api/auth/password/forgot antwortet immer mit 202 (keine User-Enumeration, Rate-Limit pro Adresse still, pro IP mit 429) und schickt einen Link /reset-password?token=… (1 Stunde gültig, einmalig). POST /api/auth/password/reset setzt das neue Passwort, widerruft alle Sitzungen und markiert die E-Mail-Adresse als bestätigt.
  • POST /api/me/password ändert das Passwort (aktuelles Passwort erforderlich) und widerruft alle anderen Sitzungen.
  • E-Mail-Änderung: POST /api/me/email (Passwort erforderlich) schickt einen Bestätigungslink /confirm-email?token=… (24 Stunden) an die neue und einen Hinweis an die alte Adresse. Erst POST /api/auth/email/confirm ändert die Adresse. Da das Access-Token die E-Mail-Adresse als Kennung trägt, muss der Client danach einmal POST /api/auth/refresh aufrufen.
  • PATCH /api/me (application/merge-patch+json) ändert Name, Sprache (de/en) und Zeitzone (IANA).
  • Einmal-Tokens für Bestätigung, Reset und E-Mail-Änderung laufen über OneTimeTokens (nur der SHA-256-Hash wird gespeichert); die Passwortregeln stecken im Constraint App\Identity\Validator\StrongPassword.

Login-Schutz ​

  • Rate-Limits (Redis, im Test Dateisystem): Login 5 Fehlversuche pro Konto und IP sowie 50 pro IP in 15 Minuten (danach 429, login_throttling der Firewall login), Registrierung 10 Versuche pro IP und Stunde (429). Passwort vergessen und Bestätigungs-Mail haben eigene Limits.
  • Captcha: ALTCHA (Proof-of-Work, ohne Drittanbieter-Requests, Bibliothek altcha-org/altcha, klassisches V1-Schema). GET /api/auth/captcha liefert eine Challenge (5 Minuten gültig, jede Lösung nur einmal), die das Widget löst; die Lösung geht als Base64-Payload im Feld captcha mit.
    • Die Registrierung verlangt immer ein captcha (sonst 422 auf captcha). Es wird erst geprüft (und verbraucht), wenn alle anderen Felder gültig sind.
    • Login: Ab 3 Fehlversuchen pro Konto und IP in 15 Minuten trägt die 401-Antwort captchaRequired: true; weitere Versuche ohne gültiges captcha scheitern mit 401 detail: "Captcha required.". Ein erfolgreicher Login setzt den Zähler zurück.
    • Konfiguration: ALTCHA_HMAC_KEY (in Produktion setzen), ALTCHA_MAX_NUMBER (Schwierigkeit, Standard 100000; Tests 1000). Tests lösen Challenges mit CaptchaTestTrait::solveCaptcha().
  • Passwörter werden zusätzlich gegen Have I Been Pwned geprüft (k-Anonymity: nur die ersten 5 Zeichen des SHA-1-Hashes verlassen den Server). Ist der Dienst nicht erreichbar, wird die Prüfung übersprungen; im Test-Environment ist sie abgeschaltet.

Zwei-Faktor-Authentifizierung (TOTP) ​

  • Einrichtung: POST /api/me/two-factor/setup liefert secret und otpauthUri (als QR-Code anzeigen), POST /api/me/two-factor/enable mit einem aktuellen Code aktiviert 2FA und gibt einmalig 10 Backup-Codes (xxxx-xxxx) zurück.
  • Login: Bei aktivem 2FA antwortet POST /api/auth/login ohne Sitzung mit {twoFactorRequired, challengeToken, expiresIn: 300}. POST /api/auth/login/two-factor mit challengeToken und code (TOTP oder Backup-Code) liefert dann die normale Login-Antwort. Das Challenge-Token gilt einmal und höchstens 5 Fehlversuche; pro Nutzer sind zusätzlich 10 Fehlversuche in 15 Minuten erlaubt. Ein TOTP-Code wird nur einmal akzeptiert, ein Backup-Code wird verbraucht.
  • POST /api/me/two-factor/disable und POST /api/me/two-factor/backup-codes (neue Codes, alte werden ungültig) verlangen das Passwort.
  • TOTP-Secrets liegen verschlüsselt in der Datenbank (libsodium), Backup-Codes nur als HMAC. Der Schlüssel kommt aus TWO_FACTOR_ENCRYPTION_KEY (mind. 32 Zeichen, in Produktion setzen; ein Wechsel macht alle 2FA-Einrichtungen unbrauchbar).
  • Die Organisationseinstellung twoFactorRequired („2FA Pflicht“) ist vorbereitet (TwoFactorPolicy), wird aber noch nicht durchgesetzt.

OAuth-Login (Google, GitHub) ​

Anmelden und Registrieren mit Google oder GitHub (knpuniversity/oauth2-client-bundle mit league/oauth2-google und league/oauth2-github). Ein Anbieter ohne Client-ID und Secret (OAUTH_GOOGLE_CLIENT_ID/SECRET, OAUTH_GITHUB_CLIENT_ID/SECRET) ist ausgeblendet; GET /api/auth/oauth/providers liefert die aktiven. Einrichtung der OAuth-Apps: Deployment.

Ablauf (SPA + API, Tokens nie in URLs außer dem Einmal-Code):

  1. Das Dashboard navigiert zu GET /api/auth/oauth/{provider}/start?returnTo=/pfad. Die API leitet zum Anbieter weiter; state und PKCE-Verifier (S256) liegen zehn Minuten in einem signierten httpOnly-Cookie (pollyou_oauth, Pfad /api/auth/oauth, SameSite=Lax).
  2. Der Anbieter ruft GET /api/auth/oauth/{provider}/callback auf (Redirect-URI <DASHBOARD_URL>/api/auth/oauth/<provider>/callback, läuft über den Dashboard-Proxy). Die API prüft state gegen das Cookie, tauscht den Code mit dem Verifier und leitet zu /auth/oauth/complete?code=… (Einmal-Code, 2 Minuten) bzw. ?error=… weiter.
  3. Das Dashboard löst den Code mit POST /api/auth/oauth/exchange ein. Ergebnis (status): authenticated (normale Login-Antwort, Refresh-Cookie), two_factor_required (Challenge für POST /api/auth/login/two-factor – 2FA gilt auch beim OAuth-Login), linked, link_confirmation_sent oder registration_required.

Schutz vor Kontoübernahme:

  • Eine E-Mail-Adresse zählt nur, wenn der Anbieter sie als bestätigt meldet (Google email_verified, GitHub: die primäre Adresse aus /user/emails mit verified).
  • Bestätigte Adresse ohne PollYou-Konto: Konto wird angelegt (Adresse bestätigt, ohne Passwort, eigene Organisation wie bei der Registrierung) und angemeldet.
  • Bestätigte Adresse eines bestehenden Kontos, Anbieterkonto noch nicht verknüpft: keine Anmeldung. Der Inhaber bekommt eine E-Mail mit Link /auth/oauth/confirm-link?token=… (1 Stunde, einmalig, TokenPurpose::OAuthLink); POST /api/auth/oauth/confirm-link verknüpft. War die Adresse des Kontos nie bestätigt (möglicherweise von jemand anderem vorregistriert), werden dabei Passwort und 2FA entfernt und alle Sitzungen beendet (accountSecured: true).
  • Unbestätigte oder fehlende Adresse: nichts wird verknüpft oder angemeldet; das Dashboard bietet die normale Registrierung mit eigener E-Mail-Bestätigung an (Name/Adresse vorausgefüllt). Den Anbieter verknüpft man danach in den Einstellungen.

Verknüpfte Konten (Tabelle user_identity, je Anbieter höchstens ein Konto pro Nutzer, ein Anbieterkonto gehört höchstens einem Nutzer):

  • GET /api/me/identities listet sie, DELETE /api/me/identities/{id} entfernt eines – mit 409, wenn es die letzte Anmeldemöglichkeit ist (kein Passwort und kein weiterer Anbieter). Konten ohne Passwort (hasPassword: false in GET /api/me) setzen eines über „Passwort vergessen“.
  • POST /api/me/identities/link mit {provider} liefert eine URL mit Einmal-Ticket (2 Minuten), weil eine Browser-Navigation kein Bearer-Token mitschickt; der Ablauf verknüpft dann statt anzumelden.
  • Audit: auth.oauth_linked (method: registration, settings, email_confirmation), auth.oauth_unlinked, auth.oauth_link_requested; Logins über einen Anbieter tragen method: "oauth:<provider>".

Entwicklung und Tests: Mit OAUTH_FAKE_PROVIDERS=1 (Standard in .env, wirkt nur in dev/test) ersetzt ein eingebauter Fake-Anbieter Google und GitHub: Statt zum Anbieter führt der Start auf eine Formularseite (/api/auth/oauth/fake/{provider}/authorize), auf der man Konto-ID, E-Mail-Adresse, „bestätigt“ und Namen wählt. Token-, Userinfo- und GitHub-API-Aufrufe beantwortet FakeOAuthServer im Prozess (inklusive PKCE-Prüfung); es gehen keine Requests an Google oder GitHub. Darauf bauen tests/Identity/OAuthTest.php und e2e/oauth.spec.ts auf.

Team und Einladungen ​

Alle Endpunkte unter /api/organizations/{id}/… verlangen den Header X-Organization-Id mit derselben Organisation wie {id} (sonst 404; ohne Header 400).

  • GET …/members (alle Rollen) listet die Mitglieder. PATCH …/members/{membershipId} mit {role} ändert die Rolle (members.manage), DELETE …/members/{membershipId} entfernt ein Mitglied – auf die eigene Mitgliedschaft angewendet heißt das „Organisation verlassen“ und geht mit jeder Rolle.
  • Regeln (in MemberManager): Admins dürfen Owner weder ändern noch entfernen; niemand wird per Rollenänderung Owner (422, dafür gibt es die Übertragung); der letzte Owner kann weder herabgestuft, entfernt werden noch austreten (422).
  • POST …/transfer-ownership mit {membershipId} (nur Owner): Das Ziel wird Owner, der bisherige Owner Admin.
  • Einladungen (members.manage, in InvitationManager): POST …/invitations mit {email, role} schickt einen Link /invitations/accept?token=… (7 Tage gültig, Mail in der Sprache des Einladenden). Nur Owner dürfen Owner einladen. 422, wenn die Adresse schon Mitglied ist oder eine offene Einladung hat; eine abgelaufene Einladung wird ersetzt. GET …/invitations listet alle nicht angenommenen Einladungen (auch abgelaufene, siehe expiresAt), DELETE …/invitations/{invitationId} widerruft, POST …/invitations/{invitationId}/resend erzeugt ein neues Token (der alte Link wird ungültig) und verlängert die Frist.
  • Annahme: GET /api/invitations/preview?token=… ist öffentlich und zeigt Organisation, Adresse, Rolle und Einladenden. POST /api/invitations/accept mit {token} braucht einen angemeldeten Nutzer mit derselben E-Mail-Adresse (Groß-/Kleinschreibung egal, sonst 403) und antwortet mit {organizationId, organizationName, role, alreadyMember}; das Dashboard wechselt damit direkt in die Organisation und öffnet deren Umfragen. Ist der Nutzer schon Mitglied, bleibt seine Rolle (alreadyMember: true), die Einladung ist trotzdem verbraucht. Abgelaufene, widerrufene oder benutzte Einladungen ergeben 400. Ein mitgeschickter X-Organization-Id-Header stört hier nicht.
  • Wer noch kein Konto hat, registriert sich über die Einladungsseite (/register?invitation=<token>): POST /api/auth/register mit invitationToken legt keine eigene Organisation an, sondern tritt der einladenden bei; die Adresse muss die eingeladene sein (sonst 422 auf email, ungültiges Token 422 auf invitationToken) und gilt als bestätigt (keine Bestätigungsmail, emailVerified: true in der Antwort). Das Dashboard meldet direkt an und öffnet die Umfragen der Organisation.
  • Weitere Organisationen legt POST /api/organizations an; gewechselt wird einfach über den Header.

Umfragen (Datenmodell) ​

Code unter backend/src/Survey/ (Überblick in dessen README.md). Alle Endpunkte verlangen X-Organization-Id; lesen dürfen alle Rollen (surveys.view), ändern ab Editor (surveys.edit). Umfragen anderer Organisationen ergeben 404.

  • Tabellen: project (Ordner), survey (Typ poll/survey, public_id aus 10 zufälligen Base62-Zeichen, Status, settings als JSONB, theme_id vorerst ohne Fremdschlüssel), survey_page, question (Typ, question_key, Titel, Pflicht, config als JSONB, Position) und question_option (option_value als stabiler Schlüssel, Label, Bild-URL, „Sonstiges“). Alle Tabellen tragen organization_id, damit der Mandantenfilter auch für die Struktur greift.
  • GET /api/surveys?status=&type=&projectId=&q=&page= liefert Zusammenfassungen (25 pro Seite, zuletzt geändert zuerst; archivierte nur mit status=archived). GET /api/surveys/{id} liefert Einstellungen und die komplette Struktur (Seiten → Fragen → Optionen).
  • POST /api/surveys mit {type, title, description?, projectId?} legt einen Entwurf an: ein Poll bekommt eine Seite mit einer Single-Choice-Frage und zwei Optionen, eine Befragung eine leere Seite.
  • PATCH /api/surveys/{id} (Merge-Patch) ändert Titel, Beschreibung, Projekt, Theme und Einstellungen (settings wird schlüsselweise gemischt, null leert). Standardwerte: resultsVisibility: after_vote, dedupStrategy: ip_hash, alles andere null.
  • PUT /api/surveys/{id}/structure ersetzt die Struktur des Entwurfs in einem Request (in jedem Status außer archived, siehe „Veröffentlichen und Versionen“): Elemente mit id bleiben erhalten (Antworten können sich später darauf beziehen), Elemente ohne id werden angelegt, fehlende gelöscht; die Positionen ergeben sich aus der Reihenfolge. Fragen-Keys (q1, q2, …) und Options-Werte (option-1, …) werden erzeugt oder beibehalten, wenn sie fehlen. Jede Frage prüft die Fragetyp-Registry; Fehler kommen als 422 mit Pfaden wie pages[0].questions[1].config.maxSelections. Ein Poll hat genau eine Seite mit genau einer Frage.
  • POST /api/surveys/{id}/duplicate kopiert die komplette Struktur als neuen Entwurf (neue IDs und publicId, Titel mit „ (Kopie)“ bzw. „ (copy)“ je nach Sprache des Nutzers). POST …/archive archiviert (idempotent), POST …/restore holt aus dem Archiv zurück (als closed, wenn sie schon veröffentlicht war, sonst als Entwurf). Archivierte Umfragen sind schreibgeschützt (409). DELETE geht nur für Entwürfe (sonst 409) und wird im Audit-Log als survey.deleted vermerkt.
  • Vorlagen (/api/survey-templates): mitgelieferte Vorlagen liegen als JSON in backend/resources/templates/ (Texte als {"de": …, "en": …}, Regeln referenzieren Fragen per key und Seiten als page-<n>), eigene Vorlagen in der Tabelle survey_template. POST /api/survey-templates/{id}/use legt einen Entwurf an (201). Details: Vorlagen und backend/src/Survey/README.md.
  • Projekte: GET/POST /api/projects, GET/PATCH/DELETE /api/projects/{id}. PATCH mit position verschiebt das Projekt, beim Löschen bleiben die Umfragen ohne Projekt erhalten.

Veröffentlichen und Versionen ​

Die normalisierten Tabellen sind der bearbeitbare Entwurf. Veröffentlichen friert ihn als unveränderliche Version ein (Tabelle survey_version: number je Umfrage fortlaufend, definition als JSONB, published_at, published_by, note, dazu starts_at/ends_at in UTC für den Scheduler). Befragte sehen – und Antworten referenzieren später – immer eine Version, nie den Entwurf; Statistiken bleiben dadurch auch nach Änderungen konsistent. Code unter backend/src/Survey/Version/ und backend/src/Survey/Lifecycle/.

  • POST /api/surveys/{id}/publish mit {note?} (Recht surveys.publish, ab Editor) legt die nächste Version an, setzt sie als current_version_id und antwortet mit der Umfrage. Der Status wird scheduled, wenn settings.startsAt in der Zukunft liegt, sonst live. Nutzer mit unbestätigter E-Mail-Adresse bekommen 403 {detail: "Please confirm your e-mail address before publishing."}, archivierte Umfragen 409. Nicht veröffentlichbar (422 mit Pfaden wie beim Strukturendpunkt): keine Frage, leere Seiten, Fragen, die die Fragetyp-Registry ablehnt (z. B. pages[0].questions[0].options bei zu wenigen Optionen), ein Ende in der Vergangenheit. Audit: survey.published mit {version, status}.
  • Die Definition enthält title, description, type, themeId, settings und pages (Form wie in SurveyDetail, inklusive aller IDs). Sie entsteht in SurveyDefinition::of() mit rekursiv sortierten Schlüsseln und Elementen nach Position, ihr SHA-256 über das kanonische JSON steht in survey_version.definition_hash.
  • PUT …/structure und PATCH bleiben in draft, scheduled, live und closed erlaubt und ändern nur den Entwurf; die Live-Version ändert sich erst mit dem nächsten Veröffentlichen. Löschen geht weiter nur für Entwürfe.
  • Ungespeicherte Änderungen: survey.draft_hash ist der Hash der Entwurfsdefinition und wird nach jedem PATCH, jedem Struktur-Speichern und beim Löschen eines verwendeten Themes neu berechnet. hasUnpublishedChanges in SurveyDetail/SurveySummary ist draft_hash ≠ current_version.definition_hash – ohne die Struktur laden zu müssen, also auch in der Liste billig. Wer etwas ändert und wieder zurückändert, hat keine Änderungen. Nie veröffentlichte Umfragen haben currentVersion: null und hasUnpublishedChanges: false.
  • GET /api/surveys/{id}/versions listet {number, publishedAt, publishedBy, note} (neueste zuerst), GET …/versions/{number} liefert zusätzlich die definition.
  • GET /api/surveys/{id}/changes vergleicht den Entwurf mit der aktuellen Version: {versionNumber, hasChanges, changes, warnings}. Der wiederverwendbare Dienst DefinitionDiff::compare($alt, $neu) funktioniert genauso zwischen zwei Versionen. Elemente werden über ihre ID zugeordnet; changes haben kind (added/removed/changed), element (page/question/option/settings/title – title umfasst auch die Beschreibung, settings auch das Theme), path (im neuen Stand, bei entfernten Elementen im alten) sowie before/after. Verschiebungen zwischen Seiten erscheinen als ….page, Umsortieren einmal pro Liste mit den IDs in alter und neuer Reihenfolge. warnings (option_removed, question_removed, question_type_changed) markieren Änderungen, die bestehende Antworten inkompatibel machen.

Lebenszyklus: draft → scheduled → live → closed → archived (Dienst SurveyLifecycle).

  • POST …/close beendet eine laufende oder geplante Umfrage, POST …/reopen öffnet eine geschlossene wieder (live, bzw. scheduled, falls der Start noch bevorsteht); beides mit surveys.publish, aus anderen Status 409. Ist das Ende der aktuellen Version vorbei, verweigert reopen mit 409 – dann Ende ändern und neu veröffentlichen. Audit: survey.closed/survey.reopened mit {status, reason} (manual, schedule, response_limit).
  • Start, Ende und Antwortlimit gelten aus der aktuellen Version, nicht aus dem Entwurf. Ein Veröffentlichen aus closed öffnet die Umfrage wieder.
  • Zeitsteuerung: App\Schedule schickt jede Minute RunSurveySchedule; SurveyScheduleHandler setzt fällige Umfragen organisationsübergreifend von scheduled auf live (starts_at erreicht) und von live auf closed (ends_at erreicht). Verpasste Läufe holt der nächste nach, mehrfaches Ausführen ändert nichts. Lokal läuft das im Compose-Dienst scheduler (messenger:consume scheduler_default, genau eine Instanz); die Worker (worker, messenger:consume async_priority async) lassen sich mit docker compose up -d --scale worker=3 vervielfachen.
  • Antwortlimit: SurveyLifecycle::closeIfResponseLimitReached($survey, $responseCount) schließt eine Live-Umfrage, sobald das Limit der Version erreicht ist. Die Collector-API ruft das nach jeder abgeschlossenen Antwort auf und flusht selbst (siehe „Erhebung (Collector-API)“).

Fragetypen und gemeinsames JSON-Schema ​

  • Fragetypen implementieren App\Survey\QuestionType\QuestionType (meist über AbstractQuestionType) und werden per Service-Tag automatisch in der QuestionTypeRegistry registriert. Jeder Typ definiert Config-Schema, Antwortprüfung und Speicherform (option_keys, integer, boolean, text, number, date, matrix, ranking; siehe backend/src/Survey/README.md).

  • Vorhanden: single_choice, multiple_choice (minSelections/maxSelections), rating (Sterne oder Skala, min 0/1 bis max ≤ 10), nps (0–10), yes_no. Choice-Fragen haben mindestens zwei Optionen, optional Zufallsreihenfolge (randomizeOptions), Bilder (imageUrl) und eine „Sonstiges“-Option (isOther, verlangt dann otherText). Dazu dropdown, likert (5/7 Punkte), matrix (Zeilen = Optionen, Spalten in config.columns), ranking, text_short, text_long, number, date, email, image_choice und hidden (Wert kommt von der Host-Seite).

  • Die Regeln liegen als JSON-Schema (Draft 07) im Paket frontend/packages/survey-schema (@pollyou/survey-schema) samt TypeScript-Typen und Validator (ajv) für Dashboard und embed-core. Das Backend prüft gegen dieselben Dateien (Kopie in backend/resources/survey-schema, Bibliothek justinrainbow/json-schema). Nach Änderungen an den Schemas:

    bash
    pnpm --filter @pollyou/survey-schema sync:backend

    Ein Vitest-Test schlägt fehl, solange die Kopie veraltet ist. schemas/conformance.json enthält gemeinsame Testfälle, die Vitest und PHPUnit gleichermaßen ausführen.

Erhebung (Collector-API) ​

Öffentliche API für die Embeds unter /api/collect/* (Code unter backend/src/Collect/, Überblick in dessen README.md). Kein Login, kein X-Organization-Id: Umfragen werden über ihre publicId adressiert, eine Antwort über ihr Session-Token. Befragte sehen immer die aktuelle Version, nie den Entwurf.

EndpunktErgebnis
GET /api/collect/surveys/{publicId}?embed=&locale=PublicSurvey (Version, Status scheduled/live/closed, Einstellungen, Theme aus ThemeResolver, Seiten, results nur bei always bzw. after_close + geschlossen). Cache-Control: public, max-age=30, ETag/304. Entwürfe, archivierte und nie veröffentlichte Umfragen: 404
POST …/surveys/{publicId}/sessions{channel, clientRequestId, embedId?, locale?, referrer?, utm?, meta?, identityToken?, deviceToken?, captcha?, website?} → 201 {responseId, sessionToken, expiresIn}; 403 {code:"not_open", status} bzw. origin_not_allowed, identity_required, identity_invalid, captcha_required, login_required; 409 {code:"already_answered", results}; 422; 429
PATCH /api/collect/responses/{id}Zwischenspeichern {answers, pageIndex?} (Header X-Session-Token) → 204
POST /api/collect/responses/{id}/complete{answers} → 200 {results, thankYouMessage, redirectUrl}; 422 mit violations[{propertyPath:"answers.<key>", message, code}]; 409 bei schon abgeschlossener Antwort
POST /api/collect/events{publicId, sessionToken?, events:[{type, pageIndex?, at}]} (max. 50, auch als text/plain für sendBeacon) → 202
GET /api/collect/surveys/{publicId}/resultsResultsPayload oder 403 {code:"results_hidden"}
GET /api/collect/captchaALTCHA-Aufgabe für Umfragen mit settings.captcha (wie /api/auth/captcha, aber mit den CORS-Regeln der Collector-API)
  • Sitzung: Das Session-Token ist ein HMAC-signiertes, zustandsloses Token (Schlüssel aus APP_SECRET), zwei Stunden ab Start gültig und an genau eine Antwort gebunden. Derselbe clientRequestId liefert innerhalb von 24 Stunden dieselbe Antwort und dasselbe Token (Idempotenz). Ein erneutes complete mit denselben Antworten liefert dasselbe Ergebnis, mit anderen 409.
  • Validierung: Antworten werden mit der Fragetyp-Registry gegen die Definition der Version geprüft, mit der die Antwort begonnen wurde – auch wenn inzwischen eine neue Version veröffentlicht ist. Per PATCH gespeicherte Antworten zählen für Pflichtfragen, null löscht eine Antwort. Codes wie in @pollyou/survey-schema, dazu unknown_question.
  • Abschluss: speichert Dauer (duration_ms), ruft bei gesetztem Antwortlimit SurveyLifecycle::closeIfResponseLimitReached() auf (mit Sperre auf die Umfrage) und schickt nach dem Commit die Messenger-Nachricht ResponseCompleted (Transport async; Handler für Auswertungen folgen).
  • Status: Sitzungen, Zwischenspeichern und Abschluss nur bei live, sonst 403 not_open mit dem Status.
  • Ergebnisse: Fragen, Optionen und Labels aus der aktuellen Version, gezählt werden abgeschlossene Antworten aller Versionen über question_key und option_value – ein Tippfehler-Fix mit neuer Version setzt die Ergebnisse also nicht zurück. Prozente auf eine Nachkommastelle, NPS-Score -100 bis 100.
  • Dedup, CORS, Missbrauchsschutz: siehe Embed-Konfigurationen, Identität und Missbrauchsschutz.
  • Rate-Limits je gehashter IP und Umfrage: lesen 300, schreiben 60, Events 120 pro Minute; zusätzlich Sitzungsstarts und Abschlüsse pro Minute und Organisation je nach Plan (PlanDefinition::$collectWritesPerMinute, Standard 24.000, Internal 60.000; 429 mit Retry-After; in Tests geteilt durch collect.organization_limit_divisor = 60).

Kontextdaten ohne IP-Speicherung: Die IP wird nie gespeichert, nur im Speicher für das Land, als HMAC für Rate-Limits und ip_hash verwendet.

  • Land über MaxMind GeoLite2: GEOIP_DATABASE_PATH auf eine .mmdb (Country oder City) setzen; leer (Standard) oder Datei fehlt → country bleibt leer.
  • User-Agent → grobe Klassen device_type (desktop/mobile/tablet/bot), browser_family, os_family (eigener kleiner Parser mit Tests).
  • Referrer → nur der Host (aus referrer im Body, sonst Referer-Header).
  • utm: source, medium, campaign, term, content (je max. 200 Zeichen). meta: max. 20 Schlüssel [a-z0-9_]{1,32}, skalare Werte, Texte max. 200 Zeichen; sonst 422.

Datenmodell: Tabellen response, answer und survey_event, monatlich partitioniert (response_y2026m10 usw.). Doctrine kann partitionierte Tabellen nicht abbilden: Die DDL steht handgeschrieben in der Migration, schema_filter nimmt die Tabellen aus Schema-Diff und doctrine:schema:validate heraus, gelesen und geschrieben wird per DBAL. Primärschlüssel enthalten den Partitionsschlüssel ((id, started_at) usw.). Indizes: BRIN auf den Zeitspalten, GIN auf response.meta, B-Trees für Umfrage-Abfragen; PartitioningTest prüft per EXPLAIN, dass Standardabfragen sie nutzen. Antworten verweisen auf Frage- und Options-IDs der Version und speichern zusätzlich question_key, Typ und option_value. embed_config_id ist vorerst eine UUID-Spalte ohne Fremdschlüssel.

bash
bin/console app:collect:create-partitions --months=2   # Partitionen bis zwei Monate voraus

Die Migration legt Partitionen vom Vormonat bis drei Monate voraus an, danach läuft der Befehl täglich über App\Schedule (Scheduler). Es gibt bewusst keine Default-Partition – fehlt eine Partition, schlägt das Speichern fehl, statt Daten unbemerkt falsch abzulegen.

Embed-Konfigurationen, Identität und Missbrauchsschutz ​

Nutzerdokumentation: Embed-Konfigurationen, Identitäts-Token, Mehrfachabstimmung und Missbrauchsschutz, iframe. Code unter backend/src/Collect/ (Embed/, Identity/, Dedup/, Abuse/, Api/).

Embed-Konfigurationen (Tabelle embed_config, mandantenfähig, je Umfrage): GET/POST /api/surveys/{id}/embeds, GET/PATCH/DELETE /api/embeds/{id} (surveys.view / surveys.edit, Audit embed_config.*).

  • AllowedOrigins normalisiert und prüft Origin-Muster (https://shop.example.com, https://*.example.com, http://localhost:3000; Wildcard wie in CSP: beliebig tiefe Subdomains, nicht die Domain selbst).
  • OriginPolicy entscheidet für CORS und frame-ancestors: Embed angegeben → dessen Liste (leer = alle); ohne (oder mit unbekannter) Embed-ID → Vereinigung, falls alle Konfigurationen der Umfrage eingeschränkt sind, sonst alle. Immer erlaubt: eigene Origin, DASHBOARD_URL, COLLECT_TRUSTED_ORIGINS (kommagetrennt) und Anfragen ohne Origin.
  • Durchsetzung: CollectCorsOptionsProvider liefert nelmio die erlaubte Origin (Preflights fremder Origins bekommen keine CORS-Header), CollectOriginListener lehnt die eigentliche Anfrage mit 403 origin_not_allowed ab. Folgeanfragen einer Antwort (PATCH, complete, events mit Token) nutzen das Embed, mit dem die Sitzung begann.
  • GET /api/collect/surveys/{publicId}?embed= liefert embed: {id, mode, overrides, requireSignedIdentity}; showResults: false unterdrückt Ergebnisse auch serverseitig.

Identitäts-Token: HS256-JWT der Host-Anwendung (sub, exp ≤ 1 h, optional meta), geprüft von IdentityTokenVerifier gegen das Embed-Secret der Organisation (embed_secret, verschlüsselt mit App\Shared\Security\SecretBox, Schlüssel abgeleitet aus TWO_FACTOR_ENCRYPTION_KEY). GET /api/organizations/{id}/embed-secret legt es beim ersten Abruf an, POST …/embed-secret/rotate ersetzt es (das alte bleibt 24 h gültig, Audit embed_secret.rotated); beides mit api_keys.manage (Admin+). Gespeichert wird nur response.external_user_ref = sub:<hmac> (organisationsbezogen).

Dedup-Strategien (Dedup/, eine Klasse je Wert von settings.dedupStrategy): none, session (nur Browser), device (Hash des deviceToken je Umfrage), ip_hash (Hash der IP mit Tagessalz aus collect_daily_salt; geprüft mit dem Salz von heute und gestern), identity (Hash von sub je Umfrage, ohne Token 403 identity_required), login (vorbereitet, 403 login_required). Der alte Wert cookie wird für eine Version als Alias von device angenommen; die Migration hat gespeicherte Einstellungen umgestellt, Versions-Snapshots mit cookie werden als device gelesen.

bash
bin/console app:collect:rotate-salts   # Salz für heute/morgen anlegen, ältere als gestern löschen (täglich 00:05 per Scheduler)

Missbrauchsschutz (Abuse/): verdächtige Antworten werden angenommen und in response.flagged_reason markiert (honeypot, too_fast, ip_spike, survey_spike); ResultsCalculator und das Antwortlimit ignorieren sie.

  • Honeypot-Feld website im Sitzungsstart oder Abschluss (SubmissionChecks).
  • Mindestbearbeitungszeit COLLECT_MIN_COMPLETION_MS (Standard 2000, 0 schaltet ab) für Versionen mit mehr als einer Frage.
  • Lebensdauer einer Antwort COLLECT_ABANDON_AFTER_HOURS (Standard 24, 1–72): so lange gilt ihr Sitzungs-Token (Fortsetzen nach einem Neuladen über GET /api/collect/responses/{id}, Abschluss), danach zählt eine unvollständige Antwort als abgebrochen (Funnel outcomes).
  • Captcha je Umfrage (settings.captcha) mit dem vorhandenen AltchaCaptcha; geprüft nach der Idempotenz, damit Wiederholungen derselben clientRequestId keine neue Lösung brauchen.
  • Spitzen-Erkennung AnomalyDetector im Messenger-Handler DetectAnomaliesHandler (auf ResponseCompleted): Parameter collect.anomaly.* in config/services.yaml. Dafür speichert jede Antwort response.ip_hash (Tageshash, umfragebezogen).
  • Tests: CollectTestTrait mockt die Uhr; complete() lässt standardmäßig 5 Sekunden vergehen.

iframe-Seite ​

GET /p/{publicId}?embed=&theme=&locale=&hide_title=1&transparent=1 (IframePageController, Template templates/collect/iframe.html.twig) rendert <pollyou-survey channel="iframe"> in voller Breite und lädt den Loader von EMBED_SCRIPT_URL (Standard https://embed.pollyou.org/v1/embed.js). Overrides der Embed-Konfiguration haben Vorrang vor den Parametern. Header: vollständige Content-Security-Policy aus Collect\Http\PublicPageSecurity (default-src 'none'; script-src mit Nonce pro Antwort und der Origin von EMBED_SCRIPT_URL; style-src 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' plus Mercure; form-action 'none'; frame-ancestors <OriginPolicy>; base-uri 'none'; object-src 'none'), Permissions-Policy (Kamera, Mikrofon, Standort usw. aus), X-Robots-Tag: noindex. Der Direktlink /s/ sendet dieselben Header. Ein Inline-Script meldet Höhe (ResizeObserver) und pollyou:*-Ereignisse per postMessage an das Elternfenster: {source:"pollyou", type:"resize"|"event", publicId, height?, event?, detail?}. Unbekannte oder unveröffentlichte Umfragen: 404-Seite.

Lokal gibt es keinen Server für das Embed-Bundle. Zum Ausprobieren das Paket bauen, dist/cdn statisch ausliefern und die URL in backend/.env.local setzen:

bash
pnpm --filter @pollyou/embed-core build
npx serve frontend/packages/embed-core/dist/cdn -l 5174 --cors
echo 'EMBED_SCRIPT_URL=http://localhost:5174/v1/embed.js' >> backend/.env.local
# dann http://localhost:8480/p/<publicId> öffnen

Tests ​

bash
make test                   # PHPUnit und Vitest
make qa                     # statische Analyse und Formatierung
pnpm exec playwright test   # E2E gegen den laufenden Stack

Playwright startet dafür den Embed-CDN-Server auf :5174 (webServer in playwright.config.ts, nutzt einen laufenden make embed-cdn mit); vorher pnpm --filter @pollyou/embed-core build. Projekte: chromium (alle Specs) sowie firefox und webkit nur für die Embed-Specs (embed-iframe, embed-config, embed-device-dedup, playground – dieser braucht pnpm playground:build, siehe Playground) – Browser einmalig mit npx playwright install firefox webkit installieren. Auf Systemen ohne die Bibliotheken für WebKit (z. B. Arch Linux) laufen diese Specs im offiziellen Image mcr.microsoft.com/playwright:v<Version>-noble mit --network host. Gegen ein Dashboard aus einem Worktree: DASHBOARD_URL=http://127.0.0.1:5183 pnpm exec playwright test.

API-Client neu generieren ​

Nach Änderungen an API-Ressourcen den TypeScript-Client aktualisieren und das Ergebnis committen:

bash
pnpm gen:api

Die CI prüft mit pnpm gen:api:check, dass der Client aktuell ist.

Logging und Error-Tracking ​

  • Jede Anfrage bekommt eine Request-ID (Header X-Request-Id, eine gültige ID vom Aufrufer wird übernommen). Sie steht in jedem Logeintrag (extra.request_id), in der Antwort, in Messenger-Nachrichten und als Tag request_id in Sentry.
  • IP-Adressen werden in Logs maskiert (letztes IPv4-Oktett bzw. die letzten 80 Bit bei IPv6).
  • In Produktion schreibt Monolog JSON nach stderr.
  • Sentry ist aus, solange keine DSN gesetzt ist: Backend SENTRY_DSN (in backend/.env.local), Dashboard VITE_SENTRY_DSN. In den Sentry-Projekteinstellungen zusätzlich „Prevent Storing of IP Addresses“ aktivieren.
  • Testen: GET /_debug/error (nur im dev-Environment) wirft absichtlich eine Exception.

Audit-Log ​

Sicherheitsrelevante Aktionen landen append-only in der Tabelle audit_log (Code unter backend/src/Tenancy/Audit/, Entity Tenancy\Entity\AuditLogEntry).

  • Schreiben: AuditLogger::log(AuditAction::…, $organization, $subject, $diff, $actor). Akteur und (bei Organisationsaktionen) Organisation werden aus Login bzw. X-Organization-Id ergänzt; auth.*/session.* sind kontobezogen und haben keine Organisation. Alle Aktionsnamen stehen in AuditAction, auch die für spätere Features (Mitglieder, Einladungen, Umfragen, API-Keys, Exporte).
  • Der Eintrag wird per DBAL in der laufenden Transaktion geschrieben (Savepoint). Fehler brechen die eigentliche Aktion in Produktion nicht ab, sondern werden geloggt; mit kernel.debug (dev/test) fliegt die Exception.
  • diff enthält geänderte Felder als {before, after} oder Kontext; Schlüssel wie password, token, secret, hash werden verworfen. Statt der IP wird ein HMAC-SHA256 mit täglich wechselndem, aus APP_SECRET abgeleitetem Schlüssel gespeichert.
  • Bereits angebunden: auth.login (auch nach 2FA), auth.login_failed (nur bestehende Konten), auth.logout, auth.password_changed, auth.password_reset, auth.email_changed, auth.two_factor_enabled/_disabled/_backup_codes_regenerated, auth.oauth_linked/_unlinked/_link_requested, session.revoked, organization.created, organization.updated, survey.published, survey.closed, survey.reopened, survey.deleted, survey.created_from_template, survey_template.created/updated/deleted.
  • Lesen: GET /api/organizations/{id}/audit-log?action=&actorId=&page= (Recht audit.view, also Owner und Admin; {id} muss die aktive Organisation sein, 50 pro Seite, neueste zuerst). GET /api/me/activity liefert die letzten 50 Kontoereignisse des eingeloggten Nutzers.
  • Aufbewahrung: bin/console app:audit:purge --older-than=365 löscht ältere Einträge (Zeitplan folgt).

Themes ​

Themes bestimmen das Aussehen eingebetteter Umfragen. Quelle der Wahrheit ist das Paket frontend/packages/themes (@pollyou/themes):

  • schema/theme-tokens.schema.json: JSON Schema (Draft 2020-12) der Design-Tokens – Farben (primary, primaryContrast, background, surface, text, textMuted, border, success, danger, focusRing, je als colors.light und colors.dark, nur #rrggbb), Typografie (fontFamily, fontSizeBase in px, headingWeight), shape.radius, spacing.base und optional logoUrl (nur https://). Die TypeScript-Typen (ThemeTokens) spiegeln das Schema.
  • presets/*.json: die Standardthemes pollyou (Indigo, Default), neutral, contrast (hoher Kontrast) und warm als reines JSON.
  • toCssVariables(tokens, mode) liefert die Custom Properties für den Embed (--pollyou-color-primary, --pollyou-color-text-muted, …, --pollyou-font-family, --pollyou-font-size-base, --pollyou-heading-weight, --pollyou-radius, --pollyou-spacing, --pollyou-color-scheme); toCssText(tokens) erzeugt daraus ein Stylesheet für .pollyou-survey mit Dark-Mode per prefers-color-scheme.
  • contrastRatio()/checkThemeContrast() prüfen WCAG-Kontraste (relative Luminanz). Die Vitest-Tests stellen sicher, dass alle Standardthemes in Hell und Dunkel AA erfüllen (Text 4,5:1; Rahmen, Fokus, Primärfarbe 3:1). Für eigene Themes kann der Editor damit warnen; das Backend erzwingt es nicht.

Backend-Kopie: Der Backend-Container sieht nur backend/, deshalb liegen Schema und Presets zusätzlich unter backend/config/themes/. Nach Änderungen im Paket:

bash
pnpm --filter @pollyou/themes sync:backend

Der Paket-Test backend-parity.spec.ts schlägt fehl, solange die Kopie veraltet ist.

API (Code unter backend/src/Survey/Theme/, Entity Survey\Entity\Theme, Tabelle theme, mandantengebunden): GET/POST /api/themes, GET/PATCH/DELETE /api/themes/{id} mit X-Organization-Id; lesen braucht surveys.view, schreiben surveys.edit. Tokens werden mit opis/json-schema gegen das Schema validiert (422 mit Pfaden wie tokens.colors.light.primary). GET /api/themes/presets liefert die Standardthemes für jeden eingeloggten Nutzer, ohne Organisation. ThemeResolver::resolve($themeId, $organization) liefert das wirksame Theme einer Umfrage – das eigene Theme oder, ohne bzw. bei gelöschtem Theme, das Default-Preset.

Custom CSS (customCss, max. 10 KB) wird mit sabberworm/php-css-parser strikt geparst und aus dem Syntaxbaum neu geschrieben (CustomCssSanitizer). Erlaubt sind nur:

  • Style-Regeln; jeder Selektor wird unter .pollyou-survey eingehängt (ein vorhandenes .pollyou-survey am Anfang bleibt stehen, außer es folgt +/~). ::part(...) ist erlaubt.
  • @media und @supports mit Style-Regeln darin (nicht verschachtelt).
  • Deklarationen mit einfachen Property-Namen (inkl. Custom Properties). Werte dürfen nichts laden oder ausführen: kein url(, image-set(, image(, element(, cross-fade(, paint(, expression(, javascript:, vbscript:, data:, keine <, >, @. Verboten sind außerdem behavior, -moz-binding und position: fixed.

Alles andere (@import, @font-face, @keyframes, @charset, @namespace, @page, Kommentare) wird nicht still entfernt, sondern mit 422 und einer Meldung je Fund abgelehnt. Backslash-Escapes, Steuerzeichen und nicht parsebares CSS werden ebenfalls abgelehnt. Gespeichert wird die normalisierte Ausgabe des Sanitizers.

Theme-Editor im Dashboard: Hauptnavigation „Themes“ (/themes, nicht unter Einstellungen, weil Themes zum Gestalten von Umfragen gehören und der Editor die volle Breite für die Vorschau braucht). Die Liste zeigt die Themes der Organisation und die Vorlagen (GET /api/themes/presets); erstellen (aus einer Vorlage), duplizieren, umbenennen und löschen brauchen surveys.edit, alle anderen sehen die Seiten nur lesend. Der Editor (/themes/:id, views/themes/ThemeEditorView.vue) bearbeitet alle Tokens (helle und dunkle Farben, Schrift, Radius, Abstände, Logo) und das Custom CSS und speichert per PATCH immer das ganze tokens-Objekt. Die Vorschau (components/themes/ThemePreview.vue) rendert eine Beispielumfrage in einem Shadow Root, der die Variablen aus toCssVariables(tokens, mode) bekommt – das Custom CSS kann so nicht ins Dashboard durchschlagen. Ungespeichertes CSS erscheint dort nur, solange es nichts nachladen kann (isPreviewSafeCss), sonst die zuletzt gespeicherte, bereinigte Fassung; die 422-Meldungen des Sanitizers stehen direkt unter dem Feld. Kontrastwarnungen kommen aus checkThemeContrast und stehen neben den Farbfeldern und als Zusammenfassung unter der Vorschau; speichern bleibt möglich.

Embed (Web Component und Loader) ​

Das Paket frontend/packages/embed-core (@pollyou/embed-core) enthält das Custom Element <pollyou-survey> (Lit, Shadow DOM), den Client der öffentlichen Collector-API (/api/collect) und den Loader embed.js für das Script-Snippet. Details zu Attributen, Ereignissen, ::part()-Namen und Architektur stehen in der README des Pakets.

bash
pnpm --filter @pollyou/embed-core test             # Vitest inkl. axe-core für alle Zustände
pnpm --filter @pollyou/embed-core build            # dist/esm und dist/cdn (v1/, v<version>/, integrity.json, _headers)
pnpm --filter @pollyou/embed-core size             # Budget: embed.js < 25 KB gzip (auch in CI)
pnpm --filter @pollyou/embed-core storybook        # Storybook auf :6006 (alle Fragetypen und Zustände)
pnpm exec playwright test e2e/embed-snippet.spec.ts  # Snippet auf demo/index.html, Collector-API gemockt
  • Die Antwortprüfung im Browser ist ein schlanker Nachbau der Regeln aus @pollyou/survey-schema (ajv allein wäre mit 37 KB gzip größer als das ganze Budget). Ein Paritätstest führt die gemeinsamen Conformance-Fälle gegen beide Validatoren aus; bei neuen Fragetypen oder Regeln müssen beide angepasst werden.
  • demo/index.html ist eine statische Seite mit zwei Umfragen; sie spricht die lokale API unter http://localhost:8480 an.
  • Das CDN-Verzeichnis dist/cdn ist fertig zum Hochladen; das Deployment selbst ist noch offen.

Das Backend liefert unter /s/{publicId} die Direktlink-Seite einer veröffentlichten Umfrage aus (src/Collect/DirectLink, Templates unter templates/direct_link). Sie lädt embed.js von EMBED_SCRIPT_URL (Standard http://localhost:5174/v1/embed.js). Lokal stellt der optionale Compose-Service embed-cdn (Profil embed) den CDN-Build von embed-core bereit:

bash
make embed-cdn                                     # baut embed-core und startet embed-cdn auf :5174
# oder ohne Docker:
pnpm --filter @pollyou/embed-core build
pnpm --filter @pollyou/embed-core serve:cdn        # http://127.0.0.1:5174/v1/embed.js

Danach zeigt z. B. http://localhost:8480/s/AbC123xyz0 die Umfrage. Der Server liest die Dateien bei jedem Request, nach einem erneuten Build genügt ein Reload. Ohne laufenden CDN-Server rendert die Seite nur die Meta-Tags (Link-Vorschau) und einen leeren Platzhalter. curl -s localhost:8480/s/<publicId> | grep -E 'og:|twitter:' zeigt die Angaben für die Link-Vorschau.

Live-Ergebnisse (Mercure) ​

Nach jeder abgeschlossenen Antwort (ResponseCompleted, async im Worker) zählt src/Collect/Realtime die Antwort in Redis (HINCRBY pollyou:live:<surveyId> completed …) und veröffentlicht über den Mercure-Hub (Compose-Service mercure, http://localhost:8481):

  • öffentlich /surveys/{publicId}/results – der ResultsPayload, nur solange die Ergebnisse öffentlich sind (always, after_close nach Ende),
  • privat /organizations/{orgId}/surveys/{surveyId} – {surveyId, publicId, status, totalResponses, completedToday, updatedAt}.

Gedrosselt und gebündelt wird umfrageübergreifend: höchstens etwa ein Veröffentlichungslauf pro Sekunde für alle Umfragen mit neuen Antworten (Redis-Set der betroffenen Umfragen, SET NX-Sperren, eine verzögerte Nachricht FlushLiveResults, die den nächsten Lauf nachliefert). Der Hub läuft mit transport local (nur im Speicher, keine History-Datenbank): verpasste Updates werden nicht nachgeliefert, embed-core und Dashboard laden nach einem Reconnect neu. Der Collector liefert die Hub-Adresse im PublicSurvey-Feld realtime; embed-core abonniert sie mit live-results/data-live-results. GET /api/surveys/{id}/realtime-token gibt dem Dashboard einen Subscriber-JWT (5 Minuten) für das private Topic. Der Dev-Hub erlaubt anonyme Abonnements und jede Origin. Updates live mitlesen: http://localhost:8481/.well-known/mercure/ui/ (Topic /surveys/<publicId>/results). Details: backend/src/Collect/README.md.

Playwright gegen ein anderes Backend als :8480 (z. B. aus einem Worktree, mit eigenem Worker): E2E_API_BASE=http://localhost:8490 pnpm exec playwright test e2e/embed-live-results.spec.ts.