Files
tessera-ctl/.planning/quick/260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u/260917-jdd-PLAN.md
T
schalli 5a444ec8f2
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 1m5s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m21s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m20s
docs(quick-260917-jdf/jdh/jdd/jn2): Aktenstand — Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse
Vier Quick-Tasks mit Plan, Bericht und Verifikation; Browser-Nachweis der
Web-Teile lokal erbracht (Kacheln #284a7b bei #0057b8, Proxy-Symbol trotz
Zertifikatsfehler, Direktbild bei interner Adresse, Sortierung ueber Reload).
Offen: CI-Beweis des Desktop-Skips nach diesem Push, Windows-VM-Probe der
Client-Aenderungen mit dem CI-Paket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:10:20 +02:00

54 KiB
Raw Blame History

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260917-jdd 01 execute 1
true
QUICK-260917-JDD
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/favorites/dto/reorder-favorites.dto.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
tokens raw_tokens tasks confidence
58000 58000 3 low
truths artifacts key_links
icon-discovery.service.ts importiert `Agent`, `fetch as undiciFetch` und den Typ `Response` aus `undici`; ein Modul-Singleton `LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } })`; `fetchWithRedirectGuard` ruft AUSSCHLIESSLICH `undiciFetch(currentUrl.toString(), { dispatcher: LENIENT_TLS_AGENT, redirect: 'manual', signal, headers })` — damit laufen HTML-Ermittlung (`fetchHtml`) und Byte-Holen (`fetchIconBytes`, vom Proxy `GET :id/icon` genutzt) beide ueber diesen Weg. Kein Aufruf des globalen `fetch` mehr in dieser Datei, keine prozessweite Abschaltung der Zertifikatspruefung. `isPublicHttpUrl` vor JEDEM Hop, `MAX_REDIRECTS` 2, 4 s Timeout, 200 000 Zeichen HTML, 1 MB Icon, `image/`-Content-Type-Pruefung: alles unveraendert.
`apps/api/package.json` dependencies enthaelt `"undici": "7.28.0"` (exakt — genau die Version, die pnpm-lock.yaml bereits ueber cheerio@1.2.0 und jsdom aufloest; keine neue Paketversion, kein neuer Download). `pnpm install --frozen-lockfile --offline` ist gruen; `apps/api/node_modules/undici/package.json` traegt Version 7.28.0.
icon-discovery.service.spec.ts mockt `undici` per `vi.mock` (Agent als aufzeichnende Klasse mit `options`, `fetch` delegiert zur Laufzeit an `globalThis.fetch`), sodass ALLE bestehenden `vi.stubGlobal('fetch', …)`-Tests (16) unveraendert gruen bleiben. Drei neue Tests: (a) `discoverFavoriteIconUrl` uebergibt `dispatcher` = Agent-Instanz mit `options` gleich `{ connect: { rejectUnauthorized: false } }` und `redirect: 'manual'`; (b) `fetchIconBytes` ebenso; (c) beide Aufrufe teilen DIESELBE Agent-Instanz (Singleton).
Neuer Endpunkt `PUT /favorites/order`: `@Put('order')` steht im Controller VOR `@Get(':id/icon')`, `@Patch(':id')` und `@Delete(':id')` (NestJS-Route-Order). Body `ReorderFavoritesDto { widgetId: uuid; ids: uuid[] }` mit `@IsUUID()` fuer widgetId und `@IsArray() @ArrayMinSize(1) @ArrayMaxSize(500) @ArrayUnique() @IsUUID('all', { each: true })` fuer ids. Antwort: die Favoriten dieses Widgets in neuer Reihenfolge. `GET :id/icon` sendet zusaetzlich `X-Content-Type-Options: nosniff` und `Content-Security-Policy: default-src 'none'; sandbox`.
`FavoritesService.reorder(tenantId, userId, dto)` laeuft als EINE Transaktion ueber `withTenantTransaction(this.prisma, tenantId, async (tx) => …)`: `tx.favoriteLink.findMany({ where: { userId, widgetId }, select: { id: true } })` → die Menge muss EXAKT mit `ids` uebereinstimmen (gleiche Anzahl, jede id vorhanden), sonst `BadRequestException` mit EINER Meldung fuer alle Faelle (fremde id, unbekannte id, Teilmenge, fremde/unbekannte widgetId — kein Existenzorakel); dann je id `tx.favoriteLink.updateMany({ where: { id, userId, widgetId }, data: { position: index } })` mit Pruefung `count === 1` (sonst Exception → Rollback); Rueckgabe `tx.favoriteLink.findMany({ where: { userId, widgetId }, orderBy: [{ position: 'asc' }, { title: 'asc' }] })`. Doppelte ids scheitern VOR der Transaktion. Kein `forTenant()`-Aufruf in dieser Methode; die Array-Form von `$transaction` auf einem gebundenen Klienten wird NICHT verwendet.
favorites.service.spec.ts: `vi.mock('../prisma/prisma-tenant.extension')` um `withTenantTransaction` erweitert (Muster groups.service.spec.ts Z. 30-35 / 296-299: `prisma.__withTenantTransaction(tenantId, fn)` reicht den gebundenen Klienten als `tx` durch und protokolliert); der Fake bekommt `updateMany` auf `favoriteLink` (filtert nach tenantId, id, userId, widgetId; wendet `data` an; liefert `{ count }`). Neue Tests: Altbestand position 0/0/0 → `reorder` mit `['f3','f1','f2']` setzt 0/1/2 und liefert die Liste in dieser Reihenfolge, `withTenantTransaction` mit `(prisma, 't1', fn)` aufgerufen; fremde id (user-a2) → BadRequestException, KEINE Position geaendert; unbekannte id → BadRequestException; Teilmenge (2 von 3) → BadRequestException; doppelte ids → BadRequestException OHNE `withTenantTransaction`-Aufruf; fremder Mandant (`reorder('t2', …)` auf t1-Zeilen) → BadRequestException; Wachhund: `forTenant` 0-mal, `withTenantTransaction` genau 1-mal je Aufruf.
`pnpm --filter @tessera/api exec vitest run` (bisher 68 Dateien / 1091 Tests, gemessen 2026-09-17, laeuft ohne Datenbank in ~12 s) und `pnpm --filter @tessera/api type-check` sind gruen; `rls-access-inventory.spec.ts` bleibt gruen (favorites.service.ts::favoriteLink bleibt `gebunden`, weil `tx.favoriteLink` ueber `withTenantTransaction(` als gebunden erkannt wird); `prisma-tenant.extension.spec.ts` bleibt gruen (nur Kommentar geaendert).
favorites-api.ts exportiert `reorderFavorites(widgetId: string, ids: string[]): Promise<FavoriteLink[]>` → `PUT ${API_URL}/favorites/order`, JSON-Body `{ widgetId, ids }`, `credentials: 'include'`, wirft bei `!res.ok`.
Widget: neue Unterkomponente `FavoriteIcon` in favorites-widget.tsx mit Stufen `proxy` → `direct` → `none`. Buchstaben-Platzhalter (`letter-fallback-{id}`) liegt IMMER darunter. Stufe `proxy` nur wenn `fav.iconUrl` gesetzt: `<img data-testid="icon-proxy-{id}" src="/api-proxy/favorites/{id}/icon">`, `onError` → Stufe `direct`. Stufe `direct` rendert `<img data-testid="icon-direct-{id}" src="{origin}/favicon.ico" referrerPolicy="no-referrer">` NUR wenn `getDirectFaviconSrc(fav.url)` (`new URL`, nur `http:`/`https:`, sonst `null`) einen Wert liefert, `onError` → Stufe `none`. Start-Stufe: `proxy` bei iconUrl, sonst `direct`. `key={iconUrl|url}` am Aufruf setzt die Stufe bei Aenderung zurueck. Keine `style.display`-Manipulation mehr, kein `dangerouslySetInnerHTML` (T-08-07), kein Drittanbieter-Favicon-Dienst.
Widget: im Bearbeitungsmodus je Eintrag (nur wenn NICHT gerade inline bearbeitet) zwei Knoepfe mit `aria-label` und `title` `t('favorites.moveUpButton')` / `t('favorites.moveDownButton')` (inline-SVG-Chevrons wie die bestehenden Bearbeiten/Loeschen-Knoepfe, im selben `widgetNoDrag`-Container, VOR Bearbeiten/Loeschen); erster Eintrag: „nach oben“ `disabled`, letzter: „nach unten“ `disabled`. Klick → `handleMove(id, 'up'|'down')`: tauscht in der `sortedFavorites`-Reihenfolge, setzt `position = index` fuer ALLE Eintraege (optimistisch per `setFavorites`), ruft `reorderFavorites(instanceId, ids)`; Erfolg → `setFavorites(antwort)`; Fehler → `setError(t('favorites.error'))` und Neuladen ueber `fetchFavorites(instanceId)`. Sichtbar in Listen- UND Kachelansicht (beide `FavoriteTile`-Aufrufe).
de.json/en.json unter `widgets.favorites`: `moveUpButton` = „Nach oben“ / „Move up“, `moveDownButton` = „Nach unten“ / „Move down“ (echte Umlaute, falls welche noetig waeren — Umlaut-Waechter `src/messages/umlaut-guard.spec.ts` bleibt gruen).
favorites-widget.test.tsx: `vi.mock('@/lib/favorites-api')` um `reorderFavorites: vi.fn()` erweitert; neue Tests: (a) iconUrl null (Notion) → `icon-direct-fav-id-2` mit `src` `https://notion.so/favicon.ico` und Attribut `referrerpolicy` `no-referrer`, KEIN `icon-proxy-fav-id-2`; (b) iconUrl gesetzt (GitHub) → `icon-proxy-fav-id-1` vorhanden; `fireEvent.error` darauf → Proxy-Bild weg, `icon-direct-fav-id-1` mit `https://github.com/favicon.ico`; `fireEvent.error` darauf → kein img mehr fuer fav-id-1, `letter-fallback-fav-id-1` zeigt weiterhin `G`; (c) Favorit mit `url: 'ftp://files.example'` und iconUrl null → kein direct-img, nur Buchstabe; (d) Bearbeitungsmodus: „nach oben“ bei GitHub `disabled`, „nach unten“ bei Notion `disabled`; Klick „nach unten“ bei GitHub → `reorderFavorites` mit `('fav-1', ['fav-id-2', 'fav-id-1'])`, Titel-Reihenfolge in `favorites-list` Notion, GitHub; (e) `reorderFavorites` rejected → `fetchFavorites` erneut aufgerufen (2 Aufrufe gesamt), `favorites.error` sichtbar, Reihenfolge wieder GitHub, Notion. Die bestehenden 11 Tests bleiben unveraendert gruen.
`pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/web type-check` sind gruen.
CHANGELOG.md `## Unveröffentlicht`: ein Stichpunkt unter `### Neu` (die Datei nutzt `Neu`, NICHT „Hinzugefügt“) zur Sortierung und einer unter `### Behoben` zum Symbol; Praefix `Favoriten-Widget:` wie Z. 14; nur ZUSAETZLICHE Zeilen; Unterueberschriften nur anlegen, wenn sie unter `## Unveröffentlicht` noch fehlen (zwei parallele Quick-Tasks ergaenzen ebenfalls Zeilen — Reihenfolge der Unterabschnitte wie im Bestand: Neu, Geändert, Entfernt, Behoben). docs/anleitung-anwender.md: Tabellenzeile „Favoriten“ (Z. 80) um ein bis zwei Saetze zur Sortierung erweitert — die Zeile bleibt EINE Zeile. docs/mandantentrennung-zugriffsklassifikation.md Z. 673 (Begruendung favoriteLink) um einen Nachtrag zu `reorder` ergaenzt. prisma-tenant.extension.ts: Kopfkommentar (Absatz BENUTZERDIMENSION, Z. 145-148) um den Nachtrag, dass `favorites.service.ts` (`reorder`, 260917-jdd) der erste Nutzer-CRUD-Aufrufer von `withTenantTransaction()` ist und deshalb `userId` UND `widgetId` in jeder Bedingung selbst traegt — KOMMENTAR-ONLY, Funktionscode unveraendert.
Drei Commits: `feat(api): …` (Task 1), `feat(web): …` (Task 2), `docs: …` (Task 3). Kein `git push`, kein Docker-Build, kein `prisma migrate`, `apps/api/prisma/schema.prisma` unveraendert, keine `.planning/`-Dateien in den Commits.
apps/api/package.json + pnpm-lock.yaml — `undici` 7.28.0 als direkte Abhaengigkeit von @tessera/api (per `pnpm add`, nicht von Hand)
apps/api/src/favorites/icon-discovery.service.ts — `LENIENT_TLS_AGENT`, `undiciFetch` in `fetchWithRedirectGuard`
apps/api/src/favorites/icon-discovery.service.spec.ts — `vi.mock('undici')`, drei 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 erweitert, sieben Reorder-Tests
apps/api/src/prisma/prisma-tenant.extension.ts — ein Kommentar-Nachtrag
apps/web/src/lib/favorites-api.ts — `reorderFavorites`
apps/web/src/components/dashboard/widgets/favorites-widget.tsx — `FavoriteIcon`, `getDirectFaviconSrc`, `handleMove`, Pfeilknoepfe
apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx — Mock erweitert, fuenf neue Tests
apps/web/src/messages/de.json, en.json — zwei Schluessel
CHANGELOG.md, docs/anleitung-anwender.md, docs/mandantentrennung-zugriffsklassifikation.md — Stichpunkte/Saetze
BEFUND AM CODE (weicht vom Ist-Zustand des Orchestrators ab): `discoverFavoriteIconUrl` liefert NIE `null`, sondern bei jedem Fehler den Origin-Rueckfall `https://host/favicon.ico` (Z. 318-332). Fuer einen internen Host steht also `https://intern/favicon.ico` in `iconUrl`, das Widget rendert das Proxy-Bild, der Proxy antwortet 502 (SSRF-Schutz lehnt ab), `onError` blendet aus. Ein Browser-Ersatzweg, der NUR an `iconUrl === null` haengt, wuerde bei internen Hosts NIE greifen — deshalb haengt die Stufe `direct` an `onError` des Proxy-Bildes UND an `iconUrl === null`.
Der `dispatcher` wirkt NUR ueber undicis EIGENES `fetch`; Nodes globales `fetch` ignoriert einen Agent aus dem npm-Paket (andere Klasse, Node 24 buendelt intern undici 7.25.0). Vom Planer gemessen am 2026-09-17: `undiciFetch('https://self-signed.badssl.com/', { dispatcher: new Agent({ connect: { rejectUnauthorized: false } }) })` → Status 200; `globalThis.fetch` derselben URL → `DEPTH_ZERO_SELF_SIGNED_CERT`. Deshalb der Modulimport — und deshalb muss die Spec `undici` mocken, sonst ginge jeder Test ins Netz.
Der Spec-Mock von `undici` delegiert `fetch` zur LAUFZEIT an `globalThis.fetch` (Pfeilfunktion im Factory, nicht beim Laden aufgeloest) — so bleiben die 16 bestehenden `vi.stubGlobal('fetch', …)`-Tests wortgleich gruen, und die neuen Tests lesen den `dispatcher` aus `fetchSpy.mock.calls[n][1]`.
`withTenantTransaction()` setzt `app.current_tenant` und `app.system_context`, aber KEINE Benutzerdimension (`app.current_user`) in der Sitzung — die Regel auf `FavoriteLink` faellt in ihren `IS NULL`-Zweig und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung im Callback `userId` UND `widgetId` (zweites Netz, wie der Kopfkommentar von favorites.service.ts es fuer alle Methoden vorsieht). Die Array-Form `tenantPrisma.$transaction([…])` ist gemessen NICHT atomar (extension Z. 69-75) und die interaktive Form auf dem gebundenen Klienten faellt unter Last aus (Z. 76-85) — beide nicht verwenden.
NestJS-Route-Order (Projektgedaechtnis): `@Put('order')` VOR `@Get(':id/icon')`/`@Patch(':id')`/`@Delete(':id')`. PUT kollidiert methodisch mit keiner `:id`-Route, die Reihenfolge ist trotzdem Konvention (tenders.controller.ts Z. 636-648).
Grenzen des Browser-Ersatzwegs (kein Plan-Mangel, fuer den Nachweis durch den Orchestrator): ein `http://`-Favorit auf einem `https://`-Tessera ist Mischinhalt — Chrome/Firefox stufen das Bild auf https hoch und blocken es sonst; ein `https://intern`-Favorit mit Firmen-CA im Browser des Nutzers klappt; ein selbstsigniertes Zertifikat ohne Vertrauen im Browser klappt NICHT (der Browser laesst sich nicht wie der Server ueberreden). Oeffentliche Hosts mit kaputtem Zertifikat holt jetzt der SERVER (Stufe `proxy`).
favorites-widget.test.tsx mockt `@/lib/favorites-api` mit einem expliziten Factory — `reorderFavorites` MUSS dort ergaenzt werden, sonst importiert das Widget `undefined` und der Klick wirft `TypeError`.
`ArrayMaxSize(500)` ist die Obergrenze je Aufruf (DoS-Deckel fuer die `updateMany`-Schleife in der Transaktion); ein Widget hat in der Praxis eine Handvoll Links.
Zwei Wuensche des Users am Favoriten-Widget:

