diff --git a/.planning/quick/260911-gwh-mandantentrennung-etappe-2-bereiche-favo/260911-gwh-PLAN.md b/.planning/quick/260911-gwh-mandantentrennung-etappe-2-bereiche-favo/260911-gwh-PLAN.md
new file mode 100644
index 0000000..44bd002
--- /dev/null
+++ b/.planning/quick/260911-gwh-mandantentrennung-etappe-2-bereiche-favo/260911-gwh-PLAN.md
@@ -0,0 +1,1226 @@
+---
+phase: quick-260911-gwh
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+autonomous: true
+requirements: [WINDOWS-18, ETAPPE-2-FAVORITES, ETAPPE-2-SETTINGS]
+
+files_modified:
+ - apps/api/scripts/rls-scratch-check.mjs
+ - docs/mandantentrennung-etappe2-fehlerrichtung.md
+ - 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/settings/settings.service.ts
+ - apps/api/src/settings/settings.service.spec.ts
+ - apps/api/src/mail/mail.module.ts
+ - docs/mandantentrennung-zugriffsklassifikation.md
+ - docs/anleitung-entwicklung.md
+ - .planning/WINDOWS.md
+
+estimate:
+ tokens: 210000
+ raw_tokens: 210000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "Beide Bereiche sind GEMESSEN, bevor eine Zeile Quelltext angefasst wird: `runFavoritesAreaChecks` und `runSettingsAreaChecks` messen an den Regeln fuer `FavoriteLink` und `SmtpConfig` WORTGLEICH aus `20260909140000_rls_remaining_tenant_tables` (Regelstand eindeutig, die Widen-Migration hat keine eigene Regel fuer beide), je Bereich mindestens einen Pfad ueber den GENERIERTEN Client an einer Wegwerf-Tabelle, deren Spaltenmenge zur Laufzeit gegen die SKALAREN Felder des Modells geprueft wird (`createdAt`/`updatedAt` eingeschlossen)."
+ - "Der Startpfad des Mailmoduls ist als SECHSTER Fall der Hintergrunddienst-Falle erkannt, gemessen und markiert, NICHT gebunden und NICHT zu einem Mehrmandanten-Versand umgebaut: die Methode traegt einen Namen, der sie nicht mit einem Anfrageweg verwechseln laesst, ihr Kopf nennt BEIDE Zustaende (heute: beliebiger Mandant liefert den SMTP-Server fuer ALLE Systemmails; nach dem Scharfschalten: `null`, und die Rueckfallkette des Moduls verdeckt das Verstummen mit einem falschen Transport statt es zu melden), die Unsymmetrie zu `ldap`/`dkv` (Rueckfallkette statt blosser Leere) und die Entscheidung EIGENER Ledger-Eintrag statt Anschluss an #21 — mit Grund."
+ - "Befund K (`tenders` (t4)) und die Uebergabe in `dkv` (d4) sind ERFUELLT und stehen als solche an drei Stellen: `getDecryptedSmtpConfig(tenantId)` — der EINZIGE Versandpfad von `tender-mail.service.ts` und `dkv-mail.service.ts` — laeuft ueber `tenantPrisma`; ein Nachtrag unter Befund K in (t4), ein Nachtrag im Uebergaben-Absatz von (d4), und die Ordnungsnotiz im Klassifikationsdokument sagen es ausdruecklich, damit die Etappe-4-Vorabpruefung keine veraltete Bedingung mitschleppt."
+ - "Alle sieben Zugriffe in `favorites.service.ts` und die drei Anfragewege in `settings.service.ts` laufen je Methode ueber GENAU EINEN Klienten `tenantPrisma`; die Besitzpruefungen (findUnique, dann Vergleich der Benutzerkennung, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben bestehen; `create` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert (der Fremdschluessel umgeht den Zeilenschutz — gemessen, Pruefung 7)."
+ - "Die umgekehrte Fehlerrichtung ist je Bereich in ihrer TATSAECHLICHEN Auspraegung benannt und gemessen: eine leere Favoritenleiste liest sich als `Noch keine Favoriten.` (favorites-widget.tsx); eine unsichtbare SMTP-Zeile liest sich als leeres Formular (200 mit leerem Rumpf, `res.json()` wirft, `.catch(() => {})` verschluckt — dieselbe Kette wie #28), waehrend die Zugangsdaten physisch noch da sind und ein erneutes Speichern unter der ungebundenen Form am Eindeutigkeitsindex scheitert (gemessen, Konstruktorname und `code` woertlich)."
+ - "Die Testlage traegt: `favorites.service.spec.ts` und `settings.service.spec.ts` existieren NEU mit dem Zwei-Klienten-Nachbau (ungebunden OHNE Anfrage-Modelle, gebunden OHNE `findFirst`), der Icon-Dienst und `nodemailer` sind Attrappen (kein echter Transport — lokal gibt es keinen `mailhog`), und vier Falsifizierungsnachweise sind durchgefuehrt, zurueckgenommen und woertlich notiert."
+ - "Etappe 2 ist mit diesem Lauf VOLLSTAENDIG: die Uebersichtstabelle, Summenzeile, Bestandsaufnahme, Klassen-Verteilung und der Hintergrunddienst-Abschnitt des Klassifikationsdokuments stehen auf ihrem Endstand fuer Etappe 2 — DERIVIERT gegatet, nicht abgeschrieben —, jede Fundstelle in `apps/api/src` ist gebunden oder mit geschriebenem Grund an der Stelle ungebunden, und die Kritikschrift traegt einen Abschluss-Abschnitt mit den Zahlen aus den eigenen Messanweisungen sowie dem, was fuer Etappe 3 bleibt."
+ - "Baseline gehalten am Ende JEDER Aufgabe: mindestens 951 Tests gruen in mindestens 60 Dateien (nach Aufgabe 2 mehr, in mindestens 62 Dateien), Typpruefung sauber, Werkzeug alle Pruefungen bestanden (mindestens 135). Schalter AUS, Schema und Migrationen unveraendert, keine Compose- oder Umgebungsdatei, nichts in Active Directory; Erlaubnisliste gegen `46f0e78`."
+ artifacts:
+ - "apps/api/scripts/rls-scratch-check.mjs — zwei neue Abschnitte `runFavoritesAreaChecks` (Wegwerf-Tabelle `FavoriteLink` mit Fremdschluessel auf die bestehende Wegwerf-Tabelle `WidgetInstance`) und `runSettingsAreaChecks` (Wegwerf-Tabelle `SmtpConfig` mit Eindeutigkeitsindex auf `tenantId`), je mindestens sieben namentlich benannte Pruefungen, in `main()` NACH `runAuthAreaChecks` und VOR `runTransactionShapeMeasurement`"
+ - "docs/mandantentrennung-etappe2-fehlerrichtung.md — Abschnitte `## Bereich favorites` ((f1)-(f5)) und `## Bereich settings` ((s1)-(s5)) sowie `## Etappe 2 — Abschluss`, alle VOR `## Verweis`; Nachtraege unter Befund K in (t4) und im Uebergaben-Absatz von (d4)"
+ - "apps/api/src/favorites/favorites.service.ts — `list(tenantId, userId, widgetId)`, `create(tenantId, userId, dto)`, `update(tenantId, id, userId, dto)`, `remove(tenantId, id, userId)`, `getIconBytes(tenantId, id, userId)` je mit EINEM Klienten `tenantPrisma`; Widget-Besitzriegel in `create`"
+ - "apps/api/src/favorites/favorites.service.spec.ts — NEU: Zwei-Klienten-Nachbau fuer `favoriteLink` und `widgetInstance`, Icon-Dienst als Attrappe, Wachhund, Grenzfaelle"
+ - "apps/api/src/favorites/favorites.controller.ts — reicht `tenantId` aus `extractContext` an alle fuenf Dienstmethoden durch; Kopfkommentar nennt die Mandantenquelle und den dashboard-Praezedenzfall"
+ - "apps/api/src/settings/settings.service.ts — `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` je mit EINEM Klienten `tenantPrisma`; der Startpfad umbenannt in `loadAnySmtpConfigForStartupTransport()`, weiterhin auf dem ungebundenen Klienten, mit Kopfkommentar beider Zustaende, Unsymmetrie, Reparaturweg und Ledger-Nummer"
+ - "apps/api/src/settings/settings.service.spec.ts — NEU: Zwei-Klienten-Nachbau fuer `smtpConfig` (ungebunden NUR `findFirst`, gebunden NUR `findUnique`/`upsert`), `nodemailer` und `CryptoService` als Attrappen, Wachhund je Anfrageweg, Null-Klienten-Nachweis fuer den Startpfad"
+ - "apps/api/src/mail/mail.module.ts — ruft den umbenannten Startpfad auf; Modulkommentar nennt beide Zustaende und die Rueckfallkette als Verdeckung"
+ - "docs/mandantentrennung-zugriffsklassifikation.md — Uebersichtszeilen `favorites`/`settings` mit neu gemessenen Zahlen, Summenzeile, Bestandsaufnahme-Zeilen (`favoriteLink` auf `gebunden`, `smtpConfig` auf `gemischt`, neue Zeile `favorites.service.ts`/`widgetInstance`), Klassen-Verteilung mit Stand-Vermerk und neuer Summe, Hintergrunddienst-Abschnitt mit sechstem Fall und angepasster Ueberschrift, `Was diese Etappe NICHT entscheidet` mit dem Mehrmandanten-Versand-Punkt und der erfuellten Befund-K-Bedingung"
+ - "docs/anleitung-entwicklung.md — der Absatz, der `FavoriteLink` als Tabelle OHNE Regel und `DkvService.loadConfig()` als ungebunden nennt, auf den gemessenen Stand gebracht (drei Regel-Migrationen, 23 Tabellen)"
+ - ".planning/WINDOWS.md — drei neue OFFENE Eintraege ueber `gsd-tools windows append`: Startpfad des Mailmoduls (eigener Eintrag), verschluckte Leere `favorites` (favorites-widget.tsx), verschluckte Leere `settings` (smtp-settings-form.tsx)"
+ key_links:
+ - "`tender-mail.service.ts:158` und `dkv-mail.service.ts:47` rufen `settingsService.getDecryptedSmtpConfig(tenantId)` — der Mandant kommt aus der jeweiligen gebundenen Schleife (260909-laa, 260909-mir). Bindet diese Methode, ist Befund K geschlossen; bleibt sie ungebunden, geht nach dem Scharfschalten KEINE Ausschreibungs- und KEINE DKV-Mail mehr raus (tender: `warn` + `null`, dkv: `throw`)."
+ - "`mail.module.ts:30` ruft den Startpfad in einer asynchronen `useFactory` VOR jedem Anfragekontext; liefert er `null`, greift Prioritaet 2-4 (Umgebungsvariablen, zuletzt `localhost:1025`) — `MailService.sendPasswordResetEmail` faengt jeden Transportfehler (T-02-12) und antwortet 200: das Verstummen ist doppelt verdeckt."
+ - "`FavoriteLink.widgetId` -> `WidgetInstance.id` (Fremdschluessel, ON DELETE CASCADE, 20260708090000): Fremdschluesselpruefungen laufen in PostgreSQL an der Zeilenschutz-Regel VORBEI — der Bezug auf ein fremdes Widget ist auf Datenbankebene nicht gesperrt (Pruefung 7 misst das); der Besitzriegel in `create` ist deshalb anwendungsseitig."
+ - "`favorites.controller.ts` `extractContext` liest `req.tenantId ?? req.user?.tenantId` — dieselbe Quelle wie `dashboard.controller.ts`, unter dessen Bindung `WidgetInstance` liegt. Eine andere Quelle wuerde Widget und Link unter einem `x-tenant-id`-Wechsel an verschiedene Mandanten binden; `favorites-api.ts` sendet die Kopfzeile nicht (gemessen)."
+ - "`settings.controller.ts` liest `req.tenantId` — fuer eine ADMIN-Konfigurationsseite die RICHTIGE Quelle (D-10: SUPER_ADMIN konfiguriert ueber `x-tenant-id` einen anderen Mandanten); die Selbstbedienungs-Begruendung aus `auth` (Claim) gilt hier NICHT, der Controller bleibt unveraendert."
+ - "`SmtpConfig_tenantId_key` (20260629130000) ist der Eindeutigkeitsindex, an dem ein ungebundenes `upsert` auf eine unsichtbare Zeile scheitert — die `dashboard`-Lehre (260910-krx, `PrismaClientUnknownRequestError` statt P2002) zur Ausfuehrungszeit erneut messen, nicht abschreiben."
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Etappe 2 der Mandantentrennung, LETZTER Lauf: die Bereiche `favorites` (sieben
+ungebundene Zugriffe auf `favoriteLink` in fuenf Methoden) und `settings`
+(vier ungebundene Zugriffe auf `smtpConfig` in vier Methoden) — zwei kleine
+Bereiche gleicher Bauform, als EIN Durchlauf, weil keiner allein drei
+Aufgaben traegt.
+
+Zweck, dreifach. Erstens: `settings` haelt die SMTP-Zugangsdaten je Mandant,
+und `getDecryptedSmtpConfig(tenantId)` ist der einzige Versandpfad fuer
+Ausschreibungs-Meldungen und DKV-Ausfuhren. Der `tenders`-Lauf hat als Befund
+K festgehalten: bleibt diese Methode ungebunden, geht nach dem Scharfschalten
+fuer NIEMANDEN eine Mail raus. Dieser Lauf schliesst diese Bedingung und sagt
+das an jeder Stelle, an der sie steht. Zweitens: `getStartupSmtpConfig()` ist
+der SECHSTE Fall der Hintergrunddienst-Falle — ein Startpfad ohne
+Mandantenkontext, der heute eine BELIEBIGE Zeile zieht (der SMTP-Server eines
+beliebigen Mandanten bedient alle Systemmails) und nach dem Scharfschalten
+`null` liefert; anders als bei `dkv` (WINDOWS #21) verdeckt hier eine
+Rueckfallkette das Verstummen mit einem falschen Transport. Der Pfad wird
+gemessen, benannt, markiert und als eigener Ledger-Eintrag gefuehrt — NICHT
+gebunden, NICHT zu einem Mehrmandanten-Versand umgebaut (das ist eine
+Funktion, dieselbe Klasse wie #21). Drittens: `favorites` ist Nutzerdaten
+mit der findUnique-dann-loeschen-Bauform, die dieses Vorhaben zweimal falsch
+und zweimal richtig vorgefunden hat — sie wird gelesen und gemessen, und der
+Fremdschluessel auf `WidgetInstance` (der den Zeilenschutz umgeht) bekommt
+einen anwendungsseitigen Besitzriegel.
+
+Nach diesem Lauf ist Etappe 2 vollstaendig: jede klassifizierte Fundstelle in
+`apps/api/src` ist gebunden oder mit geschriebenem Grund an der Stelle
+ungebunden. Die fuenf handgepflegten Dokumentstellen erreichen ihren Endstand,
+die Kritikschrift bekommt einen Abschluss-Abschnitt aus ihren eigenen Zahlen.
+
+**Der Schalter bleibt AUS.** `DATABASE_URL` zeigt weiterhin auf die Rolle
+`tessera` mit `BYPASSRLS`. Schema und Migrationen werden NICHT angefasst.
+Nichts wird in Active Directory geaendert. Kein echter SMTP-Transport wird
+aufgebaut (lokal gibt es keinen `mailhog`; `ENOTFOUND mailhog` waere
+Umgebung, nicht Befund).
+
+
+
+@~/.claude/gsd-core/workflows/execute-plan.md
+@~/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/STATE.md
+@docs/mandantentrennung-zugriffsklassifikation.md
+@docs/mandantentrennung-etappe2-fehlerrichtung.md
+@docs/anleitung-entwicklung.md
+@apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql
+@apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql
+@apps/api/prisma/migrations/20260708090000_add_favorite_link/migration.sql
+@apps/api/prisma/migrations/20260629130000_add_missing_tables/migration.sql
+@apps/api/prisma/schema.prisma
+@apps/api/src/prisma/prisma-tenant.extension.ts
+@apps/api/src/prisma/rls-access-inventory.spec.ts
+@apps/api/scripts/rls-scratch-check.mjs
+@apps/api/src/favorites/favorites.service.ts
+@apps/api/src/favorites/favorites.controller.ts
+@apps/api/src/favorites/favorites.module.ts
+@apps/api/src/favorites/icon-discovery.service.ts
+@apps/api/src/favorites/icon-discovery.service.spec.ts
+@apps/api/src/favorites/dto/create-favorite.dto.ts
+@apps/api/src/favorites/dto/update-favorite.dto.ts
+@apps/api/src/settings/settings.service.ts
+@apps/api/src/settings/settings.controller.ts
+@apps/api/src/settings/settings.module.ts
+@apps/api/src/settings/dto/smtp-config.dto.ts
+@apps/api/src/mail/mail.module.ts
+@apps/api/src/mail/mail.service.ts
+@apps/api/src/tenders/tender-mail.service.ts
+@apps/api/src/tenders/tender-mail.service.spec.ts
+@apps/api/src/dkv/dkv-mail.service.ts
+@apps/api/src/dkv/dkv.service.ts
+@apps/api/src/dkv/dkv-scheduler.service.ts
+@apps/api/src/dkv/dkv.service.spec.ts
+@apps/api/src/calendar/calendar.service.spec.ts
+@apps/api/src/dashboard/dashboard.service.spec.ts
+@apps/api/src/dashboard/dashboard.controller.ts
+@apps/api/src/tenant/tenant.guard.ts
+@apps/web/src/lib/favorites-api.ts
+@apps/web/src/components/dashboard/widgets/favorites-widget.tsx
+@apps/web/src/lib/settings-api.ts
+@apps/web/src/components/settings/smtp-settings-form.tsx
+@apps/web/src/messages/de.json
+@.planning/WINDOWS.md
+@.planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-PLAN.md
+@.planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-SUMMARY.md
+
+
+
+
+Alle Zahlen unten sind zur Planungszeit am 2026-09-11 gegen HEAD `46f0e78`
+GEMESSEN, mit der jeweils angegebenen Anweisung. Sie leiten die Untersuchung,
+sie sind KEINE Bearbeitungsvollmacht — jede Datei wird vor jeder Aenderung
+erneut gelesen, jede Zahl zur Ausfuehrungszeit erneut gemessen, und weicht
+eine Messung ab, gilt die Messung und nicht dieser Plan. Baseline laut
+`260911-fh9-SUMMARY.md`: 951 Tests gruen in 60 Dateien, Typpruefung sauber,
+Werkzeug 120/120 — zur Ausfuehrungszeit VOR der ersten Aenderung selbst
+nachmessen.
+
+**Befund A — die Zahlen halten: 7/0 und 4/0, beide Bereiche ohne einen
+einzigen gebundenen Zugriff.**
+`grep -rno "this\.prisma\.[a-zA-Z]*" apps/api/src/favorites apps/api/src/settings | grep -v spec`:
+sieben Treffer in `favorites.service.ts` (Zeilen 34 `list`/findMany, 55
+`create`/create, 74 `update`/findUnique, 103 `update`/update, 114
+`remove`/findUnique, 120 `remove`/delete, 138 `getIconBytes`/findUnique) und
+vier in `settings.service.ts` (39 `getSmtpConfig`/findUnique, 74
+`saveSmtpConfig`/upsert, 97 `getDecryptedSmtpConfig`/findUnique, 193
+`getStartupSmtpConfig`/findFirst). Gebunden: null. Nach diesem Plan:
+`favorites` 0 ungebunden / 7 gebunden (8, wenn der Besitzriegel aus Befund F
+einen `widgetInstance`-Lesezugriff hinzufuegt); `settings` 1 ungebunden (der
+Startpfad) / 3 gebunden. Summenzeile 78 -> 67 und 167 -> 177 (bzw. 178).
+Bestandsaufnahme: `favorites.service.ts`/`favoriteLink` von `ungebunden` auf
+`gebunden`; `settings.service.ts`/`smtpConfig` von `ungebunden` auf
+`gemischt` (dieselbe Form wie `dkv.service.ts`/`dkvModuleConfig`: die
+Mischung stammt ausschliesslich vom benannten Startpfad); NEUE Zeile
+`favorites.service.ts`/`widgetInstance` (`muss-mandantengebunden`,
+`gebunden`), wenn Befund F den Riegel bestaetigt -> 65 Paare, 33/17/13/2.
+Alle Zahlen zur Ausfuehrungszeit aus den Messanweisungen des Dokuments
+ableiten, nicht von hier abschreiben.
+
+**Befund B — die Regeln sind eindeutig und wortgleich extrahierbar.**
+`20260909140000_rls_remaining_tenant_tables` traegt fuer `FavoriteLink`
+(Zeile 88-92) und `SmtpConfig` (102-106) je ENABLE + FORCE + `CREATE POLICY
+tenant_isolation_policy ... USING ("tenantId" = current_tenant_id())`;
+`grep -c "FavoriteLink\|SmtpConfig" 20260910120000_.../migration.sql`: 0 —
+die Widen-Migration hat keine eigene Regel fuer beide (Regelstand eindeutig,
+Form von `calendarsource-regelstand-eindeutig`). Spalten: `FavoriteLink`
+(20260708090000) hat 10 skalare Spalten (`id`, `userId`, `tenantId`,
+`widgetId`, `title`, `url`, `iconUrl`, `position`, `createdAt`, `updatedAt`)
+und den Fremdschluessel `FavoriteLink_widgetId_fkey` auf `"WidgetInstance"("id")
+ON DELETE CASCADE`; `model FavoriteLink` hat zusaetzlich das RELATIONSFELD
+`widgetInstance` — die Wegwerf-Tabelle muss ueber
+`readSchemaModelScalarFieldNames('FavoriteLink')` (der Helfer aus 260911-fh9)
+verglichen werden, nicht ueber `readSchemaModelFieldNames`. `SmtpConfig`
+(20260629130000) hat 10 skalare Spalten (`id`, `tenantId`, `host`, `port`,
+`encryption`, `username`, `encryptedPassword`, `fromAddress`, `createdAt`,
+`updatedAt`) und den Eindeutigkeitsindex `SmtpConfig_tenantId_key` (Zeile
+169) — ohne diesen Index misst Pruefung 8 des settings-Abschnitts nichts.
+`updatedAt` hat in beiden Migrationen keine Vorgabe; die Wegwerf-Tabellen
+bekommen fuer die Roh-SQL-Einfuegungen `DEFAULT CURRENT_TIMESTAMP` mit
+Vermerk als Abweichung, die nur das Nachruesten betrifft (Form von
+260911-e2s/fh9).
+
+**Befund C — die Wegwerf-Tabelle `WidgetInstance` existiert bereits, ohne
+`createdAt`/`updatedAt`, mit drei Zeilen.** `runDashboardAreaChecks`
+(`rls-scratch-check.mjs:2461-2467`) legt `"WidgetInstance"` mit `id`,
+`userId`, `tenantId`, `widgetType` an und fuegt `widget-a1` (user-a1,
+TENANT-A), `widget-a2` (user-a2, TENANT-A), `widget-b1` (user-b1, TENANT-B)
+ein; der gebundene Loeschversuch unter TENANT-A auf `widget-b1` (Zeile 2687)
+trifft null Zeilen, `widget-rejected` wird abgewiesen — alle drei Zeilen
+sollten stehen, wenn der neue Abschnitt laeuft: zur Ausfuehrungszeit ueber
+die Wartungsrolle NACHMESSEN, nicht annehmen. Der Fremdschluessel der
+Wegwerf-Tabelle `FavoriteLink` zeigt auf DIESE Tabelle (WINDOWS #27:
+Relationen sind fuer die Bestandsaufnahme unsichtbar — hier wird die
+Relation deshalb ausdruecklich mitgebaut und gemessen). Der neue Abschnitt
+ist ein Blatt: NACH `runAuthAreaChecks`, VOR `runTransactionShapeMeasurement`;
+keine spaetere Pruefung setzt auf seinen Tabellen auf.
+
+**Befund D — der Startpfad, Glied fuer Glied gelesen.**
+`settings.service.ts:193` `findFirst()` ohne jede Bedingung; einziger
+Aufrufer `mail.module.ts:30` in `MailerModule.forRootAsync({ useFactory:
+async ... })` — BEIM START, vor jedem Anfragekontext, Kommentar dort:
+"findFirst — single-tenant default". Ergebnis wird zum EINEN Transport des
+`MailerService`, den `MailService.sendPasswordResetEmail` (einziger Aufrufer
+ausserhalb des Mailmoduls: `auth.service.ts:246`, `requestPasswordReset`)
+und `sendWelcomeEmail` benutzen. HEUTE bei mehreren Mandanten: der
+SMTP-Server und die Absenderadresse eines BELIEBIGEN Mandanten bedienen die
+Kennwort-Zuruecksetzungs-Mails ALLER Mandanten — Mandant B versendet ueber
+den Server von Mandant A, mit dessen Absender (das ist Nutzung fremder
+Zugangsdaten, nicht nur Sichtbarkeit; T-GWH-03). NACH DEM SCHARFSCHALTEN:
+`findFirst` liefert `null` -> `mail.module.ts` faellt auf Prioritaet 2
+(`MAIL_*`), 3 (`TESSERA_SMTP_*`), 4 (`localhost:1025`, Mailhog) zurueck ->
+`MailService` faengt jeden Transportfehler (`mail.service.ts:69-81`, T-02-12)
+und der Controller antwortet 200. Das Verstummen ist damit DOPPELT verdeckt:
+erst durch die Rueckfallkette (ein falscher, aber vorhandener Transport statt
+Leere), dann durch das absichtliche Verschlucken im Versand. Das ist die
+UNSYMMETRIE zu den beiden Praezedenzfaellen: `getAllActiveConfigs` (ldap)
+ist heute korrekt und verstummt spaeter; `loadAnyActiveConfigForScheduler`
+(dkv, #21) ist heute falsch und verstummt spaeter mit Protokollzeile; der
+Mail-Startpfad ist heute falsch und verstummt spaeter OHNE Protokollzeile,
+weil eine Rueckfallkette ihn ueberdeckt. ENTSCHEIDUNG (zur Ausfuehrungszeit
+nach der Lesung zu bestaetigen oder mit Grund zu verwerfen): EIGENER
+Ledger-Eintrag, nicht Anschluss an #21 — #21 ist gegen
+`dkv-scheduler.service.ts` gefuehrt, seine Reparatur ist die
+Mehrmandanten-Planung (Auftraege je Mandant); die Reparatur HIER ist eine
+andere Funktion mit bereits vorhandener Vorlage im Code: Transport je Versand
+aus `getDecryptedSmtpConfig(tenantId)` bauen, wie `DkvMailService` und
+`TenderMailService` es seit Phase 07/12 tun — `requestPasswordReset` kennt
+den Mandanten aus der Funktionszeile, `MailService` muesste ihn nur
+entgegennehmen. Das ist ein Feature (Umbau des Mailmoduls), NICHT dieser
+Auftrag — steht so im Auftrag und in ``. Umbenennung nach
+dem dkv-Praezedenzfall: `loadAnySmtpConfigForStartupTransport()`; kein Dokument
+unter `docs/` nennt den alten Namen (`grep -rn getStartupSmtpConfig docs`:
+null), die Phasen-Artefakte unter `.planning/phases` sind Geschichte und
+werden nicht angefasst.
+
+**Befund E — der Versandpfad und Befund K, Glied fuer Glied.**
+`tender-mail.service.ts:158` (`resolveTransport`, privat; bei `null`:
+`logger.warn(... skipping tender mail send (will retry next run))`, kein
+Wurf) und `dkv-mail.service.ts:47` (`sendExportEmail`; bei `null`: `throw new
+Error('No SMTP configuration found for tenant ...')`) rufen
+`getDecryptedSmtpConfig(tenantId)`; der Mandant kommt bei beiden aus der
+gebundenen Schleife des aufrufenden Bereichs (260909-laa, 260909-mir).
+`testSmtpConfig` ruft sie ebenfalls (Rueckgriff auf gespeicherte
+Zugangsdaten) und hat KEINEN eigenen Datenbankzugriff. Die
+Reihenfolgebedingung steht an zwei Stellen der Kritikschrift ((t4) Befund K,
+Zeile 625-634; (d4) Uebergaben-Absatz, Zeile 935-944) und NIRGENDS im
+Klassifikationsdokument (`grep -n "settings" docs/mandantentrennung-zugriffsklassifikation.md`:
+nur Uebersichtszeile 147 und Bestandsaufnahme 453). Nach diesem Plan: an
+beiden Stellen ein NACHTRAG (Form von (e) Befund D, Zeile 143-155: Original
+bleibt stehen, Nachtrag darunter), und der neue Hintergrunddienst-Absatz im
+Klassifikationsdokument sagt, dass die Bedingung mit 260911-gwh erfuellt ist.
+
+**Befund F — die Besitzpruefungen: dreimal richtig, einmal fehlend, und der
+Fremdschluessel umgeht den Zeilenschutz.** `update`, `remove`, `getIconBytes`
+tun `findUnique({ where: { id } })`, vergleichen `link.userId !== userId`, und
+schreiben/loeschen dann ueber die Kennung — nach der Bindung auf DEMSELBEN
+Klienten ist das die richtige Form (fremde Zeile: `findUnique` null ->
+`NotFoundException`; dkv Befund G — ein gebundenes UPDATE/DELETE ueber die
+Kennung allein traefe eine fremde Zeile still — greift nicht, weil die
+Vorpruefung davor steht; mit dem generierten Client wirft `delete` bei null
+Zeilen zudem P2025, Pruefung 6 misst das). `create` dagegen prueft NICHT, ob
+`dto.widgetId` dem Aufrufer gehoert: es schreibt `userId`/`tenantId` des
+Aufrufers und `widgetId` aus dem Rumpf. PostgreSQL prueft Fremdschluessel AN
+DER ZEILENSCHUTZ-REGEL VORBEI (dokumentiertes Verhalten: referentielle
+Integritaet umgeht Row Security) — ein gebundenes `create` unter TENANT-A mit
+`widgetId` eines TENANT-B-Widgets wuerde demnach GELINGEN, und der
+Unterschied zwischen "Widget existiert nicht" (FK-Verletzung, 500) und
+"existiert bei einem fremden Mandanten" (gelingt) ist ein Existenzorakel
+ueber Mandantengrenzen (T-GWH-05, medium). Pruefung 7 MISST das, statt es
+anzunehmen; faellt sie so aus, baut Aufgabe 2 einen Besitzriegel in `create`
+ein: gebundener `widgetInstance.findUnique({ where: { id: dto.widgetId },
+select: { userId: true } })` auf DEMSELBEN `tenantPrisma`, `null` oder
+fremde Benutzerkennung -> `NotFoundException('Widget not found')` (ohne
+Aussage ueber fremde Mandanten). Das fuegt ein NEUES (Datei, Modell)-Paar
+hinzu (Befund A). Faellt Pruefung 7 anders aus, gilt die Messung, der Riegel
+entfaellt, und das steht mit Belegzeile im SUMMARY.
+
+**Befund G — der Icon-Proxy erreicht die Datenbank NICHT und nimmt KEINE
+Client-URL.** `grep -n "prisma\|Prisma" icon-discovery.service.ts`: null
+Treffer; `getIconBytes` holt nur die GESPEICHERTE `iconUrl` einer Zeile, die
+der Aufrufer besitzt (T-QFIP-01, Controller `GET /favorites/:id/icon` nimmt
+nur die Kennung); `discoverFavoriteIconUrl(url)` bei `create`/`update` nimmt
+die Nutzer-URL, aber nur fuer den SSRF-gesicherten Abruf (privater
+IP-Bereich, gesperrte Hostnamen, manuelle Weiterleitung — T-08-05,
+`icon-discovery.service.spec.ts` deckt das). Mandantenbezug: keiner — die
+URL ist nicht mandantenskopiert, der Dienst haelt keinen Zustand je Mandant.
+Ergebnis: der Proxy bleibt UNVERAENDERT; in der Testdatei ist er eine
+Attrappe (`discoverFavoriteIconUrl`, `fetchIconBytes` als `vi.fn`), und der
+Test nagelt fest, dass `fetchIconBytes` mit der GESPEICHERTEN URL aufgerufen
+wird, nie mit einer aus dem Aufruf.
+
+**Befund H — die Mandantenquelle je Bereich, beide unterschiedlich und beide
+richtig.** `favorites.controller.ts` `extractContext`: `req.tenantId ??
+req.user?.tenantId` — WORTGLEICH mit `dashboard.controller.ts:44-45`, unter
+dessen Bindung `WidgetInstance` liegt. `FavoriteLink` haengt am Widget;
+wuerde `favorites` an das Claim binden (wie `auth` fuer Selbstbedienung),
+`dashboard` aber an die Guard-Kennung, laegen Widget und Link unter einem
+`x-tenant-id`-Wechsel eines SUPER_ADMIN in verschiedenen Mandanten.
+`favorites-api.ts` sendet die Kopfzeile nicht (`grep -rn "x-tenant-id"
+apps/web/src`: nur die vier Marktplatz-Stellen) — die Entscheidung haengt an
+der Bauform, nicht am heutigen Aufrufer. ENTSCHEIDUNG: `extractContext`
+bleibt, alle fuenf Aufrufe reichen `tenantId` durch; Kopfkommentar nennt den
+dashboard-Praezedenzfall und warum NICHT der auth-Praezedenzfall.
+`settings.controller.ts` liest `req.tenantId` — fuer eine
+ADMIN-Konfigurationsseite ist das die richtige Quelle (D-10: SUPER_ADMIN
+konfiguriert ueber `x-tenant-id` einen anderen Mandanten); der Controller
+bleibt UNVERAENDERT und steht nicht in der Erlaubnisliste.
+
+**Befund I — die umgekehrte Fehlerrichtung je Bereich, gelesen.**
+(1) `favorites`: `list` liefert `[]` -> `GET /favorites?widgetId=` 200 `[]`
+-> `fetchFavorites` (`favorites-api.ts:22-29`) gibt `[]` -> `favorites-widget.tsx:212-213`
+zeigt `t('favorites.empty')` = `Noch keine Favoriten.` (`de.json`, Zeile um
+219). "Zeile unsichtbar" und "nie einen gespeichert" sind fuer das Frontend
+derselbe Wert — Familie #23/#25/#26/#28. `update`/`remove`/`getIconBytes`:
+`NotFoundException('FavoriteLink not found')` 404 -> `favorites-api.ts` wirft
+`Failed to update/delete favorite` -> `catch` im Widget setzt
+`t('favorites.error')`. (2) `settings`: `getSmtpConfig` liefert `null` ->
+Controller gibt `null` zurueck -> NestJS sendet 200 mit LEEREM Rumpf (die
+Adapter-Kette aus (h3), 260911-fh9) -> `fetchSmtp` (`settings-api.ts:51-57`)
+prueft nur `404` und `!res.ok`, dann `res.json()` auf den leeren Rumpf ->
+wirft -> `smtp-settings-form.tsx:76-78` `.catch(() => { /* Silent fail */ })`
+-> leeres Formular: "SMTP nicht eingerichtet", waehrend die Zugangsdaten
+physisch da sind. Traegt der Administrator sie neu ein, laeuft `saveSmtpConfig`
+als `upsert({ where: { tenantId } })`: unter der UNGEBUNDENEN Form (heute, nach
+dem Scharfschalten) ist die Zeile unsichtbar, der Upsert versucht INSERT und
+scheitert am Eindeutigkeitsindex `SmtpConfig_tenantId_key` — die
+`dashboard`-Lehre (260910-krx: `PrismaClientUnknownRequestError`, NICHT
+P2002). Pruefung 8 misst Konstruktorname und `code` woertlich, statt sie
+vorwegzunehmen. `getDecryptedSmtpConfig` null: tender `warn` + kein Versand
+mit Wiederholung je Lauf, dkv `throw` (Befund E). (3) Startpfad: Befund D.
+Etappe-4-Vorabpruefung je Bereich: fuer einen bekannten Mandanten die
+`SmtpConfig`-Zeile ueber die Wartungsrolle lesen und den gebundenen
+`findUnique` daneben halten; fuer einen bekannten Nutzer/Widget die
+Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen `findMany`.
+
+**Befund J — Testlage: eine Spec fuer den Icon-Proxy, keine fuer die beiden
+Dienste; Vorlagen stehen bereit.** `ls apps/api/src/favorites/`: nur
+`icon-discovery.service.spec.ts`; `ls apps/api/src/settings/`: keine Spec.
+Vorlagen: `calendar.service.spec.ts` (260911-cwh — Bereich ohne Testdatei,
+`makeFakePrisma` mit In-Memory-Zeilen, `__makeBoundClient`, `_applySelect`,
+`boundCallLog`), `dkv.service.spec.ts` (ungebundener Nachbau OHNE die
+Anfrage-Modelle), `dashboard.service.spec.ts:565` (Wachhund),
+`tender-mail.service.spec.ts:22-30` (`makeSettingsService`-Attrappe: zeigt,
+welche Rueckgabeform die Aufrufer erwarten — `host`, `port`, `encryption`,
+`username`, `fromAddress`, `decryptedPassword`). `nodemailer` wird in der
+neuen settings-Spec per `vi.mock('nodemailer')` ersetzt (`createTransport`
+liefert `{ verify: vi.fn(), sendMail: vi.fn() }`) — KEIN echter Transport
+(Umgebung: kein `mailhog`). `CryptoService` als Attrappe (`encrypt: (s) =>
+'enc(' + s + ')'`, `decrypt` strippt).
+
+**Befund K — kein weiterer Hintergrunddienst, keine Transaktion, keine
+Relationseinbindung — bis auf den Startpfad.** `grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout\|\$transaction(\|\$queryRaw\|\$executeRaw\|include:\|_count" apps/api/src/favorites apps/api/src/settings --include=*.ts | grep -v spec`:
+genau EIN Treffer, `icon-discovery.service.ts:255` — ein `setTimeout` fuer
+den Abbruch eines HTTP-Abrufs, kein Planer (dieselbe Form wie
+`ics.provider.ts:100` in 260911-cwh). `select:` kommt zweimal vor
+(`settings.service.ts:41`, `:78`), beide ausschliesslich skalare Felder von
+`SmtpConfig`, keine Relation (WINDOWS #27 ohne Auspraegung). Der einzige
+Hintergrund-Zugriff ist der Startpfad aus Befund D — er lebt NICHT in
+`settings`, sondern wird von `mail.module.ts` beim Start aufgerufen; als
+sechster Fall gehoert er unter `mail.module.ts` /
+`SettingsService.loadAnySmtpConfigForStartupTransport()`.
+
+**Befund L — die Buchfuehrung und ein veralteter Absatz in der Anleitung.**
+Uebersichtszeilen `favorites` (heute `| favorites | 7 | 0 | unverändert |`)
+und `settings` (`| settings | 4 | 0 | unverändert |`), Summenzeile (78/167),
+Bestandsaufnahme-Zeilen 431 und 453, Klassen-Verteilung (64 Paare,
+32/17/13/2), Hintergrunddienst-Abschnitt (Ueberschrift `## Der
+Hintergrunddienst als Falle — fünf Fälle`, Zeile 237; kein Gate ausserhalb
+des Dokuments referenziert diesen Wortlaut — `grep -rn "fünf Fälle\|fuenf
+Faelle" apps/api/src apps/api/scripts`: null), `Was diese Etappe NICHT
+entscheidet` (Zeile 486). Dazu `docs/anleitung-entwicklung.md:310-316`: der
+Absatz behauptet, Regeln laegen nur auf sieben Tabellen und `FavoriteLink`
+trage KEINE, und `DkvService.loadConfig()` nutze den ungebundenen Klienten —
+beides seit `20260909140000` (16 weitere Tabellen, gemessen: `grep -c
+"ENABLE ROW LEVEL SECURITY"` liefert 4 + 3 + 16 = 23) bzw. seit 260909-mir
+falsch. Der Absatz nennt `FavoriteLink` beim Namen und liegt damit in diesem
+Bereich; er wird in Aufgabe 3 auf den gemessenen Stand gebracht (scoped
+`Edit`, nur dieser Absatz).
+
+
+
+
+
+
+ Aufgabe 1: Die Fehlerrichtung fuer BEIDE Bereiche MESSEN und aufschreiben — Favoritenleiste, SMTP-Zugangsdaten, Startpfad — ueber den generierten Client
+ Der lokale Datenbank-Container `tessera-ctl-db-1` laeuft; `docker inspect tessera-ctl-db-1` liefert eine Adresse. Ohne ihn kann das Wegwerf-Werkzeug nichts messen und die Aufgabe ist zu stoppen, nicht zu schaetzen.
+ apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md
+ apps/api/src/prisma/prisma-tenant.extension.ts (Kopf VOLLSTAENDIG), apps/api/scripts/rls-scratch-check.mjs (Kopf, `report`, `forTenantQuery`, `withAdminPrisma`, `sqlStateOf`, `readRlsWidenMigrationSql`, `readRemainingTenantTablesMigrationSql`, `extractPolicySql`, `readSchemaModelScalarFieldNames`, `runDashboardAreaChecks` VOLLSTAENDIG (Anlage von "WidgetInstance", welche Zeilen am Ende stehen), `runDkvAreaChecks` (die Pruefung `dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile` als Vorbild fuer den Startpfad), `runCalendarAreaChecks` VOLLSTAENDIG (Blatt-Form, Spaltenpruefung, Client-Pruefungen), `runAuthAreaChecks` (die Client-Pruefungen mit Konstruktorname/`code`), `buildInlineExtendedClient`, `main`), apps/api/prisma/schema.prisma (model FavoriteLink, model SmtpConfig, model WidgetInstance), apps/api/prisma/migrations/20260708090000_add_favorite_link/migration.sql (VOLLSTAENDIG), apps/api/prisma/migrations/20260629130000_add_missing_tables/migration.sql (CREATE TABLE "SmtpConfig" und den Index `SmtpConfig_tenantId_key`), apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql (die Bloecke FavoriteLink, SmtpConfig, WidgetInstance), apps/api/src/favorites/favorites.service.ts (VOLLSTAENDIG), apps/api/src/favorites/favorites.controller.ts (VOLLSTAENDIG), apps/api/src/favorites/icon-discovery.service.ts (Kopf, `discoverFavoriteIconUrl`, `fetchIconBytes`), apps/api/src/settings/settings.service.ts (VOLLSTAENDIG), apps/api/src/settings/settings.controller.ts (VOLLSTAENDIG), apps/api/src/mail/mail.module.ts (VOLLSTAENDIG), apps/api/src/mail/mail.service.ts (`sendPasswordResetEmail`, den `try/catch`), apps/api/src/tenders/tender-mail.service.ts (`resolveTransport`), apps/api/src/dkv/dkv-mail.service.ts (`sendExportEmail`), apps/api/src/dkv/dkv.service.ts (Kopfkommentar von `loadAnyActiveConfigForScheduler` VOLLSTAENDIG), apps/api/src/dkv/dkv-scheduler.service.ts (Kopf), apps/api/src/dashboard/dashboard.controller.ts (`extractContext`), apps/web/src/lib/favorites-api.ts, apps/web/src/components/dashboard/widgets/favorites-widget.tsx (den `useEffect` um Zeile 63 und die Leerstelle um Zeile 212), apps/web/src/lib/settings-api.ts (`fetchSmtp`), apps/web/src/components/settings/smtp-settings-form.tsx (den `useEffect` um Zeile 62), apps/web/src/messages/de.json (Schluessel `favorites.empty`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitte `## Bereich dkv` (d1)-(d5) und `## Bereich auth` (h1)-(h5) vollstaendig, als Form; (t4) Befund K; (e) Befund D samt Nachtrag als Form fuer Nachtraege), .planning/WINDOWS.md (Eintrag #21 VOLLSTAENDIG)
+
+TEIL 1 — die Messung, zwei Abschnitte. Erweitere `apps/api/scripts/rls-scratch-check.mjs`
+um `runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results)` und
+`runSettingsAreaChecks(adminUrl, scratchRoleUrl, results)` und rufe beide in
+`main()` in dieser Reihenfolge NACH `runAuthAreaChecks` und VOR
+`runTransactionShapeMeasurement` auf. Beide sind Blaetter (keine spaetere
+Pruefung setzt auf ihren Tabellen auf); halte die Reihenfolgebedingung im
+Kopfkommentar fest, wie `runCalendarAreaChecks` es vormacht. Beide beginnen
+mit der Regelstand-Pruefung (die Widen-Migration darf keine eigene Regel fuer
+die Tabelle tragen — Form `calendarsource-regelstand-eindeutig`), schneiden
+die Regel WORTGLEICH aus `20260909140000_rls_remaining_tenant_tables`
+(`extractPolicySql`), legen die Wegwerf-Tabelle mit SAEMTLICHEN skalaren
+Spalten an, pruefen die Spaltenmenge zur Laufzeit gegen
+`readSchemaModelScalarFieldNames()` (bei `FavoriteLink` ist das
+Relationsfeld `widgetInstance` auszufiltern — deshalb der Helfer aus
+260911-fh9, nicht `readSchemaModelFieldNames`) und brechen ab, wenn die
+Spaltenpruefung durchfaellt (Form von Pruefung 8 in `runCalendarAreaChecks`).
+`updatedAt` bekommt `DEFAULT CURRENT_TIMESTAMP` als vermerkte Abweichung
+(Prisma setzt den Wert clientseitig). Alle Pruefungen laufen unter der Rolle
+ohne `BYPASSRLS`; Vergleichswerte kommen ueber die Wartungsrolle. Jede
+Pruefung hat eine Belegausgabe mit den beobachteten Werten; wo ein Fehler
+erwartet wird, Konstruktorname und `code` woertlich — rate das Ergebnis nicht
+vorweg.
+
+`runFavoritesAreaChecks` — die Wegwerf-Tabelle `"FavoriteLink"` bekommt den
+Fremdschluessel `FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id")
+ON DELETE CASCADE` auf die von `runDashboardAreaChecks` angelegte Tabelle
+(Befund C — vorher ueber die Wartungsrolle MESSEN, welche `WidgetInstance`-
+Zeilen stehen, und die Belegausgabe der ersten Pruefung nennt sie).
+Zeilen: `fav-a1-1`, `fav-a1-2` (user-a1, TENANT-A, widget-a1), `fav-a2-1`
+(user-a2, TENANT-A, widget-a2), `fav-b1-1` (user-b1, TENANT-B, widget-b1);
+`fav-a1-1` mit `iconUrl`, `fav-a1-2` ohne. Mindestens sieben Pruefungen:
+
+1. `favoritelink-regelstand-eindeutig`.
+2. `favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`
+ — zehn skalare Felder; die Belegausgabe nennt zusaetzlich die gemessenen
+ `WidgetInstance`-Zeilen.
+3. `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste` —
+ die tragende Belegzeile: `prisma.favoriteLink.findMany({ where: { userId:
+ 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, {
+ title: 'asc' }] })` — die Form von `list` — UNGEBUNDEN: `[]`, waehrend die
+ Wartungsrolle zwei Zeilen sieht. Die Belegausgabe sagt beim Namen: das ist
+ der Wert, aus dem `favorites-widget.tsx` `Noch keine Favoriten.` macht.
+4. `favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`
+ — dieselbe Abfrage ueber `buildInlineExtendedClient(prisma, 'TENANT-A')`:
+ genau zwei Zeilen, beide `userId === 'user-a1'`, `widgetId === 'widget-a1'`;
+ die Zeile von `user-a2` ist NICHT dabei (die anwendungsseitige
+ Benutzerfilterung), aber — zweite Aussage derselben Messung, eigene
+ Belegausgabe — ein gebundener `findMany({ where: { widgetId: 'widget-a2' } })`
+ unter TENANT-A liefert die Zeile des Kollegen: die Regel kennt keine
+ Benutzerdimension (dieselbe Lehre wie `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`).
+5. `favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null`
+ — `findUnique({ where: { id: 'fav-a1-1' } })` gebunden unter TENANT-B:
+ `null`. Das ist die Datenbankseite der Vorpruefung in `update`/`remove`/
+ `getIconBytes` (T-GWH-02).
+6. `favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut`
+ — `delete({ where: { id: 'fav-a1-1' } })` ueber den GENERIERTEN Client
+ gebunden unter TENANT-B: bestanden genau dann, wenn ein Fehler geworfen
+ wird UND die Wartungsrolle die Zeile danach noch liest. Konstruktorname und
+ `code` woertlich (der generierte Client meldet null getroffene Zeilen bei
+ `delete` anders als Roh-SQL — dkv Befund G in der Client-Form).
+7. `favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei` — gebundenes
+ `create` unter TENANT-A mit `data: { id: 'fav-a1-fremdes-widget', userId:
+ 'user-a1', tenantId: 'TENANT-A', widgetId: 'widget-b1', title, url,
+ position: 0 }` (das Widget gehoert TENANT-B und ist unter TENANT-A
+ UNSICHTBAR — vorher mit gebundenem `widgetInstance.findUnique` messen und
+ ausgeben). Belegausgabe nennt, ob der INSERT gelang oder mit welchem
+ Konstruktor/`code` er scheiterte. Bestanden ist die Pruefung in BEIDEN
+ Faellen — sie ist eine Messung, deren Ergebnis Aufgabe 2 steuert (Befund
+ F): gelingt der INSERT, umgeht der Fremdschluessel den Zeilenschutz und
+ der Besitzriegel wird gebaut; die Zeile danach ueber die Wartungsrolle
+ wieder entfernen. Dazu: die zweite Haelfte derselben Pruefung — gebundenes
+ `create` unter TENANT-A mit `widgetId: 'widget-gibt-es-nicht'` — muss
+ scheitern (FK-Verletzung, Konstruktorname/`code` woertlich): der
+ Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05).
+8. `favoritelink-gebundenes-anlegen-eigener-mandant-gelingt` — gebundenes
+ `create` unter TENANT-A mit `widgetId: 'widget-a1'` gelingt, die
+ Wartungsrolle liest die Zeile mit `tenantId === 'TENANT-A'`, `createdAt`
+ und `updatedAt` gesetzt.
+
+`runSettingsAreaChecks` — die Wegwerf-Tabelle `"SmtpConfig"` bekommt den
+Eindeutigkeitsindex `CREATE UNIQUE INDEX "SmtpConfig_tenantId_key" ON
+"SmtpConfig"("tenantId")` wortgleich aus `20260629130000` (ohne ihn misst
+Pruefung 8 nichts). Zeilen: `smtp-a` (TENANT-A, host `smtp-a.example.invalid`,
+`encryptedPassword` `enc(a-passwort-platzhalter)`, `fromAddress`
+`a@example.invalid`), `smtp-b` (TENANT-B, entsprechend). Mindestens acht
+Pruefungen:
+
+1. `smtpconfig-regelstand-eindeutig`.
+2. `smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients` —
+ zehn skalare Felder; zusaetzlich `pg_indexes` liest den Index
+ `SmtpConfig_tenantId_key` (Belegausgabe nennt `indexdef`).
+3. `smtpconfig-startpfad-generierter-client-ungebunden-liefert-null` —
+ `prisma.smtpConfig.findFirst()` OHNE Bedingung, ungebunden: `null`,
+ obwohl zwei Zeilen existieren. Die Belegausgabe sagt beim Namen: das ist
+ der Wert, mit dem `mail.module.ts` nach dem Scharfschalten auf
+ Umgebungsvariablen und zuletzt `localhost:1025` zurueckfaellt — ein
+ falscher Transport statt einer Meldung.
+4. `smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile` — dieselbe
+ Abfrage ueber die Wartungsrolle (`BYPASSRLS`, die HEUTIGE Lage): genau
+ EINE Zeile, deren `tenantId` die Belegausgabe nennt, mit dem Satz, dass
+ nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird, und dass
+ dessen Server und Absender ab Start alle Kennwort-Zuruecksetzungs-Mails
+ ALLER Mandanten tragen (T-GWH-03).
+5. `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null` —
+ `findUnique({ where: { tenantId: 'TENANT-A' } })` ungebunden: `null`,
+ waehrend die Wartungsrolle die Zeile liest. Die Belegausgabe sagt: das ist
+ Befund K — `tender-mail.service.ts` protokolliert `No SMTP configuration`
+ und ueberspringt, `dkv-mail.service.ts` wirft; kein Versand fuer niemanden.
+6. `smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten`
+ — gebunden unter TENANT-A: Zeile mit `encryptedPassword ===
+ 'enc(a-passwort-platzhalter)'`, `host`, `fromAddress` wie eingefuegt.
+7. `smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null`
+ — `findUnique({ where: { tenantId: 'TENANT-A' } })` gebunden unter
+ TENANT-B: `null` — die verschluesselten Zugangsdaten von A sind fuer B
+ unsichtbar (T-GWH-01).
+8. `smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut` —
+ die Form von `saveSmtpConfig`: `upsert({ where: { tenantId: 'TENANT-A' },
+ create: { id: 'smtp-a-neu', tenantId: 'TENANT-A', host:
+ 'smtp-a-neu.example.invalid', fromAddress: 'a@example.invalid' }, update:
+ { host: 'smtp-a-neu.example.invalid' } })` UNGEBUNDEN: bestanden genau
+ dann, wenn ein Fehler geworfen wird UND die Wartungsrolle danach weiterhin
+ `smtp-a.example.invalid` liest. Konstruktorname und `code` woertlich; die
+ Belegausgabe haelt daneben, was 260910-krx fuer `DashboardLayout` gemessen
+ hat (`PrismaClientUnknownRequestError`), und sagt, ob es hier gleich oder
+ anders ausfaellt. Das ist die Kette "leeres Formular -> Administrator
+ traegt neu ein -> Speichern scheitert".
+9. `smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert` — dasselbe
+ `upsert` gebunden unter TENANT-A: gelingt, die Wartungsrolle liest den
+ neuen Host fuer `smtp-a`, `smtp-b` unveraendert, `updatedAt` gesetzt.
+
+Ergaenzt wird nur, gestrichen wird nicht; nenne im SUMMARY die tatsaechlich
+gezaehlte Zahl je Abschnitt.
+
+TEIL 2 — die Codeaussagen, jede mit ihrer Reichweite. Fuehre die
+Nachpruefungen aus den Befunden A, D, E, F, G, H, I, K und L tatsaechlich aus
+und notiere jeweils Anweisung oder Datei-und-Zeile: (A) die elf Rohtreffer;
+(D) die Aufrufer des Startpfads in `apps/api/src` (genau einer), die
+Rueckfallkette in `mail.module.ts` mit ihren vier Prioritaeten, der
+`try/catch` in `mail.service.ts`, die Aufrufer von `MailService` ausserhalb
+des Mailmoduls; (E) die zwei Aufrufer von `getDecryptedSmtpConfig` ausserhalb
+von `settings` und ihr Verhalten bei `null`, plus `testSmtpConfig`; (F) die
+drei Besitzpruefungen und der fehlende Widget-Riegel in `create`; (G) der
+Icon-Proxy ohne Datenbankzugriff, `getIconBytes` nimmt nur die Kennung; (H)
+`extractContext` in beiden Controllern nebeneinander, die `x-tenant-id`-
+Sender im Frontend; (I) die beiden Frontend-Ketten Glied fuer Glied; (K) die
+eine Anweisung; (L) die Zeilen der Anleitung. Faellt eine Nachpruefung ANDERS
+aus als in den Planungsbefunden, gilt die Messung — schreibe sie auf und
+benenne die Abweichung ausdruecklich.
+
+TEIL 3 — die Kritikschrift. Erweitere
+`docs/mandantentrennung-etappe2-fehlerrichtung.md` um ZWEI Abschnitte
+unmittelbar VOR `## Verweis`, in der Form der vorhandenen Bereichsabschnitte:
+`## Bereich favorites` mit (f1)-(f5) und danach `## Bereich settings` mit
+(s1)-(s5) (Buchstaben `f` und `s` sind frei — gemessen mit `grep -n "^### ("`).
+
+`## Bereich favorites`:
+- `### (f1) Die Messung` — die tatsaechlich beobachtete Werkzeugausgabe
+ woertlich eingerueckt, die tragende Belegzeile (Pruefung 3) benannt, der
+ Fremdschluessel als mitgebaute Relation (Befund C, WINDOWS #27), das
+ Ergebnis von Pruefung 7 mit dem Satz, was daraus fuer Aufgabe 2 folgt.
+- `### (f2) Signaltabelle je umzustellendem Pfad` — je Methode eine Zeile
+ (`list`, `create`, `update`, `remove`, `getIconBytes`): Verhalten nach dem
+ Scharfschalten ohne diesen Plan, konkretes Signal mit Statuscode und
+ woertlicher Meldung, ob das Frontend es durchlaesst (Datei und Zeile).
+- `### (f3) Welcher Code Leere als Abwesenheit deutet` — die Kette aus
+ Befund I (1) mit allen Gliedern, dem woertlichen Text `Noch keine
+ Favoriten.`, und dem Satz, dass "nie einen gespeichert" und "Zeile
+ unsichtbar" derselbe Wert sind (Familie #23/#25/#26/#28).
+- `### (f4) Was dieser Durchlauf bewusst nicht löst` — (a) die fehlende
+ Benutzerdimension der Regel (Kollege desselben Mandanten auf
+ Datenbankebene sichtbar; Etappe-3-Entscheidung (2)); (b) das Frontend
+ (nicht angefasst; Ledger-Eintrag in Aufgabe 3); (c) die Mandantenquelle —
+ warum der dashboard-Praezedenzfall und nicht der auth-Praezedenzfall
+ (Befund H); (d) die Etappe-4-Vorabpruefung aus Befund I.
+- `### (f5) Was dieser Durchlauf bewusst nicht anfasst` — der Icon-Proxy
+ (Befund G, mit der Messung, dass er die Datenbank nicht erreicht und keine
+ Client-URL nimmt), die DTOs, `dashboard.controller.ts` (nur gelesen), das
+ Frontend, Schema und Migrationen.
+
+`## Bereich settings`:
+- `### (s1) Die Messung` — Werkzeugausgabe woertlich, die tragenden
+ Belegzeilen (Pruefung 3 fuer den Startpfad, 5 fuer Befund K, 8 fuer den
+ Speicherkonflikt) benannt, der Eindeutigkeitsindex als Voraussetzung der
+ Konfliktmessung, das Ergebnis von Pruefung 8 neben der dashboard-Messung.
+- `### (s2) Signaltabelle je Pfad` — `getSmtpConfig`, `saveSmtpConfig`,
+ `getDecryptedSmtpConfig` (getrennt fuer den tenders- und den dkv-Aufrufer),
+ `testSmtpConfig`, und der Startpfad (mit seiner Rueckfallkette als eigener
+ Spalteninhalt): heute, nach dem Scharfschalten, Signal, Frontend.
+- `### (s3) Welcher Code Leere als Abwesenheit deutet` — die Kette aus
+ Befund I (2) mit allen Gliedern (`settings.controller.ts` gibt `null`,
+ Adapter sendet leeren Rumpf, `settings-api.ts` `res.json()` wirft,
+ `smtp-settings-form.tsx` verschluckt), der Satz, dass das Formular
+ "nicht eingerichtet" zeigt, waehrend die Zugangsdaten physisch da sind,
+ und dass ein erneutes Speichern unter der ungebundenen Form am Index
+ scheitert (Pruefung 8); die Versandpfade mit ihren zwei verschiedenen
+ Reaktionen auf `null` (warn/skip vs. throw).
+- `### (s4) Was dieser Durchlauf bewusst nicht löst` — (a) DER STARTPFAD in
+ einem eigenen Absatz, in der Form von (d4): beide Zustaende (heute:
+ beliebiger Mandant traegt alle Systemmails, T-GWH-03; nach dem
+ Scharfschalten: `null`, Rueckfallkette, `try/catch` — doppelt verdeckt),
+ die drei gepruefte Formen (binden: unmoeglich, kein Kontext beim Start;
+ Mehrmandanten-Versand: abgelehnt als Funktion, mit dem Satz, dass die
+ Vorlage — Transport je Versand aus `getDecryptedSmtpConfig(tenantId)` —
+ bereits in `DkvMailService`/`TenderMailService` steht und `MailService`
+ den Mandanten von `requestPasswordReset` bekommen koennte; Altlast mit
+ Markierung: GEWAEHLT), die UNSYMMETRIE zu `ldap` UND `dkv` (Befund D:
+ Rueckfallkette statt Leere, keine Protokollzeile), und die ENTSCHEIDUNG
+ eigener Ledger-Eintrag statt Anschluss an #21 mit Grund (andere Datei,
+ andere Reparatur, andere Verdeckungsform) — oder, falls die Lesung zur
+ Ausfuehrungszeit anders ausfaellt, die andere Entscheidung mit Grund;
+ (b) BEFUND K IST ERFUELLT — ein eigener Absatz, der sagt, dass die
+ Reihenfolgebedingung aus (t4) und (d4) mit Aufgabe 2 dieses Laufs erfuellt
+ ist, dass beide Stellen einen Nachtrag bekommen (Aufgabe 3), und dass die
+ Etappe-4-Vorabpruefung diese Bedingung NICHT mehr fuehren muss; (c) das
+ Frontend (Ledger-Eintrag in Aufgabe 3, Familie #28 — dieselbe
+ 200-leerer-Rumpf-Kette); (d) die Mandantenquelle `req.tenantId` im
+ Controller als RICHTIG fuer eine ADMIN-Seite (Befund H, D-10) — bewusst
+ nicht auf das Claim umgestellt, mit dem Unterschied zu `auth`; (e) die
+ Etappe-4-Vorabpruefung.
+- `### (s5) Was dieser Durchlauf bewusst nicht anfasst` —
+ `settings.controller.ts`, das DTO, `tender-mail.service.ts`,
+ `dkv-mail.service.ts`, `mail.service.ts` (nur gelesen; `mail.module.ts`
+ wird in Aufgabe 2 fuer die Umbenennung angefasst — hier angekuendigt),
+ das Frontend, Schema und Migrationen, `nodemailer`.
+
+Aendere in dieser Aufgabe KEINE Datei unter `apps/api/src`, KEINE unter
+`apps/api/prisma` und KEINE unter `apps/web`.
+
+
+ DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" && for K in favoritelink-regelstand-eindeutig favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei favoritelink-gebundenes-anlegen-eigener-mandant-gelingt smtpconfig-regelstand-eindeutig smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients smtpconfig-startpfad-generierter-client-ungebunden-liefert-null smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert; do echo "$OUT" | grep -q "^$K: bestanden" || { echo "FEHLENDE ODER FEHLGESCHLAGENE PRUEFUNG: $K"; exit 1; }; done && N=$(echo "$OUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden\.$/\1/p') && { test -n "$N" && test "$N" -ge 137 || { echo "PRUEFUNGSZAHL: ${N:-nicht alle bestanden}, erwartet mindestens 137 (120 bisherige plus mindestens 8 + 9 neue)"; exit 1; }; } && S=apps/api/scripts/rls-scratch-check.mjs && grep -q 'async function runFavoritesAreaChecks' "$S" && grep -q 'async function runSettingsAreaChecks' "$S" && grep -q "readSchemaModelScalarFieldNames('FavoriteLink')" "$S" && grep -q "readSchemaModelScalarFieldNames('SmtpConfig')" "$S" && grep -q 'REFERENCES "WidgetInstance"' "$S" && grep -q 'SmtpConfig_tenantId_key' "$S" && grep -q "extractPolicySql(remainingMigrationSql, 'FavoriteLink')\|extractPolicySql(.*'FavoriteLink')" "$S" && grep -q "extractPolicySql(.*'SmtpConfig')" "$S" && awk '/await runAuthAreaChecks\(/{a=NR} /await runFavoritesAreaChecks\(/{f=NR} /await runSettingsAreaChecks\(/{s=NR} /await runTransactionShapeMeasurement\(/{t=NR} END{ if(!(a&&f&&s&&t&&a&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -ge 951 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mindestens 951"; exit 1; }; } && npm --prefix apps/api run type-check && git rev-parse --verify 46f0e78 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 46f0e78 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 46f0e78) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }
+
+ `apps/api/scripts/rls-scratch-check.mjs` hat die Abschnitte `runFavoritesAreaChecks` und `runSettingsAreaChecks` in der richtigen Reihenfolge, mit mindestens acht bzw. neun namentlich benannten Pruefungen ueber den generierten Client an Wegwerf-Tabellen, deren Spaltenmenge zur Laufzeit gegen die skalaren Felder des Schemas geprueft wird; die `FavoriteLink`-Tabelle traegt den Fremdschluessel auf `WidgetInstance`, die `SmtpConfig`-Tabelle den Eindeutigkeitsindex; der Startpfad ist als `findFirst()` ohne Bedingung in BEIDEN Zustaenden gemessen (Wartungsrolle: beliebige Zeile; Wegwerf-Rolle: `null`), der Versandpfad als Befund K, der Speicherkonflikt mit Konstruktorname und `code` woertlich, der Fremdschluessel-Durchgriff mit Ergebnis; alle Pruefungen bestehen (mindestens 137). `docs/mandantentrennung-etappe2-fehlerrichtung.md` hat `## Bereich favorites` ((f1)-(f5)) und `## Bereich settings` ((s1)-(s5)) unmittelbar vor `## Verweis`, mit woertlicher Werkzeugausgabe, Signaltabellen, beiden Frontend-Ketten, dem Startpfad-Absatz mit beiden Zustaenden, Unsymmetrie und Ledger-Entscheidung, und dem Absatz, dass Befund K erfuellt ist. Baseline gehalten (mindestens 951 Tests, Typpruefung sauber). Unter `apps/api/src`, `apps/api/prisma`, `apps/web` und den Compose-/Umgebungsdateien ist nichts geaendert.
+
+
+
+ Aufgabe 2: Beide Dienste binden (ein Klient je Methode), den Startpfad umbenennen und markieren, den Widget-Besitzriegel einbauen, zwei neue Testdateien mit dem Zwei-Klienten-Nachbau
+ 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/settings/settings.service.ts, apps/api/src/settings/settings.service.spec.ts, apps/api/src/mail/mail.module.ts
+ apps/api/src/favorites/favorites.service.ts (VOLLSTAENDIG, erneut), apps/api/src/favorites/favorites.controller.ts (VOLLSTAENDIG, erneut), apps/api/src/settings/settings.service.ts (VOLLSTAENDIG, erneut), apps/api/src/mail/mail.module.ts (VOLLSTAENDIG, erneut), apps/api/src/calendar/calendar.service.spec.ts (VOLLSTAENDIG — die Vorlage fuer einen Bereich ohne Testdatei), apps/api/src/dkv/dkv.service.spec.ts (`makeFakePrisma`, `__makeBoundClient`, der Kopfkommentar zur Falsifizierung), apps/api/src/dashboard/dashboard.service.spec.ts (Zeilen 545-570, Wachhund), apps/api/src/tenders/tender-mail.service.spec.ts (Zeilen 1-60, die erwartete Rueckgabeform von `getDecryptedSmtpConfig`), apps/api/src/dkv/dkv.service.ts (Kopfkommentar von `loadAnyActiveConfigForScheduler` — die Vorlage fuer den Startpfad-Kommentar), apps/api/src/dkv/dkv-scheduler.service.ts (Kopf, Zeilen 20-35 und 80-86), apps/api/src/prisma/rls-access-inventory.spec.ts (`analyzeFile` — die Zuweisungsform `const X = forTenant(`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitte `## Bereich favorites` und `## Bereich settings` aus Aufgabe 1, besonders (f1) Pruefung 7, (f4)(c), (s4)(a))
+
+ZUERST die Testlage, DANN der Umbau — je Bereich eine NEUE Testdatei in der
+Form von `calendar.service.spec.ts`: `vi.mock` auf das Bindungshilfsmittel
+wird auf `prisma.__makeBoundClient(tenantId)` umgeleitet; ein handgerollter
+`makeFakePrisma()` mit In-Memory-Zeilen und Bindungsprotokoll
+`boundCallLog` (`{ tenantId, model, method }`); der UNGEBUNDENE Nachbau hat
+KEINES der Anfrage-Modelle (ein versehentlich ungebundener Modellzugriff
+scheitert mit "Cannot read properties of undefined" — die dkv-Form der
+Falsifizierung); jeder Fall eigenstaendig, jeder mit sprechendem Namen; die
+Zahl der Faelle wird am Ende ABGEZAEHLT und im SUMMARY genannt.
+
+`apps/api/src/favorites/favorites.service.spec.ts` — Zeilen fuer
+`favoriteLink` (Map ueber die Kennung, alle zehn skalaren Felder) und
+`widgetInstance` (Map ueber die Kennung: `id`, `userId`, `tenantId`,
+`widgetType`); der gebundene Klient bietet `favoriteLink.findMany` (Filter
+`where` ueber `userId`/`widgetId`, Sortierung nach `position`, dann `title`),
+`findUnique`, `create`, `update` (wirft bei unsichtbarer Zeile einen Fehler
+mit `code: 'P2025'`), `delete` (ebenso) und `widgetInstance.findUnique` (mit
+`select`); alle liefern nur Zeilen, deren `tenantId` dem Klienten entspricht.
+`IconDiscoveryService` als Attrappe: `discoverFavoriteIconUrl: vi.fn(async
+(url) => 'https://icons.invalid/' + encodeURIComponent(url))`,
+`fetchIconBytes: vi.fn(async () => ({ contentType: 'image/png', body:
+Buffer.from('png') }))`; `normalizeUrl` bleibt die ECHTE Funktion (sie ist
+rein). Faelle:
+
+- `list(t1, user-a1, widget-a1)`: liefert nur die Zeilen von `user-a1` fuer
+ `widget-a1`, sortiert nach `position` aufsteigend, bei Gleichstand nach
+ `title`; die Zeile von `user-a2` und die von `widget-a2` fehlen.
+- `list` unter FREMDEM Mandanten (`t2`, Zeilen unter `t1`): `[]` — kein
+ Fehler (das ist der Wert, aus dem das Widget `Noch keine Favoriten.`
+ macht).
+- `list` ohne `widgetId`: `BadRequestException`, KEIN Klient erzeugt.
+- `create(t1, user-a1, dto)` ohne `iconUrl`: URL normalisiert
+ (`ctl.de` -> `https://ctl.de`), Icon-Suche mit der NORMALISIERTEN URL
+ aufgerufen, gebundener `create` mit `userId`, `tenantId: 't1'`,
+ `widgetId`, `position: 0` als Vorgabe.
+- `create` mit `iconUrl`: KEINE Icon-Suche, gespeicherter Wert wie
+ uebergeben.
+- `create` mit `widgetId` eines Widgets, das einem ANDEREN Benutzer
+ desselben Mandanten gehoert: `NotFoundException` mit Meldung woertlich
+ `Widget not found`, KEIN `create`, KEINE Icon-Suche (T-GWH-05).
+- `create` mit `widgetId` eines Widgets unter FREMDEM Mandanten:
+ `NotFoundException` `Widget not found` — die Meldung nennt weder Halter
+ noch Mandanten; KEIN `create`.
+- `create` mit unbekannter `widgetId`: dieselbe `NotFoundException`.
+- `update(t1, id, user-a1, dto)`, eigene Zeile: Titel/URL/Position
+ gemergt, gebundener `update`; `iconUrl` explizit `null` im DTO -> Icon-Suche
+ gegen die EFFEKTIVE URL (neue, sonst gespeicherte); `iconUrl` gesetzt ->
+ keine Icon-Suche.
+- `update`, Zeile eines ANDEREN Benutzers desselben Mandanten:
+ `NotFoundException` `FavoriteLink not found`, KEIN Schreibzugriff.
+- `update`, Zeile unter FREMDEM Mandanten (Klient `t2`, Zeile `t1`):
+ `NotFoundException`, KEIN Schreibzugriff — der gebundene `findUnique`
+ liefert `null`, bevor irgendetwas geschrieben wird.
+- `remove`: eigene Zeile geloescht (Map leer fuer diese Kennung); fremder
+ Benutzer -> `NotFoundException`, Zeile bleibt; fremder Mandant ->
+ `NotFoundException`, Zeile bleibt.
+- `getIconBytes`: eigene Zeile mit `iconUrl` -> `fetchIconBytes` GENAU mit
+ der GESPEICHERTEN URL aufgerufen, Rueckgabe durchgereicht; Zeile ohne
+ `iconUrl` -> `NotFoundException`, `fetchIconBytes` NICHT aufgerufen;
+ fremder Mandant -> `NotFoundException`; `fetchIconBytes` wirft ->
+ `HttpException` mit Status 502.
+- Wachhund je Methode: genau EIN gebundener Klient je Aufruf
+ (`vi.mocked(forTenant).mock.calls.length`), Suche, Widget-Pruefung und
+ Schreibzugriff auf DEMSELBEN — das Protokoll zeigt fuer `create` zwei
+ Eintraege (`widgetInstance.findUnique`, `favoriteLink.create`) mit
+ derselben Mandantenkennung.
+
+`apps/api/src/settings/settings.service.spec.ts` — Zeilen fuer `smtpConfig`
+(Map ueber `tenantId`, alle zehn skalaren Felder). GRENZE als Bauform: der
+UNGEBUNDENE Nachbau bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst`
+(der Startpfad) — kein `findUnique`, kein `upsert`; der GEBUNDENE Klient
+bietet AUSSCHLIESSLICH `findUnique` (mit `select`) und `upsert` (Merge oder
+Anlage, `select` angewandt) — KEIN `findFirst`. Damit scheitert ein
+gebundener Startpfad ebenso hart wie ein ungebundener Anfrageweg.
+`CryptoService` als Attrappe (`encrypt: vi.fn((s) => 'enc(' + s + ')')`,
+`decrypt: vi.fn((s) => s.slice(4, -1))`); `nodemailer` per
+`vi.mock('nodemailer', ...)` ersetzt: `createTransport` liefert
+`{ verify: vi.fn(async () => true), sendMail: vi.fn(async () => ({})) }`,
+konfigurierbar auf Wurf. Faelle:
+
+- `getSmtpConfig(t1)`: Zeile mit `encryptedPassword` (der Controller macht
+ daraus `hasPassword`), OHNE weitere Felder ausserhalb des `select`;
+ fremder Mandant (`t2`): `null`.
+- `saveSmtpConfig(t1, dto)` mit Kennwort: `encrypt` GENAU mit dem Klartext
+ aufgerufen; gebundener `upsert` mit `where.tenantId === 't1'`, `create`
+ und `update` tragen `encryptedPassword: 'enc(...)'`; Rueckgabe OHNE
+ `encryptedPassword` (SMTP_SAFE_SELECT); der Klartext taucht in KEINEM
+ Protokolleintrag auf.
+- `saveSmtpConfig` OHNE Kennwort (leer oder fehlend): `encrypt` NICHT
+ aufgerufen, `update` traegt KEINEN Schluessel `encryptedPassword`
+ (bestehendes bleibt), `username` fehlend -> `null`.
+- `saveSmtpConfig` unter `t2`, wenn nur `t1` eine Zeile hat: der gebundene
+ Nachbau legt fuer `t2` an (die Zeile von `t1` bleibt unveraendert) — die
+ richtige Semantik NACH der Bindung.
+- `getDecryptedSmtpConfig(t1)`: `decrypt` mit `encryptedPassword`
+ aufgerufen, Rueckgabe mit `host`, `port`, `encryption`, `username`,
+ `fromAddress`, `decryptedPassword` (genau die Form, die
+ `tender-mail.service.spec.ts` als `BASE_SMTP_CONFIG` erwartet); ohne
+ `encryptedPassword` -> `decryptedPassword: null`, `decrypt` NICHT
+ aufgerufen; fremder Mandant -> `null`, `decrypt` NICHT aufgerufen
+ (T-GWH-01).
+- `testSmtpConfig(t1, dto)` ohne Kennwort/Benutzername im DTO: greift ueber
+ den GEBUNDENEN Klienten auf die gespeicherten Werte zurueck
+ (`createTransport` mit `auth.user` aus der Zeile, `auth.pass` entschluesselt),
+ `verify` aufgerufen, `{ success: true }`; mit `testTo`: `sendMail` statt
+ `verify`, `from === dto.fromAddress`, `to === dto.testTo`; `verify` wirft ->
+ `{ success: false }`, kein Wurf nach aussen; fremder Mandant ohne
+ Kennwort im DTO: `auth` ohne Benutzer (`undefined`), kein Fehler.
+- Startpfad `loadAnySmtpConfigForStartupTransport()`: laeuft ueber den
+ UNGEBUNDENEN Nachbau (`findFirst`), liefert `{ host, port, secure,
+ requireTLS, username, password, fromAddress }` mit `secure` fuer
+ `ssl-tls` und `requireTLS` fuer `starttls`, `password` entschluesselt;
+ leerer Nachbau -> `null`; und der NULL-KLIENTEN-NACHWEIS:
+ `vi.mocked(forTenant).mock.calls.length === 0` nach dem Aufruf — der
+ Startpfad erzeugt KEINEN gebundenen Klienten, gemessen, nicht behauptet.
+- Wachhund je Anfrageweg: genau EIN gebundener Klient je Aufruf von
+ `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig`;
+ `testSmtpConfig` erzeugt genau einen (ueber `getDecryptedSmtpConfig`),
+ wenn es zurueckgreift, und KEINEN, wenn Kennwort und Benutzername im DTO
+ stehen.
+
+
+TEIL 1 — `favorites.service.ts`. Stelle alle fuenf Methoden um, je Methode
+EIN Klient in der Zuweisungsform, die `rls-access-inventory.spec.ts` erkennt:
+Konstante `tenantPrisma` aus dem Bindungshilfsmittel mit `this.prisma` und
+dem uebergebenen Mandanten, `as any`. Der Mandant wird ERSTER Parameter
+jeder Methode (Konvention der Vorgaenger): `list(tenantId, userId,
+widgetId)`, `create(tenantId, userId, dto)` (heute `create(userId,
+tenantId, dto)` — Reihenfolge tauschen, den Aufrufer mit), `update(tenantId,
+id, userId, dto)`, `remove(tenantId, id, userId)`, `getIconBytes(tenantId,
+id, userId)`. Die Besitzpruefungen (`findUnique`, Vergleich `link.userId !==
+userId`, dann `update`/`delete` ueber die Kennung) bleiben WORTGLEICH und
+laufen auf DEMSELBEN Klienten wie der Schreibzugriff. Meldungen, Sortierung,
+Normalisierung, Icon-Suche, 502-Uebersetzung bleiben WORTGLEICH.
+
+In `create`, VOR der Icon-Suche, der neue Besitzriegel (T-GWH-05, nur wenn
+Pruefung 7 aus Aufgabe 1 den Durchgriff des Fremdschluessels bestaetigt hat —
+sonst entfaellt er, mit Belegzeile im SUMMARY): `tenantPrisma.widgetInstance
+.findUnique({ where: { id: dto.widgetId }, select: { userId: true } })`; ist
+das Ergebnis `null` oder `widget.userId !== userId`, `NotFoundException`
+mit Meldung woertlich `Widget not found` — keine Aussage ueber fremde
+Mandanten oder fremde Halter, dieselbe Antwort fuer "gibt es nicht", "gehoert
+einem Kollegen" und "liegt bei einem fremden Mandanten" (das schliesst das
+Existenzorakel). Der Riegel steht VOR der Icon-Suche, damit ein fremdes
+Widget keinen Netzabruf ausloest.
+
+Kopfkommentar der Klasse: warum gebunden (260911-gwh), dass der Mandant aus
+`extractContext` des Controllers kommt (Befund H: dieselbe Quelle wie
+`dashboard`, weil der Link am Widget haengt — ausdruecklich NICHT der
+auth-Praezedenzfall, mit Grund), dass die Regel keine Benutzerdimension hat
+und die `userId`-Filter deshalb bleiben (Etappe-3-Entscheidung (2)), und
+dass der Fremdschluessel am Zeilenschutz vorbei prueft (Pruefung 7, mit
+Ergebnis) — deshalb der Riegel. Kommentare in dieser Datei duerfen den
+ungebundenen Zugriff auf das Favoritenmodell NICHT woertlich nennen (das
+Gate zaehlt ueber die ganze Datei).
+
+TEIL 2 — `favorites.controller.ts`. `extractContext` bleibt WORTGLEICH;
+alle fuenf Handler reichen `tenantId` als ersten Parameter durch —
+`list(tenantId, userId, widgetId)`, `create(tenantId, userId, dto)`,
+`getIconBytes(tenantId, id, userId)`, `update(tenantId, id, userId, dto)`,
+`remove(tenantId, id, userId)`. Kopfkommentar der Klasse ergaenzen: die
+Mandantenquelle ist die vom Guard gesetzte Anfragekennung (fuer SUPER_ADMIN
+per Kopfzeile umschaltbar), WORTGLEICH mit `dashboard.controller.ts`, weil
+`FavoriteLink` ueber `widgetId` an `WidgetInstance` haengt und beide unter
+derselben Kennung liegen muessen; das Favoriten-Frontend sendet die Kopfzeile
+heute nicht (gemessen), die Entscheidung haengt an der Bauform.
+
+TEIL 3 — `settings.service.ts`. `getSmtpConfig`, `saveSmtpConfig`,
+`getDecryptedSmtpConfig` je mit EINEM Klienten `tenantPrisma`
+(Zuweisungsform); `select`, `SMTP_SAFE_SELECT`, Verschluesselung,
+Rueckgabeformen WORTGLEICH. `testSmtpConfig` hat keinen eigenen
+Datenbankzugriff und bleibt bis auf Kommentare unveraendert.
+
+Der Startpfad: benenne `getStartupSmtpConfig()` um in
+`loadAnySmtpConfigForStartupTransport()` (dkv-Praezedenzfall: ein Name, den
+niemand fuer einen Anfrageweg haelt), lasse ihn auf `this.prisma` mit dem
+unveraenderten `findFirst()` ohne Bedingung, und schreibe den Kopfkommentar
+nach der Vorlage von `loadAnyActiveConfigForScheduler` NEU: BLEIBT bewusst
+UNGEBUNDEN (260911-gwh, sechster Fall der Hintergrunddienst-Falle); HEUTE
+bereits falsch — bei mehreren Mandanten traegt der SMTP-Server und Absender
+eines BELIEBIGEN Mandanten alle Kennwort-Zuruecksetzungs- und
+Willkommensmails (T-GWH-03); NACH DEM SCHARFSCHALTEN `null`, und
+`mail.module.ts` faellt auf Umgebungsvariablen und zuletzt `localhost:1025`
+zurueck, `MailService` faengt den Transportfehler — das Verstummen erzeugt
+KEINE Protokollzeile (die Unsymmetrie zu `ldap` UND `dkv`); binden wuerde den
+Pfad garantiert leer laufen lassen (kein Kontext beim Start); der Umbau auf
+Transport je Versand aus `getDecryptedSmtpConfig(tenantId)` — die Form, die
+`DkvMailService`/`TenderMailService` bereits haben, `MailService` muesste
+den Mandanten von `requestPasswordReset` entgegennehmen — ist eine
+Funktionsaenderung, NICHT dieser Auftrag; Verweis auf den Ledger-Eintrag
+(die Nummer vergibt erst Aufgabe 3 — hier zunaechst woertlich der
+Platzhalter `WINDOWS #TBD-GWH`, den Aufgabe 3 in DIESER Datei und in
+`mail.module.ts` durch die vergebene Nummer ersetzt; das Gate von Aufgabe 3
+zaehlt den Platzhalter auf null) und auf (s4). Kommentare in dieser Datei duerfen den ungebundenen Zugriff auf das
+SMTP-Modell nicht woertlich nennen (das Gate zaehlt genau EINE Stelle in der
+Datei — den Startpfad im Code).
+
+TEIL 4 — `mail.module.ts`. Aufruf auf den neuen Namen umstellen; den
+Modulkommentar und den Zeilenkommentar an der Aufrufstelle so umschreiben,
+dass beide Zustaende stehen (der Satz "single-tenant default" verschwindet
+und wird durch die beiden Zustaende und den Verweis auf den Ledger-Eintrag
+ersetzt). KEINE Aenderung an der Rueckfallkette selbst, KEIN Umbau des
+Transports.
+
+TEIL 5 — die beiden Testdateien aus ``. Danach VIER
+Falsifizierungsnachweise, jeder zurueckgenommen und mit Testname und
+Fehlermeldung woertlich notiert: (a) ersetze in `list` probeweise den
+gebundenen Klienten durch den ungebundenen Basisclient —
+`favorites.service.spec.ts` wird rot (erwartete Form: der Nachbau hat kein
+ungebundenes Favoritenmodell); (b) entferne probeweise den Widget-Riegel in
+`create` — genau die drei `Widget not found`-Faelle werden rot; (c) ersetze
+in `getDecryptedSmtpConfig` probeweise den gebundenen Klienten durch den
+ungebundenen — `settings.service.spec.ts` wird rot (kein `findUnique` auf
+dem ungebundenen Nachbau); (d) binde probeweise den Startpfad — der
+Null-Klienten-Nachweis UND der Startpfad-Fall werden rot (der gebundene
+Nachbau hat kein `findFirst`). Zaehle jeweils, wie viele Faelle rot wurden,
+und nenne die Zahl.
+
+Aendere keine Datei ausserhalb der sechs genannten. Insbesondere: KEINE
+Aenderung an `settings.controller.ts`, den DTOs, `icon-discovery.service.ts`,
+`mail.service.ts`, `tender-mail.service.ts`, `dkv-mail.service.ts`,
+`dashboard.controller.ts`.
+
+
+ npm --prefix apps/api run test -- src/favorites/favorites.service.spec.ts src/settings/settings.service.spec.ts && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -gt 951 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mehr als 951"; exit 1; }; } && TF=$(echo "$TOUT" | sed -nE 's/^ *Test Files +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$TF" && test "$TF" -ge 62 || { echo "TESTDATEIEN: ${TF:-unbekannt}, erwartet mindestens 62"; exit 1; }; } && npm --prefix apps/api run type-check && F=apps/api/src/favorites/favorites.service.ts && SRC=$(grep -vE '^\s*(//|\*|/\*)' "$F") && test 0 -eq "$(grep -c 'this\.prisma\.favoriteLink' "$F")" && B=$(printf '%s\n' "$SRC" | grep -o 'tenantPrisma\.favoriteLink\.' | wc -l | tr -d ' ') && { test "$B" -eq 7 || { echo "BINDUNG: $B gebundene Favoritenzugriffe, erwartet genau 7"; exit 1; }; } && C=$(printf '%s\n' "$SRC" | grep -o 'forTenant(this\.prisma, tenantId)' | wc -l | tr -d ' ') && { test "$C" -eq 5 || { echo "KLIENTEN: $C Aufrufstellen des Bindungshilfsmittels in favorites.service.ts, erwartet genau 5"; exit 1; }; } && for M in list create update remove getIconBytes; do REG=$(awk -v m="async $M(" 'index($0,m){f=1} f{print} f&&/^ \}$/{f=0}' "$F" | grep -vE '^\s*(//|\*|/\*)'); K=$(printf '%s\n' "$REG" | grep -c 'forTenant(this\.prisma, tenantId)'); test "$K" -eq 1 || { echo "METHODE $M: $K Klienten, erwartet genau 1"; exit 1; }; U=$(printf '%s\n' "$REG" | grep -c 'this\.prisma\.[a-zA-Z]'); test "$U" -eq 0 || { echo "METHODE $M: $U ungebundene Modellzugriffe"; exit 1; }; done && grep -q 'async list(tenantId: string, userId: string, widgetId: string)' "$F" && grep -q 'async create(tenantId: string, userId: string, dto: CreateFavoriteDto)' "$F" && grep -q 'Widget not found' "$F" && grep -q 'tenantPrisma\.widgetInstance\.findUnique' "$F" && G=apps/api/src/settings/settings.service.ts && GS=$(grep -vE '^\s*(//|\*|/\*)' "$G") && U2=$(printf '%s\n' "$GS" | grep -c 'this\.prisma\.smtpConfig') && { test "$U2" -eq 1 || { echo "STARTPFAD: $U2 ungebundene SMTP-Zugriffe im Code von settings.service.ts, erwartet genau 1 (nur der Startpfad)"; exit 1; }; } && test 1 -eq "$(grep -c 'this\.prisma\.smtpConfig' "$G")" && B2=$(printf '%s\n' "$GS" | grep -o 'tenantPrisma\.smtpConfig\.' | wc -l | tr -d ' ') && { test "$B2" -eq 3 || { echo "BINDUNG: $B2 gebundene SMTP-Zugriffe, erwartet genau 3"; exit 1; }; } && C2=$(printf '%s\n' "$GS" | grep -o 'forTenant(this\.prisma, tenantId)' | wc -l | tr -d ' ') && { test "$C2" -eq 3 || { echo "KLIENTEN: $C2 Aufrufstellen in settings.service.ts, erwartet genau 3"; exit 1; }; } && grep -q 'async loadAnySmtpConfigForStartupTransport()' "$G" && REG=$(awk 'index($0,"async loadAnySmtpConfigForStartupTransport("){f=1} f{print} f&&/^ \}$/{f=0}' "$G" | grep -vE '^\s*(//|\*|/\*)') && test 1 -eq "$(printf '%s\n' "$REG" | grep -c 'this\.prisma\.smtpConfig\.findFirst()')" && test 0 -eq "$(printf '%s\n' "$REG" | grep -c 'forTenant(')" && awk 'index($0,"async loadAnySmtpConfigForStartupTransport("){exit} {print}' "$G" | tail -40 | grep -q 'UNGEBUNDEN' && test 0 -eq "$(grep -rc 'getStartupSmtpConfig' apps/api/src | awk -F: '{s+=$2} END{print s}')" && grep -q 'loadAnySmtpConfigForStartupTransport()' apps/api/src/mail/mail.module.ts && test 0 -eq "$(grep -c 'single-tenant default' apps/api/src/mail/mail.module.ts)" && test 0 -eq "$(grep -c 'tenantPrisma\.findFirst\|tenantPrisma\.smtpConfig\.findFirst' "$G")" && H=apps/api/src/favorites/favorites.controller.ts && for M in 'list(tenantId, userId, widgetId)' 'create(tenantId, userId, dto)' 'getIconBytes(' 'update(tenantId, id, userId, dto)' 'remove(tenantId, id, userId)'; do grep -qF "this.favoritesService.$M" "$H" || { echo "CONTROLLER: Aufruf $M nicht in der erwarteten Form"; exit 1; }; done && grep -q 'tenantId,$' "$H" && SF=apps/api/src/favorites/favorites.service.spec.ts && grep -q '__makeBoundClient' "$SF" && grep -q 'mock.calls.length' "$SF" && grep -q 'Widget not found' "$SF" && grep -q 'fetchIconBytes' "$SF" && SS=apps/api/src/settings/settings.service.spec.ts && grep -q '__makeBoundClient' "$SS" && grep -q "vi.mock('nodemailer'" "$SS" && grep -q 'mock.calls.length' "$SS" && grep -q 'loadAnySmtpConfigForStartupTransport' "$SS" && grep -q 'toBe(0)\|toHaveLength(0)\|toEqual(0)' "$SS" && git rev-parse --verify 46f0e78 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 46f0e78 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 46f0e78) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|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/settings/settings\.service\.ts|apps/api/src/settings/settings\.service\.spec\.ts|apps/api/src/mail/mail\.module\.ts|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }
+
+ `favorites.service.ts`: alle fuenf Methoden nehmen den Mandanten als ersten Parameter und laufen je ueber genau EINEN Klienten `tenantPrisma` (sieben gebundene Favoritenzugriffe, fuenf Aufrufstellen des Bindungshilfsmittels, ein gebundener `widgetInstance`-Lesezugriff als Besitzriegel in `create` mit `Widget not found`); kein ungebundener Favoritenzugriff, auch nicht in Kommentaren. `favorites.controller.ts` reicht `tenantId` an alle fuenf Aufrufe durch, `extractContext` unveraendert, Kopfkommentar nennt Quelle und Praezedenzfall. `settings.service.ts`: `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` je ueber EINEN Klienten (drei gebundene Zugriffe, drei Aufrufstellen); der Startpfad heisst `loadAnySmtpConfigForStartupTransport()`, laeuft unveraendert auf dem ungebundenen Klienten mit `findFirst()` ohne Bedingung — die einzige ungebundene Stelle der Datei —, traegt den Kopfkommentar mit beiden Zustaenden, Unsymmetrie, Reparaturweg, Ledger-Verweis; der alte Name kommt in `apps/api/src` nicht mehr vor; `mail.module.ts` ruft den neuen Namen auf und nennt beide Zustaende statt "single-tenant default". Beide Testdateien existieren neu mit dem Zwei-Klienten-Nachbau (ungebunden ohne Anfrage-Modelle, gebunden ohne `findFirst`), jedem in `` genannten Fall, Wachhund und Null-Klienten-Nachweis; alle vier Falsifizierungsnachweise durchgefuehrt, zurueckgenommen, woertlich notiert. Testzahl gestiegen, mindestens 62 Testdateien, Typpruefung sauber, Schema/DTOs/Controller `settings`/Icon-Proxy/Versanddienste unveraendert.
+
+
+
+ Aufgabe 3: Alle Dokumentstellen auf den Endstand der Etappe 2 bringen (sechster Fall, Befund K erfuellt, Abschluss-Abschnitt), drei Ledger-Eintraege anlegen, Nummern eintragen, Gates falsifizieren
+ docs/mandantentrennung-zugriffsklassifikation.md, docs/mandantentrennung-etappe2-fehlerrichtung.md, docs/anleitung-entwicklung.md, .planning/WINDOWS.md, apps/api/src/settings/settings.service.ts, apps/api/src/mail/mail.module.ts
+ docs/mandantentrennung-zugriffsklassifikation.md (VOLLSTAENDIG: Uebersichtstabelle samt Messanweisung und die Zeilen `favorites`/`settings`, Summenzeile, Klassen-Verteilung samt Tabelle, Hintergrunddienst-Abschnitt VOLLSTAENDIG (Ueberschrift, Einleitungsabsatz, der Absatz zum fuenften Fall, die Stand-Absaetze), beide Bestandsaufnahme-Zeilen, `Was diese Etappe NICHT entscheidet`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Kopf (a)-(e) mit dem Nachtrag unter Befund D als Form; (t4) Befund K; (d4) Uebergaben-Absatz; die neuen Abschnitte aus Aufgabe 1; `## Verweis`), docs/anleitung-entwicklung.md (Zeilen 300-325), apps/api/src/prisma/rls-access-inventory.spec.ts (`computeStandByKey`, `parseDocEntries`), .planning/WINDOWS.md (Kopfzaehler, Eintraege #21, #25, #26, #28 als Form), .planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-SUMMARY.md (wie die beiden Ledger-Eintraege dort angelegt wurden — der `gsd-tools windows append`-Aufruf), apps/api/src/settings/settings.service.ts (den Kopfkommentar des Startpfads mit dem Platzhalter), apps/api/src/mail/mail.module.ts (den Modulkommentar mit dem Platzhalter)
+
+TEIL 1 — die Ledger-Eintraege, ueber `gsd-tools windows append` (damit
+Tabelle, JSON-Block und Kopfzaehler zusammenpassen — nicht von Hand), je mit
+`--kind`, `--phase quick-260911-gwh`, `--file`, `--description`:
+
+(1) `--kind deviation --file apps/api/src/mail/mail.module.ts` — der
+Startpfad des Mailmoduls als SECHSTER Fall der Hintergrunddienst-Falle
+(`SettingsService.loadAnySmtpConfigForStartupTransport()`, vormals mit dem
+alten Namen), BLEIBT bewusst UNGEBUNDEN. Zwei Zustaende: HEUTE bereits
+falsch — `findFirst()` ohne Bedingung zieht bei mehreren Mandanten den
+SMTP-Server und Absender EINES beliebigen Mandanten fuer alle Systemmails
+(Kennwort-Zuruecksetzung, Willkommen) ALLER Mandanten; NACH DEM
+SCHARFSCHALTEN (#18) `null`, und `mail.module.ts` faellt auf `MAIL_*`,
+`TESSERA_SMTP_*`, zuletzt `localhost:1025` zurueck, `MailService` faengt den
+Transportfehler (T-02-12) — KEINE Protokollzeile, das Verstummen ist doppelt
+verdeckt (die Unsymmetrie zu #21 und zu `getAllActiveConfigs`). Drei
+erwogene Formen wie in #21 (binden unmoeglich; Mehrmandanten-Versand
+abgelehnt als Funktion — mit dem Satz, dass die Vorlage in
+`DkvMailService`/`TenderMailService` steht und `MailService` den Mandanten
+von `requestPasswordReset` bekommen koennte; Altlast mit Markierung GEWAEHLT).
+Warum EIGENER Eintrag statt #21: andere Datei, andere Reparatur, andere
+Verdeckungsform. Markierung: umbenannte Methode mit Kopfkommentar,
+Modulkommentar in `mail.module.ts`, (s4)(a). Signal fuer das Verstummen
+gehoert in `rls-preflight.mjs` (Etappe 4). Falls die Lesung in Aufgabe 1
+(s4)(a) auf Anschluss an #21 entschieden hat, entfaellt dieser Eintrag und
+der Grund steht im SUMMARY — die Nummer im Code verweist dann auf #21.
+
+(2) `--kind deviation --file apps/web/src/components/dashboard/widgets/favorites-widget.tsx`
+— Bereich `favorites`: ein nach dem Scharfschalten zu klein gebliebenes
+Leseergebnis auf `list` sieht aus wie `Noch keine Favoriten.`
+(`favorites-widget.tsx`, Zeile um 212; `fetchFavorites` in
+`favorites-api.ts` reicht `[]` durch) — "nie einen gespeichert" und "Zeile
+unsichtbar" sind fuer das Frontend derselbe Wert; Etappe-4-Vorabpruefung
+aus (f4)(d); an dieselbe Bedingung gebunden wie #18; Familie
+#23/#25/#26/#28; das Frontend wird von 260911-gwh NICHT geaendert.
+
+(3) `--kind deviation --file apps/web/src/components/settings/smtp-settings-form.tsx`
+— Bereich `settings`: `getSmtpConfig` liefert nach dem Scharfschalten
+`null`, der Controller antwortet 200 mit leerem Rumpf, `fetchSmtp`
+(`settings-api.ts`) laeuft mit `res.json()` auf den leeren Rumpf und wirft,
+`smtp-settings-form.tsx` verschluckt das in `.catch(() => {})` — leeres
+Formular, "nicht eingerichtet", waehrend die Zugangsdaten physisch da sind;
+ein erneutes Speichern unter der ungebundenen Form scheitert am
+Eindeutigkeitsindex (Pruefung 8, mit gemessenem Konstruktor/`code`) — nach
+diesem Lauf ist `saveSmtpConfig` gebunden und trifft die eigene Zeile;
+dieselbe 200-leerer-Rumpf-Kette wie #28; Etappe-4-Vorabpruefung aus
+(s4)(e); Frontend NICHT geaendert.
+
+Pruefe nach dem Anlegen, dass jeder Eintrag in Tabelle UND JSON-Block steht
+und die Kopfzaehler (`open_count`, `total_count`) mit den Zeilen
+uebereinstimmen. Ersetze dann den Platzhalter `WINDOWS #TBD-GWH` in
+`apps/api/src/settings/settings.service.ts` und `apps/api/src/mail/mail.module.ts`
+durch die vergebene Nummer von Eintrag (1) (bzw. `#21`, falls angeschlossen)
+— sonst nichts an diesen beiden Dateien.
+
+TEIL 2 — die Klassifikation. Ziehe `docs/mandantentrennung-zugriffsklassifikation.md`
+an ALLEN handgepflegten Stellen nach, jede einzeln nachgesehen, keine
+ueberflogen (Fehler 4 des Vorhabens):
+
+1. Uebersichtszeilen `favorites` und `settings` mit den NEU GEMESSENEN Zahlen
+ aus der im Dokument genannten Messanweisung (beide Spalten), im
+ etablierten Stil mit Vermerk des vorherigen Standes (`**war 7/0**`,
+ `**war 4/0**`): fuer `favorites`, welche fuenf Methoden gebunden wurden
+ und dass der Besitzriegel einen zusaetzlichen gebundenen
+ `widgetInstance`-Lesezugriff einfuehrt (wenn gebaut — Zahl aus der
+ Messung); fuer `settings`, dass die drei Anfragewege gebunden sind und der
+ eine verbleibende ungebundene Rohtreffer der benannte Startpfad ist
+ (Verweis auf den sechsten Fall unten, den Ledger-Eintrag, (s4)(a)).
+2. Summenzeile mit fortgeschriebener Herkunftsspur (ungebunden und gebunden
+ je um die Differenzen aus Schritt 1) UND dem Satz, dass dies der Endstand
+ der Etappe 2 ist: jeder verbleibende ungebundene Rohtreffer ist einer der
+ im Dokument benannten, bewusst ungebundenen Faelle.
+3. Bestandsaufnahme: `favorites.service.ts | favoriteLink` Stand von
+ `ungebunden` auf `gebunden`, Begruendung NEU (fuenf Methoden, ein Klient je
+ Methode, Besitzpruefungen bleiben, Mandantenquelle wie `dashboard`,
+ 260911-gwh); NEUE Zeile `apps/api/src/favorites/favorites.service.ts |
+ widgetInstance | muss-mandantengebunden | gebunden` mit Begruendung
+ (Besitzriegel vor dem Anlegen, weil der Fremdschluessel am Zeilenschutz
+ vorbei prueft — Pruefung 7) — NUR, wenn der Riegel gebaut wurde;
+ `settings.service.ts | smtpConfig` Stand von `ungebunden` auf `gemischt`,
+ Begruendung NEU in der Form der `dkv.service.ts`/`dkvModuleConfig`-Zeile
+ (die Mischung stammt ausschliesslich vom benannten Startpfad, keine
+ uebersehene Fundstelle; Befund K erfuellt). Ohne Schritt 3 ist
+ `rls-access-inventory.spec.ts` am Ende dieser Aufgabe rot (Stand-Vergleich
+ und, bei neuer Fundstelle, Vollstaendigkeit).
+4. Klassen-Verteilung: ein `**Stand 260911-gwh (Aufgabe 3):**`-Absatz — bei
+ gebautem Riegel: 65 Paare, eine neue Fundstelle
+ (`favorites.service.ts`/`widgetInstance`, `muss-mandantengebunden`), die
+ Zahl der Ausgabe der Pruefung entnommen, Tabelle `muss-mandantengebunden`
+ 33 und Summe 65, Ueberschrift auf 65 Paare; sonst der
+ "unveraendert, ausdruecklich festgehalten"-Absatz in der Form von
+ 260911-fh9. In beiden Faellen der Satz, dass zwei Paare nur ihre
+ Stand-Spalte aendern, und dass dies der Endstand der Etappe 2 ist.
+5. Hintergrunddienst-Abschnitt: Ueberschrift von `fünf Fälle` auf `sechs
+ Fälle`; im Einleitungsabsatz der Satz, dass der sechste Fall (seit
+ 260911-gwh) von derselben Bauart wie der fuenfte ist; ein NEUER Absatz
+ `**Der sechste Fall, gleicher Bauart wie der fünfte — `mail.module.ts` /
+ `SettingsService.loadAnySmtpConfigForStartupTransport()`** (260911-gwh,
+ Befund D, WINDOWS #): offen, als benannte Altlast weitergeführt.`
+ unmittelbar NACH dem Absatz zum fuenften Fall und VOR dem
+ `**Stand 260910-exd**`-Absatz, in dessen Form: beide Zustaende, warum
+ Binden keine Loesung ist, warum der Umbau eine Funktion ist, die
+ Unsymmetrie zu BEIDEN Praezedenzfaellen (Rueckfallkette, keine
+ Protokollzeile), die dreifache Markierung, das Signal fuer
+ `rls-preflight.mjs`, UND der Satz, dass die Reihenfolgebedingung Befund K
+ (`tenders` (t4), `dkv` (d4)) fuer den VERSANDPFAD `getDecryptedSmtpConfig`
+ mit diesem Lauf ERFUELLT ist — die Etappe-4-Vorabpruefung fuehrt sie nicht
+ mehr. Dazu ein PLAIN-Absatz `**Stand 260911-gwh — der Bereich `favorites`
+ fügt diesem Abschnitt keinen weiteren Fall hinzu, gemessen statt
+ angenommen (Befund K).**` mit der Anweisung und dem `setTimeout`-Treffer
+ im Icon-Proxy (Form von 260911-cwh).
+6. Abschnitt `Was diese Etappe NICHT entscheidet`: ein NEUER Punkt — wie
+ das Mailmodul kuenftig je Mandant versendet (Transport je Versand aus
+ `getDecryptedSmtpConfig(tenantId)`, Vorlage `DkvMailService`/
+ `TenderMailService`; `MailService` braucht dafuer den Mandanten, den
+ `requestPasswordReset` aus der Funktionszeile hat) — eine Funktion,
+ Verweis auf (s4)(a) und den Ledger-Eintrag; und ein ZWEITER Punkt in der
+ durchgestrichenen Form (`~~...~~ **Aufgelöst (260911-gwh):**`) NUR, wenn
+ der Abschnitt heute einen Punkt zur settings-Reihenfolgebedingung fuehrt
+ (gemessen: keinen — dann stattdessen ein Satz am Ende des neuen
+ Hintergrunddienst-Absatzes, wie in Schritt 5 verlangt).
+
+TEIL 3 — die Kritikschrift. (a) Nachtrag unter Befund K in (t4), in der
+Form des Nachtrags unter Befund D in (e): Original bleibt stehen, darunter
+ein Absatz `**Nachtrag (260911-gwh):**` — `getDecryptedSmtpConfig(tenantId)`
+laeuft seit Aufgabe 2 dieses Laufs ueber `forTenant()`, die
+Reihenfolgebedingung ist erfuellt, Verweis auf (s4)(b) und die
+Bestandsaufnahme-Zeile `settings.service.ts`/`smtpConfig`. (b) Derselbe
+Nachtrag unter dem Uebergaben-Absatz in (d4) (nur der `settings`-Teil;
+`dkv.seed.ts`/`module-registry` ist seit 260910-exd gebunden — pruefen und,
+falls zutreffend, in demselben Nachtrag mit einem Satz nennen). (c) Ein
+NEUER Abschnitt `## Etappe 2 — Abschluss` unmittelbar NACH `## Bereich
+settings` und VOR `## Verweis`: was Etappe 2 in Summe geliefert hat — die
+Zahl der Laeufe (aus STATE.md gezaehlt: zwoelf Bereichs-/Regel-Laeufe von
+260909-ipc bis 260911-gwh, nachzaehlen), die Summenzeile der
+Uebersichtstabelle VORHER (aus dem Klassifikationsdokument: der `**war
+...**`-Ausgangswert, 227 Rohtreffer laut Kopf) und NACHHER (die Summenzeile
+nach Schritt 2 oben, woertlich abgeleitet), die Zahl der Paare und die
+Klassen-Verteilung (aus Schritt 4), die Liste der BEWUSST ungebundenen
+Reste je Bereich mit ihrem Grund in je einem Satz (tenders: D-03-Katalog,
+Fan-out-Adapter, RSS-Verwaltung #24, uebergreifende Haelften; ldap:
+`getAllActiveConfigs`, `resolveEmailForWrite`; dkv: #21; user:
+`findByUsername`, Erstanlage, Treiber; module-registry/dashboard:
+Modulkatalog; auth: drei Anmeldefunktionen; tenant: die Mandantentabelle;
+settings: der sechste Fall) — jeden Bereich aus seinem eigenen Abschnitt
+dieses Dokuments bzw. der Uebersichtszeile abgeschrieben, nicht aus dem
+Gedaechtnis —, die Zahl der offenen Ledger-Eintraege dieser Etappe (aus
+WINDOWS.md gezaehlt, mit Nummern), die Zahl der Pruefungen des Werkzeugs
+(die Zeile `Alle N Pruefungen bestanden.` des letzten Laufs) und der Tests
+(letzte Testausgabe). Dann, was fuer Etappe 3 bleibt, je ein Satz mit
+Verweis auf die Stelle: Anmeldeweg unter je Mandant eindeutigen Namen
+((h4)(a)); Benutzerdimension der Regeln (Etappe-3-Entscheidung (2), (k4)/
+(f4)); Modulkatalog-Regel (Befund E `module-registry`); Kennzeichnung der
+`bewusst-uebergreifend`-Stellen; Mandantenwechsel im Digest ((t4)). Und was
+Etappe 4 VOR dem Scharfschalten pruefen muss (`rls-preflight.mjs`): die in
+den Bereichsabschnitten benannten Vorabpruefungen, als Liste mit Verweis —
+OHNE die erfuellte Befund-K-Bedingung.
+
+TEIL 4 — die Anleitung. Bringe in `docs/anleitung-entwicklung.md` den
+Absatz um Zeile 310-316 (`app.current_tenant` wird von Postgres
+Row-Level-Security ausgewertet ...) und den Folgeabsatz (`Was ein Entwickler
+nie vergessen darf`) mit scoped `Edit` auf den gemessenen Stand: die Regeln
+liegen seit `20260909140000_rls_remaining_tenant_tables` auf 23 Tabellen
+(`grep -c "ENABLE ROW LEVEL SECURITY"` ueber die drei Migrationen, zur
+Ausfuehrungszeit gezaehlt), `FavoriteLink` und `SmtpConfig` eingeschlossen;
+die Tabellen OHNE Regel sind die im Klassifikationsdokument als
+`keine-mandantengebundene-tabelle` gefuehrten (`Tenant`, `Module`,
+`Tender`, ... — aus der Bestandsaufnahme ableiten, nicht raten);
+`DkvService.loadConfig()` ist seit 260909-mir gebunden — das Beispiel fuer
+"manuelles `tenantId`-Filter" ersetzen durch die tatsaechliche Regel: jeder
+Zugriff auf eine mandantengebundene Tabelle laeuft ueber einen dienst-intern
+mit `forTenant()` gebundenen Klienten `tenantPrisma`, die `where`-Filter
+ueber `userId` bleiben zusaetzlich; Verweis auf die beiden
+Mandantentrennungs-Dokumente. Nichts sonst in dieser Datei.
+
+TEIL 5 — zwei Falsifizierungsnachweise fuer die Dokument-Gates, jeder
+zurueckgenommen und mit Meldung woertlich notiert: (a) setze die
+Bestandsaufnahme-Zeile `favorites.service.ts | favoriteLink` probeweise
+zurueck auf `ungebunden` — `rls-access-inventory.spec.ts` muss rot werden;
+(b) setze die Uebersichtszeile `settings` probeweise auf eine falsche Zahl —
+das herleitende Gate dieser Aufgabe muss fehlschlagen.
+
+Aendere keine Datei ausserhalb der sechs genannten.
+
+
+ DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && SOUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$SOUT" | tail -1 && N=$(echo "$SOUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden\.$/\1/p') && { test -n "$N" && test "$N" -ge 137 || { echo "WERKZEUG: ${N:-nicht alle} Pruefungen bestanden, erwartet mindestens 137"; exit 1; }; } && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -gt 951 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mehr als 951"; exit 1; }; } && npm --prefix apps/api run type-check && test 0 -eq "$(grep -rc 'TBD-GWH' apps/api/src docs | awk -F: '{s+=$2} END{print s}')" && grep -qE 'WINDOWS #[0-9]+' apps/api/src/settings/settings.service.ts && grep -qE 'WINDOWS #[0-9]+' apps/api/src/mail/mail.module.ts && K=docs/mandantentrennung-zugriffsklassifikation.md && FU=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/favorites | grep -v spec | wc -l | tr -d ' ') && FB=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/favorites | grep -v spec | wc -l | tr -d ' ') && SU=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/settings | grep -v spec | wc -l | tr -d ' ') && SB=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/settings | grep -v spec | wc -l | tr -d ' ') && { test "$FU" -eq 0 || { echo "favorites: $FU ungebundene Rohtreffer, erwartet 0"; exit 1; }; } && { test "$FB" -ge 7 || { echo "favorites: $FB gebundene Rohtreffer, erwartet mindestens 7"; exit 1; }; } && { test "$SU" -eq 1 || { echo "settings: $SU ungebundene Rohtreffer, erwartet 1"; exit 1; }; } && { test "$SB" -eq 3 || { echo "settings: $SB gebundene Rohtreffer, erwartet 3"; exit 1; }; } && { grep -qE "^\| favorites \| ${FU} \| ${FB} \| \*\*war 7/0\*\*" "$K" || { echo "UEBERSICHTSZEILE favorites nennt nicht ${FU}/${FB} im etablierten Stil"; exit 1; }; } && { grep -qE "^\| settings \| ${SU} \| ${SB} \| \*\*war 4/0\*\*" "$K" || { echo "UEBERSICHTSZEILE settings nennt nicht ${SU}/${SB} im etablierten Stil"; exit 1; }; } && TU=0 && TB=0 && for d in apps/api/src/*/; do u=$(grep -ro "this\.prisma\.[a-zA-Z]*" "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); b=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); TU=$((TU+u)); TB=$((TB+b)); done && { grep -qE "^\| \*\*Summe\*\* \| \*\*${TU}\*\* \| \*\*${TB}\*\* \|" "$K" || { echo "SUMMENZEILE nennt nicht die abgeleiteten Werte ${TU}/${TB}"; exit 1; }; } && grep -qE '^\| apps/api/src/favorites/favorites\.service\.ts \| favoriteLink \| muss-mandantengebunden \| gebunden \|' "$K" && grep -qE '^\| apps/api/src/settings/settings\.service\.ts \| smtpConfig \| muss-mandantengebunden \| gemischt \|' "$K" && { if grep -q 'tenantPrisma\.widgetInstance\.' apps/api/src/favorites/favorites.service.ts; then grep -qE '^\| apps/api/src/favorites/favorites\.service\.ts \| widgetInstance \| muss-mandantengebunden \| gebunden \|' "$K" || { echo "BESTANDSAUFNAHME: neue Zeile favorites.service.ts/widgetInstance fehlt"; exit 1; }; fi; } && P=$(grep -cE '^\| apps/api/src/[^|]+ \| [a-zA-Z]+ \| (muss-mandantengebunden|bewusst-uebergreifend|keine-mandantengebundene-tabelle|beides) \| (gebunden|ungebunden|gemischt) \|' "$K") && { grep -qE "^## Klassen-Verteilung \(nach \(Datei, Modell\)-Fundstellen, ${P} Paare\)" "$K" || { echo "KLASSEN-VERTEILUNG: Ueberschrift nennt nicht die gezaehlten ${P} Paare"; exit 1; }; } && { grep -qE "^\| \*\*Summe\*\* \| \*\*${P}\*\* \|$" "$K" || { echo "KLASSEN-VERTEILUNG: Summe nennt nicht ${P}"; exit 1; }; } && MM=$(grep -cE '^\| apps/api/src/[^|]+ \| [a-zA-Z]+ \| muss-mandantengebunden \|' "$K") && { grep -qE "^\| muss-mandantengebunden \| ${MM} \|$" "$K" || { echo "KLASSEN-VERTEILUNG: muss-mandantengebunden nennt nicht ${MM}"; exit 1; }; } && grep -q '^\*\*Stand 260911-gwh (Aufgabe 3)' "$K" && grep -q '^## Der Hintergrunddienst als Falle — sechs Fälle$' "$K" && test 0 -eq "$(grep -c 'fünf Fälle' "$K")" && awk '/^## Der Hintergrunddienst als Falle/{f=1; next} /^## /{f=0} f && /sechste Fall/{s=1} f && /loadAnySmtpConfigForStartupTransport/{m=1} f && /mail\.module\.ts/{o=1} f && /Befund K/{k=1} f && /favorites/{v=1} f && /localhost:1025|Rückfallkette|Rueckfallkette/{r=1} END{ if(!s||!m||!o){print "HINTERGRUNDDIENST: der sechste Fall (mail.module.ts / loadAnySmtpConfigForStartupTransport) fehlt"; exit 1} if(!k){print "HINTERGRUNDDIENST: die erfuellte Befund-K-Bedingung ist nicht benannt"; exit 1} if(!v){print "HINTERGRUNDDIENST: der favorites-Absatz fehlt"; exit 1} if(!r){print "HINTERGRUNDDIENST: die Rueckfallkette ist nicht benannt"; exit 1} }' "$K" && awk '/^## Was diese Etappe NICHT entscheidet/{f=1; next} /^## /{f=0} f && /getDecryptedSmtpConfig/{g=1} f && /260911-gwh/{w=1} END{ if(!g||!w){print "NICHT ENTSCHEIDET: der Punkt zum Mehrmandanten-Versand (260911-gwh) fehlt"; exit 1} }' "$K" && D=docs/mandantentrennung-etappe2-fehlerrichtung.md && grep -q '^## Etappe 2 — Abschluss$' "$D" && awk '/^## Bereich settings$/{s=NR} /^## Etappe 2 — Abschluss$/{a=NR} /^## Verweis$/{v=NR} END{ if(!(s&&a&&v&&s/dev/null && PRISMA_CHANGED=$(git diff --name-only 46f0e78 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 46f0e78) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|docs/mandantentrennung-zugriffsklassifikation\.md|docs/anleitung-entwicklung\.md|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/settings/settings\.service\.ts|apps/api/src/settings/settings\.service\.spec\.ts|apps/api/src/mail/mail\.module\.ts|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; } && test -z "$(git diff --name-only 46f0e78 -- docker-compose.yml docker-compose.*.yml .env .env.* apps/api/.env apps/api/.env.* 2>/dev/null)"
+
+ Drei neue offene Ledger-Eintraege (Startpfad des Mailmoduls — oder, mit Grund, Anschluss an #21 —, verschluckte Leere `favorites`, verschluckte Leere `settings`) stehen in Tabelle UND JSON-Block, die Kopfzaehler stimmen, der Platzhalter im Code ist durch die vergebene Nummer ersetzt. Alle handgepflegten Stellen von `docs/mandantentrennung-zugriffsklassifikation.md` sind nachgezogen und DERIVIERT gegatet: Uebersichtszeilen `favorites`/`settings` mit neu gemessenen Zahlen, Summenzeile aus der Messanweisung, Bestandsaufnahme (`favoriteLink` gebunden, `smtpConfig` gemischt, `widgetInstance` neu falls gebaut), Klassen-Verteilung mit Stand-Vermerk und aus der Tabelle gezaehlter Paarzahl, Hintergrunddienst-Abschnitt mit sechstem Fall, angepasster Ueberschrift, erfuellter Befund-K-Bedingung und favorites-Absatz, `Was diese Etappe NICHT entscheidet` mit dem Mehrmandanten-Versand-Punkt. Die Kritikschrift traegt die Nachtraege in (t4) und (d4) und den Abschnitt `## Etappe 2 — Abschluss` mit den aus den eigenen Messanweisungen abgeleiteten Zahlen, den bewusst ungebundenen Resten je Bereich, den Ledger-Nummern, dem Etappe-3-Rest und der Etappe-4-Vorabpruefliste. Die Anleitung nennt `FavoriteLink` nicht mehr als Tabelle ohne Regel und `loadConfig()` nicht mehr als ungebunden. Beide Dokument-Falsifizierungen durchgefuehrt, zurueckgenommen, woertlich notiert. Werkzeug alle Pruefungen bestanden (mindestens 137), Testzahl ueber 951, Typpruefung sauber, Schalter aus, Schema/Migrationen/Compose/Umgebung unveraendert, Erlaubnisliste gegen `46f0e78` gehalten.
+
+
+
+
+
+
+Konfiguriert: ASVS-Stufe 1, blockierend ab `high`.
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Angemeldeter Benutzer -> `/favorites` (`list`, `create`, `update`, `remove`, `:id/icon`) | Kennung im Pfad und `widgetId` im Rumpf sind frei waehlbare Eingaben; der Mandant kommt aus `extractContext` (Guard-Kennung, wie `dashboard`), die Benutzerkennung aus dem Sitzungsnachweis. Die Regel auf `FavoriteLink` kennt keine Benutzerdimension. |
+| `FavoriteLink.widgetId` -> `WidgetInstance.id` (Fremdschluessel) | PostgreSQL prueft referentielle Integritaet AM ZEILENSCHUTZ VORBEI: ein Bezug auf ein unsichtbares Widget wird auf Datenbankebene nicht gesperrt (Pruefung 7 misst). |
+| Favoriten-Dienst -> Icon-Proxy -> Internet | `discoverFavoriteIconUrl` nimmt die Nutzer-URL fuer einen SSRF-gesicherten Abruf (T-08-05); `fetchIconBytes` nimmt NUR die gespeicherte URL einer Zeile, die der Aufrufer besitzt (T-QFIP-01). Kein Datenbankzugriff, kein Mandantenzustand. |
+| ADMIN / SUPER_ADMIN -> `/settings/smtp` | `req.tenantId` (fuer SUPER_ADMIN per `x-tenant-id` umschaltbar) ist hier die GEWOLLTE Quelle (D-10); Zugangsdaten verschluesselt (AES-256-GCM), nie im GET. |
+| `tender-mail.service.ts` / `dkv-mail.service.ts` -> `getDecryptedSmtpConfig(tenantId)` | Der Mandant kommt aus der gebundenen Schleife des Aufrufers; der entschluesselte Wert lebt nur im Methodenrumpf (T-07-10). |
+| `mail.module.ts` (Start) -> Startpfad -> `MailerService` | Kein Mandantenkontext; eine BELIEBIGE Zeile wird zum Transport ALLER Systemmails; nach dem Scharfschalten `null` -> Rueckfallkette -> `localhost:1025`. |
+| API -> PostgreSQL, Tabellen `FavoriteLink`/`SmtpConfig` | Regel `"tenantId" = current_tenant_id()` (20260909140000); nach dem Scharfschalten liefert ein ungebundener Zugriff null Zeilen (Pruefungen 3/5), ein ungebundenes `upsert` scheitert am Eindeutigkeitsindex (Pruefung 8). |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-GWH-01 | Information Disclosure | `settings.service.ts` `getSmtpConfig`/`getDecryptedSmtpConfig`, Mandantengrenze | high | mitigate | Ohne Bindung liest ein ADMIN von Mandant A ueber eine gefaelschte oder falsch aufgeloeste Mandantenkennung die (verschluesselten bzw. entschluesselten) SMTP-Zugangsdaten von B; nach dem Scharfschalten sind ungebundene Zugriffe leer statt fremd. Gebundener Klient je Methode; Pruefung 7 (fremder Mandant: `null`) ueber den generierten Client; Testfaelle (fremder Mandant: `null`, `decrypt` nicht aufgerufen); Falsifizierung (c). |
+| T-GWH-02 | Tampering / Elevation of Privilege | `favorites.service.ts` `update`/`remove`/`getIconBytes`, Mandantengrenze | high | mitigate | Ein Nutzer von Mandant A aendert oder loescht ueber die Kennung eine Favoritenzeile von Mandant B. Heute schuetzt nur der `userId`-Vergleich; nach der Bindung liefert die Vorpruefung `null` (Pruefung 5), ein gebundenes `delete` ueber die Kennung allein wirft (Pruefung 6). Beide Zugriffe auf DEMSELBEN Klienten; Testfaelle (fremder Mandant: `NotFoundException`, kein Schreibzugriff); Falsifizierung (a). |
+| T-GWH-03 | Elevation of Privilege / Information Disclosure | `mail.module.ts` Startpfad, Nutzung fremder Zugangsdaten | high | transfer | HEUTE bei mehreren Mandanten: der SMTP-Server und Absender EINES beliebigen Mandanten tragen Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (Nutzung fremder Zugangsdaten, Absenderfaelschung aus Sicht des Empfaengers). Kein Bindungsproblem — beim Start gibt es keinen Mandanten; der Umbau auf Transport je Versand ist eine Funktion (Vorlage: `DkvMailService`/`TenderMailService`). Gemessen (Pruefung 4), benannt (Kopfkommentar beider Zustaende, (s4)(a)), als OFFENER Ledger-Eintrag mit konkretem Reparaturweg uebergeben. Heute ein Mandant — die Bedingung tritt mit dem zweiten ein. |
+| T-GWH-04 | Denial of Service (still) | `mail.module.ts` Rueckfallkette nach dem Scharfschalten | high | transfer | `null` -> `MAIL_*` -> `TESSERA_SMTP_*` -> `localhost:1025`; `MailService` faengt den Transportfehler, der Controller antwortet 200: Kennwort-Zuruecksetzung kommt nie an, ohne Protokollzeile beim Start. Gemessen (Pruefung 3), beide Zustaende an drei Stellen benannt, Signal fuer `rls-preflight.mjs` (Etappe 4) benannt; NICHT in diesem Lauf behoben (kein Umbau des Transports, kein Schalter). |
+| T-GWH-05 | Information Disclosure | `favorites.service.ts` `create`, Fremdschluessel auf `WidgetInstance` | medium | mitigate | Der Unterschied zwischen "Widget gibt es nicht" (FK-Verletzung, 500) und "gehoert einem fremden Mandanten" (gelingt, weil der Fremdschluessel am Zeilenschutz vorbei prueft) ist ein Existenzorakel ueber Mandantengrenzen; dazu haengt eine fremde Zeile am Widget eines anderen (Kaskade). Pruefung 7 misst; Besitzriegel vor dem Anlegen auf dem gebundenen Klienten mit EINER Antwort (`Widget not found`) fuer alle drei Faelle; Testfaelle; Falsifizierung (b). |
+| T-GWH-06 | Denial of Service (still) / Tampering | `saveSmtpConfig` unter der ungebundenen Form | medium | mitigate | Leeres Formular nach dem Scharfschalten verleitet zum Neueintrag; das ungebundene `upsert` findet die eigene Zeile nicht und scheitert am Eindeutigkeitsindex (Pruefung 8, Konstruktor/`code` gemessen). Gebundenes `upsert` trifft die eigene Zeile (Pruefung 9); Testfall. Die Frontend-Kette bleibt als Ledger-Eintrag (Familie #28). |
+| T-GWH-07 | Server-Side Request Forgery | `icon-discovery.service.ts` | medium | accept | Nutzer-URL fuer die Icon-Suche; SSRF-Schutz (private IP-Bereiche, gesperrte Hostnamen, manuelle Weiterleitung, Zeitueberschreitung) besteht seit T-08-05 und ist in `icon-discovery.service.spec.ts` getestet; `fetchIconBytes` nimmt nur die gespeicherte URL einer eigenen Zeile (T-QFIP-01). Kein Datenbankzugriff, kein Mandantenbezug (Befund G) — unveraendert, nur gelesen. Der Besitzriegel in `create` steht VOR der Icon-Suche, damit ein fremdes Widget keinen Abruf ausloest. |
+| T-GWH-08 | Information Disclosure | `FavoriteLink`, Kollege desselben Mandanten | low | accept | Die Regel kennt keine Benutzerdimension: auf Datenbankebene sieht ein gebundener Klient die Favoriten des Kollegen (Pruefung 4, zweite Aussage). Die anwendungsseitigen `userId`-Filter bleiben; Etappe-3-Entscheidung (2). Bestehend, benannt in (f4)(a). |
+| T-GWH-09 | Repudiation / Information Disclosure | `favorites-widget.tsx`, `smtp-settings-form.tsx` | medium | accept | Die umgekehrte Fehlerrichtung ist verschluckt: `[]` wird zu `Noch keine Favoriten.`, `null` wird zu einem leeren Formular. Gemessen (Pruefungen 3/5), in (f3)/(s3) beschrieben, je ein Ledger-Eintrag (Familie #23/#25/#26/#28), Etappe-4-Vorabpruefung benannt. Das Frontend wird nicht geaendert (Umfang). |
+| T-GWH-10 | Spoofing | `favorites.controller.ts`, Mandantenquelle | low | accept | `extractContext` liest die Guard-Kennung (fuer SUPER_ADMIN umschaltbar) — dieselbe Quelle wie `dashboard`, unter dessen Bindung das referenzierte Widget liegt; eine abweichende Quelle wuerde Widget und Link trennen. Kein Mandantenfeld im DTO (gemessen). Bewusst NICHT das Claim, mit Grund in (f4)(c). |
+| T-GWH-11 | Denial of Service | `settings.service.spec.ts` -> `nodemailer` | low | mitigate | Ein echter Transport im Test liefe gegen `mailhog` (lokal nicht vorhanden: `ENOTFOUND`). `vi.mock('nodemailer')` ersetzt `createTransport`; kein Test oeffnet eine Verbindung; gegatet. |
+| T-GWH-SC | Tampering | Paketinstallation | low | accept | Dieser Plan installiert kein Paket (npm/pip/cargo) und fuegt keine Abhaengigkeit hinzu. Das Legitimitaets-Gate faellt nicht an; ausdruecklich festgehalten statt schweigend ausgelassen. |
+
+
+
+
+
+Nach Abschluss aller drei Aufgaben:
+
+1. `node apps/api/scripts/rls-scratch-check.mjs` meldet alle Pruefungen
+ bestanden (120 bisherige plus mindestens 8 + 9 neue, mindestens 137),
+ Rueckgabewert 0; `runFavoritesAreaChecks` und `runSettingsAreaChecks`
+ stehen nach `runAuthAreaChecks` und vor `runTransactionShapeMeasurement`.
+2. `npm --prefix apps/api run test` meldet mehr als 951 Tests gruen in
+ mindestens 62 Dateien (60 bisherige plus `favorites.service.spec.ts` und
+ `settings.service.spec.ts`).
+3. `npm --prefix apps/api run type-check` ist sauber.
+4. `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts`
+ ist gruen — `favorites.service.ts`/`favoriteLink` steht auf `gebunden`,
+ `settings.service.ts`/`smtpConfig` auf `gemischt`, die neue Fundstelle
+ `favorites.service.ts`/`widgetInstance` (falls gebaut) ist eingetragen.
+5. In `favorites.service.ts`: kein ungebundener Favoritenzugriff (auch nicht
+ in Kommentaren), sieben gebundene, fuenf Aufrufstellen des
+ Bindungshilfsmittels, ein Klient je Methode, `Widget not found`. In
+ `settings.service.ts`: genau EIN ungebundener SMTP-Zugriff (der Startpfad,
+ `findFirst()` ohne Bedingung, in `loadAnySmtpConfigForStartupTransport()`),
+ drei gebundene, kein gebundener `findFirst`; der alte Name kommt in
+ `apps/api/src` nicht mehr vor; `mail.module.ts` nennt beide Zustaende und
+ die Ledger-Nummer.
+6. `git diff --name-only 46f0e78 -- apps/api/prisma` ist leer — Schema und
+ Migrationen unangetastet; keine Compose- oder Umgebungsdatei geaendert.
+7. Alle handgepflegten Stellen der Klassifikation sind maschinell gegatet
+ und gruen; die Zaehlgates LEITEN ihre Werte aus den im Dokument genannten
+ Messanweisungen ab (Uebersichtszeilen, Summenzeile ueber alle Bereiche,
+ Paarzahl aus der Bestandsaufnahme-Tabelle).
+8. Der Umfang ist als ERLAUBNISLISTE gegatet: jede Datei, die sich
+ gegenueber `46f0e78` geaendert hat, ist eine der elf in `files_modified`
+ genannten (oder liegt unter `.planning/`). Unter `apps/api/prisma`,
+ `apps/web`, `apps/api/src/settings/settings.controller.ts`,
+ `apps/api/src/favorites/icon-discovery.service.ts`,
+ `apps/api/src/mail/mail.service.ts`, `apps/api/src/tenders`,
+ `apps/api/src/dkv` und den DTOs hat sich nichts geaendert.
+9. `.planning/WINDOWS.md`: neue offene Eintraege in Tabelle UND JSON-Block,
+ Kopfzaehler stimmen mit den Zeilen ueberein; der Platzhalter `TBD-GWH`
+ kommt nirgends mehr vor.
+10. Befund K steht an drei Stellen als ERFUELLT: (t4) Nachtrag, (d4)
+ Nachtrag, Hintergrunddienst-Abschnitt der Klassifikation.
+11. `docs/mandantentrennung-etappe2-fehlerrichtung.md` traegt
+ `## Etappe 2 — Abschluss` mit den aus den eigenen Messanweisungen
+ abgeleiteten Zahlen (Summenzeile, Paarzahl), zwischen `## Bereich
+ settings` und `## Verweis`.
+12. `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera`; nichts in
+ Active Directory; kein echter SMTP-Transport in irgendeinem Test.
+
+
+
+
+
+- Beide Bereiche sind gemessen, bevor gebaut wurde: zwei neue
+ Werkzeug-Abschnitte ueber den generierten Client, mit Fremdschluessel
+ (favorites) und Eindeutigkeitsindex (settings) als mitgebauten
+ Voraussetzungen der Messung.
+- Der Startpfad des Mailmoduls ist als sechster Fall der
+ Hintergrunddienst-Falle erkannt, in beiden Zustaenden gemessen, umbenannt,
+ markiert (Kopfkommentar, Modulkommentar, Klassifikation, Kritikschrift,
+ Ledger) und NICHT gebunden, NICHT umgebaut; die Unsymmetrie zu `ldap` und
+ `dkv` (Rueckfallkette, keine Protokollzeile) und die Entscheidung zum
+ eigenen Ledger-Eintrag sind mit Grund aufgeschrieben.
+- Befund K ist erfuellt und steht als erfuellt an allen drei Stellen, so dass
+ die Etappe-4-Vorabpruefung keine veraltete Bedingung fuehrt.
+- Alle Anfragewege beider Bereiche laufen ueber genau EINEN Klienten
+ `tenantPrisma` je Methode; die Besitzpruefungen bleiben; `create` prueft das
+ Widget; beide Controller-Entscheidungen zur Mandantenquelle sind begruendet
+ (dashboard-Praezedenzfall fuer favorites, ADMIN-Seite fuer settings).
+- Die umgekehrte Fehlerrichtung ist je Bereich in ihrer tatsaechlichen
+ Auspraegung benannt (`Noch keine Favoriten.`; leeres Formular ueber die
+ 200-leerer-Rumpf-Kette; Speicherkonflikt gemessen) und als Ledger-Eintrag
+ je Bereich festgehalten.
+- Zwei neue Testdateien mit dem Zwei-Klienten-Nachbau, ohne echten
+ Transport; sechs Falsifizierungsnachweise (vier in Aufgabe 2, zwei an den
+ Dokument-Gates in Aufgabe 3) durchgefuehrt, zurueckgenommen, woertlich
+ notiert.
+- Etappe 2 ist vollstaendig: die fuenf handgepflegten Dokumentstellen stehen
+ auf ihrem Endstand, deriviert gegatet; die Kritikschrift hat einen
+ Abschluss-Abschnitt aus ihren eigenen Zahlen; die Anleitung widerspricht
+ dem Stand nicht mehr; Erlaubnisliste gegen `46f0e78`; Baseline gehalten
+ nach jeder Aufgabe; Schalter aus; Schema, Migrationen, Frontend, Active
+ Directory unberuehrt.
+
+
+
+