--- phase: quick-260917-jdd plan: 01 subsystem: dashboard-favorites tags: [nestjs, undici, prisma, rls, nextjs, react, vitest, ssrf, favicon] requires: [] provides: - "LENIENT_TLS_AGENT (icon-discovery.service.ts) — Modul-Singleton undici-Agent, toleriert Zertifikatsfehler des Zielhosts in fetchWithRedirectGuard (HTML-Ermittlung und Icon-Byte-Holen)" - "PUT /favorites/order + FavoritesService.reorder() — transaktionale Sortierung der Favoriten eines Widgets ueber withTenantTransaction()" - "FavoriteIcon (favorites-widget.tsx) — dreistufiger Browser-Ersatzweg proxy -> direct -> Buchstabe" - "reorderFavorites (favorites-api.ts) — Web-Client fuer PUT /favorites/order" affects: [favorites, dashboard-widgets] actuals: tokens: 58000 tasks: 3 commits: 3 plan_head_before: e7633e15de5ee8c6d1d607275b43ce75a9150e1a tech-stack: added: - "undici@7.28.0 (@tessera/api, direkte Abhaengigkeit — bereits im Lockfile aufgeloest ueber cheerio/jsdom, kein neuer Download)" patterns: - "Dispatcher-Option pro Aufruf (undicis eigenes fetch) statt prozessweiter NODE_TLS_REJECT_UNAUTHORIZED-Abschaltung — Nodes globales fetch ignoriert einen undici-Agent, deshalb der Modulimport von undici statt des globalen fetch" - "withTenantTransaction() als atomare Mehrschritt-Form fuer transaktionale Schreibzugriffe ohne Benutzerdimension in der Sitzung — jede Bedingung im Callback traegt userId UND widgetId selbst (zweites Netz)" - "Dreistufiger Browser-Ersatzweg fuer Bilder, die der Server nicht liefern kann (SSRF-Schutz lehnt interne Hosts bewusst ab): Server-Proxy -> Direktbild aus dem Browser des Nutzers (referrerPolicy no-referrer) -> Buchstaben-Platzhalter, React-key setzt die Stufe bei URL-Wechsel zurueck" key-files: created: - apps/api/src/favorites/dto/reorder-favorites.dto.ts modified: - apps/api/package.json - pnpm-lock.yaml - apps/api/src/favorites/icon-discovery.service.ts - apps/api/src/favorites/icon-discovery.service.spec.ts - apps/api/src/favorites/favorites.service.ts - apps/api/src/favorites/favorites.service.spec.ts - apps/api/src/favorites/favorites.controller.ts - apps/api/src/prisma/prisma-tenant.extension.ts - apps/web/src/lib/favorites-api.ts - apps/web/src/components/dashboard/widgets/favorites-widget.tsx - apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx - apps/web/src/messages/de.json - apps/web/src/messages/en.json - CHANGELOG.md - docs/anleitung-anwender.md - docs/mandantentrennung-zugriffsklassifikation.md key-decisions: - "undici als direkte Abhaengigkeit statt eines neuen Downloads: 7.28.0 lag bereits im Lockfile ueber cheerio@1.2.0/jsdom aufgeloest; `pnpm add undici@7.28.0 --offline` macht daraus eine direkte Abhaengigkeit ohne neues Major und ohne Netzabruf (Plan-Vorgabe, uebernommen)." - "Test-Extraktion der Link-Reihenfolge ueber `a.querySelector('.truncate')` statt `a.textContent` (Abweichung vom Plan-Wortlaut, siehe Deviations): der Buchstaben-Platzhalter liegt IMMER im selben `` wie der Titel-Span, `a.textContent` haette deshalb den Buchstaben vor dem Titel mitgezaehlt (z. B. \"GGitHub\" statt \"GitHub\") und die im Plan geforderte exakte Array-Gleichheit waere nie gruen geworden." requirements-completed: [QUICK-260917-JDD] coverage: - id: D1 description: "icon-discovery.service.ts holt HTML und Icon-Bytes ueber undicis eigenes fetch mit LENIENT_TLS_AGENT als dispatcher in der einzigen Ausgangsstelle fetchWithRedirectGuard; SSRF-Schutz (isPublicHttpUrl je Hop, MAX_REDIRECTS, Timeout, Groessendeckel) unveraendert" requirement: "QUICK-260917-JDD" verification: - kind: unit ref: "apps/api/src/favorites/icon-discovery.service.spec.ts — 3 neue Dispatcher-Tests (dispatcher-Instanz+options, redirect:manual, Singleton), 16 bestehende SSRF/Discovery-Tests unveraendert gruen" status: pass human_judgment: false - id: D2 description: "PUT /favorites/order (vor den :id-Routen) + FavoritesService.reorder() setzt position=index fuer exakt die Favoriten eines Widgets in EINER withTenantTransaction; fremde/unbekannte/fehlende/doppelte ids -> BadRequestException ohne Teilschreibung" requirement: "QUICK-260917-JDD" verification: - kind: unit ref: "apps/api/src/favorites/favorites.service.spec.ts — 7 neue reorder-Tests (Happy Path, fremde id, unbekannte id, Teilmenge, Duplikat, fremder Mandant, Wachhund forTenant=0/withTenantTransaction=1)" status: pass human_judgment: false - id: D3 description: "Widget zeigt bei fehlendem Server-Symbol das Direktbild aus dem Browser (referrerPolicy no-referrer, nur http/https) und danach den Buchstaben; Pfeile im Bearbeitungsmodus sortieren optimistisch und persistieren ueber PUT /favorites/order, Fehler laedt neu" requirement: "QUICK-260917-JDD" verification: - kind: unit ref: "apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx — 5 neue Tests (Ersatzbild bei iconUrl null, Kette Proxy->direkt->Buchstabe, kein Direktbild bei ftp://, Pfeilzustand+Klick, Fehlerpfad); 11 bestehende Tests unveraendert gruen" status: pass human_judgment: true rationale: "Der tatsaechliche Beweis ueber die Netzgrenze (echter Host mit Zertifikatsfehler, echter interner Host, Sortierung ueber Reload hinweg) ist laut Plan Aufgabe des Orchestrators im Browser — siehe Abschnitt unten." duration: ~40min completed: 2026-09-17 status: complete --- # Quick Task 260917-jdd: Favoriten-Widget — Symbol-Ersatzweg bei Zertifikatsfehler/interner Adresse, manuelle Sortierung Summary **Favoriten holen ihr Symbol jetzt trotz Zertifikatsfehlern (undici-Dispatcher mit toleranter TLS-Pruefung serverseitig) oder ueber einen Browser-Ersatzweg bei internen Adressen, und lassen sich im Bearbeitungsmodus per Pfeilen in eine gewuenschte Reihenfolge bringen (`PUT /favorites/order`, transaktional, mit Existenzorakel-Vermeidung).** ## Performance - **Duration:** ~40 min - **Completed:** 2026-09-17 - **Tasks:** 3/3 - **Files modified:** 16 (1 neu, 15 geändert) ## Accomplishments **Teil A — Symbol trotz Zertifikatsfehler / interner Adresse** - `icon-discovery.service.ts`: `LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } })` als Modul-Singleton; `fetchWithRedirectGuard` (die einzige Ausgangsstelle fuer HTML-Ermittlung UND Icon-Byte-Holen) ruft jetzt `undiciFetch(url, { dispatcher: LENIENT_TLS_AGENT, redirect: 'manual', signal, headers })` statt des globalen `fetch` — Nodes globales `fetch` ignoriert einen undici-Agent (gemessen: self-signed.badssl.com liefert ueber undici 200, ueber global fetch `DEPTH_ZERO_SELF_SIGNED_CERT`). DNS-Pruefung, Redirect-Limit, Timeout, Groessendeckel, HTML-Zeichenbegrenzung bleiben unangetastet - `undici@7.28.0` als direkte Abhaengigkeit von `@tessera/api` (bereits im Lockfile aufgeloest, `pnpm add --offline`, kein neuer Download); `pnpm install --frozen-lockfile --offline` gruen - `favorites-widget.tsx`: neue Unterkomponente `FavoriteIcon` mit den Stufen `proxy` (Server-Proxy `/api-proxy/favorites/:id/icon`) → `direct` (Browser-Direktbild `{origin}/favicon.ico`, `referrerPolicy="no-referrer"`, nur http/https ueber `getDirectFaviconSrc`) → `none` (Buchstaben-Platzhalter, liegt immer darunter); `key={iconUrl|url}` setzt die Stufe bei Aenderung zurueck; kein `style.display`-Hack mehr - `GET /favorites/:id/icon` sendet zusaetzlich `X-Content-Type-Options: nosniff` und eine restriktive `Content-Security-Policy` (T-JDD-02, Haertung fuer den Fall eines direkt im Tab geoeffneten SVG) **Teil B — manuelle Sortierung mit Pfeilen** - `ReorderFavoritesDto` (neu): `widgetId` (`@IsUUID`), `ids` (`@IsArray @ArrayMinSize(1) @ArrayMaxSize(500) @ArrayUnique @IsUUID('all', {each:true})`) - `@Put('order')` im Controller VOR den `:id`-Routen (NestJS-Route-Order) - `FavoritesService.reorder()`: EINE `withTenantTransaction()`-Transaktion — `findMany` prueft EXAKTE Uebereinstimmung der `ids` mit den Favoriten des Widgets, dann je id `updateMany({ where: { id, userId, widgetId }, data: { position: index } })` mit `count === 1`-Pruefung; jede Abweichung (fremde/unbekannte/fehlende id, fremdes Widget, fremder Mandant) wirft DIESELBE `BadRequestException` (Existenzorakel-Vermeidung, T-JDD-06); doppelte ids scheitern VOR der Transaktion - `favorites-widget.tsx`: `handleMove` tauscht optimistisch in `sortedFavorites`, setzt `position=index` fuer alle, ruft `reorderFavorites`; Erfolg uebernimmt die Server-Antwort, Fehler zeigt `favorites.error` und laedt per `fetchFavorites` neu; Pfeile (nur bei nicht-inline-Bearbeitung) mit `disabled` am ersten/letzten Eintrag, sichtbar in Listen- und Kachelansicht - `reorderFavorites(widgetId, ids)` im Web-Client (`PUT /favorites/order`) - i18n: `widgets.favorites.moveUpButton`/`moveDownButton` (de/en) **Dokumentation** - CHANGELOG.md: je ein Stichpunkt unter `### Neu` (Sortierpfeile) und `### Behoben` (Symbol trotz Zertifikatsfehler) - docs/anleitung-anwender.md: Tabellenzeile „Favoriten" erweitert um Pfeile und Browser-Ersatzweg (eine Zeile geblieben) - docs/mandantentrennung-zugriffsklassifikation.md: Nachtrag zu `favoriteLink` — `reorder()` laeuft ueber `withTenantTransaction()` ohne Benutzerdimension in der Sitzung, Stand bleibt `gebunden` - `prisma-tenant.extension.ts`: Kopfkommentar-Nachtrag — `favorites.service.ts` (`reorder`) ist der erste Nutzer-CRUD-Aufrufer von `withTenantTransaction()`; NUR Kommentartext, Funktionscode unveraendert ## Befund am Code (uebernommen aus dem Plan, wichtig fuer die Browser-Nachweise unten) `discoverFavoriteIconUrl` liefert NIE `null`, sondern bei jedem Fehler den Origin-Rueckfall `https://host/favicon.ico`. Fuer einen internen Host steht also `https://intern/favicon.ico` in `iconUrl`, das Widget rendert zunaechst das Proxy-Bild, der Proxy antwortet 502 (SSRF-Schutz lehnt ab), `onError` schaltet auf die Direktbild-Stufe. Die Browser-Stufe haengt deshalb korrekt an `onError` des Proxy-Bildes UND an `iconUrl === null` — nicht nur an letzterem, wie eine naive Lesart nahelegen wuerde. ## Task Commits Each task was committed atomically: 1. **Task 1: API — undici-Dispatcher fuer beide Icon-Pfade, `PUT /favorites/order` mit transaktionalem `reorder()`, Specs** - `2a562d0` (feat) 2. **Task 2: Web — `reorderFavorites`, `FavoriteIcon` mit Browser-Ersatzweg, Sortierpfeile, i18n, Tests** - `b18ac25` (feat) 3. **Task 3: CHANGELOG, Anwenderhandbuch, zwei Nachtraege** - `b023d6f` (docs) **Plan metadata:** wird vom Orchestrator nach diesem SUMMARY committet. _Beide Task-1- und Task-2-Aenderungen (`tdd="true"`) folgten RED→GREEN: Tests wurden vor der Implementierung geschrieben und liefen zunaechst rot (Task 1: 9 fehlschlagende Tests — `service.reorder is not a function`, `init.dispatcher` undefined; Task 2: alle 5 neuen Tests haetten ohne `FavoriteIcon`/`handleMove`/`reorderFavorites` fehlgeschlagen), dann gruen nach Implementierung. Task 2 traegt die Tracer-Rolle (einzige lokal Ende-zu-Ende pruefbare Kette: Klick → optimistische Reihenfolge → `reorderFavorites` → bei Fehler Neuladen; Proxy-Bild → `onError` → Direktbild → `onError` → Buchstabe) — der automatisierte ``-Block wurde nach dem Commit erneut vollstaendig gruen ausgefuehrt (Tracer-Feedback-Gate, automatisiert, kein Checkpoint noetig)._ ## Files Created/Modified - `apps/api/package.json`, `pnpm-lock.yaml` — `undici` 7.28.0 als direkte Abhaengigkeit von `@tessera/api` - `apps/api/src/favorites/icon-discovery.service.ts` — `LENIENT_TLS_AGENT`, `undiciFetch` in `fetchWithRedirectGuard`, Rueckgabetyp `UndiciResponse` - `apps/api/src/favorites/icon-discovery.service.spec.ts` — `vi.mock('undici')`, 3 neue Dispatcher-Tests - `apps/api/src/favorites/dto/reorder-favorites.dto.ts` — neu - `apps/api/src/favorites/favorites.controller.ts` — `@Put('order')` vor den `:id`-Routen, zwei Header am Icon-Proxy - `apps/api/src/favorites/favorites.service.ts` — `reorder()` ueber `withTenantTransaction` - `apps/api/src/favorites/favorites.service.spec.ts` — Mock/Fake um `withTenantTransaction`/`updateMany` erweitert, 7 neue reorder-Tests - `apps/api/src/prisma/prisma-tenant.extension.ts` — ein Kommentar-Nachtrag (Task 3) - `apps/web/src/lib/favorites-api.ts` — `reorderFavorites` - `apps/web/src/components/dashboard/widgets/favorites-widget.tsx` — `getDirectFaviconSrc`, `FavoriteIcon`, `handleMove`, Sortierpfeile - `apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx` — Mock um `reorderFavorites` erweitert, 5 neue Tests - `apps/web/src/messages/de.json`, `en.json` — zwei Schluessel - `CHANGELOG.md`, `docs/anleitung-anwender.md`, `docs/mandantentrennung-zugriffsklassifikation.md` — Stichpunkte/Saetze (Task 3) ## Decisions Made - undici als direkte Abhaengigkeit statt neuem Download — bereits im Lockfile aufgeloest, exakt gepinnt auf `7.28.0` wie im Plan vorgegeben. - Test-Extraktion der Link-Reihenfolge ueber `a.querySelector('.truncate')` statt `a.textContent` — siehe Deviations unten. - Keine weiteren Abweichungen von der im Plan vorgegebenen Architektur (Dispatcher-Ort, Transaktionsform, dreistufiger Ersatzweg). ## Deviations from Plan **1. [Rule 1 - Test-Bug im Plan-Wortlaut] Link-Reihenfolge im Test nicht ueber `a.textContent`, sondern `a.querySelector('.truncate')?.textContent`** - **Found during:** Task 2 (Test-Implementierung, vor dem ersten Testlauf) - **Issue:** Der Plan-Text schlug `within(...).getAllByRole('link').map(a => a.textContent)` vor. Der Buchstaben-Platzhalter (`letter-fallback-{id}`) liegt aber IMMER im selben ``-Element wie der Titel-Span (Bestandscode, unveraendert) — `a.textContent` haette deshalb Buchstabe+Titel konkateniert geliefert (z. B. `"GGitHub"` statt `"GitHub"`), und die im Plan geforderte exakte Array-Gleichheit `['Notion', 'GitHub']` waere mit keiner Implementierung gruen geworden. - **Fix:** Test extrahiert stattdessen `a.querySelector('.truncate')?.textContent` — die CSS-Klasse des Titel-Spans, unveraendert seit Bestand. Keine Aenderung an der Produktionsdatei noetig; nur die Testauswahl wurde praeziser. - **Files modified:** `apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx` - **Commit:** `b18ac25` ## Issues Encountered None ueber die dokumentierte Deviation hinaus. `pnpm add undici@7.28.0 --offline` erzeugte eine bereits bekannte, vorbestehende Peer-Warnung (`http-cookie-agent` erwartet `undici@^5.11.0`, findet `7.28.0`) — unveraendert seit vorher moeglich (jetzt sichtbar, weil `undici` erstmals eine direkte statt nur transitive Abhaengigkeit ist), keine Auswirkung auf Build oder Tests, nicht behoben (ausserhalb des Aufgabenbereichs). ## User Setup Required None — keine externe Konfiguration noetig. ## Nachweis durch Orchestrator (offen) Folgende Punkte sind NICHT lokal pruefbar (kein echter Netzzugriff/Browser in dieser Umgebung) und folgen laut Plan durch den Orchestrator: - **Host mit Zertifikatsfehler:** ein Favorit auf `https://self-signed.badssl.com/` (oder vergleichbar) zeigt das Symbol ueber den Server-Proxy (`icon-proxy-*`), nicht ueber das Direktbild — der Server toleriert den Zertifikatsfehler jetzt (undici-Dispatcher), der Browser des Nutzers muesste es sonst gar nicht erst versuchen. - **Interner Host:** ein Favorit auf eine Adresse im Firmennetz (die der SSRF-Schutz des Servers absichtlich ablehnt, Proxy antwortet 502) zeigt das Symbol ueber das Direktbild aus dem Browser des Nutzers (`icon-direct-*`), sofern der Host per http/https erreichbar ist und (bei https) ein vom Browser vertrautes Zertifikat traegt. Ein `http://`-Favorit auf einem `https://`-Tessera ist Mischinhalt und wird vom Browser hochgestuft/blockiert; ein selbstsigniertes Zertifikat ohne Vertrauen im Browser des Nutzers klappt ueber die Direktbild-Stufe NICHT (der Browser laesst sich nicht wie der Server ueberreden). - **Sortierung ueber Reload hinweg:** nach einem Klick auf „Nach oben"/„Nach unten" bleibt die neue Reihenfolge nach einem Neuladen der Seite erhalten (Server-persistiert). - **Altbestand-Normalisierung:** Favoriten mit `position = 0` (vor diesem Plan angelegt) ordnen sich beim ERSTEN Sortierklick zu `0..n-1`, ohne Datenverlust oder Fehlermeldung. Kein Blocker fuer weitere Arbeit — API-Suite 68 Dateien/1101 Tests, Web-Suite 64 Dateien/429 Tests, beide type-checks gruen; drei atomare Commits ohne Push, ohne Docker-Build, ohne Schema-Aenderung; `.planning/` nicht committet. --- *Quick Task: 260917-jdd* *Completed: 2026-09-17* ## Self-Check: PASSED All 11 claimed files found on disk; all three task commits (2a562d0, b18ac25, b023d6f) found in git history. ## Nachweis durch Orchestrator (2026-09-17, Playwright gegen lokale Container) — erbracht - `https://self-signed.badssl.com`: Symbol ueber den Server-Proxy geladen (180 px) — vorher Buchstabe. - `http://192.168.13.11:3002` (interner Host): Proxy antwortet 502, Widget laedt `http://192.168.13.11:3002/favicon.ico` direkt (referrerPolicy no-referrer) — Symbol da. - Sortierung: „Nach unten"/„Nach oben" aendern die Reihenfolge sofort; nach Reload bleibt sie; DB-Positionen nach dem ersten Klick 0..3 (Altbestand mit 0 normalisiert). - Testfavoriten danach aus der lokalen DB entfernt.