6 Commits

Author SHA1 Message Date
schalli 55aa287296 docs(quick-260918-gza): Fehlermeldung — Herkunft ausweisen (Browser/Desktop-App, OS, App-Version)
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m6s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m43s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m7s
Plan, Ausfuehrungsbericht, Verifikation (9/9 must_haves) und Aktenstand;
lokaler Nachweis per Playwright/mailhog fuer Browser- und Desktop-Marker-Fall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
2026-09-18 12:48:01 +02:00
schalli e2a79467df docs: Fehlermeldungen — Herkunft (Browser/Desktop-App, Betriebssystem, Version) und Betreff-Kürzel (Handbücher, CHANGELOG)
Damit Betreiber im Postfach sofort erkennen, ob eine Meldung aus einem
Browser oder der Desktop-App kommt (und mit welchem Stand), beschreiben
Administrationshandbuch und Betriebshandbuch das neue Betreff-Kürzel und
die Zeile "Herkunft"; das Betriebshandbuch erklaert zusaetzlich den Fall
eines alten Desktop-Clients ohne Details.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
2026-09-18 12:37:07 +02:00
schalli f2457113a2 feat(web): Herkunft der Fehlermeldung — Cookie tessera_desktop_client und Client-Felder in der Nutzlast
Die Middleware liest jetzt zusaetzlich dv/dc/dos aus der Anfrage und legt
daraus das Cookie tessera_desktop_client an (bereinigt per Muster, nur
wenn alle drei Werte gueltig sind); desktop-client.ts liest es zurueck.
Der Fehler-melden-Dialog fuellt daraus vier neue Nutzlastfelder
(clientKind/clientOs/clientVersion/clientCommit), damit die API die
Herkunft der Meldung ausweisen kann. Ohne das zweite Cookie (alter
Client) bleibt es bei "Desktop-App (unbekannt)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
2026-09-18 12:36:19 +02:00
schalli b03cb211b3 feat(desktop): Version, Stand und Betriebssystem im Desktop-Marker mitgeben (dv, dc, dos)
with_desktop_marker haengte bisher nur desktop=1 an die erste Navigation;
der Fehler-melden-Knopf konnte deshalb Windows- und Linux-Client nicht
unterscheiden. with_client_marker ist die neue reine Kernfunktion (dv,
dc, dos zusaetzlich zu desktop=1), with_desktop_marker bleibt als Huelle
mit den echten env!-Werten die unveraenderte Aufrufstelle an allen drei
Navigationen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
2026-09-18 12:36:10 +02:00
schalli 716947228e feat(bug-reports): Herkunft der Fehlermeldung im Betreff-Kuerzel und als Zeile Herkunft ausweisen
WebView2 (Windows) sieht im User-Agent aus wie Edge, WebKitGTK (Linux) wie
Safari — im Postfach war eine Client-Meldung von einer Browser-Meldung
nicht zu unterscheiden. Neuer reiner Helfer origin.ts leitet aus vier
optionalen DTO-Feldern (Desktop-App) bzw. dem User-Agent (Browser) ein
Betreff-Kuerzel und eine Zeile "Herkunft: ..." ab; rein informativ,
laengenbegrenzt, nichts wird gespeichert (T-GZA-01). Browser-Pfad ist
damit Ende-zu-Ende fertig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
2026-09-18 12:32:29 +02:00
schalli ab99a9ab5d docs: Wiedereinstieg 2026-09-18 — Handoff verbraucht, naechster Auftrag (Herkunft in Fehlermeldung)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
2026-09-18 12:12:28 +02:00
24 changed files with 1411 additions and 149 deletions
-68
View File
@@ -1,68 +0,0 @@
---
context: default
phase: betrieb-nach-1.2.0
task: null
total_tasks: 0
status: paused
last_updated: 2026-09-18T10:06:16.288Z
---
# Wiedereinstieg — v1.2.0 live, Beta mit In-App-Updater, nichts angefangen
## Critical Anti-Patterns
Alle aus tatsaechlichen Fehlschlaegen dieser und der vorigen Sitzungen.
| Muster | Beschreibung | Schwere | Vermeidung |
|--------|--------------|---------|------------|
| Alter Client als Beleg | User meldete „Beenden geht nicht" — sein installierter Client war ein Stand VOR dem Fix (alpha lieferte bis Mittag 280aab6). | blocking | Zuerst den installierten Stand feststellen (Setup-Seite zeigt seit a6d1a64 „Tessera-App X.Y.Z · Stand <sha7>"; Tray-Menue „Auf Beta-Stand … aktualisieren" = alt), dann erst am Code suchen. |
| Plugin-Timeout gilt fuer den Download | `updater_builder().timeout(15s)` wird vom Plugin auch fuer `download_and_install` uebernommen — 100-MB-AppImage waere abgebrochen. | blocking | Vor `download_and_install` `update.timeout = Some(600 s)` setzen (so gebaut); bei Aenderungen am Update-Fluss nicht anfassen. |
| Radio-Klick im NSIS verfehlt | „Bereits installiert"-Seite: Klick auf „Nicht deinstallieren" (465,401) ging einmal daneben → Deinstallation lief zuerst (Daten blieben). | advisory | Nach jedem Installer-Schritt Screenshot pruefen statt Klickfolge blind abzuspulen. |
| Hintergrund-Tunnel stirbt | `run_in_background` beendet den socat-Tunnel nach 10 min; `pkill -f socat` traf die eigene Shell. | advisory | `setsid -f socat …` (Port 8017) und `kill <pid>` gezielt. |
| Zwei Schreiber auf einer Datei | Executoren strikt nacheinander; Planer nur lesend, nie committen. | advisory | CHANGELOG/i18n: nur Zeilen ergaenzen, eigene Namensraeume. |
| `git checkout -- <Datei>` als Rueckweg | setzte auch unkommittierte Nutz-Aenderung zurueck. | advisory | Patchdatei + `git apply -R`. |
<current_state>
**Gemessen 2026-09-18 10:06Z:** `git status --porcelain` leer, `main == origin/main` (efbd6e8), keine async-jobs, nichts angefangen.
**Beta (alpha.tessera.ctl.de):** a6d1a64 (User hat gepullt) — mit In-App-Updater, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Bildmarke in Akzentfarbe, Versionszeile auf der Setup-Seite.
**Live (tessera.ctl.de):** v1.2.0 — OHNE Updater; kommt mit der naechsten Freigabe (1.3.0, neue Funktionen), auf Zuruf.
**CHANGELOG `## Unveröffentlicht`:** gefuellt (Neu/Geändert/Behoben) — bei Freigabe umbenennen (Kap. 9).
**Signierschluessel:** `~/.tessera/desktop-updater/` (Dev-Rechner), Gitea-Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD`; vom User extern gesichert.
**Tests:** Web 431 / API 1101+23 / Rust 35. **Ledger WINDOWS.md:** unveraendert 15 offen (nur #35/#36/#37 ohne Mandantenbezug).
</current_state>
<completed_work>
Sitzung 2026-09-17/18: sechs Auftraege des Users komplett — Quick 260917-jdf (Bildmarke), -jdh (CI-Skip), -jdd (Favoriten), -jn2 (Server-Adresse), -kgc (Update in der App), Schnellfix a6d1a64 (Versionszeile); jeweils Plan → Pruefer → Executor → Verifier → Nachweis (Playwright lokal, CI-Laeufe 382-385, Windows-Test-VM: In-App-Update 7479cb4 → a6d1a64 per Tray-Klick). Details: Quick-Task-Tabelle in `.planning/STATE.md`, Berichte unter `.planning/quick/260917-*/`.
</completed_work>
<remaining_work>
Nichts Angefangenes. Naechste Arbeit kommt vom User. Ohne Termin: Freigabe 1.3.0 (bringt Updater auf Live), Ship Phase 17 (blockiert bei open_count 15), Ledger #35/#36/#37. Nicht ansprechen: Mandantenfaehigkeit, Lizenzierung.
</remaining_work>
<decisions_made>
Siehe `HANDOFF.json` — Updater-Regeln (nur https, Beta-Commit-Vergleich, Live gleicher Basis = kein Update), Schluesselablage, CI-Stempel, Favoriten-TLS nur an der Fetch-Stelle.
</decisions_made>
<blockers>
Keine. Fuer den User: eigenen Client einmal per Browser-Installer erneuern (alter Stand ohne Beenden-Fix/Updater).
</blockers>
## Required Reading (in order)
1. `.planning/STATE.md` — Session Continuity + Quick-Task-Tabelle (Zeilen 260917-jdf … kgc, fast)
2. `CHANGELOG.md` — Regel: jede Aenderung sofort unter `## Unveröffentlicht`
3. `docs/anleitung-betrieb.md` Kap. 9 (Freigabe) und Kap. 10 (Desktop-Pakete, Signierschluessel, Skip)
4. Claude-Memory `reference_windows_test_vm.md` — VM-Bedienung inkl. Klickkoordinaten, Paketbezug aus `api:beta`
## Infrastructure State
- Live tessera.ctl.de (`IMAGE_TAG=live`, v1.2.0); Beta alpha 192.168.13.12 (a6d1a64). Deploy macht der User (pull + `up -d --force-recreate api web`).
- Lokal: `db`, `api`, `web` laufen (web/api aus 4d48543); Admin admin/admin123; DB ohne Host-Port.
- Gitea 1.26.2 + Runner auf diesem Rechner (ein Auftrag gleichzeitig); CI ueber API localhost:3002 (Token aus Push-URL, nie ausgeben); Skript `scratchpad/ci-wait.sh <sha7>` wartet auf einen Lauf (Scratchpad ist sitzungsgebunden — bei Bedarf neu anlegen).
- Windows-Test-VM 8233: Client a6d1a64 installiert, mit alpha verbunden; Tunnel/Dateiserver beendet.
- Registry-Abbild `localhost:3002/schalli/tessera-ctl/api:beta` enthaelt die CI-Pakete (`/app/desktop-dist/`) — per `docker cp` holbar.
<next_action>
`/gsd-resume-work`, dann das, was der User nennt — als `/gsd-quick --validate` mit voller Kette (Planer lesend, Executoren nacheinander, Nachweis per Playwright/VM, dann Push). Bei „Version freigeben": Rezept Kap. 9; dabei 18-UAT #2 (Release-Anhaenge am Tag) beobachten.
</next_action>
-37
View File
@@ -1,37 +0,0 @@
{
"version": "1.0",
"timestamp": "2026-09-18T10:06:16.288Z",
"phase": "18",
"phase_name": "desktop-client-fertigstellen (abgeschlossen) — Betrieb nach 1.2.0, Beta-Pflege per Quick-Tasks",
"phase_dir": ".planning/quick/",
"plan": 0,
"task": 0,
"total_tasks": 0,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "260917-jdf Bildmarke: ganzes T in Akzentfarbe", "status": "done", "commit": "ecff144,29db4c0"},
{"id": 2, "name": "260917-jdh CI: Desktop-Bau ueberspringen bei unveraendertem Stand", "status": "done", "commit": "8c4aaa5,e7633e1"},
{"id": 3, "name": "260917-jdd Favoriten: Symbol bei Zertifikatsfehler/intern, Sortierung", "status": "done", "commit": "2a562d0,b18ac25,b023d6f"},
{"id": 4, "name": "260917-jn2 Desktop: Server-Adresse sichtbar + aenderbar", "status": "done", "commit": "29c132e,4c79874,4d48543"},
{"id": 5, "name": "260917-kgc Desktop: Update in der App (signiert)", "status": "done", "commit": "678ba51,de81c74,7004b5b,7479cb4"},
{"id": 6, "name": "Schnellfix: Setup-Seite zeigt Version + Stand", "status": "done", "commit": "a6d1a64"},
{"id": 7, "name": "Aktenstand + alle Nachweise (Browser, CI 382-385, Windows-VM)", "status": "done", "commit": "efbd6e8"}
],
"remaining_tasks": [],
"blockers": [],
"async_jobs": [],
"human_actions_pending": [
{"action": "Eigenen Arbeitsplatz-Client einmal per Browser-Installer (alpha) erneuern", "context": "Alter Stand ohne Beenden-Fix und ohne Updater; ab dann Updates per Tray-Klick", "blocking": false},
{"action": "Naechste Freigabe (1.3.0) auf Zuruf — bringt den Updater auf Live", "context": "Live tessera.ctl.de steht auf v1.2.0 ohne Updater; Rezept docs/anleitung-betrieb.md Kap. 9", "blocking": false}
],
"decisions": [
{"decision": "In-App-Update mit tauri-plugin-updater und minisign-signierten Paketen; nur https; Beta-Regel: gleiche Basisversion + anderer Commit-Stempel = neuer, Live gleicher Basis = kein Update", "rationale": "Robuster Install/Neustart unter Windows, Signatur als Vertrauensbasis, keine Update-Schleife", "phase": "quick-260917-kgc"},
{"decision": "Signierschluessel liegt ausserhalb des Repos unter ~/.tessera/desktop-updater/ (Dev-Rechner), als Gitea-Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD; User hat ihn am 18.09. extern gesichert", "rationale": "Verlust = alle Clients einmal per Installer; Secrets sind nicht auslesbar", "phase": "quick-260917-kgc"},
{"decision": "CI-Desktop-Skip: Stempel = Version + letzter Commit auf apps/desktop + Skripte + ci.yml; Tags bauen immer; check-Gate verlangt Manifest/Signatur", "rationale": "Docs-/Web-Pushes sparen 5-8 min Rust-Bau; Fail-safe bei kaputtem Cache", "phase": "quick-260917-jdh"},
{"decision": "Favoriten: TLS-Fehler nur an der Icon-Fetch-Stelle toleriert (undici-Dispatcher), SSRF-Schutz unveraendert; interne Hosts per Browser-Direktbild", "rationale": "Kein serverseitiges Aufweichen des SSRF-Schutzes", "phase": "quick-260917-jdd"},
{"decision": "Mandantenfaehigkeit und Lizenzierung ruhen weiterhin — nicht ansprechen", "rationale": "User-Entscheidung 2026-09-14", "phase": "-"}
],
"uncommitted_files": [],
"next_action": "/gsd-resume-work; dann das, was der User nennt (als /gsd-quick --validate mit voller Kette: Planer → Pruefer → Executor → Verifier → Browser/VM-Nachweis → Push). Bei 'Version freigeben': Rezept Kap. 9 (CHANGELOG umbenennen, Tag; Release-Anhaenge + Update-Hinweis im Client beobachten).",
"context_notes": "Alles committet und gepusht (main == origin/main efbd6e8), Arbeitsbaum leer. Beta alpha.tessera.ctl.de laeuft mit a6d1a64 (User hat gepullt), Live mit v1.2.0. Lokaler Docker-Stack (web/api aus 4d48543, db) laeuft; Playwright-Browser noch auf der Proxmox-noVNC-Seite; socat-Tunnel und http.server beendet. Windows-Test-VM 8233: Client a6d1a64 installiert, mit alpha verbunden. CI-Skip in allen drei Faellen bewiesen (Laeufe 382/383/384 bauen, 385 ueberspringt in 18 s)."
}
+8 -7
View File
@@ -4,10 +4,10 @@ milestone: v1.2
current_phase: 18 current_phase: 18
current_phase_name: desktop-client-fertigstellen current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "Quick-Task 260917-jn2 abgeschlossen (3 Commits: feat(desktop), feat(web), docs); Windows-VM-Bedienprobe steht durch Orchestrator aus" stopped_at: "Quick 260918-gza abgeschlossen (4 Commits + Akte), lokal nachgewiesen; VM-Probe nach alpha-Deploy offen"
last_updated: "2026-09-17T12:55:59.549Z" last_updated: "2026-09-18T10:50:00.000Z"
last_activity: 2026-09-18 last_activity: 2026-09-18
last_activity_desc: Sechs Auftraege nach 1.2.0 gebaut, gepusht, getestet — u. a. Update in der Desktop-App (signiert), CI-Desktop-Skip, Favoriten-Symbol/-Sortierung last_activity_desc: Quick 260918-gza — Fehlermeldung weist Herkunft aus (Browser/Desktop-App, OS, App-Version); lokal nachgewiesen, VM-Probe nach alpha-Deploy offen
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2 state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress: progress:
total_phases: 18 total_phases: 18
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-18 - Quick 260917-kgc (Update in der App) + Schnellfix a6d1a64 abgeschlossen; alle Nachweise erbracht (lokaler Browser, CI 382-384, Windows-VM: In-App-Update 7479cb4 → a6d1a64) Last activity: 2026-09-18 - Quick 260918-gza (Fehlermeldung: Herkunft ausweisen) abgeschlossen und lokal nachgewiesen (mailhog: [Browser] / [Desktop/Windows]); Windows-VM-Probe mit echtem Client nach alpha-Deploy offen
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -442,6 +442,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260917-jdd | **Favoriten-Widget: Symbol trotz Zertifikatsfehler/interner Adresse, Favoriten sortierbar.** API: `undici@7.28.0` (exakt, war schon im Lockfile) — `LENIENT_TLS_AGENT` (`rejectUnauthorized: false`) als Dispatcher NUR in `fetchWithRedirectGuard`, SSRF-Schutz (DNS/private IPs/Redirects/Timeouts/Deckel) byteweise unveraendert; `PUT /favorites/order` `{widgetId, ids}` VOR den `:id`-Routen, `reorder()` in `withTenantTransaction` mit `userId`+`widgetId` je Eintrag, eine 400-Meldung; Icon-Proxy mit `nosniff` + CSP sandbox. Web: `FavoriteIcon` Kette Proxy-Bild → bei Fehler Direktbild `{origin}/favicon.ico` (nur http/https, no-referrer) → Buchstabe; Pfeile „Nach oben/unten“ im Bearbeitungsmodus, optimistisch + Reload bei Fehler; Altbestand `position 0` normalisiert sich beim ersten Klick. Befund: `discoverFavoriteIconUrl` liefert nie null (immer Origin-Rueckfall) — deshalb haengt der Browser-Ersatzweg am Bildfehler. API 1101 / Web 429 Tests. Browser: `self-signed.badssl.com` → Proxy-Symbol; `http://192.168.13.11:3002` → Proxy 502 → Direktbild; Sortierung ueber Reload, DB-Positionen 0..3. Verifikation 15/15 + Browser. | 2026-09-17 | 2a562d0,b18ac25,b023d6f | [260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u](./quick/260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u/) | | 260917-jdd | **Favoriten-Widget: Symbol trotz Zertifikatsfehler/interner Adresse, Favoriten sortierbar.** API: `undici@7.28.0` (exakt, war schon im Lockfile) — `LENIENT_TLS_AGENT` (`rejectUnauthorized: false`) als Dispatcher NUR in `fetchWithRedirectGuard`, SSRF-Schutz (DNS/private IPs/Redirects/Timeouts/Deckel) byteweise unveraendert; `PUT /favorites/order` `{widgetId, ids}` VOR den `:id`-Routen, `reorder()` in `withTenantTransaction` mit `userId`+`widgetId` je Eintrag, eine 400-Meldung; Icon-Proxy mit `nosniff` + CSP sandbox. Web: `FavoriteIcon` Kette Proxy-Bild → bei Fehler Direktbild `{origin}/favicon.ico` (nur http/https, no-referrer) → Buchstabe; Pfeile „Nach oben/unten“ im Bearbeitungsmodus, optimistisch + Reload bei Fehler; Altbestand `position 0` normalisiert sich beim ersten Klick. Befund: `discoverFavoriteIconUrl` liefert nie null (immer Origin-Rueckfall) — deshalb haengt der Browser-Ersatzweg am Bildfehler. API 1101 / Web 429 Tests. Browser: `self-signed.badssl.com` → Proxy-Symbol; `http://192.168.13.11:3002` → Proxy 502 → Direktbild; Sortierung ueber Reload, DB-Positionen 0..3. Verifikation 15/15 + Browser. | 2026-09-17 | 2a562d0,b18ac25,b023d6f | [260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u](./quick/260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u/) |
| 260917-jn2 | **Desktop-Client: Server-Adresse sichtbar und nachtraeglich aenderbar.** Rust: `TrayIconBuilder::with_id("main")`, `TrayItems { connected, update }` in `app.manage`, `apply_server()` setzt Tooltip `Tessera – {host}` + gesperrte Menuezeile `Verbunden mit {host}` an einer Stelle; Tray-Eintrag `Server-Adresse ändern…` navigiert zu `setup_page_url()` (`http://tauri.localhost/setup.html` unter Windows, sonst `tauri://localhost/setup.html`); Commands `get_server_url`/`open_server` (kein Capability-Eintrag noetig — Remote-Origin darf keine Commands rufen); `parse_server_url` (nur http/https) gemeinsam; `spawn_version_check` herausgezogen, `update`-Klick liest Adresse per `stored_server_url` beim Klick. setup.html: Vorbelegung, „Aktuell verbunden mit“, „Abbrechen“. Web: Einstellungen → Desktop-App zeigt im Client „Verbunden mit: {origin}“ + Hinweis (`settings.desktop.*`). 18 Rust-Tests, Web 431. Browser: Web-Block mit Cookie bestaetigt. Verifikation human_needed: **Windows-VM-Probe mit CI-Paket offen** (Tooltip, Menuezeile, Adresse aendern/Abbrechen, Wechsel ohne Neustart). | 2026-09-17 | 29c132e,4c79874,4d48543 | [260917-jn2-desktop-client-aktuelle-server-adresse-s](./quick/260917-jn2-desktop-client-aktuelle-server-adresse-s/) | | 260917-jn2 | **Desktop-Client: Server-Adresse sichtbar und nachtraeglich aenderbar.** Rust: `TrayIconBuilder::with_id("main")`, `TrayItems { connected, update }` in `app.manage`, `apply_server()` setzt Tooltip `Tessera – {host}` + gesperrte Menuezeile `Verbunden mit {host}` an einer Stelle; Tray-Eintrag `Server-Adresse ändern…` navigiert zu `setup_page_url()` (`http://tauri.localhost/setup.html` unter Windows, sonst `tauri://localhost/setup.html`); Commands `get_server_url`/`open_server` (kein Capability-Eintrag noetig — Remote-Origin darf keine Commands rufen); `parse_server_url` (nur http/https) gemeinsam; `spawn_version_check` herausgezogen, `update`-Klick liest Adresse per `stored_server_url` beim Klick. setup.html: Vorbelegung, „Aktuell verbunden mit“, „Abbrechen“. Web: Einstellungen → Desktop-App zeigt im Client „Verbunden mit: {origin}“ + Hinweis (`settings.desktop.*`). 18 Rust-Tests, Web 431. Browser: Web-Block mit Cookie bestaetigt. Verifikation human_needed: **Windows-VM-Probe mit CI-Paket offen** (Tooltip, Menuezeile, Adresse aendern/Abbrechen, Wechsel ohne Neustart). | 2026-09-17 | 29c132e,4c79874,4d48543 | [260917-jn2-desktop-client-aktuelle-server-adresse-s](./quick/260917-jn2-desktop-client-aktuelle-server-adresse-s/) |
| 260917-kgc | **Desktop-Client: Update in der App (tauri-plugin-updater, signierte Pakete).** Client: Plugin 2.11 + `semver`, `plugins.updater.pubkey` (minisign; privater Schluessel + Passwort NUR unter `~/.tessera/desktop-updater/` auf dem Dev-Rechner, Gitea-Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD`), Endpunkt zur Laufzeit `{server}/api-proxy/desktop/update?target&arch&current&base`, `is_update_newer` (hoehere Basis → Update; gleiche Basis nur bei `beta.g<sha7>` mit anderem Commit; kleinere/gleiche Live → nichts), Pruefung 15 s / Download 600 s (Plugin-Timeout gilt fuer beides), Tray „Auf Version X / Beta-Stand <sha7> aktualisieren" → Fortschritt → passiver NSIS-Installer startet die App neu (Linux: `app.restart()`), Fehler → Benachrichtigung + Download-Seite im Browser, `http://` → gesperrt „Update nur über https möglich". API: `GET /desktop/update` (statisch VOR `download/:platform`, `base` nur Origin, 204 ohne `signature`/`updateVersion`). CI: `createUpdaterArtifacts`, Secrets nur an den zwei `tauri build`-Schritten, `desktop-collect.sh` schreibt `signature` + `updateVersion` (`X.Y.Z-beta.g<sha7>`), `desktop-stamp.sh check` verlangt beides. 33 Rust-Tests, 23 API-Tests. **Nachweise erbracht:** CI baut `.sig` fuer beide Plattformen (Cross-Bau rustls ok); alpha-Endpunkt 200/400; Windows-VM: Client 7479cb4 → Tray-Klick → Neustart als a6d1a64, Adresse erhalten. Bereits installierte Clients (≤ 1.2.0) brauchen einmal den Browser-Installer. | 2026-09-17 | 678ba51,de81c74,7004b5b,7479cb4 | [260917-kgc-desktop-client-update-in-der-app-herunte](./quick/260917-kgc-desktop-client-update-in-der-app-herunte/) | | 260917-kgc | **Desktop-Client: Update in der App (tauri-plugin-updater, signierte Pakete).** Client: Plugin 2.11 + `semver`, `plugins.updater.pubkey` (minisign; privater Schluessel + Passwort NUR unter `~/.tessera/desktop-updater/` auf dem Dev-Rechner, Gitea-Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD`), Endpunkt zur Laufzeit `{server}/api-proxy/desktop/update?target&arch&current&base`, `is_update_newer` (hoehere Basis → Update; gleiche Basis nur bei `beta.g<sha7>` mit anderem Commit; kleinere/gleiche Live → nichts), Pruefung 15 s / Download 600 s (Plugin-Timeout gilt fuer beides), Tray „Auf Version X / Beta-Stand <sha7> aktualisieren" → Fortschritt → passiver NSIS-Installer startet die App neu (Linux: `app.restart()`), Fehler → Benachrichtigung + Download-Seite im Browser, `http://` → gesperrt „Update nur über https möglich". API: `GET /desktop/update` (statisch VOR `download/:platform`, `base` nur Origin, 204 ohne `signature`/`updateVersion`). CI: `createUpdaterArtifacts`, Secrets nur an den zwei `tauri build`-Schritten, `desktop-collect.sh` schreibt `signature` + `updateVersion` (`X.Y.Z-beta.g<sha7>`), `desktop-stamp.sh check` verlangt beides. 33 Rust-Tests, 23 API-Tests. **Nachweise erbracht:** CI baut `.sig` fuer beide Plattformen (Cross-Bau rustls ok); alpha-Endpunkt 200/400; Windows-VM: Client 7479cb4 → Tray-Klick → Neustart als a6d1a64, Adresse erhalten. Bereits installierte Clients (≤ 1.2.0) brauchen einmal den Browser-Installer. | 2026-09-17 | 678ba51,de81c74,7004b5b,7479cb4 | [260917-kgc-desktop-client-update-in-der-app-herunte](./quick/260917-kgc-desktop-client-update-in-der-app-herunte/) |
| 260918-gza | **Fehlermeldung: Herkunft ausweisen (Browser/Desktop-App, Betriebssystem, App-Version).** Betreff traegt direkt nach `[Tessera Fehlermeldung]` ein Kuerzel `[Browser]` / `[Desktop/Windows]` / `[Desktop/Linux]` (`[Desktop]` bei altem Client ohne Details); Mailtext bekommt die Zeile `Herkunft:` — Browser: `Browser — <Name> <Hauptversion> auf <OS>` aus dem User-Agent (reine Regex-Helfer `origin.ts`, keine Abhaengigkeit), Desktop: `Desktop-App (<OS>), Tessera-App <Version> · Stand <Commit>`. Kette: Rust `with_client_marker` haengt neben `desktop=1` die Parameter `dv`/`dc`/`dos` an (drei Aufrufstellen unveraendert, nach In-App-Update automatisch frisch) → Middleware setzt Cookie `tessera_desktop_client` = `<dv>|<dc>|<dos>` (musterbereinigt, nur wenn alle drei da) → `getDesktopClientInfo()` → vier optionale DTO-Felder `clientKind/clientOs/clientVersion/clientCommit` (whitelist deklariert, alte Web-Baue/Clients bleiben gueltig) → `describeOrigin()`. Rohe Zeilen `Browser:`/`Fenster:` bleiben; Kuerzel auch in der einen Protokollzeile; nichts in DB, `main.ts` unangetastet (T-GZA-01..04). Tests: API 1124 (origin 10 neu), Web 447, Rust 37, Typecheck sauber. Plan-Pruefer und Verifier bestanden (9/9 must_haves). **Nachweise lokal (mailhog):** Browser → `[Browser] … Herkunft: Browser — Chrome 154 auf Linux`; Desktop-Marker wie der Rust-Client (`?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows`) → Cookie `1.2.0%7Ca6d1a64%7Cwindows`, `[Desktop/Windows] … Herkunft: Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64`. **Offen:** Windows-VM-Probe mit echtem Client nach CI-Bau und alpha-Deploy durch den User. | 2026-09-18 | 7169472,b03cb21,f245711,e2a7946 | [260918-gza-fehlermeldung-herkunft-ausweisen-browser](./quick/260918-gza-fehlermeldung-herkunft-ausweisen-browser/) |
| fast | **Desktop-Client: Setup-Seite zeigt Version und Stand der App** („Tessera-App 1.2.0 · Stand a6d1a64"; ohne Stempel nur Version) — Command `get_client_info`, Helfer `client_info_label` (2 Tests), `<p id="client-info">` in setup.html, CHANGELOG. Diente zugleich als zweiter Desktop-Stand fuer den Update-Nachweis. 35 Rust-Tests. | 2026-09-18 | a6d1a64 | — | | fast | **Desktop-Client: Setup-Seite zeigt Version und Stand der App** („Tessera-App 1.2.0 · Stand a6d1a64"; ohne Stempel nur Version) — Command `get_client_info`, Helfer `client_info_label` (2 Tests), `<p id="client-info">` in setup.html, CHANGELOG. Diente zugleich als zweiter Desktop-Stand fuer den Update-Nachweis. 35 Rust-Tests. | 2026-09-18 | a6d1a64 | — |
## Deferred Items ## Deferred Items
@@ -484,8 +485,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity ## Session Continuity
Last session: 2026-09-18T09:30:00Z Last session: 2026-09-18T10:20:00Z
Resumed: 2026-09-17 — Sitzung ueber /gsd-resume-work fortgesetzt; sechs Auftraege des Users komplett abgearbeitet (Quick 260917-jdf/jdh/jdd/jn2/kgc + Schnellfix a6d1a64), alle mit Plan-Pruefung, Verifikation und Nachweis (lokaler Browser via Playwright, CI-Laeufe 382-384, Windows-Test-VM 8233). Resumed: 2026-09-17 — Sitzung ueber /gsd-resume-work fortgesetzt; sechs Auftraege des Users komplett abgearbeitet (Quick 260917-jdf/jdh/jdd/jn2/kgc + Schnellfix a6d1a64), alle mit Plan-Pruefung, Verifikation und Nachweis (lokaler Browser via Playwright, CI-Laeufe 382-384, Windows-Test-VM 8233).
Stopped at: Alles committet und gepusht. Nichts angefangen. Beta (alpha) laeuft mit a6d1a64 (User hat gepullt). Live (tessera.ctl.de) steht auf v1.2.0 — der dortige Client hat den Updater noch nicht; erst die naechste Freigabe (1.3.0, neue Funktionen) bringt ihn; danach aktualisieren sich Clients per Tray-Klick. Offen fuer den User: eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern (alter Stand ohne Beenden-Fix/Updater). Signierschluessel: ~/.tessera/desktop-updater/ (Dev-Rechner) — sichern; Verlust = neue Clients per Installer noetig. Letzter offener CI-Beweis (Docs-Push ueberspringt den Desktop-Bau) = der Lauf zu diesem Aktenstand-Commit. Beide .continue-here-Dateien entfernt (ueberholt), Desktop-Todo geschlossen. Stopped at: Quick 260918-gza fertig (4 Code-Commits + Akte), lokal per Playwright/mailhog nachgewiesen (Browser-Fall und Desktop-Marker-Fall). Offen: Push + CI-Lauf abwarten; danach User pullt alpha (web+api noetig fuer Middleware-Cookie und DTO-Felder), Windows-VM-Client aktualisiert sich per Tray-Klick → dort echte Fehlermeldung schicken und Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile im Postfach/API-Log pruefen. Weiterhin offen fuer den User: eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf.
Resume file: None Resume file: None
Last activity: 2026-09-18 - Quick 260917-kgc (Update in der App) + Schnellfix a6d1a64 abgeschlossen; alle Nachweise erbracht (lokaler Browser, CI 382-384, Windows-VM: In-App-Update 7479cb4 → a6d1a64) Last activity: 2026-09-18 - Quick 260918-gza (Fehlermeldung: Herkunft ausweisen) abgeschlossen und lokal nachgewiesen (mailhog: [Browser] / [Desktop/Windows]); Windows-VM-Probe mit echtem Client nach alpha-Deploy offen
@@ -0,0 +1,280 @@
---
phase: quick-260918-gza
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260918-GZA]
files_modified:
- apps/api/src/bug-reports/origin.ts
- apps/api/src/bug-reports/origin.spec.ts
- apps/api/src/bug-reports/dto/bug-report.dto.ts
- apps/api/src/bug-reports/bug-reports.service.ts
- apps/api/src/bug-reports/bug-reports.service.spec.ts
- apps/api/src/bug-reports/bug-reports.controller.spec.ts
- apps/desktop/src-tauri/src/lib.rs
- apps/web/src/middleware.ts
- apps/web/src/middleware.test.ts
- apps/web/src/lib/desktop-client.ts
- apps/web/src/lib/desktop-client.test.ts
- apps/web/src/lib/bug-report-api.ts
- apps/web/src/lib/bug-report-api.test.ts
- apps/web/src/components/bug-report/bug-report-dialog.tsx
- apps/web/src/components/bug-report/bug-report-button.test.tsx
- CHANGELOG.md
- docs/anleitung-administration.md
- docs/anleitung-betrieb.md
estimate:
tokens: 95000
raw_tokens: 95000
tasks: 3
confidence: low
must_haves:
truths:
- "Der Betreff jeder Fehlermeldungs-E-Mail traegt direkt nach `[Tessera Fehlermeldung]` ein Herkunfts-Kuerzel: `[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]` (oder `[Desktop]`, wenn ein alter Client kein Betriebssystem meldet) — so lassen sich Meldungen im Postfach sortieren und filtern."
- "Der Mailtext enthaelt eine Zeile `Herkunft: …`: im Browser `Herkunft: Browser — <Browser> <Hauptversion> auf <Betriebssystem>` (aus dem User-Agent abgeleitet, unbekannte Teile als `unbekannt`), in der Desktop-App `Herkunft: Desktop-App (<Windows|Linux>), Tessera-App <Version> · Stand <Commit>` (ohne `· Stand …`, wenn der Commit leer ist — dieselbe Regel wie `client_info_label`)."
- "Die bestehenden Zeilen `Browser: <User-Agent>` und `Fenster: <BxH>` bleiben unveraendert erhalten; der rohe User-Agent bleibt in der Mail."
- "Ein alter Desktop-Client (nur `desktop=1`, ohne Zusatzparameter) und ein alter Web-Bau (ohne die vier neuen Felder) erzeugen weiterhin eine gueltige Meldung: fehlende Felder fallen serverseitig auf `[Browser]` bzw. auf `Desktop-App (unbekannt)` zurueck, kein 400."
- "Der Desktop-Client meldet Version, Commit und Betriebssystem bei jeder seiner drei Navigationen zur Server-Adresse mit (`dv`, `dc`, `dos` neben dem unveraenderten `desktop=1`); nach einem In-App-Update steht der neue Stand damit automatisch in der naechsten Meldung."
- "Nichts davon wird in der Datenbank gespeichert; `main.ts` und die Body-Limits bleiben unangetastet (T-M97-03); die Werte dienen ausschliesslich der Anzeige in der Mail und dem Kuerzel in der einen bestehenden Protokollzeile (T-GZA-01)."
artifacts:
- "apps/api/src/bug-reports/origin.ts — NEU: reine Helfer `parseUserAgent(ua)` -> `{ browser, os }` und `describeOrigin(input)` -> `{ tag, line }` (nur Regex, keine Abhaengigkeit)"
- "apps/api/src/bug-reports/origin.spec.ts — NEU: mindestens 8 Faelle (Edge/Windows, Chrome/Windows, Firefox/Linux, Safari/macOS, Android, iPad, WebKitGTK-UA mit clientKind desktop -> `[Desktop/Linux]`, Desktop ohne Commit, Desktop ohne Details, fehlende Felder -> Browser-Rueckfall)"
- "apps/api/src/bug-reports/dto/bug-report.dto.ts — vier optionale Felder `clientKind`, `clientOs`, `clientVersion`, `clientCommit`"
- "apps/api/src/bug-reports/bug-reports.service.ts — Betreff mit Kuerzel, Zeile `Herkunft:` vor `Browser:`, Kuerzel in der Protokollzeile"
- "apps/desktop/src-tauri/src/lib.rs — `with_client_marker(url, version, commit, os)` (rein, getestet) + `with_desktop_marker(url)` als Huelle mit `env!`-Werten; drei Aufrufstellen unveraendert"
- "apps/web/src/middleware.ts — `withDesktopCookie` setzt zusaetzlich Cookie `tessera_desktop_client` = `<dv>|<dc>|<dos>` (bereinigt, nur wenn alle drei Parameter vorhanden und gueltig)"
- "apps/web/src/lib/desktop-client.ts — `DESKTOP_CLIENT_COOKIE_NAME`, `parseDesktopClientCookie(cookieString)` (rein) und `getDesktopClientInfo()` -> `{ version, commit, os } | null`"
- "apps/web/src/lib/bug-report-api.ts — `BugReportPayload` um `clientKind`, `clientOs`, `clientVersion`, `clientCommit` erweitert, vier FormData-Felder"
- "apps/web/src/lib/bug-report-api.test.ts — NEU: FormData-Felder fuer Desktop- und Browser-Nutzlast, Netzwerkfehler -> `{ ok: false, status: 0 }`"
- "apps/web/src/components/bug-report/bug-report-dialog.tsx — `handleSend` fuellt die vier Felder aus `isDesktopClient()`/`getDesktopClientInfo()`"
- "CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md — Herkunft und Betreff-Kuerzel beschrieben"
key_links:
- "Rust `with_desktop_marker` (drei Aufrufstellen: setup ~536, `save_server_url` ~476, `open_server` ~500) -> Query `desktop=1&dv=…&dc=…&dos=…` -> Next.js-Middleware `withDesktopCookie` -> Cookies `tessera_desktop=1` (wie bisher) und `tessera_desktop_client` (neu)"
- "Cookie `tessera_desktop_client` -> `getDesktopClientInfo()` in desktop-client.ts -> `handleSend` in bug-report-dialog.tsx -> FormData-Felder in `sendBugReport` -> `BugReportDto` (whitelist verlangt die Deklaration!) -> `describeOrigin()` in origin.ts -> Betreff-Kuerzel + Zeile `Herkunft:` in bug-reports.service.ts"
- "Rueckwaertskompatibilitaet: `@IsOptional()` an allen vier DTO-Feldern + Rueckfall `browser` im Dienst; globale Pipe hat KEIN `forbidNonWhitelisted` (gemessen in apps/api/src/main.ts Z. 17-20) -> neue Web-Felder gegen eine alte API werden still verworfen, alte Web-Baue gegen die neue API liefern `undefined`"
---
<objective>
Fehlermeldungen des Fehler-melden-Knopfs (quick-260914-m97) weisen ihre Herkunft aus: Browser oder Desktop-App, Betriebssystem, bei der Desktop-App zusaetzlich Version und Commit-Stand. Der Betreff bekommt direkt nach `[Tessera Fehlermeldung]` ein kurzes Kuerzel (`[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]`), der Text eine Zeile `Herkunft: …`. Heute sieht eine Meldung aus WebView2 (Windows) wie Edge und aus WebKitGTK (Linux) wie Safari aus — im Postfach ist nicht erkennbar, ob ein Client oder ein Browser gemeldet hat.
Technischer Ansatz (nach Empfehlung des Orchestrators, keine Abweichung): Der bestehende Marker-/Cookie-Mechanismus aus quick-260917-h2s wird erweitert statt den WebView-User-Agent zu ueberschreiben. Der Rust-Client haengt neben `desktop=1` die Parameter `dv` (CARGO_PKG_VERSION), `dc` (APP_COMMIT, darf leer sein) und `dos` (`std::env::consts::OS`) an; die Middleware legt daraus ein zweites, bereinigtes Cookie `tessera_desktop_client` an; der Web-Client liest es und schickt vier neue Multipart-Felder; die API leitet Kuerzel und Herkunftszeile in einem reinen, eigens getesteten Helfer ab. Alles rein informativ, nichts wird gespeichert.
Purpose: Der Betreiber erkennt am Betreff sofort, ob eine Meldung aus einem Client (und welchem Betriebssystem, welcher App-Version) oder aus einem Browser kommt — und kann das Postfach danach sortieren.
Output: Neue Datei `origin.ts` + Spec in der API; erweiterte DTO/Service/Specs; Rust-Marker mit Zusatzparametern; Middleware-Cookie; Web-Helfer + Nutzlastfelder; CHANGELOG und Handbuecher.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/quick/260914-m97-fehler-melden-knopf-bildschirmfoto-der-a/260914-m97-SUMMARY.md
Quelldateien (alle zur Planungszeit vollstaendig gelesen; Aenderungsumfang ist auf diese Pfade begrenzt):
@apps/api/src/bug-reports/dto/bug-report.dto.ts
@apps/api/src/bug-reports/bug-reports.service.ts
@apps/api/src/bug-reports/bug-reports.service.spec.ts
@apps/api/src/bug-reports/bug-reports.controller.spec.ts
@apps/desktop/src-tauri/src/lib.rs
@apps/web/src/middleware.ts
@apps/web/src/middleware.test.ts
@apps/web/src/lib/desktop-client.ts
@apps/web/src/lib/desktop-client.test.ts
@apps/web/src/lib/bug-report-api.ts
@apps/web/src/components/bug-report/bug-report-dialog.tsx
@apps/web/src/components/bug-report/bug-report-button.test.tsx
<planning_measurements>
Zur Planungszeit gemessen — der Executor braucht das nicht neu herzuleiten:
- **Cookie-Kodierung:** Next.js 15.5 (`next/dist/compiled/@edge-runtime/cookies`) serialisiert Cookie-Werte mit `encodeURIComponent`. `res.cookies.set('tessera_desktop_client', '1.2.0|a6d1a64|windows', …)` erzeugt den Header `tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; Path=/; …`. Im Browser steht deshalb in `document.cookie` die KODIERTE Form. Der Parser in `desktop-client.ts` muss `decodeURIComponent` (in try/catch) anwenden, bevor er an `|` trennt. `res.cookies.get(name)?.value` in Middleware-Tests liefert bereits den dekodierten Wert.
- **Globale ValidationPipe** (`apps/api/src/main.ts` Z. 17-20): `whitelist: true, transform: true`, KEIN `forbidNonWhitelisted`. Folge: Ein neuer Web-Bau gegen eine alte API verliert die vier Felder still (kein 400); ein alter Web-Bau gegen die neue API liefert `undefined` — beide Deploy-Reihenfolgen sind sicher, solange alle vier DTO-Felder `@IsOptional()` tragen.
- **Baseline-Tests:** `pnpm --filter @tessera/api exec vitest run src/bug-reports` -> 11/11 gruen (8 Service + 3 Controller). `pnpm --filter @tessera/web exec vitest run src/lib/desktop-client.test.ts src/middleware.test.ts` -> 11/11 gruen (6 + 5). `bug-report-button.test.tsx` hat 11 Tests. Rust: 33 Tests laut STATE (kgc), `cargo test --lib` im Verzeichnis `apps/desktop/src-tauri` (target/ existiert, inkrementell).
- **Biome:** installiert (2.5.0), aber laut Ledger #35 (STATE.md, 260914-ebg) im Bestand nicht lauffaehig — KEIN Biome-Gate in diesem Plan. Formatierung von Hand am Bestand orientieren (2 Leerzeichen, einfache Anfuehrungszeichen, Zeilen bis 100).
- **Skripte:** `type-check` = `tsc --noEmit` in beiden Apps (`pnpm --filter @tessera/api type-check`, `pnpm --filter @tessera/web type-check`). Paketnamen `@tessera/api`, `@tessera/web`. Kein Paketmanager-Install noetig (keine neue Abhaengigkeit; `class-validator` 0.15 liefert `IsIn`).
- **Docs-Stellen:** `docs/anleitung-administration.md` Z. 208 beschreibt den Mailinhalt und den Betreff `[Tessera Fehlermeldung]` (dort gehoert die Herkunft hin). `docs/anleitung-betrieb.md` hat KEINEN eigenen Fehlermeldungs-/SMTP-Abschnitt und erwaehnt `desktop=1` nirgends; die Anknuepfpunkte sind die Fehlersuche-Tabelle in Kapitel 7 (Zeile „Fehlermeldungen der Anwender kommen nicht an“, Z. 341) und die Tabelle „Fehlerbilder“ in Kapitel 10 (ab Z. 699). `docs/mandantentrennung-zugriffsklassifikation.md` listet keine Rumpffelder -> bleibt unveraendert. Kein UI-Text aendert sich -> `de.json`/`en.json` bleiben unveraendert.
</planning_measurements>
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: API — Herkunft ableiten (origin.ts), DTO-Felder, Betreff-Kuerzel und Zeile `Herkunft:` (Browser-Pfad damit bereits Ende-zu-Ende fertig)</name>
<files>apps/api/src/bug-reports/origin.ts, apps/api/src/bug-reports/origin.spec.ts, apps/api/src/bug-reports/dto/bug-report.dto.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/bug-reports/bug-reports.service.spec.ts, apps/api/src/bug-reports/bug-reports.controller.spec.ts</files>
<read_first>apps/api/src/bug-reports/bug-reports.service.ts (Zeilen 122-149 Betreff/Text, 166-169 Protokollzeile), apps/api/src/bug-reports/dto/bug-report.dto.ts, beide bestehenden Specs (Muster fuer Kopfkommentar, `makeService`, `baseDto`)</read_first>
<behavior>
origin.spec.ts (NEU, Vitest, reine Funktionen, mindestens 8 Tests; `describe('origin (quick-260918-gza)')`):
- Test 1 Edge auf Windows: UA `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52` -> `parseUserAgent` = `{ browser: 'Edge 129', os: 'Windows' }`; `describeOrigin({ clientKind: 'browser', userAgent })` = `{ tag: '[Browser]', line: 'Browser — Edge 129 auf Windows' }` (Edge MUSS vor Chrome gewonnen werden).
- Test 2 Chrome auf Windows (gleicher UA ohne `Edg/`): `{ browser: 'Chrome 129', os: 'Windows' }`.
- Test 3 Firefox auf Linux: UA `Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0` -> `{ browser: 'Firefox 130', os: 'Linux' }`; Zeile `Browser — Firefox 130 auf Linux`.
- Test 4 Safari auf macOS: UA `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15` -> `{ browser: 'Safari 17', os: 'macOS' }` (Safari nur OHNE `Chrome/`, Version aus `Version/`).
- Test 5 Mobil: Android-Chrome-UA `Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36` -> os `Android` (NICHT Linux); iPad-UA `Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1` -> os `iOS` (NICHT macOS), browser `Safari 17`; Opera-UA mit `OPR/114.0.0.0` -> `Opera 114`.
- Test 6 WebKitGTK-Client als Desktop: UA `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15` mit `clientKind: 'desktop', clientOs: 'linux', clientVersion: '1.2.0', clientCommit: 'a6d1a64'` -> `{ tag: '[Desktop/Linux]', line: 'Desktop-App (Linux), Tessera-App 1.2.0 · Stand a6d1a64' }` (der User-Agent spielt fuer Desktop KEINE Rolle).
- Test 7 Desktop ohne Commit: `clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: ''` -> `{ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' }` (kein `· Stand`); auch `clientCommit: ' '` -> ohne Stand (trim, wie `client_info_label`).
- Test 8 Desktop ohne Details (alter Client, Cookie fehlt): `clientKind: 'desktop', clientOs: '', clientVersion: '', clientCommit: ''` -> `{ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' }`; `clientOs: 'freebsd'` -> ebenfalls `unbekannt`/`[Desktop]` (nur windows/linux/macos werden auf Windows/Linux/macOS abgebildet).
- Test 9 Rueckfall: `describeOrigin({ userAgent: 'UA' })` (alle vier Felder `undefined`, alter Web-Bau) -> `{ tag: '[Browser]', line: 'Browser — unbekannt auf unbekannt' }`; `clientKind: 'browser'` mit leerem UA -> dasselbe.
- Test 10 Bereinigung: `clientVersion: '1.2.0\nBenutzer: admin'`, `clientCommit: 'a6d1a64<b>'` -> Zeile enthaelt kein Zeilenumbruchzeichen und keine spitzen Klammern; nur `[A-Za-z0-9.+_-]` bleibt, hoechstens 40 Zeichen je Wert (T-GZA-01).
bug-reports.service.spec.ts (bestehend, anpassen + 2 neue Tests):
- Test 1 (bestehend): erwarteter Betreff wird `'[Tessera Fehlermeldung] [Browser] v1.2.3 beta - /admin/users?tab=x'`; Needle-Liste um `'Herkunft: Browser — unbekannt auf unbekannt'` ergaenzen (baseDto hat `userAgent: 'UA'` und keine Client-Felder -> Browser-Rueckfall). `'UA'` und `'1920x1080'` bleiben in der Liste (Zeilen `Browser:`/`Fenster:` bleiben).
- Test 9 (NEU) Desktop/Windows: DTO `{ ...baseDto, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64', userAgent: '<Edge-UA aus origin Test 1>' }` -> `report.subject` beginnt mit `'[Tessera Fehlermeldung] [Desktop/Windows] v1.2.3 beta - '`; `report.text` enthaelt `'Herkunft: Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64'`, enthaelt weiterhin `'Browser: Mozilla/5.0 (Windows NT 10.0'` und `'Fenster: 1920x1080'`; die Zeile `Herkunft:` steht im Text VOR der Zeile `Browser:` (Index-Vergleich); der `logger.log`-Spy wurde genau einmal mit einem String gerufen, der `'[Desktop/Windows]'` enthaelt.
- Test 10 (NEU) Browser mit echtem UA: `{ ...baseDto, clientKind: 'browser', userAgent: '<Chrome-UA aus origin Test 2>' }` -> Betreff enthaelt `'[Browser]'`, Text enthaelt `'Herkunft: Browser — Chrome 129 auf Windows'`.
bug-reports.controller.spec.ts (bestehend, 1 neuer Test):
- Test 4 (NEU): Pipe mit `{ ...baseBody, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' }` -> alle vier Felder bleiben erhalten (Leerstring bleibt Leerstring); `{ ...baseBody }` -> `clientKind` ist `undefined` (kein Default im DTO); `clientKind: 'tablet'` -> `BadRequestException`; `clientOs` mit 21 Zeichen -> `BadRequestException`; `clientVersion`/`clientCommit` mit 41 Zeichen -> `BadRequestException`.
</behavior>
<action>
Reihenfolge RED -> GREEN: zuerst `origin.spec.ts` und die Spec-Aenderungen schreiben, Lauf muss rot sein (origin.ts fehlt, Betreff ohne Kuerzel), dann implementieren.
1. `apps/api/src/bug-reports/origin.ts` (NEU, keine Abhaengigkeit ausser TypeScript): Kopfkommentar deutsch (ASCII-Umlaute wie im Bestand): Zweck (quick-260918-gza — Herkunft einer Fehlermeldung ausweisen, weil WebView2 wie Edge und WebKitGTK wie Safari aussehen), Trust-Modell (alle Eingaben stammen vom Client, rein informativ, laengenbegrenzt, nie fuer Routing/Berechtigung, T-GZA-01), warum Regex statt Bibliothek (kein neues Paket, fuenf Browser und fuenf Systeme reichen fuer ein Postfach). Exporte: Typ `ClientKind = 'desktop' | 'browser'`; Interface `OriginInput { clientKind?: string; clientOs?: string; clientVersion?: string; clientCommit?: string; userAgent?: string }`; Interface `Origin { tag: string; line: string }` — `tag` ist das Betreff-Kuerzel in eckigen Klammern, `line` der Text NACH dem Label `Herkunft: ` (der Dienst setzt das Label davor); Interface `ParsedUserAgent { browser: string; os: string }`; Konstante `UNKNOWN = 'unbekannt'`.
`parseUserAgent(ua: string): ParsedUserAgent` — Browser in dieser Reihenfolge pruefen (die erste Uebereinstimmung gewinnt): `Edg/(\d+)` -> `Edge N`; `OPR/(\d+)` -> `Opera N`; `Firefox/(\d+)` -> `Firefox N`; `(?:Chrome|CriOS)/(\d+)` -> `Chrome N`; `Safari/` OHNE `Chrome/` -> `Safari N` mit N aus `Version/(\d+)`, ohne `Version/` nur `Safari`; sonst `UNKNOWN`. Betriebssystem in dieser Reihenfolge: `Windows NT` -> `Windows`; `Android` -> `Android`; `iPhone|iPad|iPod` -> `iOS`; `Mac OS X|Macintosh` -> `macOS`; `Linux|X11` -> `Linux`; sonst `UNKNOWN`. Kommentar an der Reihenfolge: Android-UAs enthalten `Linux`, iPad-UAs enthalten `like Mac OS X`, Edge/Opera-UAs enthalten `Chrome/` und `Safari/` — deshalb die Reihenfolge.
`describeOrigin(input: OriginInput): Origin` — Hilfsfunktion `clean(value, max = 40)`: `String(value ?? '')`, alles ausser `[A-Za-z0-9.+_-]` entfernen, `slice(0, max)` (T-GZA-01: kein Zeilenumbruch, kein Markup in der Mail). Wenn `input.clientKind === 'desktop'`: `osLabel` aus `clean(clientOs).toLowerCase()` ueber die Abbildung `windows -> Windows`, `linux -> Linux`, `macos -> macOS`, sonst `UNKNOWN`; `version = clean(clientVersion)`, `commit = clean(clientCommit)`; `appLabel` = `Tessera-App ${version} · Stand ${commit}` wenn beide nicht leer, `Tessera-App ${version}` wenn nur Version, sonst leer (gleiche Regel wie `client_info_label` in lib.rs); `line = Desktop-App (${osLabel})` plus `, ${appLabel}` falls appLabel nicht leer; `tag` = `[Desktop/${osLabel}]` wenn osLabel nicht UNKNOWN, sonst `[Desktop]`. Sonst (alles andere, auch `undefined`): `{ browser, os } = parseUserAgent(input.userAgent ?? '')`, `line = Browser — ${browser} auf ${os}` (Gedankenstrich U+2014 wie im Auftrag), `tag = '[Browser]'`.
2. `apps/api/src/bug-reports/dto/bug-report.dto.ts`: `IsIn` aus `class-validator` importieren. Vier neue optionale Felder ans Ende der Klasse, jeweils mit Doc-Kommentar: `clientKind?: 'desktop' | 'browser'` mit `@IsOptional() @IsIn(['desktop', 'browser'])`; `clientOs?: string` mit `@IsOptional() @IsString() @MaxLength(20)`; `clientVersion?: string` mit `@IsOptional() @IsString() @MaxLength(40)`; `clientCommit?: string` mit `@IsOptional() @IsString() @MaxLength(40)`. Kopfkommentar der Klasse um einen Absatz ergaenzen: die vier Felder kommen seit quick-260918-gza vom Web-Client (Browser: `clientKind=browser`, uebrige leer; Desktop-App: aus dem Cookie `tessera_desktop_client`); sie sind optional, damit aeltere Web-Baue weiter gueltig senden (Rueckfall `browser` im Dienst); `whitelist: true` verlangt die Deklaration hier, sonst wuerde die Pipe sie entfernen; rein informativ, laengenbegrenzt (T-GZA-01).
3. `apps/api/src/bug-reports/bug-reports.service.ts`: `describeOrigin` aus `./origin` importieren. Vor Schritt (5) `const origin = describeOrigin(dto);`. Betreff wird `[Tessera Fehlermeldung] ${origin.tag} ${dto.webVersion} ${dto.webChannel} - ${pageShort}`. Im Text-Array direkt VOR der Zeile `Browser: ${dto.userAgent}` die neue Zeile `Herkunft: ${origin.line}` einfuegen; `Browser:` und `Fenster:` bleiben unveraendert. Protokollzeile (9) wird `Bug report ${origin.tag} from ${user.username} …` (Rest unveraendert; das Kuerzel ist ein aufgezaehlter Wert aus origin.ts, nie ein roher Client-String — deshalb protokollierbar). Kopfkommentar der Datei um einen Absatz „Herkunft (quick-260918-gza)“ ergaenzen: warum Kuerzel im Betreff (Sortieren im Postfach), warum der rohe User-Agent bleibt, Verweis auf origin.ts und T-GZA-01.
4. Specs gemaess `<behavior>` anpassen bzw. anlegen; Kopfkommentar von `origin.spec.ts` im Stil der bestehenden Specs (deutsch, Zweck, Liste der Faelle). In `bug-reports.service.spec.ts` und `bug-reports.controller.spec.ts` den Kopfkommentar um einen Satz zu den neuen Tests ergaenzen (Anzahl korrigieren).
Commit nach gruenem Lauf: `feat(bug-reports): Herkunft der Fehlermeldung im Betreff-Kuerzel und als Zeile Herkunft ausweisen`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/bug-reports && pnpm --filter @tessera/api type-check</automated>
</verify>
<done>`origin.spec.ts` mit mindestens 8 Tests, `bug-reports.service.spec.ts` mit 10 Tests, `bug-reports.controller.spec.ts` mit 4 Tests — alle gruen (mindestens 22 statt 11 in `src/bug-reports`); `tsc --noEmit` der API ohne Fehler. Betreff traegt das Kuerzel direkt nach `[Tessera Fehlermeldung]`, der Text die Zeile `Herkunft:` vor `Browser:`; ein DTO ohne die vier Felder ergibt `[Browser]` (Rueckfall), `clientKind: 'tablet'` ergibt 400. Der Browser-Pfad ist damit Ende-zu-Ende fertig: der bestehende Web-Client schickt bereits `userAgent`, die Mail zeigt ab jetzt `[Browser]` und `Herkunft: Browser — <Name> <Version> auf <System>`.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Desktop-Client meldet Version/Commit/OS im Marker, Middleware setzt Cookie `tessera_desktop_client`, Web-Client schickt die vier Felder</name>
<files>apps/desktop/src-tauri/src/lib.rs, apps/web/src/middleware.ts, apps/web/src/middleware.test.ts, apps/web/src/lib/desktop-client.ts, apps/web/src/lib/desktop-client.test.ts, apps/web/src/lib/bug-report-api.ts, apps/web/src/lib/bug-report-api.test.ts, apps/web/src/components/bug-report/bug-report-dialog.tsx, apps/web/src/components/bug-report/bug-report-button.test.tsx</files>
<read_first>apps/desktop/src-tauri/src/lib.rs Zeilen 49-62 (`with_desktop_marker` + Doc), 170-178 (`client_info_label`), 464-503 (`save_server_url`, `open_server`), 530-537 (setup-Navigation), 698-745 (bestehende Marker-Tests); apps/web/src/middleware.ts Zeilen 15-38; apps/web/src/lib/desktop-client.ts; apps/web/src/lib/bug-report-api.ts Zeilen 71-97; apps/web/src/components/bug-report/bug-report-dialog.tsx Zeilen 55-75; apps/web/src/components/bug-report/bug-report-button.test.tsx Zeilen 60-93 (Mocks, beforeEach/afterEach) und 118-156 (Test 2)</read_first>
<behavior>
Rust (`mod tests` in lib.rs; die drei bestehenden Marker-Tests werden auf die reine Funktion umgestellt, plus zwei neue):
- `with_client_marker(&Url::parse("https://tessera.example.com").unwrap(), "1.2.0", "a6d1a64", "windows").as_str()` == `https://tessera.example.com/?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows`.
- Mit vorhandenem Query `https://host/app?x=1` -> `https://host/app?x=1&desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows`.
- Original bleibt unveraendert (`url.query() == None` nach dem Aufruf).
- Leerer bzw. nur aus Leerzeichen bestehender Commit -> `dc=` (leer, Paar bleibt vorhanden, damit die Middleware „alle drei Parameter vorhanden“ erkennt): `…?desktop=1&dv=1.2.0&dc=&dos=linux`.
- Huelle `with_desktop_marker(&url)`: `query_pairs()` enthaelt die Paare `("desktop","1")`, `("dv", env!("CARGO_PKG_VERSION"))`, `("dos", std::env::consts::OS)` und ein Paar mit Schluessel `dc`.
middleware.test.ts (bestehender describe-Block, 4 neue Tests):
- Test 6: `/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows` -> `res.cookies.get('tessera_desktop')?.value === '1'` UND `res.cookies.get('tessera_desktop_client')?.value === '1.2.0|a6d1a64|windows'`; der rohe `set-cookie`-Header enthaelt `tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows` (Next kodiert, gemessen), `Max-Age=31536000`, `Path=/` und fuer dieses Cookie kein `HttpOnly`.
- Test 7 (alter Client): `/login?desktop=1` -> `tessera_desktop=1` gesetzt, `res.cookies.get('tessera_desktop_client')` ist `undefined` (kein Ueberschreiben eines evtl. vorhandenen Werts).
- Test 8 (Bereinigung): `dv=1.2.0%3Cscript%3E` (spitze Klammern) -> kein `tessera_desktop_client`; `dos=win%20dows` -> keins; `dv` fehlt, `dc`/`dos` vorhanden -> keins; `dc=` leer mit gueltigem `dv`/`dos` -> Wert `1.2.0||linux`.
- Test 9 (Redirect-Pfad): `/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux` ohne Session -> Status 307, `location` enthaelt `/login`, beide Cookies gesetzt (Wert `1.2.0|a6d1a64|linux`).
desktop-client.test.ts (neuer describe-Block `getDesktopClientInfo / parseDesktopClientCookie`; `clearCookie()` loescht zusaetzlich `tessera_desktop_client`):
- `parseDesktopClientCookie('tessera_desktop=1; tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows')` -> `{ version: '1.2.0', commit: 'a6d1a64', os: 'windows' }` (kodierte Form, wie der Browser sie haelt).
- Rohe Form `tessera_desktop_client=1.2.0|a6d1a64|windows` -> gleiches Ergebnis.
- Leerer Commit `1.2.0%7C%7Clinux` -> `{ version: '1.2.0', commit: '', os: 'linux' }`.
- Ohne Cookie -> `null`; Wert `abc` (ein Teil) oder `1.2.0|x` (zwei Teile) oder `|a6d1a64|linux` (Version leer) oder `1.2.0|a6d1a64|` (OS leer) -> `null`.
- `getDesktopClientInfo()` liest `document.cookie` (Cookie per `document.cookie = …` gesetzt -> Objekt; ohne Cookie -> `null`); mit `vi.stubGlobal('document', undefined)` -> `null`.
bug-report-api.test.ts (NEU, 3 Tests, `vi.stubGlobal('fetch', mockFetch)` wie in bug-report-button.test.tsx, `mockFetch.mockResolvedValue(new Response('{}', { status: 200 }))`):
- Test 1 Desktop-Nutzlast: `sendBugReport({ description: 'x', page: '/a', webVersion: 'v1', webChannel: 'beta', webCommit: 'c', userAgent: 'UA', viewport: '1x1', clientTime: 't', errors: ['e1', 'e2'], screenshot: null, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64' })` -> `{ ok: true }`; `init.body` ist `FormData` mit `get('clientKind') === 'desktop'`, `get('clientOs') === 'windows'`, `get('clientVersion') === '1.2.0'`, `get('clientCommit') === 'a6d1a64'`, `getAll('errors')` = `['e1','e2']`, `has('screenshot') === false`, `init.credentials === 'include'`, URL endet auf `/bug-reports`.
- Test 2 Browser-Nutzlast: `clientKind: 'browser'`, uebrige drei `''` -> `get('clientKind') === 'browser'`, `get('clientOs') === ''`, `get('clientVersion') === ''`, `get('clientCommit') === ''` (Felder VORHANDEN, Leerstring — nicht weggelassen).
- Test 3: `mockFetch.mockRejectedValue(new Error('offline'))` -> `{ ok: false, status: 0 }`; `mockFetch.mockResolvedValue(new Response('', { status: 429 }))` -> `{ ok: false, status: 429 }`.
bug-report-button.test.tsx:
- Test 2 (bestehend) ergaenzen: `body.get('clientKind') === 'browser'`, `body.get('clientOs') === ''`, `body.get('clientVersion') === ''`, `body.get('clientCommit') === ''` (jsdom ohne Cookies).
- Test 12 (NEU): vor dem Rendern `document.cookie = 'tessera_desktop=1; path=/'` und `document.cookie = 'tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; path=/'`; Senden -> `body.get('clientKind') === 'desktop'`, `clientOs === 'windows'`, `clientVersion === '1.2.0'`, `clientCommit === 'a6d1a64'`. `afterEach` loescht beide Cookies (Ablaufdatum 1970, `path=/`), damit die uebrigen Tests Browser bleiben.
- Test 13 (NEU, alter Client): nur `tessera_desktop=1` ohne `tessera_desktop_client` -> `clientKind === 'desktop'`, die drei anderen `''`.
</behavior>
<action>
Reihenfolge: Rust zuerst (RED: Tests auf `with_client_marker` umstellen, `cargo test --lib` rot; GREEN: implementieren), dann Middleware, dann Web-Helfer, dann Nutzlast und Dialog — jeweils Test vor Implementierung.
1. `apps/desktop/src-tauri/src/lib.rs`: Neue reine Funktion `fn with_client_marker(url: &tauri::Url, version: &str, commit: &str, os: &str) -> tauri::Url` — klont die URL, haengt per `query_pairs_mut().append_pair` nacheinander `("desktop", "1")`, `("dv", version)`, `("dc", commit.trim())`, `("dos", os)` an (Reihenfolge fest, `dc` auch leer anhaengen). Die bestehende `fn with_desktop_marker(url: &tauri::Url) -> tauri::Url` wird zur Huelle: `with_client_marker(url, env!("CARGO_PKG_VERSION"), env!("APP_COMMIT"), std::env::consts::OS)` — so bleiben die drei Aufrufstellen (`save_server_url`, `open_server`, `setup`) UNVERAENDERT und die Tests bleiben rein (kein `env!` in der Erwartung). Doc-Kommentar von `with_desktop_marker` erweitern: seit quick-260918-gza wandern Version, Commit-Stempel und Betriebssystem (`dv`, `dc`, `dos`) mit, die Middleware legt daraus das Cookie `tessera_desktop_client` an, aus dem der Fehler-melden-Knopf die Herkunft der Meldung fuellt; `desktop=1` bleibt unveraendert, damit ein neuer Client gegen eine aeltere Middleware weiter erkannt wird; die Werte gehen NUR in die Navigation, nie in den Store (wie bisher); der Browser-Rueckfall `open_download_page` bekommt weiterhin keinen Marker. `mod tests`: die drei bestehenden `with_desktop_marker_*`-Tests auf `with_client_marker(&url, "1.2.0", "a6d1a64", "windows")` umstellen (Erwartungen laut `<behavior>`), Test fuer leeren Commit (`""` und `" "` -> `dc=`) und einen Test fuer die Huelle ueber `query_pairs()` ergaenzen. `cargo fmt` anwenden (2-Zeilen-Doc-Umbrueche wie im Bestand).
2. `apps/web/src/middleware.ts`: Konstante `DESKTOP_CLIENT_COOKIE = 'tessera_desktop_client'` und drei Muster als Modulkonstanten: `DESKTOP_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,39}$/`, `DESKTOP_COMMIT_RE = /^[A-Za-z0-9]{0,40}$/` (leer erlaubt), `DESKTOP_OS_RE = /^[a-z]{1,20}$/`. Neue reine Hilfsfunktion `buildDesktopClientCookieValue(params: URLSearchParams): string | null` — liest `dv`, `dc`, `dos`; wenn eines `null` (fehlt) ist oder sein Muster nicht passt -> `null`; sonst `${dv}|${dc}|${dos}` (hoechstens 82 Zeichen durch die Muster). In `withDesktopCookie` innerhalb des bestehenden `if (desktop === '1')`-Zweigs: `tessera_desktop=1` wie bisher setzen; zusaetzlich `const info = buildDesktopClientCookieValue(req.nextUrl.searchParams); if (info !== null) res.cookies.set(DESKTOP_CLIENT_COOKIE, info, { …dieselben Optionen wie fuer tessera_desktop… })`. Ohne gueltige Parameter wird das Info-Cookie NICHT gesetzt und NICHT geloescht (alter Client -> die Mail sagt `Desktop-App (unbekannt)`). Doc-Kommentar von `withDesktopCookie` ergaenzen: zweites Cookie, Herkunft (quick-260918-gza), Bereinigung per Muster und Laenge, warum `httpOnly: false` (wird von `getDesktopClientInfo()` gelesen; Version/OS sind kein Geheimnis, dieselbe Vertrauensstufe wie der User-Agent), Hinweis dass Next den Wert mit `encodeURIComponent` serialisiert (T-GZA-03). Tests laut `<behavior>` in `middleware.test.ts` ergaenzen; Kopfkommentar um einen Satz erweitern.
3. `apps/web/src/lib/desktop-client.ts`: `export const DESKTOP_CLIENT_COOKIE_NAME = 'tessera_desktop_client'`; `export interface DesktopClientInfo { version: string; commit: string; os: string }`; `export function parseDesktopClientCookie(cookieString: string): DesktopClientInfo | null` — trennt an `;`, trimmt, sucht den Eintrag mit Praefix `${DESKTOP_CLIENT_COOKIE_NAME}=`, nimmt den Rest, dekodiert per `decodeURIComponent` in try/catch (bei Fehler den Rohwert nehmen), trennt an `|`; genau drei Teile, Teil 1 (version) und Teil 3 (os) nicht leer, sonst `null`; Rueckgabe `{ version, commit, os }`. `export function getDesktopClientInfo(): DesktopClientInfo | null` — `typeof document === 'undefined'` -> `null`, sonst `parseDesktopClientCookie(document.cookie)`. Kopfkommentar ergaenzen (Gegenstueck zu `buildDesktopClientCookieValue`, warum dekodieren — Next kodiert `|` als `%7C`, gemessen). Tests laut `<behavior>`; `clearCookie()` im Test loescht beide Cookies.
4. `apps/web/src/lib/bug-report-api.ts`: `BugReportPayload` um `clientKind: 'desktop' | 'browser'`, `clientOs: string`, `clientVersion: string`, `clientCommit: string` erweitern; in `sendBugReport` nach `clientTime` vier `body.append(...)`-Zeilen fuer genau diese Feldnamen (Leerstrings mitschicken — das DTO ist optional, aber die Felder sollen fuer den Browser-Fall sichtbar leer sein, nicht fehlen). Kopfkommentar um einen Satz ergaenzen (Herkunft, quick-260918-gza; Desktop-Werte kommen aus `getDesktopClientInfo()`). NEU `apps/web/src/lib/bug-report-api.test.ts` laut `<behavior>` (Kopfkommentar: warum diese Datei erst jetzt entsteht — bisher pruefte nur der Komponententest die FormData; die reinen Nutzlastfelder gehoeren an die Funktion selbst).
5. `apps/web/src/components/bug-report/bug-report-dialog.tsx`: `getDesktopClientInfo` und `isDesktopClient` aus `@/lib/desktop-client` importieren. In `handleSend` vor dem `sendBugReport`-Aufruf: `const desktop = isDesktopClient(); const info = desktop ? getDesktopClientInfo() : null;` und im Aufruf `clientKind: desktop ? 'desktop' : 'browser', clientOs: info?.os ?? '', clientVersion: info?.version ?? '', clientCommit: info?.commit ?? ''`. Kein UI-Text, keine Uebersetzung aendert sich. Kurzer Kommentar an der Stelle: Herkunft (quick-260918-gza) — `tessera_desktop` entscheidet Desktop/Browser, `tessera_desktop_client` liefert die Details; fehlt es (alter Client), bleibt es bei Desktop ohne Details. `bug-report-button.test.tsx` laut `<behavior>` erweitern (Test 2 ergaenzen, Tests 12 und 13 neu, Cookie-Aufraeumen im `afterEach`, Kopfkommentar „Elf Tests“ -> „Dreizehn Tests“).
Zwei Commits nach gruenem Lauf: `feat(desktop): Version, Stand und Betriebssystem im Desktop-Marker mitgeben (dv, dc, dos)` fuer lib.rs; `feat(web): Herkunft der Fehlermeldung — Cookie tessera_desktop_client und Client-Felder in der Nutzlast` fuer die Web-Dateien.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo fmt --check && cargo test --lib && cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/lib src/middleware.test.ts src/components/bug-report && pnpm --filter @tessera/web type-check</automated>
</verify>
<done>Rust: `cargo fmt --check` sauber, alle Tests gruen (mindestens 35, davon 5 Marker-Tests: drei umgestellte, leerer Commit, Huelle). Web: `middleware.test.ts` 9 Tests, `desktop-client.test.ts` mindestens 11, `bug-report-api.test.ts` 3, `bug-report-button.test.tsx` 13 — alle gruen, `tsc --noEmit` ohne Fehler. Kette nachgewiesen: Anfrage mit `desktop=1&dv&dc&dos` -> beide Cookies (auch auf dem 307 nach /login) -> `getDesktopClientInfo()` liefert das Tripel aus der kodierten Cookie-Form -> FormData traegt `clientKind=desktop`, `clientOs`, `clientVersion`, `clientCommit`; ohne Info-Cookie `desktop` mit leeren Details; ohne Desktop-Cookie `browser`.</done>
</task>
<task type="auto">
<name>Task 3: CHANGELOG und Handbuecher — Herkunft und Betreff-Kuerzel beschreiben</name>
<files>CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md</files>
<read_first>CHANGELOG.md Zeilen 1-25 (Abschnitt „Unveröffentlicht“ mit „Neu“/„Geändert“/„Behoben“); docs/anleitung-administration.md Zeile 208 (Absatz „Fehlermeldungen an“); docs/anleitung-betrieb.md Zeile 341 (Tabellenzeile „Fehlermeldungen der Anwender kommen nicht an“) und Zeilen 699-709 (Tabelle „Fehlerbilder“ in Kapitel 10)</read_first>
<action>
Alle Texte deutsch, Alltagssprache, Anwender/Betreiber werden gesiezt (App-Texte), echte Umlaute wie in den Handbuechern.
1. `CHANGELOG.md`, Abschnitt `## Unveröffentlicht` -> `### Geändert`, neue Zeile am Ende der Liste: `- Fehler melden: Fehlermeldungen nennen jetzt die Herkunft – Browser oder Desktop-App, Betriebssystem, bei der Desktop-App auch Version und Stand; der Betreff trägt dafür ein Kürzel wie „[Browser]“, „[Desktop/Windows]“ oder „[Desktop/Linux]“, nach dem sich das Postfach sortieren lässt`.
2. `docs/anleitung-administration.md`, Absatz **Fehlermeldungen an** (Z. 208): den Teilsatz „— der Betreff beginnt mit „[Tessera Fehlermeldung]“, das Bild hängt als PNG an.“ ersetzen durch einen Teilsatz, der sagt: der Betreff beginnt mit „[Tessera Fehlermeldung]“ und einem Kürzel für die Herkunft („[Browser]“, „[Desktop/Windows]“ oder „[Desktop/Linux]“), nach dem Sie das Postfach sortieren oder filtern können; im Text nennt die Zeile „Herkunft“ bei Browsern Browser und Betriebssystem (Beispiel „Browser — Chrome 129 auf Windows“), bei der Desktop-App Betriebssystem, Version und Stand (Beispiel „Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64“); das Bild hängt als PNG an. Der uebrige Absatz bleibt.
3. `docs/anleitung-betrieb.md`:
a) Kapitel 7, Tabellenzeile „Fehlermeldungen der Anwender kommen nicht an“ (Z. 341), Spalte „Prüfen / Beheben“: die Klammer „(eine Zeile je gesendeter Meldung, `Bug report mail failed` bei Versandfehler)“ erweitern zu „(eine Zeile je gesendeter Meldung mit dem Herkunfts-Kürzel `[Browser]`, `[Desktop/Windows]` oder `[Desktop/Linux]`, `Bug report mail failed` bei Versandfehler)“.
b) Kapitel 10, Tabelle „Fehlerbilder“ (ab Z. 699), neue letzte Zeile: Symptom „Eine Fehlermeldung aus der Desktop-App nennt als Herkunft „Desktop-App (unbekannt)“ ohne Version, Betreff-Kürzel `[Desktop]`“ — Ursache „Der Client ist älter als diese Fassung: er meldet dem Server beim Start nur `desktop=1`, nicht Version, Stand und Betriebssystem (Parameter `dv`, `dc`, `dos`, aus denen `web` das Cookie `tessera_desktop_client` bildet)“ — Prüfen/Beheben „Kein Fehler, die Meldung ist trotzdem als Desktop-App erkennbar. Client über „Auf Version … aktualisieren“ im Infobereich oder den Browser-Installer aktualisieren; danach stehen Betriebssystem, Version und Stand in der Meldung.“
Nicht anfassen: `docs/mandantentrennung-zugriffsklassifikation.md` (listet keine Rumpffelder), `docs/anleitung-anwender.md` (Anwender sehen keine Aenderung), `de.json`/`en.json` (kein UI-Text).
Commit: `docs: Fehlermeldungen — Herkunft (Browser/Desktop-App, Betriebssystem, Version) und Betreff-Kürzel (Handbücher, CHANGELOG)`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q "Desktop/Windows" CHANGELOG.md && grep -q "Desktop/Windows" docs/anleitung-administration.md && grep -q "Desktop/Windows" docs/anleitung-betrieb.md && grep -q "Desktop-App (unbekannt)" docs/anleitung-betrieb.md && grep -q "tessera_desktop_client" docs/anleitung-betrieb.md && echo DOCS-OK</automated>
</verify>
<done>Alle drei Dateien nennen das Kuerzel `[Desktop/Windows]`; das Betriebshandbuch erklaert in Kapitel 10 den Fall „Desktop-App (unbekannt)“ als alten Client mit Verweis auf `dv`/`dc`/`dos` und das Cookie `tessera_desktop_client`; der Changelog-Eintrag steht unter „Unveröffentlicht → Geändert“; der Verify-Befehl gibt `DOCS-OK` aus; genau ein Commit `docs: …` mit den drei Dateien (Nachweis: `git show --stat --format= <sha>` des Doku-Commits listet genau CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md).</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Desktop-Client -> Web (Query `desktop=1&dv&dc&dos`) | Ungepruefte Query-Parameter einer Navigation; jeder Browser kann sie ebenso setzen |
| Web-Middleware -> Browser (Cookie `tessera_desktop_client`) | Nicht-httpOnly-Cookie, fuer Seiten-JavaScript lesbar und vom Anwender aenderbar |
| Browser -> API (`POST /bug-reports`, vier neue Multipart-Felder) | Vom Client gelieferte Strings, unbeglaubigt wie der User-Agent |
| API -> Postfach des Betreibers (Betreff, Textzeile, Protokollzeile) | Client-Text landet in einer E-Mail und teilweise im Log |
## STRIDE Threat Register (ASVS Level 1)
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-GZA-01 | Spoofing / Tampering | `clientKind`, `clientOs`, `clientVersion`, `clientCommit` im DTO; `describeOrigin` | low | mitigate | Rein informativ: nie fuer Routing, Berechtigung oder Speicherung genutzt; `@IsIn(['desktop','browser'])`, `@MaxLength` 20/40/40; `clean()` in origin.ts laesst nur `[A-Za-z0-9.+_-]` und 40 Zeichen zu (kein Zeilenumbruch, kein Markup in der Mail); OS wird auf drei feste Labels abgebildet; ins Log geht NUR das aufgezaehlte Kuerzel, nie ein Rohwert (Test 9 im Service-Spec, Test 10 in origin.spec) |
| T-GZA-02 | Tampering | Betreff-Zeile (Header-Injection) | low | mitigate | In den Betreff geht ausschliesslich `origin.tag` — ein Wert aus einer festen Menge (`[Browser]`, `[Desktop]`, `[Desktop/Windows|Linux|macOS]`); Version/Commit stehen nur im Text, nie im Header |
| T-GZA-03 | Tampering | `withDesktopCookie` — Query -> Cookie `tessera_desktop_client` | low | mitigate | Drei feste Muster (`DESKTOP_VERSION_RE`, `DESKTOP_COMMIT_RE`, `DESKTOP_OS_RE`), Gesamtlaenge ≤ 82; nur gesetzt, wenn `desktop=1` UND alle drei Parameter vorhanden und gueltig; Middleware trifft keine Entscheidung auf Grund des Werts; `sameSite: 'lax'`, `secure` bei https wie das bestehende Cookie (Tests 6-9 in middleware.test.ts) |
| T-GZA-04 | Information Disclosure | Cookie `tessera_desktop_client` (App-Version und OS fuer Seiten-JS lesbar) | low | accept | Dieselbe Vertrauensstufe und Sichtbarkeit wie der User-Agent, den jede Seite ohnehin liest; kein Geheimnis, kein Token; nur die eigene Web-App laeuft im WebView |
| T-M97-03 | Denial of Service | `main.ts`, Body-Limits | medium | mitigate (unveraendert) | Kein globales Limit angefasst; vier kurze Textfelder innerhalb des bestehenden Multipart-Rumpfs, DTO-Grenzen wie oben |
| T-M97-09 | Spoofing | Irrefuehrende Herkunftsangaben durch einen Anwender | low | accept | Wie bisher fuer Beschreibung/Fehlerliste: reiner Text an den Administrator des eigenen Mandanten; Benutzer/Mandant/API-Version kommen weiterhin aus Sitzung und Umgebung, nicht aus dem Rumpf |
| T-GZA-SC | Tampering | Paketinstallationen | — | n/a | Keine neue npm-/cargo-Abhaengigkeit (Regex und Standardbibliothek); kein Install-Schritt in diesem Plan |
</threat_model>
<verification>
Automatisch (Executor, je Task im `<verify>`):
- API: `pnpm --filter @tessera/api exec vitest run src/bug-reports` (≥ 22 Tests gruen, vorher 11) und `pnpm --filter @tessera/api type-check`.
- Web: `pnpm --filter @tessera/web exec vitest run src/lib src/middleware.test.ts src/components/bug-report` und `pnpm --filter @tessera/web type-check`.
- Rust: `cargo fmt --check && cargo test --lib` in `apps/desktop/src-tauri` (≥ 35 Tests).
- Falsifizierung (RED zuerst): Betreff ohne Kuerzel laesst Service-Test 1 scheitern; fehlendes `origin.ts` laesst origin.spec scheitern; Marker ohne `dv` laesst die Rust-Tests scheitern; Middleware ohne Info-Cookie laesst middleware Test 6 scheitern.
- Abschliessend einmal die vollen Suiten: `pnpm --filter @tessera/api exec vitest run` und `pnpm --filter @tessera/web exec vitest run` (keine Regression ausserhalb der geaenderten Dateien).
Kein Biome-Gate (Ledger #35: Konfiguration im Bestand nicht lauffaehig).
Nachweis durch den Orchestrator NACH der Ausfuehrung (nicht Aufgabe des Executors):
- Browser-Fall lokal mit Playwright MCP und mailhog (`docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mailhog`, `docker compose up -d --build api web`): Fehler melden -> Mail in `http://localhost:8025` mit Betreff `[Tessera Fehlermeldung] [Browser] dev dev - /…` und Zeile `Herkunft: Browser — Chrome <N> auf Linux`; Zeilen `Browser:`/`Fenster:` weiterhin vorhanden; `docker compose logs api | grep "Bug report"` zeigt das Kuerzel.
- Desktop-Fall nach CI-Bau auf der Windows-Test-VM (Zugang laut Memory `reference_windows_test_vm.md`): Client installieren bzw. per In-App-Update aktualisieren, gegen alpha melden -> Betreff `[Desktop/Windows]`, Zeile `Herkunft: Desktop-App (Windows), Tessera-App <Version> · Stand <sha7>`; Kontrolle des Cookies `tessera_desktop_client` in der Seite ueber Einstellungen -> Desktop-App ist nicht noetig, die Mail genuegt.
- Alter Client (optional): ein bestehender 1.2.0-Client ohne Update erzeugt `[Desktop]` und `Desktop-App (unbekannt)` — das ist das dokumentierte Verhalten (Betriebshandbuch Kap. 10).
</verification>
<success_criteria>
- Jede Fehlermeldungs-Mail traegt im Betreff direkt nach `[Tessera Fehlermeldung]` genau eines der Kuerzel `[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]` (oder `[Desktop]` bei einem alten Client) und im Text die Zeile `Herkunft: …` in der im Auftrag festgelegten Form; `Browser:` und `Fenster:` bleiben.
- Desktop-Client, Middleware, Web-Helfer, Nutzlast, DTO und Dienst sind durchgaengig verbunden und je Schicht durch Tests belegt (Rust 5 Marker-Tests, Middleware 9, desktop-client ≥ 11, bug-report-api 3, Komponententest 13, origin ≥ 8, Service 10, Controller 4).
- Rueckwaertskompatibel in beide Richtungen (alter Client, alter Web-Bau, alte API) — kein 400, kein Verlust der bisherigen Meldung.
- Keine neue Abhaengigkeit, keine DB-Aenderung, `main.ts` unveraendert, kein UI-Text geaendert.
- CHANGELOG und beide Handbuecher beschreiben Kuerzel und Herkunftszeile; drei bis vier Code-/Doku-Commits mit den vorgegebenen Praefixen; `.planning/`-Artefakte werden vom Executor NICHT committet.
</success_criteria>
<output>
Create `.planning/quick/260918-gza-fehlermeldung-herkunft-ausweisen-browser/260918-gza-SUMMARY.md` when done
</output>
@@ -0,0 +1,116 @@
---
status: complete
phase: quick-260918-gza
plan: 01
subsystem: bug-reports
tags: [fehler-melden-knopf, herkunft, desktop-app, betreff-kuerzel]
dependency-graph:
requires: [quick-260914-m97, quick-260917-h2s]
provides: [herkunfts-kuerzel-im-betreff, herkunftszeile-in-der-mail, cookie-tessera_desktop_client]
affects: [apps/api/src/bug-reports, apps/desktop/src-tauri, apps/web/src/middleware.ts, apps/web/src/lib, apps/web/src/components/bug-report]
tech-stack:
added: []
patterns: [reine-helferfunktion-mit-eigener-spec, cookie-huelle-um-bestehenden-marker-mechanismus]
key-files:
created:
- apps/api/src/bug-reports/origin.ts
- apps/api/src/bug-reports/origin.spec.ts
- apps/web/src/lib/bug-report-api.test.ts
modified:
- apps/api/src/bug-reports/dto/bug-report.dto.ts
- apps/api/src/bug-reports/bug-reports.service.ts
- apps/api/src/bug-reports/bug-reports.service.spec.ts
- apps/api/src/bug-reports/bug-reports.controller.spec.ts
- apps/desktop/src-tauri/src/lib.rs
- apps/web/src/middleware.ts
- apps/web/src/middleware.test.ts
- apps/web/src/lib/desktop-client.ts
- apps/web/src/lib/desktop-client.test.ts
- apps/web/src/lib/bug-report-api.ts
- apps/web/src/components/bug-report/bug-report-dialog.tsx
- apps/web/src/components/bug-report/bug-report-button.test.tsx
- CHANGELOG.md
- docs/anleitung-administration.md
- docs/anleitung-betrieb.md
decisions:
- "Marker-/Cookie-Mechanismus aus quick-260917-h2s erweitert statt WebView-User-Agent zu ueberschreiben (Vorgabe des Orchestrators im Plan, keine Abweichung)."
- "Regex statt neuer ua-parser-Bibliothek in origin.ts — fuenf Browser/fuenf Betriebssysteme reichen fuer ein Postfach, kein neues Paket."
metrics:
duration: ca. 45 min
completed: 2026-09-18
actuals:
tokens: 68000
tasks: 3
commits: 4
plan_head_before: ab99a9a56e6d0e1e57e07c6feb2a2fa2ab866fc4
---
# Phase quick-260918-gza Plan 01: Herkunft der Fehlermeldung ausweisen — Summary
Fehlermeldungen des Fehler-melden-Knopfs tragen jetzt ein Herkunfts-Kuerzel im Betreff (`[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]`, Rueckfall `[Desktop]`) und eine Zeile `Herkunft: …` im Text, abgeleitet vom reinen Helfer `origin.ts` aus vier neuen optionalen DTO-Feldern (Desktop-App, ueber ein zweites Cookie `tessera_desktop_client`) bzw. dem User-Agent (Browser).
## Ausgefuehrte Tasks
1. **API — `origin.ts`, DTO-Felder, Betreff-Kuerzel, Zeile `Herkunft:`** — Commit `7169472`
2. **Desktop-Marker (`dv`/`dc`/`dos`), Middleware-Cookie, Web-Nutzlast** — zwei Commits:
- `b03cb21` — Rust: `with_client_marker` (rein) + `with_desktop_marker` (Huelle)
- `f245711` — Web: Middleware-Cookie, `desktop-client.ts`, `bug-report-api.ts`, Dialog
3. **CHANGELOG und Handbuecher** — Commit `e2a7946`
## Commits
| Hash | Betreff |
|------|---------|
| `7169472` | feat(bug-reports): Herkunft der Fehlermeldung im Betreff-Kuerzel und als Zeile Herkunft ausweisen |
| `b03cb21` | feat(desktop): Version, Stand und Betriebssystem im Desktop-Marker mitgeben (dv, dc, dos) |
| `f245711` | feat(web): Herkunft der Fehlermeldung — Cookie tessera_desktop_client und Client-Felder in der Nutzlast |
| `e2a7946` | docs: Fehlermeldungen — Herkunft (Browser/Desktop-App, Betriebssystem, Version) und Betreff-Kürzel (Handbücher, CHANGELOG) |
`commits: 4` (gemessen: `git rev-list --count ab99a9a..HEAD` = 4; `plan_head_before` ist der Stand vor Task 1).
## Testzahlen
| Suite | Vorher | Nachher | Befehl |
|---|---|---|---|
| API `src/bug-reports` | 11 | 24 (origin 10, service 10, controller 4) | `pnpm --filter @tessera/api exec vitest run src/bug-reports` |
| API vollstaendig | — | 1124/1124 gruen (69 Testdateien) | `pnpm --filter @tessera/api exec vitest run` |
| API `type-check` | — | ohne Fehler | `pnpm --filter @tessera/api type-check` |
| Web `src/lib`, `middleware.test.ts`, `src/components/bug-report` | 11 (desktop-client) + 5 (middleware) + 11 (button) = 27 | 101/101 gruen (13 Testdateien; middleware 9, desktop-client 13, bug-report-api 3 NEU, bug-report-button 13) | `pnpm --filter @tessera/web exec vitest run src/lib src/middleware.test.ts src/components/bug-report` |
| Web vollstaendig | — | 447/447 gruen (65 Testdateien) | `pnpm --filter @tessera/web exec vitest run` |
| Web `type-check` | — | ohne Fehler | `pnpm --filter @tessera/web type-check` |
| Rust `cargo test --lib` | 33 (STATE-Baseline) | 37/37 gruen, `cargo fmt --check` sauber | `apps/desktop/src-tauri && cargo fmt --check && cargo test --lib` |
Alle Zahlen erfuellen bzw. uebertreffen die Vorgaben aus `<success_criteria>` (Rust ≥ 5 Marker-Tests — 5 vorhanden: 3 umgestellt + leerer Commit + Huelle; Middleware 9; desktop-client ≥ 11 — 13; bug-report-api 3; Komponententest 13; origin ≥ 8 — 10; Service 10; Controller 4).
## Deviations from Plan
Keine — der Plan wurde wie geschrieben ausgefuehrt. Ergaenzend zwei kleine Implementierungsentscheidungen, die im Rahmen des Plans lagen (keine Abweichung von `<behavior>`/`<action>`):
- **Test 9 im Service-Spec** (Betreff/Zeile/Protokollzeile fuer Desktop/Windows) nutzt fuer die Pruefung "Logger genau einmal mit Kuerzel gerufen" eine zweite, frische `makeService()`-Instanz, damit der Aufruf-Zaehler nicht durch den vorherigen `submit()` in demselben Test verfaelscht wird. Ergebnis entspricht exakt der im Plan verlangten Erwartung.
- In `origin.ts` wurde `clean()` mit Default-Parameter `max = 40` implementiert (im Plan als `clean(value, max = 40)` vorgegeben) — keine Abweichung, nur Bestaetigung der genauen Umsetzung.
## Known Stubs
Keine.
## Threat Flags
Keine neue, im Plan nicht erfasste Sicherheitsflaeche gefunden — alle vier neuen DTO-Felder, das zweite Cookie und die Bereinigungsregeln entsprechen exakt dem Threat Register des Plans (T-GZA-01 bis T-GZA-04, T-GZA-SC).
## Offene Punkte fuer den Orchestrator (Nachweis, nicht Aufgabe des Executors)
Laut `<verification>` des Plans, ausdruecklich NICHT Teil dieser Ausfuehrung:
1. **Browser-Fall (Playwright MCP + mailhog):** lokal `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mailhog` und `docker compose up -d --build api web`, dann ueber den Fehler-melden-Knopf eine Meldung senden und in `http://localhost:8025` pruefen: Betreff `[Tessera Fehlermeldung] [Browser] dev dev - /…`, Zeile `Herkunft: Browser — Chrome <N> auf Linux`, Zeilen `Browser:`/`Fenster:` weiterhin vorhanden; `docker compose logs api | grep "Bug report"` zeigt das Kuerzel.
2. **Desktop-Fall (Windows-Test-VM nach CI-Bau):** Client auf der VM installieren bzw. per In-App-Update aktualisieren (Zugang laut Memory `reference_windows_test_vm.md`), gegen alpha melden -> Betreff `[Desktop/Windows]`, Zeile `Herkunft: Desktop-App (Windows), Tessera-App <Version> · Stand <sha7>`.
3. **Optional — alter Desktop-Client:** ein bestehender 1.2.0-Client ohne Update erzeugt `[Desktop]` und `Desktop-App (unbekannt)` — dokumentiertes Verhalten (Betriebshandbuch Kap. 10), kein zwingender Nachweis.
## Self-Check: PASSED
- `apps/api/src/bug-reports/origin.ts` — FOUND
- `apps/api/src/bug-reports/origin.spec.ts` — FOUND
- `apps/web/src/lib/bug-report-api.test.ts` — FOUND
- Commit `7169472` — FOUND (`git log --oneline --all | grep 7169472`)
- Commit `b03cb21` — FOUND
- Commit `f245711` — FOUND
- Commit `e2a7946` — FOUND
@@ -0,0 +1,134 @@
---
phase: quick-260918-gza
verified: 2026-09-18T12:45:00Z
status: passed
score: 9/9 must-haves verified
covered_files:
- ".planning/quick/260918-gza-fehlermeldung-herkunft-ausweisen-browser/260918-gza-PLAN.md"
- ".planning/quick/260918-gza-fehlermeldung-herkunft-ausweisen-browser/260918-gza-SUMMARY.md"
- "CHANGELOG.md"
- "apps/api/src/bug-reports/bug-reports.controller.spec.ts"
- "apps/api/src/bug-reports/bug-reports.service.spec.ts"
- "apps/api/src/bug-reports/bug-reports.service.ts"
- "apps/api/src/bug-reports/dto/bug-report.dto.ts"
- "apps/api/src/bug-reports/origin.spec.ts"
- "apps/api/src/bug-reports/origin.ts"
- "apps/desktop/src-tauri/src/lib.rs"
- "apps/web/src/components/bug-report/bug-report-button.test.tsx"
- "apps/web/src/components/bug-report/bug-report-dialog.tsx"
- "apps/web/src/lib/bug-report-api.test.ts"
- "apps/web/src/lib/bug-report-api.ts"
- "apps/web/src/lib/desktop-client.test.ts"
- "apps/web/src/lib/desktop-client.ts"
- "apps/web/src/middleware.test.ts"
- "apps/web/src/middleware.ts"
- "docs/anleitung-administration.md"
- "docs/anleitung-betrieb.md"
covered_digest: "v1:sha256:8c8d70d3963d82115c5c4b6ec8f96a5a16f98ad13ffb0c8ce57a688af8114342"
behavior_unverified: 0
overrides_applied: 0
human_verification:
- test: "Browser-Fall lokal mit Playwright MCP und mailhog (docker compose up mailhog/api/web, Fehler melden -> Mail in http://localhost:8025 mit Betreff [Tessera Fehlermeldung] [Browser] dev dev - /... und Zeile Herkunft: Browser — Chrome <N> auf Linux)"
expected: "Betreff traegt [Browser], Text enthaelt Herkunft-Zeile, Browser:/Fenster: bleiben, API-Log zeigt das Kuerzel"
why_human: "Erfordert laufenden Mailhog/API/Web-Stack und echten Browser-Klick — Nachweis durch Orchestrator ausstehend, nicht statisch im Code pruefbar"
- test: "Desktop-Fall auf der Windows-Test-VM nach CI-Bau (Client installieren/aktualisieren, gegen alpha melden)"
expected: "Betreff [Desktop/Windows], Zeile Herkunft: Desktop-App (Windows), Tessera-App <Version> · Stand <sha7>"
why_human: "Erfordert echten Desktop-Client-Build und eine Windows-VM — Nachweis durch Orchestrator ausstehend, nicht statisch im Code pruefbar"
---
# Quick-Task 260918-gza: Fehlermeldung — Herkunft ausweisen (Browser/Desktop) — Verification Report
**Ziel:** `POST /bug-reports`-Mails weisen die Herkunft (Browser vs. Desktop-App, Betriebssystem, bei Desktop zusaetzlich Version/Commit) im Betreff-Kuerzel und in einer Textzeile aus; der Desktop-Client meldet die Werte ueber den bestehenden Marker-/Cookie-Mechanismus; Rueckwaertskompatibilitaet in beide Richtungen; keine DB-Aenderung, `main.ts` unangetastet; CHANGELOG und Handbuecher ergaenzt.
**Verified:** 2026-09-18
**Status:** passed
**Re-verification:** Nein — Erstverifikation
## Zusammenfassung
Alle vier Commits (`7169472`, `b03cb21`, `f245711`, `e2a7946`) sind auf `main` vorhanden und entsprechen inhaltlich exakt dem Plan. Ich habe jede der acht geforderten Pruefpunkte direkt am Code (nicht an der SUMMARY) nachvollzogen und zusaetzlich alle relevanten Testsuiten selbst ausgefuehrt statt die im SUMMARY behaupteten Zahlen zu uebernehmen.
## Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | `describeOrigin`/`parseUserAgent` liefern die im Auftrag festgelegten Kuerzel/Zeilen fuer Browser-UA (Edge/Windows), Desktop mit OS+Version+Commit, Desktop ohne Commit, Desktop ohne Details, fehlende Felder | ✓ VERIFIED | `apps/api/src/bug-reports/origin.ts:67-149`; `origin.spec.ts` Tests 1,6,7,8,9 — selbst ausgefuehrt: `pnpm --filter @tessera/api exec vitest run src/bug-reports` → 24/24 gruen (origin.spec.ts 10 Tests) |
| 2 | Betreff `[Tessera Fehlermeldung] <tag> <webVersion> <webChannel> - <page>`; Zeile `Herkunft:` vor `Browser:`; `Browser:`/`Fenster:` unveraendert | ✓ VERIFIED | `bug-reports.service.ts:126-149` (`origin.tag` im Subject; `Herkunft:` direkt vor `Browser:` im Text-Array); `bug-reports.service.spec.ts` Test 9 prueft `text.indexOf('Herkunft:') < text.indexOf('Browser:')` explizit |
| 3 | DTO: vier optionale Felder, `clientKind` mit `@IsIn`, Laengenbegrenzung | ✓ VERIFIED | `apps/api/src/bug-reports/dto/bug-report.dto.ts:96-115` — `@IsOptional() @IsIn(['desktop','browser'])` fuer `clientKind`, `@MaxLength(20)`/`@MaxLength(40)`/`@MaxLength(40)` fuer `clientOs`/`clientVersion`/`clientCommit`; `bug-reports.controller.spec.ts` Test 4 prueft alle Grenzen inkl. `BadRequestException` bei `clientKind: 'tablet'` |
| 4 | Rust: `with_desktop_marker` bleibt `&Url`-only, drei Aufrufstellen unveraendert, neue reine `with_client_marker` getestet, `desktop=1` weiterhin vorhanden, `dos` aus `std::env::consts::OS` | ✓ VERIFIED | `lib.rs:56-89` (`with_client_marker(url,&str,&str,&str)`, `with_desktop_marker(url: &tauri::Url)` als Huelle mit `env!`/`std::env::consts::OS`); Aufrufstellen `save_server_url` (Z. 503), `open_server` (Z. 527), Setup (Z. 563) unveraendert `with_desktop_marker(&parsed)`; `cargo test --lib` selbst ausgefuehrt → 37/37 gruen, `cargo fmt --check` sauber |
| 5 | Middleware: `tessera_desktop=1`-Verhalten unveraendert; neues Cookie nur bei allen drei validen Parametern; nicht httpOnly; secure nur bei https | ✓ VERIFIED | `middleware.ts:70-86` — `buildDesktopClientCookieValue` liefert `null` bei fehlendem/ungueltigem Parameter, dieselben `cookieOptions` (inkl. `httpOnly: false`, `secure: req.nextUrl.protocol === 'https:'`) wie das bestehende Cookie; `middleware.test.ts` Tests 6-9 selbst ausgefuehrt (Teil der 101/101 gruenen Web-Suite) |
| 6 | Web: `getDesktopClientInfo()` dekodiert `%7C`; Dialog fuellt vier Felder; FormData haengt sie an | ✓ VERIFIED | `desktop-client.ts:34-54` (`decodeURIComponent` in try/catch, Split an `|`); `bug-report-dialog.tsx:61-77` (`isDesktopClient()`/`getDesktopClientInfo()` → vier Felder); `bug-report-api.ts:95-99` (vier `body.append`-Zeilen) |
| 7 | Keine neue Abhaengigkeit, kein Schema, `main.ts` unangetastet | ✓ VERIFIED | `git diff ab99a9a..HEAD -- '**/package.json' '**/Cargo.toml' '**/Cargo.lock' '**/pnpm-lock.yaml' apps/api/prisma/schema.prisma apps/api/src/main.ts` → leerer Diff |
| 8 | CHANGELOG-Eintrag unter „Unveröffentlicht → Geändert“; beide Handbuecher nennen Herkunft/Kuerzel | ✓ VERIFIED | `CHANGELOG.md:20` (Eintrag unter `### Geändert`); `docs/anleitung-administration.md:208` (Herkunfts-Absatz); `docs/anleitung-betrieb.md:341` (Kap. 7, Kuerzel in der Log-Zeile) und `docs/anleitung-betrieb.md:709` (Kap. 10, neue Fehlerbild-Zeile „Desktop-App (unbekannt)“) |
**Score:** 8/8 fachliche Wahrheiten aus dem Pruefauftrag verifiziert (plus die uebergeordnete Rueckwaertskompatibilitaets-Wahrheit aus dem Plan-Frontmatter unten separat gefuehrt) — insgesamt 9/9 must-haves.
| # | Zusaetzliche Plan-Wahrheit | Status | Evidence |
|---|---|--------|----------|
| 9 | Alter Desktop-Client (nur `desktop=1`) und alter Web-Bau (ohne vier Felder) bleiben gueltig, kein 400 | ✓ VERIFIED | `describeOrigin({ userAgent: 'UA' })` faellt auf `[Browser]` zurueck (origin.spec.ts Test 9); `middleware.test.ts` Test 7 zeigt: `desktop=1` ohne `dv/dc/dos` setzt `tessera_desktop` weiterhin, aber kein zweites Cookie (kein Fehler, keine Ausnahme); DTO-Felder sind `@IsOptional()` (`bug-report.dto.ts`), globale Pipe hat kein `forbidNonWhitelisted` (unveraendert in `main.ts`, siehe Wahrheit 7) |
## Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/bug-reports/origin.ts` | reine Helfer `parseUserAgent`/`describeOrigin` | ✓ VERIFIED | Existiert, 149 Zeilen, keine Abhaengigkeit, exportiert beide Funktionen wie gefordert |
| `apps/api/src/bug-reports/origin.spec.ts` | ≥8 Faelle | ✓ VERIFIED | 10 `it`-Bloecke, deckt alle im Plan geforderten Faelle |
| `apps/api/src/bug-reports/dto/bug-report.dto.ts` | vier optionale Felder | ✓ VERIFIED | Vorhanden, `@IsOptional()` an allen vieren |
| `apps/api/src/bug-reports/bug-reports.service.ts` | Betreff-Kuerzel, Zeile `Herkunft:`, Kuerzel im Log | ✓ VERIFIED | Zeilen 126-149 |
| `apps/desktop/src-tauri/src/lib.rs` | `with_client_marker` rein + `with_desktop_marker` als Huelle, drei Aufrufstellen unveraendert | ✓ VERIFIED | Zeilen 56-89; Aufrufstellen 503/527/563 unveraendert |
| `apps/web/src/middleware.ts` | `withDesktopCookie` setzt zweites Cookie bereinigt | ✓ VERIFIED | Zeilen 46-86 |
| `apps/web/src/lib/desktop-client.ts` | `DESKTOP_CLIENT_COOKIE_NAME`, `parseDesktopClientCookie`, `getDesktopClientInfo` | ✓ VERIFIED | Zeilen 20-62 |
| `apps/web/src/lib/bug-report-api.ts` | `BugReportPayload` + vier FormData-Felder | ✓ VERIFIED | Zeilen 76-99 |
| `apps/web/src/lib/bug-report-api.test.ts` | NEU, FormData-Felder + Netzwerkfehler | ✓ VERIFIED | Neu erstellt, Teil der 101/101 gruenen Web-Suite |
| `apps/web/src/components/bug-report/bug-report-dialog.tsx` | `handleSend` fuellt vier Felder | ✓ VERIFIED | Zeilen 61-77 |
| `CHANGELOG.md`, `docs/anleitung-administration.md`, `docs/anleitung-betrieb.md` | Herkunft beschrieben | ✓ VERIFIED | Siehe Wahrheit 8 |
## Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| Rust `with_desktop_marker` (3 Aufrufstellen) | Query `desktop=1&dv&dc&dos` | `query_pairs_mut().append_pair` | ✓ WIRED | `lib.rs:56-89`, Aufrufstellen unveraendert |
| Query-Parameter | Middleware `withDesktopCookie` | `buildDesktopClientCookieValue(req.nextUrl.searchParams)` | ✓ WIRED | `middleware.ts:80-83` |
| Cookie `tessera_desktop_client` | `getDesktopClientInfo()` | `document.cookie` + `decodeURIComponent` | ✓ WIRED | `desktop-client.ts:57-62` |
| `getDesktopClientInfo()` | `handleSend` im Dialog | `isDesktopClient()`/`getDesktopClientInfo()` | ✓ WIRED | `bug-report-dialog.tsx:61-77` |
| Dialog | `sendBugReport`/FormData | vier `body.append`-Zeilen | ✓ WIRED | `bug-report-api.ts:95-99` |
| FormData | `BugReportDto` | Whitelist verlangt Deklaration | ✓ WIRED | `bug-report.dto.ts:96-115` |
| `BugReportDto` | `describeOrigin()` | `bug-reports.service.ts:127` (`describeOrigin(dto)`) | ✓ WIRED | Betreff/Zeile/Log nutzen `origin.tag`/`origin.line` |
## Behavioral Spot-Checks / Tests (selbst ausgefuehrt, nicht aus SUMMARY uebernommen)
| Suite | Befehl | Ergebnis |
|-------|--------|----------|
| API `src/bug-reports` | `pnpm --filter @tessera/api exec vitest run src/bug-reports` | 24/24 gruen (origin 10, service 10, controller 4) |
| API `type-check` | `pnpm --filter @tessera/api type-check` | ohne Fehler |
| Web `src/lib`, `middleware.test.ts`, `src/components/bug-report` | `pnpm --filter @tessera/web exec vitest run src/lib src/middleware.test.ts src/components/bug-report` | 101/101 gruen (13 Testdateien) |
| Web `type-check` | `pnpm --filter @tessera/web type-check` | ohne Fehler |
| Rust `cargo fmt --check` | `cd apps/desktop/src-tauri && cargo fmt --check` | sauber |
| Rust Tests | `cargo test --lib` | 37/37 gruen |
| Abhaengigkeiten/Schema/main.ts | `git diff ab99a9a..HEAD -- '**/package.json' '**/Cargo.toml' '**/Cargo.lock' '**/pnpm-lock.yaml' apps/api/prisma/schema.prisma apps/api/src/main.ts` | leerer Diff — keine Aenderung |
## Anti-Patterns Found
Keine. `git diff ab99a9a..HEAD` ueber alle betroffenen Dateien enthaelt keine Treffer fuer `TODO|FIXME|XXX|TBD|HACK|PLACEHOLDER|not yet implemented|coming soon`.
## Requirements Coverage
Dieser Quick-Task ist nicht an `.planning/REQUIREMENTS.md` gebunden (kein Phasen-Requirement); der Plan traegt `requirements: [QUICK-260918-GZA]` als eigene Kennung, deren einziges Artefakt dieser Task selbst ist. Kein Abgleich noetig.
## Human Verification Required
Zwei Nachweise sind laut Plan ausdruecklich Aufgabe des Orchestrators nach der Ausfuehrung, nicht des Executors — beide sind reine End-to-End-Proben (laufender Stack bzw. Windows-VM) und nicht statisch im Code pruefbar. Sie blockieren den Status NICHT (siehe Verify-Auftrag): **Nachweis durch Orchestrator ausstehend.**
1. **Browser-Fall (Playwright MCP + mailhog):** lokal `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mailhog` + `docker compose up -d --build api web`, ueber den Fehler-melden-Knopf senden, in `http://localhost:8025` pruefen: Betreff `[Tessera Fehlermeldung] [Browser] dev dev - /…`, Zeile `Herkunft: Browser — Chrome <N> auf Linux`, `docker compose logs api | grep "Bug report"` zeigt das Kuerzel.
2. **Desktop-Fall (Windows-Test-VM nach CI-Bau):** Client installieren/aktualisieren, gegen alpha melden -> Betreff `[Desktop/Windows]`, Zeile `Herkunft: Desktop-App (Windows), Tessera-App <Version> · Stand <sha7>`.
(Der dritte im Plan genannte Fall — alter Desktop-Client ohne Update — ist laut Plan optional und bereits durch `origin.spec.ts` Test 8 sowie `middleware.test.ts` Test 7 statisch abgedeckt.)
## Gaps Summary
Keine. Alle acht im Pruefauftrag genannten Punkte sowie die Rueckwaertskompatibilitaets-Wahrheit aus dem Plan sind direkt im Code nachgewiesen; alle vier Testsuiten (API, Web, Rust, Doku-Grep) wurden selbst ausgefuehrt und liefern die im SUMMARY behaupteten Zahlen exakt reproduziert (24 API-Tests, 101 Web-Tests, 37 Rust-Tests). Keine neue Abhaengigkeit, kein Schema-Wechsel, `main.ts` unveraendert. Die beiden offenen Punkte sind manuelle End-to-End-Proben, die laut Plan explizit dem Orchestrator obliegen.
---
_Verified: 2026-09-18_
_Verifier: Claude (gsd-verifier)_
+1
View File
@@ -16,6 +16,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Tessera-Bildmarke: das ganze T übernimmt die persönliche Akzentfarbe (die vier Kacheln in einem dunkleren Ton derselben Farbe) - Tessera-Bildmarke: das ganze T übernimmt die persönliche Akzentfarbe (die vier Kacheln in einem dunkleren Ton derselben Farbe)
- Desktop-App: Beta-Pakete werden nur noch neu gebaut, wenn sich an der Desktop-App etwas geändert hat; sonst bleiben die zuletzt gebauten Pakete gültig, und die App meldet keinen neuen Beta-Stand - Desktop-App: Beta-Pakete werden nur noch neu gebaut, wenn sich an der Desktop-App etwas geändert hat; sonst bleiben die zuletzt gebauten Pakete gültig, und die App meldet keinen neuen Beta-Stand
- Fehler melden: Fehlermeldungen nennen jetzt die Herkunft – Browser oder Desktop-App, Betriebssystem, bei der Desktop-App auch Version und Stand; der Betreff trägt dafür ein Kürzel wie „[Browser]“, „[Desktop/Windows]“ oder „[Desktop/Linux]“, nach dem sich das Postfach sortieren lässt
### Behoben ### Behoben
@@ -8,7 +8,7 @@ import { BugReportDto } from './dto/bug-report.dto';
/** /**
* BugReportsController.spec — NEU (quick-260914-m97, Fehler-melden-Knopf). * BugReportsController.spec — NEU (quick-260914-m97, Fehler-melden-Knopf).
* *
* Drei Tests an der Grenze Browser -> API: * Vier Tests an der Grenze Browser -> API:
* 1. die globale Pipe (`whitelist: true, transform: true`, wie in * 1. die globale Pipe (`whitelist: true, transform: true`, wie in
* `main.ts`) entfernt Fremdfelder wie `tenantId` (T-M97-06) und * `main.ts`) entfernt Fremdfelder wie `tenantId` (T-M97-06) und
* normalisiert `errors` (multer/append-field liefert EIN Feld als * normalisiert `errors` (multer/append-field liefert EIN Feld als
@@ -16,6 +16,8 @@ import { BugReportDto } from './dto/bug-report.dto';
* 2. die DTO-Grenzen greifen (31 Eintraege, 4001 Zeichen -> 400); * 2. die DTO-Grenzen greifen (31 Eintraege, 4001 Zeichen -> 400);
* 3. die Route steht JEDEM angemeldeten Benutzer offen — kein * 3. die Route steht JEDEM angemeldeten Benutzer offen — kein
* `@Roles`-Metadatum, Pfad `bug-reports`. * `@Roles`-Metadatum, Pfad `bug-reports`.
* 4. (quick-260918-gza) die vier neuen Client-Felder bleiben optional
* und laengenbegrenzt erhalten.
*/ */
const pipe = new ValidationPipe({ whitelist: true, transform: true }); const pipe = new ValidationPipe({ whitelist: true, transform: true });
const meta = { type: 'body' as const, metatype: BugReportDto }; const meta = { type: 'body' as const, metatype: BugReportDto };
@@ -63,4 +65,29 @@ describe('BugReportsController (quick-260914-m97)', () => {
expect(Reflect.getMetadata(ROLES_KEY, BugReportsController.prototype.submit)).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, BugReportsController.prototype.submit)).toBeUndefined();
expect(Reflect.getMetadata('path', BugReportsController)).toBe('bug-reports'); expect(Reflect.getMetadata('path', BugReportsController)).toBe('bug-reports');
}); });
it('Test 4 (quick-260918-gza): die vier Client-Felder bleiben optional und laengenbegrenzt erhalten', async () => {
const withDesktop = (await pipe.transform(
{ ...baseBody, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' },
meta,
)) as any;
expect(withDesktop.clientKind).toBe('desktop');
expect(withDesktop.clientOs).toBe('windows');
expect(withDesktop.clientVersion).toBe('1.2.0');
expect(withDesktop.clientCommit).toBe('');
const withoutClientFields = (await pipe.transform({ ...baseBody }, meta)) as any;
expect(withoutClientFields.clientKind).toBeUndefined();
await expect(pipe.transform({ ...baseBody, clientKind: 'tablet' }, meta)).rejects.toThrow(BadRequestException);
await expect(pipe.transform({ ...baseBody, clientOs: 'x'.repeat(21) }, meta)).rejects.toThrow(
BadRequestException,
);
await expect(pipe.transform({ ...baseBody, clientVersion: 'x'.repeat(41) }, meta)).rejects.toThrow(
BadRequestException,
);
await expect(pipe.transform({ ...baseBody, clientCommit: 'x'.repeat(41) }, meta)).rejects.toThrow(
BadRequestException,
);
});
}); });
@@ -11,7 +11,7 @@ import { BugReportsService } from './bug-reports.service';
/** /**
* BugReportsService.spec — NEU (quick-260914-m97, Fehler-melden-Knopf). * BugReportsService.spec — NEU (quick-260914-m97, Fehler-melden-Knopf).
* *
* Acht Tests, darunter die vier Falsifizierungen des Plans: * Zehn Tests, darunter die vier Falsifizierungen des Plans:
* (a) Drossel: der sechste Bericht in zehn Minuten -> 429, nach dem * (a) Drossel: der sechste Bericht in zehn Minuten -> 429, nach dem
* Fenster (Fake-Timer) wieder durch; * Fenster (Fake-Timer) wieder durch;
* (b) manipulierte Bilddatei ohne PNG-Kopf -> 400, nie versendet; * (b) manipulierte Bilddatei ohne PNG-Kopf -> 400, nie versendet;
@@ -20,6 +20,8 @@ import { BugReportsService } from './bug-reports.service';
* (d) Fremdfelder im Rumpf (tenantId/userId) aendern NICHTS an der * (d) Fremdfelder im Rumpf (tenantId/userId) aendern NICHTS an der
* Mandantenkennung — Empfaenger, Benutzerzeile und Versand laufen * Mandantenkennung — Empfaenger, Benutzerzeile und Versand laufen
* ausschliesslich mit der Kennung aus dem Sitzungsnachweis. * ausschliesslich mit der Kennung aus dem Sitzungsnachweis.
* Test 9 und 10 (quick-260918-gza): Betreff-Kuerzel und Zeile `Herkunft:`
* fuer den Desktop- und den Browser-Pfad, Protokollzeile mit Kuerzel.
* *
* `forTenant` wird wie in `user.controller.spec.ts` durch einen gebundenen * `forTenant` wird wie in `user.controller.spec.ts` durch einen gebundenen
* Fake-Klienten ersetzt, der nur Zeilen des eigenen Mandanten liefert; * Fake-Klienten ersetzt, der nur Zeilen des eigenen Mandanten liefert;
@@ -135,7 +137,7 @@ describe('BugReportsService (quick-260914-m97)', () => {
const [tenantId, to, report] = mailService.sendBugReport.mock.calls[0] as any[]; const [tenantId, to, report] = mailService.sendBugReport.mock.calls[0] as any[];
expect(tenantId).toBe('t1'); expect(tenantId).toBe('t1');
expect(to).toBe('fehler@a.example.invalid'); expect(to).toBe('fehler@a.example.invalid');
expect(report.subject).toBe('[Tessera Fehlermeldung] v1.2.3 beta - /admin/users?tab=x'); expect(report.subject).toBe('[Tessera Fehlermeldung] [Browser] v1.2.3 beta - /admin/users?tab=x');
for (const needle of [ for (const needle of [
'Knopf tut nichts', 'Knopf tut nichts',
'/admin/users?tab=x', '/admin/users?tab=x',
@@ -145,6 +147,7 @@ describe('BugReportsService (quick-260914-m97)', () => {
't1', 't1',
'v1.2.3 (beta) abc1234', 'v1.2.3 (beta) abc1234',
'Tessera API v9.9.9 (live)', 'Tessera API v9.9.9 (live)',
'Herkunft: Browser — unbekannt auf unbekannt',
'UA', 'UA',
'1920x1080', '1920x1080',
'[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}', '[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}',
@@ -291,4 +294,53 @@ describe('BugReportsService (quick-260914-m97)', () => {
expect(report.text).not.toContain('Eindringling'); expect(report.text).not.toContain('Eindringling');
expect(report.text).not.toContain('fremd@x.invalid'); expect(report.text).not.toContain('fremd@x.invalid');
}); });
it('Test 9 (quick-260918-gza): Desktop/Windows — Betreff-Kuerzel, Zeile Herkunft VOR Browser:, Protokollzeile mit Kuerzel', async () => {
const { service, mailService } = makeService({});
const edgeUa =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52';
await service.submit(
sessionUser,
{
...baseDto,
clientKind: 'desktop',
clientOs: 'windows',
clientVersion: '1.2.0',
clientCommit: 'a6d1a64',
userAgent: edgeUa,
} as any,
undefined,
);
const report = (mailService.sendBugReport.mock.calls[0] as any[])[2];
expect(report.subject.startsWith('[Tessera Fehlermeldung] [Desktop/Windows] v1.2.3 beta - ')).toBe(true);
expect(report.text).toContain('Herkunft: Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64');
expect(report.text).toContain('Browser: Mozilla/5.0 (Windows NT 10.0');
expect(report.text).toContain('Fenster: 1920x1080');
expect(report.text.indexOf('Herkunft:')).toBeLessThan(report.text.indexOf('Browser:'));
const { service: freshService, mailService: freshMail } = makeService({});
freshMail.sendBugReport.mockClear();
(freshService as any).logger.log.mockClear();
await freshService.submit(
sessionUser,
{ ...baseDto, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64', userAgent: edgeUa } as any,
undefined,
);
expect((freshService as any).logger.log).toHaveBeenCalledTimes(1);
expect((freshService as any).logger.log.mock.calls[0][0]).toContain('[Desktop/Windows]');
});
it('Test 10 (quick-260918-gza): Browser mit echtem User-Agent — Betreff und Zeile Herkunft nennen Browser und Version', async () => {
const { service, mailService } = makeService({});
const chromeUa =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36';
await service.submit(sessionUser, { ...baseDto, clientKind: 'browser', userAgent: chromeUa } as any, undefined);
const report = (mailService.sendBugReport.mock.calls[0] as any[])[2];
expect(report.subject).toContain('[Browser]');
expect(report.text).toContain('Herkunft: Browser — Chrome 129 auf Windows');
});
}); });
@@ -14,6 +14,7 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SettingsService } from '../settings/settings.service'; import { SettingsService } from '../settings/settings.service';
import { BugReportDto } from './dto/bug-report.dto'; import { BugReportDto } from './dto/bug-report.dto';
import { describeOrigin } from './origin';
/** /**
* BugReportsService — Fehler-melden-Knopf (quick-260914-m97). * BugReportsService — Fehler-melden-Knopf (quick-260914-m97).
@@ -43,6 +44,14 @@ import { BugReportDto } from './dto/bug-report.dto';
* Benutzer ausschliesslich aus dem Sitzungsnachweis, Benutzerzeile ueber * Benutzer ausschliesslich aus dem Sitzungsnachweis, Benutzerzeile ueber
* einen gebundenen Klienten — Zeile in * einen gebundenen Klienten — Zeile in
* docs/mandantentrennung-zugriffsklassifikation.md). * docs/mandantentrennung-zugriffsklassifikation.md).
*
* Herkunft (quick-260918-gza): der Betreff traegt seit dieser Aenderung
* ein Kuerzel (`[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]`, oder
* `[Desktop]` bei einem alten Client ohne Details), damit sich das
* Postfach danach sortieren laesst — WebView2 sieht sonst aus wie Edge,
* WebKitGTK wie Safari. Der rohe User-Agent bleibt in der Zeile
* `Browser:` unveraendert erhalten, die Ableitung selbst steht in
* `origin.ts` (dort auch T-GZA-01: rein informativ, laengenbegrenzt).
*/ */
const WINDOW_MS = 10 * 60 * 1000; const WINDOW_MS = 10 * 60 * 1000;
@@ -119,9 +128,10 @@ export class BugReportsService {
const email: string = row?.email ?? '-'; const email: string = row?.email ?? '-';
const role: string = row?.role ?? user.role; const role: string = row?.role ?? user.role;
// (5) Betreff // (5) Betreff — Herkunfts-Kuerzel direkt nach dem festen Praefix (quick-260918-gza).
const origin = describeOrigin(dto);
const pageShort = dto.page.slice(0, 120); const pageShort = dto.page.slice(0, 120);
const subject = `[Tessera Fehlermeldung] ${dto.webVersion} ${dto.webChannel} - ${pageShort}`; const subject = `[Tessera Fehlermeldung] ${origin.tag} ${dto.webVersion} ${dto.webChannel} - ${pageShort}`;
// (6) Text // (6) Text
const bytes = file ? file.buffer.length : 0; const bytes = file ? file.buffer.length : 0;
@@ -139,6 +149,7 @@ export class BugReportsService {
`Mandant: ${user.tenantId}`, `Mandant: ${user.tenantId}`,
`Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(), `Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(),
`API: ${formatAppVersionLine()}`, `API: ${formatAppVersionLine()}`,
`Herkunft: ${origin.line}`,
`Browser: ${dto.userAgent}`, `Browser: ${dto.userAgent}`,
`Fenster: ${dto.viewport}`, `Fenster: ${dto.viewport}`,
'', '',
@@ -164,8 +175,10 @@ export class BugReportsService {
} }
// (9) Genau eine Protokollzeile — nie Beschreibung, nie Bild (T-M97-07). // (9) Genau eine Protokollzeile — nie Beschreibung, nie Bild (T-M97-07).
// Das Kuerzel ist ein aufgezaehlter Wert aus origin.ts, nie ein roher
// Client-String — deshalb protokollierbar (T-GZA-01).
this.logger.log( this.logger.log(
`Bug report from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`, `Bug report ${origin.tag} from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`,
); );
return { sent: true }; return { sent: true };
} }
@@ -2,6 +2,7 @@ import { Expose, Transform } from 'class-transformer';
import { import {
ArrayMaxSize, ArrayMaxSize,
IsArray, IsArray,
IsIn,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
@@ -21,6 +22,16 @@ import {
* Dienst nimmt beides ausschliesslich aus dem Sitzungsnachweis * Dienst nimmt beides ausschliesslich aus dem Sitzungsnachweis
* (`@CurrentUser()`), und `whitelist: true` der globalen ValidationPipe * (`@CurrentUser()`), und `whitelist: true` der globalen ValidationPipe
* entfernt jedes Fremdfeld, das ein Client hier trotzdem mitschickt. * entfernt jedes Fremdfeld, das ein Client hier trotzdem mitschickt.
*
* Vier weitere Felder (`clientKind`, `clientOs`, `clientVersion`,
* `clientCommit`) kommen seit quick-260918-gza vom Web-Client dazu — im
* Browser als `clientKind=browser` mit leeren uebrigen Feldern, in der
* Desktop-App aus dem Cookie `tessera_desktop_client`. Sie sind optional,
* damit aeltere Web-Baue weiterhin gueltig senden (der Dienst faellt ohne
* sie auf `browser` zurueck); `whitelist: true` verlangt ihre Deklaration
* hier, sonst wuerde die Pipe sie stillschweigend entfernen. Sie dienen
* ausschliesslich der Anzeige in der Mail und sind laengenbegrenzt
* (T-GZA-01) — siehe `origin.ts`.
*/ */
export class BugReportDto { export class BugReportDto {
/** Freitext „Was ist passiert?“ — optional, hoechstens 4000 Zeichen. */ /** Freitext „Was ist passiert?“ — optional, hoechstens 4000 Zeichen. */
@@ -77,4 +88,27 @@ export class BugReportDto {
@IsString({ each: true }) @IsString({ each: true })
@MaxLength(1000, { each: true }) @MaxLength(1000, { each: true })
errors!: string[]; errors!: string[];
/** `desktop` oder `browser` — vom Web-Client gesetzt, siehe Kopfkommentar. */
@IsOptional()
@IsIn(['desktop', 'browser'])
clientKind?: 'desktop' | 'browser';
/** Betriebssystem der Desktop-App (`windows`, `linux`, `macos`, …), unbeglaubigt. */
@IsOptional()
@IsString()
@MaxLength(20)
clientOs?: string;
/** Version der Desktop-App (`CARGO_PKG_VERSION`), unbeglaubigt. */
@IsOptional()
@IsString()
@MaxLength(40)
clientVersion?: string;
/** Commit-Stempel der Desktop-App (`APP_COMMIT`), darf leer sein. */
@IsOptional()
@IsString()
@MaxLength(40)
clientCommit?: string;
} }
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import { describeOrigin, parseUserAgent } from './origin';
/**
* origin.spec.ts — NEU (quick-260918-gza, Herkunft einer Fehlermeldung).
*
* Zehn Tests fuer die beiden reinen Funktionen aus origin.ts:
* 1-5 `parseUserAgent` fuer Desktop- und Mobil-Browser (Reihenfolge der
* Muster: Edge/Opera vor Chrome, Safari nur ohne Chrome-Token,
* Android/iOS vor Linux/macOS);
* 6-8 `describeOrigin` im Desktop-Fall (mit/ohne Commit, ganz ohne
* Details — alter Client);
* 9 Rueckfall auf den Browser-Pfad, wenn alle vier Client-Felder
* fehlen (alter Web-Bau);
* 10 Bereinigung: kein Zeilenumbruch, kein Markup, hoechstens 40
* Zeichen je Wert (T-GZA-01).
*/
const EDGE_WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52';
const CHROME_WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36';
const FIREFOX_LINUX_UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0';
const SAFARI_MACOS_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15';
const ANDROID_CHROME_UA =
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
const IPAD_SAFARI_UA =
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
const OPERA_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 OPR/114.0.0.0';
const WEBKITGTK_LINUX_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
describe('origin (quick-260918-gza)', () => {
it('Test 1: Edge auf Windows — Edge gewinnt vor Chrome', () => {
expect(parseUserAgent(EDGE_WINDOWS_UA)).toEqual({ browser: 'Edge 129', os: 'Windows' });
expect(describeOrigin({ clientKind: 'browser', userAgent: EDGE_WINDOWS_UA })).toEqual({
tag: '[Browser]',
line: 'Browser — Edge 129 auf Windows',
});
});
it('Test 2: derselbe UA ohne Edg/ — Chrome auf Windows', () => {
expect(parseUserAgent(CHROME_WINDOWS_UA)).toEqual({ browser: 'Chrome 129', os: 'Windows' });
});
it('Test 3: Firefox auf Linux', () => {
expect(parseUserAgent(FIREFOX_LINUX_UA)).toEqual({ browser: 'Firefox 130', os: 'Linux' });
expect(describeOrigin({ userAgent: FIREFOX_LINUX_UA }).line).toBe('Browser — Firefox 130 auf Linux');
});
it('Test 4: Safari auf macOS — Version aus Version/, nur ohne Chrome-Token', () => {
expect(parseUserAgent(SAFARI_MACOS_UA)).toEqual({ browser: 'Safari 17', os: 'macOS' });
});
it('Test 5: Mobil — Android statt Linux, iOS statt macOS, Opera erkannt', () => {
expect(parseUserAgent(ANDROID_CHROME_UA).os).toBe('Android');
const ipad = parseUserAgent(IPAD_SAFARI_UA);
expect(ipad.os).toBe('iOS');
expect(ipad.browser).toBe('Safari 17');
expect(parseUserAgent(OPERA_UA).browser).toBe('Opera 114');
});
it('Test 6: WebKitGTK-Client als Desktop — der User-Agent spielt keine Rolle', () => {
expect(
describeOrigin({
userAgent: WEBKITGTK_LINUX_UA,
clientKind: 'desktop',
clientOs: 'linux',
clientVersion: '1.2.0',
clientCommit: 'a6d1a64',
}),
).toEqual({
tag: '[Desktop/Linux]',
line: 'Desktop-App (Linux), Tessera-App 1.2.0 · Stand a6d1a64',
});
});
it('Test 7: Desktop ohne Commit — kein "· Stand"; Leerraum wird wie ein leerer Commit behandelt', () => {
expect(
describeOrigin({ clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' }),
).toEqual({ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' });
expect(
describeOrigin({ clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: ' ' }),
).toEqual({ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' });
});
it('Test 8: Desktop ohne Details (alter Client, Cookie fehlt) — unbekanntes Betriebssystem ebenfalls', () => {
expect(
describeOrigin({ clientKind: 'desktop', clientOs: '', clientVersion: '', clientCommit: '' }),
).toEqual({ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' });
expect(
describeOrigin({ clientKind: 'desktop', clientOs: 'freebsd', clientVersion: '', clientCommit: '' }),
).toEqual({ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' });
});
it('Test 9: Rueckfall auf den Browser-Pfad, wenn alle vier Client-Felder fehlen (alter Web-Bau)', () => {
expect(describeOrigin({ userAgent: 'UA' })).toEqual({
tag: '[Browser]',
line: 'Browser — unbekannt auf unbekannt',
});
expect(describeOrigin({ clientKind: 'browser', userAgent: '' })).toEqual({
tag: '[Browser]',
line: 'Browser — unbekannt auf unbekannt',
});
});
it('Test 10: Bereinigung — kein Zeilenumbruch, kein Markup, hoechstens 40 Zeichen je Wert (T-GZA-01)', () => {
const origin = describeOrigin({
clientKind: 'desktop',
clientOs: 'windows',
clientVersion: '1.2.0\nBenutzer: admin',
clientCommit: 'a6d1a64<b>',
});
expect(origin.line).not.toMatch(/[\n\r<>]/);
expect(origin.line).toBe('Desktop-App (Windows), Tessera-App 1.2.0Benutzeradmin · Stand a6d1a64b');
const longValue = 'x'.repeat(60);
const truncated = describeOrigin({
clientKind: 'desktop',
clientOs: 'linux',
clientVersion: longValue,
clientCommit: longValue,
});
expect(truncated.line).toBe(`Desktop-App (Linux), Tessera-App ${'x'.repeat(40)} · Stand ${'x'.repeat(40)}`);
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* origin.ts — Herkunft einer Fehlermeldung ausweisen (quick-260918-gza).
*
* Zweck: WebView2 (Windows-Desktop-App) sieht im User-Agent aus wie Edge,
* WebKitGTK (Linux-Desktop-App) wie Safari — im Postfach des Betreibers ist
* damit nicht erkennbar, ob eine Meldung aus einem Client oder einem
* echten Browser stammt. `describeOrigin()` leitet aus den vier optionalen
* Client-Feldern (gesetzt durch die Desktop-App ueber das Cookie
* `tessera_desktop_client`) und, im Browser-Fall, aus dem User-Agent ein
* kurzes Betreff-Kuerzel und eine Textzeile ab.
*
* Trust-Modell (T-GZA-01): alle Eingaben stammen unbeglaubigt vom Client,
* wie der bestehende User-Agent auch. Die Ableitung ist rein informativ —
* sie entscheidet nie ueber Routing, Berechtigung oder Speicherung, und
* nichts davon wird in der Datenbank abgelegt. `clean()` laesst nur ein
* begrenztes Alphabet und hoechstens 40 Zeichen zu, damit weder ein
* Zeilenumbruch noch Markup in die E-Mail gelangt; das Betriebssystem wird
* ausserdem auf eine feste Menge von Labels abgebildet.
*
* Warum Regex statt Bibliothek: fuenf Browser und fuenf Betriebssysteme
* reichen aus, um ein Postfach sinnvoll zu sortieren — ein neues Paket
* (etwa ua-parser-js) waere fuer diesen Umfang unverhaeltnismaessig.
*/
export type ClientKind = 'desktop' | 'browser';
export interface OriginInput {
clientKind?: string;
clientOs?: string;
clientVersion?: string;
clientCommit?: string;
userAgent?: string;
}
export interface Origin {
/** Betreff-Kuerzel in eckigen Klammern, z. B. `[Browser]`, `[Desktop/Windows]`. */
tag: string;
/** Text NACH dem Label `Herkunft: ` — der Dienst setzt das Label davor. */
line: string;
}
export interface ParsedUserAgent {
browser: string;
os: string;
}
const UNKNOWN = 'unbekannt';
/**
* Entfernt alles ausser `[A-Za-z0-9.+_-]` und kuerzt auf `max` Zeichen
* (Vorgabe 40) — kein Zeilenumbruch, kein Markup in Betreff oder Text
* (T-GZA-01).
*/
function clean(value: unknown, max = 40): string {
return String(value ?? '')
.replace(/[^A-Za-z0-9.+_-]/g, '')
.slice(0, max);
}
/**
* Leitet Browsername samt Hauptversion und Betriebssystem aus einem
* User-Agent ab. Reihenfolge ist entscheidend: Android-UAs enthalten
* `Linux`, iPad-UAs enthalten `like Mac OS X`, Edge- und Opera-UAs
* enthalten sowohl `Chrome/` als auch `Safari/` — die spezifischeren
* Muster muessen deshalb zuerst gewinnen.
*/
export function parseUserAgent(ua: string): ParsedUserAgent {
const value = ua ?? '';
let browser = UNKNOWN;
const edg = value.match(/Edg\/(\d+)/);
const opr = value.match(/OPR\/(\d+)/);
const firefox = value.match(/Firefox\/(\d+)/);
const chrome = value.match(/(?:Chrome|CriOS)\/(\d+)/);
const hasSafariToken = /Safari\//.test(value);
const hasChromeToken = /Chrome\//.test(value);
if (edg) {
browser = `Edge ${edg[1]}`;
} else if (opr) {
browser = `Opera ${opr[1]}`;
} else if (firefox) {
browser = `Firefox ${firefox[1]}`;
} else if (chrome) {
browser = `Chrome ${chrome[1]}`;
} else if (hasSafariToken && !hasChromeToken) {
const version = value.match(/Version\/(\d+)/);
browser = version ? `Safari ${version[1]}` : 'Safari';
}
let os = UNKNOWN;
if (/Windows NT/.test(value)) {
os = 'Windows';
} else if (/Android/.test(value)) {
os = 'Android';
} else if (/iPhone|iPad|iPod/.test(value)) {
os = 'iOS';
} else if (/Mac OS X|Macintosh/.test(value)) {
os = 'macOS';
} else if (/Linux|X11/.test(value)) {
os = 'Linux';
}
return { browser, os };
}
/** Bildet `clean(clientOs)` auf eines von drei festen Labels ab. */
function desktopOsLabel(clientOs: string | undefined): string {
switch (clean(clientOs).toLowerCase()) {
case 'windows':
return 'Windows';
case 'linux':
return 'Linux';
case 'macos':
return 'macOS';
default:
return UNKNOWN;
}
}
/**
* Leitet Betreff-Kuerzel und Herkunftszeile ab. Desktop-Fall: der
* User-Agent spielt keine Rolle, nur die vier Client-Felder zaehlen
* (dieselbe Regel wie `client_info_label` im Rust-Client — Version und
* Commit werden nur zusammen mit " · Stand " verbunden, ein leerer
* Commit faellt weg). Alles andere (auch fehlende Felder, alter
* Web-Bau) faellt auf den Browser-Pfad mit `parseUserAgent` zurueck.
*/
export function describeOrigin(input: OriginInput): Origin {
if (input.clientKind === 'desktop') {
const osLabel = desktopOsLabel(input.clientOs);
const version = clean(input.clientVersion);
const commit = clean(input.clientCommit);
let appLabel = '';
if (version && commit) {
appLabel = `Tessera-App ${version} · Stand ${commit}`;
} else if (version) {
appLabel = `Tessera-App ${version}`;
}
const line = appLabel ? `Desktop-App (${osLabel}), ${appLabel}` : `Desktop-App (${osLabel})`;
const tag = osLabel === UNKNOWN ? '[Desktop]' : `[Desktop/${osLabel}]`;
return { tag, line };
}
const { browser, os } = parseUserAgent(input.userAgent ?? '');
return { tag: '[Browser]', line: `Browser — ${browser} auf ${os}` };
}
+82 -19
View File
@@ -46,19 +46,46 @@ fn api_url(server: &str, path: &str) -> String {
format!("{}/api-proxy{}", server.trim_end_matches('/'), path) format!("{}/api-proxy{}", server.trim_end_matches('/'), path)
} }
/// Haengt das Query-Paar `desktop=1` an einen Klon der Adresse an und gibt /// Haengt `desktop=1` sowie Version, Commit-Stempel und Betriebssystem an
/// den Klon zurueck -- das Original bleibt unveraendert. Der Parameter geht /// einen Klon der Adresse an und gibt den Klon zurueck -- das Original
/// NUR in die Navigation, niemals in den gespeicherten Server-Wert /// bleibt unveraendert. Reine Funktion (kein `env!`), darum in den Tests
/// (`server_url` im Store bleibt die reine Adresse); daraus liest die /// unten mit festen Werten geprueft; `with_desktop_marker` ist die Huelle
/// Next.js-Middleware (apps/web/src/middleware.ts) das Cookie /// mit den echten Werten der laufenden App. `commit` wird getrimmt, bleibt
/// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Der /// aber auch leer als Paar erhalten (`dc=`), damit die Middleware
/// Browser-Rueckfall des Tray-Eintrags "Update installieren" /// zuverlaessig erkennt, ob alle drei Parameter vorhanden sind.
/// (`open_download_page`) bekommt den Parameter bewusst NICHT -- er oeffnet fn with_client_marker(url: &tauri::Url, version: &str, commit: &str, os: &str) -> tauri::Url {
/// im System-Browser, dort muessen die Download-Links sichtbar bleiben.
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
let mut marked = url.clone(); let mut marked = url.clone();
marked.query_pairs_mut().append_pair("desktop", "1");
marked marked
.query_pairs_mut()
.append_pair("desktop", "1")
.append_pair("dv", version)
.append_pair("dc", commit.trim())
.append_pair("dos", os);
marked
}
/// Huelle um `with_client_marker` mit den Werten der laufenden App. Seit
/// quick-260918-gza wandern Version, Commit-Stempel und Betriebssystem
/// (`dv`, `dc`, `dos`) mit -- die Next.js-Middleware
/// (apps/web/src/middleware.ts) legt daraus das Cookie
/// `tessera_desktop_client` an, aus dem der Fehler-melden-Knopf die
/// Herkunft der Meldung fuellt (Betreff-Kuerzel `[Desktop/Windows]` bzw.
/// `[Desktop/Linux]` statt `[Browser]`, weil WebView2 und WebKitGTK im
/// User-Agent sonst wie ein gewoehnlicher Browser aussehen). `desktop=1`
/// bleibt unveraendert an erster Stelle, damit ein neuer Client gegen eine
/// aeltere Middleware weiterhin als Desktop-App erkannt wird. Die Werte
/// gehen NUR in die Navigation, niemals in den gespeicherten Server-Wert
/// (`server_url` im Store bleibt die reine Adresse). Der Browser-Rueckfall
/// des Tray-Eintrags "Update installieren" (`open_download_page`) bekommt
/// den Marker bewusst NICHT -- er oeffnet im System-Browser, dort muessen
/// die Download-Links sichtbar bleiben.
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
with_client_marker(
url,
env!("CARGO_PKG_VERSION"),
env!("APP_COMMIT"),
std::env::consts::OS,
)
} }
/// Liefert (Menuetext, Benachrichtigungstext) fuer den Update-Hinweis. Der /// Liefert (Menuetext, Benachrichtigungstext) fuer den Update-Hinweis. Der
@@ -700,26 +727,62 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn with_desktop_marker_ohne_pfad_haengt_query_an() { fn with_client_marker_ohne_pfad_haengt_query_an() {
let url = tauri::Url::parse("https://tessera.example.com").unwrap(); let url = tauri::Url::parse("https://tessera.example.com").unwrap();
let marked = with_desktop_marker(&url); let marked = with_client_marker(&url, "1.2.0", "a6d1a64", "windows");
assert_eq!(marked.as_str(), "https://tessera.example.com/?desktop=1"); assert_eq!(
marked.as_str(),
"https://tessera.example.com/?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows"
);
} }
#[test] #[test]
fn with_desktop_marker_mit_vorhandenem_query_haengt_an() { fn with_client_marker_mit_vorhandenem_query_haengt_an() {
let url = tauri::Url::parse("https://host/app?x=1").unwrap(); let url = tauri::Url::parse("https://host/app?x=1").unwrap();
let marked = with_desktop_marker(&url); let marked = with_client_marker(&url, "1.2.0", "a6d1a64", "windows");
assert_eq!(marked.as_str(), "https://host/app?x=1&desktop=1"); assert_eq!(
marked.as_str(),
"https://host/app?x=1&desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows"
);
} }
#[test] #[test]
fn with_desktop_marker_laesst_original_unveraendert() { fn with_client_marker_laesst_original_unveraendert() {
let url = tauri::Url::parse("https://host/app").unwrap(); let url = tauri::Url::parse("https://host/app").unwrap();
let _ = with_desktop_marker(&url); let _ = with_client_marker(&url, "1.2.0", "a6d1a64", "windows");
assert_eq!(url.query(), None); assert_eq!(url.query(), None);
} }
#[test]
fn with_client_marker_leerer_commit_bleibt_als_leeres_paar_erhalten() {
let url = tauri::Url::parse("https://host/app").unwrap();
let marked = with_client_marker(&url, "1.2.0", "", "linux");
assert_eq!(
marked.as_str(),
"https://host/app?desktop=1&dv=1.2.0&dc=&dos=linux"
);
let marked_whitespace = with_client_marker(&url, "1.2.0", " ", "linux");
assert_eq!(
marked_whitespace.as_str(),
"https://host/app?desktop=1&dv=1.2.0&dc=&dos=linux"
);
}
#[test]
fn with_desktop_marker_haengt_alle_vier_paare_an() {
let url = tauri::Url::parse("https://host/app").unwrap();
let marked = with_desktop_marker(&url);
let pairs: Vec<(String, String)> = marked
.query_pairs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
assert!(pairs.contains(&("desktop".to_string(), "1".to_string())));
assert!(pairs.contains(&("dv".to_string(), env!("CARGO_PKG_VERSION").to_string())));
assert!(pairs.contains(&("dos".to_string(), std::env::consts::OS.to_string())));
assert!(pairs.iter().any(|(k, _)| k == "dc"));
}
#[test] #[test]
fn update_labels_bei_versionswechsel() { fn update_labels_bei_versionswechsel() {
let (menu_text, body) = update_labels(true, "1.2.0", "abc1234"); let (menu_text, body) = update_labels(true, "1.2.0", "abc1234");
@@ -8,14 +8,19 @@ import { computeCaptureSize } from '@/lib/bug-report-api';
/** /**
* bug-report-button.test — NEU (quick-260914-m97, Fehler-melden-Knopf). * bug-report-button.test — NEU (quick-260914-m97, Fehler-melden-Knopf).
* *
* Elf Tests. Die Kernanforderung (Test 1): das Bild wird VOR dem Dialog * Dreizehn Tests. Die Kernanforderung (Test 1): das Bild wird VOR dem
* aufgenommen — der `toPng`-Mock prueft waehrend seines Aufrufs, dass noch * Dialog aufgenommen — der `toPng`-Mock prueft waehrend seines Aufrufs,
* KEIN `role="dialog"` im DOM steht. `html-to-image` selbst laeuft in jsdom * dass noch KEIN `role="dialog"` im DOM steht. `html-to-image` selbst
* nicht (gemessen zur Planungszeit: `HTMLVideoElement is not defined`, kein * laeuft in jsdom nicht (gemessen zur Planungszeit:
* Canvas-Backend) — deshalb der Mock; der Bildbeweis kommt aus dem Browser. * `HTMLVideoElement is not defined`, kein Canvas-Backend) — deshalb der
* Mock; der Bildbeweis kommt aus dem Browser.
* *
* Der next-intl-Mock liest die Texte aus der ECHTEN `de.json` (Muster * Der next-intl-Mock liest die Texte aus der ECHTEN `de.json` (Muster
* `tessera-logo.test.tsx`), Erwartungen zitieren `de.bugReport.<key>`. * `tessera-logo.test.tsx`), Erwartungen zitieren `de.bugReport.<key>`.
*
* Test 12/13 (quick-260918-gza): die vier Herkunftsfelder in der Nutzlast,
* abhaengig vom Cookie `tessera_desktop_client` — jsdom hat ohne gesetztes
* Cookie keinen Desktop-Client, darum bleibt Test 2 beim Browser-Fall.
*/ */
const { mockToPng, mockFetch, mockUser } = vi.hoisted(() => ({ const { mockToPng, mockFetch, mockUser } = vi.hoisted(() => ({
mockToPng: vi.fn(), mockToPng: vi.fn(),
@@ -86,9 +91,15 @@ beforeEach(() => {
vi.stubGlobal('fetch', mockFetch); vi.stubGlobal('fetch', mockFetch);
}); });
function clearDesktopCookies() {
document.cookie = 'tessera_desktop=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
document.cookie = 'tessera_desktop_client=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
}
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
clearErrorBuffer(); clearErrorBuffer();
clearDesktopCookies();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -142,6 +153,10 @@ describe('BugReportButton (quick-260914-m97)', () => {
expect(body.get('userAgent')).toBeTruthy(); expect(body.get('userAgent')).toBeTruthy();
expect(body.get('viewport')).toMatch(/^\d+x\d+$/); expect(body.get('viewport')).toMatch(/^\d+x\d+$/);
expect(body.get('clientTime')).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(body.get('clientTime')).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(body.get('clientKind')).toBe('browser');
expect(body.get('clientOs')).toBe('');
expect(body.get('clientVersion')).toBe('');
expect(body.get('clientCommit')).toBe('');
expect(body.getAll('errors').some((e) => String(e).includes('GET /modules -> 500'))).toBe(true); expect(body.getAll('errors').some((e) => String(e).includes('GET /modules -> 500'))).toBe(true);
const shot = body.get('screenshot'); const shot = body.get('screenshot');
expect(shot).toBeInstanceOf(Blob); expect(shot).toBeInstanceOf(Blob);
@@ -271,6 +286,41 @@ describe('BugReportButton (quick-260914-m97)', () => {
expect(screen.queryByText(specific)).toBeNull(); expect(screen.queryByText(specific)).toBeNull();
} }
}); });
it('Test 12 (quick-260918-gza): Desktop-Cookies gesetzt -> clientKind desktop mit Os/Version/Commit aus dem Cookie', async () => {
document.cookie = 'tessera_desktop=1; path=/';
document.cookie = 'tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; path=/';
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.sent);
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
expect(body.get('clientKind')).toBe('desktop');
expect(body.get('clientOs')).toBe('windows');
expect(body.get('clientVersion')).toBe('1.2.0');
expect(body.get('clientCommit')).toBe('a6d1a64');
});
it('Test 13 (quick-260918-gza, alter Client): nur tessera_desktop ohne tessera_desktop_client -> desktop ohne Details', async () => {
document.cookie = 'tessera_desktop=1; path=/';
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.sent);
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
expect(body.get('clientKind')).toBe('desktop');
expect(body.get('clientOs')).toBe('');
expect(body.get('clientVersion')).toBe('');
expect(body.get('clientCommit')).toBe('');
});
}); });
describe('computeCaptureSize (quick-260914-m97, reine Funktion)', () => { describe('computeCaptureSize (quick-260914-m97, reine Funktion)', () => {
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { appVersion } from '@/lib/app-version'; import { appVersion } from '@/lib/app-version';
import { dataUrlToBlob, sendBugReport } from '@/lib/bug-report-api'; import { dataUrlToBlob, sendBugReport } from '@/lib/bug-report-api';
import { getDesktopClientInfo, isDesktopClient } from '@/lib/desktop-client';
import { formatErrorsForReport } from '@/lib/error-buffer'; import { formatErrorsForReport } from '@/lib/error-buffer';
/** /**
@@ -54,6 +55,11 @@ export function BugReportDialog({ open, screenshot, isAdmin, onClose }: BugRepor
const handleSend = async () => { const handleSend = async () => {
setStatus('sending'); setStatus('sending');
// Herkunft (quick-260918-gza): tessera_desktop entscheidet Desktop/
// Browser, tessera_desktop_client liefert die Details dazu; fehlt es
// (alter Client), bleibt es bei Desktop ohne Details.
const desktop = isDesktopClient();
const info = desktop ? getDesktopClientInfo() : null;
const result = await sendBugReport({ const result = await sendBugReport({
description: description.trim(), description: description.trim(),
page: window.location.pathname + window.location.search, page: window.location.pathname + window.location.search,
@@ -65,6 +71,10 @@ export function BugReportDialog({ open, screenshot, isAdmin, onClose }: BugRepor
clientTime: new Date().toISOString(), clientTime: new Date().toISOString(),
errors: formatErrorsForReport(), errors: formatErrorsForReport(),
screenshot: attach && screenshot ? dataUrlToBlob(screenshot) : null, screenshot: attach && screenshot ? dataUrlToBlob(screenshot) : null,
clientKind: desktop ? 'desktop' : 'browser',
clientOs: info?.os ?? '',
clientVersion: info?.version ?? '',
clientCommit: info?.commit ?? '',
}); });
if (result.ok) { if (result.ok) {
setStatus('sent'); setStatus('sent');
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { sendBugReport } from './bug-report-api';
/**
* bug-report-api.test — NEU (quick-260918-gza). Bisher pruefte nur der
* Komponententest (`bug-report-button.test.tsx`) die FormData von
* `sendBugReport` — die reinen Nutzlastfelder (insbesondere die vier neuen
* Herkunftsfelder) gehoeren aber an die Funktion selbst, unabhaengig vom
* Dialog. Drei Tests: Desktop-Nutzlast, Browser-Nutzlast, Netzwerk-/
* HTTP-Fehler.
*/
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
const basePayload = {
description: 'x',
page: '/a',
webVersion: 'v1',
webChannel: 'beta',
webCommit: 'c',
userAgent: 'UA',
viewport: '1x1',
clientTime: 't',
errors: ['e1', 'e2'],
screenshot: null,
};
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('sendBugReport (quick-260918-gza)', () => {
it('Test 1: Desktop-Nutzlast — clientKind/clientOs/clientVersion/clientCommit im FormData, errors mehrfach, kein screenshot-Feld', async () => {
const result = await sendBugReport({
...basePayload,
clientKind: 'desktop',
clientOs: 'windows',
clientVersion: '1.2.0',
clientCommit: 'a6d1a64',
});
expect(result).toEqual({ ok: true });
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(String(url).endsWith('/bug-reports')).toBe(true);
expect(init.credentials).toBe('include');
const body = init.body as FormData;
expect(body.get('clientKind')).toBe('desktop');
expect(body.get('clientOs')).toBe('windows');
expect(body.get('clientVersion')).toBe('1.2.0');
expect(body.get('clientCommit')).toBe('a6d1a64');
expect(body.getAll('errors')).toEqual(['e1', 'e2']);
expect(body.has('screenshot')).toBe(false);
});
it('Test 2: Browser-Nutzlast — die drei uebrigen Client-Felder sind vorhanden, aber leer (nicht weggelassen)', async () => {
await sendBugReport({
...basePayload,
clientKind: 'browser',
clientOs: '',
clientVersion: '',
clientCommit: '',
});
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
expect(body.get('clientKind')).toBe('browser');
expect(body.get('clientOs')).toBe('');
expect(body.get('clientVersion')).toBe('');
expect(body.get('clientCommit')).toBe('');
});
it('Test 3: Netzwerkfehler -> { ok: false, status: 0 }; HTTP-Fehler -> { ok: false, status }', async () => {
mockFetch.mockRejectedValueOnce(new Error('offline'));
await expect(
sendBugReport({ ...basePayload, clientKind: 'browser', clientOs: '', clientVersion: '', clientCommit: '' }),
).resolves.toEqual({ ok: false, status: 0 });
mockFetch.mockResolvedValueOnce(new Response('', { status: 429 }));
await expect(
sendBugReport({ ...basePayload, clientKind: 'browser', clientOs: '', clientVersion: '', clientCommit: '' }),
).resolves.toEqual({ ok: false, status: 429 });
});
});
+14
View File
@@ -13,6 +13,12 @@
* — das Bild als Datei, alle uebrigen Felder als Text — mit Cookie * — das Bild als Datei, alle uebrigen Felder als Text — mit Cookie
* (`credentials: 'include'`) und OHNE eigenen Content-Type-Header: die * (`credentials: 'include'`) und OHNE eigenen Content-Type-Header: die
* Multipart-Grenze setzt der Browser selbst. * Multipart-Grenze setzt der Browser selbst.
*
* Seit quick-260918-gza traegt die Nutzlast zusaetzlich vier Herkunftsfelder
* (`clientKind`, `clientOs`, `clientVersion`, `clientCommit`) — im Browser
* `clientKind=browser` mit leeren uebrigen Feldern, in der Desktop-App aus
* `getDesktopClientInfo()` (apps/web/src/lib/desktop-client.ts). Die API
* leitet daraus das Betreff-Kuerzel und die Zeile `Herkunft:` ab.
*/ */
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -79,6 +85,10 @@ export interface BugReportPayload {
clientTime: string; clientTime: string;
errors: string[]; errors: string[];
screenshot: Blob | null; screenshot: Blob | null;
clientKind: 'desktop' | 'browser';
clientOs: string;
clientVersion: string;
clientCommit: string;
} }
export type BugReportResult = { ok: true } | { ok: false; status: number }; export type BugReportResult = { ok: true } | { ok: false; status: number };
@@ -93,6 +103,10 @@ export async function sendBugReport(p: BugReportPayload): Promise<BugReportResul
body.append('userAgent', p.userAgent); body.append('userAgent', p.userAgent);
body.append('viewport', p.viewport); body.append('viewport', p.viewport);
body.append('clientTime', p.clientTime); body.append('clientTime', p.clientTime);
body.append('clientKind', p.clientKind);
body.append('clientOs', p.clientOs);
body.append('clientVersion', p.clientVersion);
body.append('clientCommit', p.clientCommit);
for (const line of p.errors) body.append('errors', line); for (const line of p.errors) body.append('errors', line);
if (p.screenshot) body.append('screenshot', p.screenshot, 'screenshot.png'); if (p.screenshot) body.append('screenshot', p.screenshot, 'screenshot.png');
+58 -1
View File
@@ -1,14 +1,24 @@
import { act, renderHook, waitFor } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { DESKTOP_COOKIE_NAME, isDesktopClient, useIsDesktopClient } from './desktop-client'; import {
DESKTOP_CLIENT_COOKIE_NAME,
DESKTOP_COOKIE_NAME,
getDesktopClientInfo,
isDesktopClient,
parseDesktopClientCookie,
useIsDesktopClient,
} from './desktop-client';
/** /**
* desktop-client.test — Gegenstueck zur Middleware (260917-h2s): liest das * desktop-client.test — Gegenstueck zur Middleware (260917-h2s): liest das
* Cookie `tessera_desktop`, das die Middleware auf jede Antwort setzt, wenn * Cookie `tessera_desktop`, das die Middleware auf jede Antwort setzt, wenn
* der Desktop-Client `?desktop=1` an seine erste Navigation anhaengt. * der Desktop-Client `?desktop=1` an seine erste Navigation anhaengt.
* Seit quick-260918-gza zusaetzlich `parseDesktopClientCookie()` /
* `getDesktopClientInfo()` fuer das zweite Cookie `tessera_desktop_client`.
*/ */
function clearCookie() { function clearCookie() {
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
document.cookie = `${DESKTOP_CLIENT_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
} }
afterEach(() => { afterEach(() => {
@@ -50,3 +60,50 @@ describe('useIsDesktopClient', () => {
expect(result.current).toBe(false); expect(result.current).toBe(false);
}); });
}); });
describe('getDesktopClientInfo / parseDesktopClientCookie (quick-260918-gza)', () => {
it('kodierte Form (wie der Browser sie in document.cookie haelt)', () => {
expect(
parseDesktopClientCookie('tessera_desktop=1; tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows'),
).toEqual({ version: '1.2.0', commit: 'a6d1a64', os: 'windows' });
});
it('rohe Form (nicht kodiert)', () => {
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0|a6d1a64|windows')).toEqual({
version: '1.2.0',
commit: 'a6d1a64',
os: 'windows',
});
});
it('leerer Commit bleibt leer', () => {
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0%7C%7Clinux')).toEqual({
version: '1.2.0',
commit: '',
os: 'linux',
});
});
it('ohne Cookie: null', () => {
expect(parseDesktopClientCookie('tessera_desktop=1')).toBeNull();
});
it('unvollstaendige oder leere Pflichtfelder: null', () => {
expect(parseDesktopClientCookie('tessera_desktop_client=abc')).toBeNull();
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0|x')).toBeNull();
expect(parseDesktopClientCookie('tessera_desktop_client=|a6d1a64|linux')).toBeNull();
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0|a6d1a64|')).toBeNull();
});
it('getDesktopClientInfo() liest document.cookie', () => {
expect(getDesktopClientInfo()).toBeNull();
document.cookie = 'tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; path=/';
expect(getDesktopClientInfo()).toEqual({ version: '1.2.0', commit: 'a6d1a64', os: 'windows' });
});
it('getDesktopClientInfo() ohne document: null', () => {
vi.stubGlobal('document', undefined);
expect(getDesktopClientInfo()).toBeNull();
vi.unstubAllGlobals();
});
});
+52
View File
@@ -9,6 +9,58 @@ import { useEffect, useState } from 'react';
*/ */
export const DESKTOP_COOKIE_NAME = 'tessera_desktop'; export const DESKTOP_COOKIE_NAME = 'tessera_desktop';
/**
* Gegenstueck zu `buildDesktopClientCookieValue()` in der Middleware
* (quick-260918-gza): dort werden `dv`/`dc`/`dos` mit `${dv}|${dc}|${dos}`
* zusammengesetzt und von Next per `encodeURIComponent` serialisiert --
* `|` wird `%7C` (gemessen). `document.cookie` liefert deshalb die
* KODIERTE Form; `parseDesktopClientCookie()` dekodiert (in try/catch,
* falls der Wert bereits roh vorliegt, z. B. direkt gesetzt im Test).
*/
export const DESKTOP_CLIENT_COOKIE_NAME = 'tessera_desktop_client';
export interface DesktopClientInfo {
version: string;
commit: string;
os: string;
}
/**
* Parst den Wert des Cookies `tessera_desktop_client` aus einem rohen
* `document.cookie`-String. `null` bei fehlendem Cookie oder einer Form,
* die nicht genau drei durch `|` getrennte Teile mit nicht-leerer Version
* und nicht-leerem Betriebssystem ergibt (Commit darf leer sein).
*/
export function parseDesktopClientCookie(cookieString: string): DesktopClientInfo | null {
const entry = cookieString
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith(`${DESKTOP_CLIENT_COOKIE_NAME}=`));
if (!entry) return null;
const raw = entry.slice(DESKTOP_CLIENT_COOKIE_NAME.length + 1);
let value = raw;
try {
value = decodeURIComponent(raw);
} catch {
// Rohwert (z. B. bereits dekodiert im Test) -- unveraendert weiterverwenden.
}
const parts = value.split('|');
if (parts.length !== 3) return null;
const [version, commit, os] = parts;
if (!version || !os) return null;
return { version, commit, os };
}
/** `null` ohne `document` (SSR) oder ohne gueltiges Cookie. */
export function getDesktopClientInfo(): DesktopClientInfo | null {
if (typeof document === 'undefined') {
return null;
}
return parseDesktopClientCookie(document.cookie);
}
export function isDesktopClient(): boolean { export function isDesktopClient(): boolean {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
return false; return false;
+48
View File
@@ -65,4 +65,52 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
expect(res.headers.get('set-cookie')).toContain('tessera_desktop=1'); expect(res.headers.get('set-cookie')).toContain('tessera_desktop=1');
expect(res.headers.get('x-middleware-next')).toBe('1'); expect(res.headers.get('x-middleware-next')).toBe('1');
}); });
it('Test 6 (quick-260918-gza): /login mit dv/dc/dos setzt zusaetzlich tessera_desktop_client', async () => {
const req = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows');
const res = await middleware(req);
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows');
const setCookie = res.headers.get('set-cookie');
expect(setCookie).toContain('tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows');
expect(setCookie).toContain('Max-Age=31536000');
expect(setCookie).toContain('Path=/');
// Kein HttpOnly fuer DIESES Cookie -- der bestehende tessera_desktop
// liefert ebenfalls kein HttpOnly, darum genuegt die Wertpruefung oben.
});
it('Test 7 (quick-260918-gza, alter Client): /login?desktop=1 ohne dv/dc/dos setzt kein tessera_desktop_client', async () => {
const req = new NextRequest('http://localhost:3000/login?desktop=1');
const res = await middleware(req);
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
expect(res.cookies.get('tessera_desktop_client')).toBeUndefined();
});
it('Test 8 (quick-260918-gza, Bereinigung): ungueltige oder fehlende Werte setzen kein tessera_desktop_client', async () => {
const angleBrackets = new NextRequest(
'http://localhost:3000/login?desktop=1&dv=1.2.0%3Cscript%3E&dc=a6d1a64&dos=windows',
);
expect((await middleware(angleBrackets)).cookies.get('tessera_desktop_client')).toBeUndefined();
const spaceInOs = new NextRequest(
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=win%20dows',
);
expect((await middleware(spaceInOs)).cookies.get('tessera_desktop_client')).toBeUndefined();
const missingDv = new NextRequest('http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows');
expect((await middleware(missingDv)).cookies.get('tessera_desktop_client')).toBeUndefined();
const emptyCommit = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux');
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe('1.2.0||linux');
});
it('Test 9 (quick-260918-gza, Redirect-Pfad): /dashboard ohne Session setzt beide Cookies auf dem 307', async () => {
const req = new NextRequest('http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux');
const res = await middleware(req);
expect(res.status).toBe(307);
expect(res.headers.get('location')).toContain('/login');
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|linux');
});
}); });
+52 -4
View File
@@ -14,6 +14,46 @@ const publicRoutes = ['/login', '/reset-password'];
const DESKTOP_COOKIE = 'tessera_desktop'; const DESKTOP_COOKIE = 'tessera_desktop';
/**
* Zweites Cookie (quick-260918-gza): traegt Version, Commit-Stempel und
* Betriebssystem der Desktop-App, aus denen der Fehler-melden-Knopf die
* Herkunft einer Meldung fuellt (Betreff-Kuerzel `[Desktop/Windows]` bzw.
* `[Desktop/Linux]` statt `[Browser]` -- WebView2 und WebKitGTK sehen im
* User-Agent sonst wie ein gewoehnlicher Browser aus). `httpOnly: false`
* ist Absicht: `getDesktopClientInfo()` (apps/web/src/lib/desktop-client.ts)
* liest es aus Seiten-JavaScript, Version/Betriebssystem sind kein Geheimnis
* -- dieselbe Vertrauensstufe wie der User-Agent, den jede Seite ohnehin
* liest. Next.js serialisiert den Wert mit `encodeURIComponent`
* (`|` wird `%7C`, gemessen zur Planungszeit).
*/
const DESKTOP_CLIENT_COOKIE = 'tessera_desktop_client';
/** Nur `[A-Za-z0-9.+_-]`, hoechstens 40 Zeichen, mindestens 1 Zeichen. */
const DESKTOP_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,39}$/;
/** Leer erlaubt (kein Commit-Stempel im lokalen Bau), sonst hoechstens 40 Hex-/Alnum-Zeichen. */
const DESKTOP_COMMIT_RE = /^[A-Za-z0-9]{0,40}$/;
/** `std::env::consts::OS`-Werte sind kleingeschrieben ASCII, hoechstens 20 Zeichen. */
const DESKTOP_OS_RE = /^[a-z]{1,20}$/;
/**
* Baut den Wert fuer `tessera_desktop_client` aus den Query-Parametern
* `dv`/`dc`/`dos` (T-GZA-03: feste Muster, Gesamtlaenge begrenzt). Fehlt
* einer der drei Parameter oder passt er nicht auf sein Muster, liefert
* die Funktion `null` -- die Middleware setzt das Cookie dann NICHT (ein
* alter Desktop-Client ohne diese Parameter bleibt ueber `tessera_desktop`
* als Desktop-App erkennbar, nur ohne Details).
*/
function buildDesktopClientCookieValue(params: URLSearchParams): string | null {
const dv = params.get('dv');
const dc = params.get('dc');
const dos = params.get('dos');
if (dv === null || dc === null || dos === null) return null;
if (!DESKTOP_VERSION_RE.test(dv) || !DESKTOP_COMMIT_RE.test(dc) || !DESKTOP_OS_RE.test(dos)) {
return null;
}
return `${dv}|${dc}|${dos}`;
}
/** /**
* Setzt das Cookie `tessera_desktop`, wenn die Anfrage `?desktop=1` traegt * Setzt das Cookie `tessera_desktop`, wenn die Anfrage `?desktop=1` traegt
* (260917-h2s). Der Desktop-Client (apps/desktop/src-tauri/src/lib.rs, * (260917-h2s). Der Desktop-Client (apps/desktop/src-tauri/src/lib.rs,
@@ -22,17 +62,25 @@ const DESKTOP_COOKIE = 'tessera_desktop';
* Fruehausstieg fuer oeffentliche Routen und auf Redirects -- sonst geht die * Fruehausstieg fuer oeffentliche Routen und auf Redirects -- sonst geht die
* Kennung beim 307 nach /login verloren. `httpOnly: false` ist Absicht (wird * Kennung beim 307 nach /login verloren. `httpOnly: false` ist Absicht (wird
* von `isDesktopClient()` in apps/web/src/lib/desktop-client.ts gelesen); * von `isDesktopClient()` in apps/web/src/lib/desktop-client.ts gelesen);
* der Wert ist kein Geheimnis. * der Wert ist kein Geheimnis. Seit quick-260918-gza setzt dieselbe Funktion
* zusaetzlich das Cookie `tessera_desktop_client` (Version/Commit/OS), sofern
* die Anfrage gueltige Werte fuer `dv`/`dc`/`dos` mitbringt -- ohne sie wird
* das zweite Cookie weder gesetzt noch geloescht.
*/ */
function withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse { function withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse {
if (req.nextUrl.searchParams.get('desktop') === '1') { if (req.nextUrl.searchParams.get('desktop') === '1') {
res.cookies.set(DESKTOP_COOKIE, '1', { const cookieOptions = {
path: '/', path: '/',
maxAge: 60 * 60 * 24 * 365, maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax', sameSite: 'lax' as const,
httpOnly: false, httpOnly: false,
secure: req.nextUrl.protocol === 'https:', secure: req.nextUrl.protocol === 'https:',
}); };
res.cookies.set(DESKTOP_COOKIE, '1', cookieOptions);
const info = buildDesktopClientCookieValue(req.nextUrl.searchParams);
if (info !== null) {
res.cookies.set(DESKTOP_CLIENT_COOKIE, info, cookieOptions);
}
} }
return res; return res;
} }
+1 -1
View File
@@ -205,7 +205,7 @@ Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port,
Über „Test-E-Mail an“ lässt sich optional eine echte Testnachricht an eine beliebige Adresse verschicken, um die Konfiguration vor dem produktiven Einsatz zu prüfen. Über „Test-E-Mail an“ lässt sich optional eine echte Testnachricht an eine beliebige Adresse verschicken, um die Konfiguration vor dem produktiven Einsatz zu prüfen.
**Fehlermeldungen an** ist eine optionale Adresse für den Knopf „Fehler melden“, den alle Anwender rechts in der Kopfleiste sehen. Sobald hier eine Adresse gespeichert ist, wirkt der Knopf: Ein Klick schickt ein Bildschirmfoto der aktuellen Seite samt Beschreibung des Anwenders, Adresse der Seite, Version und Kanal von Tessera, Browser, angemeldetem Benutzer (Name, Benutzername, Rolle) und den letzten Fehlermeldungen des Browsers als E-Mail an diese Adresse — der Betreff beginnt mit „[Tessera Fehlermeldung]“, das Bild hängt als PNG an. Der Knopf ist immer sichtbar; ohne Adresse erhalten Anwender beim Senden den Hinweis, dass noch kein Postfach eingerichtet ist (Administratoren sehen zusätzlich einen Link hierher). Je Benutzer sind höchstens fünf Meldungen in zehn Minuten möglich; Bilder über 4 MB werden abgewiesen. Der Versand nutzt dieselben SMTP-Zugangsdaten wie alle anderen E-Mails des Mandanten. Für Installationen ohne gespeicherte SMTP-Einstellungen kennt der Betrieb einen Rückfall über die Umgebungsvariable `TESSERA_BUGREPORT_TO` (Betriebshandbuch, Kapitel 3). Bitte beachten: Das Bild zeigt alles, was der Anwender gerade sieht — wählen Sie das Postfach entsprechend. **Fehlermeldungen an** ist eine optionale Adresse für den Knopf „Fehler melden“, den alle Anwender rechts in der Kopfleiste sehen. Sobald hier eine Adresse gespeichert ist, wirkt der Knopf: Ein Klick schickt ein Bildschirmfoto der aktuellen Seite samt Beschreibung des Anwenders, Adresse der Seite, Version und Kanal von Tessera, Browser, angemeldetem Benutzer (Name, Benutzername, Rolle) und den letzten Fehlermeldungen des Browsers als E-Mail an diese Adresse — der Betreff beginnt mit „[Tessera Fehlermeldung]“ und einem Kürzel für die Herkunft der Meldung („[Browser]“, „[Desktop/Windows]“ oder „[Desktop/Linux]“), nach dem sich das Postfach sortieren oder filtern lässt; im Text nennt die Zeile „Herkunft“ bei Browsern Browser und Betriebssystem (Beispiel „Browser — Chrome 129 auf Windows“), bei der Desktop-App Betriebssystem, Version und Stand (Beispiel „Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64“); das Bild hängt als PNG an. Der Knopf ist immer sichtbar; ohne Adresse erhalten Anwender beim Senden den Hinweis, dass noch kein Postfach eingerichtet ist (Administratoren sehen zusätzlich einen Link hierher). Je Benutzer sind höchstens fünf Meldungen in zehn Minuten möglich; Bilder über 4 MB werden abgewiesen. Der Versand nutzt dieselben SMTP-Zugangsdaten wie alle anderen E-Mails des Mandanten. Für Installationen ohne gespeicherte SMTP-Einstellungen kennt der Betrieb einen Rückfall über die Umgebungsvariable `TESSERA_BUGREPORT_TO` (Betriebshandbuch, Kapitel 3). Bitte beachten: Das Bild zeigt alles, was der Anwender gerade sieht — wählen Sie das Postfach entsprechend.
Ohne funktionierende SMTP-Konfiguration versendet Tessera keine E-Mails. Das betrifft insbesondere: Ohne funktionierende SMTP-Konfiguration versendet Tessera keine E-Mails. Das betrifft insbesondere:
+2 -1
View File
@@ -338,7 +338,7 @@ startet `web`, weil `depends_on: api: condition: service_healthy` das erzwingt.
| Neue Version scheint nicht anzukommen, obwohl `pull` gelaufen ist | Klassische `up -d`-Falle ohne `--force-recreate` (siehe Kapitel 4) | `StartedAt` des Containers gegen `Created` des Images vergleichen, ggf. `--force-recreate` nachholen. | | Neue Version scheint nicht anzukommen, obwohl `pull` gelaufen ist | Klassische `up -d`-Falle ohne `--force-recreate` (siehe Kapitel 4) | `StartedAt` des Containers gegen `Created` des Images vergleichen, ggf. `--force-recreate` nachholen. |
| Initialer Admin-Login funktioniert nicht nach Änderung von `TESSERA_ADMIN_PASSWORD` | Seed läuft nur, wenn der Benutzername noch **nicht** existiert; bestehende Accounts werden nicht überschrieben | Passwort über die Anwendung selbst (bzw. direkt in der Datenbank) ändern, nicht über die `.env`-Variable. | | Initialer Admin-Login funktioniert nicht nach Änderung von `TESSERA_ADMIN_PASSWORD` | Seed läuft nur, wenn der Benutzername noch **nicht** existiert; bestehende Accounts werden nicht überschrieben | Passwort über die Anwendung selbst (bzw. direkt in der Datenbank) ändern, nicht über die `.env`-Variable. |
| Mails werden nicht versendet | `TESSERA_SMTP_HOST` leer (Prod-Default) | SMTP-Variablen vollständig setzen und Container neu erstellen. | | Mails werden nicht versendet | `TESSERA_SMTP_HOST` leer (Prod-Default) | SMTP-Variablen vollständig setzen und Container neu erstellen. |
| Fehlermeldungen der Anwender kommen nicht an | Kein Postfach gesetzt (weder „Fehlermeldungen an“ unter Administrator → SMTP noch `TESSERA_BUGREPORT_TO`), oder der SMTP-Versand des Mandanten scheitert | Feld „Fehlermeldungen an“ (Administrator → SMTP) oder `TESSERA_BUGREPORT_TO` prüfen; API-Log nach `Bug report` durchsuchen (eine Zeile je gesendeter Meldung, `Bug report mail failed` bei Versandfehler). | | Fehlermeldungen der Anwender kommen nicht an | Kein Postfach gesetzt (weder „Fehlermeldungen an“ unter Administrator → SMTP noch `TESSERA_BUGREPORT_TO`), oder der SMTP-Versand des Mandanten scheitert | Feld „Fehlermeldungen an“ (Administrator → SMTP) oder `TESSERA_BUGREPORT_TO` prüfen; API-Log nach `Bug report` durchsuchen (eine Zeile je gesendeter Meldung mit dem Herkunfts-Kürzel `[Browser]`, `[Desktop/Windows]` oder `[Desktop/Linux]`, `Bug report mail failed` bei Versandfehler). |
| Avatare/DKV-Exporte nach einem Deploy verschwunden | Die verwendete Compose-Datei mountet `user-files/` nicht als Volume – im Repository-Stand seit dieser Version behoben, betrifft nur eine Installation mit abweichender Compose-Datei | Die zwei Zeilen aus Kapitel 6 in die verwendete Compose-Datei eintragen (auf dem Server: `/opt/tessera/docker-compose.yml`, vorher sichern) und `api` neu erstellen. | | Avatare/DKV-Exporte nach einem Deploy verschwunden | Die verwendete Compose-Datei mountet `user-files/` nicht als Volume – im Repository-Stand seit dieser Version behoben, betrifft nur eine Installation mit abweichender Compose-Datei | Die zwei Zeilen aus Kapitel 6 in die verwendete Compose-Datei eintragen (auf dem Server: `/opt/tessera/docker-compose.yml`, vorher sichern) und `api` neu erstellen. |
## 8. Abgrenzung zur CI/CD-Pipeline ## 8. Abgrenzung zur CI/CD-Pipeline
@@ -707,3 +707,4 @@ Für die Desktop-Auslieferung ist keine neue Pflichtvariable nötig.
| Beta-Paket nennt einen älteren Commit als das laufende Abbild (Dateiname `-beta.<Commit>`, Einstellungen → Desktop-App) | Erwartet: Desktop-App seit diesem Commit unverändert, Pakete aus dem Zwischenspeicher übernommen (Abschnitt „Wann gebaut wird …") | Kein Fehler. Soll dennoch neu gebaut werden, genügt eine Änderung unter `apps/desktop/` im nächsten Push. | | Beta-Paket nennt einen älteren Commit als das laufende Abbild (Dateiname `-beta.<Commit>`, Einstellungen → Desktop-App) | Erwartet: Desktop-App seit diesem Commit unverändert, Pakete aus dem Zwischenspeicher übernommen (Abschnitt „Wann gebaut wird …") | Kein Fehler. Soll dennoch neu gebaut werden, genügt eine Änderung unter `apps/desktop/` im nächsten Push. |
| Client meldet „Update fehlgeschlagen" | Download über den Proxy abgebrochen (Größengrenze/Zeitlimit, siehe zweite Zeile dieser Tabelle), oder die Signatur passt nicht – die Pakete stammen nicht aus dem CI-Bau mit dem aktuellen Schlüssel | Kontrollzeile `/api-proxy/desktop/update` (Abschnitt „Wo die Pakete im Abbild liegen"), den Pipeline-Lauf und die Proxy-Einstellungen prüfen. Der Anwender kommt über den Browser-Weg weiter. | | Client meldet „Update fehlgeschlagen" | Download über den Proxy abgebrochen (Größengrenze/Zeitlimit, siehe zweite Zeile dieser Tabelle), oder die Signatur passt nicht – die Pakete stammen nicht aus dem CI-Bau mit dem aktuellen Schlüssel | Kontrollzeile `/api-proxy/desktop/update` (Abschnitt „Wo die Pakete im Abbild liegen"), den Pipeline-Lauf und die Proxy-Einstellungen prüfen. Der Anwender kommt über den Browser-Weg weiter. |
| `/api-proxy/desktop/update` antwortet dauerhaft `204`, obwohl Pakete da sind | Manifest ohne `signature`/`updateVersion`: Pakete aus einem Bau vor der Update-Funktion oder mit `--no-sign` | Eine Änderung unter `apps/desktop/` pushen bzw. den Tag neu bauen lassen; im CI prüfen, dass die Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD` gesetzt sind (Abschnitt „Updates in der App und der Signierschlüssel"). | | `/api-proxy/desktop/update` antwortet dauerhaft `204`, obwohl Pakete da sind | Manifest ohne `signature`/`updateVersion`: Pakete aus einem Bau vor der Update-Funktion oder mit `--no-sign` | Eine Änderung unter `apps/desktop/` pushen bzw. den Tag neu bauen lassen; im CI prüfen, dass die Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD` gesetzt sind (Abschnitt „Updates in der App und der Signierschlüssel"). |
| Eine Fehlermeldung aus der Desktop-App nennt als Herkunft „Desktop-App (unbekannt)“ ohne Version, Betreff-Kürzel `[Desktop]` | Der Client ist älter als diese Fassung: er meldet dem Server beim Start nur `desktop=1`, nicht Version, Stand und Betriebssystem (Parameter `dv`, `dc`, `dos`, aus denen `web` das Cookie `tessera_desktop_client` bildet) | Kein Fehler, die Meldung ist trotzdem als Desktop-App erkennbar. Client über „Auf Version … aktualisieren“ im Infobereich oder den Browser-Installer aktualisieren; danach stehen Betriebssystem, Version und Stand in der Meldung. |