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