Teil A — Symbol trotz Zertifikatsfehler / interner Adresse (zweistufiger Ersatzweg, SSRF-Schutz unangetastet).

  1. Server: icon-discovery.service.ts holt HTML und Icon-Bytes ueber undicis eigenes fetch mit einem Modul-Singleton Agent({ connect: { rejectUnauthorized: false } }) als dispatcher — GENAU in fetchWithRedirectGuard, dem einzigen Ausgangspunkt beider Pfade. Alle Schutzmassnahmen bleiben exakt erhalten. undici 7.28.0 (die bereits im Lockfile aufgeloeste Version, kein neuer Download) wird direkte Abhaengigkeit von @tessera/api.
  2. Browser: Wenn der Server nichts liefern kann (interner Host, den der SSRF-Schutz absichtlich ablehnt → Proxy 502) ODER iconUrl null ist, rendert das Widget ein direktes <img src="{origin}/favicon.ico" referrerPolicy="no-referrer"> aus dem Browser des Nutzers; scheitert auch das, bleibt der Buchstaben-Platzhalter. Befund am Code: die Ermittlung liefert NIE null, sondern den Origin-Rueckfall — deshalb haengt die Browser-Stufe an onError des Proxy-Bildes, nicht nur an iconUrl === null (siehe key_links).
  3. Nebenpfad bleibt: Icon-URL beim Bearbeiten leeren → update ermittelt neu (unveraendert).

