--- 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. Nach jeder Aufgabe committen (Praefix `feat(260911-gwh): ...` fuer Aufgabe 2, `docs(quick-260911-gwh): ...` fuer Aufgabe 1 und 3, wie die Vorgaenger), nach dem letzten Commit pushen (schlichtes `git push`, die Push-URL zeigt auf `localhost:3002`). Bei jedem Commit, der eine Datei loescht oder mehrere Pfade zugleich hinzufuegt, `git status --porcelain` VOR und NACH `git add` ansehen (Fehler 11 des Vorhabens). Am Ende `.planning/quick/260911-gwh-mandantentrennung-etappe-2-bereiche-favo/260911-gwh-SUMMARY.md` anlegen: tatsaechlich gezaehlte Pruefungs- und Testzahlen je Aufgabe, das Ergebnis von Pruefung 7 (Fremdschluessel) und Pruefung 8 (Konfliktform) mit Konstruktor/`code` woertlich, alle sechs Falsifizierungsnachweise mit Testname und Meldung woertlich, jede Abweichung von den Planungsbefunden ausdruecklich, die Ledger-Nummern und die Entscheidung zu #21 mit Grund, und ein Absatz, der den Endstand der Etappe 2 in Zahlen nennt (aus dem Abschluss-Abschnitt der Kritikschrift, nicht neu gerechnet). Kein naechster Bereichs-Lauf: Etappe 2 ist abgeschlossen; was folgt, ist Etappe 3 (Kennzeichnung der uebergreifenden Stellen, Benutzerdimension, Anmeldeweg) und die Etappe-4-Vorabpruefung.