Appearance
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 # DatenbankmigrationenNach Ä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.
| Dienst | URL |
|---|---|
| Dashboard | http://localhost:5173 |
| API / OpenAPI-Doku | http://localhost:8480/api/docs |
| Mailpit | http://localhost:8482 |
| Adminer | http://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/loginliefert ein Access-Token (JWT, 15 Minuten) und setzt das Refresh-Token als httpOnly-Cookie (Pfad/api/auth, SameSite=Strict).POST /api/auth/refreshrotiert das Refresh-Token. Wird ein bereits verbrauchtes Token erneut vorgelegt, wird die gesamte Sitzung widerrufen.POST /api/auth/logoutbeendet die Sitzung sofort; zugehörige Access-Tokens werden ab dann abgelehnt.GET /api/auth/sessions,DELETE /api/auth/sessions/{id}undPOST /api/auth/sessions/revoke-othersverwalten die Sitzungen.- Passwort vergessen:
POST /api/auth/password/forgotantwortet 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/resetsetzt 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. ErstPOST /api/auth/email/confirmändert die Adresse. Da das Access-Token die E-Mail-Adresse als Kennung trägt, muss der Client danach einmalPOST /api/auth/refreshaufrufen. 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 ConstraintApp\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_throttlingder Firewalllogin), 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/captchaliefert eine Challenge (5 Minuten gültig, jede Lösung nur einmal), die das Widget löst; die Lösung geht als Base64-Payload im Feldcaptchamit.- Die Registrierung verlangt immer ein
captcha(sonst 422 aufcaptcha). 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ültigescaptchascheitern mit 401detail: "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 mitCaptchaTestTrait::solveCaptcha().
- Die Registrierung verlangt immer ein
- 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/setupliefertsecretundotpauthUri(als QR-Code anzeigen),POST /api/me/two-factor/enablemit einem aktuellen Code aktiviert 2FA und gibt einmalig 10 Backup-Codes (xxxx-xxxx) zurück. - Login: Bei aktivem 2FA antwortet
POST /api/auth/loginohne Sitzung mit{twoFactorRequired, challengeToken, expiresIn: 300}.POST /api/auth/login/two-factormitchallengeTokenundcode(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/disableundPOST /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):
- Das Dashboard navigiert zu
GET /api/auth/oauth/{provider}/start?returnTo=/pfad. Die API leitet zum Anbieter weiter;stateund PKCE-Verifier (S256) liegen zehn Minuten in einem signierten httpOnly-Cookie (pollyou_oauth, Pfad/api/auth/oauth, SameSite=Lax). - Der Anbieter ruft
GET /api/auth/oauth/{provider}/callbackauf (Redirect-URI<DASHBOARD_URL>/api/auth/oauth/<provider>/callback, läuft über den Dashboard-Proxy). Die API prüftstategegen das Cookie, tauscht den Code mit dem Verifier und leitet zu/auth/oauth/complete?code=…(Einmal-Code, 2 Minuten) bzw.?error=…weiter. - Das Dashboard löst den Code mit
POST /api/auth/oauth/exchangeein. Ergebnis (status):authenticated(normale Login-Antwort, Refresh-Cookie),two_factor_required(Challenge fürPOST /api/auth/login/two-factor– 2FA gilt auch beim OAuth-Login),linked,link_confirmation_sentoderregistration_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/emailsmitverified). - 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-linkverknü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/identitieslistet 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: falseinGET /api/me) setzen eines über „Passwort vergessen“.POST /api/me/identities/linkmit{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 tragenmethod: "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-ownershipmit{membershipId}(nur Owner): Das Ziel wird Owner, der bisherige Owner Admin.- Einladungen (
members.manage, inInvitationManager):POST …/invitationsmit{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 …/invitationslistet alle nicht angenommenen Einladungen (auch abgelaufene, sieheexpiresAt),DELETE …/invitations/{invitationId}widerruft,POST …/invitations/{invitationId}/resenderzeugt 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/acceptmit{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 mitgeschickterX-Organization-Id-Header stört hier nicht. - Wer noch kein Konto hat, registriert sich über die Einladungsseite (
/register?invitation=<token>):POST /api/auth/registermitinvitationTokenlegt keine eigene Organisation an, sondern tritt der einladenden bei; die Adresse muss die eingeladene sein (sonst 422 aufemail, ungültiges Token 422 aufinvitationToken) und gilt als bestätigt (keine Bestätigungsmail,emailVerified: truein der Antwort). Das Dashboard meldet direkt an und öffnet die Umfragen der Organisation. - Weitere Organisationen legt
POST /api/organizationsan; 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(Typpoll/survey,public_idaus 10 zufälligen Base62-Zeichen, Status,settingsals JSONB,theme_idvorerst ohne Fremdschlüssel),survey_page,question(Typ,question_key, Titel, Pflicht,configals JSONB, Position) undquestion_option(option_valueals stabiler Schlüssel, Label, Bild-URL, „Sonstiges“). Alle Tabellen tragenorganization_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 mitstatus=archived).GET /api/surveys/{id}liefert Einstellungen und die komplette Struktur (Seiten → Fragen → Optionen).POST /api/surveysmit{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 (settingswird schlüsselweise gemischt,nullleert). Standardwerte:resultsVisibility: after_vote,dedupStrategy: ip_hash, alles anderenull.PUT /api/surveys/{id}/structureersetzt die Struktur des Entwurfs in einem Request (in jedem Status außerarchived, siehe „Veröffentlichen und Versionen“): Elemente mitidbleiben erhalten (Antworten können sich später darauf beziehen), Elemente ohneidwerden 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 wiepages[0].questions[1].config.maxSelections. Ein Poll hat genau eine Seite mit genau einer Frage.POST /api/surveys/{id}/duplicatekopiert die komplette Struktur als neuen Entwurf (neue IDs undpublicId, Titel mit „ (Kopie)“ bzw. „ (copy)“ je nach Sprache des Nutzers).POST …/archivearchiviert (idempotent),POST …/restoreholt aus dem Archiv zurück (alsclosed, wenn sie schon veröffentlicht war, sonst als Entwurf). Archivierte Umfragen sind schreibgeschützt (409).DELETEgeht nur für Entwürfe (sonst 409) und wird im Audit-Log alssurvey.deletedvermerkt.- Vorlagen (
/api/survey-templates): mitgelieferte Vorlagen liegen als JSON inbackend/resources/templates/(Texte als{"de": …, "en": …}, Regeln referenzieren Fragen perkeyund Seiten alspage-<n>), eigene Vorlagen in der Tabellesurvey_template.POST /api/survey-templates/{id}/uselegt einen Entwurf an (201). Details: Vorlagen undbackend/src/Survey/README.md. - Projekte:
GET/POST /api/projects,GET/PATCH/DELETE /api/projects/{id}.PATCHmitpositionverschiebt 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}/publishmit{note?}(Rechtsurveys.publish, ab Editor) legt die nächste Version an, setzt sie alscurrent_version_idund antwortet mit der Umfrage. Der Status wirdscheduled, wennsettings.startsAtin der Zukunft liegt, sonstlive. 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].optionsbei zu wenigen Optionen), ein Ende in der Vergangenheit. Audit:survey.publishedmit{version, status}.- Die Definition enthält
title,description,type,themeId,settingsundpages(Form wie in SurveyDetail, inklusive aller IDs). Sie entsteht inSurveyDefinition::of()mit rekursiv sortierten Schlüsseln und Elementen nach Position, ihr SHA-256 über das kanonische JSON steht insurvey_version.definition_hash. PUT …/structureundPATCHbleiben indraft,scheduled,liveundclosederlaubt 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_hashist der Hash der Entwurfsdefinition und wird nach jedemPATCH, jedem Struktur-Speichern und beim Löschen eines verwendeten Themes neu berechnet.hasUnpublishedChangesin SurveyDetail/SurveySummary istdraft_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 habencurrentVersion: nullundhasUnpublishedChanges: false. GET /api/surveys/{id}/versionslistet{number, publishedAt, publishedBy, note}(neueste zuerst),GET …/versions/{number}liefert zusätzlich diedefinition.GET /api/surveys/{id}/changesvergleicht den Entwurf mit der aktuellen Version:{versionNumber, hasChanges, changes, warnings}. Der wiederverwendbare DienstDefinitionDiff::compare($alt, $neu)funktioniert genauso zwischen zwei Versionen. Elemente werden über ihre ID zugeordnet;changeshabenkind(added/removed/changed),element(page/question/option/settings/title–titleumfasst auch die Beschreibung,settingsauch das Theme),path(im neuen Stand, bei entfernten Elementen im alten) sowiebefore/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 …/closebeendet eine laufende oder geplante Umfrage,POST …/reopenöffnet eine geschlossene wieder (live, bzw.scheduled, falls der Start noch bevorsteht); beides mitsurveys.publish, aus anderen Status 409. Ist das Ende der aktuellen Version vorbei, verweigertreopenmit 409 – dann Ende ändern und neu veröffentlichen. Audit:survey.closed/survey.reopenedmit{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\Scheduleschickt jede MinuteRunSurveySchedule;SurveyScheduleHandlersetzt fällige Umfragen organisationsübergreifend vonscheduledauflive(starts_aterreicht) und vonliveaufclosed(ends_aterreicht). Verpasste Läufe holt der nächste nach, mehrfaches Ausführen ändert nichts. Lokal läuft das im Compose-Dienstscheduler(messenger:consume scheduler_default, genau eine Instanz); die Worker (worker,messenger:consume async_priority async) lassen sich mitdocker compose up -d --scale worker=3vervielfachen. - 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 überAbstractQuestionType) und werden per Service-Tag automatisch in derQuestionTypeRegistryregistriert. Jeder Typ definiert Config-Schema, Antwortprüfung und Speicherform (option_keys,integer,boolean,text,number,date,matrix,ranking; siehebackend/src/Survey/README.md).Vorhanden:
single_choice,multiple_choice(minSelections/maxSelections),rating(Sterne oder Skala,min0/1 bismax≤ 10),nps(0–10),yes_no. Choice-Fragen haben mindestens zwei Optionen, optional Zufallsreihenfolge (randomizeOptions), Bilder (imageUrl) und eine „Sonstiges“-Option (isOther, verlangt dannotherText). Dazudropdown,likert(5/7 Punkte),matrix(Zeilen = Optionen, Spalten inconfig.columns),ranking,text_short,text_long,number,date,email,image_choiceundhidden(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 inbackend/resources/survey-schema, Bibliothekjustinrainbow/json-schema). Nach Änderungen an den Schemas:bashpnpm --filter @pollyou/survey-schema sync:backendEin Vitest-Test schlägt fehl, solange die Kopie veraltet ist.
schemas/conformance.jsonenthä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.
| Endpunkt | Ergebnis |
|---|---|
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}/results | ResultsPayload oder 403 {code:"results_hidden"} |
GET /api/collect/captcha | ALTCHA-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. DerselbeclientRequestIdliefert innerhalb von 24 Stunden dieselbe Antwort und dasselbe Token (Idempotenz). Ein erneutescompletemit 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
PATCHgespeicherte Antworten zählen für Pflichtfragen,nulllöscht eine Antwort. Codes wie in@pollyou/survey-schema, dazuunknown_question. - Abschluss: speichert Dauer (
duration_ms), ruft bei gesetztem AntwortlimitSurveyLifecycle::closeIfResponseLimitReached()auf (mit Sperre auf die Umfrage) und schickt nach dem Commit die Messenger-NachrichtResponseCompleted(Transportasync; Handler für Auswertungen folgen). - Status: Sitzungen, Zwischenspeichern und Abschluss nur bei
live, sonst 403not_openmit dem Status. - Ergebnisse: Fragen, Optionen und Labels aus der aktuellen Version, gezählt werden abgeschlossene Antworten aller Versionen über
question_keyundoption_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 mitRetry-After; in Tests geteilt durchcollect.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_PATHauf eine.mmdb(Country oder City) setzen; leer (Standard) oder Datei fehlt →countrybleibt 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
referrerim Body, sonstReferer-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 vorausDie 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.*).
AllowedOriginsnormalisiert 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).OriginPolicyentscheidet für CORS undframe-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 ohneOrigin.- Durchsetzung:
CollectCorsOptionsProviderliefert nelmio die erlaubte Origin (Preflights fremder Origins bekommen keine CORS-Header),CollectOriginListenerlehnt die eigentliche Anfrage mit 403origin_not_allowedab. Folgeanfragen einer Antwort (PATCH, complete, events mit Token) nutzen das Embed, mit dem die Sitzung begann. GET /api/collect/surveys/{publicId}?embed=liefertembed: {id, mode, overrides, requireSignedIdentity};showResults: falseunterdrü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
websiteim Sitzungsstart oder Abschluss (SubmissionChecks). - Mindestbearbeitungszeit
COLLECT_MIN_COMPLETION_MS(Standard 2000,0schaltet 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 überGET /api/collect/responses/{id}, Abschluss), danach zählt eine unvollständige Antwort als abgebrochen (Funneloutcomes). - Captcha je Umfrage (
settings.captcha) mit dem vorhandenenAltchaCaptcha; geprüft nach der Idempotenz, damit Wiederholungen derselbenclientRequestIdkeine neue Lösung brauchen. - Spitzen-Erkennung
AnomalyDetectorim Messenger-HandlerDetectAnomaliesHandler(aufResponseCompleted): Parametercollect.anomaly.*inconfig/services.yaml. Dafür speichert jede Antwortresponse.ip_hash(Tageshash, umfragebezogen). - Tests:
CollectTestTraitmockt 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> öffnenTests
bash
make test # PHPUnit und Vitest
make qa # statische Analyse und Formatierung
pnpm exec playwright test # E2E gegen den laufenden StackPlaywright 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:apiDie 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 Tagrequest_idin 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(inbackend/.env.local), DashboardVITE_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-Idergänzt;auth.*/session.*sind kontobezogen und haben keine Organisation. Alle Aktionsnamen stehen inAuditAction, 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. diffenthält geänderte Felder als{before, after}oder Kontext; Schlüssel wiepassword,token,secret,hashwerden verworfen. Statt der IP wird ein HMAC-SHA256 mit täglich wechselndem, ausAPP_SECRETabgeleitetem 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=(Rechtaudit.view, also Owner und Admin;{id}muss die aktive Organisation sein, 50 pro Seite, neueste zuerst).GET /api/me/activityliefert die letzten 50 Kontoereignisse des eingeloggten Nutzers. - Aufbewahrung:
bin/console app:audit:purge --older-than=365lö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 alscolors.lightundcolors.dark, nur#rrggbb), Typografie (fontFamily,fontSizeBasein px,headingWeight),shape.radius,spacing.baseund optionallogoUrl(nurhttps://). Die TypeScript-Typen (ThemeTokens) spiegeln das Schema.presets/*.json: die Standardthemespollyou(Indigo, Default),neutral,contrast(hoher Kontrast) undwarmals 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-surveymit Dark-Mode perprefers-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:backendDer 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-surveyeingehängt (ein vorhandenes.pollyou-surveyam Anfang bleibt stehen, außer es folgt+/~).::part(...)ist erlaubt. @mediaund@supportsmit 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ßerdembehavior,-moz-bindingundposition: 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.htmlist eine statische Seite mit zwei Umfragen; sie spricht die lokale API unterhttp://localhost:8480an.- Das CDN-Verzeichnis
dist/cdnist fertig zum Hochladen; das Deployment selbst ist noch offen.
Direktlink-Seite und Embed-CDN
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.jsDanach 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_closenach 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.