Compare commits

...

8 commits

Author SHA1 Message Date
6891b7cb03 Merge pull request 'feat(auth): KTO-01 Backend – Registrierungsregeln, Auth-Konfiguration, Mailvorlagen (1/4)' (#33) from feature/1-auth-registrierung-anmeldung into main
All checks were successful
CI / flutter (push) Successful in 1m7s
CI / web-build (push) Successful in 55s
CI / supabase (push) Successful in 3s
CI / supabase-tests (push) Successful in 34s
Reviewed-on: #33
2026-11-02 22:57:49 +00:00
cschlaefke
9f36cd0c33 feat(db): reject U+200B in display names
All checks were successful
CI / flutter (pull_request) Successful in 1m3s
CI / web-build (pull_request) Successful in 54s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 34s
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:51:56 +01:00
cschlaefke
fb0f4b9d3f fix(db): trim all Unicode whitespace in display name, harden templates
All checks were successful
CI / flutter (pull_request) Successful in 1m3s
CI / web-build (pull_request) Successful in 58s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 34s
- add public.trim_ws mirroring Dart String.trim
- require JSON string metadata, tolerate non-string locale in templates
- add tests, align docs with review

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:48:01 +01:00
cschlaefke
47721a48e8 docs(auth): align spec with spike results and add AUTH-CFG-25
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:26:03 +01:00
cschlaefke
59a47c5a5d feat(auth): configure Supabase Auth and add de/en mail templates
Adds AUTH-CFG settings, confirmation/recovery templates and
docs/auth-konfiguration.md (KTO-01 criteria 1, 3, 4, 7, 8, 11).

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:23:06 +01:00
cschlaefke
395816609f feat(db): enforce registration rules and add terms_version
Adds display name validation, terms_version, locale sync trigger and
pgTAP tests (KTO-01 criteria 1, 2, 10, 11). Includes GoTrue spike results.

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:36:21 +01:00
cschlaefke
450af82234 Merge branch 'main' into feature/1-auth-registrierung-anmeldung 2026-11-02 22:21:24 +01:00
cschlaefke
e56f75a5e1 docs(auth): add spec for KTO-01 registration, login and password reset
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:10:07 +01:00
14 changed files with 1441 additions and 60 deletions

178
docs/auth-konfiguration.md Normal file
View file

@ -0,0 +1,178 @@
# Auth-Konfiguration (Supabase Auth / GoTrue)
| | |
|---|---|
| Gilt für | KTO-01 (Issue #1) und alle folgenden Auth-Stories |
| Stand | 2026-11-02 |
| Versionen (verifiziert) | Supabase CLI 3.0.0, GoTrue `supabase/gotrue:v2.197.0`, Postgres 17, Mailpit v1.31.3 |
| Quellen | `docs/specs/1-auth-registrierung-anmeldung.md` (§ 4.1, 4.3), `docs/specs/1-auth-spike-ergebnisse.md` (Nachweis, bleibt unverändert liegen) |
| Pflege | Jede Änderung an `supabase/config.toml` (Abschnitt `[auth*]`) und am Env-Block unten gemeinsam ändern. Tests referenzieren die IDs `AUTH-CFG-nn`. |
Zweck: Die Auth-Einstellungen sind explizit festgelegt, weil sich die Standardwerte je nach Quelle unterscheiden (GoTrue-Binary, CLI-Vorlage, Docker-Setup; Klärung 18). Lokal und in der CI gilt `supabase/config.toml`. Staging und Produktion (self-hosted, ADR-0002) setzen dieselben Werte per Umgebungsvariablen.
## 1. Einstellungen (AUTH-CFG)
Spalte „Wirkung“: **lokal** = wirkt in der CLI über `config.toml`; **Env** = wirkt nur im Self-Hosting über Umgebungsvariablen (in `config.toml` nur als Kommentar bzw. Zielwert); **Gateway** = nur am Proxy/Gateway lösbar.
| ID | Einstellung | Wert | Wirkung | `config.toml` / Env | Begründung | Krit. |
|---|---|---|---|---|---|---|
| AUTH-CFG-01 | Site-URL | lokal `http://localhost:3000`; Prod `https://shuffle.cshomelabs.work` (Platzhalter); Staging legt devops fest | lokal + Env | `auth.site_url` / `GOTRUE_SITE_URL` | Basis der Mail-Links (`{{ .SiteURL }}`) | 1, 8 |
| AUTH-CFG-02 | Redirect-Allow-List | nur `<Site-URL>/auth/confirm` | lokal + Env | `auth.additional_redirect_urls` / `GOTRUE_URI_ALLOW_LIST` | Nur eigene Web-URLs erlaubt | 1, 8 |
| AUTH-CFG-03 | Registrierung erlaubt | an | lokal + Env | `auth.enable_signup`, `auth.email.enabled` / `GOTRUE_DISABLE_SIGNUP=false`, `GOTRUE_EXTERNAL_EMAIL_ENABLED=true` | Selbstregistrierung ist der Kern von KTO-01 | 1 |
| AUTH-CFG-04 | E-Mail-Bestätigung Pflicht | an | lokal + Env | `auth.email.enable_confirmations = true` / `GOTRUE_MAILER_AUTOCONFIRM=false` | CLI-Standard ist `false`. Voraussetzung für den Enumerationsschutz, zusammen mit AUTH-CFG-24 | 1, 4, 6 |
| AUTH-CFG-05 | Passwort-Mindestlänge | 10 | lokal + Env | `auth.minimum_password_length` / `GOTRUE_PASSWORD_MIN_LENGTH` | Entscheidung O10; Standard wäre 6 | 3 |
| AUTH-CFG-06 | Zeichenklassen | keine | lokal + Env | `auth.password_requirements = ""` / `GOTRUE_PASSWORD_REQUIRED_CHARACTERS` leer | Nicht gefordert | 3 |
| AUTH-CFG-07 | Gültigkeit Bestätigungs- und Reset-Link | 3600 s (1 h) | lokal + Env | `auth.email.otp_expiry` / `GOTRUE_MAILER_OTP_EXP=3600` | Entscheidung F3. Das Self-hosted-Binary hätte 86400, deshalb im Env explizit setzen | 1, 8 |
| AUTH-CFG-08 | Mindestabstand zwischen Mails je Nutzer | lokal/CI 1 s; Staging/Prod 60 s | lokal + Env | `auth.email.max_frequency` / `GOTRUE_SMTP_MAX_FREQUENCY=60s` | Lokal bewusst 1 s, sonst scheitert der Resend-Test direkt nach dem Signup an 429. Die 60 s werden auf Staging geprüft (Checkliste) | 6, 8 |
| AUTH-CFG-09 | Mails pro Stunde, **instanzweit** | lokal/CI-Zielwert 100; Staging/Prod **30** | **nur Env** | `auth.rate_limit.email_sent` (lokal ohne Wirkung) / `GOTRUE_RATE_LIMIT_EMAIL_SENT=30` | Entscheidung F4. Die CLI setzt intern `360000` (Spike A3). Risiko R3 | 1, 6, 8 |
| AUTH-CFG-10 | Drosselung Passwort-Login und Token-Refresh | 150 je 5 min je IP, Burst 30 | **nur Env, nur mit AUTH-CFG-13** | `auth.rate_limit.token_refresh` / `GOTRUE_RATE_LIMIT_TOKEN_REFRESH=150` | Login Nr. 31 einer IP → 429 `over_request_rate_limit`. Keine Sperre je Konto, nur je IP. 429 ohne `Retry-After` | 7 |
| AUTH-CFG-11 | Drosselung Registrierung, Reset, erneut senden | 30 je 5 min je IP, Burst 30 | **nur Env, nur mit AUTH-CFG-13** | `auth.rate_limit.sign_in_sign_ups` / `GOTRUE_RATE_LIMIT_OTP=30` | Für Signup getestet; dass Recover und Resend denselben Limiter nutzen, ist nur aus dem Code abgeleitet | 1, 6, 8 |
| AUTH-CFG-12 | Drosselung Link-Prüfung | 30 je 5 min je IP | **nur Env, nur mit AUTH-CFG-13** | `auth.rate_limit.token_verifications` / `GOTRUE_RATE_LIMIT_VERIFY=30` | – | 8 |
| AUTH-CFG-13 | Client-IP hinter Proxy | `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For` | **nur Env (Pflicht Staging/Prod)** | – / `GOTRUE_RATE_LIMIT_HEADER` | Ohne diese Einstellung gibt es **keine** IP-Drosselung; Requests ohne den Header werden auch mit ihr nicht gedrosselt. Der Proxy muss den Header überschreiben | 7 |
| AUTH-CFG-14 | JWT-Laufzeit | 3600 s | lokal + Env | `auth.jwt_expiry` / `GOTRUE_JWT_EXP=3600` | Entscheidung F1. Andere Geräte sind nach einem Passwort-Reset spätestens nach 60 min abgemeldet (Restrisiko R8) | 5, 9 |
| AUTH-CFG-15 | Refresh-Token-Rotation | an, Wiederverwendungsfenster 10 s | lokal + Env | `auth.enable_refresh_token_rotation`, `auth.refresh_token_reuse_interval` / `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=true`, `GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=10` | Standard | 5, 9 |
| AUTH-CFG-16 | Sitzungsbegrenzung | **nicht gesetzt** | lokal + Env | `auth.sessions.*` (auskommentiert) / `GOTRUE_SESSIONS_TIMEBOX`, `GOTRUE_SESSIONS_INACTIVITY_TIMEOUT` ungesetzt | Angemeldet bis zum Abmelden | 5 |
| AUTH-CFG-17 | Mailvorlage Bestätigung | `supabase/templates/confirmation.html`, Betreff de/en | lokal + Env | `auth.email.template.confirmation` / `GOTRUE_MAILER_TEMPLATES_CONFIRMATION` (URL), `GOTRUE_MAILER_SUBJECTS_CONFIRMATION` | Sprache aus `user_metadata.locale`, sonst Englisch. Self-hosted lädt per URL (siehe 3) | 1, 11 |
| AUTH-CFG-18 | Mailvorlage Reset | `supabase/templates/recovery.html`, Betreff de/en | lokal + Env | `auth.email.template.recovery` / `GOTRUE_MAILER_TEMPLATES_RECOVERY`, `GOTRUE_MAILER_SUBJECTS_RECOVERY` | wie oben | 8, 11 |
| AUTH-CFG-19 | Benachrichtigungs-Mails | alle aus | lokal + Env | `auth.email.notification.<typ>.enabled = false` / `GOTRUE_MAILER_NOTIFICATIONS_<TYP>_ENABLED=false` | Nur Bestätigung und Reset (Krit. 11). `email_changed` schaltet KTO-11 ein (N2) | 11 |
| AUTH-CFG-20 | Magic Link / OTP-Login | `POST /auth/v1/otp` und `/auth/v1/magiclink` am Gateway sperren | **Gateway** | – (kein Schalter in GoTrue) | Sonst Mails außerhalb von Krit. 11 und Konten ohne Passwort (Spike A7). Der Trigger ersetzt die Sperre nicht. Lokal nicht gesperrt | 11, 3 |
| AUTH-CFG-21 | Leaked-Password-Prüfung (HIBP) | aus | **nur Env** | – / `GOTRUE_PASSWORD_HIBP_ENABLED=false` | Entscheidung F2: keine Übermittlung an Dritte | 3 |
| AUTH-CFG-22 | SMTP / Mailfänger | lokal Mailpit; Staging/Prod EU-Anbieter mit AVV (**offen**, F7) | lokal + Env | `local_smtp` / `GOTRUE_SMTP_*` | Release-Blocker, kein Merge-Blocker | 1, 8, 11 |
| AUTH-CFG-23 | E-Mail-Wechsel auf beiden Adressen bestätigen | **in #1 nicht festgelegt** (CLI-Standard `true`) | – | `auth.email.double_confirm_changes` / `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED` | Entscheidung fällt in KTO-11 (N2) | (KTO-11) |
| AUTH-CFG-24 | SMS-Autoconfirm aus | `SMS_AUTOCONFIRM=false` | lokal + Env | `auth.sms.enable_confirmations = true` / `GOTRUE_SMS_AUTOCONFIRM=false`; in der Self-Hosting-Vorlage `ENABLE_PHONE_AUTOCONFIRM=false` | SMS ist nicht im Einsatz, die Einstellung steuert aber den Enumerationsschutz beim E-Mail-Signup. Mit `true` (CLI-Standard und Vorlage `docker/.env.example`!) liefert Signup mit bestätigter Adresse 422 `user_already_exists` | 4 |
| AUTH-CFG-25 | Passwortänderung ohne erneute Anmeldung | `false` (Standard, ausdrücklich gesetzt) | lokal + Env | `auth.email.secure_password_change` / `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` (Env-Name vor Release gegen das Image prüfen) | Recovery-Sitzungen sind ausgenommen; ob das aktuelle Passwort verlangt wird, entscheidet KTO-11 (N13) | (KTO-11) |
## 2. Env-Block für Staging und Produktion (GoTrue, ohne Secrets)
Gilt für das Image `supabase/gotrue:v2.197.0` (pinnen, nicht `latest`). Secrets (JWT-Secret, SMTP-Passwort, DB-URL) stehen nicht hier, sondern im Secret-Speicher des Hosting-Setups.
```env
# AUTH-CFG-01, -02, -03
GOTRUE_SITE_URL=https://shuffle.cshomelabs.work
GOTRUE_URI_ALLOW_LIST=https://shuffle.cshomelabs.work/auth/confirm
GOTRUE_DISABLE_SIGNUP=false
GOTRUE_EXTERNAL_EMAIL_ENABLED=true
# AUTH-CFG-04, -24 (beide Autoconfirm-Schalter müssen false sein; Supabase-Vorlage: ENABLE_PHONE_AUTOCONFIRM=false)
GOTRUE_MAILER_AUTOCONFIRM=false
GOTRUE_SMS_AUTOCONFIRM=false
# AUTH-CFG-05, -06, -21
GOTRUE_PASSWORD_MIN_LENGTH=10
GOTRUE_PASSWORD_REQUIRED_CHARACTERS=
GOTRUE_PASSWORD_HIBP_ENABLED=false
# AUTH-CFG-23 (Stagingwert; in #1 nicht festgelegt, CLI-Standard true, Entscheidung in KTO-11)
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true
# AUTH-CFG-25 (Env-Name vor Release gegen das Image prüfen)
GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION=false
# AUTH-CFG-07, -08
GOTRUE_MAILER_OTP_EXP=3600
GOTRUE_SMTP_MAX_FREQUENCY=60s
# AUTH-CFG-09 bis -13 (GOTRUE_RATE_LIMIT_HEADER ist Pflicht, sonst keine IP-Drosselung)
GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For
GOTRUE_RATE_LIMIT_EMAIL_SENT=30
GOTRUE_RATE_LIMIT_TOKEN_REFRESH=150
GOTRUE_RATE_LIMIT_OTP=30
GOTRUE_RATE_LIMIT_VERIFY=30
# AUTH-CFG-14, -15 (AUTH-CFG-16: Sitzungs-Variablen NICHT setzen)
GOTRUE_JWT_EXP=3600
GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=true
GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=10
# AUTH-CFG-17, -18 (Betreff je Sprache verzweigt, Text identisch zu config.toml; URLs siehe Abschnitt 3)
GOTRUE_MAILER_TEMPLATES_CONFIRMATION=https://<vorlagen-host>/confirmation.html
GOTRUE_MAILER_TEMPLATES_RECOVERY=https://<vorlagen-host>/recovery.html
GOTRUE_MAILER_SUBJECTS_CONFIRMATION={{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}Bestätige deine E-Mail-Adresse für Shuffle{{ else }}Confirm your email address for Shuffle{{ end }}
GOTRUE_MAILER_SUBJECTS_RECOVERY={{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}Passwort für Shuffle zurücksetzen{{ else }}Reset your Shuffle password{{ end }}
# AUTH-CFG-19 (Benachrichtigungen aus; KTO-11 schaltet email_changed ein)
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_LINKED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_UNLINKED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED=false
# AUTH-CFG-22 (Anbieter offen, F7): GOTRUE_SMTP_HOST, _PORT, _USER, _PASS, _ADMIN_EMAIL, _SENDER_NAME
```
Hinweis zu den Benachrichtigungs-Variablen: Namen aus dem GoTrue-Schema für `v2.197.0` abgeleitet; beim ersten Aufsetzen mit einer Testmail (Passwortänderung) prüfen, dass keine Mail ausgeht (Checkliste).
## 3. Gateway- und Dashboard-Einstellungen
- **Proxy vor GoTrue (AUTH-CFG-13):** GoTrue ist nur über den Proxy erreichbar. Der Proxy **überschreibt** `X-Forwarded-For` mit der echten Client-IP (nicht anhängen), sonst fälschen Clients ihre IP und umgehen die Drosselung. Ein direkter Zugriff auf GoTrue umgeht die Drosselung ebenfalls.
- **Magic-Link-Sperre (AUTH-CFG-20):** Das Gateway lehnt `POST /auth/v1/otp` und `POST /auth/v1/magiclink` ab (z. B. 404/403). Beide Endpunkte lassen sich in GoTrue nicht abschalten; `/otp` legt mit gültigen Metadaten ein Konto an.
- **Vorlagen-Hosting (AUTH-CFG-17, -18):** Self-hosted GoTrue lädt Mailvorlagen **per URL**. Die Dateien aus `supabase/templates/` werden unter einer intern erreichbaren URL bereitgestellt (z. B. statisch am Gateway). Ist die URL nicht erreichbar, fällt GoTrue **still** auf die englische Standardvorlage zurück (Risiko R5). Die URL gehört in den Health-Check.
- **SPA-Rewrite:** Die Web-App muss `/auth/confirm` ausliefern (alle Pfade → `index.html`), sonst laufen die Mail-Links ins Leere.
- **Dashboard:** Im Self-Hosting gibt es keine Cloud-Einstellungen; Studio ändert keine Auth-Werte. Alle Werte stehen im Env-Block oben. Änderungen im Studio sind zu unterlassen, damit Repo und Betrieb übereinstimmen.
- **Image-Pinning:** `supabase/gotrue:v2.197.0`. Die Postgres-Hauptversion 17 muss mit `db.major_version` übereinstimmen.
## 4. Verifiziert (Spike B0, 2026-11-02)
Getestet gegen CLI 3.0.0 und GoTrue `v2.197.0` über Kong (wie die App). Die Drosselungs- und Mail-Limit-Tests liefen zusätzlich gegen einen zweiten GoTrue-Container desselben Images mit den Env-Variablen der CLI plus `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For` und `GOTRUE_RATE_LIMIT_EMAIL_SENT=5` (entspricht dem Self-Hosting-Pfad). Nicht aussagekräftig lokal: SMTP-Anbieter, Verhalten hinter dem Produktions-Gateway, Header-Behandlung des Proxys, echte Zustellung. Vollständige Nachweise: `docs/specs/1-auth-spike-ergebnisse.md`.
| Punkt | Ergebnis |
|---|---|
| (a) Betreff und Vorlage werten `.Data.locale` aus | Bestätigt. `de` → deutsch; `en` oder fehlend → englisch. `printf "%v" (index .Data "locale")` ist sicher, wenn der Schlüssel fehlt oder kein String ist (sonst HTTP 500 bei `eq`). Nach Profiländerung (Sync-Trigger) kommt die Mail in der neuen Sprache. |
| (b) `type` für `token_hash` | Bestätigung `email` (auch `signup`), Reset `recovery`. Zweites Einlösen → 403 `otp_expired`. |
| (c) Limiter Passwort-Login | `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` (150 je 5 min, Burst 30); Signup/Recover/Resend `GOTRUE_RATE_LIMIT_OTP` (30 je 5 min). **Greifen nur mit `GOTRUE_RATE_LIMIT_HEADER` und Requests mit dem Header.** Die lokale CLI kann den Header nicht setzen, also lokal keine Drosselung. 429 ohne `Retry-After`. |
| (d) `email_sent` | Instanzweit. In der CLI wirkungslos (`360000`). Bei erschöpftem Limit antwortet `/recover` für bestehende Adressen 429, für unbekannte 200 (Seitenkanal, akzeptiert F12). |
| (e) Mailfänger | Mailpit v1.31.3, `http://127.0.0.1:54324/api/v1/messages`, `/api/v1/message/{id}`. |
| (f) Trigger-Fehler bei `signUp` | HTTP 500, Header `X-Sb-Error-Code: unexpected_failure`, Body `{"code":"P0001","message":"<grund>"}`. Format nicht vertraglich zugesichert. |
| (g) `weak_password` | 422, `reasons = ["length"]`. |
| (h) Versionen | CLI 3.0.0 startet GoTrue `v2.197.0`, identisch zur `docker-compose.yml` des Supabase-Repos (Stand 2026-11-02). |
| (i) Linkablauf | Maßgeblich sind `auth.users.confirmation_sent_at` / `recovery_sent_at`, nicht `auth.one_time_tokens.created_at`. |
| (j) `amr` im Recovery-JWT | `[{"method":"otp"}]`, identisch zur Bestätigungssitzung; nicht unterscheidbar (R12). |
Abweichungen von der ursprünglichen Spec-Annahme:
| # | Abweichung | Konsequenz |
|---|---|---|
| A1 | Enumerationsschutz hängt an `GOTRUE_SMS_AUTOCONFIRM=false` (CLI-Standard und Self-Hosting-Vorlage setzen `true`). Sonst 422 `user_already_exists`. | AUTH-CFG-24 |
| A2 | Ohne `GOTRUE_RATE_LIMIT_HEADER` keine IP-Drosselung; Requests ohne Header werden auch mit Einstellung nicht gedrosselt. | AUTH-CFG-13 Pflicht; Drosselungstest gegen Extra-Container |
| A3 | `email_sent` wirkt in der CLI nicht. | AUTH-CFG-09 nur per Env |
| A4 | Trigger-Fehler kommen als HTTP 500 mit der Trigger-Meldung im Body. | Mapping über Status 500 |
| A5 | Recovery-JWT ist an `amr` nicht erkennbar. | Guard nur im laufenden Prozess |
| A6 | Linkablauf wird über `auth.users.*_sent_at` bestimmt. | E2E-Helfer setzt diese Spalten |
| A7 | `/otp` und `/magiclink` nicht abschaltbar, `/otp` kann Konten anlegen. | AUTH-CFG-20 am Gateway |
### Prüfung der Konfiguration und Mailvorlagen (B2, lokal)
Mit der committeten `config.toml` geprüft (Mailpit-API): Registrierung mit `locale=de` → Betreff „Bestätige deine E-Mail-Adresse für Shuffle“, `lang="de"`; `locale=en` und ohne `locale` → englischer Betreff und Text; Passwort-Reset analog („Passwort für Shuffle zurücksetzen“ / „Reset your Shuffle password“). Links: `http://localhost:3000/auth/confirm?token_hash=…&type=email` bzw. `…&type=recovery`; der Token aus der Mail lässt sich per `POST /verify` einlösen. Im GoTrue-Container sind `GOTRUE_MAILER_AUTOCONFIRM=false`, `GOTRUE_SMS_AUTOCONFIRM=false`, `GOTRUE_PASSWORD_MIN_LENGTH=10`, `GOTRUE_MAILER_OTP_EXP=3600` und `GOTRUE_URI_ALLOW_LIST=http://localhost:3000/auth/confirm` gesetzt. Signup mit 9 Zeichen → 422 `weak_password`; Signup mit bestätigter Adresse → 200 mit erfundenem Nutzer und leeren `identities`, keine Mail.
## 5. Release-Checkliste (vor jedem Release auf Staging/Produktion)
Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken:
- [ ] GoTrue-Image ist `supabase/gotrue:v2.197.0` (oder bewusst aktualisiert, dann Spike-Punkte (c), (d), (f) wiederholen).
- [ ] Env-Block aus Abschnitt 2 ist vollständig gesetzt; `GOTRUE_PASSWORD_HIBP_ENABLED=false` (AUTH-CFG-21).
- [ ] `GOTRUE_SMS_AUTOCONFIRM=false` bzw. `ENABLE_PHONE_AUTOCONFIRM=false`: Signup mit bestätigter Adresse → 200, `identities` leer (AUTH-CFG-24).
- [ ] Drosselung am echten Gateway: 31 Fehllogins einer IP → 429; ein vom Client gefälschter `X-Forwarded-For` wird überschrieben; GoTrue ist von außen nicht direkt erreichbar (AUTH-CFG-13, -10).
- [ ] `POST /auth/v1/otp` und `/auth/v1/magiclink` werden vom Gateway abgelehnt (AUTH-CFG-20).
- [ ] Mailvorlagen-URLs erreichbar; Testmail de und en (Bestätigung und Reset) auf Staging, Betreff und Text in der richtigen Sprache (AUTH-CFG-17, -18, Risiko R5).
- [ ] Mindestabstand 60 s zwischen Mails je Nutzer (AUTH-CFG-08) und Link-Gültigkeit 1 h (AUTH-CFG-07) auf Staging geprüft.
- [ ] `GOTRUE_RATE_LIMIT_EMAIL_SENT=30` gesetzt und Monitoring auf 429 `over_email_send_rate_limit` aktiv (AUTH-CFG-09, R3).
- [ ] Nach Passwortänderung kommt keine Benachrichtigungs-Mail (AUTH-CFG-19).
- [ ] SMTP-Anbieter mit AVV und Absender-Domain entschieden und eingerichtet (AUTH-CFG-22, F7).
- [ ] SPA-Rewrite: `/auth/confirm` wird von der Web-App ausgeliefert.
- [ ] Rechtstexte-URLs und `TERMS_VERSION` final (R11); Bundle-/Application-ID final (R15).
## 6. Bekannte Grenzen
- **Keine Drosselung lokal und in der CI-CLI** (A2). Der Drosselungstest läuft gegen einen Extra-Container (Spec D2/T3).
- **Keine Wartezeit-Angabe:** Die 429-Antwort trägt kein `Retry-After`; die App nennt keine Dauer.
- **Drosselung nur je IP**, nicht je Konto.
- **Mail-Limit instanzweit** (30/h): Angreifer können Mailausfälle auslösen. Während der Sperre ist die Existenz eines Kontos über 429 vs. 200 erkennbar (R3, akzeptiert F12).
- **Enumeration auf API-Ebene:** Signup mit unbestätigter Adresse gibt den echten Nutzer zurück, mit bestätigter einen erfundenen (R6). Die App zeigt in beiden Fällen denselben Bildschirm.
- **Abgemeldete Geräte nach Reset:** Access-Tokens gelten bis zu 60 min weiter (R8, F1).
- **Abgebrochene Recovery** bleibt nach einem Reload als normale Anmeldung bestehen (R12).
- **Häufige Passwörter** prüft nur der Client; der Server erzwingt nur die Länge 10 (R14).
- **Magic Link / OTP** sind nicht abschaltbar, nur am Gateway sperrbar (R4).
- **Vorlagen-Rückfall:** Unerreichbare Vorlagen-URL → stille englische Standardvorlage (R5).
- **Plain-Text-Teil der Mails:** GoTrue sendet nur HTML. Die Vorlagen zeigen den Link deshalb zusätzlich als Klartext; Mailpit erzeugt lokal einen Text-Teil.

View file

@ -34,7 +34,7 @@ R1 braucht 17 Tabellen in vier Bereichen. Jede Tabelle hat einen eigenen Primär
| Tabelle | Spalten | Zweck |
| --- | --- | --- |
| `profiles` | `id` (PK, zugleich FK auf `auth.users.id`), `display_name` (max. 30), `bio` (max. 300), `city`, `city_visible`, `avatar_path`, `locale`, `terms_accepted_at`, `age_confirmed_at`, `deleted_at` | Ein Profil je Konto; wird beim Registrieren automatisch angelegt |
| `profiles` | `id` (PK, zugleich FK auf `auth.users.id`), `display_name` (1–30 Zeichen nach Trim), `bio` (max. 300), `city`, `city_visible`, `avatar_path`, `locale`, `terms_accepted_at`, `terms_version`, `age_confirmed_at`, `deleted_at` | Ein Profil je Konto; wird beim Registrieren automatisch angelegt. Regeln der Registrierung (Anzeigename, Zustimmung, Sprache): siehe unten |
| `favorite_games` | `profile_id` (FK → `profiles`), `game_id` (FK → `games`), `position` (1–5) | Lieblingsspiele, höchstens 5 je Nutzer |
| `bgg_links` | `profile_id` (FK → `profiles`, eindeutig), `bgg_username`, `last_import_at` | Verknüpfter BGG-Name für den Import |
@ -85,6 +85,22 @@ Dazu kommen diese Regeln:
- **Konto löschen (DSGVO):** Löscht Profil, Sammlung, Wertungsbögen und Avatar. Partien bleiben für andere Mitspieler erhalten, der gelöschte Nutzer erscheint dort als „Gelöschter Nutzer“. Entschieden am 2. November 2026.
- **Admin-Rolle:** Nur für Katalogpflege, serverseitig, nie aus dem Client heraus.
### Regeln bei der Registrierung (Migration `auth_registration_rules`, KTO-01)
Der Trigger `handle_new_user` legt das Profil an und lehnt die Registrierung mit einem Fehlertext (SQLSTATE `P0001`) ab, wenn eine Regel verletzt ist. Es gibt keinen stillen Ersatzwert.
| Regel | Fehlertext |
| --- | --- |
| Mindestalter (`age_confirmed = true`) | `age_confirmation_required` |
| Zustimmung (`terms_accepted = true`) | `terms_acceptance_required` |
| `terms_version` vorhanden, nach Trim 1–50 Zeichen (Nachweis der zugestimmten Rechtstexte) | `terms_version_required` |
| `display_name` vorhanden, nach Trim 1–30 Unicode-Zeichen (Codepoints); kein E-Mail-Präfix als Ersatz, kein Kürzen; U+200B (Zero Width Space) ist an keiner Stelle erlaubt, U+200C/U+200D (ZWNJ/ZWJ, u. a. in Emoji-Sequenzen) schon | `display_name_invalid` |
- `terms_accepted_at`, `age_confirmed_at` setzt der Trigger auf `now()`. `terms_version` und beide Zeitstempel kann der Nutzer nicht ändern (Spalten-Grants).
- `locale`: nur `de`/`en`, sonst und als Spalten-Standard `'en'`.
- Checks `profiles_display_name_not_blank` (`char_length(trim_ws(display_name)) between 1 and 30`) und `profiles_display_name_no_zwsp` (kein U+200B) gelten auch für spätere Änderungen (KTO-02, SQLSTATE `23514`).
- Trigger `sync_locale_to_auth`: Ändert sich `profiles.locale`, schreibt `sync_profile_locale_to_auth()` (security definer, leerer `search_path`) den Wert in `auth.users.raw_user_meta_data.locale`. GoTrue-Mailvorlagen lesen die Sprache von dort. Betroffen ist nur das eigene Konto.
## Dateien, Import, Offline, Statistik
- **Dateien:** Avatare (max. 5 MB) und Spielbilder liegen im Storage in einem privaten Bucket je Zweck. Die App greift über eine eigene Zugriffsschicht darauf zu, damit der Speicher später auf Hetzner Object Storage wechseln kann (ADR-0002). In der Datenbank steht nur der Pfad.
@ -124,8 +140,9 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel
| `city` | text | nein | | |
| `city_visible` | boolean | ja | `true` | |
| `avatar_path` | text | nein | | |
| `locale` | text | ja | `'de'` | |
| `locale` | text | ja | `'en'` | |
| `terms_accepted_at` | timestamptz | ja | | |
| `terms_version` | text | nein (Altdaten) | | |
| `age_confirmed_at` | timestamptz | ja | | |
| `deleted_at` | timestamptz | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
@ -307,8 +324,9 @@ Zwei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Av
| `city` | text | nein | | |
| `city_visible` | boolean | ja | `true` | |
| `avatar_path` | text | nein | | |
| `locale` | text | ja | `'de'::text` | |
| `locale` | text | ja | `'en'::text` | |
| `terms_accepted_at` | timestamptz | ja | | |
| `terms_version` | text | nein (Altdaten) | | |
| `age_confirmed_at` | timestamptz | ja | | |
| `deleted_at` | timestamptz | nein | | |
| `created_at` | timestamptz | ja | `now()` | |

View file

@ -0,0 +1,494 @@
# Spec #1 – KTO-01 Registrieren, anmelden und Passwort zurücksetzen
| | |
|---|---|
| Issue | #1 (KTO-01) |
| Status | freigegeben 2026-11-02, angepasst an Spike-Ergebnisse (B0, siehe Änderungsprotokoll § 10) |
| Autor | architect |
| Stand | 2026-11-02 |
| Abhängigkeit | Fundament-Issue #31 (blockiert #1) |
| Quellen | `requirements/20-r1-akzeptanzkriterien.md` § KTO-01 und § Querschnitt, `requirements/r1-klaerungen.md` (18, 21/47, 22, 45, N1–N5, N3a, N13), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100100_profiles.sql`, `supabase/migrations/20261102212208_auth_registration_rules.sql`, **`docs/specs/1-auth-spike-ergebnisse.md` (Spike B0, maßgeblich für das GoTrue-Verhalten)**, ADR-0001, ADR-0002 |
## 1. Ziel
Brettspieler können sich mit E-Mail, Passwort und Anzeigenamen registrieren (mit Bestätigung „mindestens 16“ und Zustimmung zu Datenschutzerklärung und Nutzungsbedingungen), ihre E-Mail-Adresse bestätigen, sich anmelden und angemeldet bleiben sowie ihr Passwort über einen einmaligen, zeitlich begrenzten Link zurücksetzen. Grundlage ist Supabase Auth (GoTrue) im Self-Hosting (ADR-0002); alle Auth-Einstellungen werden explizit im Repo festgelegt und dokumentiert (Klärung 18).
Gleichzeitig ist #1 die **erste Story mit App-Code**. Das Flutter-Gerüst existiert noch nicht (`app/` fehlt) und entsteht im vorgelagerten **Fundament-Issue #31** (siehe 8.1).
### Kernentscheidungen (Kurzfassung)
| # | Entscheidung | Begründung |
|---|---|---|
| E1 | **App-Fundament als eigenes Chore-Issue #31** vor #1, nicht als Teil von #1 (entschieden, F10) | KAT-01/KAT-02 (R1.1) brauchen das Gerüst auch ohne Anmeldung; kleinere, prüfbare PRs; CLAUDE.md verlangt ein Issue je Änderung. `datenmodell-r1.md` Schritt 4 bündelt Gerüst und Anmeldung. Wir weichen davon nur im Zuschnitt ab, die Reihenfolge bleibt gleich. |
| E2 | **Ein Issue #1, drei PRs** (01a Registrierung, 01b Anmelden, 01c Passwort zurücksetzen), davor ein Backend-PR (Migration + Auth-Konfiguration) | Die Kriterien teilen Backend-Konfiguration und Domänenschicht. Getrennte Issues würden Abhängigkeiten nur verschieben. Die PRs referenzieren `Refs #1`, der letzte `Closes #1`. **Abweichung vom Issue-Vorschlag:** Krit. 6 liegt in 01b statt 01a, weil der Hinweis auf die ausstehende Bestätigung beim *Anmelden* erscheint und den LoginScreen braucht. Die Aktion „E-Mail erneut senden“ entsteht schon in 01a (CheckEmailScreen) und wird in 01b wiederverwendet. |
| E3 | **Links in Mails zeigen auf eine App-Seite `/auth/confirm?token_hash=…&type=…`**, nicht auf `{{ .ConfirmationURL }}`. Die Seite löst `verifyOTP` erst per Knopfdruck aus. | Funktioniert geräteübergreifend: kein PKCE-Verifier nötig, z. B. Registrierung am Handy und Mail am PC. Mail-Scanner (z. B. Safe Links) verbrauchen den Einmal-Link nicht. Die Einmaligkeit bleibt serverseitig erhalten (Kriterium 8). |
| E4 | **In R1 landen Mail-Links immer in der Web-App.** Universal Links/App Links (iOS/Android) sind nicht Teil von #1. | Die Web-App kann alle Schritte ausführen. Universal Links brauchen AASA/assetlinks-Hosting und Store-Signaturen; das ist eine eigene Aufgabe und sollte ein Folge-Issue werden. |
| E5 | **Mailsprache über `user_metadata.locale`**: Je Mailtyp gibt es *eine* Vorlage mit Go-Template-Verzweigung de/en (Standard Englisch, F6). Ein neuer DB-Trigger hält `auth.users.raw_user_meta_data.locale` synchron zu `profiles.locale`. | GoTrue kennt nur eine Vorlage je Typ und stellt `.Data` (= `user_metadata`) bereit. Die Profilsprache ist maßgeblich (Kriterium 11, KTO-11 Kriterium 2). Ein Send-Email-Hook (Edge Function + eigener SMTP-Versand) wäre mehr bewegliche Teile ohne zweiten Anwendungsfall. Spike B0 hat bestätigt, dass Betreff und Text `.Data.locale` auswerten (Spike-Dokument (a)); ein Rückfall ist nicht nötig. |
| E6 | **Validierung zweistufig**: Feldfehler im Client (Kriterien 2, 3, 10). Der Server erzwingt dieselben Regeln als Schutz (Trigger, `minimum_password_length = 10`). | Der Client gibt sofort Rückmeldung am Feld. Der Server vertraut nie dem Client. GoTrue meldet Trigger-Fehler als HTTP 500; der Body enthält zwar die Meldung des Triggers (z. B. `display_name_invalid`, Spike A4; gilt auch für U+200B im Anzeigenamen), das Format ist aber nicht vertraglich zugesichert. Der Client bleibt deshalb die nutzersichtbare Stelle. |
| E7 | **„Liste häufiger Passwörter“** wird nur im Client als gebündelte Liste geprüft; der Server erzwingt die Mindestlänge 10. **Kein Have I Been Pwned** (entschieden, F2): `GOTRUE_PASSWORD_HIBP_ENABLED=false`. | HIBP würde einen SHA-1-Präfix an einen US-Dienst (Cloudflare/HIBP) übertragen (Klärung 21/47). Außerdem gibt es HIBP in der lokalen Supabase CLI nicht. Restrisiko: Über die rohe API lassen sich häufige Passwörter mit mindestens 10 Zeichen setzen (R14). |
| E8 | **Drosselung und Linkgültigkeit**: Es gelten die Supabase-Standardwerte, die *explizit gepinnt* werden, identisch in `supabase/config.toml` (lokal/CI) und im Env-Block für Staging/Produktion. Die Werte stehen in `docs/auth-konfiguration.md` und werden in Tests über die Doku-ID referenziert. | Klärung 18. Die Standardwerte unterscheiden sich zwischen GoTrue-Binary, CLI-Vorlage und Docker-Setup (z. B. OTP-Gültigkeit 86400 s im Binary gegenüber 3600 s in der CLI, `enable_confirmations` in der CLI standardmäßig `false`). Ohne Pinning wären „die Standards“ nicht eindeutig. |
| E9 | **Sitzung bleibt bestehen** über den Standard-Speicher von `supabase_flutter` (Mobile: SharedPreferences, Web: localStorage). Kein Timebox, kein Inaktivitäts-Timeout. | Kriterium 5. Ein zusätzliches Paket (z. B. flutter_secure_storage) gibt es erst bei konkretem Bedarf, siehe Risiko R7. |
## 2. Akzeptanzkriterien
Übernommen aus Issue #1 / `20-r1-akzeptanzkriterien.md` § KTO-01. Referenz in Tests und PRs: „KTO-01 Kriterium n“.
1. Gegeben ich habe kein Konto, wenn ich E-Mail-Adresse, Passwort und Anzeigenamen eingebe und bestätige, dass ich mindestens 16 Jahre alt bin, dann wird ein Konto angelegt und ich erhalte eine E-Mail zur Bestätigung.
2. Gegeben ich bestätige das Mindestalter nicht, wenn ich mich registrieren will, dann wird die Registrierung abgelehnt und ich sehe den Grund.
3. Gegeben das Passwort hat weniger als 10 Zeichen oder steht auf einer Liste häufiger Passwörter, wenn ich es eingebe, dann erklärt mir die App am Feld, was fehlt.
4. Gegeben die E-Mail-Adresse ist schon registriert, wenn ich mich damit registriere, dann erfahre ich nicht, ob ein Konto existiert, und es entsteht kein zweites Konto.
5. Gegeben ein bestätigtes Konto, wenn ich die richtigen Zugangsdaten eingebe, dann bin ich angemeldet und bleibe es auf diesem Gerät, bis ich mich abmelde.
6. Gegeben ein unbestätigtes Konto, wenn ich mich anmelde, dann werde ich auf die ausstehende Bestätigung hingewiesen und kann die E-Mail erneut senden lassen.
7. Gegeben falsche Zugangsdaten, wenn ich mich anmelde, dann sehe ich eine allgemeine Fehlermeldung, die nicht verrät, ob E-Mail oder Passwort falsch war; nach mehreren Fehlversuchen wird der Zugang zeitweise gebremst. Anzahl der Versuche und Dauer der Bremse folgen den Standards der Supabase-Auth-Konfiguration (self-hosted); die Werte sind im Repo dokumentiert und in den Tests referenziert (Klärung 18).
8. Gegeben ich habe das Passwort vergessen, wenn ich das Zurücksetzen anfordere, dann erhalte ich einen zeitlich begrenzten Link, mit dem ich ein neues Passwort setze; der Link funktioniert nur einmal. Die Gültigkeit von Reset- und Bestätigungslink folgt der Supabase-Auth-Konfiguration und ist im Repo dokumentiert (Klärung 18).
9. Gegeben ich habe das Passwort zurückgesetzt, dann bin ich auf allen anderen Geräten abgemeldet.
10. Gegeben ich registriere mich, wenn der Anzeigename leer ist oder länger als 30 Zeichen, dann kann ich mich nicht registrieren und sehe den Fehler am Feld; es gelten dieselben Regeln wie in KTO-02 Kriterium 1 (Klärung 22).
11. Gegeben Shuffle sendet mir eine E-Mail zur Bestätigung oder zum Zurücksetzen des Passworts, dann ist sie in der Sprache meines Profils; andere E-Mails verschickt Shuffle in R1 nicht, außer Bestätigungslink und Info-Mail beim Wechsel der E-Mail-Adresse (KTO-11 Kriterium 4) (Klärung 45, N2).
**Relevante Querschnittskriterien** (Referenz „QUE-04 Kriterium n“ usw.):
- **Datenschutz (QUE-04) Kriterium 1:** Datenschutzerklärung und Nutzungsbedingungen sind vor der Registrierung sichtbar, und die Zustimmung ist ausdrücklich (eigene Checkbox).
- **Datenschutz Kriterien 2 und 4:** In den Auth-Abläufen gibt es keine optionalen personenbeziehbaren Dienste, kein Tracking-SDK und keinen HIBP-Abgleich (F2).
- **Datenschutz, Nachweis der Zustimmung:** Gespeichert werden Zeitpunkt (`terms_accepted_at`) und Version (`terms_version`) der zugestimmten Rechtstexte (F5).
- **Sicherheit der Daten Kriterium 2:** Ohne Anmeldung keine privaten Daten. Router-Guard im Client, RLS im Server; die RLS für `profiles` ist bereits getestet.
- **Sicherheit der Daten Kriterium 3:** Neue DB-Objekte (Trigger) bekommen pgTAP-Tests.
- **Sprachen (QUE-05) Kriterien 1, 2, 4:** Alle Texte stehen in ARB, Fallback ist Englisch. Die Sprache bei der Registrierung ist die aktive Oberflächensprache (KTO-11 Kriterium 11, N3a).
- **Barrierefreiheit (QUE-06) Kriterien 1–4:** Labels, Fehler werden per Screenreader angesagt, Kontrast AA, 48 dp, 200 % Schrift, Tastaturbedienung im Web.
- **Zustände, Fehler und Netz Kriterien 2, 3:** Offline gibt es einen verständlichen Hinweis; Eingaben bleiben bei Fehlern erhalten.
- **Plattformen Kriterien 1, 2:** iOS, Android, Web; auf breiten Bildschirmen ein zentriertes Formular mit begrenzter Breite.
## 3. Datenmodell
### 3.1 Bestand (Migration `20261102100100_profiles.sql`) gegen die Kriterien
| Regel | Bestand | Abgleich | Handlung |
|---|---|---|---|
| Mindestalter (Krit. 1, 2) | Trigger `handle_new_user` wirft `age_confirmation_required`, wenn `age_confirmed <> 'true'`; `profiles.age_confirmed_at not null` | passt | keine |
| Zustimmung Rechtstexte (QUE-04 Krit. 1) | Trigger wirft `terms_acceptance_required`; `terms_accepted_at not null` | Zeitpunkt passt; die Version der Texte wird nicht gespeichert | neue Spalte `terms_version` (3.2, entschieden F5) |
| Anzeigename 1–30 (Krit. 10; Entscheidung Chris: U+200B verboten) | Check `char_length(display_name) between 1 and 30`. Der Trigger **füllt aber leere Namen mit dem E-Mail-Präfix auf und kürzt zu lange Namen auf 30**. Ein Name nur aus Leerzeichen (`' '`) besteht den Check. | **widerspricht Krit. 10** (Registrierung muss abgelehnt werden, nicht stillschweigend korrigiert) und KTO-02 Krit. 1 („leer“ schließt reine Leerzeichen ein) | neue Migration (3.2) |
| Sprache (Krit. 11, KTO-11 Krit. 11) | `locale` aus `user_metadata.locale`, wenn `de`/`en`, sonst `'de'` | Der Fallback `'de'` und der Spalten-Standard `'de'` weichen von QUE-05 ab. Entschieden (F6): Ohne Angabe gilt die Gerätesprache, sonst Englisch. Die Gerätesprache kennt nur der Client; er sendet sie immer als aktive Oberflächensprache. Der Server fällt deshalb auf `'en'` zurück. | neue Migration (3.2): Standard und Trigger-Fallback `'en'` |
| Mailsprache folgt Profil (Krit. 11) | Mailvorlagen sehen nur `user_metadata`. `profiles.locale` und `user_metadata.locale` laufen auseinander, sobald KTO-11 die Sprache ändert. | **Lücke** | neue Migration (3.2): Sync-Trigger |
| RLS `profiles` | nur das eigene Profil lesen und ändern; Spaltenrechte; anlegen nur per Trigger | passt (pgTAP `01_profiles.test.sql`) | keine |
### 3.2 Neue Migration `supabase/migrations/<ts>_auth_registration_rules.sql`
Nur additiv: `create or replace function` und neue Objekte, keine Änderung an bestehenden Dateien.
| Tabelle/Objekt | Spalte | Typ | Hinweis |
|---|---|---|---|
| `public.profiles` | `display_name` | text | **neuer Check** `profiles_display_name_not_blank`: `char_length(btrim(display_name)) between 1 and 30`. Er gilt auch für KTO-02 (Updates). Bestehende Seed-Zeilen erfüllen ihn. **Zweiter Check** `profiles_display_name_no_zwsp`: `strpos(display_name, chr(8203)) = 0` (U+200B verboten, Update → 23514). Seeds und Bestand enthalten kein U+200B. |
| `public.profiles` | `terms_version` | text, nullable | **neue Spalte** (F5): Version der zugestimmten Rechtstexte, z. B. `2026-11-draft`. Check `terms_version is null or char_length(terms_version) between 1 and 50`. Nullable nur wegen bereits vorhandener Zeilen (nur Entwicklung); für neue Konten erzwingt der Trigger einen Wert. **Nicht** in den Update-Rechten von `authenticated`, der Nutzer kann sie also nicht ändern. Der Zeitstempel der Zustimmung steht bereits in `terms_accepted_at` (Trigger setzt `now()`). Eine erneute Zustimmung nach einer Änderung der Rechtstexte ist nicht Teil von R1 (siehe 9.4). |
| `public.profiles` | `locale` | text | **Standard geändert** auf `'en'` (`alter column locale set default 'en'`, F6). Der Check `('de','en')` bleibt. |
| `public.handle_new_user()` | – | Trigger-Funktion | **ersetzt** (`create or replace`). Neue Regeln: (a) Alter und Zustimmung wie bisher. (b) `terms_version := btrim(meta->>'terms_version')`; ist der Wert `null`, leer oder länger als 50 Zeichen, wirft der Trigger `terms_version_required` (P0001). (c) `display_name := btrim(meta->>'display_name')`. Ist der Wert `null`, leer, länger als 30 Codepoints oder enthält U+200B, wirft der Trigger `display_name_invalid` (SQLSTATE P0001). **Kein Rückgriff auf das E-Mail-Präfix, kein Kürzen.** (d) Der getrimmte Name und `terms_version` werden gespeichert, `terms_accepted_at` und `age_confirmed_at` = `now()`. (e) `locale` aus `meta->>'locale'`, wenn `de`/`en`, **sonst `'en'`** (F6). |
| `public.sync_profile_locale_to_auth()` | – | Trigger-Funktion, `security definer`, `search_path = ''` | `after update of locale on public.profiles`, wenn sich der Wert ändert: `update auth.users set raw_user_meta_data = coalesce(raw_user_meta_data,'{}') \|\| jsonb_build_object('locale', new.locale) where id = new.id`. Ausführen nur für den Trigger: `revoke all … from public, anon, authenticated`. |
**Trim-Regel (verbindlich für Client und Server, auch KTO-02):** Leerraum am Anfang und Ende wird entfernt. Danach muss der Name 1 bis 30 **Unicode-Codepoints** lang sein. Der Server prüft mit `char_length(btrim(…))`, der Client mit `value.trim().runes.length`, **nicht** mit `String.length` (UTF-16). Bekannte Abweichung: Dart-`trim()` entfernt auch Unicode-Leerraum (z. B. NBSP), `btrim` nur Leerzeichen. Der Client sendet bereits getrimmte Werte, die Abweichung ist deshalb nur über die rohe API erreichbar und unkritisch. **Verbotenes Zeichen (Entscheidung Chris):** Ein Anzeigename, der U+200B (Zero Width Space) an irgendeiner Stelle enthält, ist ungültig (Server: `display_name_invalid` bzw. Check `profiles_display_name_no_zwsp`, 23514). U+200B wird nicht getrimmt, sondern abgelehnt. U+200C/U+200D (ZWJ steckt in Emoji-Sequenzen) und U+FEFF (wird getrimmt) sind davon nicht betroffen.
**Keine neuen Tabellen.** `auth.users` und `auth.sessions` verwaltet GoTrue.
**RLS in Klartext:** Es gibt keine neuen Policies. Profile legt weiterhin nur der Trigger bei der Registrierung an. Jeder Nutzer liest und ändert nur sein eigenes Profil. Bestätigungszeitpunkte, `terms_version` und Löschmarke kann der Nutzer nicht ändern, lesen darf er sie (eigenes Profil). Der Sync-Trigger schreibt als Definer nur die Sprache des **eigenen** Nutzers (`where id = new.id`). Ein Nutzer kann so nur seine eigene Mailsprache beeinflussen.
**Doku:** `docs/datenmodell-r1.md` (Regel Anzeigename, `terms_version`, Standard `locale = 'en'`, Sync-Trigger) und `supabase/README.md` werden im selben PR aktualisiert.
## 4. Backend
### 4.1 Supabase-Auth-Konfiguration (self-hosted)
Alle Werte stehen identisch in `supabase/config.toml` (lokal/CI) und als GoTrue-Env-Block in `docs/auth-konfiguration.md` (Staging/Produktion, ohne Secrets). Spike B0 hat die Werte gegen CLI 3.0.0 und das Image `supabase/gotrue:v2.197.0` geprüft (`docs/specs/1-auth-spike-ergebnisse.md`); die Tabelle unten enthält die Ergebnisse. Ausnahmen, die lokal **ohne Wirkung** sind: AUTH-CFG-09 (`email_sent`) und alle IP-Drosselungen (AUTH-CFG-10 bis -12, nur mit AUTH-CFG-13 wirksam). Tests referenzieren die Zeilen-ID (z. B. `AUTH-CFG-07`).
| ID | Einstellung | CLI `config.toml` | GoTrue-Env (self-hosted) | Wert | Quelle / Anmerkung | Krit. |
|---|---|---|---|---|---|---|
| AUTH-CFG-01 | Site-URL | `auth.site_url` | `GOTRUE_SITE_URL` | lokal `http://localhost:3000`; Prod `https://shuffle.cshomelabs.work` (Platzhalter-Domain, F8, leicht änderbar); Staging-Subdomain legt devops fest | Basis für Mail-Links (`{{ .SiteURL }}`) | 1, 8 |
| AUTH-CFG-02 | Redirect-Allow-List | `auth.additional_redirect_urls` | `GOTRUE_URI_ALLOW_LIST` | nur eigene Web-URLs (`…/auth/confirm`) | Mail-Links bauen auf Site-URL auf | 1, 8 |
| AUTH-CFG-03 | Registrierung erlaubt | `auth.enable_signup`, `auth.email.enabled` (CLI 3.x; frühere Bezeichnung `auth.email.enable_signup`) | `GOTRUE_DISABLE_SIGNUP=false`, `GOTRUE_EXTERNAL_EMAIL_ENABLED=true` | an | – | 1 |
| AUTH-CFG-04 | E-Mail-Bestätigung Pflicht | `auth.email.enable_confirmations = true` | `GOTRUE_MAILER_AUTOCONFIRM=false` | Bestätigung Pflicht | CLI-Standard ist `false`, muss gesetzt werden. **Voraussetzung für den Enumeration-Schutz** (4.3), zusammen mit AUTH-CFG-24. | 1, 4, 6 |
| AUTH-CFG-05 | Passwort-Mindestlänge | `auth.minimum_password_length` | `GOTRUE_PASSWORD_MIN_LENGTH` | **10** | O10; Standard wäre 6 | 3 |
| AUTH-CFG-06 | Zeichenklassen | `auth.password_requirements` | `GOTRUE_PASSWORD_REQUIRED_CHARACTERS` | keine (leer) | Nicht gefordert | 3 |
| AUTH-CFG-07 | Gültigkeit Bestätigungs- und Reset-Link | `auth.email.otp_expiry` | `GOTRUE_MAILER_OTP_EXP` | **3600 s (1 h)** | Entschieden (F3). Entspricht dem CLI- und Cloud-Standard; das Self-hosted-Binary hätte 86400, deshalb im Env explizit setzen. Ein Wert für beide Linkarten. | 1, 8 |
| AUTH-CFG-08 | Mindestabstand zwischen Mails je Nutzer | `auth.email.max_frequency` | `GOTRUE_SMTP_MAX_FREQUENCY` | Staging/Prod: 60 s (Standard); lokal/CI: 1 s | Lokale Abweichung ist bewusst, sonst scheitert der Resend-Test (Krit. 6) direkt nach dem Signup an 429. Ein eigener Test prüft die 60 s auf Staging (Release-Checkliste). | 6, 8 |
| AUTH-CFG-09 | Mails pro Stunde (Instanz) | `auth.rate_limit.email_sent` | `GOTRUE_RATE_LIMIT_EMAIL_SENT` | lokal/CI: 100 (sonst blockieren E2E-Tests); Staging/Prod: **30** (Binary-Standard, entschieden F4) | **Instanzweit** (B0 (d) bestätigt). Wirkt nur über das Env (Self-Hosting); in der lokalen CLI **ohne Wirkung** (CLI setzt `GOTRUE_RATE_LIMIT_EMAIL_SENT=360000`), in `config.toml` nur dokumentieren und so kennzeichnen. Risiko R3. | 1, 6, 8 |
| AUTH-CFG-10 | Drosselung Anmeldung (Passwort) | `auth.rate_limit.token_refresh` | `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` | 150 je 5 min je IP, Burst 30 | B0 (c) bestätigt: Der Passwort-Login nutzt den Token-Limiter; Login #31 einer IP → 429 `over_request_rate_limit`, danach ca. 0,5 Versuche/s. **Nur wirksam mit AUTH-CFG-13** und nur für Requests, die den Header tragen. Die 429-Antwort hat **keinen** `Retry-After`- oder `X-RateLimit-*`-Header. **Keine Sperre je Konto, nur je IP.** | 7 |
| AUTH-CFG-11 | Drosselung Registrierung, Reset, erneut senden | `auth.rate_limit.sign_in_sign_ups` | `GOTRUE_RATE_LIMIT_OTP` | 30 je 5 min je IP, Burst 30 | Für Signup bestätigt (B0 (c)); dass Recover und Resend denselben Limiter teilen, ist nur aus dem Code abgeleitet. Nur wirksam mit AUTH-CFG-13. | 1, 6, 8 |
| AUTH-CFG-12 | Drosselung Link-Prüfung | `auth.rate_limit.token_verifications` | `GOTRUE_RATE_LIMIT_VERIFY` | 30 je 5 min je IP | Nur wirksam mit AUTH-CFG-13. | 8 |
| AUTH-CFG-13 | Client-IP hinter Proxy | – (CLI kann den Header nicht setzen) | `GOTRUE_RATE_LIMIT_HEADER` (z. B. `X-Forwarded-For`) + Proxy überschreibt den Header | Staging/Prod: **Pflicht**, gesetzt und nicht fälschbar | B0 (c), A2: **Ohne diese Einstellung gibt es gar keine IP-Drosselung**, auch mit ihr werden Requests ohne den Header nicht gedrosselt. GoTrue darf deshalb nur über den Proxy erreichbar sein, der den Header setzt. Risiko R2. | 7 |
| AUTH-CFG-14 | JWT-Laufzeit | `auth.jwt_expiry` | `GOTRUE_JWT_EXP` | 3600 s | Bleibt (entschieden F1). Bestimmt die Verzögerung bei Krit. 9: andere Geräte sind spätestens nach 60 min abgemeldet (bewusstes Restrisiko R8). | 5, 9 |
| AUTH-CFG-15 | Refresh-Token-Rotation | `auth.enable_refresh_token_rotation`, `refresh_token_reuse_interval` | `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED`, `…_REUSE_INTERVAL` | an, 10 s | Standard | 5, 9 |
| AUTH-CFG-16 | Sitzungsbegrenzung | `auth.sessions.timebox`, `inactivity_timeout` | `GOTRUE_SESSIONS_TIMEBOX`, `…_INACTIVITY_TIMEOUT` | **nicht gesetzt** | Krit. 5: angemeldet bis zum Abmelden | 5 |
| AUTH-CFG-17 | Mailvorlage Bestätigung | `auth.email.template.confirmation.{subject,content_path}` | `GOTRUE_MAILER_TEMPLATES_CONFIRMATION` (URL), `GOTRUE_MAILER_SUBJECTS_CONFIRMATION` | `supabase/templates/confirmation.html` | Self-hosted lädt Vorlagen **per URL**; ist die URL nicht erreichbar, fällt GoTrue still auf die englische Standardvorlage zurück (Risiko R5) | 1, 11 |
| AUTH-CFG-18 | Mailvorlage Reset | `auth.email.template.recovery.{…}` | `GOTRUE_MAILER_TEMPLATES_RECOVERY`, `…_SUBJECTS_RECOVERY` | `supabase/templates/recovery.html` | wie oben | 8, 11 |
| AUTH-CFG-19 | Benachrichtigungs-Mails (`password_changed`, `email_changed`, `phone_changed`, `mfa_*`, `identity_*`) | `auth.email.notification.<typ>.enabled` | `GOTRUE_MAILER_NOTIFICATIONS_<TYP>_ENABLED` | **alle aus** | Krit. 11. `email_changed` (Info-Mail an die alte Adresse, N2) schaltet KTO-11 ein. | 11 |
| AUTH-CFG-20 | Magic Link / OTP-Login | – (kein Schalter) | – (kein Schalter) | Prod: `POST /auth/v1/otp` und `/auth/v1/magiclink` am Gateway sperren | Sonst kann jeder Magic-Link-Mails an beliebige Adressen auslösen (Verstoß gegen Krit. 11) und Konten ohne Passwort anlegen (Risiko R4). B0 (A7): beide Endpunkte sind nicht abschaltbar; `/otp` legt mit gültigen Metadaten ein Konto an, der Trigger ersetzt die Sperre also nicht. | 11, 3 |
| AUTH-CFG-21 | Leaked-Password-Prüfung (HIBP) | nicht in der CLI | `GOTRUE_PASSWORD_HIBP_ENABLED`, `…_FAIL_CLOSED` | **aus** (`GOTRUE_PASSWORD_HIBP_ENABLED=false`, explizit gesetzt) | Entschieden (F2): kein HIBP, keine Drittlandübermittlung | 3 |
| AUTH-CFG-22 | SMTP | lokal: CLI-Mailfänger Mailpit v1.31.3 (Container heißt historisch `supabase_inbucket_…`, API `http://127.0.0.1:54324/api/v1`) | `GOTRUE_SMTP_*` | Staging/Prod: EU-Anbieter mit AVV, **Anbieter und Absender-Domain noch offen** (F7, später) | Release-Blocker über D1, kein Merge-Blocker | 1, 8, 11 |
| AUTH-CFG-23 | E-Mail-Wechsel beide Adressen bestätigen | `auth.email.double_confirm_changes` | `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED` | **in #1 nicht festlegen**, Entscheidung in KTO-11 | Der Standard `true` schickt einen Bestätigungslink auch an die alte Adresse; N2 verlangt dort nur eine Info-Mail | (KTO-11) |
| AUTH-CFG-24 | SMS-Autoconfirm aus (Voraussetzung Enumerationsschutz) | `auth.sms.enable_confirmations = true` | `GOTRUE_SMS_AUTOCONFIRM=false`; in der Supabase-Self-Hosting-Vorlage über `ENABLE_PHONE_AUTOCONFIRM=false` in `docker/.env` (die Vorlage `docker/.env.example` setzt `true`!) | **an** (`SMS_AUTOCONFIRM=false`) | B0 (A1): SMS ist nicht im Einsatz, die Einstellung steuert aber trotzdem den Enumerationsschutz beim E-Mail-Signup. Mit `SMS_AUTOCONFIRM=true` (CLI-Standard und Self-Hosting-Vorlage) liefert Signup mit bestätigter Adresse **422 `user_already_exists`** und verrät damit das Konto. Ein API-Test (7.1 Krit. 4) sichert die Einstellung ab. | 4 |
| AUTH-CFG-25 | Aktuelles Passwort beim Passwortwechsel verlangen | `auth.email.secure_password_change` | `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` (Env-Name bei B3 gegen das Image `v2.197.0` bestätigen) | **`false`** (Standard, explizit gesetzt); **in #1 nicht entschieden, Entscheidung in KTO-11** (N13) | Recovery-Sitzungen (Krit. 8) sind davon nicht betroffen; für #1 ohne Wirkung. Wie AUTH-CFG-23 nur als Platzhalter-ID für KTO-11 geführt. | (KTO-11) |
**Mailvorlagen** (`supabase/templates/confirmation.html`, `recovery.html`):
- Je eine Vorlage mit Verzweigung `{{ if and .Data (eq (index .Data "locale") "de") }}…DE…{{ else }}…EN…{{ end }}` (`index` ist sicher, wenn der Schlüssel fehlt; B0 (a)). Der Else-Zweig ist Englisch und entspricht damit dem neuen Profil-Standard `'en'` (F6).
- Die Betreffzeile wird gleich verzweigt (B0 (a) bestätigt, kein zweisprachiger Rückfall-Betreff nötig).
- Link: `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email` (Bestätigung) bzw. `…&type=recovery` (Reset). B0 (b) bestätigt: `email` (auch `signup`) und `recovery` werden akzeptiert.
- Inhalt: kurzer Text, Gültigkeitsdauer in Klartext („1 Stunde“), Hinweis „Wenn du das nicht warst, ignoriere diese E-Mail“. Kein Tracking-Pixel, keine externen Bilder.
### 4.2 RPCs / Edge Functions
**Keine.** Alle Abläufe laufen über die GoTrue-REST-API (`supabase_flutter`):
| Ablauf | Aufruf | Eingabe | Ausgabe / Fehler |
|---|---|---|---|
| Registrieren | `auth.signUp(email, password, data: {display_name, age_confirmed: true, terms_accepted: true, terms_version, locale})` | siehe links | 200 mit Nutzer, Sitzung `null` (Bestätigung offen). **Auch bei bereits registrierter Adresse 200** (4.3). `weak_password` (422, `reasons`), `over_request_rate_limit` / `over_email_send_rate_limit` (429), Trigger-Fehler → HTTP 500, Header `X-Sb-Error-Code: unexpected_failure`, Body `{"code":"P0001","message":"display_name_invalid"}` (bzw. `age_confirmation_required`, `terms_acceptance_required`, `terms_version_required`; B0 (f)), Netzfehler |
| Bestätigung erneut senden | `auth.resend(type: OtpType.signup, email)` | E-Mail | 200 immer; 429 bei Drosselung |
| Anmelden | `auth.signInWithPassword(email, password)` | – | Sitzung; `invalid_credentials` (400), `email_not_confirmed` (400, **nur bei richtigem Passwort**, siehe 4.3), 429 |
| Reset anfordern | `auth.resetPasswordForEmail(email)` | E-Mail | 200 (keine Enumeration); 429. Ausnahme bei erschöpftem Mail-Limit: bestehende Adresse 429 `over_email_send_rate_limit`, unbekannte 200 (Seitenkanal nur auf API-Ebene, die App zeigt in beiden Fällen denselben Screen, 4.4, F12) |
| Link einlösen | `auth.verifyOTP(tokenHash, type: email \| recovery)` | aus der URL | Sitzung; bei Reset Ereignis `passwordRecovery`; `otp_expired` bzw. ungültig (403) |
| Neues Passwort | `auth.updateUser(UserAttributes(password))` in der Recovery-Sitzung | Passwort | 200; `weak_password` (422), `same_password` (422). GoTrue beendet **alle anderen Sitzungen** des Nutzers (`LogoutAllExceptMe`) |
### 4.3 Verhalten von GoTrue, auf das sich die Spec stützt (verifiziert in B0, siehe `docs/specs/1-auth-spike-ergebnisse.md`)
- **Krit. 4 (Enumeration):** **Voraussetzung sind beide Autoconfirm-Schalter auf `false`:** `GOTRUE_MAILER_AUTOCONFIRM=false` (AUTH-CFG-04) **und** `GOTRUE_SMS_AUTOCONFIRM=false` (AUTH-CFG-24). Ist SMS-Autoconfirm an (CLI-Standard, Self-Hosting-Vorlage), liefert `signUp` mit einer bestätigten Adresse **422 `user_already_exists`** (B0, A1). Mit beiden Schaltern aus (bestätigt): `signUp` mit einer **bestätigten** Adresse liefert 200 und einen erfundenen Nutzer (zufällige ID, leere `identities`). Es wird keine Mail verschickt und kein Konto angelegt; das ursprüngliche Passwort bleibt gültig. Bei einer **unbestätigten** Adresse geht die Bestätigungsmail erneut an das bestehende Konto, Passwort und Metadaten bleiben unverändert, und es kommt der echte Nutzer zurück. Die Antworten unterscheiden sich also auf API-Ebene; das ist das bekannte Restrisiko des Supabase-Standards (R6). **Die App zeigt nach jeder 200-Antwort denselben Bildschirm „Prüfe dein Postfach“ und wertet `user.identities` ausdrücklich nicht aus.**
- **Krit. 4 / Reset, Seitenkanal bei erschöpftem Mail-Limit:** Ist `email_sent` (AUTH-CFG-09, instanzweit) erschöpft, antwortet `/recover` für eine **bestehende** Adresse mit 429 `over_email_send_rate_limit`, für eine unbekannte mit 200 (B0 (d), getestet). Regel dahinter: 429 kommt genau dann, wenn GoTrue eine Mail senden würde. Für Signup und Resend ist das **abgeleitet, nicht getestet**: Resend → unbestätigte Adresse 429, unbekannte und bestätigte 200; Signup **umgekehrt** → neue oder unbestätigte Adresse 429, bestätigte 200. Über die rohe API lässt sich während der Sperre die Existenz eines Kontos erkennen (akzeptiert, R3, F12). **In der App bleibt der Kanal verdeckt (F12):** Bei `over_email_send_rate_limit` zeigen Registrierung und Passwort-Reset denselben neutralen Screen „Prüfe dein Postfach … falls …“ wie bei 200 (Mapping 4.4). Damit bleibt Krit. 4 in der App erfüllt.
- **Krit. 6/7:** `email_not_confirmed` kommt erst **nach** erfolgreicher Passwortprüfung. Falsches Passwort und unbekannte Adresse liefern beide `invalid_credentials` mit identischer Meldung (bestätigt).
- **Krit. 7 (Drosselung):** Nur je IP und nur, wenn `GOTRUE_RATE_LIMIT_HEADER` gesetzt ist **und** der Request den Header trägt (AUTH-CFG-13). Requests ohne den Header werden nicht gedrosselt. Die 429-Antwort trägt kein `Retry-After`; die App kann keine Wartezeit nennen.
- **Krit. 9:** `UpdatePassword` mit Sitzungs-ID ruft `LogoutAllExceptMe` auf. Andere Refresh-Tokens werden ungültig (`refresh_token_not_found`, bestätigt), bereits ausgestellte Access-JWTs bleiben aber bis zu `jwt_expiry` (3600 s) gültig, weil PostgREST keine Sitzungen prüft (bestätigt: GoTrue `/user` lehnt ab, PostgREST akzeptiert). Chris hat diese Verzögerung von bis zu 60 min akzeptiert (F1, Restrisiko R8). Texte in App und Hilfe versprechen deshalb nicht „sofort“.
- **Krit. 8 (einmalig):** Mit `verifyOTP` ist der Token verbraucht; ein zweites Einlösen ergibt 403 `otp_expired` (bestätigt). Eine neue Reset-Anforderung ersetzt den alten Token. Nach `updateUser(password)` löscht GoTrue alle Einmal-Tokens des Nutzers. Maßgeblich für den Ablauf ist `auth.users.recovery_sent_at` bzw. `confirmation_sent_at`, nicht `auth.one_time_tokens.created_at` (B0 (i)).
### 4.4 Fehlerfälle → Domänenfehler (Mapping-Tabelle, verbindlich für `flutter-dev`)
| GoTrue (`AuthException.code` / HTTP) | Domänenfehler | Anzeige |
|---|---|---|
| `invalid_credentials` | `AuthFailure.invalidCredentials` | allgemein: „E-Mail-Adresse oder Passwort ist nicht korrekt.“ (ein Text für beide Fälle) |
| `email_not_confirmed` | `AuthFailure.emailNotConfirmed` | Hinweis „Bestätige zuerst deine E-Mail-Adresse“ + Aktion „E-Mail erneut senden“ |
| `over_email_send_rate_limit` (429) bei `signUp`, `resetPasswordForEmail` und `resend` | `AuthFailure.emailSendLimited` | **Kein Fehler sichtbar (F12)**, wo die Adresse frei eingegeben werden kann: `RegisterController` und `ForgotPasswordController` behandeln ihn wie Erfolg und navigieren zum CheckEmailScreen (gleicher Text wie bei 200, Krit. 4); der `ResendController` auf dem **CheckEmailScreen** bestätigt ihn wie einen erfolgreichen Versand. **Ausnahme:** „E-Mail erneut senden“ im **LoginScreen** nach `email_not_confirmed` (Krit. 6, Passwort war richtig, nichts zu verbergen) zeigt den Drosselungshinweis wie `rateLimited`. Der `ResendController` erhält dafür den Aufrufkontext (`ResendOrigin.checkEmail \| login`). Nur ins Log (ohne E-Mail-Adresse). Damit Nutzer bei verzögerter Mail nicht ratlos sind, enthält der CheckEmailScreen **immer** (unabhängig vom Fall) den neutralen Zusatz „Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.“ Die Entscheidung trifft der Controller, nicht das Widget. |
| `over_request_rate_limit` (IP-Drosselung, unabhängig vom Konto), `emailSendLimited` beim Resend aus dem LoginScreen, sonstiges HTTP 429 | `AuthFailure.rateLimited` | „Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.“ (Ohne konkrete Dauer: GoTrue sendet kein `Retry-After`, B0 (c).) |
| `weak_password` (`reasons: length, characters`; `pwned` tritt ohne HIBP nicht auf, wird aber defensiv mitgemappt) | `AuthFailure.weakPassword(reasons)` | am Passwortfeld, je Grund |
| `same_password` | `AuthFailure.samePassword` | am Passwortfeld: „Wähle ein anderes als dein bisheriges Passwort.“ |
| `otp_expired`, `invalid` / 403 bei `verifyOTP` | `AuthFailure.linkInvalidOrExpired` | „Der Link ist abgelaufen oder wurde schon benutzt.“ + Aktion „Neuen Link anfordern“ |
| `email_address_invalid`, `validation_failed` | `AuthFailure.invalidEmail` | am E-Mail-Feld |
| HTTP 500 bei `signUp` (Trigger; Body `{"code":"P0001","message":"<grund>"}`, Header `X-Sb-Error-Code: unexpected_failure`) | `AuthFailure.registrationRejected` | allgemein: „Registrierung nicht möglich. Bitte prüfe deine Angaben.“ (Im Normalfall verhindert der Client das.) **Mapping-Regel:** maßgeblich ist der HTTP-Status 500 beim `signUp`, **nicht** `code == 'P0001'` (welches Feld `AuthException.code` in `supabase_flutter` füllt, ist offen; vermutlich `unexpected_failure`). Die Trigger-Meldung (`display_name_invalid`, `age_confirmation_required`, `terms_acceptance_required`, `terms_version_required`) steht in `AuthException.message` und darf nur fürs Log und für Zusatzhinweise genutzt werden; das Format ist nicht zugesichert. A1 prüft, welche Felder der Client tatsächlich befüllt; T3 sichert das Body-Format mit einem API-Test ab. |
| `AuthRetryableFetchException`, `SocketException`, `ClientException`, Timeout | `AuthFailure.network` | Offline-Hinweis „Keine Verbindung. Für diesen Schritt brauchst du Internet.“ |
| sonst | `AuthFailure.unexpected` | allgemein + „Erneut versuchen“; Details nur ins Log (ohne E-Mail und Passwort) |
## 5. Frontend
### 5.1 Voraussetzung: Fundament-Issue #31 (siehe 8.1)
Vorhanden sein müssen: Projektgerüst, Riverpod mit Codegen, go_router mit `usePathUrlStrategy()`, l10n de/en mit englischem Fallback, Theme-Basis, Logger, Env-Konfiguration (`--dart-define-from-file`), Supabase-Client-Provider sowie `appLocaleProvider` (aktive Sprache: Gerätesprache de/en, sonst en).
**Platzhalter (F8, F9, zentral und leicht änderbar):**
| Wert | Platzhalter | Ort | Hinweis |
|---|---|---|---|
| App-Name | „Shuffle“ | Plattform-Manifeste, l10n | – |
| Bundle-/Application-ID | `work.cshomelabs.shuffle` | iOS/Android-Projekt | **vor dem Store-Release endgültig festlegen**; nachträgliche Änderung ist nach Veröffentlichung nicht möglich |
| Web-Domain | `shuffle.cshomelabs.work` | `SITE_URL`, AUTH-CFG-01/02 | – |
| Rechtstexte | `LEGAL_PRIVACY_URL`, `LEGAL_TERMS_URL` → Platzhalter-URLs | `app/env/*.json` | Chris liefert die Texte vor dem Release (R11) |
| Version der Rechtstexte | `TERMS_VERSION` (z. B. `2026-11-draft`) | `app/env/*.json` → `AppConfig.termsVersion` | wird bei jeder Registrierung als `terms_version` mitgeschickt (F5); bei neuen Rechtstexten hochzählen |
### 5.2 Struktur `app/lib/features/auth/`
```
domain/
auth_user.dart AuthUser (freezed): id, email, emailConfirmed
auth_status.dart sealed AuthStatus: signedOut | signedIn(AuthUser) | passwordRecovery(AuthUser)
auth_failure.dart sealed AuthFailure (siehe 4.4)
auth_repository.dart Interface (siehe unten)
validation/
display_name_rule.dart trim + runes.length 1..30 → DisplayNameError.empty | tooLong
password_policy.dart minLength 10, häufige Passwörter → PasswordError.tooShort(missing) | common
email_format.dart einfache Syntaxprüfung (Feinprüfung macht der Server)
common_passwords.dart Interface CommonPasswordList (Implementierung in data/)
data/
supabase_auth_repository.dart Implementierung + Fehler-Mapping 4.4
asset_common_password_list.dart lädt assets/security/common-passwords.txt (lazy)
presentation/
auth_status_provider.dart @riverpod Stream<AuthStatus> aus onAuthStateChange
register/ register_screen.dart, register_controller.dart
login/ login_screen.dart, login_controller.dart
check_email/ check_email_screen.dart (+ resend_controller.dart)
forgot_password/ forgot_password_screen.dart, forgot_password_controller.dart
email_link/ email_link_screen.dart, email_link_controller.dart
reset_password/ reset_password_screen.dart, reset_password_controller.dart
```
Die Domäne ist frei von Flutter- und Supabase-Imports. `DisplayNameRule` und `PasswordPolicy` werden in KTO-02 und KTO-11 wiederverwendet. Ein zweiter Anwendungsfall ist damit belegt, deshalb liegen beide in der Domäne von `auth`. Bei KTO-02 ist zu prüfen, ob `DisplayNameRule` nach `features/profile/domain` wandert.
**`AuthRepository`:**
```dart
Stream<AuthStatus> watchStatus();
AuthStatus get currentStatus;
Future<Result<void, AuthFailure>> signUp(RegistrationInput input); // email, password, displayName, ageConfirmed, termsAccepted, termsVersion, locale
Future<Result<void, AuthFailure>> resendConfirmation(String email);
Future<Result<void, AuthFailure>> signIn(String email, String password);
Future<Result<void, AuthFailure>> requestPasswordReset(String email);
Future<Result<void, AuthFailure>> verifyEmailLink(String tokenHash, EmailLinkType type); // confirmation | recovery
Future<Result<void, AuthFailure>> setNewPassword(String password);
Future<void> signOut(); // genutzt bei abgebrochener Recovery; das UI zum Abmelden kommt mit KTO-11
```
`Result` ist ein einfacher eigener sealed Typ in `core/` (kein zusätzliches Paket).
### 5.3 Routen (go_router)
| Pfad | Screen | Zugang |
|---|---|---|
| `/` | Startseite (Fundament: Platzhalter; später QUE-09 bzw. öffentliche Katalogsuche) | öffentlich |
| `/login` | LoginScreen | nur abgemeldet; angemeldet → `/` |
| `/register` | RegisterScreen | nur abgemeldet |
| `/register/check-email` | CheckEmailScreen (E-Mail als `extra`, nicht in der URL) | öffentlich |
| `/forgot-password` | ForgotPasswordScreen → danach CheckEmailScreen (Modus Reset) | nur abgemeldet |
| `/auth/confirm?token_hash&type` | EmailLinkScreen | öffentlich |
| `/reset-password` | ResetPasswordScreen | **nur** im Status `passwordRecovery` |
**Redirect-Regeln** (zentral im Router, `refreshListenable` aus `authStatusProvider`):
- Status `passwordRecovery` → jede Route außer `/reset-password` leitet nach `/reset-password` um.
- Status `signedIn` auf `/login`, `/register` oder `/forgot-password` → `/`.
- Private Routen (kommen mit späteren Stories) → bei `signedOut` nach `/login?from=<pfad>`.
- Status wechselt zu `signedOut`, etwa durch einen fehlgeschlagenen Refresh nach Krit. 9 → private Provider werden invalidiert (`ref.invalidate` über einen zentralen `onSignedOut`-Hook), Navigation zu `/login`.
### 5.4 Screens und Zustände
Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zustände 3). Senden ist während `loading` deaktiviert; der Knopf zeigt den Ladezustand. Leer- und Datenzustände im Listensinn gibt es hier nicht; die Zustände sind **idle / submitting / error(AuthFailure) / success**.
**RegisterScreen** (01a, Krit. 1, 2, 3, 4, 10; QUE-04 Krit. 1)
- Felder: E-Mail, Passwort (mit Sichtbarkeitsschalter), Anzeigename, Checkbox „Ich bin mindestens 16 Jahre alt“, Checkbox „Ich habe die [Datenschutzerklärung] gelesen und stimme den [Nutzungsbedingungen] zu“. Die Links öffnen die Rechtstexte (Platzhalter-URLs aus Env, `url_launcher`; F9).
- Validierung: beim Verlassen des Feldes und beim Absenden. Das Passwort wird zusätzlich beim Tippen geprüft (Krit. 3: „wenn ich es eingebe“). Fehler stehen am Feld:
- Passwort zu kurz: „Mindestens 10 Zeichen – es fehlen noch n.“
- Passwort häufig: „Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.“
- Anzeigename leer, zu lang (`n/30`-Zähler sichtbar) oder mit U+200B an beliebiger Stelle: `DisplayNameRule` meldet U+200B als Fehler am Feld, mit derselben Meldung wie „ungültig“ (kein eigener Text).
- Alter nicht bestätigt: „Shuffle ist erst ab 16 Jahren nutzbar.“ (Krit. 2: Grund sichtbar)
- Zustimmung fehlt: „Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu.“
- Absenden mit `locale = appLocaleProvider` (aktive Oberflächensprache = Gerätesprache de/en, sonst en; KTO-11 Krit. 11, F6) und `terms_version = AppConfig.termsVersion` (F5).
- Erfolg (jede 200-Antwort **und** `emailSendLimited`, F12) → `/register/check-email`. Text: „Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist …“. Dieselbe Formulierung gilt auch für bereits registrierte Adressen (Krit. 4).
- `weak_password` vom Server wird ans Passwortfeld gemappt (praktisch nur die Mindestlänge, da kein HIBP).
**CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, neutraler Zusatz „Die E-Mail kann einige Minuten dauern …“ (immer, F12), Gültigkeit „1 Stunde“ (Wert aus AUTH-CFG-07 als Konstante in `core/config`, mit Verweis auf die Doku), Aktion „E-Mail erneut senden“ (Modus Bestätigung) mit Rückmeldung und Sperre gegen Doppelklick; `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (F12, die Adresse kann hier frei eingegeben sein); `rateLimited` (IP-Drosselung) → Drosselungshinweis. Im Modus Reset gibt es keine Resend-Aktion, sondern einen Link zurück zu `/forgot-password`.
**LoginScreen** (01b, Krit. 5, 6, 7)
- Felder E-Mail und Passwort, Links „Passwort vergessen?“ und „Konto erstellen“.
- Fehler: `invalidCredentials` → allgemeiner Fehler als Banner über dem Formular (nicht an einem Feld, um nichts zu verraten); `emailNotConfirmed` → Info-Banner mit Aktion „Bestätigungs-E-Mail erneut senden“ (Resend mit `ResendOrigin.login`: hier zeigt auch `emailSendLimited` den Drosselungshinweis, F12); `rateLimited` → Drosselungshinweis.
- Erfolg → `from` oder `/`. Autofill: `AutofillGroup` plus `TextInput.finishAutofillContext()` bei Erfolg.
**ForgotPasswordScreen** (01c, Krit. 8): Feld E-Mail, danach immer CheckEmailScreen (Modus Reset), unabhängig davon, ob die Adresse existiert, auch bei `emailSendLimited` (F12).
**EmailLinkScreen `/auth/confirm`** (Krit. 1, 8)
- Liest `token_hash` und `type`. Ohne gültige Parameter → Fehlerzustand.
- Zeigt einen Knopf „E-Mail-Adresse bestätigen“ bzw. „Weiter zum neuen Passwort“. Erst der Klick löst `verifyEmailLink` aus (Schutz vor Mail-Scannern, E3).
- Bestätigung erfolgreich → Hinweis „E-Mail bestätigt“ → `/`. Der Nutzer ist auf diesem Gerät angemeldet. Auf Mobile-Web zusätzlich: „Du kannst dich jetzt auch in der App anmelden.“
- Recovery erfolgreich → Status `passwordRecovery` → `/reset-password`.
- `linkInvalidOrExpired` → Fehlerzustand mit Aktion „Neuen Link anfordern“ (→ `/login` bzw. `/forgot-password`).
**ResetPasswordScreen** (01c, Krit. 3, 8, 9): neues Passwort mit Live-Regeln aus `PasswordPolicy`, Server-Fehler `weak_password`/`same_password` am Feld. Erfolg → Hinweis „Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.“ → `/` (Formulierung passend zu F1). „Abbrechen“ → `signOut()`. Wird der Tab geschlossen oder die App neu geladen, stellt `supabase_flutter` die Sitzung als normale Anmeldung wieder her; das Ereignis `passwordRecovery` kommt dann nicht erneut. B0 (j) hat gezeigt: Das Recovery-JWT trägt `amr = [{"method":"otp"}]`, **identisch** zur Sitzung nach dem Bestätigungslink; eine Recovery-Sitzung ist daran nicht erkennbar. Verbindlich deshalb:
- Der Guard `passwordRecovery` lebt **nur im laufenden Prozess** (Ereignis `passwordRecovery` aus `onAuthStateChange`). `authStatusProvider` leitet ihn **nicht** aus dem `amr`-Claim ab; eine Ableitung aus `amr = otp` würde auch frisch bestätigte Konten in den Reset-Screen zwingen und ist unzulässig.
- Nach einem Reload ist die Recovery-Sitzung eine normale Anmeldung. Das ist vertretbar, weil sie den Besitz des Postfachs nachweist, wie ein Magic Link (Risiko R12). Das alte Passwort bleibt dann gültig, bis der Nutzer es ändert (KTO-11).
### 5.5 Provider / Notifier
- `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy).
- `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`.
- Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue<void>` bzw. freezed-Formzustand. Keine Logik in Widgets.
### 5.6 Liste häufiger Passwörter (Krit. 3)
- Asset `app/assets/security/common-passwords.txt`, ca. 10.000 Einträge (Größenordnung 100 KB), in Kleinschreibung, mit deutschen Einträgen (z. B. `passwort1234`, `hallo12345`). Nur Einträge mit mindestens 10 Zeichen sind relevant, kürzere fängt schon die Längenregel.
- Quelle und Lizenz prüft `test-engineer` in Aufgabe A1 und dokumentiert sie in `app/assets/security/README.md`. Kandidat: SecLists (MIT); deutsche Ergänzung mit Quellenangabe. Keine Liste ohne klare Lizenz.
- Vergleich ohne Groß-/Kleinschreibung, nach Trim. Die Liste wird lazy beim ersten Öffnen eines Passwortfeldes geladen; im Web fällt sie damit nicht beim Start ins Gewicht.
### 5.7 Neue Pakete (pub.dev geprüft 2026-11-02)
Stand der pub.dev-Suche (MCP `pub_dev_search`) am 2026-11-02:
| Paket | Version (pub.dev) | Zweck | Lizenz | Herausgeber | Hinweis | Issue |
|---|---|---|---|---|---|---|
| `supabase_flutter` | 2.18.3 | Auth, Sitzung, Client | MIT | supabase.io (verifiziert) | 150/160 Punkte, sehr verbreitet | #31 |
| `flutter_riverpod` / `riverpod_annotation` / `riverpod_generator` | 3.4.3 / 4.0.7 / 4.0.9 | State mit Codegen | MIT | dash-overflow.net | Versionen von Annotation und Generator gemeinsam pinnen | #31 |
| `go_router` | 18.1.2 | Navigation, Web-URLs | BSD-3 (flutter/packages) | flutter.dev | Die Suche zeigt für die aktuelle Version keine Punkte/Lizenz an (vermutlich frisch veröffentlicht); Lizenz und Plattformen im PR auf der Paketseite bestätigen | #31 |
| `freezed` / `freezed_annotation` | 4.0.3 / 3.1.0 | Modelle | MIT | dash-overflow.net | – | #31 |
| `json_serializable` / `json_annotation` | 6.15.2 / 4.12.0 | JSON | BSD-3 | google.dev | – | #31 |
| `build_runner` | aktuell (dart.dev) | Codegen | BSD-3 | tools.dart.dev | im PR pinnen | #31 |
| `logging` | 1.3.0 | Logger-Basis | BSD-3 | dart.dev | 160/160 | #31 |
| `flutter_localizations`, `intl` | SDK / passend zum SDK | l10n | BSD-3 | flutter.dev / dart.dev | – | #31 |
| `mocktail` (dev) | 1.0.5 | Mocks | MIT | felangel.dev | 160/160 | #31 |
| `url_launcher` | 6.3.3 | Rechtstexte öffnen | BSD-3 | flutter.dev | iOS, Android, Web | #1 (01a) |
Alle Pakete unterstützen iOS, Android und Web. `flutter-dev` pinnt die Versionen im Fundament-PR und vermerkt Abweichungen dort.
## 6. Plattform-Besonderheiten
| Thema | Web | iOS / Android |
|---|---|---|
| Sitzung (Krit. 5) | `localStorage`; bleibt nach Reload und Browser-Neustart erhalten. Kein Abgleich zwischen Tabs: ein zweiter Tab merkt die Abmeldung erst beim nächsten Refresh. | SharedPreferences (App-Sandbox); bleibt nach Neustart erhalten. Bei Neuinstallation ist die Sitzung weg. |
| Mail-Links (E3, E4) | `/auth/confirm` braucht `usePathUrlStrategy` **und** eine SPA-Rewrite-Regel im Webserver (alle Pfade → `index.html`, Aufgabe D1). | Links öffnen in R1 den Browser (Web-App). Universal/App Links kommen in einem Folge-Issue. |
| Autofill / Passwortmanager | `AutofillHints.email`, `newPassword`, `password`; Flutter Web unterstützt Passwortmanager nur eingeschränkt (bekannt, im PR vermerken) | Autofill iOS/Android inkl. „Starkes Passwort vorschlagen“; Associated Domains erst mit Universal Links |
| Tastatur | Tab-Reihenfolge = Lesereihenfolge, Enter sendet ab, sichtbarer Fokus (QUE-06 Krit. 4) | `TextInputType.emailAddress`, `TextInputAction.next/done` |
| Offline | Login/Registrierung/Reset brauchen Verbindung → Hinweis statt technischem Fehler (Querschnitt Zustände 2). Kein Connectivity-Paket; der Fehler wird beim Aufruf erkannt. | gleich |
| Layout (Plattformen Krit. 2) | ab 600 dp zentrierte Karte, max. 480 dp breit | Vollbild, scrollbar (200 % Schrift) |
| Push, Kamera, Dateien | nicht betroffen | nicht betroffen |
## 7. Teststrategie
Werkzeuge: `flutter_test` + `mocktail` (Unit/Widget), pgTAP (`supabase test db`), **Playwright** in `e2e/` mit zwei Projekten:
- `api`: HTTP direkt gegen das lokale GoTrue (über Kong, `http://127.0.0.1:54321/auth/v1`), plus Helfer für den CLI-Mailfänger Mailpit (`GET /api/v1/messages`, `GET /api/v1/message/{id}`; Mails je Test per `To` filtern, nicht global löschen). Liest Mails, Betreff, Sprache und Links.
- `web`: Browser gegen den lokalen Web-Build (`flutter build web`, ausgeliefert auf Port 3000).
- `throttle`: Drosselungstests (Krit. 7, AUTH-CFG-10/11) gegen einen **zusätzlichen GoTrue-Container** (siehe D2), nicht gegen die CLI. Grund: Die CLI setzt `GOTRUE_RATE_LIMIT_HEADER` nicht, ohne ihn gibt es keine IP-Drosselung (B0, A2; entschieden F11). Alle Requests in diesem Projekt tragen `X-Forwarded-For` mit einer je Test zufälligen IP (simuliert den Proxy).
Testnamen und Beschreibungen enthalten die Referenz („KTO-01 Kriterium n“). Drosselungs- und Gültigkeitstests referenzieren `AUTH-CFG-xx` und lesen die Zahlen aus einer gemeinsamen Konstantendatei `e2e/support/auth-config.ts`, die mit `docs/auth-konfiguration.md` übereinstimmen muss.
### 7.1 Abdeckungsmatrix
| AK | Unit | Widget | Integration/E2E (Playwright) | RLS/pgTAP | Task |
|---|---|---|---|---|---|
| KTO-01 Krit. 1 | `RegisterController` ruft `signUp` mit allen Metadaten inkl. `locale` | gültiges Formular → Navigation zu CheckEmail | api: Signup → Nutzer + Profil existieren (inkl. `terms_version`), Bestätigungsmail im Fänger; web: kompletter Ablauf bis „E-Mail bestätigt“ | `05_auth_registration`: gültige Metadaten → Profil mit getrimmtem Namen, `locale`, `terms_version`, `terms_accepted_at` gesetzt | B1, B2, A2, T1–T3 |
| Krit. 2 | Formular-Validierung ohne Altersbestätigung → Fehler | Checkbox nicht gesetzt → Fehlertext sichtbar, kein Aufruf | api: Signup ohne `age_confirmed` → HTTP 500, Body-`message` `age_confirmation_required`, kein Nutzer angelegt | bestehend `01_profiles` (age_confirmation_required) | B1, A2 |
| Krit. 3 | `PasswordPolicy`: 9/10 Zeichen, Liste (Groß/Klein, Trim), Codepoints; Mapping `weak_password` | Live-Fehler beim Tippen am Feld (beide Gründe); Server-`weak_password` erscheint am Feld | api: Signup mit 9 Zeichen → 422 `weak_password` (AUTH-CFG-05) | – | B2, A1, A2, A4 |
| Krit. 4 | Controller wertet `identities` nicht aus (Test: Antwort mit leeren `identities` → gleicher Erfolgspfad); Repository mappt `over_email_send_rate_limit` bei `signUp`/`resetPasswordForEmail` auf `emailSendLimited`, `over_request_rate_limit` weiter auf `rateLimited`; `RegisterController` und `ForgotPasswordController` behandeln `emailSendLimited` wie Erfolg (F12) | gleicher Bildschirm für neue und vorhandene Adresse; **F12:** RegisterScreen und ForgotPasswordScreen mit gemocktem `emailSendLimited` → Navigation zum CheckEmailScreen, **kein** Drosselungshinweis, Text identisch zum 200-Fall (Golden-String-Vergleich), neutraler Zusatz „kann einige Minuten dauern“ sichtbar | api: Signup mit bestätigter Adresse → **200** (nicht 422), `identities` leer, ID ≠ ID des echten Nutzers, Anzahl `auth.users` unverändert, **keine** Mail, altes Passwort weiter gültig (Regressionstest für AUTH-CFG-04 **und** AUTH-CFG-24); mit unbestätigter Adresse → 200, Passwort unverändert (Login mit altem Passwort nach Bestätigung möglich). Der API-Seitenkanal bei erschöpftem Mail-Limit (R3) wird nicht E2E getestet (lokal wirkungslos, akzeptiert, F12); die App-Seite sichern Unit- und Widget-Test ab. | – | B2, A2, A4, T1, T2, T3 |
| Krit. 5 | `authStatusProvider` mappt Ereignisse | App-Start mit gespeicherter Sitzung → angemeldeter Zustand | web: Login → Reload und neuer Browser-Kontext mit gleichem Storage → weiterhin angemeldet; api: `sessions.timebox` nicht gesetzt (Konfig-Check) | – | A3, T3 |
| Krit. 6 | Mapping `email_not_confirmed`; `ResendController`: `emailSendLimited` mit `ResendOrigin.checkEmail` → Erfolg, mit `ResendOrigin.login` → Drosselungshinweis (F12) | Hinweis + Resend-Aktion; Resend-Erfolg/429; **F12:** CheckEmailScreen mit gemocktem `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (exakter Textvergleich), kein Drosselungshinweis; LoginScreen-Resend mit `emailSendLimited` → Drosselungshinweis | api: unbestätigtes Konto + richtiges Passwort → `email_not_confirmed`; falsches Passwort → `invalid_credentials`; Resend → zweite Mail | – | A3, T3 |
| Krit. 7 | Mapping `invalid_credentials`/429 → genau ein Fehlertyp | falsche E-Mail und falsches Passwort zeigen **denselben** Text (Golden-String-Vergleich) | throttle (Extra-Container mit `GOTRUE_RATE_LIMIT_HEADER`): 30 Fehllogins mit gleichem `X-Forwarded-For` → jeweils `invalid_credentials`, Login #31 → 429 `over_request_rate_limit` (Burst aus AUTH-CFG-10); gleichzeitig eine zweite IP nicht betroffen; Antwort ohne `Retry-After` (App zeigt keine Dauer). api (CLI): falsche E-Mail und falsches Passwort → identische Meldung. web: Drosselungshinweis sichtbar, 429 per Playwright `page.route` simuliert (die CLI drosselt nie, ein echter Test wäre nie rot) | – | B2, A3, T3, D2 |
| Krit. 8 | `EmailLinkController`: Parameter-Parsing, Mapping `otp_expired` | Link-Screen: Knopf nötig; Fehlerzustand bei ungültigem Link | api: Reset anfordern → Mail → `verifyOTP` ok → zweites Einlösen 403; zweites Einlösen → 403 `otp_expired`; abgelaufener Link: Helfer `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Port 54322, Rolle `postgres`) und setzt `auth.users.recovery_sent_at` (Reset) bzw. `confirmation_sent_at` (Bestätigung) auf `now() - (otp_expiry + 1 s)` (B0 (i); `auth.one_time_tokens.created_at` ist wirkungslos). Wert aus AUTH-CFG-07; Reset für unbekannte Adresse → 200, keine Mail | – | B2, A4, T3 |
| Krit. 9 | `onSignedOut`-Hook invalidiert private Provider | fehlgeschlagener Refresh → Navigation zu `/login` | api: Sitzungen A und B; Reset über A → Refresh-Token von B ungültig (`refresh_token_not_found`/`session_not_found`); web: zweiter Browser-Kontext ist nach dem nächsten Refresh abgemeldet (Test erzwingt den Refresh; die Wartezeit bis 60 min wird bewusst nicht getestet, F1) | – | A4, T3 |
| Krit. 10 | `DisplayNameRule`: leer, nur Leerzeichen, 30/31 Codepoints, Emoji | Fehler am Feld, Zähler | api: Signup mit `display_name` = 31 Zeichen → HTTP 500, Body `{"code":"P0001","message":"display_name_invalid"}` (sichert das Format aus B0 (f) ab), kein Nutzer | `05_auth_registration`: fehlend/leer/Leerzeichen/31 → `display_name_invalid`; Trim; neuer Check bei Update `' '` → 23514 | B1, A1, A2 |
| Krit. 11 | – | – | api: Signup mit `locale=en` → englischer Betreff und Text; `de` → deutsch; Profilsprache per Update auf `en` ändern → Reset-Mail englisch; nach Reset und Login **keine** weitere Mail (Benachrichtigungen aus) | `05_auth_registration`: Update `profiles.locale` → `auth.users.raw_user_meta_data.locale` folgt; fremdes Profil nicht betroffen; Signup ohne `locale` → Profil `'en'` (F6); Signup mit ungültigem `locale` → `'en'` | B1, B2, T3 |
| QUE-04 Krit. 1 (+ Nachweis F5) | Validierung Zustimmung; `RegisterController` sendet `terms_version` aus `AppConfig` | Links zu Datenschutzerklärung/Nutzungsbedingungen vorhanden und bedienbar; ohne Haken kein Absenden | – | bestehend (terms_acceptance_required); neu `05_auth_registration`: fehlende/leere/zu lange `terms_version` → `terms_version_required`; `authenticated` kann `terms_version` nicht ändern (42501) | B1, A2 |
| Sicherheit Krit. 2 | Router-Redirect-Logik | private Route abgemeldet → `/login` | – | bestehend (`anon` ohne Zugriff) | A1 |
| QUE-05 Krit. 2, 4 | – | Alle Screens in de und en ohne fehlende Schlüssel; Test „fehlende Übersetzung → Englisch“ im Fundament | – | – | F5, A2–A4 |
| QUE-06 Krit. 1–4 | – | `meetsGuideline(androidTapTargetGuideline, labeledTapTargetGuideline, textContrastGuideline)` je Screen; `textScaler` 2.0 ohne Overflow; Fehler als Live-Region | web: Ablauf nur per Tastatur | – | U1, T2 |
| Zustände Krit. 2, 3 | Mapping Netzfehler | Offline-Hinweis; Eingaben bleiben nach Fehler erhalten | – | – | A1–A4, T2 |
**Nicht automatisiert testbar, manuelle Prüfung im PR:** echter Versand über den Staging-SMTP (sobald F7 entschieden ist), HIBP im Prod-Env aus (AUTH-CFG-21), IP-Erkennung hinter dem Produktions-Gateway (AUTH-CFG-13: 31 Fehllogins einer IP → 429; ein vom Client gefälschter `X-Forwarded-For` wird überschrieben; GoTrue ist nicht direkt erreichbar), Sperre `/otp` und `/magiclink` am Gateway (AUTH-CFG-20), `GOTRUE_SMS_AUTOCONFIRM=false` im Prod-Env (AUTH-CFG-24, Signup mit bestätigter Adresse → 200), Mail-Limit `email_sent` (AUTH-CFG-09, lokal wirkungslos). Diese Punkte stehen als Checkliste in `docs/auth-konfiguration.md` und sind vor dem Release abzuhaken.
## 8. Aufgaben
### 8.1 Fundament-Issue #31
Angelegt am 2026-11-02: [#31 „chore: App-Fundament – Flutter-Gerüst und Supabase-Konfiguration“](https://git.cshomelabs.work/cschlaefke/Shuffle/issues/31). Milestone R1, Etappe R1.1, Labels `type/tech`, `epic/querschnitt`, `prio/must`, `size/l`. #1 hängt in Forgejo davon ab (blockierende Abhängigkeit); danach braucht es auch #3, #4 und #28. Branch: `chore/31-app-fundament`. Die Tasks F1–F8 stehen dort als Checkliste.
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| F1 | devops | Repo-Hygiene: `supabase init` → `supabase/config.toml` committen (zunächst ohne Auth-Anpassungen); `.gitignore` um `app/env/*.json` (Ausnahme `!app/env/*.example.json`), `supabase/.temp/`, `supabase/.branches/` ergänzen. Hinweis: `env/*.json` greift nur im Repo-Root. CI-Pfade prüfen. | – |
| F2 | flutter-dev | `flutter create app` (Plattformen ios, android, web; Platzhalter laut 5.1: App-Name „Shuffle“, ID `work.cshomelabs.shuffle`, zentral änderbar); `pubspec.yaml` mit den Paketen aus 5.7 (gepinnt); `analysis_options.yaml` (flutter_lints, strikt); Ordnerstruktur `core/`, `features/` | F1 |
| F3 | flutter-dev | `core/config`: `AppConfig` aus `--dart-define-from-file` (`SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SITE_URL`, `LEGAL_PRIVACY_URL`, `LEGAL_TERMS_URL`, `TERMS_VERSION`; Platzhalterwerte laut 5.1); `app/env/dev.example.json`, `app/env/ci.example.json`; `core/logging` (Wrapper um `package:logging`, kein `print`, keine personenbezogenen Daten im Log); globale Fehlerbehandlung; `supabaseClientProvider`; `Result`-Typ | F2 |
| F4 | flutter-dev | `core/routing`: go_router-Provider, `usePathUrlStrategy`, Redirect-Hook mit austauschbarer Auth-Quelle, Startseite `/` als Platzhalter, 404-Seite | F2 |
| F5 | flutter-dev | `core/l10n`: gen-l10n, `app_de.arb`/`app_en.arb`, englischer Fallback (QUE-05 Krit. 4), `appLocaleProvider` (Gerätesprache de/en, sonst en; manuelle Wahl folgt in KTO-11) | F2 |
| F6 | ui-designer | Theme-Basis Material 3: Farbschema mit geprüften AA-Kontrasten (hell; dunkel optional), Typografie, Abstände, Breakpoints 600/1024 dp, Fokus-Stil Web | F2 |
| F7 | test-engineer | Test-Grundlagen: `test/helpers/pump_app.dart` (l10n, ProviderScope-Overrides), Beispiel-Unit- und Widget-Test, Fallback-Test l10n | F3–F5 |
| F8 | devops | CI: Flutter-Job läuft mit `app/` (`env/ci.example.json`); `supabase-tests` läuft mit committeter `config.toml` | F1, F2 |
### 8.2 Issue #1 – Backend-PR (vor 01a)
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B0 | backend-dev | **Erledigt 2026-11-02.** Ergebnisse und Abweichungen A1–A7: `docs/specs/1-auth-spike-ergebnisse.md` (in diese Spec eingearbeitet, siehe § 10). Ursprünglicher Auftrag: **Spike (max. ½ Tag)** gegen die lokale CLI. Zu klären: (a) Wertet die Betreffzeile `.Data.locale` aus? (b) Exakte `type`-Werte für `token_hash` (email/signup, recovery). (c) Welcher Limiter greift beim Passwort-Login, mit welchen Zahlen (AUTH-CFG-10)? (d) Ist `email_sent` instanzweit? (e) Welcher Mailfänger ist im Einsatz (Inbucket oder Mailpit, API)? (f) Wie erscheinen Trigger-Fehler bei `signUp` (500, Text)? (g) Welche `reasons` liefert `weak_password`? (h) Stimmen die GoTrue-Version der CLI und das geplante Docker-Image überein? (i) Welche Spalte prüft GoTrue für den Linkablauf: `auth.users.confirmation_sent_at`/`recovery_sent_at` oder `auth.one_time_tokens.created_at`? Das braucht der E2E-Ablauftest. (j) Trägt das JWT einer Recovery-Sitzung einen `amr`-Eintrag, der sie von einer normalen Anmeldung unterscheidet (5.4, R12)? Ergebnis als Abschnitt „Verifiziert“ in `docs/auth-konfiguration.md` (überführt B3). | F1 |
| B1 | backend-dev | Migration `<ts>_auth_registration_rules.sql` (3.2): neues `handle_new_user` (Anzeigename streng, `terms_version` Pflicht, `locale`-Fallback `'en'`), Spalte `terms_version` mit Check, Standard `locale = 'en'`, Check `profiles_display_name_not_blank`, Sync-Trigger `profiles.locale` → `auth.users`. pgTAP: `01_profiles.test.sql` anpassen (Erwartung „Fallback E-Mail-Präfix“ und „kürzen auf 30“ wird zu „wird abgelehnt“); Erwartung „ohne Sprache → `'de'`“ wird zu `'en'`. Fixtures in `01_profiles`, `02_catalog`, `03_collection` und `04_plays` um `display_name` (wo fehlend) und `terms_version` ergänzen (R9). Neu `05_auth_registration.test.sql` (Krit. 10, 11, Trim, `terms_version` Pflicht und nicht änderbar, `locale`-Fallback `'en'`, Sync nur für eigenes Konto). Doku `datenmodell-r1.md`, `supabase/README.md`. | F1 (nur für `supabase start`), sonst keine |
| B2 | backend-dev | `config.toml` Auth-Abschnitt gemäß 4.1 (alle Werte explizit, **inkl. AUTH-CFG-24 `[auth.sms] enable_confirmations = true`**); `email_sent` und die Rate-Limits im Kommentar als „lokal ohne Wirkung“ bzw. „nur mit `GOTRUE_RATE_LIMIT_HEADER` wirksam“ kennzeichnen; Mailvorlagen `supabase/templates/confirmation.html`, `recovery.html` (de/en mit `index .Data "locale"`, Links `type=email` / `type=recovery` nach E3, barrierearmes HTML, nur Text und Links); Benachrichtigungen explizit aus | B0 |
| B3 | backend-dev | `docs/auth-konfiguration.md`: **überführt den Inhalt von `docs/specs/1-auth-spike-ergebnisse.md`** (Abschnitt „Verifiziert“ mit Versionen, Ergebnissen je Punkt, Abweichungen A1–A7). Dazu Tabelle 4.1 mit verifizierten Werten (ID, CLI-Schlüssel, Env, Wert lokal, Wert Staging/Prod, Quelle, Test-Referenz), Env-Block für Self-Hosting ohne Secrets (`GOTRUE_RATE_LIMIT_HEADER` als **Pflicht**, `GOTRUE_RATE_LIMIT_EMAIL_SENT`, `…_TOKEN_REFRESH`, `…_OTP`, `…_VERIFY`, `GOTRUE_SMS_AUTOCONFIRM=false` bzw. `ENABLE_PHONE_AUTOCONFIRM=false`), Release-Checkliste (manuelle Prüfungen aus 7.1; bei jedem GoTrue-Update B0-Punkte (c), (d), (f) wiederholen). Das Spike-Dokument bleibt als Nachweis unter `docs/specs/` liegen. | B0, B2 |
| D1 | devops | Betriebsanteil dokumentieren und vorbereiten (kein Eingriff in Server): GoTrue-Image auf **`supabase/gotrue:v2.197.0` pinnen** (nicht `latest`, Stand B0 (h)); Auslieferung der Mailvorlagen per URL für GoTrue; `GOTRUE_RATE_LIMIT_HEADER` (Pflicht) + Proxy-Konfiguration (Header überschreiben, nicht anhängen; GoTrue nur über den Proxy erreichbar); `ENABLE_PHONE_AUTOCONFIRM=false` bzw. `GOTRUE_SMS_AUTOCONFIRM=false` (AUTH-CFG-24, Vorlage setzt `true`); Gateway-Sperre `/auth/v1/otp` und `/auth/v1/magiclink`; SPA-Rewrite für die Web-App; `GOTRUE_PASSWORD_HIBP_ENABLED=false` und `GOTRUE_MAILER_OTP_EXP=3600` explizit; SMTP, sobald Anbieter und Absender-Domain entschieden sind (F7, später). **Blockiert das Release, nicht den Merge von #1** (Hosting-Setup nach ADR-0002 steht noch aus). | B3 |
### 8.3 Issue #1 – UI und App (drei PRs)
| # | Agent | Aufgabe | PR | abhängig von |
|---|---|---|---|---|
| U1 | ui-designer | Komponenten in `core/widgets/`: `AppTextField` (Label, Hilfetext, `errorText` als Live-Region), `AppPasswordField` (Sichtbarkeitsschalter mit Semantics-Label, Regelhinweis), `AppCheckboxField` (Fehlerzustand, Label mit eingebetteten Links, ganzes Label als Touch-Ziel ≥ 48 dp), `AppPrimaryButton` (Ladezustand), `AppFormScaffold` (zentrierte Karte ab 600 dp, scrollbar), `AppBanner` (Info/Fehler). Layout-Skizzen der sechs Auth-Screens, Textvorschläge de/en für alle Meldungen aus 4.4 und 5.4. | 01a | F6 |
| A1 | flutter-dev | Auth-Domäne und -Daten: Modelle, `AuthFailure`, Validatoren (`DisplayNameRule`, `PasswordPolicy`, `EmailFormat`), `AuthRepository` + Supabase-Implementierung mit Mapping 4.4, `authStatusProvider`, Router-Anbindung (Redirect-Regeln 5.3, `onSignedOut`-Hook), Asset „häufige Passwörter“ (Lizenzprüfung gemeinsam mit test-engineer) | 01a | F2–F5 |
| A2 | flutter-dev | RegisterScreen, CheckEmailScreen (inkl. Resend), EmailLinkScreen (Typ Bestätigung), Rechtstext-Links (`url_launcher`), ARB-Texte | 01a | A1, U1, B1, B2 |
| A3 | flutter-dev | LoginScreen (allgemeiner Fehler, Hinweis bei unbestätigtem Konto + Resend, Drosselung, Offline), Autofill, Weiterleitung `from` | 01b | A1, U1 (parallel zu A2 möglich) |
| A4 | flutter-dev | ForgotPasswordScreen, EmailLinkScreen (Typ Recovery), ResetPasswordScreen mit Recovery-Guard und Abbruch → `signOut`, Erfolgsmeldung Krit. 9 | 01c | A2 (EmailLinkScreen), A1, U1 |
| T1 | test-engineer | Unit-Tests: Validatoren, Fehler-Mapping (jede Zeile aus 4.4), Controller (mocktail), `authStatusProvider`, Router-Redirects | je PR | A1–A4 (begleitend) |
| T2 | test-engineer | Widget-Tests je Screen: Feldfehler, identische Meldung bei Krit. 7, Zustände, a11y-Guidelines, `textScaler` 2.0, de/en | je PR | U1, A2–A4 |
| T3 | test-engineer | Playwright `e2e/`: Projekte `api` (GoTrue über Kong + Mailpit-Helfer), `web` und `throttle` (Drosselungstests gegen den Extra-Container aus D2, Basis-URL aus Env, z. B. `THROTTLE_GOTRUE_URL`; alle Requests mit `X-Forwarded-For` = zufällige IP je Test; Login #31 → 429, zweite IP unbeeinflusst, kein `Retry-After`); Tests je Kriterium nach 7.1, inkl. Regressionstest AUTH-CFG-24 (Krit. 4, `identities` leer) und Body-Format der Trigger-Fehler (Krit. 10); `e2e/support/auth-config.ts` mit Bezug auf AUTH-CFG-IDs; `e2e/support/db.ts` setzt `auth.users.recovery_sent_at` / `confirmation_sent_at` zurück (nur lokal/CI). Jeder Test nutzt eigene, zufällige E-Mail-Adressen. Der Teil `api` kann schon nach B2 starten, unabhängig von der App. | api, throttle: 01a; web: je PR | B2 (api), D2 (throttle), A2–A4 (web) |
| D2 | devops | CI-Job `e2e` auf dem Runner `docker-host`: `supabase start` (mit Mailpit), `flutter build web --dart-define-from-file=env/ci.example.json`, statisch auf Port 3000 ausliefern, `npx playwright test`; Artefakte (Report, Traces) bei Fehlern. **Zusätzlich für Krit. 7 (F11):** nach `supabase start` einen zweiten GoTrue-Container starten: dasselbe Image wie die CLI (`public.ecr.aws/supabase/gotrue:v2.197.0`, Version aus dem CLI-Container lesen statt hart kodieren), im Docker-Netz der CLI (`supabase_network_<projekt>`), Env des CLI-Auth-Containers übernehmen (`docker inspect`) plus `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For` und `API_EXTERNAL_URL`, eigener Port (z. B. 9998) für das Playwright-Projekt `throttle`. Der Container nutzt dieselbe DB, hat aber eigene Rate-Limit-Buckets (im Speicher). CI läuft über Docker-in-Docker (siehe Commit `51ab6e2`): Port-Freigabe und Netzname im Runner prüfen. Container nach dem Lauf entfernen. Ein Skript `e2e/support/start-throttle-gotrue.sh` macht den Ablauf auch lokal nutzbar. | 01a | F8, T3 |
### 8.4 Reihenfolge und Parallelität
```
Phase 0 F1 (devops, klein; Issue #31)
Phase 1 parallel: F2→F3/F4/F5 (flutter-dev) | F6 (ui-designer) | B0 (erledigt)→B2→B3, B1 (backend-dev)
Phase 2 parallel: F7, F8 | U1 | A1 | T3-api (nach B2) | D2-Extra-Container + T3-throttle | D1 (Doku, nach B3)
Phase 3 PR 01a: A2 + T1/T2/T3-web + D2
Phase 4 parallel: PR 01b: A3 (+Tests) | PR 01c: A4 (+Tests, braucht A2)
```
Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge ist Fundament (#31) → Backend → 01a → 01b/01c. Alle PRs: `Refs #1`, der letzte `Closes #1`. Branches: `feature/1-auth-backend`, `feature/1-auth-registrierung`, `feature/1-auth-anmeldung`, `feature/1-auth-passwort-reset`. Der aktuell ausgecheckte Branch `feature/1-auth-registrierung-anmeldung` trägt die Spec.
## 9. Risiken & offene Fragen
### 9.1 Risiken
| # | Risiko | Auswirkung | Gegenmaßnahme |
|---|---|---|---|
| R1 | Supabase-Standardwerte unterscheiden sich je nach Quelle (Binary, CLI, Docker) und Version | Tests sind grün, Produktion verhält sich anders (Krit. 7, 8) | Alle Werte pinnen (E8); B0 hat CLI 3.0.0 und Image `v2.197.0` verifiziert; Image pinnen (D1) und bei jedem GoTrue-Update B0 (c), (d), (f) wiederholen; Release-Checkliste |
| R2 | **Drosselung (Krit. 7) wirkt nur mit `GOTRUE_RATE_LIMIT_HEADER`** (B0, A2). Ohne die Einstellung gibt es gar keine IP-Drosselung; auch mit ihr werden Requests **ohne** den Header nicht gedrosselt. Fehlt der Proxy oder ist GoTrue direkt erreichbar, ist die Drosselung umgehbar; hängt der Proxy den Header nur an, fälschen Clients ihre IP. Die 429-Antwort trägt kein `Retry-After`. Lokal (CLI) gibt es keine Drosselung. | Passwort-Raten ungebremst (Krit. 7), oder Umgehung durch gefälschte IPs | AUTH-CFG-13 Pflicht; D1: Proxy überschreibt den Header, GoTrue nur über den Proxy erreichbar; CI-Test gegen Extra-Container (D2, T3, F11); Release-Checkliste am echten Gateway (31 Fehllogins, gefälschter Header wird überschrieben). App-Text nennt keine Dauer. |
| R3 | `email_sent` ist **instanzweit** (B0 (d) bestätigt, 30 Mails/h) und wirkt nur über das Env, in der lokalen CLI nicht | Ab der 31. Mail pro Stunde gehen keine Mails mehr raus; Angreifer können das billig auslösen. **Seitenkanal:** Während der Sperre antwortet `/recover` für bestehende Adressen 429, für unbekannte 200 (Signup abgeleitet umgekehrt, siehe 4.3); über die rohe API wird die Existenz eines Kontos erkennbar. Nebenwirkung der Gegenmaßnahme: Nutzer erfahren in der App nicht, dass ihre Mail wegen der Sperre verzögert ist oder ausbleibt. | 30/h reicht laut Chris zum Start (F4). **F12 (Chris, 2026-11-02):** In der App verdeckt: `over_email_send_rate_limit` bei Registrierung und Reset führt zum selben neutralen CheckEmailScreen wie 200, „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt ebenso neutral (4.4); nur nach Login mit richtigem Passwort (Krit. 6) erscheint der Drosselungshinweis. Krit. 4 bleibt in der App erfüllt; der neutrale Zusatz „kann einige Minuten dauern“ steht immer da. Auf API-Ebene akzeptiert. Der Angriff erzeugt vor allem Ausfälle. Monitoring auf 429 `over_email_send_rate_limit`, Wert bei Bedarf erhöhen; Proxy-Lösung erst bei Bedarf. Nicht automatisiert getestet (lokal wirkungslos). |
| R4 | GoTrue hat keinen Schalter gegen Magic Link/OTP (`/otp`) | Mails außerhalb von Krit. 11, Konten ohne Passwort | Gateway-Sperre (AUTH-CFG-20, D1). B0 (A7): `/otp` legt mit gültigen Metadaten Konten an, der Trigger schützt also nicht. Lokal nicht absicherbar, deshalb nur dokumentiert |
| R5 | Self-hosted GoTrue lädt Mailvorlagen per URL; bei Fehlern still auf die englische Standardvorlage | Krit. 11 verletzt, ohne dass es auffällt | Vorlagen-URL im Health-Check, Release-Checkliste: Testmail de/en auf Staging |
| R6 | Enumeration auf API-Ebene: (a) Signup mit unbestätigter Adresse gibt den echten Nutzer zurück, mit bestätigter Adresse einen erfundenen (B0 bestätigt). (b) **Fehlkonfiguration:** Mit `GOTRUE_SMS_AUTOCONFIRM=true` (CLI-Standard, Self-Hosting-Vorlage `ENABLE_PHONE_AUTOCONFIRM=true`) liefert Signup mit bestätigter Adresse 422 `user_already_exists` (B0, A1). (c) Seitenkanal bei erschöpftem Mail-Limit, siehe R3. | (a) Mit Skripten lässt sich die Existenz unbestätigter Konten erkennen. (b) Existenz bestätigter Konten offen lesbar, Krit. 4 auf API-Ebene gebrochen. | (a) Supabase-Standardverhalten; die App verrät nichts (Krit. 4 bezieht sich auf das Erleben in der App). Bei Bedarf später Captcha oder eigener Proxy. (b) AUTH-CFG-24 in `config.toml` und Env; API-Regressionstest (7.1 Krit. 4); Release-Checkliste. (c) in der App verdeckt (neutraler Screen bei Registrierung, Reset und Resend auf dem CheckEmailScreen, 4.4), auf API-Ebene akzeptiert (F12, R3). |
| R7 | Sitzung im Web in `localStorage`, auf iOS in unverschlüsselten UserDefaults | Bei XSS bzw. Gerätezugriff ist das Refresh-Token lesbar | CSP für den Web-Build (devops), kein Fremd-JS; Secure Storage auf Mobile als möglicher Folgeschritt |
| R8 | Krit. 9: Access-JWTs bleiben bis zu 3600 s gültig | Ein anderes Gerät kann bis zu 1 h weiter auf Daten zugreifen | **Bewusst akzeptiertes Restrisiko** (Chris, F1, 2026-11-02); `jwt_expiry` bleibt 3600 s. App reagiert sofort auf fehlgeschlagenen Refresh; Texte versprechen nicht „sofort“. |
| R9 | Der neue Trigger lehnt Registrierungen ohne Anzeigenamen oder ohne `terms_version` ab | Bestehende pgTAP-Tests legen Nutzer ohne diese Angaben an und brechen: ohne `display_name` `01_profiles` (ben), `02_catalog` und `03_collection` (anna, ben); ohne `terms_version` alle vier Test-Dateien 01–04. `seed.sql` legt keine Nutzer an. | B1 ergänzt die Test-Fixtures (Tests sind keine Migrationen, Änderung zulässig) |
| R10 | `user_metadata` behält `display_name` aus der Registrierung, auch wenn KTO-02 ihn später ändert | Veralteter Wert im JWT, keine fachliche Wirkung | Maßgeblich ist immer `profiles`; Hinweis in KTO-02 |
| R11 | Rechtliche Prüfung (Mindestalter per Selbstbestätigung) steht aus, Datenschutzerklärung und Nutzungsbedingungen existieren noch nicht (Klärung 21/47, F9) | Release-Blocker | Bleibt bei Chris; bis dahin Platzhalter-URLs und `TERMS_VERSION`-Platzhalter, alles per Env änderbar |
| R12 | Abgebrochene Recovery (Tab geschlossen) bleibt nach einem Reload als normale Anmeldung bestehen. B0 (j): Das Recovery-JWT trägt `amr = [{"method":"otp"}]`, identisch zur Sitzung nach dem Bestätigungslink; eine Unterscheidung ist **nicht möglich**. | Nutzer ist angemeldet, ohne ein neues Passwort gesetzt zu haben; das alte Passwort bleibt gültig | **Akzeptiertes Restrisiko:** vertretbar, weil der Postfachbesitz nachgewiesen ist (wie Magic Link). Der Guard lebt nur im laufenden Prozess (5.4); eine Ableitung aus `amr` ist unzulässig. |
| R13 | Drosselungstests leeren IP-Buckets für bis zu 5 min | Nachfolgende Login-Tests scheitern zufällig | Weitgehend entschärft: Drosselungstests laufen gegen den Extra-Container (D2) mit eigenen, im Speicher gehaltenen Buckets; die CLI drosselt ohnehin nicht. Im Projekt `throttle` nutzt jeder Test eine eigene zufällige `X-Forwarded-For`-IP (simuliert den Proxy, gewollt). Gegen die gemeinsame CLI-Instanz werden **keine** gefälschten `X-Forwarded-For`-Header verwendet. |
| R14 | Ohne HIBP prüft nur der Client die Liste häufiger Passwörter (F2) | Über die rohe API lassen sich häufige Passwörter mit mindestens 10 Zeichen setzen | Bewusst akzeptiert; der Server erzwingt die Mindestlänge 10, die Drosselung (AUTH-CFG-10) bremst Rateversuche |
| R15 | Bundle-/Application-ID ist ein Platzhalter (F8) | Nach der ersten Store-Veröffentlichung nicht mehr änderbar | Vor dem Store-Release festlegen (Release-Checkliste) |
| R16 | SMTP-Anbieter und Absender-Domain sind offen (F7) | Ohne SMTP keine Mails auf Staging/Prod | Lokal Mailpit; D1 ist Release-Blocker, kein Merge-Blocker |
### 9.2 Fragen an Chris (alle entschieden am 2026-11-02)
| # | Frage | Status | Entscheidung | Wirkung in der Spec |
|---|---|---|---|---|
| F1 | Reicht es, wenn andere Geräte nach einem Reset spätestens nach Ablauf des Access-Tokens (bis zu 60 min) abgemeldet sind? | entschieden | Ja. `jwt_expiry` bleibt 3600 s; bewusstes Restrisiko. | AUTH-CFG-14, 4.3, 5.4 (Erfolgstext), 7.1 Krit. 9, R8. A4 ist nicht mehr blockiert. |
| F2 | Have I Been Pwned aktivieren? | entschieden | Nein. Nur die im Client gebündelte Liste häufiger Passwörter, dazu die Server-Mindestlänge 10. | E7, AUTH-CFG-21 (`false` explizit), 4.4, 5.4, R14 |
| F3 | Gültigkeit von Bestätigungs- und Reset-Link? | entschieden | 1 Stunde für beide | AUTH-CFG-07, D1 |
| F4 | Reichen 30 Mails pro Stunde zum Start? | entschieden | Ja | AUTH-CFG-09, R3 |
| F5 | Version der zugestimmten Rechtstexte speichern? | entschieden | Ja: Spalte `terms_version`, Zeitstempel über `terms_accepted_at`. Der Client übergibt die Version aus einer konfigurierbaren Konstante (`TERMS_VERSION`). | 3.1, 3.2, 4.2, 5.1, 5.4, 7.1, B1, F3, R9 |
| F6 | Profilsprache ohne Angabe? | entschieden | Gerätesprache, sonst Englisch (QUE-05). Der Client sendet immer die aktive Sprache; der Server fällt auf `'en'` zurück (Standard und Trigger, neue Migration). | 3.1, 3.2, Mailvorlagen 4.1 (Else-Zweig Englisch), 5.4, 7.1, B1 |
| F7 | SMTP-Anbieter und Absender-Domain? | **vertagt** (Entscheidung später) | Lokal Mailpit; D1 bleibt Release-Blocker, kein Merge-Blocker | AUTH-CFG-22, D1, R16 |
| F8 | App-Identität? | entschieden (Platzhalter) | App-Name „Shuffle“, Bundle-ID `work.cshomelabs.shuffle`, Web-Domain `shuffle.cshomelabs.work`; leicht änderbar halten, Bundle-ID vor dem Store-Release festlegen | 5.1, AUTH-CFG-01, F2, R15 |
| F9 | Wo liegen die Rechtstexte? | entschieden | Sie existieren noch nicht; konfigurierbare Platzhalter-URLs, Chris liefert vor dem Release | 5.1, 5.4, R11 |
| F10 | Fundament als eigenes Issue? | entschieden, umgesetzt | Ja: Issue #31 angelegt, #1 hängt davon ab | E1, 5.1, 8.1, 8.4 |
| F11 | (Spike Q1) Krit. 7: Wo läuft der Drosselungstest, da die CLI keine IP-Drosselung kann? | entschieden (2026-11-02) | Im CI gegen einen zusätzlich gestarteten GoTrue-Container mit gesetztem `GOTRUE_RATE_LIMIT_HEADER`; zusätzlich manuelle Prüfung am echten Gateway vor dem Release | 7, 7.1 Krit. 7, D2, T3, R2, R13 |
| F12 | (Spike Q2) Seitenkanal bei erschöpftem Mail-Limit (`/recover`: bestehende Adresse 429, unbekannte 200) | entschieden (2026-11-02) | In der App verdeckt: Bei `over_email_send_rate_limit` zeigen Registrierung und Passwort-Reset denselben neutralen Screen „Prüfe dein Postfach … falls …“ (Krit. 4 bleibt in der App erfüllt), mit neutralem Zusatz „kann einige Minuten dauern“. Ergänzt (Chris, 2026-11-02): Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt neutral; nach Login mit richtigem Passwort (Krit. 6) bleibt der Drosselungshinweis. Über die rohe API bleibt der Kanal sichtbar: akzeptiert und als Risiko dokumentiert (Limit bleibt bei 30/h laut F4). | 4.2, 4.3, 4.4, 5.4, 7.1 Krit. 4, R3, R6 |
Es gibt keine offenen fachlichen Fragen mehr, die die Umsetzung von #1 blockieren. F7 blockiert nur das Release.
### 9.3 Ausstehende Prozessschritte
- Spec von Chris freigegeben am 2026-11-02; B0 erledigt, Anpassung an die Spike-Ergebnisse eingearbeitet (§ 10). Kriterientexte unverändert; Krit. 4 bleibt in der App auch während einer Mail-Sperre erfüllt (F12).
- Nächste Schritte: B2/B3 (backend-dev) mit AUTH-CFG-24, D2-Erweiterung (devops), dann T3.
### 9.4 Hinweise für KTO-11 (#28), nicht Teil von #1
- `double_confirm_changes` (AUTH-CFG-23) muss für N2 vermutlich `false` sein. Die Info-Mail an die alte Adresse kommt dann über die Benachrichtigung `email_changed` (AUTH-CFG-19).
- Passwort ändern mit aktuellem Passwort (N13): AUTH-CFG-25 (`secure_password_change`, in #1 `false`) entscheiden; Recovery-Sitzungen sind davon ausgenommen (passt zu #1).
- Der Sync-Trigger aus B1 deckt die Mailsprache nach einem Sprachwechsel in KTO-11 bereits ab.
- Abmelden-UI: `AuthRepository.signOut()` existiert, das UI liefert KTO-11.
- Erneute Zustimmung bei geänderten Rechtstexten (Vergleich `profiles.terms_version` mit `TERMS_VERSION`) ist nicht Teil von R1. Falls gewünscht, als eigene Story erfassen; dafür bräuchte es dann eine RPC zum Aktualisieren von `terms_version`.
- `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true` ist CLI-Standard (B0); Entscheidung bleibt bei KTO-11 (AUTH-CFG-23).
- `user_metadata.locale` kann der Nutzer selbst über `auth.updateUser(data: …)` ändern; die Mailsprache kann dann vom Profil abweichen (nur eigenes Konto). Der Client ändert die Sprache ausschließlich über `profiles`; der Sync-Trigger zieht bei der nächsten Profiländerung nach (B0).
## 10. Änderungsprotokoll
### 2026-11-02 – Anpassung an Spike B0 (`docs/specs/1-auth-spike-ergebnisse.md`)
Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien sind unverändert; Q1/Q2 des Spikes hat Chris als F11/F12 entschieden. Krit. 4 bleibt in der App auch während einer Mail-Sperre erfüllt (F12: neutraler Screen); nur über die rohe API ist der Seitenkanal sichtbar (akzeptiert).
1. **Statuskopf und Quellen:** „freigegeben 2026-11-02, angepasst an Spike-Ergebnisse“; Spike-Dokument und Migration `20261102212208_auth_registration_rules.sql` als Quellen.
2. **Neue Konfiguration AUTH-CFG-24** (A1): `[auth.sms] enable_confirmations = true` / `GOTRUE_SMS_AUTOCONFIRM=false`, im Self-Hosting `ENABLE_PHONE_AUTOCONFIRM=false` (Vorlage `docker/.env.example` setzt `true`). Sonst 422 `user_already_exists` statt Enumerationsschutz (Krit. 4). AUTH-CFG-04 verweist darauf.
3. **4.1:** Einleitung nicht mehr „Entwurf“; AUTH-CFG-09 instanzweit und lokal wirkungslos; AUTH-CFG-10 Schlüssel `token_refresh` bestätigt, kein `Retry-After`; AUTH-CFG-11 nur für Signup bestätigt; AUTH-CFG-10 bis -12 nur mit AUTH-CFG-13 wirksam; AUTH-CFG-13 **Pflicht**; AUTH-CFG-20 um A7 ergänzt; AUTH-CFG-22 Mailpit; Mailvorlagen: kein zweisprachiger Rückfall-Betreff, `type`-Werte bestätigt. E5 entsprechend angepasst.
4. **Fehlerbehandlung (E6, 4.2, 4.4)** (A4): Trigger-Fehler kommen als HTTP 500 mit `{"code":"P0001","message":"<grund>"}` im Body (Header `X-Sb-Error-Code: unexpected_failure`). Mapping auf `registrationRejected` über HTTP 500 bei `signUp`, nicht über `P0001`; die Meldung nur für Log/Zusatzhinweise. 429 ohne Dauerangabe.
5. **4.3:** als verifiziert markiert; Krit. 4 mit beiden Autoconfirm-Voraussetzungen und dem Seitenkanal bei erschöpftem Mail-Limit; Krit. 7 Drosselung nur mit Header; Krit. 8 maßgebliche Spalte `auth.users.*_sent_at`.
6. **5.4** (A5): Der `amr`-Weg entfällt (Recovery-JWT `amr = otp`, nicht unterscheidbar); Guard nur im laufenden Prozess, Ableitung aus `amr` unzulässig.
7. **7 / 7.1:** neues Playwright-Projekt `throttle` gegen Extra-Container; Krit. 2 und 10 erwarten 500 mit Body-Meldung; Krit. 4 prüft `identities` leer (Regressionstest AUTH-CFG-24) und nennt den akzeptierten Seitenkanal; Krit. 7 echter Test im Projekt `throttle` (Login #31 → 429, zweite IP frei), Web-Hinweis per gemocktem 429; Krit. 8 Spalten `recovery_sent_at` / `confirmation_sent_at`, zweites Einlösen 403 `otp_expired`; manuelle Checkliste erweitert (AUTH-CFG-09, -13, -24).
8. **Aufgaben:** B0 als erledigt markiert (Verweis auf Spike-Dokument); B2 um AUTH-CFG-24 und Kennzeichnung lokal wirkungsloser Werte ergänzt; **B3 überführt den Inhalt von `docs/specs/1-auth-spike-ergebnisse.md` nach `docs/auth-konfiguration.md`**; D1 pinnt `gotrue:v2.197.0`, setzt `ENABLE_PHONE_AUTOCONFIRM=false`, GoTrue nur über den Proxy; **D2 startet im CI einen zusätzlichen GoTrue-Container** mit `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For`; **T3** Projekt `throttle` gegen diesen Container; Phasenplan 8.4 ergänzt.
9. **Risiken:** R1 (Image gepinnt, Wiederholung bei Updates), R2 (Drosselung nur mit Header, Requests ohne Header ungedrosselt, kein `Retry-After`), R3 (instanzweit, nur per Env; Seitenkanal `/recover` 429 akzeptiert), R4 (A7), R6 (Fehlkonfiguration SMS-Autoconfirm, Seitenkanal), R12 (`amr` = `otp`, nicht erkennbar, akzeptiert), R13 (durch Extra-Container entschärft; gefälschte Header nur im Projekt `throttle`).
10. **9.2:** F11 (Q1, CI-Drosselungstest mit Extra-Container) und F12 (Q2) ergänzt; 9.3 aktualisiert; 9.4 um zwei KTO-11-Hinweise aus dem Spike ergänzt.
11. **F12 entschieden (Chris, 2026-11-02):** Neuer Domänenfehler `AuthFailure.emailSendLimited` für `over_email_send_rate_limit` bei `signUp` und `resetPasswordForEmail`; `RegisterController` und `ForgotPasswordController` behandeln ihn wie Erfolg → neutraler CheckEmailScreen (4.3, 4.4, 5.4). CheckEmailScreen zeigt immer den Zusatz „Die E-Mail kann einige Minuten dauern …“, weil Nutzer eine Verzögerung sonst nicht erfahren. 7.1 Krit. 4 um Unit- und Widget-Tests ergänzt. R3/R6: Kanal in der App verdeckt, auf API-Ebene akzeptiert. Die Aussage „Krit. 4 gilt während einer Mail-Sperre nicht in der App“ ist entfallen.
12. **F12 ergänzt (Chris, 2026-11-02):** Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt `over_email_send_rate_limit` neutral; im LoginScreen nach `email_not_confirmed` (Krit. 6) bleibt der Drosselungshinweis. `ResendController` mit `ResendOrigin.checkEmail | login` (4.4, 5.4); Tests in 7.1 Krit. 6; R3, R6, F12 angepasst.
13. **AUTH-CFG-25** neu: `auth.email.secure_password_change = false`, Entscheidung in KTO-11 (N13); 9.4 verweist darauf. **ID-Abgleich** mit dem B2-Stand (`supabase/config.toml`, `docs/auth-konfiguration.md`): IDs 01–24 stimmen überein. Angeglichen: AUTH-CFG-03 CLI-Schlüssel `auth.email.enabled` (statt `auth.email.enable_signup`). Nachgezogen in B2: `secure_password_change` → AUTH-CFG-25 (erledigt).
14. **Anzeigename: U+200B verboten (Chris, 2026-11-02):** Ein Name mit U+200B an beliebiger Stelle ist ungültig (Trigger `display_name_invalid`, zusätzlicher Check `profiles_display_name_no_zwsp`, Update → 23514; Migration `20261102212208_auth_registration_rules.sql`, pgTAP `05_auth_registration`). U+200C/U+200D/U+FEFF unverändert. Betroffen: E6, 3.1, 3.2 (Check, Trigger, Trim-Regel), RegisterScreen (Client-Regel für 01a: `DisplayNameRule` meldet U+200B als Feldfehler, gleiche Meldung wie „ungültig").

View file

@ -0,0 +1,129 @@
# Spike B0 – Ergebnisse zu GoTrue (Spec #1, KTO-01)
| | |
|---|---|
| Issue | #1 (KTO-01), Aufgabe B0 |
| Spec | `docs/specs/1-auth-registrierung-anmeldung.md` (§ 4.1, 4.3, 5.4, 7.1, 8.2, 9.1) |
| Datum | 2026-11-02 |
| Autor | backend-dev |
| Versionen | Supabase CLI 3.0.0, GoTrue `v2.197.0` (Image `public.ecr.aws/supabase/gotrue:v2.197.0`), Postgres 17.11, Mailpit v1.31.3 |
## Vorgehen und Grenzen der Aussagekraft
- Die lokale CLI wurde mit einer **temporären** `config.toml` gestartet: `enable_confirmations = true`, `minimum_password_length = 10`, `email_sent = 100`, `max_frequency = "1s"`, Vorlagen für Bestätigung und Reset mit `.Data.locale`-Verzweigung (Vorlagen und Änderungen sind wieder entfernt; `config.toml` im Repo ist unverändert, das ist Aufgabe B2).
- Alle Requests liefen gegen `http://127.0.0.1:54321/auth/v1` (Kong, wie die App). Die Drosselungstests (c, d) liefen zusätzlich gegen einen **zweiten GoTrue-Container** desselben Images im Netz der CLI, mit den Umgebungsvariablen des CLI-Containers plus `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For` und `GOTRUE_RATE_LIMIT_EMAIL_SENT=5`. Das entspricht dem Self-Hosting-Pfad (Env statt `config.toml`).
- **Korrektur während des Spikes:** Die erste Messung zu Krit. 4 zeigte 422 `user_already_exists`; die Ursache war `GOTRUE_SMS_AUTOCONFIRM=true` (CLI-Standard), nicht GoTrue selbst. Ein Wiederholungstest mit `[auth.sms] enable_confirmations = true` bestätigt das Verhalten der Spec (siehe A1).
- **Nicht aussagekräftig lokal:** SMTP-Anbieter, Verhalten hinter dem echten Produktions-Gateway, Header-Behandlung durch den Proxy, Mailzustellung. Die Mail-Rate-Limits der CLI gelten nicht (siehe d).
- Die Versionsprüfung (h) basiert auf der `docker-compose.yml` im Supabase-Repository (Stand heute, Branch `master`); eine eigene Self-Hosting-Instanz gibt es noch nicht.
## Kurzfassung: Abweichungen von der Spec
| # | Abweichung | Wirkung |
|---|---|---|
| **A1** | **Der Enumerationsschutz (Krit. 4) hängt an einer weiteren Einstellung: `GOTRUE_SMS_AUTOCONFIRM` muss `false` sein.** Die CLI-Vorlage setzt `[auth.sms] enable_confirmations = false` und damit `GOTRUE_SMS_AUTOCONFIRM=true`. Dann liefert Signup mit einer *bestätigten* Adresse **422 `user_already_exists`**. Mit `[auth.sms] enable_confirmations = true` (`GOTRUE_SMS_AUTOCONFIRM=false`) kommt wie in der Spec **200 mit erfundenem Nutzer** (zufällige ID, `identities` leer, keine Mail, Log `user_repeated_signup`). | Neue Zeile AUTH-CFG-24 für B2/B3 (`auth.sms.enable_confirmations = true`, Env `GOTRUE_SMS_AUTOCONFIRM=false`). Die Selbsthosting-Vorlage `docker/.env.example` setzt `ENABLE_PHONE_AUTOCONFIRM=true`, die Falle gilt dort also auch. Die Spec-Annahme 4.3 stimmt dann. |
| **A2** | **Ohne `GOTRUE_RATE_LIMIT_HEADER` gibt es gar keine IP-Drosselung** (weder Login noch Signup noch Refresh). Auch mit gesetzter Konfiguration wird ein Request **ohne** diesen Header nicht gedrosselt (60 Fehllogins ohne `X-Forwarded-For` gegen den Container mit Header-Konfiguration: kein 429). Die lokale CLI kann den Header nicht setzen. | AUTH-CFG-13 ist Pflicht, nicht optional. GoTrue darf nur über den Proxy erreichbar sein, der den Header setzt (sonst umgeht ein direkter Zugriff die Drosselung). Drosselungstests (Krit. 7) sind gegen die CLI nicht möglich (Frage Q1). Betrifft B2, B3, D1, D2, T3. |
| **A3** | `email_sent` wirkt in der lokalen CLI nicht (`GOTRUE_RATE_LIMIT_EMAIL_SENT=360000`, obwohl `email_sent = 100` gesetzt war). Wirkt im Self-Hosting-Env und ist dort **instanzweit**. | AUTH-CFG-09 lokal nicht testbar; Seiteneffekt auf Enumeration (siehe d). |
| **A4** | Trigger-Fehler kommen als **HTTP 500 mit der Meldung des Triggers** im Body (`{"code":"P0001","message":"display_name_invalid"}`), nicht als generisches „Database error saving new user“. | Der Client kann den Grund unterscheiden. Verhalten ist versionsabhängig, siehe (f). |
| **A5** | Das JWT einer Recovery-Sitzung trägt `amr = [{"method":"otp"}]`, **identisch** zur Sitzung nach Bestätigungslink. Es ist also nicht als Recovery erkennbar. | Der `amr`-Weg aus 5.4 entfällt; es gilt R12 (Pfad „Wenn nein“). |
| **A6** | Für den Linkablauf zählt `auth.users.confirmation_sent_at` bzw. `recovery_sent_at`, **nicht** `auth.one_time_tokens.created_at`. | T3 setzt die Spalten an `auth.users` zurück. |
| **A7** | `/otp` und `/magiclink` lassen sich nicht abschalten, und ein neues Konto per `/otp` ist möglich, **wenn** der Aufrufer gültige Metadaten (`data`) mitschickt. Ohne Metadaten blockiert der Trigger (500). | AUTH-CFG-20 (Gateway-Sperre) bleibt nötig; der Trigger ersetzt sie nicht. |
Alle übrigen Annahmen der Spec wurden bestätigt (Details unten).
## Ergebnis je Punkt
### (a) Betreffzeile und Vorlage werten `.Data.locale` aus
- **Annahme:** Vorlage und Betreff lassen sich mit `{{ if and .Data (eq (index .Data "locale") "de") }}` verzweigen; sonst Englisch.
- **Test:** Signup mit `locale` de, en und ohne `locale`; Mails in Mailpit gelesen. Danach `profiles.locale` auf `en` gesetzt (Sync-Trigger) und Passwort-Reset angefordert.
- **Ergebnis: bestätigt.** `de` führte zu deutschem Betreff und Text, `en` und fehlende Angabe zu Englisch. Betreff und Text verzweigen gleich. Nach der Profiländerung auf `en` kam die Reset-Mail englisch (Sync-Trigger wirkt Ende-zu-Ende). `index .Data "locale"` ist sicher, wenn der Schlüssel fehlt (leer, kein Fehler). Die CLI liefert die Vorlagen per interner URL (`GOTRUE_MAILER_TEMPLATES_*=http://…kong:8088/email/…`), wie beim Self-Hosting.
- **Konsequenz B2:** Der Rückfall „zweisprachiger Betreff“ ist nicht nötig. `{{ .Data.locale }}` und `index` funktionieren beide; für Else-Zweig `index` verwenden.
### (b) `type`-Werte für `token_hash`
- **Test:** `POST /verify` mit `token_hash` aus der Mail.
- **Ergebnis: bestätigt.** Bestätigung: `type=email` **und** `type=signup` werden akzeptiert (beide getestet, Sitzung wird ausgestellt, `email_confirmed_at` gesetzt). Reset: `type=recovery`. Ein Token ist nach der Einlösung verbraucht: zweiter Versuch 403 `otp_expired` („Email link is invalid or has expired“).
- **Konsequenz B2/A2/A4:** Bestätigungsvorlage mit `type=email`, Reset mit `type=recovery`. Der Client mappt `otp_expired` (403) auf `linkInvalidOrExpired`, auch für „schon benutzt“.
### (c) Limiter beim Passwort-Login (AUTH-CFG-10)
- **Annahme:** Passwort-Login nutzt den Token-Limiter (150 je 5 min je IP, Burst 30).
- **Test:** Fehlgeschlagene Logins in Schleife. (1) Gegen die CLI über Kong: 600 Logins, 300 Refresh-Aufrufe, 80 Signups, 80 Recovers in wenigen Sekunden, **kein einziges 429**. (2) Gegen den Spike-Container mit `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For` und fester Quell-IP: Login #31 wird mit **429 `over_request_rate_limit`** abgelehnt (Burst 30). Nach 20 s waren wieder 10 Logins möglich (etwa 0,5 pro Sekunde, also 150 je 300 s). Signup: ebenfalls Burst 30, danach 429 (Limiter `RATE_LIMIT_OTP` = 30 je 5 min). Eine zweite Quell-IP war währenddessen nicht betroffen. Die 429-Antwort hat **keinen** `Retry-After`- oder `X-RateLimit-*`-Header, nur `X-Sb-Error-Code: over_request_rate_limit`.
- **Ergebnis: Limiter bestätigt, aber mit schwerwiegender Einschränkung (A2).** Passwort-Login nutzt `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` (150 je 5 min, Burst 30); Signup/Recover/Resend nutzen `GOTRUE_RATE_LIMIT_OTP` (30 je 5 min). **Ohne `GOTRUE_RATE_LIMIT_HEADER` greift keiner der Limiter.** Die CLI setzt den Header nicht und bietet keinen Schalter dafür; lokal und im CI (CLI) gibt es deshalb keine Drosselung. Zusätzlich zeigen die Audit-Logs `ip_address: ""`.
- **Konsequenz:**
- AUTH-CFG-13 ist für Staging/Prod **zwingend** (sonst keine Drosselung von Passwort-Raten, Krit. 7). Der Proxy muss den Header setzen und Client-Werte überschreiben.
- T3/D2: Der Drosselungstest (Krit. 7) kann nicht gegen die CLI laufen. Optionen siehe Frage Q1.
- Die Tabelle AUTH-CFG-10/11: Env-Namen stimmen (`…_TOKEN_REFRESH`, `…_OTP`). Die Aussage „Signups, Recover, Resend teilen sich diesen Limiter“ ist nur für Signup geprüft; `/recover` wurde bei unbekannten Adressen nicht gedrosselt, weil keine Mail ausgelöst wird (nur das IP-Limit gilt, und das greift ohne Header nicht).
### (d) `email_sent` instanzweit?
- **Test:** Spike-Container mit `GOTRUE_RATE_LIMIT_EMAIL_SENT=5`. Sechs Signups mit verschiedenen Adressen und verschiedenen Quell-IPs.
- **Ergebnis: bestätigt, instanzweit.** Signup 1–5 ok, ab Signup 6 429 `over_email_send_rate_limit` („email rate limit exceeded“) für alle IPs. Das gilt auch für `/recover` an bestehende Adressen. In der **lokalen CLI** wirkt `email_sent` nicht: der CLI-Container läuft mit `GOTRUE_RATE_LIMIT_EMAIL_SENT=360000`, obwohl `config.toml` 100 enthielt (die CLI-Doku nennt „Requires auth.email.smtp to be enabled“). Der Standard der CLI-Vorlage (`email_sent = 2`) greift also lokal ebenfalls nicht.
- **Neue Nebenwirkung auf Krit. 4:** Ist das Mail-Limit erschöpft, antwortet `/recover` für eine **bestehende** Adresse mit 429, für eine unbekannte Adresse mit 200 (es wird keine Mail ausgelöst). Das ist ein Enumerations-Seitenkanal während der Sperre. Mögliche Gegenmaßnahmen: Limit höher setzen oder ein Proxy vor GoTrue. Siehe Frage Q2.
- **Konsequenz B2/B3:** AUTH-CFG-09 nur im Env-Block wirksam; in `config.toml` den Wert dokumentieren, aber als „lokal ohne Wirkung“ kennzeichnen. R3 bleibt bestehen.
### (e) Mailfänger
- **Ergebnis:** **Mailpit** v1.31.3 (Container `supabase_inbucket_shuffle`, Name historisch). Web-Oberfläche und API: `http://127.0.0.1:54324`. Relevante Endpunkte: `GET /api/v1/messages` (Liste mit `To[].Address`, `Subject`, `ID`), `GET /api/v1/message/{id}` (`HTML`, `Text`), `DELETE /api/v1/messages`. SMTP-Port nach außen nur mit `smtp_port` in `[local_smtp]`.
- **Konsequenz T3:** Der Mail-Helfer spricht die Mailpit-API. Das Token steht im HTML (und im Text) doppelt; das erste Vorkommen genügt. Mails je Test per `To` filtern (zufällige Adressen), nicht per globalem Löschen, wenn Tests parallel laufen.
### (f) Trigger-Fehler bei `signUp`
- **Test:** Signup ohne `display_name`, ohne Altersbestätigung (`age_confirmed:"false"`), ohne `terms_version`.
- **Ergebnis: abweichend (A4).** HTTP **500**, Header `X-Sb-Error-Code: unexpected_failure`, Body `{"code":"P0001","message":"display_name_invalid"}` (bzw. `age_confirmation_required`, …). Der Text des Triggers wird also durchgereicht. Im GoTrue-Log steht zusätzlich „current transaction is aborted“. Es entsteht kein Nutzer.
- **Konsequenz A1/A2 (Flutter):** `registrationRejected` darf an `AuthException.message` unterscheiden, ist aber nur zum Logging und für Zusatzhinweise gedacht; die Hauptvalidierung bleibt im Client. Das Format ist nicht stabil vertraglich zugesichert (bei GoTrue-Updates erneut prüfen; T3 sichert es mit einem API-Test ab).
### (g) `weak_password`
- **Test:** Signup mit 9 Zeichen.
- **Ergebnis: bestätigt.** HTTP 422, `error_code: weak_password`, `msg: "Password should be at least 10 characters."`, `weak_password.reasons = ["length"]`. Mit `password_requirements = ""` kommt kein Grund `characters`.
- **Konsequenz A1:** Mapping 4.4 stimmt.
### (h) Versionen CLI und Self-Hosting-Image
- **Ergebnis: bestätigt.** CLI 3.0.0 startet GoTrue `v2.197.0`. Die `docker-compose.yml` des Supabase-Repos (Branch `master`, heute) verwendet `supabase/gotrue:v2.197.0`. Das Image `public.ecr.aws/supabase/gotrue:v2.197.0` ließ sich mit den Env-Variablen der CLI direkt starten (zusätzlich erforderlich: `API_EXTERNAL_URL`).
- **Konsequenz B3/D1:** Das Self-Hosting-Image beim Aufsetzen auf `v2.197.0` pinnen (nicht `latest`) und bei jedem Update die Punkte (c), (d), (f) wiederholen. Die Postgres-Version im Compose (17.6.1.136) weicht von der CLI (17.11.0.011) im Patchstand ab; Hauptversion 17 stimmt mit `config.toml` überein.
### (i) Welche Spalte begrenzt die Linkgültigkeit?
- **Test:** Neuen Reset angefordert, nur `auth.users.recovery_sent_at` um 2 h zurückgesetzt, Token eingelöst. Dann neuen Reset, nur `auth.one_time_tokens.created_at` um 2 h zurückgesetzt, Token eingelöst. Für die Bestätigung `confirmation_sent_at` ebenso.
- **Ergebnis: bestätigt für `auth.users`, abweichend von der Tabelle.** Zurückgesetztes `recovery_sent_at` (bzw. `confirmation_sent_at`) ergibt 403 `otp_expired`. Zurückgesetztes `one_time_tokens.created_at` allein hat **keine** Wirkung (Einlösung gelang).
- **Konsequenz T3:** `e2e/support/db.ts` setzt `auth.users.recovery_sent_at` (Reset) bzw. `confirmation_sent_at` (Bestätigung) auf `now() - (otp_expiry + 1 s)`. Direktzugriff als `postgres` (Port 54322) genügt.
### (j) `amr` im Recovery-JWT
- **Test:** Access-Token nach `verify` mit `type=recovery`, nach `verify` mit `type=email` und nach Passwort-Login dekodiert.
- **Ergebnis: abweichend (A5).** Recovery: `amr = [{"method":"otp"}]`, `aal1`. Bestätigung: ebenfalls `otp`. Passwort-Login: `password`. Recovery ist von einer Bestätigungssitzung nicht unterscheidbar; nach dem Passwortwechsel in der Sitzung bleibt der Eintrag gleich.
- **Konsequenz A4/R12:** Pfad „Wenn nein“ aus 5.4: Der Guard `passwordRecovery` lebt nur im laufenden Prozess (Ereignis `passwordRecovery`). Eine Ableitung aus `amr = otp` würde auch frisch bestätigte Konten in den Reset-Screen zwingen und ist nicht zulässig. R12 bleibt als akzeptiertes Restrisiko.
## Weitere geprüfte Annahmen (4.3 und Tabelle 4.1)
| Annahme | Test | Ergebnis |
|---|---|---|
| **Krit. 4, bestätigte Adresse:** 200 mit erfundenem Nutzer, keine Mail | Signup mit bestätigter Adresse, zuerst mit CLI-Standard, dann mit `[auth.sms] enable_confirmations = true` | **Nur mit `GOTRUE_SMS_AUTOCONFIRM=false` bestätigt (A1):** Dann 200, zufällige ID (nicht die des echten Nutzers), `identities` leer, keine Mail, kein neues Konto, das ursprüngliche Passwort bleibt gültig. Mit dem CLI-Standard (`SMS_AUTOCONFIRM=true`) kommt stattdessen 422 `user_already_exists`. |
| **Krit. 4, unbestätigte Adresse:** Mail erneut, Passwort und Metadaten unverändert | Signup mit neuem Passwort und neuer `locale` für unbestätigte Adresse | **Bestätigt:** 200 mit echtem Nutzer, Bestätigungsmail erneut, Passwort-Hash und `user_metadata` unverändert, kein zweites Konto. |
| Resend / Reset immer 200 | `/resend` und `/recover` mit bestätigter, unbestätigter und unbekannter Adresse | **Bestätigt** (200 `{}`); Mail geht nur an eine existierende, passende Adresse. Einschränkung bei Mail-Limit: siehe (d). |
| **Krit. 6/7:** `email_not_confirmed` erst nach richtigem Passwort | Login unbestätigt mit richtigem/falschem Passwort, unbekannte Adresse | **Bestätigt:** richtig → 400 `email_not_confirmed`; falsch und unbekannt → 400 `invalid_credentials`, identische Meldung. |
| **Krit. 8:** Reset-Link einmalig | Zweites Einlösen | **Bestätigt** (403 `otp_expired`). |
| **Krit. 9:** Reset beendet andere Sitzungen | Sitzungen A, B; Reset über Recovery-Sitzung; Passwort geändert | **Bestätigt:** Refresh-Token von A und B → `refresh_token_not_found`. Der alte Access-Token von A wird von GoTrue (`/user`) mit 403 abgelehnt, **von PostgREST aber weiter akzeptiert (200)**, bis er abläuft. Das ist R8 (bis 60 min), bewusst akzeptiert. |
| `same_password` | Reset mit altem Passwort | **Bestätigt:** 422 `same_password`. |
| Passwortmindestlänge 10 | Signup mit 9 Zeichen | **Bestätigt** (siehe g). |
| Benachrichtigungen aus (Krit. 11) | Passwortänderung beobachtet | **Bestätigt:** Es kommt keine Mail. Der CLI-Standard ist „aus“; in `config.toml` trotzdem explizit setzen. |
| Magic Link abschaltbar (AUTH-CFG-20) | `/otp` und `/magiclink` für bestätigten Nutzer; `/otp` für neue Adresse | **Nicht abschaltbar (A7):** Bestätigter Nutzer bekommt Mail „Your sign-in link“. Neue Adresse **ohne** Metadaten: 500 durch den Trigger (`age_confirmation_required`), kein Nutzer. Neue Adresse **mit** gültigen Metadaten: Konto wird angelegt (unbestätigt, mit Passwort-Hash eines Zufallswerts) und eine Bestätigungsmail geht raus. Ein zweiter Aufruf innerhalb von `max_frequency` ergibt 429. |
| Zusätzliche Auffälligkeit | – | `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true` (CLI-Standard) bleibt für KTO-11 offen (AUTH-CFG-23). |
## Konsequenzen für B2, B3 und die übrigen Aufgaben
- **B2:** Alle Werte aus 4.1 in `config.toml`. `email_sent` und die Rate-Limit-Einstellungen sind lokal wirkungslos bzw. nur mit Header wirksam; das im Kommentar kenntlich machen. Vorlagen: `type=email` / `type=recovery`, `index .Data "locale"`.
- **B3:** Der Env-Block für Self-Hosting muss `GOTRUE_RATE_LIMIT_HEADER` als Pflicht aufführen, plus `GOTRUE_RATE_LIMIT_EMAIL_SENT`, `…_TOKEN_REFRESH`, `…_OTP`, `…_VERIFY`. Release-Checkliste: Drosselung am echten Gateway mit 31 Fehllogins einer IP prüfen; Test, dass ein gefälschter `X-Forwarded-For` überschrieben wird.
- **Spec-Anpassungen** (nach Entscheidung von Chris): 4.3 (Krit. 4, Enumeration), 5.4 (`amr`), 7.1 (Krit. 7, 8, 4), 8.2 (B0-Text), R2, R3, R6, R12.
- **B2/B3 (neu, A1):** Zeile AUTH-CFG-24 ergänzen: `auth.sms.enable_confirmations = true` bzw. `GOTRUE_SMS_AUTOCONFIRM=false` (SMS ist ausgeschaltet, die Einstellung steuert trotzdem den Enumerationsschutz bei E-Mail-Signup). T3 sichert das mit einem API-Test für Krit. 4 ab (bestätigte Adresse → 200, `identities` leer).
- **T3:** Drosselungstests nur gegen einen GoTrue mit gesetztem Header und Requests mit Header (Q1).
- **KTO-11-Hinweis:** `user_metadata.locale` kann der Nutzer selbst über `auth.updateUser(data: …)` ändern, die Mailsprache kann so von `profiles.locale` abweichen (Wirkung nur auf das eigene Konto). Der Client ändert die Sprache ausschließlich über `profiles`; der Sync-Trigger zieht `user_metadata` bei der nächsten Profiländerung nach.
## Offene Fragen an Chris
| # | Frage | Hintergrund |
|---|---|---|
| Q1 | **Krit. 7:** Die lokale CLI kann keine IP-Drosselung (kein `RATE_LIMIT_HEADER`). Soll der Drosselungstest im CI gegen einen zusätzlich gestarteten GoTrue-Container mit gesetztem Header laufen (wie in diesem Spike), oder nur als manuelle Prüfung auf Staging? | A2, Aufwand für D2/T3. |
| Q2 | Seitenkanal bei erschöpftem Mail-Limit (`/recover` bestehende Adresse 429, unbekannte 200). Akzeptiert (Limit 30/h, Angriff erzeugt vor allem Ausfälle), oder soll das Limit höher gesetzt bzw. durch einen Proxy ergänzt werden? | (d), R3, R6. |

View file

@ -5,6 +5,7 @@
|---|---|
| `migrations/` | Schema, Trigger und RLS-Policies für R1 (17 Tabellen, 2 Views). Nur additiv ändern, nie bestehende Dateien editieren. |
| `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. |
| `templates/` | Auth-Mailvorlagen (HTML, Go-Template) für Bestätigung und Passwort-Reset. |
| `seed.sql` | Beispieldaten nur für die lokale Entwicklung. |
## Lokal starten und testen
@ -14,6 +15,13 @@ supabase db reset # Migrationen + Seed einspielen
supabase test db # alle pgTAP-Tests ausführen
```
## Registrierung (KTO-01)
- Der Trigger `handle_new_user` erzwingt Altersbestätigung, Zustimmung, `terms_version` und Anzeigename (1–30 Zeichen). Fehlertexte: siehe `docs/datenmodell-r1.md`. Clients senden diese Werte als `user_metadata` bei `signUp`.
- pgTAP-Fixtures, die `auth.users` direkt füllen, brauchen daher `age_confirmed`, `terms_accepted`, `terms_version` und `display_name` in `raw_user_meta_data`.
- Ergebnisse der GoTrue-Prüfung: `docs/specs/1-auth-spike-ergebnisse.md`.
- Auth-Einstellungen (`AUTH-CFG-nn` in `config.toml`), Env-Block für Staging/Produktion, Release-Checkliste und bekannte Grenzen: [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md).
- Mailvorlagen (Bestätigung, Passwort-Reset; de/en nach `user_metadata.locale`): `templates/`. Lokal landen Mails in Mailpit (`http://127.0.0.1:54324`).
## Regeln
- Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security.
- `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich.

View file

@ -103,6 +103,9 @@ openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
# AUTH-CFG-22 (wirkt lokal): Mailfänger ist Mailpit (Container heißt historisch supabase_inbucket_*).
# Web-Oberfläche und API: http://127.0.0.1:54324 (API: /api/v1/messages, /api/v1/message/{id}).
# Staging/Prod: SMTP über GOTRUE_SMTP_* (Anbieter offen, siehe docs/auth-konfiguration.md).
[local_smtp]
enabled = true
# Port to use for the email testing server web interface.
@ -155,36 +158,44 @@ max_indexes = 5
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
# AUTH-CFG-01 (wirkt lokal): Site-URL, Basis der Mail-Links ({{ .SiteURL }}).
# Staging/Prod: GOTRUE_SITE_URL (Prod-Platzhalter https://shuffle.cshomelabs.work).
site_url = "http://localhost:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["http://localhost:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
# AUTH-CFG-02 (wirkt lokal): Redirect-Allow-List, nur die eigene Web-App-Seite für Mail-Links.
# Staging/Prod: GOTRUE_URI_ALLOW_LIST.
additional_redirect_urls = ["http://localhost:3000/auth/confirm"]
# AUTH-CFG-14 (wirkt lokal): JWT-Laufzeit 3600 s. Bestimmt die Verzögerung bei KTO-01 Kriterium 9
# (andere Geräte spätestens nach 60 min abgemeldet, Restrisiko R8). Staging/Prod: GOTRUE_JWT_EXP.
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
# AUTH-CFG-15 (wirkt lokal): Refresh-Token-Rotation an, Wiederverwendungsfenster 10 s.
# Staging/Prod: GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED, GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project. Existing users can still sign in.
# AUTH-CFG-03 (wirkt lokal): Registrierung erlaubt. Staging/Prod: GOTRUE_DISABLE_SIGNUP=false.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
# AUTH-CFG-05 (wirkt lokal): Passwort-Mindestlänge 10 (O10). Staging/Prod: GOTRUE_PASSWORD_MIN_LENGTH.
minimum_password_length = 10
# AUTH-CFG-06 (wirkt lokal): keine Zeichenklassen gefordert. Staging/Prod: GOTRUE_PASSWORD_REQUIRED_CHARACTERS (leer).
password_requirements = ""
# AUTH-CFG-21 (nur Env / nur Self-Hosting): Leaked-Password-Prüfung (HIBP) ist aus (Entscheidung F2).
# Die CLI kennt dafür keinen Schlüssel und führt keine Prüfung aus. Staging/Prod muss
# GOTRUE_PASSWORD_HIBP_ENABLED=false explizit setzen.
# AUTH-CFG-20 (nur Self-Hosting / Gateway): Magic Link und OTP-Login (/auth/v1/otp, /auth/v1/magiclink)
# lassen sich in GoTrue nicht abschalten (Spike A7). Staging/Prod: am Gateway sperren.
# Lokal bewusst nicht gesperrt.
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
@ -195,18 +206,27 @@ password_requirements = ""
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
# Rate Limits. ACHTUNG: Die lokale CLI kann GOTRUE_RATE_LIMIT_HEADER nicht setzen (AUTH-CFG-13).
# Ohne diesen Header gibt es keine IP-Drosselung; lokal und in der CI-CLI wird nichts gedrosselt.
# Die Werte unten sind daher Dokumentation der Zielwerte. Wirksam sind sie nur im Self-Hosting mit
# GOTRUE_RATE_LIMIT_HEADER und Requests, die den Header tragen (Spike B0, A2).
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# AUTH-CFG-09 (nur Env, lokal OHNE Wirkung): Mails pro Stunde, instanzweit. Die CLI setzt intern
# GOTRUE_RATE_LIMIT_EMAIL_SENT=360000 (Spike A3). Lokal/CI-Zielwert 100, Staging/Prod: 30 über
# GOTRUE_RATE_LIMIT_EMAIL_SENT=30.
email_sent = 100
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
# AUTH-CFG-10 (nur mit AUTH-CFG-13 wirksam, lokal ohne Wirkung): Passwort-Login und Token-Refresh,
# 150 je 5 min je IP. Env: GOTRUE_RATE_LIMIT_TOKEN_REFRESH.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
# AUTH-CFG-11 (nur mit AUTH-CFG-13 wirksam, lokal ohne Wirkung): Registrierung, Reset, erneut senden,
# 30 je 5 min je IP. Env: GOTRUE_RATE_LIMIT_OTP.
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
# AUTH-CFG-12 (nur mit AUTH-CFG-13 wirksam, lokal ohne Wirkung): Link-Prüfung, 30 je 5 min je IP.
# Env: GOTRUE_RATE_LIMIT_VERIFY.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
@ -217,22 +237,28 @@ web3 = 30
# provider = "hcaptcha"
# secret = ""
# AUTH-CFG-13 (nur Env / nur Self-Hosting): Client-IP hinter Proxy, GOTRUE_RATE_LIMIT_HEADER
# (z. B. X-Forwarded-For). Pflicht für Staging/Prod, in der CLI nicht setzbar.
[auth.email]
# Allow/disallow the email provider. When false, users can't sign up or sign in with email.
# To block new signups only, set enable_signup = false under [auth] instead.
# AUTH-CFG-03 (wirkt lokal): E-Mail-Anmeldung erlaubt. Staging/Prod: GOTRUE_EXTERNAL_EMAIL_ENABLED=true.
enabled = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
# AUTH-CFG-23 (wirkt lokal, in #1 NICHT festgelegt): CLI-Standard bleibt unverändert; Entscheidung in
# KTO-11 (Info-Mail an die alte Adresse, N2). Staging/Prod: GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
# AUTH-CFG-04 (wirkt lokal): E-Mail-Bestätigung Pflicht (CLI-Standard wäre false). Voraussetzung für den
# Enumerationsschutz zusammen mit AUTH-CFG-24. Staging/Prod: GOTRUE_MAILER_AUTOCONFIRM=false.
enable_confirmations = true
# AUTH-CFG-25 (Spec 9.4): Passwortänderung ohne erneute Anmeldung bleibt wie im Standard.
# Recovery-Sitzungen sind ohnehin ausgenommen; die Entscheidung "aktuelles Passwort verlangen" fällt in KTO-11 (N13).
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
# AUTH-CFG-08 (wirkt lokal): Mindestabstand zwischen Mails je Nutzer. Lokal/CI 1 s (sonst scheitert der
# Resend-Test direkt nach dem Signup mit 429). Staging/Prod: 60 s über GOTRUE_SMTP_MAX_FREQUENCY=60s.
max_frequency = "1s"
# Number of characters used in the email OTP.
# Länge des E-Mail-OTP-Codes (nur der Vollständigkeit halber gesetzt; Shuffle nutzt token_hash-Links).
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
# AUTH-CFG-07 (wirkt lokal): Gültigkeit von Bestätigungs- und Reset-Link, 3600 s (1 h, Entscheidung F3).
# Staging/Prod: GOTRUE_MAILER_OTP_EXP=3600 explizit setzen (das Binary hätte 86400).
otp_expiry = 3600
# Use a production-ready SMTP server
@ -245,23 +271,45 @@ otp_expiry = 3600
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# AUTH-CFG-17 (wirkt lokal): Mailvorlage Bestätigung, de/en per user_metadata.locale (Else-Zweig Englisch).
# Betreff und Text verzweigen gleich (Spike B0 (a)). Staging/Prod: Vorlagen per URL
# (GOTRUE_MAILER_TEMPLATES_CONFIRMATION, GOTRUE_MAILER_SUBJECTS_CONFIRMATION).
[auth.email.template.confirmation]
subject = '{{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}Bestätige deine E-Mail-Adresse für Shuffle{{ else }}Confirm your email address for Shuffle{{ end }}'
content_path = "./supabase/templates/confirmation.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./supabase/templates/password_changed_notification.html"
# AUTH-CFG-18 (wirkt lokal): Mailvorlage Passwort zurücksetzen, wie oben.
# Staging/Prod: GOTRUE_MAILER_TEMPLATES_RECOVERY, GOTRUE_MAILER_SUBJECTS_RECOVERY.
[auth.email.template.recovery]
subject = '{{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}Passwort für Shuffle zurücksetzen{{ else }}Reset your Shuffle password{{ end }}'
content_path = "./supabase/templates/recovery.html"
# AUTH-CFG-19 (wirkt lokal): Benachrichtigungs-Mails explizit aus (KTO-01 Kriterium 11, nur Bestätigung
# und Reset). email_changed schaltet KTO-11 ein (N2). Staging/Prod: GOTRUE_MAILER_NOTIFICATIONS_<TYP>_ENABLED=false.
[auth.email.notification.password_changed]
enabled = false
[auth.email.notification.email_changed]
enabled = false
[auth.email.notification.phone_changed]
enabled = false
[auth.email.notification.identity_linked]
enabled = false
[auth.email.notification.identity_unlinked]
enabled = false
[auth.email.notification.mfa_factor_enrolled]
enabled = false
[auth.email.notification.mfa_factor_unenrolled]
enabled = false
[auth.sms]
# Allow/disallow the phone provider. When false, users can't sign up or sign in with SMS.
# To block new signups only, set enable_signup = false under [auth] instead.
enabled = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# AUTH-CFG-24 (wirkt lokal): SMS ist nicht im Einsatz, diese Einstellung steuert aber den
# Enumerationsschutz beim E-Mail-Signup. Mit false (= GOTRUE_SMS_AUTOCONFIRM=true, CLI-Standard) liefert
# Signup mit bestätigter Adresse 422 user_already_exists (Spike A1). MUSS true bleiben.
# Staging/Prod: GOTRUE_SMS_AUTOCONFIRM=false bzw. ENABLE_PHONE_AUTOCONFIRM=false (Vorlage setzt true!).
enable_confirmations = true
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
@ -272,7 +320,8 @@ max_frequency = "5s"
# [auth.sms.test_otp]
# 14152127777 = "123456"
# Configure logged in session timeouts.
# AUTH-CFG-16 (wirkt lokal): Sitzungsbegrenzung bewusst NICHT gesetzt (KTO-01 Kriterium 5: angemeldet
# bis zum Abmelden). Nicht auskommentieren.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"

View file

@ -0,0 +1,132 @@
-- KTO-01 Registrierung: strengere Regeln beim Anlegen des Profils (Spec #1, § 3.2).
-- Nur additiv: bestehende Migrationen bleiben unverändert.
--
-- 1. display_name: kein Fallback auf das E-Mail-Präfix, kein stilles Kürzen; Länge 1-30 Codepoints
-- nach trim_ws (Dart-kompatibel); U+200B (Zero Width Space) ist nirgends erlaubt. Verstoß -> Registrierung scheitert mit 'display_name_invalid'.
-- 2. terms_version: Version der zugestimmten Rechtstexte (Nachweis); Pflicht für neue Konten.
-- 3. locale: Standard und Trigger-Fallback 'en' (nur 'de'/'en' erlaubt).
-- 4. Sync-Trigger: profiles.locale -> auth.users.raw_user_meta_data.locale (Mailsprache).
-- Trim wie Dart String.trim(): btrim() entfernt nur U+0020, Tab, Zeilenumbruch, NBSP usw. blieben
-- stehen. Explizite Liste (Unicode White_Space + BOM, identisch zur Dart-Doku):
-- U+0009-000D, 0020, 0085, 00A0, 1680, 2000-200A, 2028, 2029, 202F, 205F, 3000, FEFF.
-- Bewusst kein [[:space:]] (locale-abhängig). U+200B (Zero Width Space) ist kein Leerraum und
-- wird hier nicht getrimmt (wie in Dart); im Anzeigenamen ist er stattdessen verboten (siehe
-- profiles_display_name_no_zwsp).
-- immutable + strict (NULL -> NULL), search_path leer, kein security definer. Execute bleibt für
-- public: Der Check-Constraint unten wird mit den Rechten der schreibenden Rolle ausgewertet
-- (authenticated beim Profil-Update); die Funktion ist rein und greift auf keine Daten zu.
create or replace function public.trim_ws(value text)
returns text
language sql
immutable
strict
parallel safe
set search_path = ''
as $$
select pg_catalog.regexp_replace(
value,
'^[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+'
|| '|' ||
'[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+$',
'',
'g'
);
$$;
-- Anzeigename: Leerraum zählt nicht. char_length zählt Zeichen (Codepoints), nicht Bytes.
-- Der Constraint wird direkt (ohne NOT VALID) angelegt: Er prüft sofort den Bestand und bricht die
-- Migration ab, falls ein vorhandener (Entwicklungs-)Name nur aus Leerraum besteht.
alter table public.profiles
add constraint profiles_display_name_not_blank
check (char_length(public.trim_ws(display_name)) between 1 and 30);
-- U+200B (Zero Width Space) ist im Anzeigenamen verboten, egal an welcher Stelle (Entscheidung
-- Chris): Er ist unsichtbar und ermöglicht scheinbar leere oder verwechselbare Namen. U+200C/U+200D
-- (ZWNJ/ZWJ) bleiben erlaubt (ZWJ steckt in Emoji-Sequenzen), U+FEFF wird bereits getrimmt.
-- Eigener Constraint mit klarem Namen; gilt auch für spätere Änderungen (KTO-02).
-- chr(8203) = U+200B; strpos statt Regex, damit nichts locale-abhängig ist.
alter table public.profiles
add constraint profiles_display_name_no_zwsp
check (strpos(display_name, chr(8203)) = 0);
-- Version der Rechtstexte. Nullable nur wegen vorhandener Entwicklungszeilen; für neue Konten
-- erzwingt handle_new_user() einen Wert. Die Spalte steht bewusst NICHT im Spalten-Grant
-- "update" von 20261102100100_profiles.sql: der Nutzer kann sie nicht ändern.
alter table public.profiles
add column terms_version text
check (terms_version is null or char_length(terms_version) between 1 and 50);
-- Ohne Angabe gilt Englisch (QUE-05); der Client sendet immer die aktive Sprache.
alter table public.profiles alter column locale set default 'en';
-- Profil beim Registrieren anlegen (ersetzt die Funktion aus 20261102100100_profiles.sql).
-- security definer nötig: Der Trigger läuft im Kontext von auth.users (Rolle supabase_auth_admin),
-- die Rolle hat keine Rechte auf public.profiles (RLS, keine Insert-Policy für Clients).
-- search_path ist leer, alle Objekte sind schema-qualifiziert.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
meta jsonb := coalesce(new.raw_user_meta_data, '{}'::jsonb);
name text := case when jsonb_typeof(meta -> 'display_name') = 'string'
then public.trim_ws(meta ->> 'display_name') end;
version text := case when jsonb_typeof(meta -> 'terms_version') = 'string'
then public.trim_ws(meta ->> 'terms_version') end;
begin
if coalesce(meta ->> 'age_confirmed', '') <> 'true' then
raise exception 'age_confirmation_required';
end if;
if coalesce(meta ->> 'terms_accepted', '') <> 'true' then
raise exception 'terms_acceptance_required';
end if;
if version is null or char_length(version) not between 1 and 50 then
raise exception 'terms_version_required';
end if;
if name is null or char_length(name) not between 1 and 30 or strpos(name, chr(8203)) > 0 then
raise exception 'display_name_invalid';
end if;
insert into public.profiles
(id, display_name, locale, terms_version, terms_accepted_at, age_confirmed_at)
values (
new.id,
name,
case when meta ->> 'locale' in ('de', 'en') then meta ->> 'locale' else 'en' end,
version,
now(),
now()
);
return new;
end;
$$;
-- Mailsprache folgt dem Profil: GoTrue-Vorlagen sehen nur user_metadata (.Data.locale).
-- security definer nötig: Schreiben auf auth.users ist dem Client verwehrt. Der Trigger
-- ändert ausschließlich die Zeile des eigenen Nutzers (where id = new.id) und nur den Schlüssel
-- 'locale'. search_path ist leer.
create or replace function public.sync_profile_locale_to_auth()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
update auth.users
set raw_user_meta_data =
coalesce(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('locale', new.locale)
where id = new.id;
return new;
end;
$$;
revoke all on function public.sync_profile_locale_to_auth() from public, anon, authenticated;
create trigger sync_locale_to_auth
after update of locale on public.profiles
for each row
when (old.locale is distinct from new.locale)
execute function public.sync_profile_locale_to_auth();

View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
{{- /*
AUTH-CFG-17: Bestätigungs-Mail (KTO-01 Kriterium 1, 11).
Sprache aus user_metadata.locale ("de"), sonst Englisch. Keine externen Ressourcen, keine Bilder.
Der Link führt auf die App-Seite /auth/confirm; erst der Klick dort löst die Prüfung aus (Spec E3).
Gültigkeit "1 Stunde" im Text entspricht AUTH-CFG-07 (GOTRUE_MAILER_OTP_EXP=3600); bei Änderung hier anpassen.
Der Link (inkl. TokenHash) ist sichtbar, damit er kopiert werden kann; der Code {{ .Token }} wird bewusst nicht angezeigt.
*/ -}}
{{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Bestätige deine E-Mail-Adresse für Shuffle</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">
<div style="max-width:560px;margin:0 auto;padding:24px;background-color:#ffffff;">
<h1 style="margin:0 0 16px 0;font-size:22px;line-height:1.3;color:#1a1a1a;">Bestätige deine E-Mail-Adresse</h1>
<p style="margin:0 0 16px 0;">Willkommen bei Shuffle! Bitte bestätige deine E-Mail-Adresse, damit dein Konto genutzt werden kann.</p>
<p style="margin:0 0 24px 0;">
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email" style="display:inline-block;padding:14px 24px;background-color:#1d4ed8;color:#ffffff;font-weight:bold;text-decoration:none;border-radius:6px;">E-Mail-Adresse bestätigen</a>
</p>
<p style="margin:0 0 8px 0;">Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden.</p>
<p style="margin:0 0 16px 0;">Falls der Knopf nicht funktioniert, kopiere diese Adresse in die Adresszeile deines Browsers:</p>
<p style="margin:0 0 24px 0;word-break:break-all;"><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email" style="color:#1d4ed8;">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email</a></p>
<p style="margin:0;color:#4b5563;">Wenn du das nicht warst, ignoriere diese E-Mail. Es wird dann nichts geändert.</p>
</div>
</body>
</html>
{{ else }}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Confirm your email address for Shuffle</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">
<div style="max-width:560px;margin:0 auto;padding:24px;background-color:#ffffff;">
<h1 style="margin:0 0 16px 0;font-size:22px;line-height:1.3;color:#1a1a1a;">Confirm your email address</h1>
<p style="margin:0 0 16px 0;">Welcome to Shuffle! Please confirm your email address so you can use your account.</p>
<p style="margin:0 0 24px 0;">
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email" style="display:inline-block;padding:14px 24px;background-color:#1d4ed8;color:#ffffff;font-weight:bold;text-decoration:none;border-radius:6px;">Confirm email address</a>
</p>
<p style="margin:0 0 8px 0;">The link is valid for 1 hour and can only be used once.</p>
<p style="margin:0 0 16px 0;">If the button does not work, copy this address into your browser's address bar:</p>
<p style="margin:0 0 24px 0;word-break:break-all;"><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email" style="color:#1d4ed8;">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email</a></p>
<p style="margin:0;color:#4b5563;">If this wasn't you, you can ignore this email. Nothing will be changed.</p>
</div>
</body>
</html>
{{ end }}

View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
{{- /*
AUTH-CFG-18: Passwort-zurücksetzen-Mail (KTO-01 Kriterium 8, 11).
Sprache aus user_metadata.locale ("de"), sonst Englisch. Keine externen Ressourcen, keine Bilder.
Der Link führt auf die App-Seite /auth/confirm; erst der Klick dort löst die Prüfung aus (Spec E3).
*/ -}}
{{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Passwort für Shuffle zurücksetzen</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">
<div style="max-width:560px;margin:0 auto;padding:24px;background-color:#ffffff;">
<h1 style="margin:0 0 16px 0;font-size:22px;line-height:1.3;color:#1a1a1a;">Passwort zurücksetzen</h1>
<p style="margin:0 0 16px 0;">Für dein Shuffle-Konto wurde ein neues Passwort angefordert. Mit dem folgenden Knopf legst du ein neues Passwort fest.</p>
<p style="margin:0 0 24px 0;">
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery" style="display:inline-block;padding:14px 24px;background-color:#1d4ed8;color:#ffffff;font-weight:bold;text-decoration:none;border-radius:6px;">Neues Passwort festlegen</a>
</p>
<p style="margin:0 0 8px 0;">Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden.</p>
<p style="margin:0 0 16px 0;">Falls der Knopf nicht funktioniert, kopiere diese Adresse in die Adresszeile deines Browsers:</p>
<p style="margin:0 0 24px 0;word-break:break-all;"><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery" style="color:#1d4ed8;">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery</a></p>
<p style="margin:0;color:#4b5563;">Wenn du das nicht warst, ignoriere diese E-Mail. Dein Passwort bleibt dann unverändert.</p>
</div>
</body>
</html>
{{ else }}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reset your Shuffle password</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">
<div style="max-width:560px;margin:0 auto;padding:24px;background-color:#ffffff;">
<h1 style="margin:0 0 16px 0;font-size:22px;line-height:1.3;color:#1a1a1a;">Reset your password</h1>
<p style="margin:0 0 16px 0;">A new password was requested for your Shuffle account. Use the button below to choose a new password.</p>
<p style="margin:0 0 24px 0;">
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery" style="display:inline-block;padding:14px 24px;background-color:#1d4ed8;color:#ffffff;font-weight:bold;text-decoration:none;border-radius:6px;">Set a new password</a>
</p>
<p style="margin:0 0 8px 0;">The link is valid for 1 hour and can only be used once.</p>
<p style="margin:0 0 16px 0;">If the button does not work, copy this address into your browser's address bar:</p>
<p style="margin:0 0 24px 0;word-break:break-all;"><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery" style="color:#1d4ed8;">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery</a></p>
<p style="margin:0;color:#4b5563;">If this wasn't you, you can ignore this email. Your password will stay the same.</p>
</div>
</body>
</html>
{{ end }}

View file

@ -1,6 +1,6 @@
-- Profile: Registrierung, Zugriff, Schutz der Spalten, Zeitstempel.
begin;
select plan(16);
select plan(15);
-- Registrierung: ohne Altersbestätigung oder Zustimmung scheitert sie
select throws_ok(
@ -14,17 +14,13 @@ select throws_ok(
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com',
'{"age_confirmed":"true","terms_accepted":"true","display_name":"Anna","locale":"en"}'),
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna","locale":"en"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com',
'{"age_confirmed":"true","terms_accepted":"true"}'),
('33333333-3333-3333-3333-333333333333', 'cara@example.com',
'{"age_confirmed":"true","terms_accepted":"true","display_name":"123456789012345678901234567890-zu-lang"}');
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}');
select is((select count(*) from public.profiles), 3::bigint, 'Profil wird bei der Registrierung angelegt');
select is((select count(*) from public.profiles), 2::bigint, 'Profil wird bei der Registrierung angelegt');
select is((select display_name from public.profiles where id = '22222222-2222-2222-2222-222222222222'),
'ben', 'Ohne Anzeigename dient der Teil vor dem @ der E-Mail');
select is((select char_length(display_name) from public.profiles where id = '33333333-3333-3333-3333-333333333333'),
30, 'Zu langer Anzeigename wird auf 30 Zeichen gekürzt');
'Ben', 'Anzeigename wird aus der Registrierung übernommen (Regeln der Registrierung: 05_auth_registration)');
select is((select locale from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
'en', 'Sprache aus der Registrierung wird übernommen');

View file

@ -9,8 +9,8 @@ delete from public.mechanics;
delete from public.categories;
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true"}');
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}');
insert into public.games (id, title, visibility, bgg_id) values
('a0000000-0000-0000-0000-000000000001', 'Catan', 'catalog', 13);

View file

@ -3,8 +3,8 @@ begin;
select plan(18);
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true"}');
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}');
insert into public.games (id, title, visibility) values
('a0000000-0000-0000-0000-000000000001', 'Catan', 'catalog'),

View file

@ -3,10 +3,10 @@ begin;
select plan(39);
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Anna"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Ben"}'),
('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Cara"}'),
('44444444-4444-4444-4444-444444444444', 'dan@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Dan"}');
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'),
('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}'),
('44444444-4444-4444-4444-444444444444', 'dan@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Dan"}');
update public.profiles set city = 'Hannover' where id = '11111111-1111-1111-1111-111111111111';
update public.profiles set city = 'Bielefeld', city_visible = false where id = '22222222-2222-2222-2222-222222222222';

View file

@ -0,0 +1,275 @@
-- KTO-01 Registrierung (Datenbankseite): Regeln des Triggers handle_new_user, Anzeigename,
-- Nachweis der Zustimmung (terms_version), Sprache und Mailsprache-Sync.
begin;
select plan(67);
-- ---------------------------------------------------------------------------------------------
-- KTO-01 Kriterium 1: gültige Registrierung legt ein Profil an
-- ---------------------------------------------------------------------------------------------
select lives_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"2026-11-draft","display_name":" Anna ","locale":"de"}')$$,
'KTO-01 Kriterium 1: gültige Registrierung wird angenommen');
select is((select display_name from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
'Anna', 'KTO-01 Kriterium 1: Anzeigename wird getrimmt gespeichert');
select is((select locale from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
'de', 'KTO-01 Kriterium 11: Sprache aus der Registrierung wird übernommen');
select is((select terms_version from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
'2026-11-draft', 'KTO-01 Kriterium 1: terms_version wird gespeichert (QUE-04 Nachweis)');
select ok((select terms_accepted_at is not null and age_confirmed_at is not null
and terms_accepted_at = now() and age_confirmed_at = now()
from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
'KTO-01 Kriterium 1: Zeitpunkte von Zustimmung und Altersbestätigung sind gesetzt');
-- Der Client sendet JSON-Booleans (kein String): beide Formen müssen gelten.
select lives_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values
('88888888-8888-8888-8888-888888888888', 'bool@example.com',
'{"age_confirmed":true,"terms_accepted":true,"terms_version":"v1","display_name":"Bool"}')$$,
'KTO-01 Kriterium 1: JSON-Booleans true werden angenommen');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'k2c@example.com',
'{"age_confirmed":false,"terms_accepted":true,"terms_version":"v1","display_name":"X"}')$$,
'P0001', 'age_confirmation_required', 'KTO-01 Kriterium 2: JSON-Boolean age_confirmed=false wird abgelehnt');
-- ---------------------------------------------------------------------------------------------
-- KTO-01 Kriterium 2: Mindestalter muss bestätigt sein
-- ---------------------------------------------------------------------------------------------
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'k2a@example.com',
'{"age_confirmed":"false","terms_accepted":"true","terms_version":"v1","display_name":"X"}')$$,
'P0001', 'age_confirmation_required', 'KTO-01 Kriterium 2: age_confirmed=false wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'k2b@example.com',
'{"terms_accepted":"true","terms_version":"v1","display_name":"X"}')$$,
'P0001', 'age_confirmation_required', 'KTO-01 Kriterium 2: fehlende Altersbestätigung wird abgelehnt');
select is((select count(*) from auth.users where email like 'k2%@example.com'), 0::bigint,
'KTO-01 Kriterium 2: abgelehnte Registrierung legt kein Konto an');
-- ---------------------------------------------------------------------------------------------
-- QUE-04 Kriterium 1: ausdrückliche Zustimmung
-- ---------------------------------------------------------------------------------------------
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'q1@example.com',
'{"age_confirmed":"true","terms_accepted":"false","terms_version":"v1","display_name":"X"}')$$,
'P0001', 'terms_acceptance_required', 'QUE-04 Kriterium 1: terms_accepted=false wird abgelehnt');
-- terms_version Pflicht (Nachweis F5)
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv1@example.com',
'{"age_confirmed":"true","terms_accepted":"true","display_name":"X"}')$$,
'P0001', 'terms_version_required', 'QUE-04 Nachweis: fehlende terms_version wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv2@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"","display_name":"X"}')$$,
'P0001', 'terms_version_required', 'QUE-04 Nachweis: leere terms_version wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv3@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":" ","display_name":"X"}')$$,
'P0001', 'terms_version_required', 'QUE-04 Nachweis: terms_version nur aus Leerzeichen wird abgelehnt');
select throws_ok(
format($$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv4@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"%s","display_name":"X"}')$$, repeat('v', 51)),
'P0001', 'terms_version_required', 'QUE-04 Nachweis: terms_version mit 51 Zeichen wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv6@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"\u00a0","display_name":"X"}')$$,
'P0001', 'terms_version_required', 'QUE-04 Nachweis: terms_version nur aus NBSP wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv7@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":5,"display_name":"X"}')$$,
'P0001', 'terms_version_required', 'QUE-04 Nachweis: terms_version als Zahl wird abgelehnt');
select lives_ok(
format($$insert into auth.users (id, email, raw_user_meta_data) values
('55555555-5555-5555-5555-555555555555', 'tv5@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":" %s ","display_name":"X"}')$$, repeat('v', 50)),
'QUE-04 Nachweis: terms_version mit 50 Zeichen (getrimmt) wird angenommen');
select is((select char_length(terms_version) from public.profiles where id = '55555555-5555-5555-5555-555555555555'),
50, 'QUE-04 Nachweis: terms_version wird getrimmt gespeichert');
-- ---------------------------------------------------------------------------------------------
-- KTO-01 Kriterium 10: Anzeigename 1-30 Zeichen, streng
-- ---------------------------------------------------------------------------------------------
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n1@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: fehlender Anzeigename wird abgelehnt (kein E-Mail-Präfix)');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n2@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":""}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: leerer Anzeigename wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n3@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":" "}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename nur aus Leerzeichen wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n4@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":null}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename null wird abgelehnt');
select throws_ok(
format($$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n5@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"%s"}')$$, repeat('a', 31)),
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename mit 31 Zeichen wird abgelehnt (kein Kürzen)');
select throws_ok(
format($$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n6@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"%s"}')$$, repeat('😀', 31)),
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: 31 Emoji (Codepoints) werden abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n9@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"\t"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename nur aus Tab wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n10@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"\n"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename nur aus Zeilenumbruch wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n11@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"\u00a0"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename nur aus NBSP wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n12@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":5}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename als Zahl wird abgelehnt');
select is((select count(*) from auth.users where email ~ '^n[0-9]+@example.com$'), 0::bigint,
'KTO-01 Kriterium 10: abgelehnte Registrierungen legen kein Konto an');
select lives_ok(
format($$insert into auth.users (id, email, raw_user_meta_data) values
('66666666-6666-6666-6666-666666666666', 'n7@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"%s"}')$$, repeat('😀', 30)),
'KTO-01 Kriterium 10: 30 Emoji (30 Codepoints, 120 Bytes) werden angenommen');
select lives_ok(
format($$insert into auth.users (id, email, raw_user_meta_data) values
('77777777-7777-7777-7777-777777777777', 'n8@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":" %s "}')$$, repeat('b', 30)),
'KTO-01 Kriterium 10: 30 Zeichen mit umgebenden Leerzeichen werden nach dem Trim angenommen');
select is((select display_name from public.profiles where id = '77777777-7777-7777-7777-777777777777'),
repeat('b', 30), 'KTO-01 Kriterium 10: der Name wird ohne Leerraum gespeichert');
select lives_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values
('99999999-9999-9999-9999-999999999999', 'n13@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"Anna\t"}')$$,
'KTO-01 Kriterium 10: Anzeigename mit Tab am Ende wird angenommen');
select is((select display_name from public.profiles where id = '99999999-9999-9999-9999-999999999999'),
'Anna', 'KTO-01 Kriterium 10: Tab wird entfernt, gespeichert wird Anna');
-- U+200B (Zero Width Space) ist im Anzeigenamen verboten; U+200C/U+200D bleiben erlaubt.
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'z1@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"​"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename nur aus U+200B wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'z2@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"An​na"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: U+200B mitten im Namen wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'z3@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"Anna​"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: U+200B am Ende wird abgelehnt (nicht getrimmt)');
select is((select count(*) from auth.users where email ~ '^z[0-9]+@example.com$'), 0::bigint,
'KTO-01 Kriterium 10: abgelehnte Namen mit U+200B legen kein Konto an');
-- Familien-Emoji: Mann, ZWJ, Frau, ZWJ, Mädchen, ZWJ, Junge = 7 Codepoints; ZWJ (U+200D) bleibt erlaubt
select lives_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values
('abababab-abab-abab-abab-abababababab', 'z4@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"👨‍👩‍👧‍👦"}')$$,
'KTO-01 Kriterium 10: Familien-Emoji mit ZWJ (7 Codepoints) wird angenommen');
select is((select char_length(display_name) from public.profiles where id = 'abababab-abab-abab-abab-abababababab'),
7, 'KTO-01 Kriterium 10: ZWJ bleibt erhalten (7 Codepoints)');
-- Check-Constraint (gilt auch für spätere Änderungen, KTO-02)
select ok(exists (select 1 from pg_constraint
where conrelid = 'public.profiles'::regclass and conname = 'profiles_display_name_not_blank'
and contype = 'c' and convalidated),
'KTO-01 Kriterium 10: Constraint profiles_display_name_not_blank existiert und ist validiert');
select throws_ok(
$$update public.profiles set display_name = ' ' where id = '11111111-1111-1111-1111-111111111111'$$,
'23514', null, 'KTO-01 Kriterium 10: Constraint lehnt Anzeigename nur aus Leerzeichen ab (Update)');
select throws_ok(
$$update public.profiles set display_name = chr(160) where id = '11111111-1111-1111-1111-111111111111'$$,
'23514', null, 'KTO-02 Kriterium 1: Constraint lehnt Anzeigename nur aus NBSP ab (Update)');
select throws_ok(
$$update public.profiles set display_name = 'An' || chr(8203) || 'na' where id = '11111111-1111-1111-1111-111111111111'$$,
'23514', 'new row for relation "profiles" violates check constraint "profiles_display_name_no_zwsp"',
'KTO-02 Kriterium 1: Constraint lehnt Anzeigename mit U+200B ab (Update)');
-- ---------------------------------------------------------------------------------------------
-- KTO-01 Kriterium 11: Sprache
-- ---------------------------------------------------------------------------------------------
select lives_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values
('22222222-2222-2222-2222-222222222222', 'ben@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"Ben"}'),
('33333333-3333-3333-3333-333333333333', 'cara@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"Cara","locale":"fr"}')$$,
'KTO-01 Kriterium 11: Registrierungen ohne und mit ungültiger Sprache werden angenommen');
select is((select locale from public.profiles where id = '22222222-2222-2222-2222-222222222222'),
'en', 'KTO-01 Kriterium 11: ohne locale gilt Englisch');
select is((select locale from public.profiles where id = '33333333-3333-3333-3333-333333333333'),
'en', 'KTO-01 Kriterium 11: ungültige locale fällt auf Englisch zurück');
select col_default_is('public', 'profiles', 'locale', 'en', 'KTO-01 Kriterium 11: Spalten-Standard ist en');
-- Sync profiles.locale -> auth.users.raw_user_meta_data.locale
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '11111111-1111-1111-1111-111111111111'),
'de', 'KTO-01 Kriterium 11: Ausgangszustand der Mail-Sprache (Anna de)');
-- Marker setzen: Eine Änderung anderer Spalten darf den Sync nicht auslösen
update auth.users set raw_user_meta_data = raw_user_meta_data || '{"locale":"xx"}'
where id = '11111111-1111-1111-1111-111111111111';
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select lives_ok($$update public.profiles set bio = 'nur Bio' where id = '11111111-1111-1111-1111-111111111111'$$,
'KTO-01 Kriterium 11: Anna darf ihre Bio ändern');
reset role;
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '11111111-1111-1111-1111-111111111111'),
'xx', 'KTO-01 Kriterium 11: Änderung einer anderen Spalte löst den Sync nicht aus');
set local role authenticated;
select lives_ok($$update public.profiles set locale = 'en' where id = '11111111-1111-1111-1111-111111111111'$$,
'KTO-01 Kriterium 11: Anna darf ihre Sprache ändern');
select is_empty($$update public.profiles set locale = 'de' where id = '22222222-2222-2222-2222-222222222222' returning 1$$,
'KTO-01 Kriterium 11: Anna kann die Sprache von Ben nicht ändern');
select throws_ok($$update auth.users set raw_user_meta_data = '{}' where id = '11111111-1111-1111-1111-111111111111'$$,
'42501', null, 'KTO-01 Kriterium 11: Nutzer kann auth.users nicht direkt schreiben');
select throws_ok($$select public.sync_profile_locale_to_auth()$$,
'42501', null, 'KTO-01 Kriterium 11: Sync-Funktion ist für Nutzer nicht aufrufbar');
select throws_ok($$select public.handle_new_user()$$,
'42501', null, 'KTO-01 Kriterium 1: handle_new_user ist für Nutzer nicht aufrufbar');
-- Sicherheit: Zustimmungsnachweis ist für den Nutzer unveränderlich
select throws_ok($$update public.profiles set terms_version = 'gefälscht' where id = '11111111-1111-1111-1111-111111111111'$$,
'42501', null, 'KTO-01 Sicherheit: Nutzer kann terms_version nicht ändern');
select throws_ok($$update public.profiles set terms_accepted_at = now() where id = '11111111-1111-1111-1111-111111111111'$$,
'42501', null, 'KTO-01 Sicherheit: Nutzer kann terms_accepted_at nicht ändern');
select is((select terms_version from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
'2026-11-draft', 'KTO-01 Sicherheit: Nutzer kann seine terms_version lesen');
select is((select count(*) from public.profiles), 1::bigint, 'KTO-01 Sicherheit: Nutzer sieht nur das eigene Profil');
reset role;
select set_config('request.jwt.claims', '', true);
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '11111111-1111-1111-1111-111111111111'),
'en', 'KTO-01 Kriterium 11: Profilsprache wird in die Mail-Sprache übernommen');
select is((select raw_user_meta_data ->> 'display_name' from auth.users where id = '11111111-1111-1111-1111-111111111111'),
' Anna ', 'KTO-01 Kriterium 11: übrige Metadaten bleiben beim Sync erhalten');
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '22222222-2222-2222-2222-222222222222'),
null, 'KTO-01 Kriterium 11: Metadaten fremder Konten bleiben unverändert');
-- anon darf die Trigger-Funktionen nicht ausführen
set local role anon;
select throws_ok($$select public.handle_new_user()$$,
'42501', null, 'KTO-01 Sicherheit: anon darf handle_new_user nicht ausführen');
select throws_ok($$select public.sync_profile_locale_to_auth()$$,
'42501', null, 'KTO-01 Sicherheit: anon darf sync_profile_locale_to_auth nicht ausführen');
reset role;
-- Update auf denselben Wert löst keinen Sync aus (Cara: locale en, Marker in den Metadaten)
update auth.users set raw_user_meta_data = raw_user_meta_data || '{"locale":"xx"}'
where id = '33333333-3333-3333-3333-333333333333';
update public.profiles set locale = 'en' where id = '33333333-3333-3333-3333-333333333333';
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '33333333-3333-3333-3333-333333333333'),
'xx', 'KTO-01 Kriterium 11: Update auf denselben Wert löst den Sync nicht aus');
select * from finish();
rollback;