Teil B — manuelle Sortierung mit Pfeilen. Im Bearbeitungsmodus je Eintrag „nach oben“/„nach unten“ (erster/letzter deaktiviert), optimistische Neuberechnung, PUT /favorites/order mit { widgetId, ids }; der Service setzt in EINER Transaktion position = index fuer genau die Eintraege dieses Nutzers/Widgets, fremde/unbekannte/fehlende ids → 400 ohne Teilschreibung. Altbestand (alle position 0) normalisiert sich beim ersten Klick. Kein Schema-Eingriff: FavoriteLink.position Int @default(0) existiert.

Tracer-Rolle: Die einzige lokal Ende-zu-Ende pruefbare Kette (Klick → optimistische Reihenfolge → reorderFavorites → bei Fehler Neuladen; Proxy-Bild → onError → Direktbild → onError → Buchstabe) liegt komplett in Task 2 — Task 2 traegt deshalb die Tracer-Rolle; Task 1 liefert Endpunkt und Dispatcher mit Unit-Tests. Der Beweis ueber die Netzgrenze (echter Host mit Zertifikatsfehler, echter interner Host im Firmennetz) erfolgt durch den Orchestrator im Browser.

Purpose: Favoriten sollen ihr Symbol auch bei Zertifikatsfehlern und internen Adressen zeigen und sich in der vom Nutzer gewuenschten Reihenfolge anordnen lassen. Output: undici-Dispatcher + Spec; DTO, Controller-Route, reorder() + Spec; reorderFavorites im Web-Client; Widget mit FavoriteIcon und Pfeilen + Tests; zwei i18n-Schluessel; CHANGELOG, Anwenderhandbuch, zwei Kommentar-/Doku-Nachtraege; drei Commits.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@/home/vicolab/projects/tessera-ctl/CLAUDE.md @/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/icon-discovery.service.ts @/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/favorites.service.ts @/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/favorites.controller.ts @/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx @/home/vicolab/projects/tessera-ctl/apps/web/src/lib/favorites-api.ts Task 1: API — undici-Dispatcher fuer beide Icon-Pfade, `PUT /favorites/order` mit transaktionalem `reorder()`, Specs 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/dto/reorder-favorites.dto.ts, apps/api/src/favorites/favorites.controller.ts, apps/api/src/favorites/favorites.service.ts, apps/api/src/favorites/favorites.service.spec.ts - apps/api/src/favorites/icon-discovery.service.ts Z. 1-22 (Kopfkommentar mit Schutzmassnahmen), Z. 234-287 (`fetchWithRedirectGuard` — EINZIGE Fetch-Stelle beider Pfade), Z. 289-307 (`fetchHtml`), Z. 309-379 (Klasse; `fetchIconBytes` Z. 343-378 mit Browser-User-Agent) - apps/api/src/favorites/icon-discovery.service.spec.ts Z. 1-24 (`mockResponse`), Z. 73-116 (Discovery-Tests mit `vi.stubGlobal('fetch', …)`), Z. 118-176 (fetchIconBytes-Tests, darunter Z. 152-162: SSRF-Block ohne fetch-Aufruf), Z. 178-205 - apps/api/src/favorites/favorites.service.ts Z. 1-56 (Kopfkommentar: Mandantenquelle, Benutzerdimension, zweites Netz), Z. 58-70 (`list`), Z. 117-157 (`update`) - apps/api/src/favorites/favorites.service.spec.ts Z. 18-20 (`vi.mock` nur `forTenant`), Z. 64-151 (`makeFakePrisma`: bound client mit findMany/findUnique/create/update/delete — KEIN updateMany), Z. 168-183, Z. 478-507 (Wachhund je Methode) - apps/api/src/groups/groups.service.spec.ts Z. 30-35 (`vi.mock` mit `withTenantTransaction` → `prisma.__withTenantTransaction`), Z. 296-299 (`__withTenantTransaction` reicht `__makeBoundClient(tenantId)` als `tx` durch) - apps/api/src/groups/groups.service.ts Z. 189-202 (Aufrufform `withTenantTransaction(this.prisma, tenantId, async (tx: any) => { … })`) - apps/api/src/prisma/prisma-tenant.extension.ts Z. 33-49 (Grenzfaelle: Array-Form auf gebundenem Klienten NICHT atomar), Z. 104-109 (Entscheidung fuer `withTenantTransaction`), Z. 139-148 (Benutzerdimension — `withTenantTransaction` setzt keine), Z. 253-260 (Implementierung) - apps/api/src/favorites/favorites.controller.ts Z. 35-40 (Routenliste im Kommentar), Z. 70-118 (create, getIcon, update) - apps/api/src/tenders/tenders.controller.ts Z. 636-648 (Praezedenz-Kommentar zur Route-Order bei `@Put`) - apps/api/src/favorites/dto/create-favorite.dto.ts (Decorator-Stil); apps/api/src/bug-reports/dto/bug-report.dto.ts Z. 1-10 und Z. 70-80 (`ArrayMaxSize`-Stil) - apps/api/src/main.ts Z. 17-21 (`ValidationPipe({ whitelist: true, transform: true })`) icon-discovery.service.spec.ts — ganz oben (vor den Imports, `vi.mock` wird gehoistet) ein Factory-Mock fuer `undici`: `class Agent { constructor(public readonly options: unknown) {} }` und `fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args)` (Pfeilfunktion, damit `vi.stubGlobal('fetch', …)` je Test greift). `import { Agent } from 'undici'` in der Spec liefert die Mock-Klasse. Neue `describe('IconDiscoveryService — Dispatcher (260917-jdd)')`: - Test 1: `fetchSpy` (stubGlobal) liefert eine HTML-Antwort (wie Z. 84-97); `discoverFavoriteIconUrl('http://8.8.8.8')`; `const init = fetchSpy.mock.calls[0][1]`; `expect(init.dispatcher).toBeInstanceOf(Agent)`; `expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } })`; `expect(init.redirect).toBe('manual')`. - Test 2: `fetchSpy` liefert `mockResponse({ contentType: 'image/png' })`; `fetchIconBytes('http://8.8.8.8/favicon.ico')`; dieselben drei Erwartungen auf `fetchSpy.mock.calls[0][1]`. - Test 3: erst Discovery, dann fetchIconBytes im selben Test (zwei stubGlobal-Aufrufe oder ein Spy mit `mockResolvedValueOnce` x2); `expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher)` (Modul-Singleton). - Alle 16 bestehenden Tests bleiben WORTGLEICH bestehen und gruen (insbesondere Z. 152-162: bei `127.0.0.1` wird `fetch` NICHT aufgerufen). favorites.service.spec.ts: - Fake: `updateMany: async ({ where, data })` auf dem gebundenen `favoriteLink`: Zeilen mit `tenantId === tenantId` und, falls in `where` vorhanden, `id`/`userId`/`widgetId` gleich; auf jede Treffer-Zeile `{ ...row, ...data, updatedAt: new Date() }`; Protokoll `{ tenantId, model: 'favoriteLink', method: 'updateMany' }`; Rueckgabe `{ count }`. Plus `__withTenantTransaction(tenantId, fn)` wie groups.service.spec.ts Z. 296-299 und `withTenantTransaction` im `vi.mock` wie Z. 32-34. - `describe('reorder (260917-jdd)')` mit drei Zeilen f1/f2/f3 (user-a1, t1, widget-a1, Titel 'A'/'B'/'C', position 0/0/0 — Altbestand) und einer Zeile f9 (user-a2, t1, widget-a1): - `reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'] })` → Rueckgabe-ids `['f3', 'f1', 'f2']`; `prisma.__favorites.get('f3').position === 0`, f1 === 1, f2 === 2; f9 unveraendert 0; `expect(withTenantTransaction).toHaveBeenCalledWith(prisma, 't1', expect.any(Function))`; `expectBoundCall(prisma, 't1', 'favoriteLink', 'updateMany')`. - ids `['f3', 'f1', 'f9']` (fremder Nutzer) → `rejects.toThrow(BadRequestException)`; danach ALLE Positionen unveraendert (0). - ids `['f3', 'f1', 'f-fehlt']` → BadRequestException. - ids `['f1', 'f2']` (Teilmenge) → BadRequestException. - ids `['f1', 'f1', 'f2']` (Duplikat) → BadRequestException UND `vi.mocked(withTenantTransaction)` NICHT aufgerufen. - `reorder('t2', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2', 'f3'] })` (fremder Mandant) → BadRequestException, Positionen unveraendert. - Wachhund: nach `mockClear` genau 0 `forTenant`-Aufrufe und genau 1 `withTenantTransaction`-Aufruf fuer den Happy Path. Tests aus `` zuerst schreiben, rot sehen (Import/Methode fehlen), dann implementieren:
1. **Abhaengigkeit.** `pnpm --filter @tessera/api add undici@7.28.0 --offline` (7.28.0 liegt bereits im Store und im Lockfile ueber cheerio@1.2.0/jsdom; ohne `--offline` wiederholen, falls der Offline-Modus die Metadaten nicht findet). Ergebnis pruefen: `apps/api/package.json` traegt exakt `"undici": "7.28.0"` (Pinning-Stil wie `"cron": "4.4.0"`), `git diff --stat pnpm-lock.yaml` zeigt nur den `importers`-Eintrag von apps/api (keine neue Paketversion, keine Aenderung an anderen Importern). NICHT auf 8.x heben (neues Major, neuer Download, nicht noetig — Node 24 buendelt selbst 7.25.0). `apps/api/package.json` NICHT von Hand editieren.

