116 KiB
phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
| phase | plan | type | wave | depends_on | autonomous | requirements | files_modified | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-260911-gwh | 01 | execute | 1 | true |
|
|
|
|
Zweck, dreifach. Erstens: settings haelt die SMTP-Zugangsdaten je Mandant,
und getDecryptedSmtpConfig(tenantId) ist der einzige Versandpfad fuer
Ausschreibungs-Meldungen und DKV-Ausfuhren. Der tenders-Lauf hat als Befund
K festgehalten: bleibt diese Methode ungebunden, geht nach dem Scharfschalten
fuer NIEMANDEN eine Mail raus. Dieser Lauf schliesst diese Bedingung und sagt
das an jeder Stelle, an der sie steht. Zweitens: getStartupSmtpConfig() ist
der SECHSTE Fall der Hintergrunddienst-Falle — ein Startpfad ohne
Mandantenkontext, der heute eine BELIEBIGE Zeile zieht (der SMTP-Server eines
beliebigen Mandanten bedient alle Systemmails) und nach dem Scharfschalten
null liefert; anders als bei dkv (WINDOWS #21) verdeckt hier eine
Rueckfallkette das Verstummen mit einem falschen Transport. Der Pfad wird
gemessen, benannt, markiert und als eigener Ledger-Eintrag gefuehrt — NICHT
gebunden, NICHT zu einem Mehrmandanten-Versand umgebaut (das ist eine
Funktion, dieselbe Klasse wie #21). Drittens: favorites ist Nutzerdaten
mit der findUnique-dann-loeschen-Bauform, die dieses Vorhaben zweimal falsch
und zweimal richtig vorgefunden hat — sie wird gelesen und gemessen, und der
Fremdschluessel auf WidgetInstance (der den Zeilenschutz umgeht) bekommt
einen anwendungsseitigen Besitzriegel.
Nach diesem Lauf ist Etappe 2 vollstaendig: jede klassifizierte Fundstelle in
apps/api/src ist gebunden oder mit geschriebenem Grund an der Stelle
ungebunden. Die fuenf handgepflegten Dokumentstellen erreichen ihren Endstand,
die Kritikschrift bekommt einen Abschluss-Abschnitt aus ihren eigenen Zahlen.
Der Schalter bleibt AUS. DATABASE_URL zeigt weiterhin auf die Rolle
tessera mit BYPASSRLS. Schema und Migrationen werden NICHT angefasst.
Nichts wird in Active Directory geaendert. Kein echter SMTP-Transport wird
aufgebaut (lokal gibt es keinen mailhog; ENOTFOUND mailhog waere
Umgebung, nicht Befund).
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
<planning_time_findings>
Alle Zahlen unten sind zur Planungszeit am 2026-09-11 gegen HEAD 46f0e78
GEMESSEN, mit der jeweils angegebenen Anweisung. Sie leiten die Untersuchung,
sie sind KEINE Bearbeitungsvollmacht — jede Datei wird vor jeder Aenderung
erneut gelesen, jede Zahl zur Ausfuehrungszeit erneut gemessen, und weicht
eine Messung ab, gilt die Messung und nicht dieser Plan. Baseline laut
260911-fh9-SUMMARY.md: 951 Tests gruen in 60 Dateien, Typpruefung sauber,
Werkzeug 120/120 — zur Ausfuehrungszeit VOR der ersten Aenderung selbst
nachmessen.
Befund A — die Zahlen halten: 7/0 und 4/0, beide Bereiche ohne einen
einzigen gebundenen Zugriff.
grep -rno "this\.prisma\.[a-zA-Z]*" apps/api/src/favorites apps/api/src/settings | grep -v spec:
sieben Treffer in favorites.service.ts (Zeilen 34 list/findMany, 55
create/create, 74 update/findUnique, 103 update/update, 114
remove/findUnique, 120 remove/delete, 138 getIconBytes/findUnique) und
vier in settings.service.ts (39 getSmtpConfig/findUnique, 74
saveSmtpConfig/upsert, 97 getDecryptedSmtpConfig/findUnique, 193
getStartupSmtpConfig/findFirst). Gebunden: null. Nach diesem Plan:
favorites 0 ungebunden / 7 gebunden (8, wenn der Besitzriegel aus Befund F
einen widgetInstance-Lesezugriff hinzufuegt); settings 1 ungebunden (der
Startpfad) / 3 gebunden. Summenzeile 78 -> 67 und 167 -> 177 (bzw. 178).
Bestandsaufnahme: favorites.service.ts/favoriteLink von ungebunden auf
gebunden; settings.service.ts/smtpConfig von ungebunden auf
gemischt (dieselbe Form wie dkv.service.ts/dkvModuleConfig: die
Mischung stammt ausschliesslich vom benannten Startpfad); NEUE Zeile
favorites.service.ts/widgetInstance (muss-mandantengebunden,
gebunden), wenn Befund F den Riegel bestaetigt -> 65 Paare, 33/17/13/2.
Alle Zahlen zur Ausfuehrungszeit aus den Messanweisungen des Dokuments
ableiten, nicht von hier abschreiben.
Befund B — die Regeln sind eindeutig und wortgleich extrahierbar.
20260909140000_rls_remaining_tenant_tables traegt fuer FavoriteLink
(Zeile 88-92) und SmtpConfig (102-106) je ENABLE + FORCE + CREATE POLICY tenant_isolation_policy ... USING ("tenantId" = current_tenant_id());
grep -c "FavoriteLink\|SmtpConfig" 20260910120000_.../migration.sql: 0 —
die Widen-Migration hat keine eigene Regel fuer beide (Regelstand eindeutig,
Form von calendarsource-regelstand-eindeutig). Spalten: FavoriteLink
(20260708090000) hat 10 skalare Spalten (id, userId, tenantId,
widgetId, title, url, iconUrl, position, createdAt, updatedAt)
und den Fremdschluessel FavoriteLink_widgetId_fkey auf "WidgetInstance"("id") ON DELETE CASCADE; model FavoriteLink hat zusaetzlich das RELATIONSFELD
widgetInstance — die Wegwerf-Tabelle muss ueber
readSchemaModelScalarFieldNames('FavoriteLink') (der Helfer aus 260911-fh9)
verglichen werden, nicht ueber readSchemaModelFieldNames. SmtpConfig
(20260629130000) hat 10 skalare Spalten (id, tenantId, host, port,
encryption, username, encryptedPassword, fromAddress, createdAt,
updatedAt) und den Eindeutigkeitsindex SmtpConfig_tenantId_key (Zeile
169) — ohne diesen Index misst Pruefung 8 des settings-Abschnitts nichts.
updatedAt hat in beiden Migrationen keine Vorgabe; die Wegwerf-Tabellen
bekommen fuer die Roh-SQL-Einfuegungen DEFAULT CURRENT_TIMESTAMP mit
Vermerk als Abweichung, die nur das Nachruesten betrifft (Form von
260911-e2s/fh9).
Befund C — die Wegwerf-Tabelle WidgetInstance existiert bereits, ohne
createdAt/updatedAt, mit drei Zeilen. runDashboardAreaChecks
(rls-scratch-check.mjs:2461-2467) legt "WidgetInstance" mit id,
userId, tenantId, widgetType an und fuegt widget-a1 (user-a1,
TENANT-A), widget-a2 (user-a2, TENANT-A), widget-b1 (user-b1, TENANT-B)
ein; der gebundene Loeschversuch unter TENANT-A auf widget-b1 (Zeile 2687)
trifft null Zeilen, widget-rejected wird abgewiesen — alle drei Zeilen
sollten stehen, wenn der neue Abschnitt laeuft: zur Ausfuehrungszeit ueber
die Wartungsrolle NACHMESSEN, nicht annehmen. Der Fremdschluessel der
Wegwerf-Tabelle FavoriteLink zeigt auf DIESE Tabelle (WINDOWS #27:
Relationen sind fuer die Bestandsaufnahme unsichtbar — hier wird die
Relation deshalb ausdruecklich mitgebaut und gemessen). Der neue Abschnitt
ist ein Blatt: NACH runAuthAreaChecks, VOR runTransactionShapeMeasurement;
keine spaetere Pruefung setzt auf seinen Tabellen auf.
Befund D — der Startpfad, Glied fuer Glied gelesen.
settings.service.ts:193 findFirst() ohne jede Bedingung; einziger
Aufrufer mail.module.ts:30 in MailerModule.forRootAsync({ useFactory: async ... }) — BEIM START, vor jedem Anfragekontext, Kommentar dort:
"findFirst — single-tenant default". Ergebnis wird zum EINEN Transport des
MailerService, den MailService.sendPasswordResetEmail (einziger Aufrufer
ausserhalb des Mailmoduls: auth.service.ts:246, requestPasswordReset)
und sendWelcomeEmail benutzen. HEUTE bei mehreren Mandanten: der
SMTP-Server und die Absenderadresse eines BELIEBIGEN Mandanten bedienen die
Kennwort-Zuruecksetzungs-Mails ALLER Mandanten — Mandant B versendet ueber
den Server von Mandant A, mit dessen Absender (das ist Nutzung fremder
Zugangsdaten, nicht nur Sichtbarkeit; T-GWH-03). NACH DEM SCHARFSCHALTEN:
findFirst liefert null -> mail.module.ts faellt auf Prioritaet 2
(MAIL_*), 3 (TESSERA_SMTP_*), 4 (localhost:1025, Mailhog) zurueck ->
MailService faengt jeden Transportfehler (mail.service.ts:69-81, T-02-12)
und der Controller antwortet 200. Das Verstummen ist damit DOPPELT verdeckt:
erst durch die Rueckfallkette (ein falscher, aber vorhandener Transport statt
Leere), dann durch das absichtliche Verschlucken im Versand. Das ist die
UNSYMMETRIE zu den beiden Praezedenzfaellen: getAllActiveConfigs (ldap)
ist heute korrekt und verstummt spaeter; loadAnyActiveConfigForScheduler
(dkv, #21) ist heute falsch und verstummt spaeter mit Protokollzeile; der
Mail-Startpfad ist heute falsch und verstummt spaeter OHNE Protokollzeile,
weil eine Rueckfallkette ihn ueberdeckt. ENTSCHEIDUNG (zur Ausfuehrungszeit
nach der Lesung zu bestaetigen oder mit Grund zu verwerfen): EIGENER
Ledger-Eintrag, nicht Anschluss an #21 — #21 ist gegen
dkv-scheduler.service.ts gefuehrt, seine Reparatur ist die
Mehrmandanten-Planung (Auftraege je Mandant); die Reparatur HIER ist eine
andere Funktion mit bereits vorhandener Vorlage im Code: Transport je Versand
aus getDecryptedSmtpConfig(tenantId) bauen, wie DkvMailService und
TenderMailService es seit Phase 07/12 tun — requestPasswordReset kennt
den Mandanten aus der Funktionszeile, MailService muesste ihn nur
entgegennehmen. Das ist ein Feature (Umbau des Mailmoduls), NICHT dieser
Auftrag — steht so im Auftrag und in <hard_constraints>. Umbenennung nach
dem dkv-Praezedenzfall: loadAnySmtpConfigForStartupTransport(); kein Dokument
unter docs/ nennt den alten Namen (grep -rn getStartupSmtpConfig docs:
null), die Phasen-Artefakte unter .planning/phases sind Geschichte und
werden nicht angefasst.
Befund E — der Versandpfad und Befund K, Glied fuer Glied.
tender-mail.service.ts:158 (resolveTransport, privat; bei null:
logger.warn(... skipping tender mail send (will retry next run)), kein
Wurf) und dkv-mail.service.ts:47 (sendExportEmail; bei null: throw new Error('No SMTP configuration found for tenant ...')) rufen
getDecryptedSmtpConfig(tenantId); der Mandant kommt bei beiden aus der
gebundenen Schleife des aufrufenden Bereichs (260909-laa, 260909-mir).
testSmtpConfig ruft sie ebenfalls (Rueckgriff auf gespeicherte
Zugangsdaten) und hat KEINEN eigenen Datenbankzugriff. Die
Reihenfolgebedingung steht an zwei Stellen der Kritikschrift ((t4) Befund K,
Zeile 625-634; (d4) Uebergaben-Absatz, Zeile 935-944) und NIRGENDS im
Klassifikationsdokument (grep -n "settings" docs/mandantentrennung-zugriffsklassifikation.md:
nur Uebersichtszeile 147 und Bestandsaufnahme 453). Nach diesem Plan: an
beiden Stellen ein NACHTRAG (Form von (e) Befund D, Zeile 143-155: Original
bleibt stehen, Nachtrag darunter), und der neue Hintergrunddienst-Absatz im
Klassifikationsdokument sagt, dass die Bedingung mit 260911-gwh erfuellt ist.
Befund F — die Besitzpruefungen: dreimal richtig, einmal fehlend, und der
Fremdschluessel umgeht den Zeilenschutz. update, remove, getIconBytes
tun findUnique({ where: { id } }), vergleichen link.userId !== userId, und
schreiben/loeschen dann ueber die Kennung — nach der Bindung auf DEMSELBEN
Klienten ist das die richtige Form (fremde Zeile: findUnique null ->
NotFoundException; dkv Befund G — ein gebundenes UPDATE/DELETE ueber die
Kennung allein traefe eine fremde Zeile still — greift nicht, weil die
Vorpruefung davor steht; mit dem generierten Client wirft delete bei null
Zeilen zudem P2025, Pruefung 6 misst das). create dagegen prueft NICHT, ob
dto.widgetId dem Aufrufer gehoert: es schreibt userId/tenantId des
Aufrufers und widgetId aus dem Rumpf. PostgreSQL prueft Fremdschluessel AN
DER ZEILENSCHUTZ-REGEL VORBEI (dokumentiertes Verhalten: referentielle
Integritaet umgeht Row Security) — ein gebundenes create unter TENANT-A mit
widgetId eines TENANT-B-Widgets wuerde demnach GELINGEN, und der
Unterschied zwischen "Widget existiert nicht" (FK-Verletzung, 500) und
"existiert bei einem fremden Mandanten" (gelingt) ist ein Existenzorakel
ueber Mandantengrenzen (T-GWH-05, medium). Pruefung 7 MISST das, statt es
anzunehmen; faellt sie so aus, baut Aufgabe 2 einen Besitzriegel in create
ein: gebundener widgetInstance.findUnique({ where: { id: dto.widgetId }, select: { userId: true } }) auf DEMSELBEN tenantPrisma, null oder
fremde Benutzerkennung -> NotFoundException('Widget not found') (ohne
Aussage ueber fremde Mandanten). Das fuegt ein NEUES (Datei, Modell)-Paar
hinzu (Befund A). Faellt Pruefung 7 anders aus, gilt die Messung, der Riegel
entfaellt, und das steht mit Belegzeile im SUMMARY.
Befund G — der Icon-Proxy erreicht die Datenbank NICHT und nimmt KEINE
Client-URL. grep -n "prisma\|Prisma" icon-discovery.service.ts: null
Treffer; getIconBytes holt nur die GESPEICHERTE iconUrl einer Zeile, die
der Aufrufer besitzt (T-QFIP-01, Controller GET /favorites/:id/icon nimmt
nur die Kennung); discoverFavoriteIconUrl(url) bei create/update nimmt
die Nutzer-URL, aber nur fuer den SSRF-gesicherten Abruf (privater
IP-Bereich, gesperrte Hostnamen, manuelle Weiterleitung — T-08-05,
icon-discovery.service.spec.ts deckt das). Mandantenbezug: keiner — die
URL ist nicht mandantenskopiert, der Dienst haelt keinen Zustand je Mandant.
Ergebnis: der Proxy bleibt UNVERAENDERT; in der Testdatei ist er eine
Attrappe (discoverFavoriteIconUrl, fetchIconBytes als vi.fn), und der
Test nagelt fest, dass fetchIconBytes mit der GESPEICHERTEN URL aufgerufen
wird, nie mit einer aus dem Aufruf.
Befund H — die Mandantenquelle je Bereich, beide unterschiedlich und beide
richtig. favorites.controller.ts extractContext: req.tenantId ?? req.user?.tenantId — WORTGLEICH mit dashboard.controller.ts:44-45, unter
dessen Bindung WidgetInstance liegt. FavoriteLink haengt am Widget;
wuerde favorites an das Claim binden (wie auth fuer Selbstbedienung),
dashboard aber an die Guard-Kennung, laegen Widget und Link unter einem
x-tenant-id-Wechsel eines SUPER_ADMIN in verschiedenen Mandanten.
favorites-api.ts sendet die Kopfzeile nicht (grep -rn "x-tenant-id" apps/web/src: nur die vier Marktplatz-Stellen) — die Entscheidung haengt an
der Bauform, nicht am heutigen Aufrufer. ENTSCHEIDUNG: extractContext
bleibt, alle fuenf Aufrufe reichen tenantId durch; Kopfkommentar nennt den
dashboard-Praezedenzfall und warum NICHT der auth-Praezedenzfall.
settings.controller.ts liest req.tenantId — fuer eine
ADMIN-Konfigurationsseite ist das die richtige Quelle (D-10: SUPER_ADMIN
konfiguriert ueber x-tenant-id einen anderen Mandanten); der Controller
bleibt UNVERAENDERT und steht nicht in der Erlaubnisliste.
Befund I — die umgekehrte Fehlerrichtung je Bereich, gelesen.
(1) favorites: list liefert [] -> GET /favorites?widgetId= 200 []
-> fetchFavorites (favorites-api.ts:22-29) gibt [] -> favorites-widget.tsx:212-213
zeigt t('favorites.empty') = Noch keine Favoriten. (de.json, Zeile um
219). "Zeile unsichtbar" und "nie einen gespeichert" sind fuer das Frontend
derselbe Wert — Familie #23/#25/#26/#28. update/remove/getIconBytes:
NotFoundException('FavoriteLink not found') 404 -> favorites-api.ts wirft
Failed to update/delete favorite -> catch im Widget setzt
t('favorites.error'). (2) settings: getSmtpConfig liefert null ->
Controller gibt null zurueck -> NestJS sendet 200 mit LEEREM Rumpf (die
Adapter-Kette aus (h3), 260911-fh9) -> fetchSmtp (settings-api.ts:51-57)
prueft nur 404 und !res.ok, dann res.json() auf den leeren Rumpf ->
wirft -> smtp-settings-form.tsx:76-78 .catch(() => { /* Silent fail */ })
-> leeres Formular: "SMTP nicht eingerichtet", waehrend die Zugangsdaten
physisch da sind. Traegt der Administrator sie neu ein, laeuft saveSmtpConfig
als upsert({ where: { tenantId } }): unter der UNGEBUNDENEN Form (heute, nach
dem Scharfschalten) ist die Zeile unsichtbar, der Upsert versucht INSERT und
scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key — die
dashboard-Lehre (260910-krx: PrismaClientUnknownRequestError, NICHT
P2002). Pruefung 8 misst Konstruktorname und code woertlich, statt sie
vorwegzunehmen. getDecryptedSmtpConfig null: tender warn + kein Versand
mit Wiederholung je Lauf, dkv throw (Befund E). (3) Startpfad: Befund D.
Etappe-4-Vorabpruefung je Bereich: fuer einen bekannten Mandanten die
SmtpConfig-Zeile ueber die Wartungsrolle lesen und den gebundenen
findUnique daneben halten; fuer einen bekannten Nutzer/Widget die
Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany.
Befund J — Testlage: eine Spec fuer den Icon-Proxy, keine fuer die beiden
Dienste; Vorlagen stehen bereit. ls apps/api/src/favorites/: nur
icon-discovery.service.spec.ts; ls apps/api/src/settings/: keine Spec.
Vorlagen: calendar.service.spec.ts (260911-cwh — Bereich ohne Testdatei,
makeFakePrisma mit In-Memory-Zeilen, __makeBoundClient, _applySelect,
boundCallLog), dkv.service.spec.ts (ungebundener Nachbau OHNE die
Anfrage-Modelle), dashboard.service.spec.ts:565 (Wachhund),
tender-mail.service.spec.ts:22-30 (makeSettingsService-Attrappe: zeigt,
welche Rueckgabeform die Aufrufer erwarten — host, port, encryption,
username, fromAddress, decryptedPassword). nodemailer wird in der
neuen settings-Spec per vi.mock('nodemailer') ersetzt (createTransport
liefert { verify: vi.fn(), sendMail: vi.fn() }) — KEIN echter Transport
(Umgebung: kein mailhog). CryptoService als Attrappe (encrypt: (s) => 'enc(' + s + ')', decrypt strippt).
Befund K — kein weiterer Hintergrunddienst, keine Transaktion, keine
Relationseinbindung — bis auf den Startpfad. grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout\|\$transaction(\|\$queryRaw\|\$executeRaw\|include:\|_count" apps/api/src/favorites apps/api/src/settings --include=*.ts | grep -v spec:
genau EIN Treffer, icon-discovery.service.ts:255 — ein setTimeout fuer
den Abbruch eines HTTP-Abrufs, kein Planer (dieselbe Form wie
ics.provider.ts:100 in 260911-cwh). select: kommt zweimal vor
(settings.service.ts:41, :78), beide ausschliesslich skalare Felder von
SmtpConfig, keine Relation (WINDOWS #27 ohne Auspraegung). Der einzige
Hintergrund-Zugriff ist der Startpfad aus Befund D — er lebt NICHT in
settings, sondern wird von mail.module.ts beim Start aufgerufen; als
sechster Fall gehoert er unter mail.module.ts /
SettingsService.loadAnySmtpConfigForStartupTransport().
Befund L — die Buchfuehrung und ein veralteter Absatz in der Anleitung.
Uebersichtszeilen favorites (heute | favorites | 7 | 0 | unverändert |)
und settings (| settings | 4 | 0 | unverändert |), Summenzeile (78/167),
Bestandsaufnahme-Zeilen 431 und 453, Klassen-Verteilung (64 Paare,
32/17/13/2), Hintergrunddienst-Abschnitt (Ueberschrift ## Der Hintergrunddienst als Falle — fünf Fälle, Zeile 237; kein Gate ausserhalb
des Dokuments referenziert diesen Wortlaut — grep -rn "fünf Fälle\|fuenf Faelle" apps/api/src apps/api/scripts: null), Was diese Etappe NICHT entscheidet (Zeile 486). Dazu docs/anleitung-entwicklung.md:310-316: der
Absatz behauptet, Regeln laegen nur auf sieben Tabellen und FavoriteLink
trage KEINE, und DkvService.loadConfig() nutze den ungebundenen Klienten —
beides seit 20260909140000 (16 weitere Tabellen, gemessen: grep -c "ENABLE ROW LEVEL SECURITY" liefert 4 + 3 + 16 = 23) bzw. seit 260909-mir
falsch. Der Absatz nennt FavoriteLink beim Namen und liegt damit in diesem
Bereich; er wird in Aufgabe 3 auf den gemessenen Stand gebracht (scoped
Edit, nur dieser Absatz).
</planning_time_findings>
Aufgabe 1: Die Fehlerrichtung fuer BEIDE Bereiche MESSEN und aufschreiben — Favoritenleiste, SMTP-Zugangsdaten, Startpfad — ueber den generierten Client Der lokale Datenbank-Container `tessera-ctl-db-1` laeuft; `docker inspect tessera-ctl-db-1` liefert eine Adresse. Ohne ihn kann das Wegwerf-Werkzeug nichts messen und die Aufgabe ist zu stoppen, nicht zu schaetzen. apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md apps/api/src/prisma/prisma-tenant.extension.ts (Kopf VOLLSTAENDIG), apps/api/scripts/rls-scratch-check.mjs (Kopf, `report`, `forTenantQuery`, `withAdminPrisma`, `sqlStateOf`, `readRlsWidenMigrationSql`, `readRemainingTenantTablesMigrationSql`, `extractPolicySql`, `readSchemaModelScalarFieldNames`, `runDashboardAreaChecks` VOLLSTAENDIG (Anlage von "WidgetInstance", welche Zeilen am Ende stehen), `runDkvAreaChecks` (die Pruefung `dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile` als Vorbild fuer den Startpfad), `runCalendarAreaChecks` VOLLSTAENDIG (Blatt-Form, Spaltenpruefung, Client-Pruefungen), `runAuthAreaChecks` (die Client-Pruefungen mit Konstruktorname/`code`), `buildInlineExtendedClient`, `main`), apps/api/prisma/schema.prisma (model FavoriteLink, model SmtpConfig, model WidgetInstance), apps/api/prisma/migrations/20260708090000_add_favorite_link/migration.sql (VOLLSTAENDIG), apps/api/prisma/migrations/20260629130000_add_missing_tables/migration.sql (CREATE TABLE "SmtpConfig" und den Index `SmtpConfig_tenantId_key`), apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql (die Bloecke FavoriteLink, SmtpConfig, WidgetInstance), apps/api/src/favorites/favorites.service.ts (VOLLSTAENDIG), apps/api/src/favorites/favorites.controller.ts (VOLLSTAENDIG), apps/api/src/favorites/icon-discovery.service.ts (Kopf, `discoverFavoriteIconUrl`, `fetchIconBytes`), apps/api/src/settings/settings.service.ts (VOLLSTAENDIG), apps/api/src/settings/settings.controller.ts (VOLLSTAENDIG), apps/api/src/mail/mail.module.ts (VOLLSTAENDIG), apps/api/src/mail/mail.service.ts (`sendPasswordResetEmail`, den `try/catch`), apps/api/src/tenders/tender-mail.service.ts (`resolveTransport`), apps/api/src/dkv/dkv-mail.service.ts (`sendExportEmail`), apps/api/src/dkv/dkv.service.ts (Kopfkommentar von `loadAnyActiveConfigForScheduler` VOLLSTAENDIG), apps/api/src/dkv/dkv-scheduler.service.ts (Kopf), apps/api/src/dashboard/dashboard.controller.ts (`extractContext`), apps/web/src/lib/favorites-api.ts, apps/web/src/components/dashboard/widgets/favorites-widget.tsx (den `useEffect` um Zeile 63 und die Leerstelle um Zeile 212), apps/web/src/lib/settings-api.ts (`fetchSmtp`), apps/web/src/components/settings/smtp-settings-form.tsx (den `useEffect` um Zeile 62), apps/web/src/messages/de.json (Schluessel `favorites.empty`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitte `## Bereich dkv` (d1)-(d5) und `## Bereich auth` (h1)-(h5) vollstaendig, als Form; (t4) Befund K; (e) Befund D samt Nachtrag als Form fuer Nachtraege), .planning/WINDOWS.md (Eintrag #21 VOLLSTAENDIG) TEIL 1 — die Messung, zwei Abschnitte. Erweitere `apps/api/scripts/rls-scratch-check.mjs` um `runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results)` und `runSettingsAreaChecks(adminUrl, scratchRoleUrl, results)` und rufe beide in `main()` in dieser Reihenfolge NACH `runAuthAreaChecks` und VOR `runTransactionShapeMeasurement` auf. Beide sind Blaetter (keine spaetere Pruefung setzt auf ihren Tabellen auf); halte die Reihenfolgebedingung im Kopfkommentar fest, wie `runCalendarAreaChecks` es vormacht. Beide beginnen mit der Regelstand-Pruefung (die Widen-Migration darf keine eigene Regel fuer die Tabelle tragen — Form `calendarsource-regelstand-eindeutig`), schneiden die Regel WORTGLEICH aus `20260909140000_rls_remaining_tenant_tables` (`extractPolicySql`), legen die Wegwerf-Tabelle mit SAEMTLICHEN skalaren Spalten an, pruefen die Spaltenmenge zur Laufzeit gegen `readSchemaModelScalarFieldNames()` (bei `FavoriteLink` ist das Relationsfeld `widgetInstance` auszufiltern — deshalb der Helfer aus 260911-fh9, nicht `readSchemaModelFieldNames`) und brechen ab, wenn die Spaltenpruefung durchfaellt (Form von Pruefung 8 in `runCalendarAreaChecks`). `updatedAt` bekommt `DEFAULT CURRENT_TIMESTAMP` als vermerkte Abweichung (Prisma setzt den Wert clientseitig). Alle Pruefungen laufen unter der Rolle ohne `BYPASSRLS`; Vergleichswerte kommen ueber die Wartungsrolle. Jede Pruefung hat eine Belegausgabe mit den beobachteten Werten; wo ein Fehler erwartet wird, Konstruktorname und `code` woertlich — rate das Ergebnis nicht vorweg.runFavoritesAreaChecks — die Wegwerf-Tabelle "FavoriteLink" bekommt den
Fremdschluessel FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE auf die von runDashboardAreaChecks angelegte Tabelle
(Befund C — vorher ueber die Wartungsrolle MESSEN, welche WidgetInstance-
Zeilen stehen, und die Belegausgabe der ersten Pruefung nennt sie).
Zeilen: fav-a1-1, fav-a1-2 (user-a1, TENANT-A, widget-a1), fav-a2-1
(user-a2, TENANT-A, widget-a2), fav-b1-1 (user-b1, TENANT-B, widget-b1);
fav-a1-1 mit iconUrl, fav-a1-2 ohne. Mindestens sieben Pruefungen:
favoritelink-regelstand-eindeutig.favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients— zehn skalare Felder; die Belegausgabe nennt zusaetzlich die gemessenenWidgetInstance-Zeilen.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 vonlist— UNGEBUNDEN:[], waehrend die Wartungsrolle zwei Zeilen sieht. Die Belegausgabe sagt beim Namen: das ist der Wert, aus demfavorites-widget.tsxNoch keine Favoriten.macht.favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen— dieselbe Abfrage ueberbuildInlineExtendedClient(prisma, 'TENANT-A'): genau zwei Zeilen, beideuserId === 'user-a1',widgetId === 'widget-a1'; die Zeile vonuser-a2ist NICHT dabei (die anwendungsseitige Benutzerfilterung), aber — zweite Aussage derselben Messung, eigene Belegausgabe — ein gebundenerfindMany({ where: { widgetId: 'widget-a2' } })unter TENANT-A liefert die Zeile des Kollegen: die Regel kennt keine Benutzerdimension (dieselbe Lehre wiecalendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar).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 inupdate/remove/getIconBytes(T-GWH-02).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 undcodewoertlich (der generierte Client meldet null getroffene Zeilen beideleteanders als Roh-SQL — dkv Befund G in der Client-Form).favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei— gebundenescreateunter TENANT-A mitdata: { 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 gebundenemwidgetInstance.findUniquemessen und ausgeben). Belegausgabe nennt, ob der INSERT gelang oder mit welchem Konstruktor/codeer 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 — gebundenescreateunter TENANT-A mitwidgetId: 'widget-gibt-es-nicht'— muss scheitern (FK-Verletzung, Konstruktorname/codewoertlich): der Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05).favoritelink-gebundenes-anlegen-eigener-mandant-gelingt— gebundenescreateunter TENANT-A mitwidgetId: 'widget-a1'gelingt, die Wartungsrolle liest die Zeile mittenantId === 'TENANT-A',createdAtundupdatedAtgesetzt.
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:
smtpconfig-regelstand-eindeutig.smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients— zehn skalare Felder; zusaetzlichpg_indexesliest den IndexSmtpConfig_tenantId_key(Belegausgabe nenntindexdef).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 demmail.module.tsnach dem Scharfschalten auf Umgebungsvariablen und zuletztlocalhost:1025zurueckfaellt — ein falscher Transport statt einer Meldung.smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile— dieselbe Abfrage ueber die Wartungsrolle (BYPASSRLS, die HEUTIGE Lage): genau EINE Zeile, derentenantIddie 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).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.tsprotokolliertNo SMTP configurationund ueberspringt,dkv-mail.service.tswirft; kein Versand fuer niemanden.smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten— gebunden unter TENANT-A: Zeile mitencryptedPassword === 'enc(a-passwort-platzhalter)',host,fromAddresswie eingefuegt.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).smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut— die Form vonsaveSmtpConfig: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 weiterhinsmtp-a.example.invalidliest. Konstruktorname undcodewoertlich; die Belegausgabe haelt daneben, was 260910-krx fuerDashboardLayoutgemessen hat (PrismaClientUnknownRequestError), und sagt, ob es hier gleich oder anders ausfaellt. Das ist die Kette "leeres Formular -> Administrator traegt neu ein -> Speichern scheitert".smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert— dasselbeupsertgebunden unter TENANT-A: gelingt, die Wartungsrolle liest den neuen Host fuersmtp-a,smtp-bunveraendert,updatedAtgesetzt.
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 TextNoch 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.tsgibtnull, Adapter sendet leeren Rumpf,settings-api.tsres.json()wirft,smtp-settings-form.tsxverschluckt), 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 aufnull(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 ausgetDecryptedSmtpConfig(tenantId)— bereits inDkvMailService/TenderMailServicesteht undMailServiceden Mandanten vonrequestPasswordResetbekommen koennte; Altlast mit Markierung: GEWAEHLT), die UNSYMMETRIE zuldapUNDdkv(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 Mandantenquellereq.tenantIdim Controller als RICHTIG fuer eine ADMIN-Seite (Befund H, D-10) — bewusst nicht auf das Claim umgestellt, mit dem Unterschied zuauth; (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.tswird in Aufgabe 2 fuer die Umbenennung angefasst — hier angekuendigt), das Frontend, Schema und Migrationen,nodemailer.
Aendere in dieser Aufgabe KEINE Datei unter apps/api/src, KEINE unter
apps/api/prisma und KEINE unter apps/web.
DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" && for K in favoritelink-regelstand-eindeutig favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei favoritelink-gebundenes-anlegen-eigener-mandant-gelingt smtpconfig-regelstand-eindeutig smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients smtpconfig-startpfad-generierter-client-ungebunden-liefert-null smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert; do echo "$OUT" | grep -q "^$K: bestanden" || { echo "FEHLENDE ODER FEHLGESCHLAGENE PRUEFUNG: $K"; exit 1; }; done && N=$(echo "$OUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden.$/\1/p') && { test -n "$N" && test "$N" -ge 137 || { echo "PRUEFUNGSZAHL: ${N:-nicht alle bestanden}, erwartet mindestens 137 (120 bisherige plus mindestens 8 + 9 neue)"; exit 1; }; } && S=apps/api/scripts/rls-scratch-check.mjs && grep -q 'async function runFavoritesAreaChecks' "$S" && grep -q 'async function runSettingsAreaChecks' "$S" && grep -q "readSchemaModelScalarFieldNames('FavoriteLink')" "$S" && grep -q "readSchemaModelScalarFieldNames('SmtpConfig')" "$S" && grep -q 'REFERENCES "WidgetInstance"' "$S" && grep -q 'SmtpConfig_tenantId_key' "$S" && grep -q "extractPolicySql(remainingMigrationSql, 'FavoriteLink')|extractPolicySql(.'FavoriteLink')" "$S" && grep -q "extractPolicySql(.'SmtpConfig')" "$S" && awk '/await runAuthAreaChecks(/{a=NR} /await runFavoritesAreaChecks(/{f=NR} /await runSettingsAreaChecks(/{s=NR} /await runTransactionShapeMeasurement(/{t=NR} END{ if(!(a&&f&&s&&t&&a<f&&f<s&&s<t)){print "REIHENFOLGE in main(): runFavoritesAreaChecks und runSettingsAreaChecks muessen nach runAuthAreaChecks und vor runTransactionShapeMeasurement stehen"; exit 1} }' "$S" && D=docs/mandantentrennung-etappe2-fehlerrichtung.md && grep -q '^## Bereich favorites$' "$D" && grep -q '^## Bereich settings$' "$D" && for X in f1 f2 f3 f4 f5 s1 s2 s3 s4 s5; do grep -qE "^### ($X) " "$D" || { echo "FEHLENDER UNTERABSCHNITT: ($X)"; exit 1; }; done && awk '/^## Bereich favorites$/{f=1; next} /^## /{f=0} f && /Noch keine Favoriten./{e=1} f && /favorites-widget.tsx/{w=1} f && /favorites-api.ts/{a=1} f && /icon-discovery.service.ts/{i=1} f && /WidgetInstance/{r=1} f && /dashboard.controller.ts/{c=1} END{ if(!e||!w||!a){print "(f3): die Frontend-Kette (favorites-widget.tsx, favorites-api.ts, Noch keine Favoriten.) ist nicht benannt"; exit 1} if(!i){print "(f5): der Icon-Proxy ist nicht benannt"; exit 1} if(!r){print "(f1): der Fremdschluessel auf WidgetInstance ist nicht benannt"; exit 1} if(!c){print "(f4): die Mandantenquelle / der dashboard-Praezedenzfall ist nicht benannt"; exit 1} }' "$D" && awk '/^## Bereich settings$/{f=1; next} /^## /{f=0} f && /mail.module.ts/{m=1} f && /smtp-settings-form.tsx/{w=1} f && /settings-api.ts/{a=1} f && /tender-mail.service.ts/{t=1} f && /dkv-mail.service.ts/{d=1} f && /Befund K/{k=1} f && /#21/{l=1} f && /SmtpConfig_tenantId_key/{u=1} f && /localhost:1025/{p=1} f && /req.tenantId/{q=1} END{ if(!m||!p){print "(s4): der Startpfad / die Rueckfallkette (mail.module.ts, localhost:1025) ist nicht benannt"; exit 1} if(!w||!a){print "(s3): die Frontend-Kette (smtp-settings-form.tsx, settings-api.ts) ist nicht benannt"; exit 1} if(!t||!d||!k){print "(s2)/(s4): Befund K mit beiden Versandpfaden ist nicht benannt"; exit 1} if(!l){print "(s4): die Entscheidung zu WINDOWS #21 ist nicht benannt"; exit 1} if(!u){print "(s1): der Eindeutigkeitsindex ist nicht benannt"; exit 1} if(!q){print "(s4): die Mandantenquelle des Controllers ist nicht benannt"; exit 1} }' "$D" && awk '/^## Bereich settings$/{f=1} /^## Verweis$/{ if(f){ok=1} f=0 } END{ if(!ok){print "ABSCHNITT ## Bereich settings steht nicht unmittelbar vor ## Verweis"; exit 1} }' "$D" && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ Tests +([0-9]+) passed./\1/p' | head -1) && { test -n "$T" && test "$T" -ge 951 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mindestens 951"; exit 1; }; } && npm --prefix apps/api run type-check && git rev-parse --verify 46f0e78 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 46f0e78 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 46f0e78) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check.mjs|docs/mandantentrennung-etappe2-fehlerrichtung.md|.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }
apps/api/scripts/rls-scratch-check.mjs hat die Abschnitte runFavoritesAreaChecks und runSettingsAreaChecks in der richtigen Reihenfolge, mit mindestens acht bzw. neun namentlich benannten Pruefungen ueber den generierten Client an Wegwerf-Tabellen, deren Spaltenmenge zur Laufzeit gegen die skalaren Felder des Schemas geprueft wird; die FavoriteLink-Tabelle traegt den Fremdschluessel auf WidgetInstance, die SmtpConfig-Tabelle den Eindeutigkeitsindex; der Startpfad ist als findFirst() ohne Bedingung in BEIDEN Zustaenden gemessen (Wartungsrolle: beliebige Zeile; Wegwerf-Rolle: null), der Versandpfad als Befund K, der Speicherkonflikt mit Konstruktorname und code woertlich, der Fremdschluessel-Durchgriff mit Ergebnis; alle Pruefungen bestehen (mindestens 137). docs/mandantentrennung-etappe2-fehlerrichtung.md hat ## Bereich favorites ((f1)-(f5)) und ## Bereich settings ((s1)-(s5)) unmittelbar vor ## Verweis, mit woertlicher Werkzeugausgabe, Signaltabellen, beiden Frontend-Ketten, dem Startpfad-Absatz mit beiden Zustaenden, Unsymmetrie und Ledger-Entscheidung, und dem Absatz, dass Befund K erfuellt ist. Baseline gehalten (mindestens 951 Tests, Typpruefung sauber). Unter apps/api/src, apps/api/prisma, apps/web und den Compose-/Umgebungsdateien ist nichts geaendert.
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 vonuser-a1fuerwidget-a1, sortiert nachpositionaufsteigend, bei Gleichstand nachtitle; die Zeile vonuser-a2und die vonwidget-a2fehlen.listunter FREMDEM Mandanten (t2, Zeilen untert1):[]— kein Fehler (das ist der Wert, aus dem das WidgetNoch keine Favoriten.macht).listohnewidgetId:BadRequestException, KEIN Klient erzeugt.create(t1, user-a1, dto)ohneiconUrl: URL normalisiert (ctl.de->https://ctl.de), Icon-Suche mit der NORMALISIERTEN URL aufgerufen, gebundenercreatemituserId,tenantId: 't1',widgetId,position: 0als Vorgabe.createmiticonUrl: KEINE Icon-Suche, gespeicherter Wert wie uebergeben.createmitwidgetIdeines Widgets, das einem ANDEREN Benutzer desselben Mandanten gehoert:NotFoundExceptionmit Meldung woertlichWidget not found, KEINcreate, KEINE Icon-Suche (T-GWH-05).createmitwidgetIdeines Widgets unter FREMDEM Mandanten:NotFoundExceptionWidget not found— die Meldung nennt weder Halter noch Mandanten; KEINcreate.createmit unbekannterwidgetId: dieselbeNotFoundException.update(t1, id, user-a1, dto), eigene Zeile: Titel/URL/Position gemergt, gebundenerupdate;iconUrlexplizitnullim DTO -> Icon-Suche gegen die EFFEKTIVE URL (neue, sonst gespeicherte);iconUrlgesetzt -> keine Icon-Suche.update, Zeile eines ANDEREN Benutzers desselben Mandanten:NotFoundExceptionFavoriteLink not found, KEIN Schreibzugriff.update, Zeile unter FREMDEM Mandanten (Klientt2, Zeilet1):NotFoundException, KEIN Schreibzugriff — der gebundenefindUniqueliefertnull, 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 miticonUrl->fetchIconBytesGENAU mit der GESPEICHERTEN URL aufgerufen, Rueckgabe durchgereicht; Zeile ohneiconUrl->NotFoundException,fetchIconBytesNICHT aufgerufen; fremder Mandant ->NotFoundException;fetchIconByteswirft ->HttpExceptionmit 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 fuercreatezwei 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 mitencryptedPassword(der Controller macht daraushasPassword), OHNE weitere Felder ausserhalb desselect; fremder Mandant (t2):null.saveSmtpConfig(t1, dto)mit Kennwort:encryptGENAU mit dem Klartext aufgerufen; gebundenerupsertmitwhere.tenantId === 't1',createundupdatetragenencryptedPassword: 'enc(...)'; Rueckgabe OHNEencryptedPassword(SMTP_SAFE_SELECT); der Klartext taucht in KEINEM Protokolleintrag auf.saveSmtpConfigOHNE Kennwort (leer oder fehlend):encryptNICHT aufgerufen,updatetraegt KEINEN SchluesselencryptedPassword(bestehendes bleibt),usernamefehlend ->null.saveSmtpConfiguntert2, wenn nurt1eine Zeile hat: der gebundene Nachbau legt fuert2an (die Zeile vont1bleibt unveraendert) — die richtige Semantik NACH der Bindung.getDecryptedSmtpConfig(t1):decryptmitencryptedPasswordaufgerufen, Rueckgabe mithost,port,encryption,username,fromAddress,decryptedPassword(genau die Form, dietender-mail.service.spec.tsalsBASE_SMTP_CONFIGerwartet); ohneencryptedPassword->decryptedPassword: null,decryptNICHT aufgerufen; fremder Mandant ->null,decryptNICHT aufgerufen (T-GWH-01).testSmtpConfig(t1, dto)ohne Kennwort/Benutzername im DTO: greift ueber den GEBUNDENEN Klienten auf die gespeicherten Werte zurueck (createTransportmitauth.useraus der Zeile,auth.passentschluesselt),verifyaufgerufen,{ success: true }; mittestTo:sendMailstattverify,from === dto.fromAddress,to === dto.testTo;verifywirft ->{ success: false }, kein Wurf nach aussen; fremder Mandant ohne Kennwort im DTO:authohne Benutzer (undefined), kein Fehler.- Startpfad
loadAnySmtpConfigForStartupTransport(): laeuft ueber den UNGEBUNDENEN Nachbau (findFirst), liefert{ host, port, secure, requireTLS, username, password, fromAddress }mitsecurefuerssl-tlsundrequireTLSfuerstarttls,passwordentschluesselt; leerer Nachbau ->null; und der NULL-KLIENTEN-NACHWEIS:vi.mocked(forTenant).mock.calls.length === 0nach dem Aufruf — der Startpfad erzeugt KEINEN gebundenen Klienten, gemessen, nicht behauptet. - Wachhund je Anfrageweg: genau EIN gebundener Klient je Aufruf von
getSmtpConfig,saveSmtpConfig,getDecryptedSmtpConfig;testSmtpConfigerzeugt genau einen (uebergetDecryptedSmtpConfig), wenn es zurueckgreift, und KEINEN, wenn Kennwort und Benutzername im DTO stehen. TEIL 1 —favorites.service.ts. Stelle alle fuenf Methoden um, je Methode EIN Klient in der Zuweisungsform, dierls-access-inventory.spec.tserkennt: KonstantetenantPrismaaus dem Bindungshilfsmittel mitthis.prismaund dem uebergebenen Mandanten,as any. Der Mandant wird ERSTER Parameter jeder Methode (Konvention der Vorgaenger):list(tenantId, userId, widgetId),create(tenantId, userId, dto)(heutecreate(userId, tenantId, dto)— Reihenfolge tauschen, den Aufrufer mit),update(tenantId, id, userId, dto),remove(tenantId, id, userId),getIconBytes(tenantId, id, userId). Die Besitzpruefungen (findUnique, Vergleichlink.userId !== userId, dannupdate/deleteueber die Kennung) bleiben WORTGLEICH und laufen auf DEMSELBEN Klienten wie der Schreibzugriff. Meldungen, Sortierung, Normalisierung, Icon-Suche, 502-Uebersetzung bleiben WORTGLEICH.
In create, VOR der Icon-Suche, der neue Besitzriegel (T-GWH-05, nur wenn
Pruefung 7 aus Aufgabe 1 den Durchgriff des Fremdschluessels bestaetigt hat —
sonst entfaellt er, mit Belegzeile im SUMMARY): tenantPrisma.widgetInstance .findUnique({ where: { id: dto.widgetId }, select: { userId: true } }); ist
das Ergebnis null oder widget.userId !== userId, NotFoundException
mit Meldung woertlich Widget not found — keine Aussage ueber fremde
Mandanten oder fremde Halter, dieselbe Antwort fuer "gibt es nicht", "gehoert
einem Kollegen" und "liegt bei einem fremden Mandanten" (das schliesst das
Existenzorakel). Der Riegel steht VOR der Icon-Suche, damit ein fremdes
Widget keinen Netzabruf ausloest.
Kopfkommentar der Klasse: warum gebunden (260911-gwh), dass der Mandant aus
extractContext des Controllers kommt (Befund H: dieselbe Quelle wie
dashboard, weil der Link am Widget haengt — ausdruecklich NICHT der
auth-Praezedenzfall, mit Grund), dass die Regel keine Benutzerdimension hat
und die userId-Filter deshalb bleiben (Etappe-3-Entscheidung (2)), und
dass der Fremdschluessel am Zeilenschutz vorbei prueft (Pruefung 7, mit
Ergebnis) — deshalb der Riegel. Kommentare in dieser Datei duerfen den
ungebundenen Zugriff auf das Favoritenmodell NICHT woertlich nennen (das
Gate zaehlt ueber die ganze Datei).
TEIL 2 — favorites.controller.ts. extractContext bleibt WORTGLEICH;
alle fuenf Handler reichen tenantId als ersten Parameter durch —
list(tenantId, userId, widgetId), create(tenantId, userId, dto),
getIconBytes(tenantId, id, userId), update(tenantId, id, userId, dto),
remove(tenantId, id, userId). Kopfkommentar der Klasse ergaenzen: die
Mandantenquelle ist die vom Guard gesetzte Anfragekennung (fuer SUPER_ADMIN
per Kopfzeile umschaltbar), WORTGLEICH mit dashboard.controller.ts, weil
FavoriteLink ueber widgetId an WidgetInstance haengt und beide unter
derselben Kennung liegen muessen; das Favoriten-Frontend sendet die Kopfzeile
heute nicht (gemessen), die Entscheidung haengt an der Bauform.
TEIL 3 — settings.service.ts. getSmtpConfig, saveSmtpConfig,
getDecryptedSmtpConfig je mit EINEM Klienten tenantPrisma
(Zuweisungsform); select, SMTP_SAFE_SELECT, Verschluesselung,
Rueckgabeformen WORTGLEICH. testSmtpConfig hat keinen eigenen
Datenbankzugriff und bleibt bis auf Kommentare unveraendert.
Der Startpfad: benenne getStartupSmtpConfig() um in
loadAnySmtpConfigForStartupTransport() (dkv-Praezedenzfall: ein Name, den
niemand fuer einen Anfrageweg haelt), lasse ihn auf this.prisma mit dem
unveraenderten findFirst() ohne Bedingung, und schreibe den Kopfkommentar
nach der Vorlage von loadAnyActiveConfigForScheduler NEU: BLEIBT bewusst
UNGEBUNDEN (260911-gwh, sechster Fall der Hintergrunddienst-Falle); HEUTE
bereits falsch — bei mehreren Mandanten traegt der SMTP-Server und Absender
eines BELIEBIGEN Mandanten alle Kennwort-Zuruecksetzungs- und
Willkommensmails (T-GWH-03); NACH DEM SCHARFSCHALTEN null, und
mail.module.ts faellt auf Umgebungsvariablen und zuletzt localhost:1025
zurueck, MailService faengt den Transportfehler — das Verstummen erzeugt
KEINE Protokollzeile (die Unsymmetrie zu ldap UND dkv); binden wuerde den
Pfad garantiert leer laufen lassen (kein Kontext beim Start); der Umbau auf
Transport je Versand aus getDecryptedSmtpConfig(tenantId) — die Form, die
DkvMailService/TenderMailService bereits haben, MailService muesste
den Mandanten von requestPasswordReset entgegennehmen — ist eine
Funktionsaenderung, NICHT dieser Auftrag; Verweis auf den Ledger-Eintrag
(die Nummer vergibt erst Aufgabe 3 — hier zunaechst woertlich der
Platzhalter WINDOWS #TBD-GWH, den Aufgabe 3 in DIESER Datei und in
mail.module.ts durch die vergebene Nummer ersetzt; das Gate von Aufgabe 3
zaehlt den Platzhalter auf null) und auf (s4). Kommentare in dieser Datei duerfen den ungebundenen Zugriff auf das
SMTP-Modell nicht woertlich nennen (das Gate zaehlt genau EINE Stelle in der
Datei — den Startpfad im Code).
TEIL 4 — mail.module.ts. Aufruf auf den neuen Namen umstellen; den
Modulkommentar und den Zeilenkommentar an der Aufrufstelle so umschreiben,
dass beide Zustaende stehen (der Satz "single-tenant default" verschwindet
und wird durch die beiden Zustaende und den Verweis auf den Ledger-Eintrag
ersetzt). KEINE Aenderung an der Rueckfallkette selbst, KEIN Umbau des
Transports.
TEIL 5 — die beiden Testdateien aus <behavior>. Danach VIER
Falsifizierungsnachweise, jeder zurueckgenommen und mit Testname und
Fehlermeldung woertlich notiert: (a) ersetze in list probeweise den
gebundenen Klienten durch den ungebundenen Basisclient —
favorites.service.spec.ts wird rot (erwartete Form: der Nachbau hat kein
ungebundenes Favoritenmodell); (b) entferne probeweise den Widget-Riegel in
create — genau die drei Widget not found-Faelle werden rot; (c) ersetze
in getDecryptedSmtpConfig probeweise den gebundenen Klienten durch den
ungebundenen — settings.service.spec.ts wird rot (kein findUnique auf
dem ungebundenen Nachbau); (d) binde probeweise den Startpfad — der
Null-Klienten-Nachweis UND der Startpfad-Fall werden rot (der gebundene
Nachbau hat kein findFirst). Zaehle jeweils, wie viele Faelle rot wurden,
und nenne die Zahl.
Aendere keine Datei ausserhalb der sechs genannten. Insbesondere: KEINE
Aenderung an settings.controller.ts, den DTOs, icon-discovery.service.ts,
mail.service.ts, tender-mail.service.ts, dkv-mail.service.ts,
dashboard.controller.ts.
npm --prefix apps/api run test -- src/favorites/favorites.service.spec.ts src/settings/settings.service.spec.ts && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ Tests +([0-9]+) passed./\1/p' | head -1) && { test -n "$T" && test "$T" -gt 951 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mehr als 951"; exit 1; }; } && TF=$(echo "$TOUT" | sed -nE 's/^ Test Files +([0-9]+) passed./\1/p' | head -1) && { test -n "$TF" && test "$TF" -ge 62 || { echo "TESTDATEIEN: ${TF:-unbekannt}, erwartet mindestens 62"; exit 1; }; } && npm --prefix apps/api run type-check && F=apps/api/src/favorites/favorites.service.ts && SRC=$(grep -vE '^\s*(//|*|/*)' "$F") && test 0 -eq "$(grep -c 'this.prisma.favoriteLink' "$F")" && B=$(printf '%s\n' "$SRC" | grep -o 'tenantPrisma.favoriteLink.' | wc -l | tr -d ' ') && { test "$B" -eq 7 || { echo "BINDUNG: $B gebundene Favoritenzugriffe, erwartet genau 7"; exit 1; }; } && C=$(printf '%s\n' "$SRC" | grep -o 'forTenant(this.prisma, tenantId)' | wc -l | tr -d ' ') && { test "$C" -eq 5 || { echo "KLIENTEN: $C Aufrufstellen des Bindungshilfsmittels in favorites.service.ts, erwartet genau 5"; exit 1; }; } && for M in list create update remove getIconBytes; do REG=$(awk -v m="async $M(" 'index($0,m){f=1} f{print} f&&/^ }$/{f=0}' "$F" | grep -vE '^\s*(//|*|/*)'); K=$(printf '%s\n' "$REG" | grep -c 'forTenant(this.prisma, tenantId)'); test "$K" -eq 1 || { echo "METHODE $M: $K Klienten, erwartet genau 1"; exit 1; }; U=$(printf '%s\n' "$REG" | grep -c 'this.prisma.[a-zA-Z]'); test "$U" -eq 0 || { echo "METHODE $M: $U ungebundene Modellzugriffe"; exit 1; }; done && grep -q 'async list(tenantId: string, userId: string, widgetId: string)' "$F" && grep -q 'async create(tenantId: string, userId: string, dto: CreateFavoriteDto)' "$F" && grep -q 'Widget not found' "$F" && grep -q 'tenantPrisma.widgetInstance.findUnique' "$F" && G=apps/api/src/settings/settings.service.ts && GS=$(grep -vE '^\s*(//|*|/*)' "$G") && U2=$(printf '%s\n' "$GS" | grep -c 'this.prisma.smtpConfig') && { test "$U2" -eq 1 || { echo "STARTPFAD: $U2 ungebundene SMTP-Zugriffe im Code von settings.service.ts, erwartet genau 1 (nur der Startpfad)"; exit 1; }; } && test 1 -eq "$(grep -c 'this.prisma.smtpConfig' "$G")" && B2=$(printf '%s\n' "$GS" | grep -o 'tenantPrisma.smtpConfig.' | wc -l | tr -d ' ') && { test "$B2" -eq 3 || { echo "BINDUNG: $B2 gebundene SMTP-Zugriffe, erwartet genau 3"; exit 1; }; } && C2=$(printf '%s\n' "$GS" | grep -o 'forTenant(this.prisma, tenantId)' | wc -l | tr -d ' ') && { test "$C2" -eq 3 || { echo "KLIENTEN: $C2 Aufrufstellen in settings.service.ts, erwartet genau 3"; exit 1; }; } && grep -q 'async loadAnySmtpConfigForStartupTransport()' "$G" && REG=$(awk 'index($0,"async loadAnySmtpConfigForStartupTransport("){f=1} f{print} f&&/^ }$/{f=0}' "$G" | grep -vE '^\s*(//|*|/*)') && test 1 -eq "$(printf '%s\n' "$REG" | grep -c 'this.prisma.smtpConfig.findFirst()')" && test 0 -eq "$(printf '%s\n' "$REG" | grep -c 'forTenant(')" && awk 'index($0,"async loadAnySmtpConfigForStartupTransport("){exit} {print}' "$G" | tail -40 | grep -q 'UNGEBUNDEN' && test 0 -eq "$(grep -rc 'getStartupSmtpConfig' apps/api/src | awk -F: '{s+=$2} END{print s}')" && grep -q 'loadAnySmtpConfigForStartupTransport()' apps/api/src/mail/mail.module.ts && test 0 -eq "$(grep -c 'single-tenant default' apps/api/src/mail/mail.module.ts)" && test 0 -eq "$(grep -c 'tenantPrisma.findFirst|tenantPrisma.smtpConfig.findFirst' "$G")" && H=apps/api/src/favorites/favorites.controller.ts && for M in 'list(tenantId, userId, widgetId)' 'create(tenantId, userId, dto)' 'getIconBytes(' 'update(tenantId, id, userId, dto)' 'remove(tenantId, id, userId)'; do grep -qF "this.favoritesService.$M" "$H" || { echo "CONTROLLER: Aufruf $M nicht in der erwarteten Form"; exit 1; }; done && grep -q 'tenantId,$' "$H" && SF=apps/api/src/favorites/favorites.service.spec.ts && grep -q '__makeBoundClient' "$SF" && grep -q 'mock.calls.length' "$SF" && grep -q 'Widget not found' "$SF" && grep -q 'fetchIconBytes' "$SF" && SS=apps/api/src/settings/settings.service.spec.ts && grep -q '__makeBoundClient' "$SS" && grep -q "vi.mock('nodemailer'" "$SS" && grep -q 'mock.calls.length' "$SS" && grep -q 'loadAnySmtpConfigForStartupTransport' "$SS" && grep -q 'toBe(0)|toHaveLength(0)|toEqual(0)' "$SS" && git rev-parse --verify 46f0e78 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 46f0e78 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 46f0e78) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check.mjs|docs/mandantentrennung-etappe2-fehlerrichtung.md|apps/api/src/favorites/favorites.service.ts|apps/api/src/favorites/favorites.service.spec.ts|apps/api/src/favorites/favorites.controller.ts|apps/api/src/settings/settings.service.ts|apps/api/src/settings/settings.service.spec.ts|apps/api/src/mail/mail.module.ts|.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }
favorites.service.ts: alle fuenf Methoden nehmen den Mandanten als ersten Parameter und laufen je ueber genau EINEN Klienten tenantPrisma (sieben gebundene Favoritenzugriffe, fuenf Aufrufstellen des Bindungshilfsmittels, ein gebundener widgetInstance-Lesezugriff als Besitzriegel in create mit Widget not found); kein ungebundener Favoritenzugriff, auch nicht in Kommentaren. favorites.controller.ts reicht tenantId an alle fuenf Aufrufe durch, extractContext unveraendert, Kopfkommentar nennt Quelle und Praezedenzfall. settings.service.ts: getSmtpConfig, saveSmtpConfig, getDecryptedSmtpConfig je ueber EINEN Klienten (drei gebundene Zugriffe, drei Aufrufstellen); der Startpfad heisst loadAnySmtpConfigForStartupTransport(), laeuft unveraendert auf dem ungebundenen Klienten mit findFirst() ohne Bedingung — die einzige ungebundene Stelle der Datei —, traegt den Kopfkommentar mit beiden Zustaenden, Unsymmetrie, Reparaturweg, Ledger-Verweis; der alte Name kommt in apps/api/src nicht mehr vor; mail.module.ts ruft den neuen Namen auf und nennt beide Zustaende statt "single-tenant default". Beide Testdateien existieren neu mit dem Zwei-Klienten-Nachbau (ungebunden ohne Anfrage-Modelle, gebunden ohne findFirst), jedem in <behavior> genannten Fall, Wachhund und Null-Klienten-Nachweis; alle vier Falsifizierungsnachweise durchgefuehrt, zurueckgenommen, woertlich notiert. Testzahl gestiegen, mindestens 62 Testdateien, Typpruefung sauber, Schema/DTOs/Controller settings/Icon-Proxy/Versanddienste unveraendert.
(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):
- Uebersichtszeilen
favoritesundsettingsmit 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**): fuerfavorites, welche fuenf Methoden gebunden wurden und dass der Besitzriegel einen zusaetzlichen gebundenenwidgetInstance-Lesezugriff einfuehrt (wenn gebaut — Zahl aus der Messung); fuersettings, 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)). - 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.
- Bestandsaufnahme:
favorites.service.ts | favoriteLinkStand vonungebundenaufgebunden, Begruendung NEU (fuenf Methoden, ein Klient je Methode, Besitzpruefungen bleiben, Mandantenquelle wiedashboard, 260911-gwh); NEUE Zeileapps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebundenmit Begruendung (Besitzriegel vor dem Anlegen, weil der Fremdschluessel am Zeilenschutz vorbei prueft — Pruefung 7) — NUR, wenn der Riegel gebaut wurde;settings.service.ts | smtpConfigStand vonungebundenaufgemischt, Begruendung NEU in der Form derdkv.service.ts/dkvModuleConfig-Zeile (die Mischung stammt ausschliesslich vom benannten Startpfad, keine uebersehene Fundstelle; Befund K erfuellt). Ohne Schritt 3 istrls-access-inventory.spec.tsam Ende dieser Aufgabe rot (Stand-Vergleich und, bei neuer Fundstelle, Vollstaendigkeit). - 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, Tabellemuss-mandantengebunden33 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. - Hintergrunddienst-Abschnitt: Ueberschrift von
fünf Fälleaufsechs 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 fuerrls-preflight.mjs, UND der Satz, dass die Reihenfolgebedingung Befund K (tenders(t4),dkv(d4)) fuer den VERSANDPFADgetDecryptedSmtpConfigmit diesem Lauf ERFUELLT ist — die Etappe-4-Vorabpruefung fuehrt sie nicht mehr. Dazu ein PLAIN-Absatz**Stand 260911-gwh — der Bereichfavoritesfügt diesem Abschnitt keinen weiteren Fall hinzu, gemessen statt angenommen (Befund K).**mit der Anweisung und demsetTimeout-Treffer im Icon-Proxy (Form von 260911-cwh). - Abschnitt
Was diese Etappe NICHT entscheidet: ein NEUER Punkt — wie das Mailmodul kuenftig je Mandant versendet (Transport je Versand ausgetDecryptedSmtpConfig(tenantId), VorlageDkvMailService/TenderMailService;MailServicebraucht dafuer den Mandanten, denrequestPasswordResetaus der Funktionszeile hat) — eine Funktion, Verweis auf (s4)(a) und den Ledger-Eintrag; und ein ZWEITER Punkt in der durchgestrichenen Form (~~...~~ **Aufgelöst (260911-gwh):**) NUR, wenn der Abschnitt heute einen Punkt zur settings-Reihenfolgebedingung fuehrt (gemessen: keinen — dann stattdessen ein Satz am Ende des neuen Hintergrunddienst-Absatzes, wie in Schritt 5 verlangt).
TEIL 3 — die Kritikschrift. (a) Nachtrag unter Befund K in (t4), in der
Form des Nachtrags unter Befund D in (e): Original bleibt stehen, darunter
ein Absatz **Nachtrag (260911-gwh):** — getDecryptedSmtpConfig(tenantId)
laeuft seit Aufgabe 2 dieses Laufs ueber forTenant(), die
Reihenfolgebedingung ist erfuellt, Verweis auf (s4)(b) und die
Bestandsaufnahme-Zeile settings.service.ts/smtpConfig. (b) Derselbe
Nachtrag unter dem Uebergaben-Absatz in (d4) (nur der settings-Teil;
dkv.seed.ts/module-registry ist seit 260910-exd gebunden — pruefen und,
falls zutreffend, in demselben Nachtrag mit einem Satz nennen). (c) Ein
NEUER Abschnitt ## Etappe 2 — Abschluss unmittelbar NACH ## Bereich settings und VOR ## Verweis: was Etappe 2 in Summe geliefert hat — die
Zahl der Laeufe (aus STATE.md gezaehlt: zwoelf Bereichs-/Regel-Laeufe von
260909-ipc bis 260911-gwh, nachzaehlen), die Summenzeile der
Uebersichtstabelle VORHER (aus dem Klassifikationsdokument: der **war ...**-Ausgangswert, 227 Rohtreffer laut Kopf) und NACHHER (die Summenzeile
nach Schritt 2 oben, woertlich abgeleitet), die Zahl der Paare und die
Klassen-Verteilung (aus Schritt 4), die Liste der BEWUSST ungebundenen
Reste je Bereich mit ihrem Grund in je einem Satz (tenders: D-03-Katalog,
Fan-out-Adapter, RSS-Verwaltung #24, uebergreifende Haelften; ldap:
getAllActiveConfigs, resolveEmailForWrite; dkv: #21; user:
findByUsername, Erstanlage, Treiber; module-registry/dashboard:
Modulkatalog; auth: drei Anmeldefunktionen; tenant: die Mandantentabelle;
settings: der sechste Fall) — jeden Bereich aus seinem eigenen Abschnitt
dieses Dokuments bzw. der Uebersichtszeile abgeschrieben, nicht aus dem
Gedaechtnis —, die Zahl der offenen Ledger-Eintraege dieser Etappe (aus
WINDOWS.md gezaehlt, mit Nummern), die Zahl der Pruefungen des Werkzeugs
(die Zeile Alle N Pruefungen bestanden. des letzten Laufs) und der Tests
(letzte Testausgabe). Dann, was fuer Etappe 3 bleibt, je ein Satz mit
Verweis auf die Stelle: Anmeldeweg unter je Mandant eindeutigen Namen
((h4)(a)); Benutzerdimension der Regeln (Etappe-3-Entscheidung (2), (k4)/
(f4)); Modulkatalog-Regel (Befund E module-registry); Kennzeichnung der
bewusst-uebergreifend-Stellen; Mandantenwechsel im Digest ((t4)). Und was
Etappe 4 VOR dem Scharfschalten pruefen muss (rls-preflight.mjs): die in
den Bereichsabschnitten benannten Vorabpruefungen, als Liste mit Verweis —
OHNE die erfuellte Befund-K-Bedingung.
TEIL 4 — die Anleitung. Bringe in docs/anleitung-entwicklung.md den
Absatz um Zeile 310-316 (app.current_tenant wird von Postgres
Row-Level-Security ausgewertet ...) und den Folgeabsatz (Was ein Entwickler nie vergessen darf) mit scoped Edit auf den gemessenen Stand: die Regeln
liegen seit 20260909140000_rls_remaining_tenant_tables auf 23 Tabellen
(grep -c "ENABLE ROW LEVEL SECURITY" ueber die drei Migrationen, zur
Ausfuehrungszeit gezaehlt), FavoriteLink und SmtpConfig eingeschlossen;
die Tabellen OHNE Regel sind die im Klassifikationsdokument als
keine-mandantengebundene-tabelle gefuehrten (Tenant, Module,
Tender, ... — aus der Bestandsaufnahme ableiten, nicht raten);
DkvService.loadConfig() ist seit 260909-mir gebunden — das Beispiel fuer
"manuelles tenantId-Filter" ersetzen durch die tatsaechliche Regel: jeder
Zugriff auf eine mandantengebundene Tabelle laeuft ueber einen dienst-intern
mit forTenant() gebundenen Klienten tenantPrisma, die where-Filter
ueber userId bleiben zusaetzlich; Verweis auf die beiden
Mandantentrennungs-Dokumente. Nichts sonst in dieser Datei.
TEIL 5 — zwei Falsifizierungsnachweise fuer die Dokument-Gates, jeder
zurueckgenommen und mit Meldung woertlich notiert: (a) setze die
Bestandsaufnahme-Zeile favorites.service.ts | favoriteLink probeweise
zurueck auf ungebunden — rls-access-inventory.spec.ts muss rot werden;
(b) setze die Uebersichtszeile settings probeweise auf eine falsche Zahl —
das herleitende Gate dieser Aufgabe muss fehlschlagen.
Aendere keine Datei ausserhalb der sechs genannten.
DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && SOUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$SOUT" | tail -1 && N=$(echo "$SOUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden.$/\1/p') && { test -n "$N" && test "$N" -ge 137 || { echo "WERKZEUG: ${N:-nicht alle} Pruefungen bestanden, erwartet mindestens 137"; exit 1; }; } && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ Tests +([0-9]+) passed./\1/p' | head -1) && { test -n "$T" && test "$T" -gt 951 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mehr als 951"; exit 1; }; } && npm --prefix apps/api run type-check && test 0 -eq "$(grep -rc 'TBD-GWH' apps/api/src docs | awk -F: '{s+=$2} END{print s}')" && grep -qE 'WINDOWS #[0-9]+' apps/api/src/settings/settings.service.ts && grep -qE 'WINDOWS #[0-9]+' apps/api/src/mail/mail.module.ts && K=docs/mandantentrennung-zugriffsklassifikation.md && FU=$(grep -ro "this.prisma.[a-zA-Z]" apps/api/src/favorites | grep -v spec | wc -l | tr -d ' ') && FB=$(grep -ro "tenantPrisma.[a-zA-Z]." apps/api/src/favorites | grep -v spec | wc -l | tr -d ' ') && SU=$(grep -ro "this.prisma.[a-zA-Z]" apps/api/src/settings | grep -v spec | wc -l | tr -d ' ') && SB=$(grep -ro "tenantPrisma.[a-zA-Z]." apps/api/src/settings | grep -v spec | wc -l | tr -d ' ') && { test "$FU" -eq 0 || { echo "favorites: $FU ungebundene Rohtreffer, erwartet 0"; exit 1; }; } && { test "$FB" -ge 7 || { echo "favorites: $FB gebundene Rohtreffer, erwartet mindestens 7"; exit 1; }; } && { test "$SU" -eq 1 || { echo "settings: $SU ungebundene Rohtreffer, erwartet 1"; exit 1; }; } && { test "$SB" -eq 3 || { echo "settings: $SB gebundene Rohtreffer, erwartet 3"; exit 1; }; } && { grep -qE "^| favorites | ${FU} | ${FB} | **war 7/0**" "$K" || { echo "UEBERSICHTSZEILE favorites nennt nicht ${FU}/${FB} im etablierten Stil"; exit 1; }; } && { grep -qE "^| settings | ${SU} | ${SB} | **war 4/0**" "$K" || { echo "UEBERSICHTSZEILE settings nennt nicht ${SU}/${SB} im etablierten Stil"; exit 1; }; } && TU=0 && TB=0 && for d in apps/api/src//; do u=$(grep -ro "this.prisma.[a-zA-Z]" "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); b=$(grep -ro "tenantPrisma.[a-zA-Z]*." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); TU=$((TU+u)); TB=$((TB+b)); done && { grep -qE "^| **Summe** | **${TU}** | **${TB}** |" "$K" || { echo "SUMMENZEILE nennt nicht die abgeleiteten Werte ${TU}/${TB}"; exit 1; }; } && grep -qE '^| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden |' "$K" && grep -qE '^| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | gemischt |' "$K" && { if grep -q 'tenantPrisma.widgetInstance.' apps/api/src/favorites/favorites.service.ts; then grep -qE '^| apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden |' "$K" || { echo "BESTANDSAUFNAHME: neue Zeile favorites.service.ts/widgetInstance fehlt"; exit 1; }; fi; } && P=$(grep -cE '^| apps/api/src/[^|]+ | [a-zA-Z]+ | (muss-mandantengebunden|bewusst-uebergreifend|keine-mandantengebundene-tabelle|beides) | (gebunden|ungebunden|gemischt) |' "$K") && { grep -qE "^## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, ${P} Paare)" "$K" || { echo "KLASSEN-VERTEILUNG: Ueberschrift nennt nicht die gezaehlten ${P} Paare"; exit 1; }; } && { grep -qE "^| **Summe** | **${P}** |$" "$K" || { echo "KLASSEN-VERTEILUNG: Summe nennt nicht ${P}"; exit 1; }; } && MM=$(grep -cE '^| apps/api/src/[^|]+ | [a-zA-Z]+ | muss-mandantengebunden |' "$K") && { grep -qE "^| muss-mandantengebunden | ${MM} |$" "$K" || { echo "KLASSEN-VERTEILUNG: muss-mandantengebunden nennt nicht ${MM}"; exit 1; }; } && grep -q '^**Stand 260911-gwh (Aufgabe 3)' "$K" && grep -q '^## Der Hintergrunddienst als Falle — sechs Fälle$' "$K" && test 0 -eq "$(grep -c 'fünf Fälle' "$K")" && awk '/^## Der Hintergrunddienst als Falle/{f=1; next} /^## /{f=0} f && /sechste Fall/{s=1} f && /loadAnySmtpConfigForStartupTransport/{m=1} f && /mail.module.ts/{o=1} f && /Befund K/{k=1} f && /favorites/{v=1} f && /localhost:1025|Rückfallkette|Rueckfallkette/{r=1} END{ if(!s||!m||!o){print "HINTERGRUNDDIENST: der sechste Fall (mail.module.ts / loadAnySmtpConfigForStartupTransport) fehlt"; exit 1} if(!k){print "HINTERGRUNDDIENST: die erfuellte Befund-K-Bedingung ist nicht benannt"; exit 1} if(!v){print "HINTERGRUNDDIENST: der favorites-Absatz fehlt"; exit 1} if(!r){print "HINTERGRUNDDIENST: die Rueckfallkette ist nicht benannt"; exit 1} }' "$K" && awk '/^## Was diese Etappe NICHT entscheidet/{f=1; next} /^## /{f=0} f && /getDecryptedSmtpConfig/{g=1} f && /260911-gwh/{w=1} END{ if(!g||!w){print "NICHT ENTSCHEIDET: der Punkt zum Mehrmandanten-Versand (260911-gwh) fehlt"; exit 1} }' "$K" && D=docs/mandantentrennung-etappe2-fehlerrichtung.md && grep -q '^## Etappe 2 — Abschluss$' "$D" && awk '/^## Bereich settings$/{s=NR} /^## Etappe 2 — Abschluss$/{a=NR} /^## Verweis$/{v=NR} END{ if(!(s&&a&&v&&s<a&&a<v)){print "ABSCHLUSS-ABSCHNITT steht nicht zwischen Bereich settings und Verweis"; exit 1} }' "$D" && awk -v tu="$TU" -v tb="$TB" -v p="$P" '/^## Etappe 2 — Abschluss$/{f=1; next} /^## /{f=0} f && index($0, tu){u=1} f && index($0, tb){b=1} f && index($0, p " Paare"){q=1} f && /rls-preflight.mjs/{r=1} f && /Etappe 3/{e=1} f && /WINDOWS/{w=1} END{ if(!u||!b){print "ABSCHLUSS: die abgeleitete Summenzeile (" tu "/" tb ") ist nicht genannt"; exit 1} if(!q){print "ABSCHLUSS: die Paarzahl (" p " Paare) ist nicht genannt"; exit 1} if(!r||!e||!w){print "ABSCHLUSS: Etappe 3, rls-preflight.mjs oder die Ledger-Eintraege sind nicht genannt"; exit 1} }' "$D" && awk '/^### (t4)/{f=1} /^### (t5)/{f=0} f && /Nachtrag (260911-gwh)/{n=1} END{ if(!n){print "(t4): Nachtrag unter Befund K fehlt"; exit 1} }' "$D" && awk '/^### (d4)/{f=1} /^### (d5)/{f=0} f && /Nachtrag (260911-gwh)/{n=1} END{ if(!n){print "(d4): Nachtrag im Uebergaben-Absatz fehlt"; exit 1} }' "$D" && A=docs/anleitung-entwicklung.md && test 0 -eq "$(grep -c 'FavoriteLink— tragen zwar einetenantId-Spalte, aber \*\*keine\*\*' "$A")" && test 0 -eq "$(grep -c 'nutzt den plain, UNGEBUNDENEN' "$A")" && grep -q '20260909140000_rls_remaining_tenant_tables' "$A" && grep -q 'tenantPrisma' "$A" && W=.planning/WINDOWS.md && OC=$(sed -nE 's/^open_count: ([0-9]+)$/\1/p' "$W") && TC=$(sed -nE 's/^total_count: ([0-9]+)$/\1/p' "$W") && ROWS=$(grep -cE '^\| [0-9]+ \| ' "$W") && { test "$ROWS" -eq "$TC" || { echo "WINDOWS: total_count=$TC, Tabellenzeilen=$ROWS"; exit 1; }; } && OPENROWS=$(grep -E '^\| [0-9]+ \| ' "$W" | grep -c '| open |') && { test "$OPENROWS" -eq "$OC" || { echo "WINDOWS: open_count=$OC, offene Zeilen=$OPENROWS"; exit 1; }; } && test "$(grep -cE '^\| [0-9]+ \| quick-260911-gwh \|' "$W")" -ge 2 && grep -q 'favorites-widget\.tsx' "$W" && grep -q 'smtp-settings-form\.tsx' "$W" && git rev-parse --verify 46f0e78 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 46f0e78 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 46f0e78) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|docs/mandantentrennung-zugriffsklassifikation\.md|docs/anleitung-entwicklung\.md|apps/api/src/favorites/favorites\.service\.ts|apps/api/src/favorites/favorites\.service\.spec\.ts|apps/api/src/favorites/favorites\.controller\.ts|apps/api/src/settings/settings\.service\.ts|apps/api/src/settings/settings\.service\.spec\.ts|apps/api/src/mail/mail\.module\.ts|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; } && test -z "$(git diff --name-only 46f0e78 -- docker-compose.yml docker-compose.*.yml .env .env.* apps/api/.env apps/api/.env.* 2>/dev/null)"</automated> </verify> <done>Drei neue offene Ledger-Eintraege (Startpfad des Mailmoduls — oder, mit Grund, Anschluss an #21 —, verschluckte Leere favorites, verschluckte Leere settings) stehen in Tabelle UND JSON-Block, die Kopfzaehler stimmen, der Platzhalter im Code ist durch die vergebene Nummer ersetzt. Alle handgepflegten Stellen von docs/mandantentrennung-zugriffsklassifikation.mdsind nachgezogen und DERIVIERT gegatet: Uebersichtszeilenfavorites/settings mit neu gemessenen Zahlen, Summenzeile aus der Messanweisung, Bestandsaufnahme (favoriteLinkgebunden,smtpConfiggemischt,widgetInstanceneu falls gebaut), Klassen-Verteilung mit Stand-Vermerk und aus der Tabelle gezaehlter Paarzahl, Hintergrunddienst-Abschnitt mit sechstem Fall, angepasster Ueberschrift, erfuellter Befund-K-Bedingung und favorites-Absatz,Was diese Etappe NICHT entscheidetmit dem Mehrmandanten-Versand-Punkt. Die Kritikschrift traegt die Nachtraege in (t4) und (d4) und den Abschnitt## Etappe 2 — Abschlussmit den aus den eigenen Messanweisungen abgeleiteten Zahlen, den bewusst ungebundenen Resten je Bereich, den Ledger-Nummern, dem Etappe-3-Rest und der Etappe-4-Vorabpruefliste. Die Anleitung nenntFavoriteLinknicht mehr als Tabelle ohne Regel undloadConfig()nicht mehr als ungebunden. Beide Dokument-Falsifizierungen durchgefuehrt, zurueckgenommen, woertlich notiert. Werkzeug alle Pruefungen bestanden (mindestens 137), Testzahl ueber 951, Typpruefung sauber, Schalter aus, Schema/Migrationen/Compose/Umgebung unveraendert, Erlaubnisliste gegen46f0e78` gehalten.
<threat_model>
Konfiguriert: ASVS-Stufe 1, blockierend ab high.
Trust Boundaries
| Boundary | Description |
|---|---|
Angemeldeter Benutzer -> /favorites (list, create, update, remove, :id/icon) |
Kennung im Pfad und widgetId im Rumpf sind frei waehlbare Eingaben; der Mandant kommt aus extractContext (Guard-Kennung, wie dashboard), die Benutzerkennung aus dem Sitzungsnachweis. Die Regel auf FavoriteLink kennt keine Benutzerdimension. |
FavoriteLink.widgetId -> WidgetInstance.id (Fremdschluessel) |
PostgreSQL prueft referentielle Integritaet AM ZEILENSCHUTZ VORBEI: ein Bezug auf ein unsichtbares Widget wird auf Datenbankebene nicht gesperrt (Pruefung 7 misst). |
| Favoriten-Dienst -> Icon-Proxy -> Internet | discoverFavoriteIconUrl nimmt die Nutzer-URL fuer einen SSRF-gesicherten Abruf (T-08-05); fetchIconBytes nimmt NUR die gespeicherte URL einer Zeile, die der Aufrufer besitzt (T-QFIP-01). Kein Datenbankzugriff, kein Mandantenzustand. |
ADMIN / SUPER_ADMIN -> /settings/smtp |
req.tenantId (fuer SUPER_ADMIN per x-tenant-id umschaltbar) ist hier die GEWOLLTE Quelle (D-10); Zugangsdaten verschluesselt (AES-256-GCM), nie im GET. |
tender-mail.service.ts / dkv-mail.service.ts -> getDecryptedSmtpConfig(tenantId) |
Der Mandant kommt aus der gebundenen Schleife des Aufrufers; der entschluesselte Wert lebt nur im Methodenrumpf (T-07-10). |
mail.module.ts (Start) -> Startpfad -> MailerService |
Kein Mandantenkontext; eine BELIEBIGE Zeile wird zum Transport ALLER Systemmails; nach dem Scharfschalten null -> Rueckfallkette -> localhost:1025. |
API -> PostgreSQL, Tabellen FavoriteLink/SmtpConfig |
Regel "tenantId" = current_tenant_id() (20260909140000); nach dem Scharfschalten liefert ein ungebundener Zugriff null Zeilen (Pruefungen 3/5), ein ungebundenes upsert scheitert am Eindeutigkeitsindex (Pruefung 8). |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-GWH-01 | Information Disclosure | settings.service.ts getSmtpConfig/getDecryptedSmtpConfig, Mandantengrenze |
high | mitigate | Ohne Bindung liest ein ADMIN von Mandant A ueber eine gefaelschte oder falsch aufgeloeste Mandantenkennung die (verschluesselten bzw. entschluesselten) SMTP-Zugangsdaten von B; nach dem Scharfschalten sind ungebundene Zugriffe leer statt fremd. Gebundener Klient je Methode; Pruefung 7 (fremder Mandant: null) ueber den generierten Client; Testfaelle (fremder Mandant: null, decrypt nicht aufgerufen); Falsifizierung (c). |
| T-GWH-02 | Tampering / Elevation of Privilege | favorites.service.ts update/remove/getIconBytes, Mandantengrenze |
high | mitigate | Ein Nutzer von Mandant A aendert oder loescht ueber die Kennung eine Favoritenzeile von Mandant B. Heute schuetzt nur der userId-Vergleich; nach der Bindung liefert die Vorpruefung null (Pruefung 5), ein gebundenes delete ueber die Kennung allein wirft (Pruefung 6). Beide Zugriffe auf DEMSELBEN Klienten; Testfaelle (fremder Mandant: NotFoundException, kein Schreibzugriff); Falsifizierung (a). |
| T-GWH-03 | Elevation of Privilege / Information Disclosure | mail.module.ts Startpfad, Nutzung fremder Zugangsdaten |
high | transfer | HEUTE bei mehreren Mandanten: der SMTP-Server und Absender EINES beliebigen Mandanten tragen Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (Nutzung fremder Zugangsdaten, Absenderfaelschung aus Sicht des Empfaengers). Kein Bindungsproblem — beim Start gibt es keinen Mandanten; der Umbau auf Transport je Versand ist eine Funktion (Vorlage: DkvMailService/TenderMailService). Gemessen (Pruefung 4), benannt (Kopfkommentar beider Zustaende, (s4)(a)), als OFFENER Ledger-Eintrag mit konkretem Reparaturweg uebergeben. Heute ein Mandant — die Bedingung tritt mit dem zweiten ein. |
| T-GWH-04 | Denial of Service (still) | mail.module.ts Rueckfallkette nach dem Scharfschalten |
high | transfer | null -> MAIL_* -> TESSERA_SMTP_* -> localhost:1025; MailService faengt den Transportfehler, der Controller antwortet 200: Kennwort-Zuruecksetzung kommt nie an, ohne Protokollzeile beim Start. Gemessen (Pruefung 3), beide Zustaende an drei Stellen benannt, Signal fuer rls-preflight.mjs (Etappe 4) benannt; NICHT in diesem Lauf behoben (kein Umbau des Transports, kein Schalter). |
| T-GWH-05 | Information Disclosure | favorites.service.ts create, Fremdschluessel auf WidgetInstance |
medium | mitigate | Der Unterschied zwischen "Widget gibt es nicht" (FK-Verletzung, 500) und "gehoert einem fremden Mandanten" (gelingt, weil der Fremdschluessel am Zeilenschutz vorbei prueft) ist ein Existenzorakel ueber Mandantengrenzen; dazu haengt eine fremde Zeile am Widget eines anderen (Kaskade). Pruefung 7 misst; Besitzriegel vor dem Anlegen auf dem gebundenen Klienten mit EINER Antwort (Widget not found) fuer alle drei Faelle; Testfaelle; Falsifizierung (b). |
| T-GWH-06 | Denial of Service (still) / Tampering | saveSmtpConfig unter der ungebundenen Form |
medium | mitigate | Leeres Formular nach dem Scharfschalten verleitet zum Neueintrag; das ungebundene upsert findet die eigene Zeile nicht und scheitert am Eindeutigkeitsindex (Pruefung 8, Konstruktor/code gemessen). Gebundenes upsert trifft die eigene Zeile (Pruefung 9); Testfall. Die Frontend-Kette bleibt als Ledger-Eintrag (Familie #28). |
| T-GWH-07 | Server-Side Request Forgery | icon-discovery.service.ts |
medium | accept | Nutzer-URL fuer die Icon-Suche; SSRF-Schutz (private IP-Bereiche, gesperrte Hostnamen, manuelle Weiterleitung, Zeitueberschreitung) besteht seit T-08-05 und ist in icon-discovery.service.spec.ts getestet; fetchIconBytes nimmt nur die gespeicherte URL einer eigenen Zeile (T-QFIP-01). Kein Datenbankzugriff, kein Mandantenbezug (Befund G) — unveraendert, nur gelesen. Der Besitzriegel in create steht VOR der Icon-Suche, damit ein fremdes Widget keinen Abruf ausloest. |
| T-GWH-08 | Information Disclosure | FavoriteLink, Kollege desselben Mandanten |
low | accept | Die Regel kennt keine Benutzerdimension: auf Datenbankebene sieht ein gebundener Klient die Favoriten des Kollegen (Pruefung 4, zweite Aussage). Die anwendungsseitigen userId-Filter bleiben; Etappe-3-Entscheidung (2). Bestehend, benannt in (f4)(a). |
| T-GWH-09 | Repudiation / Information Disclosure | favorites-widget.tsx, smtp-settings-form.tsx |
medium | accept | Die umgekehrte Fehlerrichtung ist verschluckt: [] wird zu Noch keine Favoriten., null wird zu einem leeren Formular. Gemessen (Pruefungen 3/5), in (f3)/(s3) beschrieben, je ein Ledger-Eintrag (Familie #23/#25/#26/#28), Etappe-4-Vorabpruefung benannt. Das Frontend wird nicht geaendert (Umfang). |
| T-GWH-10 | Spoofing | favorites.controller.ts, Mandantenquelle |
low | accept | extractContext liest die Guard-Kennung (fuer SUPER_ADMIN umschaltbar) — dieselbe Quelle wie dashboard, unter dessen Bindung das referenzierte Widget liegt; eine abweichende Quelle wuerde Widget und Link trennen. Kein Mandantenfeld im DTO (gemessen). Bewusst NICHT das Claim, mit Grund in (f4)(c). |
| T-GWH-11 | Denial of Service | settings.service.spec.ts -> nodemailer |
low | mitigate | Ein echter Transport im Test liefe gegen mailhog (lokal nicht vorhanden: ENOTFOUND). vi.mock('nodemailer') ersetzt createTransport; kein Test oeffnet eine Verbindung; gegatet. |
| T-GWH-SC | Tampering | Paketinstallation | low | accept | Dieser Plan installiert kein Paket (npm/pip/cargo) und fuegt keine Abhaengigkeit hinzu. Das Legitimitaets-Gate faellt nicht an; ausdruecklich festgehalten statt schweigend ausgelassen. |
</threat_model>
Nach Abschluss aller drei Aufgaben:
node apps/api/scripts/rls-scratch-check.mjsmeldet alle Pruefungen bestanden (120 bisherige plus mindestens 8 + 9 neue, mindestens 137), Rueckgabewert 0;runFavoritesAreaChecksundrunSettingsAreaChecksstehen nachrunAuthAreaChecksund vorrunTransactionShapeMeasurement.npm --prefix apps/api run testmeldet mehr als 951 Tests gruen in mindestens 62 Dateien (60 bisherige plusfavorites.service.spec.tsundsettings.service.spec.ts).npm --prefix apps/api run type-checkist sauber.npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.tsist gruen —favorites.service.ts/favoriteLinksteht aufgebunden,settings.service.ts/smtpConfigaufgemischt, die neue Fundstellefavorites.service.ts/widgetInstance(falls gebaut) ist eingetragen.- In
favorites.service.ts: kein ungebundener Favoritenzugriff (auch nicht in Kommentaren), sieben gebundene, fuenf Aufrufstellen des Bindungshilfsmittels, ein Klient je Methode,Widget not found. Insettings.service.ts: genau EIN ungebundener SMTP-Zugriff (der Startpfad,findFirst()ohne Bedingung, inloadAnySmtpConfigForStartupTransport()), drei gebundene, kein gebundenerfindFirst; der alte Name kommt inapps/api/srcnicht mehr vor;mail.module.tsnennt beide Zustaende und die Ledger-Nummer. git diff --name-only 46f0e78 -- apps/api/prismaist leer — Schema und Migrationen unangetastet; keine Compose- oder Umgebungsdatei geaendert.- 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).
- Der Umfang ist als ERLAUBNISLISTE gegatet: jede Datei, die sich
gegenueber
46f0e78geaendert hat, ist eine der elf infiles_modifiedgenannten (oder liegt unter.planning/). Unterapps/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/dkvund den DTOs hat sich nichts geaendert. .planning/WINDOWS.md: neue offene Eintraege in Tabelle UND JSON-Block, Kopfzaehler stimmen mit den Zeilen ueberein; der PlatzhalterTBD-GWHkommt nirgends mehr vor.- Befund K steht an drei Stellen als ERFUELLT: (t4) Nachtrag, (d4) Nachtrag, Hintergrunddienst-Abschnitt der Klassifikation.
docs/mandantentrennung-etappe2-fehlerrichtung.mdtraegt## Etappe 2 — Abschlussmit den aus den eigenen Messanweisungen abgeleiteten Zahlen (Summenzeile, Paarzahl), zwischen## Bereich settingsund## Verweis.DATABASE_URLzeigt unveraendert auf die Rolletessera; nichts in Active Directory; kein echter SMTP-Transport in irgendeinem Test.
<success_criteria>
- Beide Bereiche sind gemessen, bevor gebaut wurde: zwei neue Werkzeug-Abschnitte ueber den generierten Client, mit Fremdschluessel (favorites) und Eindeutigkeitsindex (settings) als mitgebauten Voraussetzungen der Messung.
- Der Startpfad des Mailmoduls ist als sechster Fall der
Hintergrunddienst-Falle erkannt, in beiden Zustaenden gemessen, umbenannt,
markiert (Kopfkommentar, Modulkommentar, Klassifikation, Kritikschrift,
Ledger) und NICHT gebunden, NICHT umgebaut; die Unsymmetrie zu
ldapunddkv(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
tenantPrismaje Methode; die Besitzpruefungen bleiben;createprueft das Widget; beide Controller-Entscheidungen zur Mandantenquelle sind begruendet (dashboard-Praezedenzfall fuer favorites, ADMIN-Seite fuer settings). - Die umgekehrte Fehlerrichtung ist je Bereich in ihrer tatsaechlichen
Auspraegung benannt (
Noch keine Favoriten.; leeres Formular ueber die 200-leerer-Rumpf-Kette; Speicherkonflikt gemessen) und als Ledger-Eintrag je Bereich festgehalten. - Zwei neue Testdateien mit dem Zwei-Klienten-Nachbau, ohne echten Transport; sechs Falsifizierungsnachweise (vier in Aufgabe 2, zwei an den Dokument-Gates in Aufgabe 3) durchgefuehrt, zurueckgenommen, woertlich notiert.
- Etappe 2 ist vollstaendig: die fuenf handgepflegten Dokumentstellen stehen
auf ihrem Endstand, deriviert gegatet; die Kritikschrift hat einen
Abschluss-Abschnitt aus ihren eigenen Zahlen; die Anleitung widerspricht
dem Stand nicht mehr; Erlaubnisliste gegen
46f0e78; Baseline gehalten nach jeder Aufgabe; Schalter aus; Schema, Migrationen, Frontend, Active Directory unberuehrt.
</success_criteria>
Nach jeder Aufgabe committen (Praefix `feat(260911-gwh): ...` fuer Aufgabe 2, `docs(quick-260911-gwh): ...` fuer Aufgabe 1 und 3, wie die Vorgaenger), nach dem letzten Commit pushen (schlichtes `git push`, die Push-URL zeigt auf `localhost:3002`). Bei jedem Commit, der eine Datei loescht oder mehrere Pfade zugleich hinzufuegt, `git status --porcelain` VOR und NACH `git add` ansehen (Fehler 11 des Vorhabens). Am Ende `.planning/quick/260911-gwh-mandantentrennung-etappe-2-bereiche-favo/260911-gwh-SUMMARY.md` anlegen: tatsaechlich gezaehlte Pruefungs- und Testzahlen je Aufgabe, das Ergebnis von Pruefung 7 (Fremdschluessel) und Pruefung 8 (Konfliktform) mit Konstruktor/`code` woertlich, alle sechs Falsifizierungsnachweise mit Testname und Meldung woertlich, jede Abweichung von den Planungsbefunden ausdruecklich, die Ledger-Nummern und die Entscheidung zu #21 mit Grund, und ein Absatz, der den Endstand der Etappe 2 in Zahlen nennt (aus dem Abschluss-Abschnitt der Kritikschrift, nicht neu gerechnet). Kein naechster Bereichs-Lauf: Etappe 2 ist abgeschlossen; was folgt, ist Etappe 3 (Kennzeichnung der uebergreifenden Stellen, Benutzerdimension, Anmeldeweg) und die Etappe-4-Vorabpruefung.