Files

1227 lines
116 KiB
Markdown

---
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."
---
<!-- planner-discipline-allow: this.prisma.favoriteLink -->
<!-- planner-discipline-allow: this\.prisma\.favoriteLink -->
<!-- planner-discipline-allow: this.prisma.smtpConfig -->
<!-- planner-discipline-allow: this\.prisma\.smtpConfig -->
<!-- planner-discipline-allow: getStartupSmtpConfig -->
<!-- planner-discipline-allow: forTenant -->
<!-- planner-discipline-allow: tenantId -->
<!-- planner-discipline-allow: tenantPrisma.findFirst -->
<!-- planner-discipline-allow: findFirst -->
<!-- planner-discipline-allow: PrismaService -->
<!-- planner-discipline-allow: fünf Fälle -->
<objective>
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).
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@docs/mandantentrennung-zugriffsklassifikation.md
@docs/mandantentrennung-etappe2-fehlerrichtung.md
@docs/anleitung-entwicklung.md
@apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql
@apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql
@apps/api/prisma/migrations/20260708090000_add_favorite_link/migration.sql
@apps/api/prisma/migrations/20260629130000_add_missing_tables/migration.sql
@apps/api/prisma/schema.prisma
@apps/api/src/prisma/prisma-tenant.extension.ts
@apps/api/src/prisma/rls-access-inventory.spec.ts
@apps/api/scripts/rls-scratch-check.mjs
@apps/api/src/favorites/favorites.service.ts
@apps/api/src/favorites/favorites.controller.ts
@apps/api/src/favorites/favorites.module.ts
@apps/api/src/favorites/icon-discovery.service.ts
@apps/api/src/favorites/icon-discovery.service.spec.ts
@apps/api/src/favorites/dto/create-favorite.dto.ts
@apps/api/src/favorites/dto/update-favorite.dto.ts
@apps/api/src/settings/settings.service.ts
@apps/api/src/settings/settings.controller.ts
@apps/api/src/settings/settings.module.ts
@apps/api/src/settings/dto/smtp-config.dto.ts
@apps/api/src/mail/mail.module.ts
@apps/api/src/mail/mail.service.ts
@apps/api/src/tenders/tender-mail.service.ts
@apps/api/src/tenders/tender-mail.service.spec.ts
@apps/api/src/dkv/dkv-mail.service.ts
@apps/api/src/dkv/dkv.service.ts
@apps/api/src/dkv/dkv-scheduler.service.ts
@apps/api/src/dkv/dkv.service.spec.ts
@apps/api/src/calendar/calendar.service.spec.ts
@apps/api/src/dashboard/dashboard.service.spec.ts
@apps/api/src/dashboard/dashboard.controller.ts
@apps/api/src/tenant/tenant.guard.ts
@apps/web/src/lib/favorites-api.ts
@apps/web/src/components/dashboard/widgets/favorites-widget.tsx
@apps/web/src/lib/settings-api.ts
@apps/web/src/components/settings/smtp-settings-form.tsx
@apps/web/src/messages/de.json
@.planning/WINDOWS.md
@.planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-PLAN.md
@.planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-SUMMARY.md
</context>
<planning_time_findings>
Alle Zahlen unten sind zur Planungszeit am 2026-09-11 gegen HEAD `46f0e78`
GEMESSEN, mit der jeweils angegebenen Anweisung. Sie leiten die Untersuchung,
sie sind KEINE Bearbeitungsvollmacht — jede Datei wird vor jeder Aenderung
erneut gelesen, jede Zahl zur Ausfuehrungszeit erneut gemessen, und weicht
eine Messung ab, gilt die Messung und nicht dieser Plan. Baseline laut
`260911-fh9-SUMMARY.md`: 951 Tests gruen in 60 Dateien, Typpruefung sauber,
Werkzeug 120/120 — zur Ausfuehrungszeit VOR der ersten Aenderung selbst
nachmessen.
**Befund A — die Zahlen halten: 7/0 und 4/0, beide Bereiche ohne einen
einzigen gebundenen Zugriff.**
`grep -rno "this\.prisma\.[a-zA-Z]*" apps/api/src/favorites apps/api/src/settings | grep -v spec`:
sieben Treffer in `favorites.service.ts` (Zeilen 34 `list`/findMany, 55
`create`/create, 74 `update`/findUnique, 103 `update`/update, 114
`remove`/findUnique, 120 `remove`/delete, 138 `getIconBytes`/findUnique) und
vier in `settings.service.ts` (39 `getSmtpConfig`/findUnique, 74
`saveSmtpConfig`/upsert, 97 `getDecryptedSmtpConfig`/findUnique, 193
`getStartupSmtpConfig`/findFirst). Gebunden: null. Nach diesem Plan:
`favorites` 0 ungebunden / 7 gebunden (8, wenn der Besitzriegel aus Befund F
einen `widgetInstance`-Lesezugriff hinzufuegt); `settings` 1 ungebunden (der
Startpfad) / 3 gebunden. Summenzeile 78 -> 67 und 167 -> 177 (bzw. 178).
Bestandsaufnahme: `favorites.service.ts`/`favoriteLink` von `ungebunden` auf
`gebunden`; `settings.service.ts`/`smtpConfig` von `ungebunden` auf
`gemischt` (dieselbe Form wie `dkv.service.ts`/`dkvModuleConfig`: die
Mischung stammt ausschliesslich vom benannten Startpfad); NEUE Zeile
`favorites.service.ts`/`widgetInstance` (`muss-mandantengebunden`,
`gebunden`), wenn Befund F den Riegel bestaetigt -> 65 Paare, 33/17/13/2.
Alle Zahlen zur Ausfuehrungszeit aus den Messanweisungen des Dokuments
ableiten, nicht von hier abschreiben.
**Befund B — die Regeln sind eindeutig und wortgleich extrahierbar.**
`20260909140000_rls_remaining_tenant_tables` traegt fuer `FavoriteLink`
(Zeile 88-92) und `SmtpConfig` (102-106) je ENABLE + FORCE + `CREATE POLICY
tenant_isolation_policy ... USING ("tenantId" = current_tenant_id())`;
`grep -c "FavoriteLink\|SmtpConfig" 20260910120000_.../migration.sql`: 0 —
die Widen-Migration hat keine eigene Regel fuer beide (Regelstand eindeutig,
Form von `calendarsource-regelstand-eindeutig`). Spalten: `FavoriteLink`
(20260708090000) hat 10 skalare Spalten (`id`, `userId`, `tenantId`,
`widgetId`, `title`, `url`, `iconUrl`, `position`, `createdAt`, `updatedAt`)
und den Fremdschluessel `FavoriteLink_widgetId_fkey` auf `"WidgetInstance"("id")
ON DELETE CASCADE`; `model FavoriteLink` hat zusaetzlich das RELATIONSFELD
`widgetInstance` — die Wegwerf-Tabelle muss ueber
`readSchemaModelScalarFieldNames('FavoriteLink')` (der Helfer aus 260911-fh9)
verglichen werden, nicht ueber `readSchemaModelFieldNames`. `SmtpConfig`
(20260629130000) hat 10 skalare Spalten (`id`, `tenantId`, `host`, `port`,
`encryption`, `username`, `encryptedPassword`, `fromAddress`, `createdAt`,
`updatedAt`) und den Eindeutigkeitsindex `SmtpConfig_tenantId_key` (Zeile
169) — ohne diesen Index misst Pruefung 8 des settings-Abschnitts nichts.
`updatedAt` hat in beiden Migrationen keine Vorgabe; die Wegwerf-Tabellen
bekommen fuer die Roh-SQL-Einfuegungen `DEFAULT CURRENT_TIMESTAMP` mit
Vermerk als Abweichung, die nur das Nachruesten betrifft (Form von
260911-e2s/fh9).
**Befund C — die Wegwerf-Tabelle `WidgetInstance` existiert bereits, ohne
`createdAt`/`updatedAt`, mit drei Zeilen.** `runDashboardAreaChecks`
(`rls-scratch-check.mjs:2461-2467`) legt `"WidgetInstance"` mit `id`,
`userId`, `tenantId`, `widgetType` an und fuegt `widget-a1` (user-a1,
TENANT-A), `widget-a2` (user-a2, TENANT-A), `widget-b1` (user-b1, TENANT-B)
ein; der gebundene Loeschversuch unter TENANT-A auf `widget-b1` (Zeile 2687)
trifft null Zeilen, `widget-rejected` wird abgewiesen — alle drei Zeilen
sollten stehen, wenn der neue Abschnitt laeuft: zur Ausfuehrungszeit ueber
die Wartungsrolle NACHMESSEN, nicht annehmen. Der Fremdschluessel der
Wegwerf-Tabelle `FavoriteLink` zeigt auf DIESE Tabelle (WINDOWS #27:
Relationen sind fuer die Bestandsaufnahme unsichtbar — hier wird die
Relation deshalb ausdruecklich mitgebaut und gemessen). Der neue Abschnitt
ist ein Blatt: NACH `runAuthAreaChecks`, VOR `runTransactionShapeMeasurement`;
keine spaetere Pruefung setzt auf seinen Tabellen auf.
**Befund D — der Startpfad, Glied fuer Glied gelesen.**
`settings.service.ts:193` `findFirst()` ohne jede Bedingung; einziger
Aufrufer `mail.module.ts:30` in `MailerModule.forRootAsync({ useFactory:
async ... })` — BEIM START, vor jedem Anfragekontext, Kommentar dort:
"findFirst — single-tenant default". Ergebnis wird zum EINEN Transport des
`MailerService`, den `MailService.sendPasswordResetEmail` (einziger Aufrufer
ausserhalb des Mailmoduls: `auth.service.ts:246`, `requestPasswordReset`)
und `sendWelcomeEmail` benutzen. HEUTE bei mehreren Mandanten: der
SMTP-Server und die Absenderadresse eines BELIEBIGEN Mandanten bedienen die
Kennwort-Zuruecksetzungs-Mails ALLER Mandanten — Mandant B versendet ueber
den Server von Mandant A, mit dessen Absender (das ist Nutzung fremder
Zugangsdaten, nicht nur Sichtbarkeit; T-GWH-03). NACH DEM SCHARFSCHALTEN:
`findFirst` liefert `null` -> `mail.module.ts` faellt auf Prioritaet 2
(`MAIL_*`), 3 (`TESSERA_SMTP_*`), 4 (`localhost:1025`, Mailhog) zurueck ->
`MailService` faengt jeden Transportfehler (`mail.service.ts:69-81`, T-02-12)
und der Controller antwortet 200. Das Verstummen ist damit DOPPELT verdeckt:
erst durch die Rueckfallkette (ein falscher, aber vorhandener Transport statt
Leere), dann durch das absichtliche Verschlucken im Versand. Das ist die
UNSYMMETRIE zu den beiden Praezedenzfaellen: `getAllActiveConfigs` (ldap)
ist heute korrekt und verstummt spaeter; `loadAnyActiveConfigForScheduler`
(dkv, #21) ist heute falsch und verstummt spaeter mit Protokollzeile; der
Mail-Startpfad ist heute falsch und verstummt spaeter OHNE Protokollzeile,
weil eine Rueckfallkette ihn ueberdeckt. ENTSCHEIDUNG (zur Ausfuehrungszeit
nach der Lesung zu bestaetigen oder mit Grund zu verwerfen): EIGENER
Ledger-Eintrag, nicht Anschluss an #21 — #21 ist gegen
`dkv-scheduler.service.ts` gefuehrt, seine Reparatur ist die
Mehrmandanten-Planung (Auftraege je Mandant); die Reparatur HIER ist eine
andere Funktion mit bereits vorhandener Vorlage im Code: Transport je Versand
aus `getDecryptedSmtpConfig(tenantId)` bauen, wie `DkvMailService` und
`TenderMailService` es seit Phase 07/12 tun — `requestPasswordReset` kennt
den Mandanten aus der Funktionszeile, `MailService` muesste ihn nur
entgegennehmen. Das ist ein Feature (Umbau des Mailmoduls), NICHT dieser
Auftrag — steht so im Auftrag und in `<hard_constraints>`. Umbenennung nach
dem dkv-Praezedenzfall: `loadAnySmtpConfigForStartupTransport()`; kein Dokument
unter `docs/` nennt den alten Namen (`grep -rn getStartupSmtpConfig docs`:
null), die Phasen-Artefakte unter `.planning/phases` sind Geschichte und
werden nicht angefasst.
**Befund E — der Versandpfad und Befund K, Glied fuer Glied.**
`tender-mail.service.ts:158` (`resolveTransport`, privat; bei `null`:
`logger.warn(... skipping tender mail send (will retry next run))`, kein
Wurf) und `dkv-mail.service.ts:47` (`sendExportEmail`; bei `null`: `throw new
Error('No SMTP configuration found for tenant ...')`) rufen
`getDecryptedSmtpConfig(tenantId)`; der Mandant kommt bei beiden aus der
gebundenen Schleife des aufrufenden Bereichs (260909-laa, 260909-mir).
`testSmtpConfig` ruft sie ebenfalls (Rueckgriff auf gespeicherte
Zugangsdaten) und hat KEINEN eigenen Datenbankzugriff. Die
Reihenfolgebedingung steht an zwei Stellen der Kritikschrift ((t4) Befund K,
Zeile 625-634; (d4) Uebergaben-Absatz, Zeile 935-944) und NIRGENDS im
Klassifikationsdokument (`grep -n "settings" docs/mandantentrennung-zugriffsklassifikation.md`:
nur Uebersichtszeile 147 und Bestandsaufnahme 453). Nach diesem Plan: an
beiden Stellen ein NACHTRAG (Form von (e) Befund D, Zeile 143-155: Original
bleibt stehen, Nachtrag darunter), und der neue Hintergrunddienst-Absatz im
Klassifikationsdokument sagt, dass die Bedingung mit 260911-gwh erfuellt ist.
**Befund F — die Besitzpruefungen: dreimal richtig, einmal fehlend, und der
Fremdschluessel umgeht den Zeilenschutz.** `update`, `remove`, `getIconBytes`
tun `findUnique({ where: { id } })`, vergleichen `link.userId !== userId`, und
schreiben/loeschen dann ueber die Kennung — nach der Bindung auf DEMSELBEN
Klienten ist das die richtige Form (fremde Zeile: `findUnique` null ->
`NotFoundException`; dkv Befund G — ein gebundenes UPDATE/DELETE ueber die
Kennung allein traefe eine fremde Zeile still — greift nicht, weil die
Vorpruefung davor steht; mit dem generierten Client wirft `delete` bei null
Zeilen zudem P2025, Pruefung 6 misst das). `create` dagegen prueft NICHT, ob
`dto.widgetId` dem Aufrufer gehoert: es schreibt `userId`/`tenantId` des
Aufrufers und `widgetId` aus dem Rumpf. PostgreSQL prueft Fremdschluessel AN
DER ZEILENSCHUTZ-REGEL VORBEI (dokumentiertes Verhalten: referentielle
Integritaet umgeht Row Security) — ein gebundenes `create` unter TENANT-A mit
`widgetId` eines TENANT-B-Widgets wuerde demnach GELINGEN, und der
Unterschied zwischen "Widget existiert nicht" (FK-Verletzung, 500) und
"existiert bei einem fremden Mandanten" (gelingt) ist ein Existenzorakel
ueber Mandantengrenzen (T-GWH-05, medium). Pruefung 7 MISST das, statt es
anzunehmen; faellt sie so aus, baut Aufgabe 2 einen Besitzriegel in `create`
ein: gebundener `widgetInstance.findUnique({ where: { id: dto.widgetId },
select: { userId: true } })` auf DEMSELBEN `tenantPrisma`, `null` oder
fremde Benutzerkennung -> `NotFoundException('Widget not found')` (ohne
Aussage ueber fremde Mandanten). Das fuegt ein NEUES (Datei, Modell)-Paar
hinzu (Befund A). Faellt Pruefung 7 anders aus, gilt die Messung, der Riegel
entfaellt, und das steht mit Belegzeile im SUMMARY.
**Befund G — der Icon-Proxy erreicht die Datenbank NICHT und nimmt KEINE
Client-URL.** `grep -n "prisma\|Prisma" icon-discovery.service.ts`: null
Treffer; `getIconBytes` holt nur die GESPEICHERTE `iconUrl` einer Zeile, die
der Aufrufer besitzt (T-QFIP-01, Controller `GET /favorites/:id/icon` nimmt
nur die Kennung); `discoverFavoriteIconUrl(url)` bei `create`/`update` nimmt
die Nutzer-URL, aber nur fuer den SSRF-gesicherten Abruf (privater
IP-Bereich, gesperrte Hostnamen, manuelle Weiterleitung — T-08-05,
`icon-discovery.service.spec.ts` deckt das). Mandantenbezug: keiner — die
URL ist nicht mandantenskopiert, der Dienst haelt keinen Zustand je Mandant.
Ergebnis: der Proxy bleibt UNVERAENDERT; in der Testdatei ist er eine
Attrappe (`discoverFavoriteIconUrl`, `fetchIconBytes` als `vi.fn`), und der
Test nagelt fest, dass `fetchIconBytes` mit der GESPEICHERTEN URL aufgerufen
wird, nie mit einer aus dem Aufruf.
**Befund H — die Mandantenquelle je Bereich, beide unterschiedlich und beide
richtig.** `favorites.controller.ts` `extractContext`: `req.tenantId ??
req.user?.tenantId` — WORTGLEICH mit `dashboard.controller.ts:44-45`, unter
dessen Bindung `WidgetInstance` liegt. `FavoriteLink` haengt am Widget;
wuerde `favorites` an das Claim binden (wie `auth` fuer Selbstbedienung),
`dashboard` aber an die Guard-Kennung, laegen Widget und Link unter einem
`x-tenant-id`-Wechsel eines SUPER_ADMIN in verschiedenen Mandanten.
`favorites-api.ts` sendet die Kopfzeile nicht (`grep -rn "x-tenant-id"
apps/web/src`: nur die vier Marktplatz-Stellen) — die Entscheidung haengt an
der Bauform, nicht am heutigen Aufrufer. ENTSCHEIDUNG: `extractContext`
bleibt, alle fuenf Aufrufe reichen `tenantId` durch; Kopfkommentar nennt den
dashboard-Praezedenzfall und warum NICHT der auth-Praezedenzfall.
`settings.controller.ts` liest `req.tenantId` — fuer eine
ADMIN-Konfigurationsseite ist das die richtige Quelle (D-10: SUPER_ADMIN
konfiguriert ueber `x-tenant-id` einen anderen Mandanten); der Controller
bleibt UNVERAENDERT und steht nicht in der Erlaubnisliste.
**Befund I — die umgekehrte Fehlerrichtung je Bereich, gelesen.**
(1) `favorites`: `list` liefert `[]` -> `GET /favorites?widgetId=` 200 `[]`
-> `fetchFavorites` (`favorites-api.ts:22-29`) gibt `[]` -> `favorites-widget.tsx:212-213`
zeigt `t('favorites.empty')` = `Noch keine Favoriten.` (`de.json`, Zeile um
219). "Zeile unsichtbar" und "nie einen gespeichert" sind fuer das Frontend
derselbe Wert — Familie #23/#25/#26/#28. `update`/`remove`/`getIconBytes`:
`NotFoundException('FavoriteLink not found')` 404 -> `favorites-api.ts` wirft
`Failed to update/delete favorite` -> `catch` im Widget setzt
`t('favorites.error')`. (2) `settings`: `getSmtpConfig` liefert `null` ->
Controller gibt `null` zurueck -> NestJS sendet 200 mit LEEREM Rumpf (die
Adapter-Kette aus (h3), 260911-fh9) -> `fetchSmtp` (`settings-api.ts:51-57`)
prueft nur `404` und `!res.ok`, dann `res.json()` auf den leeren Rumpf ->
wirft -> `smtp-settings-form.tsx:76-78` `.catch(() => { /* Silent fail */ })`
-> leeres Formular: "SMTP nicht eingerichtet", waehrend die Zugangsdaten
physisch da sind. Traegt der Administrator sie neu ein, laeuft `saveSmtpConfig`
als `upsert({ where: { tenantId } })`: unter der UNGEBUNDENEN Form (heute, nach
dem Scharfschalten) ist die Zeile unsichtbar, der Upsert versucht INSERT und
scheitert am Eindeutigkeitsindex `SmtpConfig_tenantId_key` — die
`dashboard`-Lehre (260910-krx: `PrismaClientUnknownRequestError`, NICHT
P2002). Pruefung 8 misst Konstruktorname und `code` woertlich, statt sie
vorwegzunehmen. `getDecryptedSmtpConfig` null: tender `warn` + kein Versand
mit Wiederholung je Lauf, dkv `throw` (Befund E). (3) Startpfad: Befund D.
Etappe-4-Vorabpruefung je Bereich: fuer einen bekannten Mandanten die
`SmtpConfig`-Zeile ueber die Wartungsrolle lesen und den gebundenen
`findUnique` daneben halten; fuer einen bekannten Nutzer/Widget die
Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen `findMany`.
**Befund J — Testlage: eine Spec fuer den Icon-Proxy, keine fuer die beiden
Dienste; Vorlagen stehen bereit.** `ls apps/api/src/favorites/`: nur
`icon-discovery.service.spec.ts`; `ls apps/api/src/settings/`: keine Spec.
Vorlagen: `calendar.service.spec.ts` (260911-cwh — Bereich ohne Testdatei,
`makeFakePrisma` mit In-Memory-Zeilen, `__makeBoundClient`, `_applySelect`,
`boundCallLog`), `dkv.service.spec.ts` (ungebundener Nachbau OHNE die
Anfrage-Modelle), `dashboard.service.spec.ts:565` (Wachhund),
`tender-mail.service.spec.ts:22-30` (`makeSettingsService`-Attrappe: zeigt,
welche Rueckgabeform die Aufrufer erwarten — `host`, `port`, `encryption`,
`username`, `fromAddress`, `decryptedPassword`). `nodemailer` wird in der
neuen settings-Spec per `vi.mock('nodemailer')` ersetzt (`createTransport`
liefert `{ verify: vi.fn(), sendMail: vi.fn() }`) — KEIN echter Transport
(Umgebung: kein `mailhog`). `CryptoService` als Attrappe (`encrypt: (s) =>
'enc(' + s + ')'`, `decrypt` strippt).
**Befund K — kein weiterer Hintergrunddienst, keine Transaktion, keine
Relationseinbindung — bis auf den Startpfad.** `grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout\|\$transaction(\|\$queryRaw\|\$executeRaw\|include:\|_count" apps/api/src/favorites apps/api/src/settings --include=*.ts | grep -v spec`:
genau EIN Treffer, `icon-discovery.service.ts:255` — ein `setTimeout` fuer
den Abbruch eines HTTP-Abrufs, kein Planer (dieselbe Form wie
`ics.provider.ts:100` in 260911-cwh). `select:` kommt zweimal vor
(`settings.service.ts:41`, `:78`), beide ausschliesslich skalare Felder von
`SmtpConfig`, keine Relation (WINDOWS #27 ohne Auspraegung). Der einzige
Hintergrund-Zugriff ist der Startpfad aus Befund D — er lebt NICHT in
`settings`, sondern wird von `mail.module.ts` beim Start aufgerufen; als
sechster Fall gehoert er unter `mail.module.ts` /
`SettingsService.loadAnySmtpConfigForStartupTransport()`.
**Befund L — die Buchfuehrung und ein veralteter Absatz in der Anleitung.**
Uebersichtszeilen `favorites` (heute `| favorites | 7 | 0 | unverändert |`)
und `settings` (`| settings | 4 | 0 | unverändert |`), Summenzeile (78/167),
Bestandsaufnahme-Zeilen 431 und 453, Klassen-Verteilung (64 Paare,
32/17/13/2), Hintergrunddienst-Abschnitt (Ueberschrift `## Der
Hintergrunddienst als Falle — fünf Fälle`, Zeile 237; kein Gate ausserhalb
des Dokuments referenziert diesen Wortlaut — `grep -rn "fünf Fälle\|fuenf
Faelle" apps/api/src apps/api/scripts`: null), `Was diese Etappe NICHT
entscheidet` (Zeile 486). Dazu `docs/anleitung-entwicklung.md:310-316`: der
Absatz behauptet, Regeln laegen nur auf sieben Tabellen und `FavoriteLink`
trage KEINE, und `DkvService.loadConfig()` nutze den ungebundenen Klienten —
beides seit `20260909140000` (16 weitere Tabellen, gemessen: `grep -c
"ENABLE ROW LEVEL SECURITY"` liefert 4 + 3 + 16 = 23) bzw. seit 260909-mir
falsch. Der Absatz nennt `FavoriteLink` beim Namen und liegt damit in diesem
Bereich; er wird in Aufgabe 3 auf den gemessenen Stand gebracht (scoped
`Edit`, nur dieser Absatz).
</planning_time_findings>
<tasks>
<task type="tracer">
<name>Aufgabe 1: Die Fehlerrichtung fuer BEIDE Bereiche MESSEN und aufschreiben — Favoritenleiste, SMTP-Zugangsdaten, Startpfad — ueber den generierten Client</name>
<precondition>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.</precondition>
<files>apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md</files>
<read_first>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)</read_first>
<action>
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(<Modell>)` (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`.
</action>
<verify>
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" && for K in favoritelink-regelstand-eindeutig favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei favoritelink-gebundenes-anlegen-eigener-mandant-gelingt smtpconfig-regelstand-eindeutig smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients smtpconfig-startpfad-generierter-client-ungebunden-liefert-null smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert; do echo "$OUT" | grep -q "^$K: bestanden" || { echo "FEHLENDE ODER FEHLGESCHLAGENE PRUEFUNG: $K"; exit 1; }; done && N=$(echo "$OUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden\.$/\1/p') && { test -n "$N" && test "$N" -ge 137 || { echo "PRUEFUNGSZAHL: ${N:-nicht alle bestanden}, erwartet mindestens 137 (120 bisherige plus mindestens 8 + 9 neue)"; exit 1; }; } && S=apps/api/scripts/rls-scratch-check.mjs && grep -q 'async function runFavoritesAreaChecks' "$S" && grep -q 'async function runSettingsAreaChecks' "$S" && grep -q "readSchemaModelScalarFieldNames('FavoriteLink')" "$S" && grep -q "readSchemaModelScalarFieldNames('SmtpConfig')" "$S" && grep -q 'REFERENCES "WidgetInstance"' "$S" && grep -q 'SmtpConfig_tenantId_key' "$S" && grep -q "extractPolicySql(remainingMigrationSql, 'FavoriteLink')\|extractPolicySql(.*'FavoriteLink')" "$S" && grep -q "extractPolicySql(.*'SmtpConfig')" "$S" && awk '/await runAuthAreaChecks\(/{a=NR} /await runFavoritesAreaChecks\(/{f=NR} /await runSettingsAreaChecks\(/{s=NR} /await runTransactionShapeMeasurement\(/{t=NR} END{ if(!(a&&f&&s&&t&&a<f&&f<s&&s<t)){print "REIHENFOLGE in main(): runFavoritesAreaChecks und runSettingsAreaChecks muessen nach runAuthAreaChecks und vor runTransactionShapeMeasurement stehen"; exit 1} }' "$S" && D=docs/mandantentrennung-etappe2-fehlerrichtung.md && grep -q '^## Bereich favorites$' "$D" && grep -q '^## Bereich settings$' "$D" && for X in f1 f2 f3 f4 f5 s1 s2 s3 s4 s5; do grep -qE "^### \($X\) " "$D" || { echo "FEHLENDER UNTERABSCHNITT: ($X)"; exit 1; }; done && awk '/^## Bereich favorites$/{f=1; next} /^## /{f=0} f && /Noch keine Favoriten\./{e=1} f && /favorites-widget\.tsx/{w=1} f && /favorites-api\.ts/{a=1} f && /icon-discovery\.service\.ts/{i=1} f && /WidgetInstance/{r=1} f && /dashboard\.controller\.ts/{c=1} END{ if(!e||!w||!a){print "(f3): die Frontend-Kette (favorites-widget.tsx, favorites-api.ts, Noch keine Favoriten.) ist nicht benannt"; exit 1} if(!i){print "(f5): der Icon-Proxy ist nicht benannt"; exit 1} if(!r){print "(f1): der Fremdschluessel auf WidgetInstance ist nicht benannt"; exit 1} if(!c){print "(f4): die Mandantenquelle / der dashboard-Praezedenzfall ist nicht benannt"; exit 1} }' "$D" && awk '/^## Bereich settings$/{f=1; next} /^## /{f=0} f && /mail\.module\.ts/{m=1} f && /smtp-settings-form\.tsx/{w=1} f && /settings-api\.ts/{a=1} f && /tender-mail\.service\.ts/{t=1} f && /dkv-mail\.service\.ts/{d=1} f && /Befund K/{k=1} f && /#21/{l=1} f && /SmtpConfig_tenantId_key/{u=1} f && /localhost:1025/{p=1} f && /req\.tenantId/{q=1} END{ if(!m||!p){print "(s4): der Startpfad / die Rueckfallkette (mail.module.ts, localhost:1025) ist nicht benannt"; exit 1} if(!w||!a){print "(s3): die Frontend-Kette (smtp-settings-form.tsx, settings-api.ts) ist nicht benannt"; exit 1} if(!t||!d||!k){print "(s2)/(s4): Befund K mit beiden Versandpfaden ist nicht benannt"; exit 1} if(!l){print "(s4): die Entscheidung zu WINDOWS #21 ist nicht benannt"; exit 1} if(!u){print "(s1): der Eindeutigkeitsindex ist nicht benannt"; exit 1} if(!q){print "(s4): die Mandantenquelle des Controllers ist nicht benannt"; exit 1} }' "$D" && awk '/^## Bereich settings$/{f=1} /^## Verweis$/{ if(f){ok=1} f=0 } END{ if(!ok){print "ABSCHNITT ## Bereich settings steht nicht unmittelbar vor ## Verweis"; exit 1} }' "$D" && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -ge 951 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mindestens 951"; exit 1; }; } && npm --prefix apps/api run type-check && git rev-parse --verify 46f0e78 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 46f0e78 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 46f0e78) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
</verify>
<done>`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.</done>
</task>
<task type="auto" tdd="true">
<name>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</name>
<files>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</files>
<read_first>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))</read_first>
<behavior>
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.
</behavior>
<action>
TEIL 1 — `favorites.service.ts`. Stelle alle fuenf Methoden um, je Methode
EIN Klient in der Zuweisungsform, die `rls-access-inventory.spec.ts` erkennt:
Konstante `tenantPrisma` aus dem Bindungshilfsmittel mit `this.prisma` und
dem uebergebenen Mandanten, `as any`. Der Mandant wird ERSTER Parameter
jeder Methode (Konvention der Vorgaenger): `list(tenantId, userId,
widgetId)`, `create(tenantId, userId, dto)` (heute `create(userId,
tenantId, dto)` — Reihenfolge tauschen, den Aufrufer mit), `update(tenantId,
id, userId, dto)`, `remove(tenantId, id, userId)`, `getIconBytes(tenantId,
id, userId)`. Die Besitzpruefungen (`findUnique`, Vergleich `link.userId !==
userId`, dann `update`/`delete` ueber die Kennung) bleiben WORTGLEICH und
laufen auf DEMSELBEN Klienten wie der Schreibzugriff. Meldungen, Sortierung,
Normalisierung, Icon-Suche, 502-Uebersetzung bleiben WORTGLEICH.
In `create`, VOR der Icon-Suche, der neue Besitzriegel (T-GWH-05, nur wenn
Pruefung 7 aus Aufgabe 1 den Durchgriff des Fremdschluessels bestaetigt hat —
sonst entfaellt er, mit Belegzeile im SUMMARY): `tenantPrisma.widgetInstance
.findUnique({ where: { id: dto.widgetId }, select: { userId: true } })`; ist
das Ergebnis `null` oder `widget.userId !== userId`, `NotFoundException`
mit Meldung woertlich `Widget not found` — keine Aussage ueber fremde
Mandanten oder fremde Halter, dieselbe Antwort fuer "gibt es nicht", "gehoert
einem Kollegen" und "liegt bei einem fremden Mandanten" (das schliesst das
Existenzorakel). Der Riegel steht VOR der Icon-Suche, damit ein fremdes
Widget keinen Netzabruf ausloest.
Kopfkommentar der Klasse: warum gebunden (260911-gwh), dass der Mandant aus
`extractContext` des Controllers kommt (Befund H: dieselbe Quelle wie
`dashboard`, weil der Link am Widget haengt — ausdruecklich NICHT der
auth-Praezedenzfall, mit Grund), dass die Regel keine Benutzerdimension hat
und die `userId`-Filter deshalb bleiben (Etappe-3-Entscheidung (2)), und
dass der Fremdschluessel am Zeilenschutz vorbei prueft (Pruefung 7, mit
Ergebnis) — deshalb der Riegel. Kommentare in dieser Datei duerfen den
ungebundenen Zugriff auf das Favoritenmodell NICHT woertlich nennen (das
Gate zaehlt ueber die ganze Datei).
TEIL 2 — `favorites.controller.ts`. `extractContext` bleibt WORTGLEICH;
alle fuenf Handler reichen `tenantId` als ersten Parameter durch —
`list(tenantId, userId, widgetId)`, `create(tenantId, userId, dto)`,
`getIconBytes(tenantId, id, userId)`, `update(tenantId, id, userId, dto)`,
`remove(tenantId, id, userId)`. Kopfkommentar der Klasse ergaenzen: die
Mandantenquelle ist die vom Guard gesetzte Anfragekennung (fuer SUPER_ADMIN
per Kopfzeile umschaltbar), WORTGLEICH mit `dashboard.controller.ts`, weil
`FavoriteLink` ueber `widgetId` an `WidgetInstance` haengt und beide unter
derselben Kennung liegen muessen; das Favoriten-Frontend sendet die Kopfzeile
heute nicht (gemessen), die Entscheidung haengt an der Bauform.
TEIL 3 — `settings.service.ts`. `getSmtpConfig`, `saveSmtpConfig`,
`getDecryptedSmtpConfig` je mit EINEM Klienten `tenantPrisma`
(Zuweisungsform); `select`, `SMTP_SAFE_SELECT`, Verschluesselung,
Rueckgabeformen WORTGLEICH. `testSmtpConfig` hat keinen eigenen
Datenbankzugriff und bleibt bis auf Kommentare unveraendert.
Der Startpfad: benenne `getStartupSmtpConfig()` um in
`loadAnySmtpConfigForStartupTransport()` (dkv-Praezedenzfall: ein Name, den
niemand fuer einen Anfrageweg haelt), lasse ihn auf `this.prisma` mit dem
unveraenderten `findFirst()` ohne Bedingung, und schreibe den Kopfkommentar
nach der Vorlage von `loadAnyActiveConfigForScheduler` NEU: BLEIBT bewusst
UNGEBUNDEN (260911-gwh, sechster Fall der Hintergrunddienst-Falle); HEUTE
bereits falsch — bei mehreren Mandanten traegt der SMTP-Server und Absender
eines BELIEBIGEN Mandanten alle Kennwort-Zuruecksetzungs- und
Willkommensmails (T-GWH-03); NACH DEM SCHARFSCHALTEN `null`, und
`mail.module.ts` faellt auf Umgebungsvariablen und zuletzt `localhost:1025`
zurueck, `MailService` faengt den Transportfehler — das Verstummen erzeugt
KEINE Protokollzeile (die Unsymmetrie zu `ldap` UND `dkv`); binden wuerde den
Pfad garantiert leer laufen lassen (kein Kontext beim Start); der Umbau auf
Transport je Versand aus `getDecryptedSmtpConfig(tenantId)` — die Form, die
`DkvMailService`/`TenderMailService` bereits haben, `MailService` muesste
den Mandanten von `requestPasswordReset` entgegennehmen — ist eine
Funktionsaenderung, NICHT dieser Auftrag; Verweis auf den Ledger-Eintrag
(die Nummer vergibt erst Aufgabe 3 — hier zunaechst woertlich der
Platzhalter `WINDOWS #TBD-GWH`, den Aufgabe 3 in DIESER Datei und in
`mail.module.ts` durch die vergebene Nummer ersetzt; das Gate von Aufgabe 3
zaehlt den Platzhalter auf null) und auf (s4). Kommentare in dieser Datei duerfen den ungebundenen Zugriff auf das
SMTP-Modell nicht woertlich nennen (das Gate zaehlt genau EINE Stelle in der
Datei — den Startpfad im Code).
TEIL 4 — `mail.module.ts`. Aufruf auf den neuen Namen umstellen; den
Modulkommentar und den Zeilenkommentar an der Aufrufstelle so umschreiben,
dass beide Zustaende stehen (der Satz "single-tenant default" verschwindet
und wird durch die beiden Zustaende und den Verweis auf den Ledger-Eintrag
ersetzt). KEINE Aenderung an der Rueckfallkette selbst, KEIN Umbau des
Transports.
TEIL 5 — die beiden Testdateien aus `<behavior>`. Danach VIER
Falsifizierungsnachweise, jeder zurueckgenommen und mit Testname und
Fehlermeldung woertlich notiert: (a) ersetze in `list` probeweise den
gebundenen Klienten durch den ungebundenen Basisclient —
`favorites.service.spec.ts` wird rot (erwartete Form: der Nachbau hat kein
ungebundenes Favoritenmodell); (b) entferne probeweise den Widget-Riegel in
`create` — genau die drei `Widget not found`-Faelle werden rot; (c) ersetze
in `getDecryptedSmtpConfig` probeweise den gebundenen Klienten durch den
ungebundenen — `settings.service.spec.ts` wird rot (kein `findUnique` auf
dem ungebundenen Nachbau); (d) binde probeweise den Startpfad — der
Null-Klienten-Nachweis UND der Startpfad-Fall werden rot (der gebundene
Nachbau hat kein `findFirst`). Zaehle jeweils, wie viele Faelle rot wurden,
und nenne die Zahl.
Aendere keine Datei ausserhalb der sechs genannten. Insbesondere: KEINE
Aenderung an `settings.controller.ts`, den DTOs, `icon-discovery.service.ts`,
`mail.service.ts`, `tender-mail.service.ts`, `dkv-mail.service.ts`,
`dashboard.controller.ts`.
</action>
<verify>
<automated>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; }; }</automated>
</verify>
<done>`favorites.service.ts`: alle fuenf Methoden nehmen den Mandanten als ersten Parameter und laufen je ueber genau EINEN Klienten `tenantPrisma` (sieben gebundene Favoritenzugriffe, fuenf Aufrufstellen des Bindungshilfsmittels, ein gebundener `widgetInstance`-Lesezugriff als Besitzriegel in `create` mit `Widget not found`); kein ungebundener Favoritenzugriff, auch nicht in Kommentaren. `favorites.controller.ts` reicht `tenantId` an alle fuenf Aufrufe durch, `extractContext` unveraendert, Kopfkommentar nennt Quelle und Praezedenzfall. `settings.service.ts`: `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` je ueber EINEN Klienten (drei gebundene Zugriffe, drei Aufrufstellen); der Startpfad heisst `loadAnySmtpConfigForStartupTransport()`, laeuft unveraendert auf dem ungebundenen Klienten mit `findFirst()` ohne Bedingung — die einzige ungebundene Stelle der Datei —, traegt den Kopfkommentar mit beiden Zustaenden, Unsymmetrie, Reparaturweg, Ledger-Verweis; der alte Name kommt in `apps/api/src` nicht mehr vor; `mail.module.ts` ruft den neuen Namen auf und nennt beide Zustaende statt "single-tenant default". Beide Testdateien existieren neu mit dem Zwei-Klienten-Nachbau (ungebunden ohne Anfrage-Modelle, gebunden ohne `findFirst`), jedem in `<behavior>` genannten Fall, Wachhund und Null-Klienten-Nachweis; alle vier Falsifizierungsnachweise durchgefuehrt, zurueckgenommen, woertlich notiert. Testzahl gestiegen, mindestens 62 Testdateien, Typpruefung sauber, Schema/DTOs/Controller `settings`/Icon-Proxy/Versanddienste unveraendert.</done>
</task>
<task type="auto">
<name>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</name>
<files>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</files>
<read_first>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)</read_first>
<action>
TEIL 1 — die Ledger-Eintraege, ueber `gsd-tools windows append` (damit
Tabelle, JSON-Block und Kopfzaehler zusammenpassen — nicht von Hand), je mit
`--kind`, `--phase quick-260911-gwh`, `--file`, `--description`:
(1) `--kind deviation --file apps/api/src/mail/mail.module.ts` — der
Startpfad des Mailmoduls als SECHSTER Fall der Hintergrunddienst-Falle
(`SettingsService.loadAnySmtpConfigForStartupTransport()`, vormals mit dem
alten Namen), BLEIBT bewusst UNGEBUNDEN. Zwei Zustaende: HEUTE bereits
falsch — `findFirst()` ohne Bedingung zieht bei mehreren Mandanten den
SMTP-Server und Absender EINES beliebigen Mandanten fuer alle Systemmails
(Kennwort-Zuruecksetzung, Willkommen) ALLER Mandanten; NACH DEM
SCHARFSCHALTEN (#18) `null`, und `mail.module.ts` faellt auf `MAIL_*`,
`TESSERA_SMTP_*`, zuletzt `localhost:1025` zurueck, `MailService` faengt den
Transportfehler (T-02-12) — KEINE Protokollzeile, das Verstummen ist doppelt
verdeckt (die Unsymmetrie zu #21 und zu `getAllActiveConfigs`). Drei
erwogene Formen wie in #21 (binden unmoeglich; Mehrmandanten-Versand
abgelehnt als Funktion — mit dem Satz, dass die Vorlage in
`DkvMailService`/`TenderMailService` steht und `MailService` den Mandanten
von `requestPasswordReset` bekommen koennte; Altlast mit Markierung GEWAEHLT).
Warum EIGENER Eintrag statt #21: andere Datei, andere Reparatur, andere
Verdeckungsform. Markierung: umbenannte Methode mit Kopfkommentar,
Modulkommentar in `mail.module.ts`, (s4)(a). Signal fuer das Verstummen
gehoert in `rls-preflight.mjs` (Etappe 4). Falls die Lesung in Aufgabe 1
(s4)(a) auf Anschluss an #21 entschieden hat, entfaellt dieser Eintrag und
der Grund steht im SUMMARY — die Nummer im Code verweist dann auf #21.
(2) `--kind deviation --file apps/web/src/components/dashboard/widgets/favorites-widget.tsx`
— Bereich `favorites`: ein nach dem Scharfschalten zu klein gebliebenes
Leseergebnis auf `list` sieht aus wie `Noch keine Favoriten.`
(`favorites-widget.tsx`, Zeile um 212; `fetchFavorites` in
`favorites-api.ts` reicht `[]` durch) — "nie einen gespeichert" und "Zeile
unsichtbar" sind fuer das Frontend derselbe Wert; Etappe-4-Vorabpruefung
aus (f4)(d); an dieselbe Bedingung gebunden wie #18; Familie
#23/#25/#26/#28; das Frontend wird von 260911-gwh NICHT geaendert.
(3) `--kind deviation --file apps/web/src/components/settings/smtp-settings-form.tsx`
— Bereich `settings`: `getSmtpConfig` liefert nach dem Scharfschalten
`null`, der Controller antwortet 200 mit leerem Rumpf, `fetchSmtp`
(`settings-api.ts`) laeuft mit `res.json()` auf den leeren Rumpf und wirft,
`smtp-settings-form.tsx` verschluckt das in `.catch(() => {})` — leeres
Formular, "nicht eingerichtet", waehrend die Zugangsdaten physisch da sind;
ein erneutes Speichern unter der ungebundenen Form scheitert am
Eindeutigkeitsindex (Pruefung 8, mit gemessenem Konstruktor/`code`) — nach
diesem Lauf ist `saveSmtpConfig` gebunden und trifft die eigene Zeile;
dieselbe 200-leerer-Rumpf-Kette wie #28; Etappe-4-Vorabpruefung aus
(s4)(e); Frontend NICHT geaendert.
Pruefe nach dem Anlegen, dass jeder Eintrag in Tabelle UND JSON-Block steht
und die Kopfzaehler (`open_count`, `total_count`) mit den Zeilen
uebereinstimmen. Ersetze dann den Platzhalter `WINDOWS #TBD-GWH` in
`apps/api/src/settings/settings.service.ts` und `apps/api/src/mail/mail.module.ts`
durch die vergebene Nummer von Eintrag (1) (bzw. `#21`, falls angeschlossen)
— sonst nichts an diesen beiden Dateien.
TEIL 2 — die Klassifikation. Ziehe `docs/mandantentrennung-zugriffsklassifikation.md`
an ALLEN handgepflegten Stellen nach, jede einzeln nachgesehen, keine
ueberflogen (Fehler 4 des Vorhabens):
1. Uebersichtszeilen `favorites` und `settings` mit den NEU GEMESSENEN Zahlen
aus der im Dokument genannten Messanweisung (beide Spalten), im
etablierten Stil mit Vermerk des vorherigen Standes (`**war 7/0**`,
`**war 4/0**`): fuer `favorites`, welche fuenf Methoden gebunden wurden
und dass der Besitzriegel einen zusaetzlichen gebundenen
`widgetInstance`-Lesezugriff einfuehrt (wenn gebaut — Zahl aus der
Messung); fuer `settings`, dass die drei Anfragewege gebunden sind und der
eine verbleibende ungebundene Rohtreffer der benannte Startpfad ist
(Verweis auf den sechsten Fall unten, den Ledger-Eintrag, (s4)(a)).
2. Summenzeile mit fortgeschriebener Herkunftsspur (ungebunden und gebunden
je um die Differenzen aus Schritt 1) UND dem Satz, dass dies der Endstand
der Etappe 2 ist: jeder verbleibende ungebundene Rohtreffer ist einer der
im Dokument benannten, bewusst ungebundenen Faelle.
3. Bestandsaufnahme: `favorites.service.ts | favoriteLink` Stand von
`ungebunden` auf `gebunden`, Begruendung NEU (fuenf Methoden, ein Klient je
Methode, Besitzpruefungen bleiben, Mandantenquelle wie `dashboard`,
260911-gwh); NEUE Zeile `apps/api/src/favorites/favorites.service.ts |
widgetInstance | muss-mandantengebunden | gebunden` mit Begruendung
(Besitzriegel vor dem Anlegen, weil der Fremdschluessel am Zeilenschutz
vorbei prueft — Pruefung 7) — NUR, wenn der Riegel gebaut wurde;
`settings.service.ts | smtpConfig` Stand von `ungebunden` auf `gemischt`,
Begruendung NEU in der Form der `dkv.service.ts`/`dkvModuleConfig`-Zeile
(die Mischung stammt ausschliesslich vom benannten Startpfad, keine
uebersehene Fundstelle; Befund K erfuellt). Ohne Schritt 3 ist
`rls-access-inventory.spec.ts` am Ende dieser Aufgabe rot (Stand-Vergleich
und, bei neuer Fundstelle, Vollstaendigkeit).
4. Klassen-Verteilung: ein `**Stand 260911-gwh (Aufgabe 3):**`-Absatz — bei
gebautem Riegel: 65 Paare, eine neue Fundstelle
(`favorites.service.ts`/`widgetInstance`, `muss-mandantengebunden`), die
Zahl der Ausgabe der Pruefung entnommen, Tabelle `muss-mandantengebunden`
33 und Summe 65, Ueberschrift auf 65 Paare; sonst der
"unveraendert, ausdruecklich festgehalten"-Absatz in der Form von
260911-fh9. In beiden Faellen der Satz, dass zwei Paare nur ihre
Stand-Spalte aendern, und dass dies der Endstand der Etappe 2 ist.
5. Hintergrunddienst-Abschnitt: Ueberschrift von `fünf Fälle` auf `sechs
Fälle`; im Einleitungsabsatz der Satz, dass der sechste Fall (seit
260911-gwh) von derselben Bauart wie der fuenfte ist; ein NEUER Absatz
`**Der sechste Fall, gleicher Bauart wie der fünfte — `mail.module.ts` /
`SettingsService.loadAnySmtpConfigForStartupTransport()`** (260911-gwh,
Befund D, WINDOWS #<Nr.>): offen, als benannte Altlast weitergeführt.`
unmittelbar NACH dem Absatz zum fuenften Fall und VOR dem
`**Stand 260910-exd**`-Absatz, in dessen Form: beide Zustaende, warum
Binden keine Loesung ist, warum der Umbau eine Funktion ist, die
Unsymmetrie zu BEIDEN Praezedenzfaellen (Rueckfallkette, keine
Protokollzeile), die dreifache Markierung, das Signal fuer
`rls-preflight.mjs`, UND der Satz, dass die Reihenfolgebedingung Befund K
(`tenders` (t4), `dkv` (d4)) fuer den VERSANDPFAD `getDecryptedSmtpConfig`
mit diesem Lauf ERFUELLT ist — die Etappe-4-Vorabpruefung fuehrt sie nicht
mehr. Dazu ein PLAIN-Absatz `**Stand 260911-gwh — der Bereich `favorites`
fügt diesem Abschnitt keinen weiteren Fall hinzu, gemessen statt
angenommen (Befund K).**` mit der Anweisung und dem `setTimeout`-Treffer
im Icon-Proxy (Form von 260911-cwh).
6. Abschnitt `Was diese Etappe NICHT entscheidet`: ein NEUER Punkt — wie
das Mailmodul kuenftig je Mandant versendet (Transport je Versand aus
`getDecryptedSmtpConfig(tenantId)`, Vorlage `DkvMailService`/
`TenderMailService`; `MailService` braucht dafuer den Mandanten, den
`requestPasswordReset` aus der Funktionszeile hat) — eine Funktion,
Verweis auf (s4)(a) und den Ledger-Eintrag; und ein ZWEITER Punkt in der
durchgestrichenen Form (`~~...~~ **Aufgelöst (260911-gwh):**`) NUR, wenn
der Abschnitt heute einen Punkt zur settings-Reihenfolgebedingung fuehrt
(gemessen: keinen — dann stattdessen ein Satz am Ende des neuen
Hintergrunddienst-Absatzes, wie in Schritt 5 verlangt).
TEIL 3 — die Kritikschrift. (a) Nachtrag unter Befund K in (t4), in der
Form des Nachtrags unter Befund D in (e): Original bleibt stehen, darunter
ein Absatz `**Nachtrag (260911-gwh):**` — `getDecryptedSmtpConfig(tenantId)`
laeuft seit Aufgabe 2 dieses Laufs ueber `forTenant()`, die
Reihenfolgebedingung ist erfuellt, Verweis auf (s4)(b) und die
Bestandsaufnahme-Zeile `settings.service.ts`/`smtpConfig`. (b) Derselbe
Nachtrag unter dem Uebergaben-Absatz in (d4) (nur der `settings`-Teil;
`dkv.seed.ts`/`module-registry` ist seit 260910-exd gebunden — pruefen und,
falls zutreffend, in demselben Nachtrag mit einem Satz nennen). (c) Ein
NEUER Abschnitt `## Etappe 2 — Abschluss` unmittelbar NACH `## Bereich
settings` und VOR `## Verweis`: was Etappe 2 in Summe geliefert hat — die
Zahl der Laeufe (aus STATE.md gezaehlt: zwoelf Bereichs-/Regel-Laeufe von
260909-ipc bis 260911-gwh, nachzaehlen), die Summenzeile der
Uebersichtstabelle VORHER (aus dem Klassifikationsdokument: der `**war
...**`-Ausgangswert, 227 Rohtreffer laut Kopf) und NACHHER (die Summenzeile
nach Schritt 2 oben, woertlich abgeleitet), die Zahl der Paare und die
Klassen-Verteilung (aus Schritt 4), die Liste der BEWUSST ungebundenen
Reste je Bereich mit ihrem Grund in je einem Satz (tenders: D-03-Katalog,
Fan-out-Adapter, RSS-Verwaltung #24, uebergreifende Haelften; ldap:
`getAllActiveConfigs`, `resolveEmailForWrite`; dkv: #21; user:
`findByUsername`, Erstanlage, Treiber; module-registry/dashboard:
Modulkatalog; auth: drei Anmeldefunktionen; tenant: die Mandantentabelle;
settings: der sechste Fall) — jeden Bereich aus seinem eigenen Abschnitt
dieses Dokuments bzw. der Uebersichtszeile abgeschrieben, nicht aus dem
Gedaechtnis —, die Zahl der offenen Ledger-Eintraege dieser Etappe (aus
WINDOWS.md gezaehlt, mit Nummern), die Zahl der Pruefungen des Werkzeugs
(die Zeile `Alle N Pruefungen bestanden.` des letzten Laufs) und der Tests
(letzte Testausgabe). Dann, was fuer Etappe 3 bleibt, je ein Satz mit
Verweis auf die Stelle: Anmeldeweg unter je Mandant eindeutigen Namen
((h4)(a)); Benutzerdimension der Regeln (Etappe-3-Entscheidung (2), (k4)/
(f4)); Modulkatalog-Regel (Befund E `module-registry`); Kennzeichnung der
`bewusst-uebergreifend`-Stellen; Mandantenwechsel im Digest ((t4)). Und was
Etappe 4 VOR dem Scharfschalten pruefen muss (`rls-preflight.mjs`): die in
den Bereichsabschnitten benannten Vorabpruefungen, als Liste mit Verweis —
OHNE die erfuellte Befund-K-Bedingung.
TEIL 4 — die Anleitung. Bringe in `docs/anleitung-entwicklung.md` den
Absatz um Zeile 310-316 (`app.current_tenant` wird von Postgres
Row-Level-Security ausgewertet ...) und den Folgeabsatz (`Was ein Entwickler
nie vergessen darf`) mit scoped `Edit` auf den gemessenen Stand: die Regeln
liegen seit `20260909140000_rls_remaining_tenant_tables` auf 23 Tabellen
(`grep -c "ENABLE ROW LEVEL SECURITY"` ueber die drei Migrationen, zur
Ausfuehrungszeit gezaehlt), `FavoriteLink` und `SmtpConfig` eingeschlossen;
die Tabellen OHNE Regel sind die im Klassifikationsdokument als
`keine-mandantengebundene-tabelle` gefuehrten (`Tenant`, `Module`,
`Tender`, ... — aus der Bestandsaufnahme ableiten, nicht raten);
`DkvService.loadConfig()` ist seit 260909-mir gebunden — das Beispiel fuer
"manuelles `tenantId`-Filter" ersetzen durch die tatsaechliche Regel: jeder
Zugriff auf eine mandantengebundene Tabelle laeuft ueber einen dienst-intern
mit `forTenant()` gebundenen Klienten `tenantPrisma`, die `where`-Filter
ueber `userId` bleiben zusaetzlich; Verweis auf die beiden
Mandantentrennungs-Dokumente. Nichts sonst in dieser Datei.
TEIL 5 — zwei Falsifizierungsnachweise fuer die Dokument-Gates, jeder
zurueckgenommen und mit Meldung woertlich notiert: (a) setze die
Bestandsaufnahme-Zeile `favorites.service.ts | favoriteLink` probeweise
zurueck auf `ungebunden` — `rls-access-inventory.spec.ts` muss rot werden;
(b) setze die Uebersichtszeile `settings` probeweise auf eine falsche Zahl —
das herleitende Gate dieser Aufgabe muss fehlschlagen.
Aendere keine Datei ausserhalb der sechs genannten.
</action>
<verify>
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && SOUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$SOUT" | tail -1 && N=$(echo "$SOUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden\.$/\1/p') && { test -n "$N" && test "$N" -ge 137 || { echo "WERKZEUG: ${N:-nicht alle} Pruefungen bestanden, erwartet mindestens 137"; exit 1; }; } && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -gt 951 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mehr als 951"; exit 1; }; } && npm --prefix apps/api run type-check && test 0 -eq "$(grep -rc 'TBD-GWH' apps/api/src docs | awk -F: '{s+=$2} END{print s}')" && grep -qE 'WINDOWS #[0-9]+' apps/api/src/settings/settings.service.ts && grep -qE 'WINDOWS #[0-9]+' apps/api/src/mail/mail.module.ts && K=docs/mandantentrennung-zugriffsklassifikation.md && FU=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/favorites | grep -v spec | wc -l | tr -d ' ') && FB=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/favorites | grep -v spec | wc -l | tr -d ' ') && SU=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/settings | grep -v spec | wc -l | tr -d ' ') && SB=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/settings | grep -v spec | wc -l | tr -d ' ') && { test "$FU" -eq 0 || { echo "favorites: $FU ungebundene Rohtreffer, erwartet 0"; exit 1; }; } && { test "$FB" -ge 7 || { echo "favorites: $FB gebundene Rohtreffer, erwartet mindestens 7"; exit 1; }; } && { test "$SU" -eq 1 || { echo "settings: $SU ungebundene Rohtreffer, erwartet 1"; exit 1; }; } && { test "$SB" -eq 3 || { echo "settings: $SB gebundene Rohtreffer, erwartet 3"; exit 1; }; } && { grep -qE "^\| favorites \| ${FU} \| ${FB} \| \*\*war 7/0\*\*" "$K" || { echo "UEBERSICHTSZEILE favorites nennt nicht ${FU}/${FB} im etablierten Stil"; exit 1; }; } && { grep -qE "^\| settings \| ${SU} \| ${SB} \| \*\*war 4/0\*\*" "$K" || { echo "UEBERSICHTSZEILE settings nennt nicht ${SU}/${SB} im etablierten Stil"; exit 1; }; } && TU=0 && TB=0 && for d in apps/api/src/*/; do u=$(grep -ro "this\.prisma\.[a-zA-Z]*" "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); b=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); TU=$((TU+u)); TB=$((TB+b)); done && { grep -qE "^\| \*\*Summe\*\* \| \*\*${TU}\*\* \| \*\*${TB}\*\* \|" "$K" || { echo "SUMMENZEILE nennt nicht die abgeleiteten Werte ${TU}/${TB}"; exit 1; }; } && grep -qE '^\| apps/api/src/favorites/favorites\.service\.ts \| favoriteLink \| muss-mandantengebunden \| gebunden \|' "$K" && grep -qE '^\| apps/api/src/settings/settings\.service\.ts \| smtpConfig \| muss-mandantengebunden \| gemischt \|' "$K" && { if grep -q 'tenantPrisma\.widgetInstance\.' apps/api/src/favorites/favorites.service.ts; then grep -qE '^\| apps/api/src/favorites/favorites\.service\.ts \| widgetInstance \| muss-mandantengebunden \| gebunden \|' "$K" || { echo "BESTANDSAUFNAHME: neue Zeile favorites.service.ts/widgetInstance fehlt"; exit 1; }; fi; } && P=$(grep -cE '^\| apps/api/src/[^|]+ \| [a-zA-Z]+ \| (muss-mandantengebunden|bewusst-uebergreifend|keine-mandantengebundene-tabelle|beides) \| (gebunden|ungebunden|gemischt) \|' "$K") && { grep -qE "^## Klassen-Verteilung \(nach \(Datei, Modell\)-Fundstellen, ${P} Paare\)" "$K" || { echo "KLASSEN-VERTEILUNG: Ueberschrift nennt nicht die gezaehlten ${P} Paare"; exit 1; }; } && { grep -qE "^\| \*\*Summe\*\* \| \*\*${P}\*\* \|$" "$K" || { echo "KLASSEN-VERTEILUNG: Summe nennt nicht ${P}"; exit 1; }; } && MM=$(grep -cE '^\| apps/api/src/[^|]+ \| [a-zA-Z]+ \| muss-mandantengebunden \|' "$K") && { grep -qE "^\| muss-mandantengebunden \| ${MM} \|$" "$K" || { echo "KLASSEN-VERTEILUNG: muss-mandantengebunden nennt nicht ${MM}"; exit 1; }; } && grep -q '^\*\*Stand 260911-gwh (Aufgabe 3)' "$K" && grep -q '^## Der Hintergrunddienst als Falle — sechs Fälle$' "$K" && test 0 -eq "$(grep -c 'fünf Fälle' "$K")" && awk '/^## Der Hintergrunddienst als Falle/{f=1; next} /^## /{f=0} f && /sechste Fall/{s=1} f && /loadAnySmtpConfigForStartupTransport/{m=1} f && /mail\.module\.ts/{o=1} f && /Befund K/{k=1} f && /favorites/{v=1} f && /localhost:1025|Rückfallkette|Rueckfallkette/{r=1} END{ if(!s||!m||!o){print "HINTERGRUNDDIENST: der sechste Fall (mail.module.ts / loadAnySmtpConfigForStartupTransport) fehlt"; exit 1} if(!k){print "HINTERGRUNDDIENST: die erfuellte Befund-K-Bedingung ist nicht benannt"; exit 1} if(!v){print "HINTERGRUNDDIENST: der favorites-Absatz fehlt"; exit 1} if(!r){print "HINTERGRUNDDIENST: die Rueckfallkette ist nicht benannt"; exit 1} }' "$K" && awk '/^## Was diese Etappe NICHT entscheidet/{f=1; next} /^## /{f=0} f && /getDecryptedSmtpConfig/{g=1} f && /260911-gwh/{w=1} END{ if(!g||!w){print "NICHT ENTSCHEIDET: der Punkt zum Mehrmandanten-Versand (260911-gwh) fehlt"; exit 1} }' "$K" && D=docs/mandantentrennung-etappe2-fehlerrichtung.md && grep -q '^## Etappe 2 — Abschluss$' "$D" && awk '/^## Bereich settings$/{s=NR} /^## Etappe 2 — Abschluss$/{a=NR} /^## Verweis$/{v=NR} END{ if(!(s&&a&&v&&s<a&&a<v)){print "ABSCHLUSS-ABSCHNITT steht nicht zwischen Bereich settings und Verweis"; exit 1} }' "$D" && awk -v tu="$TU" -v tb="$TB" -v p="$P" '/^## Etappe 2 — Abschluss$/{f=1; next} /^## /{f=0} f && index($0, tu){u=1} f && index($0, tb){b=1} f && index($0, p " Paare"){q=1} f && /rls-preflight\.mjs/{r=1} f && /Etappe 3/{e=1} f && /WINDOWS/{w=1} END{ if(!u||!b){print "ABSCHLUSS: die abgeleitete Summenzeile (" tu "/" tb ") ist nicht genannt"; exit 1} if(!q){print "ABSCHLUSS: die Paarzahl (" p " Paare) ist nicht genannt"; exit 1} if(!r||!e||!w){print "ABSCHLUSS: Etappe 3, rls-preflight.mjs oder die Ledger-Eintraege sind nicht genannt"; exit 1} }' "$D" && awk '/^### \(t4\)/{f=1} /^### \(t5\)/{f=0} f && /Nachtrag \(260911-gwh\)/{n=1} END{ if(!n){print "(t4): Nachtrag unter Befund K fehlt"; exit 1} }' "$D" && awk '/^### \(d4\)/{f=1} /^### \(d5\)/{f=0} f && /Nachtrag \(260911-gwh\)/{n=1} END{ if(!n){print "(d4): Nachtrag im Uebergaben-Absatz fehlt"; exit 1} }' "$D" && A=docs/anleitung-entwicklung.md && test 0 -eq "$(grep -c 'FavoriteLink` — tragen zwar eine `tenantId`-Spalte, aber \*\*keine\*\*' "$A")" && test 0 -eq "$(grep -c 'nutzt den plain, UNGEBUNDENEN' "$A")" && grep -q '20260909140000_rls_remaining_tenant_tables' "$A" && grep -q 'tenantPrisma' "$A" && W=.planning/WINDOWS.md && OC=$(sed -nE 's/^open_count: ([0-9]+)$/\1/p' "$W") && TC=$(sed -nE 's/^total_count: ([0-9]+)$/\1/p' "$W") && ROWS=$(grep -cE '^\| [0-9]+ \| ' "$W") && { test "$ROWS" -eq "$TC" || { echo "WINDOWS: total_count=$TC, Tabellenzeilen=$ROWS"; exit 1; }; } && OPENROWS=$(grep -E '^\| [0-9]+ \| ' "$W" | grep -c '| open |') && { test "$OPENROWS" -eq "$OC" || { echo "WINDOWS: open_count=$OC, offene Zeilen=$OPENROWS"; exit 1; }; } && test "$(grep -cE '^\| [0-9]+ \| quick-260911-gwh \|' "$W")" -ge 2 && grep -q 'favorites-widget\.tsx' "$W" && grep -q 'smtp-settings-form\.tsx' "$W" && git rev-parse --verify 46f0e78 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 46f0e78 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 46f0e78) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|docs/mandantentrennung-zugriffsklassifikation\.md|docs/anleitung-entwicklung\.md|apps/api/src/favorites/favorites\.service\.ts|apps/api/src/favorites/favorites\.service\.spec\.ts|apps/api/src/favorites/favorites\.controller\.ts|apps/api/src/settings/settings\.service\.ts|apps/api/src/settings/settings\.service\.spec\.ts|apps/api/src/mail/mail\.module\.ts|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; } && test -z "$(git diff --name-only 46f0e78 -- docker-compose.yml docker-compose.*.yml .env .env.* apps/api/.env apps/api/.env.* 2>/dev/null)"</automated>
</verify>
<done>Drei neue offene Ledger-Eintraege (Startpfad des Mailmoduls — oder, mit Grund, Anschluss an #21 —, verschluckte Leere `favorites`, verschluckte Leere `settings`) stehen in Tabelle UND JSON-Block, die Kopfzaehler stimmen, der Platzhalter im Code ist durch die vergebene Nummer ersetzt. Alle handgepflegten Stellen von `docs/mandantentrennung-zugriffsklassifikation.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.</done>
</task>
</tasks>
<threat_model>
Konfiguriert: ASVS-Stufe 1, blockierend ab `high`.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Angemeldeter Benutzer -> `/favorites` (`list`, `create`, `update`, `remove`, `:id/icon`) | Kennung im Pfad und `widgetId` im Rumpf sind frei waehlbare Eingaben; der Mandant kommt aus `extractContext` (Guard-Kennung, wie `dashboard`), die Benutzerkennung aus dem Sitzungsnachweis. Die Regel auf `FavoriteLink` kennt keine Benutzerdimension. |
| `FavoriteLink.widgetId` -> `WidgetInstance.id` (Fremdschluessel) | PostgreSQL prueft referentielle Integritaet AM ZEILENSCHUTZ VORBEI: ein Bezug auf ein unsichtbares Widget wird auf Datenbankebene nicht gesperrt (Pruefung 7 misst). |
| Favoriten-Dienst -> Icon-Proxy -> Internet | `discoverFavoriteIconUrl` nimmt die Nutzer-URL fuer einen SSRF-gesicherten Abruf (T-08-05); `fetchIconBytes` nimmt NUR die gespeicherte URL einer Zeile, die der Aufrufer besitzt (T-QFIP-01). Kein Datenbankzugriff, kein Mandantenzustand. |
| ADMIN / SUPER_ADMIN -> `/settings/smtp` | `req.tenantId` (fuer SUPER_ADMIN per `x-tenant-id` umschaltbar) ist hier die GEWOLLTE Quelle (D-10); Zugangsdaten verschluesselt (AES-256-GCM), nie im GET. |
| `tender-mail.service.ts` / `dkv-mail.service.ts` -> `getDecryptedSmtpConfig(tenantId)` | Der Mandant kommt aus der gebundenen Schleife des Aufrufers; der entschluesselte Wert lebt nur im Methodenrumpf (T-07-10). |
| `mail.module.ts` (Start) -> Startpfad -> `MailerService` | Kein Mandantenkontext; eine BELIEBIGE Zeile wird zum Transport ALLER Systemmails; nach dem Scharfschalten `null` -> Rueckfallkette -> `localhost:1025`. |
| API -> PostgreSQL, Tabellen `FavoriteLink`/`SmtpConfig` | Regel `"tenantId" = current_tenant_id()` (20260909140000); nach dem Scharfschalten liefert ein ungebundener Zugriff null Zeilen (Pruefungen 3/5), ein ungebundenes `upsert` scheitert am Eindeutigkeitsindex (Pruefung 8). |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-GWH-01 | Information Disclosure | `settings.service.ts` `getSmtpConfig`/`getDecryptedSmtpConfig`, Mandantengrenze | high | mitigate | Ohne Bindung liest ein ADMIN von Mandant A ueber eine gefaelschte oder falsch aufgeloeste Mandantenkennung die (verschluesselten bzw. entschluesselten) SMTP-Zugangsdaten von B; nach dem Scharfschalten sind ungebundene Zugriffe leer statt fremd. Gebundener Klient je Methode; Pruefung 7 (fremder Mandant: `null`) ueber den generierten Client; Testfaelle (fremder Mandant: `null`, `decrypt` nicht aufgerufen); Falsifizierung (c). |
| T-GWH-02 | Tampering / Elevation of Privilege | `favorites.service.ts` `update`/`remove`/`getIconBytes`, Mandantengrenze | high | mitigate | Ein Nutzer von Mandant A aendert oder loescht ueber die Kennung eine Favoritenzeile von Mandant B. Heute schuetzt nur der `userId`-Vergleich; nach der Bindung liefert die Vorpruefung `null` (Pruefung 5), ein gebundenes `delete` ueber die Kennung allein wirft (Pruefung 6). Beide Zugriffe auf DEMSELBEN Klienten; Testfaelle (fremder Mandant: `NotFoundException`, kein Schreibzugriff); Falsifizierung (a). |
| T-GWH-03 | Elevation of Privilege / Information Disclosure | `mail.module.ts` Startpfad, Nutzung fremder Zugangsdaten | high | transfer | HEUTE bei mehreren Mandanten: der SMTP-Server und Absender EINES beliebigen Mandanten tragen Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (Nutzung fremder Zugangsdaten, Absenderfaelschung aus Sicht des Empfaengers). Kein Bindungsproblem — beim Start gibt es keinen Mandanten; der Umbau auf Transport je Versand ist eine Funktion (Vorlage: `DkvMailService`/`TenderMailService`). Gemessen (Pruefung 4), benannt (Kopfkommentar beider Zustaende, (s4)(a)), als OFFENER Ledger-Eintrag mit konkretem Reparaturweg uebergeben. Heute ein Mandant — die Bedingung tritt mit dem zweiten ein. |
| T-GWH-04 | Denial of Service (still) | `mail.module.ts` Rueckfallkette nach dem Scharfschalten | high | transfer | `null` -> `MAIL_*` -> `TESSERA_SMTP_*` -> `localhost:1025`; `MailService` faengt den Transportfehler, der Controller antwortet 200: Kennwort-Zuruecksetzung kommt nie an, ohne Protokollzeile beim Start. Gemessen (Pruefung 3), beide Zustaende an drei Stellen benannt, Signal fuer `rls-preflight.mjs` (Etappe 4) benannt; NICHT in diesem Lauf behoben (kein Umbau des Transports, kein Schalter). |
| T-GWH-05 | Information Disclosure | `favorites.service.ts` `create`, Fremdschluessel auf `WidgetInstance` | medium | mitigate | Der Unterschied zwischen "Widget gibt es nicht" (FK-Verletzung, 500) und "gehoert einem fremden Mandanten" (gelingt, weil der Fremdschluessel am Zeilenschutz vorbei prueft) ist ein Existenzorakel ueber Mandantengrenzen; dazu haengt eine fremde Zeile am Widget eines anderen (Kaskade). Pruefung 7 misst; Besitzriegel vor dem Anlegen auf dem gebundenen Klienten mit EINER Antwort (`Widget not found`) fuer alle drei Faelle; Testfaelle; Falsifizierung (b). |
| T-GWH-06 | Denial of Service (still) / Tampering | `saveSmtpConfig` unter der ungebundenen Form | medium | mitigate | Leeres Formular nach dem Scharfschalten verleitet zum Neueintrag; das ungebundene `upsert` findet die eigene Zeile nicht und scheitert am Eindeutigkeitsindex (Pruefung 8, Konstruktor/`code` gemessen). Gebundenes `upsert` trifft die eigene Zeile (Pruefung 9); Testfall. Die Frontend-Kette bleibt als Ledger-Eintrag (Familie #28). |
| T-GWH-07 | Server-Side Request Forgery | `icon-discovery.service.ts` | medium | accept | Nutzer-URL fuer die Icon-Suche; SSRF-Schutz (private IP-Bereiche, gesperrte Hostnamen, manuelle Weiterleitung, Zeitueberschreitung) besteht seit T-08-05 und ist in `icon-discovery.service.spec.ts` getestet; `fetchIconBytes` nimmt nur die gespeicherte URL einer eigenen Zeile (T-QFIP-01). Kein Datenbankzugriff, kein Mandantenbezug (Befund G) — unveraendert, nur gelesen. Der Besitzriegel in `create` steht VOR der Icon-Suche, damit ein fremdes Widget keinen Abruf ausloest. |
| T-GWH-08 | Information Disclosure | `FavoriteLink`, Kollege desselben Mandanten | low | accept | Die Regel kennt keine Benutzerdimension: auf Datenbankebene sieht ein gebundener Klient die Favoriten des Kollegen (Pruefung 4, zweite Aussage). Die anwendungsseitigen `userId`-Filter bleiben; Etappe-3-Entscheidung (2). Bestehend, benannt in (f4)(a). |
| T-GWH-09 | Repudiation / Information Disclosure | `favorites-widget.tsx`, `smtp-settings-form.tsx` | medium | accept | Die umgekehrte Fehlerrichtung ist verschluckt: `[]` wird zu `Noch keine Favoriten.`, `null` wird zu einem leeren Formular. Gemessen (Pruefungen 3/5), in (f3)/(s3) beschrieben, je ein Ledger-Eintrag (Familie #23/#25/#26/#28), Etappe-4-Vorabpruefung benannt. Das Frontend wird nicht geaendert (Umfang). |
| T-GWH-10 | Spoofing | `favorites.controller.ts`, Mandantenquelle | low | accept | `extractContext` liest die Guard-Kennung (fuer SUPER_ADMIN umschaltbar) — dieselbe Quelle wie `dashboard`, unter dessen Bindung das referenzierte Widget liegt; eine abweichende Quelle wuerde Widget und Link trennen. Kein Mandantenfeld im DTO (gemessen). Bewusst NICHT das Claim, mit Grund in (f4)(c). |
| T-GWH-11 | Denial of Service | `settings.service.spec.ts` -> `nodemailer` | low | mitigate | Ein echter Transport im Test liefe gegen `mailhog` (lokal nicht vorhanden: `ENOTFOUND`). `vi.mock('nodemailer')` ersetzt `createTransport`; kein Test oeffnet eine Verbindung; gegatet. |
| T-GWH-SC | Tampering | Paketinstallation | low | accept | Dieser Plan installiert kein Paket (npm/pip/cargo) und fuegt keine Abhaengigkeit hinzu. Das Legitimitaets-Gate faellt nicht an; ausdruecklich festgehalten statt schweigend ausgelassen. |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- Beide Bereiche sind gemessen, bevor gebaut wurde: zwei neue
Werkzeug-Abschnitte ueber den generierten Client, mit Fremdschluessel
(favorites) und Eindeutigkeitsindex (settings) als mitgebauten
Voraussetzungen der Messung.
- Der Startpfad des Mailmoduls ist als sechster Fall der
Hintergrunddienst-Falle erkannt, in beiden Zustaenden gemessen, umbenannt,
markiert (Kopfkommentar, Modulkommentar, Klassifikation, Kritikschrift,
Ledger) und NICHT gebunden, NICHT umgebaut; die Unsymmetrie zu `ldap` und
`dkv` (Rueckfallkette, keine Protokollzeile) und die Entscheidung zum
eigenen Ledger-Eintrag sind mit Grund aufgeschrieben.
- Befund K ist erfuellt und steht als erfuellt an allen drei Stellen, so dass
die Etappe-4-Vorabpruefung keine veraltete Bedingung fuehrt.
- Alle Anfragewege beider Bereiche laufen ueber genau EINEN Klienten
`tenantPrisma` je Methode; die Besitzpruefungen bleiben; `create` prueft das
Widget; beide Controller-Entscheidungen zur Mandantenquelle sind begruendet
(dashboard-Praezedenzfall fuer favorites, ADMIN-Seite fuer settings).
- Die umgekehrte Fehlerrichtung ist je Bereich in ihrer tatsaechlichen
Auspraegung benannt (`Noch keine Favoriten.`; leeres Formular ueber die
200-leerer-Rumpf-Kette; Speicherkonflikt gemessen) und als Ledger-Eintrag
je Bereich festgehalten.
- Zwei neue Testdateien mit dem Zwei-Klienten-Nachbau, ohne echten
Transport; sechs Falsifizierungsnachweise (vier in Aufgabe 2, zwei an den
Dokument-Gates in Aufgabe 3) durchgefuehrt, zurueckgenommen, woertlich
notiert.
- Etappe 2 ist vollstaendig: die fuenf handgepflegten Dokumentstellen stehen
auf ihrem Endstand, deriviert gegatet; die Kritikschrift hat einen
Abschluss-Abschnitt aus ihren eigenen Zahlen; die Anleitung widerspricht
dem Stand nicht mehr; Erlaubnisliste gegen `46f0e78`; Baseline gehalten
nach jeder Aufgabe; Schalter aus; Schema, Migrationen, Frontend, Active
Directory unberuehrt.
</success_criteria>
<output>
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.
</output>