2. **icon-discovery.service.ts.** `import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';` ergaenzen. Modul-Konstante `LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } })` neben den anderen Konstanten (Z. 17-22) mit Doc-Kommentar: Ziel ist ein Bildchen, kein Geheimnis — selbstsignierte, abgelaufene oder falsch benannte Zertifikate sollen das Symbol nicht verhindern; gilt NUR fuer die Aufrufe dieser Datei (Dispatcher pro Aufruf, keine prozessweite Abschaltung der Zertifikatspruefung, insbesondere NICHT ueber die Node-Umgebungsvariable, die mit `NODE_TLS_` beginnt); der Dispatcher wirkt nur mit undicis eigenem `fetch`, Nodes globales `fetch` ignoriert ihn (gemessen 2026-09-17 gegen self-signed.badssl.com: undici 200, global fetch `DEPTH_ZERO_SELF_SIGNED_CERT`); DNS-Pruefung, Redirect-Limit, Timeout, Groessendeckel bleiben davon unberuehrt (T-JDD-01). In `fetchWithRedirectGuard` (Z. 258-265) den Aufruf des globalen Fetch durch `undiciFetch` ersetzen — erstes Argument unveraendert `currentUrl.toString()`, zweites Argument das bisherige Options-Objekt plus `dispatcher: LENIENT_TLS_AGENT` (also `dispatcher`, `redirect: 'manual'`, `signal: controller.signal`, `headers` wie bisher) — sonst NICHTS an der Funktion aendern (Schleife, `isPublicHttpUrl` je Hop, `MAX_REDIRECTS`, Timeout, `!response.ok`). Den Rueckgabetyp der Funktion und `FetchHtmlResult`/`fetchIconBytes` auf `UndiciResponse` statt des globalen `Response` typisieren, wo `tsc` es verlangt (die Datei nutzt nur `.status`, `.ok`, `.headers.get`, `.text()`, `.arrayBuffer()`). Kopfkommentar Z. 5-15 um eine Zeile ergaenzen (Zertifikatsfehler werden toleriert, Begruendung siehe Konstante). `discoverFavoriteIconUrl` und `fetchIconBytes` selbst bleiben unveraendert — beide laufen ueber `fetchWithRedirectGuard`.

3. **dto/reorder-favorites.dto.ts** (neu): `ReorderFavoritesDto` mit `@IsUUID() widgetId!: string;` und `@IsArray() @ArrayMinSize(1) @ArrayMaxSize(500) @ArrayUnique() @IsUUID('all', { each: true }) ids!: string[];`. Doc-Kommentar: vollstaendige ID-Liste in Anzeigereihenfolge; der Service verlangt exakte Uebereinstimmung mit den Favoriten des Widgets; 500 als Deckel (T-JDD-05).

