Files

116 KiB

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260911-gwh 01 execute 1
true
WINDOWS-18
ETAPPE-2-FAVORITES
ETAPPE-2-SETTINGS
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
tokens raw_tokens tasks confidence
210000 210000 3 low
truths artifacts key_links
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`.
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)
`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).

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

@.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

<planning_time_findings>

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 <hard_constraints>. 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).

</planning_time_findings>

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<f&&f<s&&s<t)){print "REIHENFOLGE in main(): runFavoritesAreaChecks und runSettingsAreaChecks muessen nach runAuthAreaChecks und vor runTransactionShapeMeasurement stehen"; exit 1} }' "$S" && D=docs/mandantentrennung-etappe2-fehlerrichtung.md && grep -q '^## Bereich favorites$' "$D" && grep -q '^## Bereich settings$' "$D" && for X in f1 f2 f3 f4 f5 s1 s2 s3 s4 s5; do grep -qE "^### ($X) " "$D" || { echo "FEHLENDER UNTERABSCHNITT: ($X)"; exit 1; }; done && awk '/^## Bereich favorites$/{f=1; next} /^## /{f=0} f && /Noch keine Favoriten./{e=1} f && /favorites-widget.tsx/{w=1} f && /favorites-api.ts/{a=1} f && /icon-discovery.service.ts/{i=1} f && /WidgetInstance/{r=1} f && /dashboard.controller.ts/{c=1} END{ if(!e||!w||!a){print "(f3): die Frontend-Kette (favorites-widget.tsx, favorites-api.ts, Noch keine Favoriten.) ist nicht benannt"; exit 1} if(!i){print "(f5): der Icon-Proxy ist nicht benannt"; exit 1} if(!r){print "(f1): der Fremdschluessel auf WidgetInstance ist nicht benannt"; exit 1} if(!c){print "(f4): die Mandantenquelle / der dashboard-Praezedenzfall ist nicht benannt"; exit 1} }' "$D" && awk '/^## Bereich settings$/{f=1; next} /^## /{f=0} f && /mail.module.ts/{m=1} f && /smtp-settings-form.tsx/{w=1} f && /settings-api.ts/{a=1} f && /tender-mail.service.ts/{t=1} f && /dkv-mail.service.ts/{d=1} f && /Befund K/{k=1} f && /#21/{l=1} f && /SmtpConfig_tenantId_key/{u=1} f && /localhost:1025/{p=1} f && /req.tenantId/{q=1} END{ if(!m||!p){print "(s4): der Startpfad / die Rueckfallkette (mail.module.ts, localhost:1025) ist nicht benannt"; exit 1} if(!w||!a){print "(s3): die Frontend-Kette (smtp-settings-form.tsx, settings-api.ts) ist nicht benannt"; exit 1} if(!t||!d||!k){print "(s2)/(s4): Befund K mit beiden Versandpfaden ist nicht benannt"; exit 1} if(!l){print "(s4): die Entscheidung zu WINDOWS #21 ist nicht benannt"; exit 1} if(!u){print "(s1): der Eindeutigkeitsindex ist nicht benannt"; exit 1} if(!q){print "(s4): die Mandantenquelle des Controllers ist nicht benannt"; exit 1} }' "$D" && awk '/^## Bereich settings$/{f=1} /^## Verweis$/{ if(f){ok=1} f=0 } END{ if(!ok){print "ABSCHNITT ## Bereich settings steht nicht unmittelbar vor ## Verweis"; exit 1} }' "$D" && 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" -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 <behavior>. 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 <behavior> 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 #<Nr.>): 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<a&&a<v)){print "ABSCHLUSS-ABSCHNITT steht nicht zwischen Bereich settings und Verweis"; exit 1} }' "$D" && awk -v tu="$TU" -v tb="$TB" -v p="$P" '/^## Etappe 2 — Abschluss$/{f=1; next} /^## /{f=0} f && index($0, tu){u=1} f && index($0, tb){b=1} f && index($0, p " Paare"){q=1} f && /rls-preflight.mjs/{r=1} f && /Etappe 3/{e=1} f && /WINDOWS/{w=1} END{ if(!u||!b){print "ABSCHLUSS: die abgeleitete Summenzeile (" tu "/" tb ") ist nicht genannt"; exit 1} if(!q){print "ABSCHLUSS: die Paarzahl (" p " Paare) ist nicht genannt"; exit 1} if(!r||!e||!w){print "ABSCHLUSS: Etappe 3, rls-preflight.mjs oder die Ledger-Eintraege sind nicht genannt"; exit 1} }' "$D" && awk '/^### (t4)/{f=1} /^### (t5)/{f=0} f && /Nachtrag (260911-gwh)/{n=1} END{ if(!n){print "(t4): Nachtrag unter Befund K fehlt"; exit 1} }' "$D" && awk '/^### (d4)/{f=1} /^### (d5)/{f=0} f && /Nachtrag (260911-gwh)/{n=1} END{ if(!n){print "(d4): Nachtrag im Uebergaben-Absatz fehlt"; exit 1} }' "$D" && A=docs/anleitung-entwicklung.md && test 0 -eq "$(grep -c 'FavoriteLink— tragen zwar einetenantId-Spalte, aber \*\*keine\*\*' "$A")" && test 0 -eq "$(grep -c 'nutzt den plain, UNGEBUNDENEN' "$A")" && grep -q '20260909140000_rls_remaining_tenant_tables' "$A" && grep -q 'tenantPrisma' "$A" && W=.planning/WINDOWS.md && OC=$(sed -nE 's/^open_count: ([0-9]+)$/\1/p' "$W") && TC=$(sed -nE 's/^total_count: ([0-9]+)$/\1/p' "$W") && ROWS=$(grep -cE '^\| [0-9]+ \| ' "$W") && { test "$ROWS" -eq "$TC" || { echo "WINDOWS: total_count=$TC, Tabellenzeilen=$ROWS"; exit 1; }; } && OPENROWS=$(grep -E '^\| [0-9]+ \| ' "$W" | grep -c '| open |') && { test "$OPENROWS" -eq "$OC" || { echo "WINDOWS: open_count=$OC, offene Zeilen=$OPENROWS"; exit 1; }; } && test "$(grep -cE '^\| [0-9]+ \| quick-260911-gwh \|' "$W")" -ge 2 && grep -q 'favorites-widget\.tsx' "$W" && grep -q 'smtp-settings-form\.tsx' "$W" && 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|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)"</automated> </verify> <done>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.mdsind nachgezogen und DERIVIERT gegatet: Uebersichtszeilenfavorites/settings mit neu gemessenen Zahlen, Summenzeile aus der Messanweisung, Bestandsaufnahme (favoriteLinkgebunden,smtpConfiggemischt,widgetInstanceneu 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 entscheidetmit dem Mehrmandanten-Versand-Punkt. Die Kritikschrift traegt die Nachtraege in (t4) und (d4) und den Abschnitt## Etappe 2 — Abschlussmit 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 nenntFavoriteLinknicht mehr als Tabelle ohne Regel undloadConfig()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 gegen46f0e78` gehalten.

<threat_model>

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.

</threat_model>

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.

<success_criteria>

  • 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.

</success_criteria>

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.