4. **favorites.controller.ts.** `Put` in den `@nestjs/common`-Import, `ReorderFavoritesDto` importieren. Direkt NACH `create` (Z. 70-78) und VOR `@Get(':id/icon')`: `@Put('order') async reorder(@Body() dto: ReorderFavoritesDto, @Req() req: Request)` → `extractContext` → `this.favoritesService.reorder(tenantId, userId, dto)`. Kommentar ueber der Methode: statische Route steht bewusst VOR den `:id`-Routen (NestJS-Route-Order, Praezedenz tenders.controller.ts Z. 636-648). Routenliste im Klassenkommentar (Z. 35-39) um `PUT /favorites/order` und `GET /favorites/:id/icon` ergaenzen. In `getIcon` (Z. 102-104) zwei Header ergaenzen: `X-Content-Type-Options: nosniff` und `Content-Security-Policy: default-src 'none'; sandbox` — Kommentar: die Bytes kommen jetzt auch von Hosts ohne gueltiges Zertifikat; als `<img>`-Unterressource ignoriert der Browser diese Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne Skript und ohne Tessera-Origin (T-JDD-02).

5. **favorites.service.ts.** `withTenantTransaction` zusaetzlich aus `'../prisma/prisma-tenant.extension'` importieren, `ReorderFavoritesDto` importieren. Neue Methode `reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto)`:
   - Vorab (ohne Datenbank): `new Set(dto.ids).size !== dto.ids.length` → `BadRequestException`.
   - `return withTenantTransaction(this.prisma, tenantId, async (tx: any) => { … })`: `existing = await tx.favoriteLink.findMany({ where: { userId, widgetId: dto.widgetId }, select: { id: true } })`; `existingIds = new Set(existing.map(r => r.id))`; wenn `existing.length !== dto.ids.length` oder eine id nicht in `existingIds` → `throw new BadRequestException('ids must match the favorites of this widget exactly')` (EINE Meldung fuer alle Faelle). Dann `for (const [index, id] of dto.ids.entries())`: `const { count } = await tx.favoriteLink.updateMany({ where: { id, userId, widgetId: dto.widgetId }, data: { position: index } })`; `count !== 1` → dieselbe BadRequestException (Rollback). Rueckgabe `tx.favoriteLink.findMany({ where: { userId, widgetId: dto.widgetId }, orderBy: [{ position: 'asc' }, { title: 'asc' }] })`.
   - Doc-Kommentar (Stil des Bestands, ae/oe/ue): Warum `withTenantTransaction` (einzige gemessene atomare Form fuer Mehrschritt, extension Z. 33-49/104-109) und NICHT die Array-Form auf dem gebundenen Klienten; dass diese Form KEINE Benutzerdimension in der Sitzung setzt und deshalb `userId` UND `widgetId` in JEDER Bedingung stehen (zweites Netz); dass `updateMany` statt `update` gewaehlt ist, weil `update({ where: { id } })` nur nach id filtern koennte; Existenzorakel-Vermeidung (T-JDD-06); Altbestand mit position 0 normalisiert sich beim ersten Aufruf zu 0..n-1.
   - Kopfkommentar der Klasse (Z. 36-39, Access control) um eine Zeile fuer `reorder()` ergaenzen.

6. **Specs** laut `<behavior>`. In favorites.service.spec.ts den Kopfkommentar (Z. 6-17) um zwei Saetze zu `withTenantTransaction`/`updateMany` im Fake ergaenzen. `BadRequestException` ist dort bereits importiert.

Nicht anfassen: `apps/api/prisma/schema.prisma`, `favorites.module.ts`, `list`/`create`/`update`/`remove`/`getIconBytes`, die Funktionsbodies in `prisma-tenant.extension.ts`.
cd /home/vicolab/projects/tessera-ctl && grep -q '"undici": "7.28.0"' apps/api/package.json && test "$(node -p "require('./apps/api/node_modules/undici/package.json').version")" = "7.28.0" && pnpm install --frozen-lockfile --offline >/dev/null && git diff --quiet apps/api/prisma/schema.prisma && test "$(grep -v '^\s*\*' apps/api/src/favorites/icon-discovery.service.ts | grep -v '^\s*//' | grep -c 'rejectUnauthorized: false')" = "1" && grep -q 'dispatcher: LENIENT_TLS_AGENT' apps/api/src/favorites/icon-discovery.service.ts && ! grep -q 'NODE_TLS_REJECT_UNAUTHORIZED' apps/api/src/favorites/icon-discovery.service.ts && ! grep -qE '(^|[^a-zA-Z])fetch\(' <(grep -v '^\s*//' apps/api/src/favorites/icon-discovery.service.ts | grep -v '^\s*\*') && test "$(grep -n "@Put('order')" apps/api/src/favorites/favorites.controller.ts | cut -d: -f1)" -lt "$(grep -n "@Get(':id/icon')" apps/api/src/favorites/favorites.controller.ts | cut -d: -f1)" && grep -q 'withTenantTransaction(this.prisma, tenantId' apps/api/src/favorites/favorites.service.ts && grep -q 'ArrayUnique' apps/api/src/favorites/dto/reorder-favorites.dto.ts && pnpm --filter @tessera/api exec vitest run src/favorites && pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/api type-check undici 7.28.0 ist direkte Abhaengigkeit, beide Icon-Pfade laufen ueber undicis `fetch` mit dem toleranten Agent (Spec belegt Dispatcher, redirect manual, Singleton; SSRF-Tests unveraendert gruen); `PUT /favorites/order` steht vor den `:id`-Routen und setzt in EINER Transaktion `position = index` nur fuer exakt passende ids (sieben Reorder-Tests gruen); volle API-Suite (bisher 1091 Tests + neue) und type-check gruen; Commit `feat(api): Favoriten — Symbol trotz Zertifikatsfehler holen, Reihenfolge per PUT /favorites/order speichern` (nur die acht Dateien dieses Tasks; keine .planning-Dateien). Task 2: Web — `reorderFavorites`, `FavoriteIcon` mit Browser-Ersatzweg, Sortierpfeile, i18n, Tests 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 - apps/web/src/components/dashboard/widgets/favorites-widget.tsx Z. 3-13 (Imports), Z. 83-91 (`sortedFavorites`), Z. 93-117 (Ladeeffekt), Z. 119-122 (`getFallbackLetter`), Z. 264-323 (Listen-/Kachel-Rendering mit zwei `FavoriteTile`-Aufrufen), Z. 356-374 (`FavoriteTileProps`), Z. 395-471 (Tile: Icon-Block Z. 407-428, Aktionsknoepfe Z. 433-471 mit inline-SVG) - apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx Z. 1-81 (Mocks mit explizitem Factory, `BASE_FAVORITES`, `beforeEach`), Z. 166-215 (Muster fuer `act`/`fireEvent`/`getAllByRole`), Z. 276-292 (Buchstaben-Test) - apps/web/src/lib/favorites-api.ts (81 Zeilen, Muster `updateFavorite` fuer PATCH mit JSON-Body) - apps/web/src/messages/de.json Z. 295-312 und en.json Z. 295-312 (`widgets.favorites`) - apps/web/src/messages/umlaut-guard.spec.ts Z. 1-30 (de.json nur mit echten Umlauten) favorites-widget.test.tsx (Mock-Factory um `reorderFavorites: vi.fn()` erweitert; `mockReorder = reorderFavorites as ReturnType`; in `beforeEach` `mockReorder.mockResolvedValue([])` NICHT setzen — je Test explizit): - Test A „Ersatzbild bei iconUrl null“: Standarddaten, Ansicht; nach `waitFor` Notion sichtbar: `screen.getByTestId('icon-direct-fav-id-2')` hat `src` `https://notion.so/favicon.ico` und Attribut `referrerpolicy` = `no-referrer`; `screen.queryByTestId('icon-proxy-fav-id-2')` ist null; `letter-fallback-fav-id-2` zeigt `N`. - Test B „Kette Proxy → direkt → Buchstabe“: `icon-proxy-fav-id-1` vorhanden mit `src` `/api-proxy/favorites/fav-id-1/icon`, kein `icon-direct-fav-id-1`; `act(() => fireEvent.error(proxyImg))` → `queryByTestId('icon-proxy-fav-id-1')` null, `getByTestId('icon-direct-fav-id-1')` mit `src` `https://github.com/favicon.ico`; `act(() => fireEvent.error(directImg))` → beide null, `letter-fallback-fav-id-1` zeigt `G`. - Test C „kein Direktbild bei Nicht-http-URL“: `mockFetch.mockResolvedValue([{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 }])` → nach Laden kein `icon-direct-fav-id-3`, kein `icon-proxy-fav-id-3`, `letter-fallback-fav-id-3` zeigt `A`. - Test D „Pfeile: Zustand und Klick“: `isEditMode`, Standarddaten (GitHub 0, Notion 1); `mockReorder.mockResolvedValue([{ ...BASE_FAVORITES[1], position: 0 }, { ...BASE_FAVORITES[0], position: 1 }])`; `up = getAllByRole('button', { name: 'favorites.moveUpButton' })`, `down = getAllByRole('button', { name: 'favorites.moveDownButton' })`: `up[0]` disabled, `down[0]` nicht, `up[1]` nicht, `down[1]` disabled; `act(() => fireEvent.click(down[0]))`; `waitFor`: `mockReorder` mit `('fav-1', ['fav-id-2', 'fav-id-1'])`; Titel-Reihenfolge innerhalb `getByTestId('favorites-list')` (`within(...).getAllByRole('link').map(a => a.textContent)`) ist `['Notion', 'GitHub']`. - Test E „Fehler → Neuladen“: wie D, aber `mockReorder.mockRejectedValue(new Error('boom'))`; nach Klick `waitFor`: `mockFetch` 2-mal aufgerufen (Mount + Neuladen), `screen.getByText('favorites.error')` sichtbar, Reihenfolge wieder `['GitHub', 'Notion']`. - Die bestehenden 11 Tests bleiben unveraendert gruen (Buchstaben-Test Z. 276-292 gilt weiterhin, weil der Platzhalter immer rendert). Tests aus `` zuerst schreiben, rot sehen, dann implementieren:
1. **favorites-api.ts** — `export async function reorderFavorites(widgetId: string, ids: string[]): Promise<FavoriteLink[]>`: Aufruf per `fetch` an `${API_URL}/favorites/order` mit `{ method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ widgetId, ids }) }` (Muster `updateFavorite`); `!res.ok` → `throw new Error('Failed to reorder favorites')`; `return res.json()`. Doc-Kommentar: vollstaendige ID-Liste in Anzeigereihenfolge; der Server antwortet mit der Liste in neuer Reihenfolge. Kopfkommentar Z. 1-5 um den Endpunkt ergaenzen.

2. **favorites-widget.tsx — Icon.** Modulfunktion `getDirectFaviconSrc(url: string): string | null` (`try { const u = new URL(url); if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; return `${u.origin}/favicon.ico`; } catch { return null; }`). Neue Unterkomponente `FavoriteIcon({ fav, getFallbackLetter })`: `proxySrc = fav.iconUrl ? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon` : null`; `directSrc = getDirectFaviconSrc(fav.url)`; `const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>(proxySrc ? 'proxy' : 'direct')`. Rendert den bestehenden Container (Z. 408-428) mit dem Buchstaben-`span` (unveraendert, `data-testid` bleibt) und darueber: bei `stage === 'proxy'` das bisherige `<img>` (Attribute wie bisher, zusaetzlich `data-testid={`icon-proxy-${fav.id}`}`, `onError={() => setStage('direct')}`); bei `stage === 'direct' && directSrc` ein `<img data-testid={`icon-direct-${fav.id}`} src={directSrc} alt="" width={20} height={20} loading="lazy" referrerPolicy="no-referrer" className="absolute inset-0 w-5 h-5 rounded" onError={() => setStage('none')} />`; bei `none` oder ohne `directSrc` nichts. Im Tile den Icon-Block durch `<FavoriteIcon key={`${fav.iconUrl ?? ''}|${fav.url}`} fav={fav} getFallbackLetter={getFallbackLetter} />` ersetzen (der `key` setzt die Stufe zurueck, wenn URL oder Icon-URL sich aendern — kein Effekt noetig). Doc-Kommentar an `FavoriteIcon`: Stufe 1 Proxy ueber den Server (holt seit 260917-jdd auch bei Zertifikatsfehlern), Stufe 2 Direktbild aus dem Browser des Nutzers (erreicht interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt; `referrerPolicy` no-referrer; Origin nur aus http/https), Stufe 3 Buchstabe; bewusst kein Drittanbieter-Favicon-Dienst (wuerde Hostnamen nach aussen geben und interne Hosts ohnehin nicht kennen); Grenzen (Mischinhalt http-Favorit auf https-Tessera, nicht vertrautes Zertifikat im Browser) in einem Satz. Die bisherige Ausblendung per Style-Manipulation im `onError` (Z. 423-426) entfaellt — der Zustand `stage` ersetzt sie. Kopfkommentar der Datei (Z. 18-31) um eine Zeile zum Ersatzweg und eine zur Sortierung ergaenzen.

3. **favorites-widget.tsx — Sortierung.** `reorderFavorites` in den Import (Z. 6-12). Handler `async function handleMove(id: string, direction: 'up' | 'down')`: `order = sortedFavorites.map(f => f.id)`; `index = order.indexOf(id)`; `target = direction === 'up' ? index - 1 : index + 1`; bei `index < 0 || target < 0 || target >= order.length` return; tauschen; `byId = new Map(favorites.map(f => [f.id, f]))`; `reindexed = order.map((fid, i) => ({ ...byId.get(fid)!, position: i }))`; `setFavorites(reindexed)`; `setError(null)`; `try { setFavorites(await reorderFavorites(instanceId, order)); } catch { setError(t('favorites.error')); try { setFavorites(await fetchFavorites(instanceId)); } catch { /* Fehlermeldung steht bereits */ } }`. `FavoriteTileProps` um `canMoveUp: boolean`, `canMoveDown: boolean`, `onMove: (id: string, direction: 'up' | 'down') => void` erweitern; in BEIDEN `sortedFavorites.map`-Aufrufen (Kachel Z. 275-294 und Liste Z. 301-320) `(fav, index)` und `canMoveUp={index > 0} canMoveDown={index < sortedFavorites.length - 1} onMove={(fid, dir) => void handleMove(fid, dir)}` uebergeben. Im Tile im Aktionscontainer (Z. 435, `widgetNoDrag`) VOR dem Bearbeiten-Knopf zwei Knoepfe im Stil der bestehenden (`type="button"`, `aria-label` und `title` aus `t('favorites.moveUpButton')` bzw. `t('favorites.moveDownButton')`, `className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:hover:text-muted-foreground"`, `disabled={!canMoveUp}` bzw. `!canMoveDown`, `onClick={() => onMove(fav.id, 'up')}` bzw. `'down'`) mit inline-SVG 14x14, `fill="currentColor"`, `aria-hidden="true"`: nach oben `<path d="M12 8.6 5.4 15.2l1.4 1.4L12 11.4l5.2 5.2 1.4-1.4z" />`, nach unten `<path d="m12 15.4 6.6-6.6-1.4-1.4L12 12.6 6.8 7.4 5.4 8.8z" />`. Kein Drag & Drop (kollidiert mit dem Ziehen der Kachel in react-grid-layout) — als Kommentar an den Knoepfen. Widget-Config/Dashboard-Layout bleiben unberuehrt; `sortedFavorites` (position asc, title asc) bleibt.

4. **i18n.** de.json `widgets.favorites`: `"moveUpButton": "Nach oben"`, `"moveDownButton": "Nach unten"` nach `deleteButton` (Z. 306); en.json an derselben Stelle `"Move up"` / `"Move down"`. Keine weiteren Schluessel.

5. **Tests** laut `<behavior>`; `within` aus `@testing-library/react` importieren. Kopfkommentar der Testdatei nicht noetig; neue Tests in einem `describe('Ersatzbild und Sortierung (quick-260917-jdd)')`.
cd /home/vicolab/projects/tessera-ctl && grep -q 'referrerPolicy="no-referrer"' apps/web/src/components/dashboard/widgets/favorites-widget.tsx && grep -q 'function getDirectFaviconSrc' apps/web/src/components/dashboard/widgets/favorites-widget.tsx && ! grep -q "style.display = 'none'" apps/web/src/components/dashboard/widgets/favorites-widget.tsx && grep -q 'favorites.moveUpButton' apps/web/src/components/dashboard/widgets/favorites-widget.tsx && grep -q '"moveUpButton": "Nach oben"' apps/web/src/messages/de.json && grep -q '"moveDownButton": "Move down"' apps/web/src/messages/en.json && grep -q "method: 'PUT'" apps/web/src/lib/favorites-api.ts && grep -q '/favorites/order' apps/web/src/lib/favorites-api.ts && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/favorites-widget.test.tsx src/messages && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check Widget zeigt bei fehlendem Server-Symbol das Direktbild aus dem Browser und danach den Buchstaben (Kette per Test belegt); im Bearbeitungsmodus sortieren Pfeile optimistisch und persistieren ueber `PUT /favorites/order`, bei Fehler Neuladen mit Meldung; 11 + 5 Widget-Tests, Umlaut-Waechter, volle Web-Suite und type-check gruen; Commit `feat(web): Favoriten-Widget — Symbol-Ersatzweg aus dem Browser, Sortierpfeile im Bearbeitungsmodus`. Task 3: CHANGELOG, Anwenderhandbuch, zwei Nachtraege (Zugriffsklassifikation, Kopfkommentar der Extension) CHANGELOG.md, docs/anleitung-anwender.md, docs/mandantentrennung-zugriffsklassifikation.md, apps/api/src/prisma/prisma-tenant.extension.ts - CHANGELOG.md Z. 1-40 — FRISCH lesen: `## Unveröffentlicht` (Z. 5) ist beim Planen LEER; parallele Quick-Tasks (Bildmarke, CI) koennen inzwischen Unterueberschriften und Zeilen angelegt haben. Unterabschnitte heissen `### Neu`, `### Geändert`, `### Entfernt`, `### Behoben` (Z. 9-27) — NICHT „Hinzugefügt“. Anfuehrungszeichen „…“ (Z. 27). - docs/anleitung-anwender.md Z. 59-67 (Bearbeitungsmodus des Dashboards: Stift-Schalter „Dashboard bearbeiten“), Z. 70-83 (Widget-Tabelle; Zeile 80 „Favoriten“ — Tabellenzeilen sind EINE Zeile; Anfuehrungszeichen dort „…" mit geradem Schlusszeichen wie Z. 62) - docs/mandantentrennung-zugriffsklassifikation.md Z. 673 (Zeile `| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | … |` — Begruendung ist freier Text, `Stand` bleibt `gebunden`) - apps/api/src/prisma/prisma-tenant.extension.ts Z. 139-148 (Absatz „Wer den Benutzer setzt“ mit dem Satz Z. 145-148, dass `withTenantTransaction()` KEINEN dritten Parameter bekommt, weil kein Nutzer-CRUD-Aufrufer sie nutzt) 1. **CHANGELOG.md**, `## Unveröffentlicht`: Falls `### Neu` bzw. `### Behoben` dort fehlen, anlegen (Reihenfolge Neu, Geändert, Entfernt, Behoben — nur die benoetigten). Je EINE neue Zeile am Ende der jeweiligen Liste, bestehende Zeilen (auch neue aus parallelen Tasks) unangetastet: - unter `### Neu`: `- Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus mit den Pfeilen „Nach oben“/„Nach unten“ festlegen` - unter `### Behoben`: `- Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler oder internen Adressen – das Symbol wird jetzt trotz Zertifikatsfehler geholt, bei internen Adressen versucht es der Browser direkt` Stil wie Bestand: kurz, typografische Anfuehrungszeichen, Gedankenstrich „–“, kein Punkt am Ende. Mit `Edit` (gezielt), nie die Datei neu schreiben. 2. **docs/anleitung-anwender.md**, Tabellenzeile „Favoriten“ (Z. 80), zweite Spalte am Ende ergaenzen (Zeile bleibt EINE Zeile, Sie-Form, Anfuehrungszeichen wie Z. 62): `Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt`. Keine weiteren Aenderungen am Handbuch. 3. **docs/mandantentrennung-zugriffsklassifikation.md** Z. 673, Begruendungsspalte vor dem abschliessenden `|` ergaenzen: ` Nachtrag (260917-jdd): `reorder()` laeuft als Mehrschritt ueber `withTenantTransaction()` (einzige gemessene atomare Form, siehe prisma-tenant.extension.ts) — diese Form setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung innerhalb der Transaktion `userId` UND `widgetId`; der Stand bleibt `gebunden` (Erkennungsform 2 des Detektors).` Die Zeile bleibt EINE Zeile; Spalten `Klasse`/`Stand` unveraendert. 4. **apps/api/src/prisma/prisma-tenant.extension.ts**, Kopfkommentar Z. 145-148: den Satz `\`withTenantTransaction()\` bekommt KEINEN dritten Parameter: kein Nutzer-CRUD-Aufrufer nutzt diese Funktion (nur \`groups\`, ein Verwaltungsweg) — ein unbenutzter Parameter waere Spekulation ohne heutigen Aufrufer.` um einen Nachtrag im selben Absatz erweitern: ` Nachtrag (260917-jdd): \`favorites.service.ts\` (\`reorder\`) ist seither der erste Nutzer-CRUD-Aufrufer — er kommt OHNE Benutzerdimension in der Sitzung aus und traegt \`userId\` UND \`widgetId\` in jeder Bedingung innerhalb der Transaktion selbst (zweites Netz). Ein dritter Parameter kommt erst, wenn ein Aufrufer die Benutzerdimension INNERHALB der Transaktion braucht.` NUR Kommentartext (`*`-Zeilen, Zeilenumbruch im Stil des Blocks); die Funktionen `forTenant`, `forSystem`, `withTenantTransaction` bleiben byteweise unveraendert (Gate: `prisma-tenant.extension.spec.ts`). 5. Kein Docker-Build, kein Push. `git status` vor dem Commit: nur die vier Dateien dieses Tasks (plus ggf. `.planning/`, das NICHT mit committet wird). cd /home/vicolab/projects/tessera-ctl && grep -q '^- Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus' CHANGELOG.md && grep -q '^- Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler' CHANGELOG.md && ! grep -q '^### Hinzugefügt' CHANGELOG.md && grep -q 'Nach oben' docs/anleitung-anwender.md && test "$(grep -c '^| Favoriten |' docs/anleitung-anwender.md)" = "1" && grep -q 'Nachtrag (260917-jdd)' docs/mandantentrennung-zugriffsklassifikation.md && grep -q 'Nachtrag (260917-jdd)' apps/api/src/prisma/prisma-tenant.extension.ts && pnpm --filter @tessera/api exec vitest run src/prisma/prisma-tenant.extension.spec.ts src/prisma/rls-access-inventory.spec.ts && pnpm --filter @tessera/web exec vitest run src/lib/changelog.test.ts CHANGELOG traegt zwei neue `Favoriten-Widget:`-Stichpunkte unter `### Neu` und `### Behoben`; das Handbuch nennt die Pfeile und den Browser-Ersatzweg in der Favoriten-Zeile; Zugriffsklassifikation und Extension-Kopfkommentar fuehren `reorder` als ersten Nutzer-CRUD-Aufrufer von `withTenantTransaction()` (Specs gruen); Commit `docs: Favoriten-Sortierung und Symbol-Ersatzweg im CHANGELOG und Anwenderhandbuch; Nachtraege zur Mandantenbindung`. Der Nachweis im Browser (echter Host mit Zertifikatsfehler, echter interner Host, Sortierung ueber Reload hinweg) folgt durch den Orchestrator — im SUMMARY als offen fuehren.

<threat_model>

Trust Boundaries

Boundary Description
API → fremde Web-Server (Icon-Ermittlung, Icon-Proxy) Ausgehende Anfragen an vom Nutzer eingetragene Adressen; seit diesem Plan OHNE Zertifikatspruefung
Browser des Nutzers → Origin des Favoriten Direktes <img> auf {origin}/favicon.ico aus dem Browser (auch Firmennetz)
Browser → API (PUT /favorites/order) Nutzergesteuerte ID-Liste, JWT-geschuetzt, mandanten- und nutzergebunden
API → Browser (GET /favorites/:id/icon) Fremde Bild-Bytes werden unter Tessera-Origin ausgeliefert

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-JDD-01 Tampering LENIENT_TLS_AGENT / fetchWithRedirectGuard (icon-discovery.service.ts) medium accept Ein Angreifer auf dem Netzpfad kann bei abgeschalteter Zertifikatspruefung hoechstens ANDERE Bytes unterschieben. Die Bytes werden ausschliesslich als Bild weitergereicht: image/-Content-Type-Pruefung, 1 MB-Deckel, HTML-Pfad nur 200 000 Zeichen und nur Link-/Meta-Tags per Regex (kein Skript, kein DOM); das Widget rendert <img> ohne dangerouslySetInnerHTML (T-08-07). Es fliessen KEINE Geheimnisse ueber diese Verbindungen (keine Cookies, keine Tokens, nur Accept/User-Agent). Der Dispatcher gilt nur fuer diese Datei, nicht prozessweit. SSRF-Schutz T-08-05 unveraendert: isPublicHttpUrl je Hop, MAX_REDIRECTS 2, 4 s Timeout (Spec Z. 152-162 unveraendert gruen).
T-JDD-02 Elevation of Privilege GET /favorites/:id/icon liefert fremde Bytes unter Tessera-Origin low mitigate Vorbestehend (nicht durch diesen Plan eingefuehrt: auch mit gueltigem Zertifikat kann der Zielserver ein SVG mit Skript liefern). Guenstige Haertung im Zuge dieses Plans: X-Content-Type-Options: nosniff und Content-Security-Policy: default-src 'none'; sandbox am Proxy — als <img>-Unterressource wirkungslos, bei direktem Oeffnen im Tab laeuft ein SVG damit ohne Skript und ohne Tessera-Origin. Aufruf weiterhin nur per FavoriteLink-id des Aufrufers (T-QFIP-01), nie per Client-URL.
T-JDD-03 Tampering FavoritesService.reorder / PUT /favorites/order medium mitigate withTenantTransaction: eine Transaktion, Rollback bei jeder Abweichung. Menge der ids muss EXAKT den Favoriten von userId+widgetId entsprechen; updateMany traegt id+userId+widgetId und prueft count === 1. Fremde/unbekannte/fehlende ids → 400 ohne Schreibung (Spec: Positionen unveraendert). ValidationPipe({ whitelist: true }) + DTO (IsUUID, ArrayUnique) filtern fremde Felder und Duplikate vor dem Service.
T-JDD-04 Information Disclosure Direktes <img> aus dem Browser auf {origin}/favicon.ico low accept Ziel ist der vom Nutzer selbst eingetragene Host (kein Dritter); referrerPolicy="no-referrer" gibt die Tessera-Adresse nicht preis; Origin nur aus http:/https: per new URL (kein javascript:/data:); kein Drittanbieter-Favicon-Dienst (wuerde Hostnamen nach aussen geben). Kein CSP img-src in apps/web vorhanden (Bestand).
T-JDD-05 Denial of Service reorder-Schleife in der Transaktion; Browser-Ersatzweg low mitigate ArrayMaxSize(500) je Aufruf, ArrayMinSize(1); ein Widget haelt praktisch wenige Links. Der Ersatzweg loest je Favorit hoechstens EIN zusaetzliches Bild-GET aus (nur nach onError des Proxy-Bildes oder bei iconUrl null), kein Retry.
T-JDD-06 Information Disclosure Existenzorakel ueber widgetId/ids in reorder low mitigate Eine BadRequestException mit derselben Meldung fuer „fremde id“, „unbekannte id“, „Teilmenge“, „fremdes/unbekanntes Widget“ und „fremder Mandant“ (Spec belegt alle Faelle) — Muster T-GWH-05.
T-JDD-SC Tampering npm-Installation undici low mitigate undici (nodejs/undici, offizielle fetch-Implementierung von Node.js) ist bereits in pnpm-lock.yaml mit Integritaetssumme aufgeloest (7.28.0 ueber cheerio@1.2.0 und jsdom) und liegt im Store — der Plan ERKLAERT die vorhandene Version zur direkten Abhaengigkeit (pnpm add … --offline), kein neues Paket, kein neues Major. Vom Planer geprueft (2026-09-17): Registry-Version 8.10.2 vorhanden, engines.node >=20.18.1, Aufruf mit dispatcher gegen self-signed.badssl.com liefert 200. Gate im Verify: git diff --stat pnpm-lock.yaml nur Importer-Eintrag, pnpm install --frozen-lockfile --offline gruen. Kein [ASSUMED]/[SUS]-Paket → kein blockierender Checkpoint.
</threat_model>
- API: `pnpm --filter @tessera/api exec vitest run` (68+ Dateien, bisher 1091 Tests + 10 neue) und `pnpm --filter @tessera/api type-check` gruen; `rls-access-inventory.spec.ts` und `prisma-tenant.extension.spec.ts` gruen; `pnpm install --frozen-lockfile --offline` gruen; `schema.prisma` unveraendert. - Web: `pnpm --filter @tessera/web exec vitest run` (bisher 11 Widget-Tests + 5 neue, Umlaut-Waechter) und `pnpm --filter @tessera/web type-check` gruen. - Route-Order: `@Put('order')` steht vor `@Get(':id/icon')` (Zeilennummern-Gate). - CHANGELOG: zwei neue `Favoriten-Widget:`-Zeilen; Handbuch-Zeile „Favoriten“ bleibt eine Tabellenzeile. - Offen (nicht lokal pruefbar, Orchestrator im Browser): Favorit auf einen Host mit Zertifikatsfehler (z. B. `https://self-signed.badssl.com/`) zeigt das Symbol ueber den Proxy; Favorit auf einen internen Host zeigt das Symbol ueber das Direktbild (sofern der Browser dem Zertifikat vertraut bzw. es http/https-passend ist); Sortierung ueberlebt einen Reload; Altbestand mit position 0 wird beim ersten Klick zu 0..n-1.

<success_criteria>

  • Alle must_haves.truths erfuellt; drei Commits ohne Push, ohne Docker-Build, ohne Schema-Aenderung.
  • Keine Datei ausserhalb von files_modified + .planning/ veraendert (git status vor jedem Commit gegenpruefen); .planning/ wird NICHT committet.
  • SUMMARY nennt die offenen Browser-Nachweise ausdruecklich und den Befund, dass die Ermittlung nie null liefert (Grund fuer die onError-Kette). </success_criteria>
Create `/home/vicolab/projects/tessera-ctl/.planning/quick/260917-jdd-favoriten-widget-favicon-ersatzweg-bei-u/260917-jdd-SUMMARY.md` when done