9 Commits

Author SHA1 Message Date
schalli ad834076c2 docs(quick-260921-m34): 288 any auf 15 gesenkt, jede verbliebene mit Urteil
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m6s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m0s
Zusammenfassung mit Urteilsregister und STATE.md zum Quick-Vorgang
260921-m34.

Der groesste Posten war ein einziges Missverstaendnis: 105 Stellen
trugen eine Zusicherung an der Mandantenbindung, die nie noetig war -
prisma.$extends() liefert laengst einen getypten Klienten.

Die tenantId-Frage wurde hergeleitet statt nach Bequemlichkeit
entschieden: string ohne Fragezeichen, belegt aus Pflichtspalte im
Schema, Bestandstyp und dem Super-Admin-Zweig der Mandantenpruefung -
der diesen Typ gar nicht liest und deshalb nicht zu totem Code werden
kann. Der Waechter blieb ueber den ganzen Lauf unveraendert.

Vier Befunde gemeldet statt still repariert. Zwei brauchen eine
Entscheidung, beide wuerden Verhalten aendern - darunter ein
sicherheitsrelevanter: die IMAP-Einstellung STARTTLS erzwingt nichts,
weil die gesetzte Option in imapflow 1.4.3 nicht existiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:35:00 +02:00
schalli d8fb9ae07d refactor(quick-260921-m34): Aufgabe 3c - Randschicht beurteilt, drei Befunde gemeldet, 15 bleiben mit Urteil
httpntlm (exchange.provider, exchange-inbox.provider): NtlmOptions und
NtlmResponse beschreiben genau das, was uebergeben und gelesen wird. Die
ueberfluessige Zusicherung (httpntlm as any) faellt weg.

Graph-Rueckrufe (exchange.provider :157/:313): AuthProviderCallback aus dem
SDK selbst statt Handannotation - als import type, also ohne den dynamischen
Import zur Laufzeit zurueckzunehmen.

imapflow: streamToBuffer() nimmt Readable statt NodeJS.ReadableStream (alle
drei Aufrufer reichen client.download().content herein, imapflow deklariert
das als Readable) - damit traegt der Typ destroy() und die Zusicherung
faellt. node.parameters?.name war ebenfalls schon getypt.

nodemailer: ResolvedTransport.options wird SMTPTransport.Options; beide
Zweige bauen reine SMTP-Optionen, createTransport() nimmt sie ohne
Zusicherung.

node-forge: die vier let p7: any werden Captured<PkcsEnvelopedData |
PkcsSignedData> - der MITGELIEFERTE Typ. Die Lesestellen grenzen mit
'certificates' in p7 ein statt zuzusichern; verhaltensgleich, weil der
enveloped-Form das Feld fehlt und beide Schreibweisen dann die leere Liste
liefern. cert.siginfo war bereits getypt.

apps/web/src/test/setup.ts: expect.extend(matchers) traegt ohne Zusicherung
- geprueft im echten Typlauf (setup.ts liegt im include von
apps/web/tsconfig.json, mit einem absichtlichen Fehler nachgewiesen).

BEFUND 4 (D-03, gemeldet, NICHT repariert) imap.provider.ts:78 - der
Ausdruck (node as any).disposition?.parameters?.filename liest .parameters
von einer ZEICHENKETTE: imapflow deklariert disposition als string
(imap-flow.d.ts:448), die Parameter liegen in dispositionParameters (:450).
dispositionFilename ist damit zur Laufzeit immer ''. Folge: Outlook-Anhaenge,
die als application/octet-stream kommen, werden ueber den Dateinamen aus
Content-Disposition NICHT erkannt - nur ueber den aus Content-Type. Umbiegen
waere eine Verhaltensaenderung; die Zusicherung bleibt sichtbar stehen.

BEFUND 5 (D-03, gemeldet, NICHT repariert) imap.provider.ts:402 -
requireTLS kommt in imapflow 1.4.3 NIRGENDS vor, weder in ImapFlowOptions
noch im Laufzeitcode (beides durchsucht). Die Option wird still verworfen;
STARTTLS wird durch sie nicht erzwungen. Genau das } as any hat es
verdeckt. Bleibt stehen, damit der Befund in der Zaehlung sichtbar ist.

BEFUND 6 (D-03, gemeldet, Verhalten unveraendert) httpntlm liefert den
Rumpf als Zeichenkette, nicht als Buffer: httpreq setzt ihn nur bei
gesetzter Option binary auf Buffer (httpreq@1.1.1/lib/httpreq.js:391),
keiner der beiden Aufrufer setzt sie. Der Bestand rief unbesehen
.toString('utf-8') auf - das ging nur gut, weil String.toString() sein
Argument ignoriert. Die Testdoppel reichen dagegen wirklich Buffer herein.
NtlmResponse.body nennt jetzt beide Formen, die Fallunterscheidung liefert
fuer jede exakt dasselbe Ergebnis wie zuvor.

Urteil BLEIBT mit Begruendung im Code an allen 15 verbleibenden Stellen:
3x addCronJob (require-Umweg aus 07-04), 5x node-forge (EC-Zweig und
extensions: any[] sind in @types/node-forge nicht beschrieben, 2x null as
any wo die Typen die Bibliothek nachweislich falsch beschreiben), 2x
imap-Befunde oben, 2x tx: any plus 2x Gefolge (Aufgabe 1), 1x
disposition-Befund.

noExplicitAny in apps/api/src: 31 -> 15 (Ausgang 288, Schranke 45), apps/web
1 -> 0. type-check 4/4, lint 5/5 (0 error), apps/api 72/1143, apps/web
73/531, rls-access-inventory 30/30. noNonNullAssertion 56, as unknown as 33,
ts-expect-error/ts-ignore 0/0, Unterdrueckungsmarker 1. biome.json, alle
package.json und pnpm-lock.yaml unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:29:28 +02:00
schalli 3892c5f3c6 refactor(quick-260921-m34): Aufgabe 3b - Prisma-nahe Formen getypt, Erkenner-Falle gemeldet
- auth.service.ts:393: (response as any).cookie war schlicht ueberfluessig.
  response ist in derselben Signatur bereits Response aus express, die
  Schwesterstelle :190 kommt ohne Zusicherung aus. Ersatzlos entfernt.
- calendar.service.ts: das lokal gebaute data-Objekt traegt jetzt
  Prisma.CalendarSourceUncheckedCreateInput bzw. ...UncheckedUpdateInput
  statt Record<string, unknown> plus Zusicherung. Damit fallen beide
  `data as any` weg, ohne dass ein Feld behauptet wird.
- user.service.ts: `let created: any` -> User (die Zuweisung steht im try,
  der catch endet ausnahmslos mit throw). `const updateData: any` wird aus
  der Signatur hergeleitet - Omit<UpdateUserInput, 'password'> plus dem
  daraus berechneten passwordHash; die Parameterform ist dafuer als
  UpdateUserInput benannt und nicht neu erfunden. `const results: any[]`
  wird Pick<User, keyof typeof PLATFORM_USER_SELECT>[], die Spaltenauswahl
  steht als Konstante daneben.
- tenant.controller.ts:69: Elementtyp aus dem hergeleitet, was die Schleife
  hineinlegt (fuenf Tenant-Spalten plus userCount).

BEFUND 3 (D-03, gemeldet, kein Verhalten betroffen) Die naheliegende
Prisma-Schreibweise Prisma.UserGetPayload<{ select: typeof X }> laesst
rls-access-inventory.spec.ts rot werden: der Erkenner zaehlt JEDE
select:-Angabe ausserhalb eines erkannten Modellaufrufs als Verstoss und
unterscheidet Typposition nicht von Aufrufposition. Gemessen beim ersten
Versuch. Der Erkenner ist die Mandantenkontrolle (T-M34-03) und wurde
NICHT aufgeweicht - stattdessen leitet der Zeilentyp ueber Pick<User, ...>
her, was ohne das Wort select auskommt. Begruendung steht am Typ.

noExplicitAny in apps/api/src: 38 -> 31. type-check 4/4, lint 5/5 (0
error), apps/api 72/1143, apps/web 73/531, rls-access-inventory 30/30.
noNonNullAssertion 56, as unknown as 33, Unterdrueckungsmarker 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:21:53 +02:00
schalli 32591b6690 refactor(quick-260921-m34): Aufgabe 3a - 18 Fehlerfaenger auf unknown, mit echter Eingrenzung
catch (e: any) in groups, module-grants, ldap, user, admin-seed, calendar
und vier tenders-Diensten auf catch (e: unknown) umgestellt. Die
Eingrenzung passiert an der Verwendungsstelle, nicht per Zusicherung.

Neu: apps/api/src/prisma/prisma-error.ts mit prismaErrorCode() und
prismaErrorTarget(). Bewusst Form-Pruefungen statt instanceof
Prisma.PrismaClientKnownRequestError - gemessen: samtliche Testdoppel in
apps/api werfen new Error(...) mit angehaengtem .code (groups, user, ldap,
tenders, module-grants, admin-seed) und dashboard.service.spec.ts:451 ein
reines { code: 'P2002' }. Ein instanceof-Test haette all diese Werte in den
anderen Zweig geschickt - Verhaltensaenderung, verboten nach D-03/T-M34-06.
Die Helfer bilden err?.code und err?.meta?.target eins zu eins ab.

ldap.service.ts liest zusaetzlich meta.target; prismaErrorTarget() gibt
unknown zurueck, weil der Bestand dort Array UND Zeichenkette getrennt
behandelt - ein engerer Typ waere eine Behauptung.

calendar.service.ts:341 nutzt instanceof Error statt e?.message: gemessen
wirft validateUrlNotPrivate() ausschliesslich ForbiddenException (der
eigene catch dort setzt jeden Fremdfehler in eine um), also trifft
instanceof dieselben Faelle. Ersatzzweig 'URL not allowed' unveraendert.

noExplicitAny in apps/api/src: 56 -> 38. type-check 4/4, lint 5/5 (0
error), apps/api 72/1143, apps/web 73/531, rls-access-inventory 30/30.
noNonNullAssertion 56, as unknown as 33, Unterdrueckungsmarker 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:18:12 +02:00
schalli 52668c2c88 refactor(quick-260921-m34): Aufgabe 2c - Hochladewege getypt, 25 Zusicherungen fallen mit
@UploadedFile()/@UploadedFiles() in cert-manager.controller auf
UploadedFileLike. Der Dienst nimmt CertFileLike = Pick<UploadedFileLike,
'buffer' | 'originalname'> - genau die zwei Felder, die er liest; mimetype
und size bleiben draussen, weil kein Zweig sie anfasst.

Belegt statt behauptet: keiner der sechs FileInterceptor/FilesInterceptor-
Aufrufe in apps/api/src setzt eine storage-Option, also gilt multers
memoryStorage, also ist buffer ein Buffer. @types/multer bleibt
uninstalliert (D-04).

Damit fallen 25 Zusicherungen der Form file.buffer as Buffer und
file.originalname as string ersatzlos weg - sie standen nur da, weil file
ein any war. as unknown as bleibt bei 33, noNonNullAssertion bei 56.

noExplicitAny in apps/api/src: 66 -> 56 (Ausgang der Aufgabe: 149,
Schranke des Plans: 75). type-check 4/4, lint 5/5, apps/api 72/1143,
apps/web 73/531.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:11:28 +02:00
schalli 7c9d7c1223 refactor(quick-260921-m34): Aufgabe 2b - getypte Anfrage in elf Controllern, zwei Befunde gemeldet
(req as any) und @Req() req: any durch AuthenticatedRequest ersetzt in
dashboard, favorites, calendar, groups, module-grants, module-registry,
tenders, dkv, ldap, settings; @CurrentUser() in user.controller auf AuthUser.
Die abwehrenden Pruefungen ("No tenant context", "No user context") bleiben
lebendig, weil user auf dem Anfragetyp wahlfrei ist - genau das beschreibt
den Zustand auf oeffentlichen Wegen.

Nebengewinn ohne neue Zusicherungen: req.tenantId as string | undefined
(dkv, settings), file.buffer as Buffer und file.mimetype as string
(dkv, user) sind weggefallen, weil der Typ sie jetzt traegt.

BEFUND 1 (D-03, gemeldet) dashboard.controller.ts:74 alt: der Handler las
req.user?.role NACH extractContext und gab sie an getWidgets(role: Role)
weiter, das eine Rolle zwingend verlangt. Die Annahme "hier gibt es immer
einen Aufrufer" stimmt - die Pruefung "No user context" erzwingt sie -, aber
sie stand in einer anderen Methode, wo der Compiler sie nicht sehen konnte.
extractContext gibt die Rolle jetzt mit zurueck: keine neue Pruefung, kein
erfundener Wert, gleiche Reihenfolge, gleiche Meldungen.

BEFUND 2 (D-03, gemeldet) tenders.controller.ts:142: resolveRequestingTenantId
erklaerte string | undefined, liest aber req.tenantId, das TenantGuard fuer
einen SUPER_ADMIN ohne Mandanten auf null setzt. Die Erklaerung war also nie
vollstaendig. Erweitert auf string | null | undefined, und buildTenderWhere
nimmt string | null - beides nur Erklaerung, kein Verhalten: die Funktion
entscheidet seit jeher ueber Wahrheitswert und faellt bei beiden zu
(nur global sichtbare Ausschreibungen).

Fixtures in user.controller.spec.ts ergaenzt (username, mustChangePassword,
originalname, size). Testzahlen unveraendert.

noExplicitAny in apps/api/src: 137 -> 66. type-check 4/4, lint 5/5,
apps/api 72/1143, apps/web 73/531, tenant.guard.ts unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:08:44 +02:00
schalli f2fc39f51c refactor(quick-260921-m34): Aufgabe 2a - gemeinsamer Aufrufer-Typ, aus den Signierstellen abgeleitet
apps/api/src/auth/types/auth-user.ts angelegt: AuthUser, AuthenticatedRequest,
LocalAuthenticatedRequest, LoginUser, JwtPayload, UploadedFileLike. Jedes Feld
traegt seine Herkunft als Kommentar.

tenantId ist string, hergeleitet und nicht gewaehlt: die Spalte User.tenantId
ist in schema.prisma Pflicht, beide Signierstellen schreiben genau sie, und
der Bestand beschreibt dasselbe Objekt in SessionUser schon so. Der
SUPER_ADMIN-Zweig in TenantGuard spricht nicht dagegen - der Waechter liest
AuthUser gar nicht, und dass es den Zweig gibt, steht als null in
AuthenticatedRequest.tenantId weiter im Typsystem. tenant.guard.ts bleibt
unberuehrt.

role ist die Aufzaehlung Role: schema.prisma deklariert die Spalte so, die
SQL-Funktion auth_lookup_user_by_username gibt sie als "Role" zurueck. Die
Handannotation role: string in AuthLookupUserByUsernameRow war eine zweite
Fassung desselben Wertes und faellt damit weg.

SessionUser und UploadedPng in bug-reports.service.ts sind jetzt Pick<> der
neuen Typen statt eigener Beschreibungen.

Fixtures in auth.controller.spec.ts ergaenzt: sie uebergaben einen Aufrufer
ohne username und ohne mustChangePassword - eine Form, die JwtStrategy nie
erzeugt. Testzahlen unveraendert.

noExplicitAny in apps/api/src: 149 -> 137. type-check 4/4, lint 5/5,
apps/api 72/1143, apps/web 73/531.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:03:24 +02:00
schalli b188946e31 refactor(quick-260921-m34): Aufgabe 1 - Mandantenbindung entzaubert, 105 unnoetige any-Zusicherungen entfernt
- prisma-tenant.extension.ts: (prisma as any) und die Handannotation an
  $allOperations in forTenant()/forSystem() entfernt; Kopfkommentar
  unveraendert. .then((results: any[]) => ...) auf unknown[] umgestellt.
- 105 Aufrufstellen `const X = forTenant(...) as any` / `forSystem(...) as
  any` von der Zusicherung befreit, Zuweisungsform woertlich erhalten
  (rls-access-inventory.spec.ts bleibt scharf, 30/30 gruen einzeln
  geprueft).
- withTenantTransaction(): Prisma.TransactionClient fuer tx probiert,
  gemessen verworfen - bricht das Testdoppel in
  prisma-tenant.extension.spec.ts (TS2322 auf einem absichtlich
  unvollstaendigen Fake-Objekt). tx bleibt any, mit Begruendung am Typ.
- Gefolge des jetzt getypten Klienten entfernt: any[]-Annotationen und
  .map((x: any) => ...) in groups.service.ts, module-grants.service.ts,
  dkv.service.ts, ldap-config.service.ts, tenders.controller.ts:270.
- Befund (D-03): tender-matching.service.ts:159 trug eine Handannotation
  (match: { tender: unknown }), die den Wert nur deshalb auf unknown
  verengte, um TS7006 unter dem alten any-Klienten zu vermeiden - mit dem
  getypten Klienten war das falsch. Annotation geloescht, kein Ersatz
  durch Zusicherung.
- Zwei any bleiben gezielt in groups.service.ts (u/a in
  ensureDefaultGroup(), gefolge von tx: any) - Begruendung am Code.

noExplicitAny apps/api/src: 288 -> 149 (Schranke 155). type-check 4/4,
lint 5/5 (0 error). apps/api 72/1143 gruen, apps/web 73/531 gruen,
rls-access-inventory.spec.ts 30/30 gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 16:19:16 +02:00
schalli 8d845e732e docs(quick-260921-m34): Plan fuer die 288 any im Backend, auf Messungen gebaut
Drei Aufgaben statt einer Sammelaktion, geschnitten nach Form und Risiko.
Die Formen wurden nicht geschaetzt, sondern durch Probeumbauten am echten
Baum gemessen (jeder danach zurueckgenommen):

- 105 mal forTenant(...) as any: die Zusicherung war nie noetig, tsc meldet
  ohne sie genau einen Folgefehler.
- 13 der 15 Stellen in prisma-tenant.extension.ts fallen ebenso.
- Sechs Controller auf einen getypten Request: genau ein Fehler, und der
  ist ein echter Befund im Bestandscode.
- Die drei addCronJob-Stellen sind gemessen nicht aufloesbar und bekommen
  das Urteil bleibt.

Erwartetes Ergebnis 20 bis 40 verbleibende Befunde, nicht null: wer die
letzten Stellen erzwingt, tauscht eine ehrliche Warnung gegen eine
unehrliche Behauptung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 16:09:33 +02:00
57 changed files with 1707 additions and 488 deletions
+5 -4
View File
@@ -4,8 +4,8 @@ milestone: v1.2
current_phase: 18
current_phase_name: desktop-client-fertigstellen
status: verified
stopped_at: "Neun Quick-Vorgaenge am 2026-09-21 (9ie, a1d, bi2, fi3, gof, i8x, iwr, jt4, ldf). Der Wackeltest aus CI-Lauf 395 ist geklaert und war ein Produktfehler. Offen: 288 any in apps/api (reine Typarbeit), danach zwei neue Dashboard-Widgets. Das erste ist ein Bilderrahmen: Bilder werden hochgeladen ODER per https-Webadresse eingebunden (Browser laedt direkt, kein Server-Abruf, damit keine SSRF-Flaeche); Einstellungen fuer Bildausschnitt, Wechselintervall, Reihenfolge/Zufall, Bildunterschrift, Klick zeigt gross. Das zweite Widget hat der Nutzer noch nicht benannt. ldf ist noch nicht gepusht."
last_updated: "2026-09-21T14:15:00.000Z"
stopped_at: "Zehn Quick-Vorgaenge am 2026-09-21. Der gesamte Lint- und Fehlerrueckstand ist abgearbeitet (2923 → 125 Diagnosen, 0 Fehlerstufe). ZWEI BEFUNDE WARTEN AUF ENTSCHEIDUNG DES NUTZERS, beide aus m34 Aufgabe 3, beide wuerden Verhalten aendern: (B-06, Sicherheit) imap.provider.ts setzt requireTLS, das es in imapflow 1.4.3 nicht gibt — die Einstellung STARTTLS erzwingt nichts und faellt bei fehlender Server-Unterstuetzung unverschluesselt zurueck; richtig waere doSTARTTLS: true, Folge: solche Postfaecher scheitern dann statt im Klartext zu verbinden. (B-05) imap.provider.ts:78 liest .parameters von einer Zeichenkette, Outlook-Anhaenge werden ueber Content-Disposition nicht erkannt, betrifft den DKV-Rechnungseinzug. Danach: zwei neue Dashboard-Widgets, das erste ein Bilderrahmen (Upload ODER https-Webadresse, Browser laedt direkt), das zweite noch unbenannt. m34 ist noch nicht gepusht."
last_updated: "2026-09-21T16:10:00.000Z"
last_activity: 2026-09-21
last_activity_desc: Quick 260921-9ie, a1d, bi2, fi3 und gof — Lint-Tor scharf, Benutzerverwaltung meldet abgewiesene Aktionen, Lint-Rueckstand 2923 → 446, erzwungener Passwortwechsel an der API durchgesetzt (war eine tote Sperre), 21 Effekt-Abhaengigkeiten einzeln beurteilt; alle fuenf verifiziert, die letzten drei am laufenden System
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-21 - Quick 260921-ldf: der Wackeltest aus CI-Lauf 395 war ein echter Produktfehler — der Fehler-melden-Dialog stand bei jedem Oeffnen kurz mit ausgeschaltetem Haekchen da; Ursache behoben, nicht der Test beruhigt
Last activity: 2026-09-21 - Quick 260921-m34: 288 any im Backend auf 15 gesenkt, jede verbliebene mit Urteil; dabei vier Befunde gemeldet statt still repariert, darunter ein sicherheitsrelevanter: die IMAP-Einstellung STARTTLS erzwingt nichts, weil die gesetzte Option in imapflow gar nicht existiert
Progress: [██████████] 99%
@@ -453,6 +453,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260921-iwr | **Listenschluessel und Ausrufezeichen-Zusicherungen: 30 Stellen geprueft, wieder kein echter Fehler.** Damit ist der fehlerverdaechtige Rueckstand abgearbeitet. **Zwei Vorannahmen des Orchestrators widerlegt, beide durch Messung statt Argument:** (1) Die LDAP-Seite galt als heisser Kandidat, weil dort Zuordnungsregeln hinzugefuegt und geloescht werden — die Liste, die tatsaechlich waechst und schrumpft (`config.fieldMappings`), benutzt jedoch laengst `key={mapping.id}`; die sechs Meldungen betreffen zustandslose Textlisten. (2) Im Cert-Manager galt eine Zusicherung auf hochgeladenen Dateiinhalt als moeglicher Absturz — der Planer hat eine 83-Byte-Schrottdatei gebaut, die node-forge `bag.cert = null` setzen laesst, und gegen den echten Dienst laufen lassen: **alle vier Pfade enden mit 400, nie 500**, und `certificateToPem(null)` wirft nachweislich, statt still ein falsches Zertifikat zu bauen. Also weder Verfuegbarkeits- noch Integritaetsluecke, sondern eine irrefuehrende Fehlermeldung. **Ein Fund dreht die Richtung um:** bei `admin/modules/grants/page.tsx:246` waere die Korrektur schaedlich — die Gruppierung fasst nur aufeinanderfolgende Kategorien zusammen, die Positionsnummer ist dort fuer die Eindeutigkeit noetig, ohne sie entstuenden doppelte Schluessel. **Geaendert: 5 Stellen** (drei Waechter im Cert-Manager, die den Meldungstext praezisieren — Status bleibt 400, rot-dann-gruen belegt; zwei ueberfluessige Zusicherungen in `imap.provider.ts`, die imapflow ohnehin als Pflichtfeld typisiert). **25 Stellen bleiben bewusst stehen und bleiben in der Zaehlung sichtbar** — mit Begruendung je Stelle in der Akte, damit der naechste Durchgang sie nicht erneut aufrollt; kein Unterdrueckungskommentar, um die Zahl zu schoenen. **Das Tor hat sich selbst bewaehrt:** der erste Entwurf eines Waechters erzeugte einen neuen Lint-Fund (430 statt 429) und wurde von der Verifikation des Plans gefangen; die Reparatur brach `tsc`, weil `@types/node-forge` `Bag.cert` als `Certificate | undefined` deklariert, waehrend die Bibliothek zur Laufzeit `null` zuweist — Endfassung prueft beides. **Zahlen:** 434 → 429, `noArrayIndexKey` unveraendert 19 (alle geprueft, alle harmlos), `noNonNullAssertion` 11 → 6, web-Tests 68/481 → 69/484, api 72/1137 → 72/1143, type-check 4/4, lint 5/5. | 2026-09-21 | 8716fa5,b4aaed4,27909e4,de69863 | [260921-iwr-listenschluessel-per-positionsnummer-und](./quick/260921-iwr-listenschluessel-per-positionsnummer-und/) |
| 260921-jt4 | **Barrierefreiheit von 30 auf 1 Befund, plus die vier zurueckgestellten Restposten.** Die 30 a11y-Befunde galten seit bi2 als "braucht Bedienentscheidungen"; die hat der Orchestrator getroffen, und der Planer hat **zwei davon widerlegt**: (1) Der vorgesehene Rueckfallweg (`role` + `tabIndex` + Tastaturhandler, wo kein echter Knopf geht) tauscht gemessen drei Befunde gegen einen neuen `useSemanticElements` — eine Regel, die bi2 gerade erst auf 0 gebracht hatte; wird nirgends benutzt, fuer den Verschachtelungsfall (Marktplatz-Karte) tritt eine deckende Geschwister-Schaltflaeche an seine Stelle. (2) **Vier der elf "Klick"-Befunde sind gar keine Klicks**, sondern `onError`-Handler an `<img>` — da gibt es keinen Tastaturweg zu schaffen, sie bekommen `aria-hidden`. **Ein Fund darueber hinaus:** alle fuenf ARIA-Befunde sind `aria-label` auf rollenlosen Elementen — die werden von Vorleseprogrammen still verworfen, die Beschriftungen kamen also bei niemandem an; jetzt mit korrekter Rolle. Sechs Stellen wurden zu echten `<button>` (Aussehen unveraendert), vier `autoFocus` auf Seiten entfernt (auf Seiten reisst er beim Laden den Fokus an sich — im Dialog waere er richtig gewesen, alle vier waren Seiten). **Ein Befund bleibt bewusst stehen und bleibt gezaehlt** (`calculator-widget.tsx:323`), samt ausdruecklich verworfener Umgehung. **Restposten:** ZIP-Name uebersetzt mit getesteter Schutzfunktion `zip-filename.ts` (der frueher genannte Umlaut-Einwand trifft fuer "Zertifikate.zip" nicht zu, die Schutzfunktion sichert kuenftige Uebersetzungen ab); die ueberfluessige `case`-Marke im Normalisierer aufgeloest, Absicht in den Kommentar gewandert; Kalender-Verschwendung abgestellt. **Zur `t`-Frage eine Korrektur an gof:** `use-intl` 4.13 erzeugt `t` in einem `useMemo`, es ist also in der Bibliothek stabil — instabil ist es nur in den Test-Attrappen, und daher kam der Beleg von damals. Die acht Korrekturen aus gof bleiben richtig und schaedlich sind sie nicht, aber die Begruendung war zu breit; ein Test an der Wurzel misst es jetzt. **Laufzeitnachweis vom Orchestrator** (Browser, 90 Tage Vorschau — bei der Voreinstellung 30 tritt der Doppelabruf gar nicht auf, die Messung haette also nichts gezeigt): drei Monatswechsel holen `calendar/sources` nur noch **1x statt 4x**, und der Termin-Abruf mit identischem Zeitraum ist weg (3 Klicks → 2 Abrufe statt 3). Der 5-Minuten-Auffrischer bleibt unangetastet — belegt nicht durch Warten im Browser (zwei Messversuche waren ungueltig, weil das Werkzeug die Seite zwischendurch neu laedt: nach 330 s Wartezeit war das Dokument 37 s alt), sondern durch Test 19 mit gestellter Uhr: nach `advanceTimersByTime(300_000)` werden **beide** Abrufe erneut ausgefuehrt. **Zahlen:** 429 → 399, a11y 30 → 1, web-Tests 69/484 → 73/529, api 72/1143 unveraendert, type-check 4/4, lint 5/5, keine neuen Unterdrueckungen. | 2026-09-21 | a8531d4,3d0bc0b,0c89c13,b601141,e651c24,+7 | [260921-jt4-barrierefreiheit-mit-bedienentscheidunge](./quick/260921-jt4-barrierefreiheit-mit-bedienentscheidunge/) |
| 260921-ldf | **Der Wackeltest war ein echter Produktfehler — nachgewiesen, nicht vermutet.** CI-Lauf 395 war rot; durchgefallen war ein Test aus quick-260914-m97, rund einmal in 17 vollen Laeufen, isoliert nie. Symptom: Vorschaubild da, Haekchen "Bildschirmfoto anhaengen" aus. **Ursache:** der Fehler-melden-Dialog war dauerhaft eingehaengt, sein `useState(screenshot !== null)` lief damit genau einmal — beim allerersten Laden der Seite, als noch kein Bild existierte — und der richtige Wert wurde erst von einem `useEffect` nachgezogen, der bauartbedingt nach dem Commit laeuft. **Beleg, deterministisch statt statistisch:** ein MutationObserver ueber jeden einzelnen DOM-Commit zeigt gegen den alten Stand, ohne jede kuenstliche Verzoegerung: `COMMIT dialog=true img=ja box=AUS` gefolgt von `COMMIT dialog=true img=ja box=AN`. Der falsche Zustand entsteht bei JEDEM Oeffnen, nicht nur unter Last, und haelt zwei Makrotask-Runden — dazwischen darf der Browser zeichnen, ein Nutzer kann es also sehen. **Ehrliche Einordnung der Tragweite:** die Korrektur kommt binnen Millisekunden, lange bevor jemand "Senden" treffen kann. Der befuerchtete Fall (Bild gesehen, abgeschickt, Bild fehlt) ist NICHT erreichbar; es bleibt ein kurzes Flackern. Repariert wurde trotzdem der Produktcode, nicht der Test — wer einen wirklich vorhandenen falschen Zustand im Test wegberuhigt, laesst ihn stehen. **Zwei Teilursachen, einzeln reicht keine:** der Dialog wird nur noch eingehaengt, solange er offen ist (frischer Mount je Oeffnen, der zuruecksetzende Effekt entfaellt), und das Haekchen wird beim Rendern abgeleitet statt nachgezogen. Dieselbe Ursache lag an einer zweiten Stelle: nach einem Versand stand beim erneuten Oeffnen zwei Runden lang der alte Danke-Bildschirm im DOM. **Zur Statistik, weil es der Kern der Sache ist:** 20 volle Laeufe ohne Fehlschlag gelten ausdruecklich NICHT als Beweis — bei der Ausgangsrate 1:17 waeren sie auch ohne Reparatur zu rund 30 Prozent zu erwarten. Tragend ist, dass der falsche Zwischenzustand nicht mehr existiert und die neuen Tests gegen den alten Stand 5 von 5 rot sind. Kein `retry`, kein hoeheres Zeitlimit — die Ursache war nie blosse Zeit. **Zwei Konstruktionsfehler des Tests mitbehoben:** das `expect` innerhalb der Attrappe (wirft es, landet der Fehler mitten im `await` von `captureScreenshot`, dessen `catch` still `null` liefert — der Test waere viel spaeter mit "kein Vorschaubild" durchgefallen, also in die falsche Richtung zeigend) und die per `Object.defineProperty` gesetzte `document.body`-Groesse, die `cleanup()` ueberlebte und alle zwoelf folgenden Tests derselben Datei 3200x1000 sehen liess. **Widerlegt unterwegs:** der Verdacht auf den dynamischen Import von `html-to-image` — er loest auf, bevor ein zuvor gesetzter `setTimeout(0)` feuert, ueberschreitet also keine Makrotask-Grenze. **Zahlen:** Warnungen 399 unveraendert, web-Tests 529 → 531, api 72/1143 unveraendert, type-check 4/4, lint 5/5. | 2026-09-21 | c0ab5b5,de7fdb7,9f02fcc,a6181e2 | [260921-ldf-wackeltest-fehler-melden-haekchen-bildsc](./quick/260921-ldf-wackeltest-fehler-melden-haekchen-bildsc/) |
| 260921-m34 | **288 `any` im Backend beurteilt: 15 bleiben, mit Urteil je Stelle.** Drei Durchgaenge. **Der groesste Posten war ein einziges Missverstaendnis:** 105 Stellen trugen `forTenant(...) as any`, obwohl `prisma.$extends()` laengst einen getypten Klienten liefert — die Zusicherung war nie noetig. Entfernen ergab genau EINEN Folgefehler, und der war selbst ein Befund (eine Handannotation, die nur existierte, um unter dem ungetypten Klienten eine Meldung zu umgehen, und falsch geworden war). **Aufgabe 2 war die sicherheitsrelevante:** ein gemeinsamer Typ `AuthUser` fuer die Aufrufer-Identitaet. Die `tenantId`-Frage wurde HERGELEITET, nicht nach Bequemlichkeit entschieden — `string | undefined` erzeugt 8 Fehler, `string` keinen, und das war ausdruecklich kein Argument. Belege: Pflichtspalte in `schema.prisma:38`, Bestandstyp `SessionUser`, und der Super-Admin-Zweig in `TenantGuard`. Der dritte Beleg widerlegt `string` NICHT, weil der Waechter sein Anfrageobjekt ungetypt holt und `AuthUser` gar nicht liest — der Zweig kann also nicht zu totem Code werden. Dass es ihn gibt, steht trotzdem im Typsystem: `AuthenticatedRequest.tenantId` ist `string | null | undefined`, das `null` stammt nur von dort, mit Warnkommentar. `tenant.guard.ts` ueber den ganzen Lauf 0 geaenderte Zeilen (Tor). **Aufgabe 3 ist zugleich das Urteilsregister:** typisiert 252, auf `unknown` umgestellt 21, bleibt 15 — jede der 15 mit Begruendung im Code (6 node-forge, wo die mitgelieferten Typen die Bibliothek nachweislich falsch beschreiben; 3 Cron; 4 `withTenantTransaction`, wo der genaue Typ eine bewusst unvollstaendige Test-Attrappe braeche; 2 imapflow). Null war ausdruecklich NICHT das Ziel. **Vier Befunde gemeldet statt still repariert** — zwei davon brauchen eine Entscheidung des Nutzers: (B-06, sicherheitsrelevant) `imap.provider.ts:402` setzt `requireTLS`, das es in imapflow 1.4.3 NIRGENDS gibt (vom Orchestrator unabhaengig nachgeprueft: kein Treffer im ganzen Paket). Die Option wird still verworfen, die Einstellung "STARTTLS" erzwingt also nichts; die Bibliothek faellt dann auf ihr Standardverhalten zurueck und setzt laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — sie nennt das selbst eine Downgrade-Angriffsflaeche. Richtig waere `doSTARTTLS: true`. Die `as any`-Zusicherung hatte das verdeckt. (B-05) `imap.provider.ts:78` liest `.parameters` von einer Zeichenkette (imapflow deklariert `disposition: string`, die Parameter liegen in `dispositionParameters`) — zur Laufzeit immer `undefined`, Outlook-Anhaenge als `application/octet-stream` werden ueber Content-Disposition nicht erkannt; betrifft den DKV-Rechnungseinzug. Dazu (B-04) eine Falle im RLS-Erkenner (er zaehlt jede `select:`-Angabe ausserhalb eines Modellaufrufs als Verstoss) — Erkenner NICHT aufgeweicht, Typ anders hergeleitet; und (B-07) httpntlm liefert den Rumpf als Zeichenkette, nicht als Buffer. **Zahlen:** Diagnosen 399 → 125, `any` im Quellcode 288 → 15, `apps/web` 1 → 0, Disziplin-Zaehler unveraendert (`as unknown as` 33, `noNonNullAssertion` 56, Unterdrueckungen 1, `ts-expect-error` 0), api 72/1143, web 73/531, type-check 4/4, lint 5/5, RLS-Waechter 30/30. | 2026-09-21 | b188946,f2fc39f,7c9d7c1,52668c2,32591b6,3892c5f,d8fb9ae | [260921-m34-288-any-im-backend-einzeln-beurteilen-un](./quick/260921-m34-288-any-im-backend-einzeln-beurteilen-un/) |
## Deferred Items
@@ -498,4 +499,4 @@ Last session: 2026-09-21T04:50:00Z
Resumed: 2026-09-21 — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 55aa287, CI-Lauf 387 fuer 55aa287 erfolgreich (Beta-Images gebaut). Push und CI aus dem letzten Stopp-Punkt sind damit erledigt.
Stopped at: Warte auf Nutzerentscheidung, womit weitergearbeitet wird. Offen fuer den User: alpha pullen (web+api) und danach am Windows-VM-Client die echte Fehlermeldung schicken (Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile pruefen); eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf. Technisch offen im Ledger: WINDOWS #35 (Biome laeuft nicht — biome.json:3 `organizeImports` ist in Biome 2.5.0 unbekannt, `biome check` bricht mit Konfigurationsfehler ab, reproduziert 2026-09-21) und WINDOWS #36 (403-Antworten bleiben in handleSubmit/handleDelete ohne sichtbare Reaktion).
Resume file: None
Last activity: 2026-09-21 - Quick 260921-ldf: der Wackeltest aus CI-Lauf 395 war ein echter Produktfehler — der Fehler-melden-Dialog stand bei jedem Oeffnen kurz mit ausgeschaltetem Haekchen da; Ursache behoben, nicht der Test beruhigt
Last activity: 2026-09-21 - Quick 260921-m34: 288 any im Backend auf 15 gesenkt, jede verbliebene mit Urteil; dabei vier Befunde gemeldet statt still repariert, darunter ein sicherheitsrelevanter: die IMAP-Einstellung STARTTLS erzwingt nichts, weil die gesetzte Option in imapflow gar nicht existiert
@@ -0,0 +1,336 @@
---
phase: quick-260921-m34
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-M34-01]
files_modified:
- apps/api/src/prisma/prisma-tenant.extension.ts
- apps/api/src/auth/auth.service.ts
- apps/api/src/auth/auth.controller.ts
- apps/api/src/auth/strategies/jwt.strategy.ts
- apps/api/src/auth/strategies/local.strategy.ts
- apps/api/src/auth/interceptors/force-password-change.interceptor.ts
- apps/api/src/auth/types/auth-user.ts
- apps/api/src/bug-reports/bug-reports.service.ts
- apps/api/src/bug-reports/bug-reports.controller.ts
- apps/api/src/calendar/calendar.controller.ts
- apps/api/src/calendar/calendar.service.ts
- apps/api/src/calendar/providers/exchange.provider.ts
- apps/api/src/cert-manager/cert-manager.controller.ts
- apps/api/src/cert-manager/cert-manager.service.ts
- apps/api/src/dashboard/dashboard.controller.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/api/src/dkv/dkv.service.ts
- apps/api/src/dkv/dkv-scheduler.service.ts
- apps/api/src/favorites/favorites.controller.ts
- apps/api/src/favorites/favorites.service.ts
- apps/api/src/groups/groups.controller.ts
- apps/api/src/groups/groups.service.ts
- apps/api/src/groups/module-grants.controller.ts
- apps/api/src/groups/module-grants.service.ts
- apps/api/src/inbox/exchange-inbox.provider.ts
- apps/api/src/inbox/imap.provider.ts
- apps/api/src/ldap/ldap.controller.ts
- apps/api/src/ldap/ldap.service.ts
- apps/api/src/ldap/ldap-config.service.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/module-registry/module-access.service.ts
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/module-registry/module-registry.service.ts
- apps/api/src/settings/settings.controller.ts
- apps/api/src/settings/settings.service.ts
- apps/api/src/tenant/tenant.controller.ts
- apps/api/src/tenders/tenders.controller.ts
- apps/api/src/tenders/tender-digest.scheduler.ts
- apps/api/src/tenders/tender-email-config.service.ts
- apps/api/src/tenders/tender-matching.service.ts
- apps/api/src/tenders/tender-notification-pref.service.ts
- apps/api/src/tenders/tender-rss-feed.service.ts
- apps/api/src/tenders/tender-saved-search.service.ts
- apps/api/src/tenders/tender-scheduler.service.ts
- apps/api/src/tenders/tender-triage.service.ts
- apps/api/src/user/admin-seed.service.ts
- apps/api/src/user/user.controller.ts
- apps/api/src/user/user.service.ts
- apps/api/src/auth/auth.controller.spec.ts
- apps/api/src/user/user.controller.spec.ts
- apps/web/src/test/setup.ts
estimate:
tokens: 260000
raw_tokens: 173000
tasks: 3
confidence: low
must_haves:
truths:
- "Jeder der 288 Befunde in apps/api hat am Ende genau ein Urteil: typisiert, auf unknown umgestellt, oder bleibt mit gemessener Begruendung (D-01)."
- "Kein Befund wird durch eine Behauptung stillgelegt: noNonNullAssertion bleibt bei hoechstens 56, ts-expect-error/ts-ignore bleibt bei 0, Lint-Unterdrueckungsmarker bleiben bei 1, 'as unknown as' bleibt bei hoechstens 33 (D-02)."
- "Das Verhalten der API ist unveraendert; jede Stelle, an der die neue Typisierung eine falsche Annahme im Bestandscode aufdeckt, wird als Befund gemeldet statt still korrigiert (D-03)."
- "Nach JEDER Aufgabe: pnpm type-check 4/4 und pnpm lint 5/5 mit 0 Fehlern der Schwere error (D-05)."
- "Nach JEDER Aufgabe: apps/api 72 Dateien / 1143 Tests gruen, apps/web 73 / 531 gruen (D-06)."
- "Der gemeinsame Aufrufer-Typ leitet jedes Feld aus den beiden Signierstellen und der Verbraucherpruefung ab; kein Feld wird so getypt, dass eine bestehende Berechtigungspruefung als tot erscheint (D-02, D-03)."
artifacts:
- "apps/api/src/auth/types/auth-user.ts — AuthUser, AuthenticatedRequest, UploadedFileLike, JwtPayload"
- "apps/api/src/prisma/prisma-tenant.extension.ts ohne interne Zusicherungen"
- "Urteilsregister der bleibenden Befunde im SUMMARY, plus je eine Begruendungszeile direkt an der Codestelle"
key_links:
- "forTenant()/forSystem() Rueckgabewert -> 105 Aufrufstellen: der Zusicherungsverzicht darf den Erkenner in rls-access-inventory.spec.ts nicht blind machen"
- "JwtStrategy.validate() -> AuthUser -> TenantGuard/RolesGuard: die Feldtypen entscheiden, ob Mandanten- und Rollenpruefungen weiterhin scharf sind"
- "FileInterceptor ohne storage-Option -> multer memoryStorage -> file.buffer ist ein Buffer: nur deshalb ist UploadedFileLike ueberpruefbar und keine Behauptung"
---
<objective>
288 `any`-Befunde im Backend einzeln beurteilen und typisieren. Kein Aufraeumen nach Gefuehl: jeder Befund bekommt ein Urteil, jedes Urteil eine Begruendung, und die Zahl faellt nur um das, was tatsaechlich ehrlich getypt werden konnte (D-01).
Purpose: Der `any`-Rueckstand ist der letzte Posten des Rueckstands, den der Nutzer vor neuen Funktionen geraeumt haben will. Er hat keinen bekannten Fehler hinter sich — es ist reine Typarbeit. Der Wert liegt darin, dass die naechste Aenderung an Mandanten-, Rollen- und Upload-Wegen vom Compiler begleitet wird statt von Vertrauen.
Output: apps/api mit deutlich weniger `any`, einem gemeinsamen Aufrufer-Typ, und einem Register der Stellen, die bewusst stehen bleiben.
## Was vorab gemessen wurde (2026-09-21, Planungszeitpunkt)
Ausgangslage, mit `npx biome lint --reporter=json` gezaehlt (Feld `category`, nie durch Textsuche im Quelltext):
| Groesse | Wert |
|---|---|
| `lint/suspicious/noExplicitAny` in `apps/api/src` | **288** (48 Dateien, +1 in apps/web) |
| `lint/style/noNonNullAssertion` in `apps/api/src` | 56 |
| Befunde der Schwere `error` | 0 |
| `ts-expect-error` / `ts-ignore` | 0 |
| Lint-Unterdrueckungsmarker | 1 |
| `as unknown as` | 33 |
| `pnpm type-check` / `pnpm lint` | 4/4 und 5/5, Rueckgabewert 0 |
| `apps/api` Vitest / `apps/web` Vitest | 72 Dateien / 1143 Tests, 73 / 531 |
Die Befunde fallen in wenige wiederkehrende Formen. Sie wurden nicht geschaetzt, sondern durch Probeumbauten am echten Baum gemessen (jeder Probeumbau danach zurueckgenommen, Baum wieder sauber):
| Form | Anzahl | Messergebnis |
|---|---|---|
| `forTenant(...) as any` / `forSystem(...) as any` | **105** | Zusicherung an allen 105 Stellen entfernt -> `tsc` meldet **genau einen** Folgefehler. Die Zusicherung war nie noetig: `prisma.$extends(...)` liefert bereits einen vollstaendig getypten Klienten. |
| Gefolge davon (`: any[]`, `.map((g: any) => ...)`, `x as any[]`) | 22 | haengt am `any`-Klienten und faellt mit ihm |
| Innereien von `prisma-tenant.extension.ts` | 15 | `(prisma as any)` und die Handannotationen an `$allOperations` entfernt -> `tsc` sauber. 13 fallen, 2 (`tx: any`) sind noch offen. |
| `(req as any)` | 32 | sechs Controller auf einen getypten Request umgestellt -> `tsc` meldet **genau einen** Fehler, und der ist ein echter Befund (siehe unten) |
| `@Req() req: any` | 25 | dito |
| `@CurrentUser() user: any` | 13 | mit `AuthUser` getypt -> 8 Fehler im Produktivcode, 28 in Testdateien (Fixtures ohne `username`/`mustChangePassword`) |
| `catch (e: any)` | 18 | Umstellung auf `unknown` plus Eingrenzung an der Verwendungsstelle |
| node-forge in `cert-manager.service.ts` | 11 | gemischt; `@types/node-forge` ist installiert |
| `@UploadedFile()` / `@UploadedFiles()` und ihre Dienst-Gegenstuecke | 11 | `Express.Multer.File` existiert hier **nicht** (kein `@types/multer`, gemessen). Alle `FileInterceptor`-Aufrufe setzen **keine** `storage`-Option -> multer memoryStorage -> `file.buffer` ist ein Buffer. |
| `job as any` an `addCronJob` | 3 | Zusicherung entfernt -> `tsc` meldet 3 Fehler: das lokale `job` hat die Form `{ start(): void }`, `addCronJob` verlangt einen echten `CronJob`. **Nicht aufloesbar ohne Verhaltensaenderung.** |
| Rest (httpntlm, imap, nodemailer, calendar, web-setup, ...) | 33 | einzeln zu beurteilen |
## Zwei Befunde, die die Messung schon jetzt aufgedeckt hat (D-03)
1. `apps/api/src/tenders/tender-matching.service.ts:159` — die Handannotation `(match: { tender: unknown })` verengt den Wert **faelschlich** auf `unknown`, sobald der Klient richtig getypt ist. Sie existierte nur, um unter dem `any`-Klienten TS7006 zu vermeiden. Loeschen ist der richtige Umgang, keine Zusicherung.
2. `apps/api/src/dashboard/dashboard.controller.ts:74` — gibt `req.user?.role` (moeglicherweise `undefined`) an etwas weiter, das `Role` verlangt. Der Code nimmt an, dass ein Aufrufer immer vorhanden ist. Das ist als Befund zu melden, nicht stumm zu reparieren.
## Was am Ende erwartet wird, und warum
Erwartung: **etwa 20 bis 40 verbleibende Befunde** in `apps/api` (Ausgang 288). Herleitung, nicht Wunsch:
- Aufgabe 1 nimmt rund 136 (105 gemessen + 13 gemessen + rund 18 Gefolge).
- Aufgabe 2 nimmt rund 90 (13 + 25 + 32 + 1 + 11 + rund 8 Umfeld).
- Aufgabe 3 findet rund 62 vor und loest davon vielleicht 35; der Rest bleibt.
Bleiben werden voraussichtlich: die drei Cron-Stellen (gemessen nicht aufloesbar), ein Teil der node-forge-Stellen, an denen die mitgelieferten Typen die Bibliothek falsch beschreiben, sowie einzelne Stellen an Fremdbibliotheken ohne Typen. **Null ist ausdruecklich nicht das Ziel.** Wer die letzten Stellen erzwingt, tauscht eine ehrliche Warnung gegen eine unehrliche Behauptung — genau das verbietet D-02. Eine kleinere Zahl mit sauberen Urteilen ist das bessere Ergebnis.
## Keine laufende Umgebung noetig
Fuer reine Typarbeit ist kein Stapel noetig: alle Pruefungen dieses Plans sind `tsc`, Biome und die beiden Testlaeufe. Es wird **kein** `docker compose` gestartet. Sollte wider Erwarten eine Laufzeitfrage auftauchen, ist das ein Grund, sie als Befund zu melden (D-03), nicht ein Grund, einen Stapel hochzufahren.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@apps/api/src/prisma/prisma-tenant.extension.ts
@apps/api/src/auth/strategies/jwt.strategy.ts
@apps/api/src/auth/decorators/current-user.decorator.ts
@apps/api/src/tenant/tenant.guard.ts
@apps/api/src/bug-reports/bug-reports.service.ts
</context>
<!-- planner-discipline-allow: biome-ignore, ts-expect-error, ts-ignore, as unknown as, noExplicitAny, noNonNullAssertion -->
<tasks>
<task type="tracer">
<name>Aufgabe 1: Die Mandantenbindung entzaubern — 105 Zusicherungen, die nie noetig waren</name>
<files>apps/api/src/prisma/prisma-tenant.extension.ts, apps/api/src/auth/auth.service.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/dkv/dkv.service.ts, apps/api/src/favorites/favorites.service.ts, apps/api/src/groups/groups.service.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/ldap/ldap-config.service.ts, apps/api/src/ldap/ldap.service.ts, apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-registry.service.ts, apps/api/src/settings/settings.service.ts, apps/api/src/tenant/tenant.controller.ts, apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-notification-pref.service.ts, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tender-triage.service.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.service.ts</files>
<read_first>apps/api/src/prisma/prisma-tenant.extension.ts (Kopfkommentar Zeile 1-196 erklaert, warum die Array-Form der Transaktion Pflicht ist — daran wird nichts geaendert), apps/api/src/prisma/rls-access-inventory.spec.ts (der Erkenner, der diese Aufrufstellen zaehlt), apps/api/src/groups/groups.service.ts Zeile 55-75 (zeigt das Gefolge: der `any`-Klient erzwingt `any[]` und `(g: any)`), apps/api/src/tenders/tender-matching.service.ts Zeile 135-170</read_first>
<action>
Der groesste Block ist zugleich der harmloseste, und deshalb steht er zuerst: er beweist die Methode an der breitesten Stelle, bevor irgendetwas Sicherheitsrelevantes angefasst wird.
Schritt A — die Innereien des Erweiterungsmoduls. In `prisma-tenant.extension.ts` sind die Zusicherungen `(prisma as any)` in `forTenant`, `forSystem` und `withTenantTransaction` unnoetig: `PrismaClient` traegt `$executeRaw` und `$transaction` bereits. Entferne sie. Entferne ebenso die Handannotation an `$allOperations` — der Parameter wird von Prisma hergeleitet, die Annotation `{ args: any; query: (args: any) => any }` ersetzt eine korrekte Herleitung durch drei `any`. Stelle `.then((results: any[]) => results[1])` auf `unknown[]` um; der Ergebnistyp der Aufrufstellen kommt aus Prismas Erweiterungstypen, nicht aus diesem Rueckgabewert (gemessen: `tsc` bleibt danach sauber). Fuer `withTenantTransaction` bleibt `fn: (tx: any)`: pruefe, ob `Prisma.TransactionClient` hier passt, und wenn ja, ziehe die vier Aufrufstellen in `groups.service.ts` und `favorites.service.ts` mit. Wenn `tsc` das nicht traegt, ist `bleibt` mit gemessener Begruendung das richtige Urteil (D-01).
Am Kopfkommentar (Zeile 1-196) wird nichts geaendert. Er dokumentiert Messungen zu Verbindungen und Transaktionen, nicht zu Typen.
Schritt B — die 105 Aufrufstellen. Die Form ist ueberall `const tenantPrisma = forTenant(this.prisma, tenantId) as any;` beziehungsweise `const systemPrisma = forSystem(this.prisma) as any;`. Entferne die Zusicherung. Gemessen: `tsc` meldet danach genau einen Folgefehler, naemlich den aus Schritt C.
Wichtig fuer die Mandantentrennung: der Erkenner in `rls-access-inventory.spec.ts` sucht nach `const <Name> = forTenant(` beziehungsweise `const <Name> = forSystem(`. Das Entfernen der nachgestellten Zusicherung beruehrt diesen Praefix nicht. Aendere die Zuweisungsform nicht, fasse keine zwei Aufrufe zusammen, und verschiebe keinen Aufruf in eine andere Datei — die Erlaubnisliste `FORSYSTEM_ALLOWED_CALL_SITES` haelt je Datei eine exakte Zahl fest, jede Abweichung macht die Spec rot. Das ist die eigentliche Schutzwirkung dieser Aufgabe und darf nicht beschaedigt werden (T-M34-03).
Schritt C — das Gefolge. Mit einem richtig getypten Klienten werden die Handannotationen, die nur seinetwegen dastanden, von Hilfe zu Schaden. Entferne sie:
- `const groups: any[] = await tenantPrisma.group.findMany(...)` in `groups.service.ts:63` und `const masters: any[] = ...` in `dkv.service.ts:755` — samt des erklaerenden Kommentars darueber, der jetzt nicht mehr stimmt.
- die `.map((g: any) => ...)`, `.map((u: any) => ...)`, `.map((a: any) => ...)`, `(a: any, b: any) =>` und `for (const g of groupGrants as any[])`-Stellen in `groups.service.ts`, `module-grants.service.ts`, `ldap-config.service.ts`, `tenders.controller.ts:270`.
- **Der gemessene Befund:** `tender-matching.service.ts:159` traegt `(match: { tender: unknown })`. Diese Handannotation verengt den Wert falsch, sobald der Klient getypt ist, und erzeugt den einen Fehler aus Schritt B. Loesche die Annotation, damit der hergeleitete Typ durchkommt. Setze hier **keine** Zusicherung.
Fuer jede Stelle, an der das Entfernen einer Annotation einen neuen `tsc`-Fehler erzeugt, gilt: der Fehler ist ein Befund. Pruefe, was der Code tatsaechlich annimmt. Wenn die Annahme falsch war, melde sie im SUMMARY und lass das Verhalten unangetastet (D-03). Ersetze sie **nicht** durch eine Zusicherung, eine Ausrufezeichen-Behauptung oder einen Unterdrueckungskommentar (D-02) — die Zaehlwerte in `<verify>` fangen genau das ab.
Keine Formatierung ueber den Bestand hinaus, keine Versionsspruenge, keine neuen Abhaengigkeiten, und die Testdatei-Ausnahme in `biome.json` bleibt unberuehrt (D-04).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; a=sum(1 for x in d if x.get('category')=='lint/suspicious/noExplicitAny'); n=sum(1 for x in d if x.get('category')=='lint/style/noNonNullAssertion'); e=sum(1 for x in d if x.get('severity')=='error'); print('any',a,'nonnull',n,'error',e); sys.exit(0 if a<=155 and n<=56 and e==0 else 1)"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -rho 'ts-expect-error' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'ts-ignore' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'biome-ignore' apps/api/src --include='*.ts' | wc -l)" = 1 && test "$(grep -rho 'as unknown as' apps/api/src --include='*.ts' | wc -l)" -le 33 && echo "keine stillgelegten Stellen"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | grep -q '4 successful, 4 total' && pnpm lint 2>&1 | grep -q '5 successful, 5 total' && echo "type-check 4/4, lint 5/5"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api run test 2>&1 | tee /tmp/m34-api.log | tail -5 && grep -q 'Test Files 72 passed (72)' /tmp/m34-api.log && grep -q 'Tests 1143 passed (1143)' /tmp/m34-api.log && echo "api 72/1143 gruen"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/web run test 2>&1 | tee /tmp/m34-web.log | tail -5 && grep -q 'Test Files 73 passed (73)' /tmp/m34-web.log && grep -q 'Tests 531 passed (531)' /tmp/m34-web.log && echo "web 73/531 gruen"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api exec vitest run src/prisma/rls-access-inventory.spec.ts 2>&1 | tail -4</automated>
</verify>
<done>Die Zaehlung liegt bei hoechstens 155 (Ausgang 288), `noNonNullAssertion` bei hoechstens 56, `error`-Befunde bei 0, keine neuen Unterdrueckungsmarker. `pnpm type-check` 4/4 und `pnpm lint` 5/5. Beide Testsuiten unveraendert gruen (72/1143 und 73/531), `rls-access-inventory.spec.ts` ausdruecklich gruen. Der Kopfkommentar von `prisma-tenant.extension.ts` ist unveraendert. Jede Stelle, an der ein neuer `tsc`-Fehler auftrat, steht als Befund im SUMMARY.</done>
</task>
<task type="auto">
<name>Aufgabe 2: Wer ruft hier eigentlich an — ein gemeinsamer Typ fuer Aufrufer, Request und Upload</name>
<files>apps/api/src/auth/types/auth-user.ts, apps/api/src/auth/auth.controller.ts, apps/api/src/auth/auth.service.ts, apps/api/src/auth/strategies/jwt.strategy.ts, apps/api/src/auth/strategies/local.strategy.ts, apps/api/src/auth/interceptors/force-password-change.interceptor.ts, apps/api/src/bug-reports/bug-reports.controller.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dkv/dkv.controller.ts, apps/api/src/favorites/favorites.controller.ts, apps/api/src/groups/groups.controller.ts, apps/api/src/groups/module-grants.controller.ts, apps/api/src/ldap/ldap.controller.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/settings/settings.controller.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/user/user.controller.ts, apps/api/src/auth/auth.controller.spec.ts, apps/api/src/user/user.controller.spec.ts</files>
<read_first>apps/api/src/auth/strategies/jwt.strategy.ts (die Quelle des Aufrufer-Objekts), apps/api/src/auth/auth.service.ts Zeile 165-200 und 370-395 (die beiden Stellen, die das Token signieren — sie bestimmen, welche Felder es ueberhaupt gibt), apps/api/src/tenant/tenant.guard.ts (der Verbraucher, dessen Pruefungen scharf bleiben muessen), apps/api/src/bug-reports/bug-reports.service.ts Zeile 55-90 (`SessionUser` und `UploadedPng` — das Vorbild im Bestand fuer schmale, nur die gelesenen Felder beschreibende Schnittstellen), apps/api/prisma/schema.prisma Zeile 23-53 (Role-Aufzaehlung und User-Modell)</read_first>
<behavior>
- `AuthUser` beschreibt genau die fuenf Felder, die `JwtStrategy.validate()` zurueckgibt, und keines mehr.
- Jedes Feld ist aus den beiden Signierstellen in `auth.service.ts` belegbar; kein Feld ist erfunden.
- `AuthenticatedRequest` beschreibt `user` (von Passport gesetzt) und `tenantId` (von `TenantGuard` gesetzt, dort auch auf `null` gesetzt) mit den Optionalitaeten, die diese beiden Setzer tatsaechlich erzeugen.
- `UploadedFileLike` beschreibt ausschliesslich die Felder, die der Code liest: `buffer`, `originalname`, `mimetype`, `size`.
- Keine bestehende Berechtigungspruefung wird durch die Typisierung tot, und keine wird entfernt.
</behavior>
<action>
Das ist die Aufgabe mit Sicherheitsfolgen. Sie faellt den einen Typ, an dem jede Mandanten- und Rollenentscheidung der API haengt. Deshalb wird hier nichts geraten.
Schritt A — die Typen anlegen, in `apps/api/src/auth/types/auth-user.ts`. Vorbild ist ausdruecklich `SessionUser`/`UploadedPng` in `bug-reports.service.ts`: schmale Schnittstellen, die nur beschreiben, was gelesen wird. Ziehe `SessionUser` und `UploadedPng` danach auf die neuen Typen zurueck, damit es keine zwei konkurrierenden Beschreibungen desselben Objekts gibt.
- `AuthUser` — leite jedes einzelne Feld aus `JwtStrategy.validate()` (Zeile 27-38) ab und belege es an den **beiden** Signierstellen `auth.service.ts:171` und `auth.service.ts:376`. Felder: `id`, `username`, `role`, `tenantId`, `mustChangePassword`.
- Zu `role`: die Aufzaehlung `Role` aus `@prisma/client` ist der ehrliche Typ, weil der Signierer genau den Spaltenwert schreibt. Gemessen: sechs Controller vertragen `role: Role` ohne einen einzigen Fehler. Wenn `tsc` irgendwo doch anschlaegt, weil eine Stelle gegen eine Zeichenkette ausserhalb der Aufzaehlung vergleicht, ist das ein Befund nach D-03 und wird gemeldet, nicht mit einer Zusicherung beruhigt (T-M34-02).
- **Zu `tenantId` — das ist die sicherheitsrelevante Entscheidung dieses Plans.** Die Messung: `tenantId: string | undefined` erzeugt acht Fehler im Produktivcode, `tenantId: string` keinen. Das ist **kein** Argument fuer die bequemere Variante. Pruefe stattdessen die Belege: die Spalte `User.tenantId` ist in `schema.prisma` **nicht optional**; der Bestand beschreibt dasselbe Objekt in `SessionUser` bereits als `tenantId: string`; und `TenantGuard` haelt fuer SUPER_ADMIN einen Zweig ohne Mandanten vor. Entscheide auf dieser Grundlage und schreibe die Begruendung als Kommentar an den Typ. Wenn die Wahl auf `string` faellt, halte im selben Kommentar ausdruecklich fest, dass der SUPER_ADMIN-Zweig in `TenantGuard` ein Schutzzweig bleibt und **nicht** wegtypisiert oder entfernt werden darf, weil er sonst bei der naechsten Aenderung als toter Code geloescht wird (T-M34-01). Entferne an `TenantGuard` in dieser Aufgabe nichts.
- `AuthenticatedRequest` — erweitert `Request` aus `express` um `user` und `tenantId`. `TenantGuard` setzt `tenantId` auf eine Zeichenkette **oder** auf `null` und laeuft auf oeffentlichen Wegen gar nicht; die Optionalitaeten muessen das abbilden.
- `UploadedFileLike` — `Express.Multer.File` gibt es in diesem Baum **nicht** (gemessen: `@types/multer` ist nicht installiert), und D-04 verbietet, es nachzuinstallieren. Eine eigene schmale Schnittstelle ist hier trotzdem keine Behauptung, sondern belegbar: kein einziger `FileInterceptor`/`FilesInterceptor`-Aufruf setzt eine `storage`-Option, damit gilt multer memoryStorage, damit ist `file.buffer` ein Buffer. Pruefe das nach und beschreibe **nur** die gelesenen Felder. Nimm kein Feld auf, das kein Aufrufer liest.
- `JwtPayload` — fuer `jwt.strategy.ts:27` (`payload: any`). Die Nutzlast ist an den beiden Signierstellen vollstaendig belegt; beschreibe sie danach. `mustChangePassword` wird dort bereits bewusst mit `=== true` gegen alte Token abgesichert (260921-fi3, D-01) — dieses Verhalten bleibt woertlich erhalten.
Schritt B — anwenden. Ersetze `@CurrentUser() user: any` (13 Stellen), `@Req() req: any` (25), `(req as any)` (32), `@Res() res: any` (1), `@UploadedFile()`/`@UploadedFiles()` samt der Dienst-Gegenstuecke `file?: any` und `files?: any[]` in `cert-manager.service.ts` (11). Zieh das Umfeld mit: `resolveTargetTenantId(currentUser: any)`, `resolveTargetUser(currentUser: any)`, `login(user: any)`, `validateUser(): Promise<any>`, `local.strategy` `validate(): Promise<any>`, `normalizePath(request: any)`, `intercept(): Observable<any>` (dort ist `unknown` der ehrliche Typ), `dkv.controller` `_requireTenant(req: any)`.
Ein Nebengewinn, der mitzunehmen ist: in `cert-manager.service.ts` stehen heute rund 25 Zusicherungen der Form `file.buffer as Buffer` und `file.originalname as string`, die es nur gibt, weil `file` ein `any` ist. Mit der schmalen Schnittstelle fallen sie weg — der Zaehlwert `as unknown as` darf dabei nicht steigen.
**Der gemessene Befund:** `dashboard.controller.ts:74` gibt einen moeglicherweise fehlenden Aufrufer-Wert an etwas weiter, das ihn zwingend verlangt. Das ist eine falsche Annahme im Bestandscode. Melde sie im SUMMARY mit Datei, Zeile und dem, was der Code annimmt. Verhalten unveraendert lassen (D-03): keine neue Pruefung einbauen, die vorher nicht da war, und keinen Wert erfinden.
Schritt C — die Testfixtures. Gemessen: 28 `tsc`-Fehler in `auth.controller.spec.ts` und `user.controller.spec.ts`, weil die Fixtures Teilobjekte wie `{ id, tenantId, role }` uebergeben. Ergaenze die fehlenden Felder in den Fixtures. Das ist reine Fixture-Pflege: die Zahl der Testdateien und Tests darf sich **nicht** aendern (D-06), und keine Zusicherung in einer Testdatei darf den Fehler stattdessen zudecken. Die Testdatei-Ausnahme in `biome.json` bleibt unberuehrt (D-04).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; a=sum(1 for x in d if x.get('category')=='lint/suspicious/noExplicitAny'); n=sum(1 for x in d if x.get('category')=='lint/style/noNonNullAssertion'); e=sum(1 for x in d if x.get('severity')=='error'); print('any',a,'nonnull',n,'error',e); sys.exit(0 if a<=75 and n<=56 and e==0 else 1)"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -rho 'ts-expect-error' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'ts-ignore' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'biome-ignore' apps/api/src --include='*.ts' | wc -l)" = 1 && test "$(grep -rho 'as unknown as' apps/api/src --include='*.ts' | wc -l)" -le 33 && echo "keine stillgelegten Stellen"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && git diff --stat -- apps/api/src/tenant/tenant.guard.ts > /tmp/m34-guard.txt || exit 1; cat /tmp/m34-guard.txt; test ! -s /tmp/m34-guard.txt && echo "TenantGuard unberuehrt"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | grep -q '4 successful, 4 total' && pnpm lint 2>&1 | grep -q '5 successful, 5 total' && echo "type-check 4/4, lint 5/5"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api run test 2>&1 | tee /tmp/m34-api.log | tail -5 && grep -q 'Test Files 72 passed (72)' /tmp/m34-api.log && grep -q 'Tests 1143 passed (1143)' /tmp/m34-api.log && echo "api 72/1143 gruen"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/web run test 2>&1 | tee /tmp/m34-web.log | tail -5 && grep -q 'Test Files 73 passed (73)' /tmp/m34-web.log && grep -q 'Tests 531 passed (531)' /tmp/m34-web.log && echo "web 73/531 gruen"</automated>
</verify>
<done>Die Zaehlung liegt bei hoechstens 75. `apps/api/src/auth/types/auth-user.ts` existiert und traegt die Begruendung jedes Feldes als Kommentar, mit Verweis auf die Signierstellen und auf `schema.prisma`. `tenant.guard.ts` ist unveraendert. `SessionUser`/`UploadedPng` in `bug-reports.service.ts` sind auf die neuen Typen zurueckgezogen, es gibt keine zweite Beschreibung desselben Objekts. `noNonNullAssertion` hoechstens 56, `as unknown as` hoechstens 33, keine neuen Unterdrueckungsmarker, 0 `error`-Befunde. Beide Suiten gruen mit unveraenderten Zahlen. Der Befund aus `dashboard.controller.ts:74` steht im SUMMARY.</done>
</task>
<task type="auto">
<name>Aufgabe 3: Die Randschicht — Fehlerfaenger auf unknown, und ein Urteil fuer jede Stelle, die bleibt</name>
<files>apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/providers/exchange.provider.ts, apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/dkv/dkv-scheduler.service.ts, apps/api/src/groups/groups.service.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/inbox/exchange-inbox.provider.ts, apps/api/src/inbox/imap.provider.ts, apps/api/src/ldap/ldap.service.ts, apps/api/src/mail/mail.service.ts, apps/api/src/auth/auth.service.ts, apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-notification-pref.service.ts, apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tender-scheduler.service.ts, apps/api/src/tenders/tender-triage.service.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/user.service.ts, apps/web/src/test/setup.ts</files>
<read_first>apps/api/src/cert-manager/cert-manager.service.ts Zeile 220-290 und 490-545 (node-forge-Umgang), apps/api/src/dkv/dkv-scheduler.service.ts Zeile 120-140 (der require-Umweg fuer CronJob aus 07-04), apps/api/src/inbox/imap.provider.ts Zeile 20-70, apps/api/src/groups/groups.service.ts Zeile 95-110 (ein typischer Fehlerfaenger mit Prisma-Fehlercode-Pruefung)</read_first>
<action>
Der Rest, Form fuer Form. Hier ist die Erwartung ausdruecklich gemischt: ein Teil wird getypt, ein Teil bleibt — und das Bleiben ist ein vollwertiges Ergebnis, kein Versagen (D-01).
Schritt A — die 18 Fehlerfaenger. `catch (e: any)` wird zu `catch (e: unknown)` plus Eingrenzung an der Verwendungsstelle. Fast alle lesen `err.code` (Prisma-Fehlercodes wie P2025, P2002) oder `err.message`. Grenze ein, statt zu behaupten: eine Pruefung auf Objektform und Feld, oder `instanceof Error` fuer `message`. Der Bestand hat dafuer bereits ein Vorbild in `tender-matching.service.ts:123` (`(err as Error).message`) — das ist die schwaechere Variante; wo eine echte Eingrenzung ohne Aufwand moeglich ist, nimm die echte. Entscheidend: **welcher Zweig genommen wird, darf sich nicht aendern**. Ein Fehlerfaenger, der heute bei einem fremden Fehlerobjekt in den einen Zweig laeuft, muss das danach auch tun (D-03). Wo der gefangene Wert gar nicht gelesen wird, ist die Bindung ersatzlos zu streichen — genau das hat 260921-bi2 an einer Stelle bereits so gemacht.
Schritt B — die Stellen mit Fremdbibliotheken, einzeln beurteilt:
- **`addCronJob(..., job as any)`** in `dkv-scheduler.service.ts:136`, `tender-digest.scheduler.ts:86`, `tender-scheduler.service.ts:135`. **Gemessen: bleibt.** Ohne Zusicherung meldet `tsc` an allen drei Stellen, dass das lokale `job` die Form `{ start(): void }` hat, waehrend `addCronJob` einen vollstaendigen `CronJob` verlangt. Ursache ist der `require()`-Umweg aus 07-04 (pnpm-Isolation, `cron` ist eine mittelbare Abhaengigkeit). Das aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — das waere eine Verhaltensaenderung und ist hier verboten (D-03), und eine neue Abhaengigkeit ist ebenfalls verboten (D-04). Trage das Urteil samt dieser Begruendung als kurzen Kommentar an jeder der drei Stellen ein.
- **node-forge in `cert-manager.service.ts`** (11 Stellen: `p7: any` viermal, `cert.publicKey as any`, `cert.siginfo as any`, `sanExt as any`, `(n: any)`, `null as any` zweimal). `@types/node-forge` **ist** installiert. Pruefe Stelle fuer Stelle, ob der mitgelieferte Typ passt. Wo er passt: typisieren. Wo die mitgelieferten Typen die Bibliothek nachweislich falsch beschreiben — die beiden `null as any` tragen bereits den Vermerk, dass node-forge 1.4.0 einen fehlenden Schluessel akzeptiert, die Typen das aber ausschliessen — ist `bleibt` das ehrliche Urteil. Erzwinge dort nichts: eine Umdeutung ueber zwei Stufen waere schlimmer als das `any`, weil sie dieselbe Luecke verdeckt und zusaetzlich so aussieht, als sei sie geprueft (D-02).
- **httpntlm** (`exchange-inbox.provider.ts:3` und `:241`, `exchange.provider.ts:6`) und **`authProvider`-Rueckrufe** (`exchange.provider.ts:157`, `:313`). Die Handschnittstelle fuer `post` existiert schon; sie kann enger werden, weil der Code genau weiss, welche Optionen er uebergibt und welche Antwortfelder er liest. Beschreibe nur diese. Der Fehlerparameter eines Rueckrufs ist ehrlich `Error | null`.
- **imap** (`stream as any`, `node as any` zweimal, `} as any`) und **`nodemailer.createTransport(resolved.options as any)`**: einzeln pruefen. Wo eine Eingrenzung reicht, eingrenzen; sonst Urteil `bleibt` mit Begruendung.
- **`(response as any).cookie(...)`** in `auth.service.ts:384`: `Response` aus `express` ist in dieser Datei bereits importiert und wird an der Schwesterstelle `auth.service.ts:181` ohne Zusicherung benutzt. Pruefe, ob die Zusicherung schlicht ueberfluessig ist.
- **`data as any`** in `calendar.service.ts:206` und `:255`: Prisma-JSON-Eingaben. `Prisma.InputJsonValue` ist der vorgesehene Typ; pruefe, ob er traegt.
- **`let created: any` / `const updateData: any`** in `user.service.ts:110` und `:161`: Prismas erzeugte Typen decken beides ab.
- **`apps/web/src/test/setup.ts:8`** (`expect.extend(matchers as any)`): der einzige Befund ausserhalb von apps/api. Die Datei heisst `setup.ts` und faellt deshalb **nicht** unter die Testdatei-Ausnahme in `biome.json` — diese Ausnahme wird nicht erweitert (D-04). Beurteile die Stelle: passen die jest-dom-Matcher-Typen auf Vitests `expect.extend`, oder ist das ein bekannter Versatz zwischen den beiden Typwelten? Urteil eintragen, so oder so.
Schritt C — das Urteilsregister. Fuehre im SUMMARY eine Tabelle mit **jeder** Stelle, die stehen bleibt: Datei, Zeile, Form, Begruendung. Das ist der Zweck der ganzen Aufgabe — ein spaeterer Durchlauf soll diese Stellen nicht noch einmal aufreissen, sondern nachlesen koennen, warum sie so sind. Ergaenze zusaetzlich je eine kurze Begruendungszeile direkt an der Codestelle, damit die Auskunft auch dort steht, wo jemand sie zuerst sucht. Diese Zeilen sind normale erklaerende Kommentare; sie duerfen **keinen** Marker enthalten, der die Pruefung stilllegt — die Zaehlwerte in `<verify>` fangen das ab, und der Befund soll ja sichtbar bleiben (D-01, D-05).
Halte ausserdem im SUMMARY fest: Ausgangszahl 288, Endzahl, und die Aufteilung der 288 auf die drei Urteile.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; a=sum(1 for x in d if x.get('category')=='lint/suspicious/noExplicitAny'); n=sum(1 for x in d if x.get('category')=='lint/style/noNonNullAssertion'); e=sum(1 for x in d if x.get('severity')=='error'); print('any',a,'nonnull',n,'error',e); sys.exit(0 if a<=45 and n<=56 and e==0 else 1)"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src apps/web/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; [print(x['location']['path']+':'+str(x['location']['start']['line'])) for x in d if x.get('category')=='lint/suspicious/noExplicitAny']" | sort > /tmp/m34-rest.txt; wc -l < /tmp/m34-rest.txt; echo "--- jede dieser Zeilen muss im Urteilsregister des SUMMARY stehen ---"; cat /tmp/m34-rest.txt</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -rho 'ts-expect-error' apps/api/src apps/web/src --include='*.ts' --include='*.tsx' | wc -l)" = 0 && test "$(grep -rho 'ts-ignore' apps/api/src apps/web/src --include='*.ts' --include='*.tsx' | wc -l)" = 0 && test "$(grep -rho 'biome-ignore' apps/api/src --include='*.ts' | wc -l)" = 1 && test "$(grep -rho 'as unknown as' apps/api/src --include='*.ts' | wc -l)" -le 33 && echo "keine stillgelegten Stellen"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && git diff --stat HEAD -- biome.json > /tmp/m34-biome.txt || exit 1; cat /tmp/m34-biome.txt; test ! -s /tmp/m34-biome.txt && echo "biome.json unberuehrt"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && git diff --stat HEAD -- package.json apps/api/package.json apps/web/package.json pnpm-lock.yaml > /tmp/m34-deps.txt || exit 1; cat /tmp/m34-deps.txt; test ! -s /tmp/m34-deps.txt && echo "keine neuen Abhaengigkeiten, keine Versionsspruenge"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | grep -q '4 successful, 4 total' && pnpm lint 2>&1 | grep -q '5 successful, 5 total' && echo "type-check 4/4, lint 5/5"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api run test 2>&1 | tee /tmp/m34-api.log | tail -5 && grep -q 'Test Files 72 passed (72)' /tmp/m34-api.log && grep -q 'Tests 1143 passed (1143)' /tmp/m34-api.log && echo "api 72/1143 gruen"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/web run test 2>&1 | tee /tmp/m34-web.log | tail -5 && grep -q 'Test Files 73 passed (73)' /tmp/m34-web.log && grep -q 'Tests 531 passed (531)' /tmp/m34-web.log && echo "web 73/531 gruen"</automated>
</verify>
<done>Die Zaehlung in `apps/api` liegt bei hoechstens 45. Jede verbleibende Stelle aus der Ausgabe der zweiten Pruefung steht mit Datei, Zeile und Begruendung im Urteilsregister des SUMMARY und traegt eine Begruendungszeile im Code. `biome.json`, die `package.json`-Dateien und `pnpm-lock.yaml` sind unveraendert. `noNonNullAssertion` hoechstens 56, `as unknown as` hoechstens 33, keine Unterdrueckungsmarker ueber den einen Bestandsmarker hinaus, 0 `error`-Befunde. `pnpm type-check` 4/4, `pnpm lint` 5/5, beide Suiten gruen mit unveraenderten Zahlen. Das SUMMARY nennt Ausgangszahl 288, Endzahl und die Aufteilung auf die drei Urteile.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> API (JWT-Cookie) | Das Sitzungstoken traegt Identitaet, Rolle und Mandant. `JwtStrategy.validate()` ist die Stelle, an der daraus ein Objekt wird, das jede spaetere Berechtigungsentscheidung traegt. |
| API -> PostgreSQL (RLS) | `forTenant()`/`forSystem()` setzen die Sitzungsvariablen, an denen die Zeilenregeln haengen. Wer hier die Bindung verliert, sieht fremde Mandanten oder gar nichts. |
| Browser -> API (multipart) | Hochgeladene Dateien werden als Zertifikate, CSV und Bilder weiterverarbeitet. |
| Aufrufer-Objekt -> TenantGuard/RolesGuard | Die Typen der Felder entscheiden mit, ob eine bestehende Pruefung als lebendig oder als tot gelesen wird. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-M34-01 | Elevation of Privilege | `apps/api/src/auth/types/auth-user.ts` -> `tenant.guard.ts` | high | mitigate | `tenantId` wird nicht nach Bequemlichkeit getypt. Aufgabe 2 verlangt die Herleitung aus drei Belegen (Spalte `User.tenantId` in `schema.prisma`, Bestandstyp `SessionUser`, SUPER_ADMIN-Zweig in `TenantGuard`) und einen Kommentar am Typ, der festhaelt, dass der Schutzzweig in `TenantGuard` nicht wegtypisiert werden darf. `<verify>` erzwingt zusaetzlich, dass `tenant.guard.ts` in dieser Aufgabe unveraendert bleibt (`git diff --stat` leer). |
| T-M34-02 | Elevation of Privilege | `role`-Feld, alle Rollenvergleiche | medium | mitigate | `role` als `Role`-Aufzaehlung macht jeden Vergleich gegen eine Zeichenkette ausserhalb der Aufzaehlung zum Compilerfehler. Jeder solche Fehler ist nach D-03 ein zu meldender Befund und darf nicht per Zusicherung beruhigt werden; die Zaehlwerte fuer `as unknown as` und Ausrufezeichen-Behauptungen in `<verify>` fangen den Umweg ab. |
| T-M34-03 | Tampering | `forTenant()`/`forSystem()`, 105 Aufrufstellen | high | mitigate | Die Zuweisungsform `const X = forTenant(` bleibt woertlich erhalten — nur die nachgestellte Zusicherung faellt. Der Erkenner `rls-access-inventory.spec.ts` und die exakten Zahlen in `FORSYSTEM_ALLOWED_CALL_SITES` sind die Kontrolle; Aufgabe 1 laesst diese Spec zusaetzlich einzeln laufen. Kein Aufruf wird zusammengefasst, verschoben oder hinzugefuegt. |
| T-M34-04 | Information Disclosure | `UploadedFileLike`, `cert-manager`/`user`/`bug-reports`/`dkv`-Uploads | medium | mitigate | Die Schnittstelle beschreibt ausschliesslich gelesene Felder und ist an der Konfiguration belegt (kein `storage`-Argument -> memoryStorage -> `buffer` ist ein Buffer). Sie ersetzt keine Pruefung: Groessengrenzen bleiben in den Interceptor-Optionen, die PNG-Signaturpruefung in `bug-reports.service.ts` bleibt, und D-03 verbietet jede Verhaltensaenderung an diesen Wegen. |
| T-M34-05 | Repudiation | Urteilsregister | low | mitigate | Ohne Register waere nach diesem Lauf nicht nachvollziehbar, welche Stelle geprueft und bewusst gelassen wurde und welche nur uebersehen wurde. Aufgabe 3 erzeugt das Register aus der maschinellen Restliste, nicht aus dem Gedaechtnis; die zweite Pruefung in `<verify>` druckt genau diese Liste aus. |
| T-M34-06 | Denial of Service | Fehlerfaenger, Umstellung auf `unknown` | medium | mitigate | Eine falsche Eingrenzung koennte einen Fehler kuenftig in einen anderen Zweig laufen lassen und damit einen Hintergrundlauf abbrechen, der heute weiterlaeuft. Aufgabe 3 schreibt ausdruecklich fest, dass die Zweigwahl unveraendert bleiben muss; die 1143 Tests in `apps/api` decken die Fehlerwege der betroffenen Dienste ab und muessen gruen bleiben. |
| T-M34-SC | Tampering | Lieferkette | low | accept | Dieser Plan installiert nichts. D-04 verbietet neue Abhaengigkeiten und Versionsspruenge; `<verify>` in Aufgabe 3 erzwingt, dass `package.json` und `pnpm-lock.yaml` unveraendert bleiben. Damit entsteht keine neue Lieferkettenflaeche, und das Paket-Echtheitstor ist nicht anwendbar. |
</threat_model>
<verification>
Nach jeder einzelnen Aufgabe, nicht nur am Ende (D-05, D-06):
1. `pnpm type-check` meldet `4 successful, 4 total`.
2. `pnpm lint` meldet `5 successful, 5 total`, Rueckgabewert 0.
3. Biome-JSON ueber `apps/api/src`: `severity == error` ist 0; `lint/style/noNonNullAssertion` hoechstens 56; `lint/suspicious/noExplicitAny` unter der Schranke der jeweiligen Aufgabe (155 / 75 / 45).
4. `grep`-Zaehlungen ueber `apps/api/src`: `ts-expect-error` 0, `ts-ignore` 0, Lint-Unterdrueckungsmarker 1, `as unknown as` hoechstens 33.
5. `apps/api` Vitest: 72 Dateien, 1143 Tests, alle gruen. `apps/web` Vitest: 73 Dateien, 531 Tests, alle gruen.
6. `biome.json`, `package.json` (alle drei), `pnpm-lock.yaml` unveraendert.
Gezaehlt wird ausschliesslich ueber das Feld `category` im JSON-Bericht von Biome, nie durch Textsuche nach `any` im Quelltext — eine Textsuche zaehlt Kommentare mit und waere damit selbst verfaelschend.
Vor dem Festschreiben: die erzeugten Dateien und Commit-Texte auf rohe Steuerzeichen pruefen. Das Schreibwerkzeug wandelt Folgen der Form Backslash-u-vier-Ziffern in das tatsaechliche Zeichen um; das ist heute fuenfmal passiert und hat Commits scheitern lassen. Solche Folgen, falls ueberhaupt noetig, ueber `python3` mit `chr(92)` erzeugen und die Rohbytes danach kontrollieren.
</verification>
<success_criteria>
- Die Zahl der `any`-Befunde in `apps/api` ist von 288 auf hoechstens 45 gefallen, erwartet auf 20 bis 40.
- Jede verbleibende Stelle hat ein Urteil mit Begruendung, im SUMMARY und im Code.
- Kein Befund wurde durch Zusicherung, Ausrufezeichen-Behauptung, Unterdrueckungskommentar oder eine unbelegte Handschnittstelle stillgelegt (D-02) — nachgewiesen ueber die Zaehlwerte, nicht behauptet.
- Kein Verhalten der API hat sich geaendert (D-03); jede aufgedeckte falsche Annahme steht als Befund im SUMMARY, mindestens die beiden schon gemessenen (`tender-matching.service.ts:159`, `dashboard.controller.ts:74`).
- Der gemeinsame Aufrufer-Typ existiert, ist an den Signierstellen belegt, und `tenant.guard.ts` ist unveraendert.
- `pnpm type-check` 4/4 und `pnpm lint` 5/5 nach jeder Aufgabe, nicht nur am Ende (D-05).
- Beide Testsuiten gruen mit unveraenderten Zahlen (D-06).
- Drei Commits, einer je Aufgabe, jeder fuer sich gruen.
</success_criteria>
<output>
Create `.planning/quick/260921-m34-288-any-im-backend-einzeln-beurteilen-un/260921-m34-SUMMARY.md` when done.
Das SUMMARY traegt zwingend:
- Ausgangszahl 288, Endzahl, Aufteilung der 288 auf die drei Urteile aus D-01.
- Das Urteilsregister aller verbleibenden Stellen (Datei, Zeile, Form, Begruendung).
- Die Liste der aufgedeckten falschen Annahmen im Bestandscode (D-03) — jede mit Datei, Zeile und dem, was der Code annimmt. Diese Liste ist ein Ergebnis, kein Anhang: sie ist der eigentliche Fund einer Aufgabe, die ohne bekannten Fehler begonnen hat.
- Die Begruendung der `tenantId`-Entscheidung im gemeinsamen Aufrufer-Typ.
</output>
@@ -0,0 +1,414 @@
---
phase: quick-260921-m34
plan: 01
subsystem: apps/api (Typdisziplin)
tags: [typescript, any, refactor, mandantentrennung, prisma, node-forge, imapflow]
status: complete
requires: []
provides:
- "apps/api/src/auth/types/auth-user.ts (AuthUser, AuthenticatedRequest, JwtPayload, UploadedFileLike)"
- "apps/api/src/prisma/prisma-error.ts (prismaErrorCode, prismaErrorTarget)"
affects:
- "apps/api/src (48 Dateien)"
- "apps/web/src/test/setup.ts"
tech-stack:
added: []
patterns:
- "catch (e: unknown) plus Form-Eingrenzung statt catch (e: any)"
- "Mitgelieferte Bibliothekstypen vor eigenen Handschnittstellen"
- "Ehrliches any mit geschriebener Begruendung statt erzwungener Zusicherung"
key-files:
created:
- apps/api/src/auth/types/auth-user.ts
- apps/api/src/prisma/prisma-error.ts
modified:
- apps/api/src/prisma/prisma-tenant.extension.ts
- apps/api/src/cert-manager/cert-manager.service.ts
- apps/api/src/inbox/imap.provider.ts
- apps/api/src/inbox/exchange-inbox.provider.ts
- apps/api/src/calendar/providers/exchange.provider.ts
- apps/api/src/user/user.service.ts
- apps/api/src/groups/groups.service.ts
- apps/api/src/ldap/ldap.service.ts
- apps/api/src/mail/mail.service.ts
decisions:
- "AuthUser.tenantId ist string, aus drei Belegen hergeleitet; der SUPER_ADMIN-Zweig in TenantGuard bleibt unangetastet"
- "Fehlereingrenzung per Form-Pruefung statt instanceof PrismaClientKnownRequestError, weil alle Testdoppel angehaengte .code-Felder werfen"
- "15 Befunde bleiben mit Urteil und Begruendung stehen; Null war ausdruecklich nicht das Ziel"
metrics:
duration: "1h 20min (16:09 bis 17:29 Uhr, 21.09.2026)"
completed: 2026-09-21
actuals:
tokens: 151201
tasks: 3
commits: 7
plan_head_before: 8d845e7
---
# Quick-Aufgabe 260921-m34: 288 any im Backend einzeln beurteilen Summary
Die 288 `any`-Befunde in `apps/api` sind auf **15** gefallen, jeder der 288 hat
ein Urteil mit Begruendung, und die Arbeit hat sieben falsche Annahmen im
Bestandscode aufgedeckt, die alle gemeldet und keine still repariert wurden.
## Die Zahl, und was sie bedeutet
| Groesse | Ausgang | Ende |
|---|---|---|
| `lint/suspicious/noExplicitAny` in `apps/api/src` | **288** (48 Dateien) | **15** (7 Dateien) |
| dasselbe in `apps/web/src` | 1 | 0 |
| `lint/style/noNonNullAssertion` in `apps/api/src` | 56 | 56 |
| `as unknown as` in `apps/api/src` | 33 | 33 |
| `ts-expect-error` / `ts-ignore` | 0 / 0 | 0 / 0 |
| Lint-Unterdrueckungsmarker | 1 | 1 |
| Befunde der Schwere `error` | 0 | 0 |
Die vier Zeilen in der Mitte sind die wichtigsten der Tabelle. Sie belegen, dass
die 273 verschwundenen Befunde tatsaechlich getypt und nicht bloss stillgelegt
wurden: haette die Arbeit die bequeme Abkuerzung genommen, waere mindestens einer
dieser Zaehlwerte gestiegen. Keiner ist gestiegen.
### Aufteilung der 288 auf die drei Urteile (D-01)
| Urteil | Anzahl | Was dahintersteckt |
|---|---:|---|
| **typisiert** | 252 | Die Zusicherung war ueberfluessig oder der richtige Typ war ableitbar — aus Prismas Erweiterungstypen, aus den beiden Signierstellen des Tokens, aus `schema.prisma`, oder aus den mitgelieferten Typen einer Fremdbibliothek. |
| **auf `unknown` umgestellt** | 21 | 18 Fehlerfaenger, die beiden `.then((results: unknown[]) => ...)` in `prisma-tenant.extension.ts` und `intercept(): Observable<unknown>`. |
| **bleibt** | 15 | Register unten. Jede Stelle traegt ihre Begruendung ausserdem direkt im Code. |
| | **288** | |
Der Plan hat 20 bis 40 verbleibende Befunde erwartet; es sind 15 geworden. Der
Unterschied kommt nicht daher, dass hier mehr erzwungen wurde, sondern aus drei
Messungen, die guenstiger ausfielen als die Vorschau: `@types/node-forge`
beschreibt PKCS7 besser als angenommen (die vier `p7: any` liessen sich mit dem
mitgelieferten `Captured<...>`-Typ aufloesen), `imapflow` deklariert
`node.parameters` bereits vollstaendig, und `expect.extend(matchers)` in
`apps/web` brauchte seine Zusicherung schlicht nicht mehr. Die Zaehlwerte oben
sind der Beleg, dass dabei nichts gegen eine Behauptung getauscht wurde.
## Urteilsregister: die 15 Stellen, die bleiben
Jede dieser Zeilen steht so auch als Kommentar an der Codestelle. Wer spaeter
hier aufraeumen will, findet die Begruendung dort, wo er zuerst nachsieht.
| # | Datei:Zeile | Form | Begruendung (gemessen) |
|---|---|---|---|
| 1 | `cert-manager/cert-manager.service.ts:296` | `cert.publicKey as any` | `@types/node-forge` kennt nur `PublicKey = rsa.PublicKey \| ed25519.Key` (index.d.ts:232). Der EC-Zweig darunter liest `curve` und `params.curve.q.bitLength()` — Felder, die node-forge zur Laufzeit liefert, die der mitgelieferte Typ aber gar nicht kennt. Eine Umdeutung ueber zwei Stufen wuerde dieselbe Luecke verdecken und zusaetzlich geprueft aussehen. |
| 2 | `cert-manager/cert-manager.service.ts:318` | `(e: any)` | `Certificate.extensions` ist in `@types/node-forge` als `any[]` deklariert (index.d.ts:435). Der mitgelieferte Typ sagt ueber den Inhalt einer Erweiterung nichts aus. |
| 3 | `cert-manager/cert-manager.service.ts:319` | `(sanExt as any)` | wie 2 — `sanExt` stammt aus demselben `any[]`. |
| 4 | `cert-manager/cert-manager.service.ts:319` | `(n: any)` | wie 2. Eine eigene Schnittstelle fuer `altNames` waere unbelegt: der Compiler koennte sie an keiner Stelle gegen etwas pruefen, sie saehe aber geprueft aus (D-02). |
| 5 | `cert-manager/cert-manager.service.ts:589` | `null as any` | node-forge 1.4.0 nimmt hier einen fehlenden Schluessel an und erzeugt ein reines Zertifikatsbuendel; `@types/node-forge` schliesst `null` aus. Die mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle nachweislich falsch. |
| 6 | `cert-manager/cert-manager.service.ts:752` | `null as any` | wie 5, zweite Aufrufstelle. |
| 7 | `dkv/dkv-scheduler.service.ts:144` | `job as any` | Ohne die Zusicherung meldet `tsc`, dass das lokale `job` nur die Form `{ start(): void }` hat, waehrend `addCronJob()` einen vollstaendigen `CronJob` verlangt. Ursache ist der `require()`-Umweg aus 07-04 (pnpm-Isolation, `cron` ist nur mittelbare Abhaengigkeit). Aufloesen hiesse die Beschaffung der Klasse aendern (Verhaltensaenderung, D-03) oder `cron` direkt aufnehmen (neue Abhaengigkeit, D-04). |
| 8 | `tenders/tender-digest.scheduler.ts:94` | `job as any` | wie 7. |
| 9 | `tenders/tender-scheduler.service.ts:143` | `job as any` | wie 7. |
| 10 | `groups/groups.service.ts:373` | `(u: any)` | Gefolge von 12: `tx` ist selbst `any`, und `any.map()` gibt dem Parameter keine kontextuelle Typisierung. Faellt automatisch mit 12. |
| 11 | `groups/groups.service.ts:390` | `(a: any)` | wie 10. |
| 12 | `prisma/prisma-tenant.extension.ts:264` | `fn: (tx: any)` | In Aufgabe 1 gemessen und verworfen: `Prisma.TransactionClient` erzwingt an den vier Aufrufstellen vollstaendige Prisma-Erzeugungstypen und bricht das absichtlich unvollstaendige Testdoppel in `prisma-tenant.extension.spec.ts` (TS2322). Das waere eine Aenderung an einer Teststruktur, kein ehrliches Typisieren. |
| 13 | `prisma/prisma-tenant.extension.ts:266` | `async (tx: any)` | wie 12, dieselbe Funktion. |
| 14 | `inbox/imap.provider.ts:78` | `(node as any).disposition...` | **Befund B-05.** Bleibt absichtlich sichtbar: der Ausdruck liest `.parameters` von einer Zeichenkette und ist zur Laufzeit immer `undefined`. Umbiegen auf `dispositionParameters` waere eine Verhaltensaenderung. |
| 15 | `inbox/imap.provider.ts:402` | `} as any` | **Befund B-06.** Bleibt absichtlich sichtbar: die Zusicherung verdeckt, dass `requireTLS` in imapflow 1.4.3 gar nicht existiert. Die Option zu entfernen waere eine stille Reparatur. |
Die Gruppen dahinter sind klein: sechs Stellen an node-forge, drei an der
Cron-Beschaffung, vier an der Transaktionshilfe, zwei absichtlich stehen
gelassene Befunde an imapflow.
## Die aufgedeckten falschen Annahmen (D-03)
Diese Aufgabe hat ohne bekannten Fehler begonnen. Das hier ist ihr eigentlicher
Ertrag: sieben Stellen, an denen der Bestandscode etwas annimmt, was nicht
stimmt. **Keine davon wurde still repariert** — das Verhalten der API ist
unveraendert.
**B-01 — `tenders/tender-matching.service.ts:159` (Aufgabe 1).**
Die Handannotation `(match: { tender: unknown })` verengte den Wert falsch,
sobald der Prisma-Klient richtig getypt war. Sie existierte nur, um unter dem
`any`-Klienten TS7006 zu vermeiden. Annotation geloescht, damit der hergeleitete
Typ durchkommt; keine Zusicherung an ihrer Stelle.
**B-02 — `dashboard/dashboard.controller.ts:74` (Aufgabe 2b).**
Der Handler las `req.user?.role` nach `extractContext()` und gab die Rolle an
`getWidgets(role: Role)` weiter, das sie zwingend verlangt. Die Annahme "hier
gibt es immer einen Aufrufer" stimmt — die Pruefung "No user context" erzwingt
sie —, aber sie stand in einer anderen Methode, wo der Compiler sie nicht sehen
konnte. `extractContext()` gibt die Rolle jetzt mit zurueck: keine neue Pruefung,
kein erfundener Wert, gleiche Reihenfolge, gleiche Meldungen.
**B-03 — `tenders/tenders.controller.ts:142` (Aufgabe 2b).**
`resolveRequestingTenantId()` erklaerte `string | undefined`, liest aber
`req.tenantId`, das `TenantGuard` fuer einen SUPER_ADMIN ohne Mandanten auf
`null` setzt. Die Erklaerung war nie vollstaendig. Auf
`string | null | undefined` erweitert — reine Erklaerung: die Funktion
entscheidet seit jeher ueber den Wahrheitswert und faellt bei `null` wie bei
`undefined` zu (nur global sichtbare Ausschreibungen).
**B-04 — Falle im Mandantentrennungs-Erkenner (Aufgabe 3b).**
Die naheliegende Prisma-Schreibweise `Prisma.UserGetPayload<{ select: typeof X }>`
laesst `rls-access-inventory.spec.ts` rot werden: der Erkenner zaehlt **jede**
`select:`-Angabe ausserhalb eines erkannten Modellaufrufs als Verstoss und
unterscheidet Typposition nicht von Aufrufposition. Beim ersten Versuch gemessen.
Der Erkenner ist die Mandantenkontrolle (T-M34-03) und wurde **nicht**
aufgeweicht — stattdessen leitet der Zeilentyp ueber `Pick<User, keyof typeof
PLATFORM_USER_SELECT>` her, was ohne das Wort `select` auskommt. Wer kuenftig
`UserGetPayload` einsetzen will, muss zuerst den Erkenner erweitern, nicht die
Ausnahmeliste.
**B-05 — `inbox/imap.provider.ts:78` (Aufgabe 3c). Sicherheitsnah, offen.**
`(node as any).disposition?.parameters?.filename` liest `.parameters` von einer
**Zeichenkette**: imapflow deklariert `disposition` als `string`
(`imap-flow.d.ts:448`, also "attachment"/"inline"), die zugehoerigen Parameter
liegen in einem eigenen Feld `dispositionParameters` (:450). Der Ausdruck ist zur
Laufzeit **immer** `undefined`, `dispositionFilename` ist stets `''`. Folge:
Anhaenge, die als `application/octet-stream` kommen (typisch fuer Outlook),
werden ueber den Dateinamen aus Content-Disposition **nicht** erkannt — nur ueber
den aus Content-Type. Betrifft den DKV-Rechnungseinzug. Nicht repariert, weil das
Umbiegen erstmals Anhaenge einsammeln wuerde, die heute uebersprungen werden —
eine Verhaltensaenderung, die ein Mensch entscheiden muss.
**B-06 — `inbox/imap.provider.ts:402` (Aufgabe 3c). Sicherheitsnah, offen.**
`requireTLS: config.encryption === 'starttls'` wird an `new ImapFlow(...)`
uebergeben, aber `requireTLS` kommt in imapflow 1.4.3 **nirgends** vor — weder in
`ImapFlowOptions` (`lib/imap-flow.d.ts`) noch im Laufzeitcode
(`lib/imap-flow.js`), beides durchsucht. Die Option wird still verworfen; ein
STARTTLS-Zwang entsteht durch sie nicht. Genau das `} as any` hat es verdeckt.
Die Einstellung "STARTTLS" in der Postfach-Konfiguration bewirkt damit nicht das,
was ihr Name verspricht. Nicht repariert (D-03), Zusicherung bleibt sichtbar
stehen, damit der Befund in der Zaehlung nicht verschwindet.
**B-07 — httpntlm-Antwortrumpf (Aufgabe 3c). Ohne Auswirkung, aber falsch.**
Beide Exchange-Wege riefen `res.body?.toString('utf-8')` auf und nahmen damit
einen Buffer an. Gemessen: httpntlm reicht an httpreq durch, und httpreq gibt den
Rumpf als **Zeichenkette** zurueck, solange die Option `binary` nicht gesetzt ist
(`httpreq@1.1.1/lib/httpreq.js:391`) — keiner der beiden Aufrufer setzt sie. Das
ging bisher nur gut, weil `String.prototype.toString()` sein Argument ignoriert.
Die Testdoppel reichen umgekehrt wirklich einen Buffer herein, beide Formen kommen
also vor. `NtlmResponse.body` nennt jetzt beide; die Fallunterscheidung liefert
fuer jede exakt dasselbe Ergebnis wie zuvor.
## Die `tenantId`-Entscheidung im gemeinsamen Aufrufer-Typ
`AuthUser.tenantId` ist **`string`**, nicht `string | undefined`. Die Messung
allein haette in die falsche Richtung gedraengt (`string | undefined` erzeugte
acht Fehler im Produktivcode, `string` keinen); entschieden wurde auf drei
Belegen:
1. `apps/api/prisma/schema.prisma` deklariert `User.tenantId String` **ohne** `?`.
Die Spalte ist Pflicht, und beide Signierstellen schreiben genau diesen
Spaltenwert — seit dem ersten Commit des Anmeldedienstes (6190f3d) gibt es
keine Token-Erzeugung ohne diesen Anspruch.
2. Der Bestand beschreibt dasselbe Objekt in `SessionUser`
(`bug-reports.service.ts`) bereits als `tenantId: string`. `SessionUser` ist
jetzt ein `Pick<>` von `AuthUser`, damit es keine zweite, abweichende
Beschreibung desselben Objekts gibt.
3. `TenantGuard` haelt fuer SUPER_ADMIN einen Zweig ohne Mandanten vor und setzt
dort `req.tenantId = null`.
Beleg 3 spricht **nicht** gegen `string`, und genau daran haengt die
Sicherheitsfrage: der Zweig in `TenantGuard` ist eine Tiefenverteidigung gegen
ein Token ohne diesen Anspruch, und er liest `AuthUser` gar nicht — der Waechter
holt sein Anfrageobjekt ungetypt. Dieser Typ kann den Zweig also nicht zu totem
Code machen. Dass es den Zweig gibt, steht ausserdem weiterhin im Typsystem, nur
an der richtigen Stelle: `AuthenticatedRequest.tenantId` ist
`string | null | undefined`.
Am Typ steht dazu eine ausdrueckliche Warnung fuer spaetere Leser, dass der
SUPER_ADMIN-Zweig ein Schutzzweig ist und nicht entfernt oder wegtypisiert werden
darf (T-M34-01). `tenant.guard.ts` wurde in diesem ganzen Lauf **nicht
angefasst** — in Aufgabe 2 per `git diff --stat` nachgewiesen.
## Was die Arbeit sonst noch geaendert hat
**Zwei neue Dateien, beide klein und begruendet.**
`apps/api/src/auth/types/auth-user.ts` traegt den gemeinsamen Aufrufer-Typ; jedes
Feld hat seine Herkunft als Kommentar. `apps/api/src/prisma/prisma-error.ts`
traegt `prismaErrorCode()` und `prismaErrorTarget()`.
**Warum die Fehlereingrenzung Form-Pruefungen macht und kein `instanceof`.**
Der naheliegende Weg waere
`err instanceof Prisma.PrismaClientKnownRequestError` gewesen. Gemessen: samtliche
Testdoppel in `apps/api` werfen `new Error(...)` mit angehaengtem `.code`
(groups, user, ldap, tenders, module-grants, admin-seed) und
`dashboard.service.spec.ts:451` ein reines `{ code: 'P2002' }`. Ein
`instanceof`-Test haette all diese Werte in den jeweils **anderen** Zweig
geschickt — eine Verhaltensaenderung, und nach T-M34-06 genau die Art von
Aenderung, die einen Hintergrundlauf kuenftig abbrechen laesst, der heute
weiterlaeuft. Die Helfer bilden `err?.code` und `err?.meta?.target` deshalb eins
zu eins ab, nur ohne `any`.
**Nebengewinne ohne neue Zusicherungen.** In `cert-manager.service.ts` sind 25
Zusicherungen der Form `file.buffer as Buffer` weggefallen, weil `file` kein
`any` mehr ist. In `dkv` und `settings` fielen `req.tenantId as string |
undefined` weg. `as unknown as` ist trotzdem bei 33 geblieben — dieselbe Zahl wie
zu Beginn.
## Abweichungen vom Plan
**1. [Regel 3 — blockierend] Neue Datei `prisma/prisma-error.ts` statt 18
Eingrenzungen von Hand.** Der Plan nennt fuer Aufgabe 3 Schritt A keine neue
Datei. 18 Fehlerfaenger einzeln mit einer vierzeiligen Form-Pruefung zu versehen
haette dieselbe Logik achtzehnmal wiederholt und die Begruendung, warum kein
`instanceof` verwendet wird, achtzehnmal daneben. Die Datei liegt in `prisma/`,
weil alle Aufrufer Prisma-Fehlercodes pruefen. Keine neue Abhaengigkeit.
Commit: `32591b6`.
**2. [Regel 1 — Fehler] Erste Fassung von `user.service.ts` machte die
Mandanten-Spec rot.** `Prisma.UserGetPayload<{ select: typeof X }>` hat
`rls-access-inventory.spec.ts` gebrochen (siehe B-04). Sofort im selben Schritt
auf `Pick<User, ...>` umgestellt, der Erkenner blieb unangetastet, Spec wieder
30/30. Commit: `3892c5f`.
**3. [Messung weicht vom Plan ab] `calendar.service.ts:206/255` sind keine
Prisma-JSON-Eingaben.** Der Plan vermutete `Prisma.InputJsonValue`. Gemessen:
es sind dynamisch gebaute Erzeugungs- und Aenderungseingaben fuer
`CalendarSource`. Richtig getypt mit
`Prisma.CalendarSourceUncheckedCreateInput` / `...UncheckedUpdateInput`.
Commit: `3892c5f`.
**4. [Messung weicht vom Plan ab] node-forge war besser beschrieben als
erwartet.** Der Plan rechnete damit, dass ein Teil der elf Stellen bleibt.
Gemessen: die vier `p7: any` liessen sich mit dem mitgelieferten
`Captured<PkcsEnvelopedData | PkcsSignedData>` aufloesen, `cert.siginfo` war
ohnehin getypt. Sechs bleiben, fuenf wurden getypt. Commit: `d8fb9ae`.
## Pruefungen — die echten Ausgaben
### `noExplicitAny`, `noNonNullAssertion`, `error`-Befunde
```
$ npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "..."
any 15 nonnull 56 error 0
Rueckgabewert: 0
```
Schranke der Aufgabe: `any <= 45`, `nonnull <= 56`, `error == 0`. Alle drei
eingehalten.
### Vollstaendige Restliste (Pruefung 2 der Aufgabe)
```
apps/api/src/cert-manager/cert-manager.service.ts:296:40 | const pubKey = cert.publicKey as any;
apps/api/src/cert-manager/cert-manager.service.ts:318:48 | const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName');
apps/api/src/cert-manager/cert-manager.service.ts:319:41 | const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
apps/api/src/cert-manager/cert-manager.service.ts:319:71 | const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
apps/api/src/cert-manager/cert-manager.service.ts:589:19 | null as any, // cert-only PFX - null key accepted by node-forge 1.4.0
apps/api/src/cert-manager/cert-manager.service.ts:752:19 | null as any, // cert-only PFX - null key accepted by node-forge 1.4.0
apps/api/src/dkv/dkv-scheduler.service.ts:144:55 | this.schedulerRegistry.addCronJob(jobName, job as any);
apps/api/src/groups/groups.service.ts:373:33 | data: users.map((u: any) => ({
apps/api/src/groups/groups.service.ts:390:39 | data: activations.map((a: any) => ({
apps/api/src/inbox/imap.provider.ts:78:15 | ((node as any).disposition?.parameters?.filename as string | undefined)?.toLowerCase() ?? '';
apps/api/src/inbox/imap.provider.ts:402:10 | } as any);
apps/api/src/prisma/prisma-tenant.extension.ts:264:12 | fn: (tx: any) => Promise<T>,
apps/api/src/prisma/prisma-tenant.extension.ts:266:41 | return prisma.$transaction(async (tx: any) => {
apps/api/src/tenders/tender-digest.scheduler.ts:94:63 | this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
apps/api/src/tenders/tender-scheduler.service.ts:143:61 | this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
TOTAL 15
```
Jede dieser 15 Zeilen steht im Urteilsregister oben. `apps/web/src` liefert
keine Zeile mehr.
### Unterdrueckungsmarker
```
### 3. Unterdrueckungsmarker
keine stillgelegten Stellen
```
Das heisst im Einzelnen: `ts-expect-error` 0, `ts-ignore` 0, `biome-ignore` 1
(der eine Bestandsmarker), `as unknown as` 33.
### `biome.json` und Abhaengigkeiten
```
### 4. biome.json
biome.json unberuehrt
### 5. Abhaengigkeiten
keine neuen Abhaengigkeiten, keine Versionsspruenge
```
Geprueft ueber `git diff --stat HEAD` gegen `biome.json`, alle drei
`package.json` und `pnpm-lock.yaml` — jede Ausgabe leer.
### Typlauf und Lint
```
### 6. type-check / lint
type-check 4/4, lint 5/5
```
### Testsuiten
```
$ pnpm --dir apps/api run test
Test Files 72 passed (72)
Tests 1143 passed (1143)
Duration 8.05s
$ pnpm --dir apps/web run test
Test Files 73 passed (73)
Tests 531 passed (531)
Duration 23.48s
```
Unveraenderte Zahlen gegenueber dem Ausgang (72/1143 und 73/531).
### Mandantentrennungs-Erkenner, ausdruecklich einzeln
```
$ pnpm --dir apps/api exec vitest run src/prisma/rls-access-inventory.spec.ts
Test Files 1 passed (1)
Tests 30 passed (30)
Duration 557ms
```
Das ist die Kontrolle aus T-M34-03. Sie war in Aufgabe 3b einmal rot (B-04) und
wurde nicht durch Aufweichen, sondern durch eine andere Typschreibweise wieder
gruen.
## Commits
| Commit | Aufgabe | `any` in `apps/api` danach |
|---|---|---:|
| `b188946` | 1 — Mandantenbindung entzaubert, 105 unnoetige Zusicherungen | 149 |
| `f2fc39f` | 2a — gemeinsamer Aufrufer-Typ, aus den Signierstellen abgeleitet | 137 |
| `7c9d7c1` | 2b — getypte Anfrage in elf Controllern, zwei Befunde gemeldet | 66 |
| `52668c2` | 2c — Hochladewege getypt, 25 Zusicherungen fallen mit | 56 |
| `32591b6` | 3a — 18 Fehlerfaenger auf `unknown`, mit echter Eingrenzung | 38 |
| `3892c5f` | 3b — Prisma-nahe Formen getypt, Erkenner-Falle gemeldet | 31 |
| `d8fb9ae` | 3c — Randschicht beurteilt, drei Befunde gemeldet | **15** |
Jeder Commit war fuer sich gruen: nach jedem lief `pnpm type-check` 4/4,
`pnpm lint` 5/5 mit 0 Befunden der Schwere `error`, und beide Testsuiten mit
unveraenderten Zahlen (D-05, D-06).
## Offene Punkte fuer den Menschen
Zwei der sieben Befunde brauchen eine Entscheidung, die diese Aufgabe nicht
treffen durfte, weil beide das Verhalten aendern wuerden:
- **B-05** (`imap.provider.ts:78`): sollen Anhaenge mit
`application/octet-stream` kuenftig auch ueber den Dateinamen aus
Content-Disposition erkannt werden? Heute werden sie es nicht.
- **B-06** (`imap.provider.ts:402`): soll die Postfach-Einstellung "STARTTLS"
tatsaechlich einen Zwang bewirken? Heute wird die Option von imapflow
verworfen.
Beide sind im Code markiert und stehen in der Zaehlung, verschwinden also nicht
aus dem Blick.
## Self-Check
**BESTANDEN.**
Geprueft, nicht behauptet:
- Beide neu angelegten Dateien existieren auf der Platte
(`apps/api/src/auth/types/auth-user.ts`,
`apps/api/src/prisma/prisma-error.ts`).
- Alle sieben genannten Commits sind in `git log` vorhanden.
- `apps/api/src/tenant/tenant.guard.ts` ist ueber den gesamten Lauf
(`git diff --stat 8d845e7..HEAD`) unveraendert — keine einzige Zeile.
- Die 15 Zeilen der Restliste stammen aus der maschinellen Biome-Ausgabe, nicht
aus dem Gedaechtnis, und stimmen eins zu eins mit dem Urteilsregister ueberein.
- Die Aufteilung 252 + 21 + 15 ergibt 288.
+8 -8
View File
@@ -36,7 +36,7 @@ describe('AuthController.me', () => {
authService.getMe.mockResolvedValue({ id: 'u1' });
const controller = new AuthController(authService, makeFakeUserService());
await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' });
await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
});
@@ -46,7 +46,7 @@ describe('AuthController.me', () => {
authService.getMe.mockResolvedValue({ id: 'u1' });
const controller = new AuthController(authService, makeFakeUserService());
await controller.me({ id: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN });
await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false });
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
});
@@ -56,7 +56,7 @@ describe('AuthController.me', () => {
authService.getMe.mockResolvedValue(null);
const controller = new AuthController(authService, makeFakeUserService());
const result = await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' });
const result = await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
expect(result).toBeNull();
});
@@ -69,7 +69,7 @@ describe('AuthController.changePassword', () => {
const res = {} as any;
const result = await controller.changePassword(
{ id: 'u1', tenantId: 't1', role: 'USER' },
{ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false },
{ currentPassword: 'old', newPassword: 'new' } as any,
res,
);
@@ -88,7 +88,7 @@ describe('AuthController.adminResetPassword', () => {
const result = await controller.adminResetPassword(
'target',
{ newPassword: 'new-password' } as any,
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN },
{ id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
);
expect(authService.adminResetPassword).toHaveBeenCalledWith(
@@ -109,7 +109,7 @@ describe('AuthController.adminResetPassword', () => {
await controller.adminResetPassword(
'target',
{ newPassword: 'new-password', mustChangePassword: false } as any,
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN },
{ id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
);
expect(authService.adminResetPassword).toHaveBeenCalledWith(
@@ -134,7 +134,7 @@ describe('AuthController.adminResetPassword', () => {
await controller.adminResetPassword(
'target',
{ newPassword: 'new-password' } as any,
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN },
{ id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
);
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledTimes(1);
@@ -158,7 +158,7 @@ describe('AuthController.adminResetPassword', () => {
controller.adminResetPassword(
'unknown',
{ newPassword: 'new-password' } as any,
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN },
{ id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
),
).rejects.toThrow(new BadRequestException('User not found'));
expect(authService.adminResetPassword).not.toHaveBeenCalled();
+7 -6
View File
@@ -12,7 +12,7 @@ import {
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Role } from '@prisma/client';
import { Request, Response } from 'express';
import { Response } from 'express';
import { UserService } from '../user/user.service';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
@@ -22,6 +22,7 @@ import { AdminResetPasswordDto } from './dto/admin-reset-password.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { RequestResetDto, ResetPasswordDto } from './dto/reset-password.dto';
import { RolesGuard } from './guards/roles.guard';
import type { AuthUser, LocalAuthenticatedRequest } from './types/auth-user';
@Controller('auth')
export class AuthController {
@@ -42,7 +43,7 @@ export class AuthController {
* Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode
* sieht wie vor dieser Umstellung.
*/
private async resolveTargetTenantId(currentUser: any, userId: string): Promise<string> {
private async resolveTargetTenantId(currentUser: AuthUser, userId: string): Promise<string> {
if (currentUser.role === Role.SUPER_ADMIN) {
const target = await this.userService.findByIdForPlatformAdmin(userId);
if (!target) {
@@ -62,7 +63,7 @@ export class AuthController {
@Post('login')
@HttpCode(200)
async login(
@Req() req: Request,
@Req() req: LocalAuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
) {
return this.authService.login(req.user, res);
@@ -91,7 +92,7 @@ export class AuthController {
* Befund C).
*/
@Get('me')
async me(@CurrentUser() user: any) {
async me(@CurrentUser() user: AuthUser) {
return this.authService.getMe(user.tenantId, user.id);
}
@@ -130,7 +131,7 @@ export class AuthController {
@Post('change-password')
@HttpCode(200)
async changePassword(
@CurrentUser() user: any,
@CurrentUser() user: AuthUser,
@Body() dto: ChangePasswordDto,
@Res({ passthrough: true }) res: Response,
) {
@@ -163,7 +164,7 @@ export class AuthController {
async adminResetPassword(
@Param('userId') userId: string,
@Body() dto: AdminResetPasswordDto,
@CurrentUser() currentUser: any,
@CurrentUser() currentUser: AuthUser,
) {
const tenantId = await this.resolveTargetTenantId(currentUser, userId);
await this.authService.adminResetPassword(
+21 -12
View File
@@ -16,6 +16,7 @@ import { LdapService } from '../ldap/ldap.service';
import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { JwtPayload, LoginUser } from './types/auth-user';
/**
* Zeilenform der drei auth_lookup_*-Datenbankfunktionen
@@ -29,7 +30,12 @@ interface AuthLookupUserByUsernameRow {
passwordHash: string | null;
ldapDn: string | null;
isActive: boolean;
role: string;
/**
* Die SQL-Funktion deklariert diese Spalte als `role "Role"` (Migration
* 20260909160000, Zeile 64) — `string` war hier eine weitere Fassung
* desselben Wertes, nicht seine Beschreibung (quick-260921-m34).
*/
role: Role;
displayName: string | null;
mustChangePassword: boolean;
}
@@ -104,7 +110,10 @@ export class AuthService {
* T-02-01: Returns null on any failure (never reveals which field is wrong).
* Pitfall 6: Checks isActive to prevent deactivated users from logging in.
*/
async validateUser(username: string, password: string): Promise<any> {
async validateUser(
username: string,
password: string,
): Promise<AuthLookupUserByUsernameRow | null> {
// Usernames are stored lowercase (case-insensitive login).
const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>`
SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()})
@@ -115,7 +124,7 @@ export class AuthService {
return null;
}
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, user.tenantId);
// LDAP users have no local password — authenticate them against the
// directory by binding as their OWN DN with the password they entered.
@@ -167,8 +176,8 @@ export class AuthService {
* D-02: 30-day session.
* T-02-02: httpOnly + secure (prod) + sameSite=lax.
*/
async login(user: any, response: Response) {
const payload = {
async login(user: LoginUser, response: Response) {
const payload: JwtPayload = {
sub: user.id,
username: user.username,
role: user.role,
@@ -233,7 +242,7 @@ export class AuthService {
// Create the reset token record — mandantengebunden, sobald der
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, user.tenantId);
await tenantPrisma.passwordResetToken.create({
data: {
token,
@@ -274,7 +283,7 @@ export class AuthService {
// Mandant ist ab hier bekannt (aus der Funktion mitgeliefert) — beide
// Schreibzugriffe laufen gebunden (WINDOWS #20, Aufgabe 1).
const tenantPrisma = forTenant(this.prisma, resetToken.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, resetToken.tenantId);
// Hash the new password and update user
const passwordHash = await argon2.hash(newPassword);
@@ -308,7 +317,7 @@ export class AuthService {
* Zeile.
*/
async getMe(tenantId: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: userId },
select: {
@@ -353,7 +362,7 @@ export class AuthService {
newPassword: string,
response: Response,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: userId },
});
@@ -373,7 +382,7 @@ export class AuthService {
data: { passwordHash, mustChangePassword: false },
});
const payload = {
const payload: JwtPayload = {
sub: user.id,
username: user.username,
role: user.role,
@@ -381,7 +390,7 @@ export class AuthService {
mustChangePassword: false,
};
const token = this.jwtService.sign(payload);
(response as any).cookie('session', token, {
response.cookie('session', token, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
sameSite: 'lax',
@@ -417,7 +426,7 @@ export class AuthService {
newPassword: string,
mustChangePassword: boolean = true,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: userId },
});
@@ -8,6 +8,7 @@ import {
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import type { AuthenticatedRequest } from '../types/auth-user';
/**
* Global interceptor: forces users with mustChangePassword=true to change
@@ -30,7 +31,7 @@ const ALLOWED_ROUTES = Object.freeze([
{ method: 'GET', path: '/auth/me' },
]);
function normalizePath(request: any): string {
function normalizePath(request: AuthenticatedRequest): string {
const raw = request.route?.path || request.url || '';
const withoutQuery = raw.split('?')[0];
const withoutTrailingSlash = withoutQuery.replace(/\/+$/, '');
@@ -41,7 +42,7 @@ function normalizePath(request: any): string {
export class ForcePasswordChangeInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
// Skip public routes (login, health, reset-password)
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
@@ -51,7 +52,7 @@ export class ForcePasswordChangeInterceptor implements NestInterceptor {
return next.handle();
}
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
// No user on request (shouldn't happen after auth guard, but be defensive)
+2 -1
View File
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { Request } from 'express';
import type { AuthUser, JwtPayload } from '../types/auth-user';
/**
* Custom extractor that reads JWT from the httpOnly "session" cookie.
@@ -24,7 +25,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
async validate(payload: any) {
async validate(payload: JwtPayload): Promise<AuthUser> {
return {
id: payload.sub,
username: payload.username,
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
import type { LoginUser } from '../types/auth-user';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
@@ -9,7 +10,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
super({ usernameField: 'username' });
}
async validate(username: string, password: string): Promise<any> {
async validate(username: string, password: string): Promise<LoginUser> {
const user = await this.authService.validateUser(username, password);
if (!user) {
// T-02-01: Generic error message - never reveal whether username or password is wrong
+154
View File
@@ -0,0 +1,154 @@
import type { Role } from '@prisma/client';
import type { Request } from 'express';
/**
* Gemeinsame Beschreibung des Aufrufers (quick-260921-m34, Aufgabe 2).
*
* Zweck: an diesem Objekt haengt jede Mandanten- und Rollenentscheidung
* der API. Vor dieser Datei war es an rund 70 Stellen `any` — jede
* Berechtigungspruefung lief also ohne Begleitung durch den Compiler.
*
* Herkunft jedes Feldes, nicht gewaehlt sondern abgelesen: die einzige
* Stelle, die dieses Objekt erzeugt, ist `JwtStrategy.validate()`
* (`../strategies/jwt.strategy.ts`, Zeile 27-38). Sie liest die Ansprueche
* eines Tokens, das ausschliesslich an zwei Stellen signiert wird:
* `AuthService.login()` (`../auth.service.ts`, Zeile ~171) und
* `AuthService.changePassword()` (ebenda, Zeile ~376). Was dort nicht
* geschrieben wird, gibt es hier nicht.
*/
export interface AuthUser {
/** `payload.sub` — an beiden Signierstellen `user.id`. */
id: string;
/** An beiden Signierstellen `user.username`; Spalte `User.username` ist Pflicht. */
username: string;
/**
* An beiden Signierstellen der Spaltenwert `User.role`. Die Spalte ist in
* `apps/api/prisma/schema.prisma` als Aufzaehlung `Role` deklariert, die
* SQL-Funktion `auth_lookup_user_by_username` gibt sie als `"Role"` zurueck
* (Migration 20260909160000). Deshalb ist `Role` der ehrliche Typ und nicht
* `string`: ein Vergleich gegen eine Zeichenkette ausserhalb der
* Aufzaehlung ist ein Fehler und soll einer sein (T-M34-02).
*/
role: Role;
/**
* SICHERHEITSRELEVANTE ENTSCHEIDUNG (T-M34-01) — hergeleitet, nicht gewaehlt.
*
* Beleg 1: `apps/api/prisma/schema.prisma` deklariert `User.tenantId String`
* OHNE `?`. Die Spalte ist Pflicht, jede Benutzerzeile hat einen Mandanten.
* Beide Signierstellen schreiben genau diesen Spaltenwert, seit dem ersten
* Commit des Anmeldedienstes (6190f3d) — es gibt keine Token-Generation
* ohne diesen Anspruch.
* Beleg 2: der Bestand beschreibt dasselbe Objekt in `SessionUser`
* (`../../bug-reports/bug-reports.service.ts`) bereits als `tenantId: string`.
* Diese Datei zieht `SessionUser` auf `AuthUser` zurueck; eine zweite,
* abweichende Beschreibung desselben Objekts soll es nicht geben.
* Beleg 3: `TenantGuard` (`../../tenant/tenant.guard.ts`) haelt fuer
* SUPER_ADMIN einen Zweig ohne Mandanten vor und setzt dort
* `req.tenantId = null`.
*
* Beleg 3 spricht NICHT gegen `string`, und das ist der Punkt, an dem hier
* nicht nach Bequemlichkeit entschieden wurde: der Zweig in `TenantGuard`
* ist eine Tiefenverteidigung gegen ein Token OHNE diesen Anspruch, und er
* liest `AuthUser` gar nicht — der Waechter holt sein Anfrageobjekt
* ungetypt. Dieser Typ kann den Zweig also nicht zu totem Code machen.
*
* ACHTUNG fuer spaetere Leser: der SUPER_ADMIN-Zweig in `TenantGuard` ist
* ein Schutzzweig und darf NICHT entfernt oder wegtypisiert werden, auch
* wenn er unter diesem Typ unerreichbar aussieht. Dass es ihn gibt, steht
* unten in `AuthenticatedRequest.tenantId` als `null` weiterhin im Typsystem.
*/
tenantId: string;
/**
* `JwtStrategy.validate()` bildet diesen Wert mit `=== true` — ein aelteres
* Token ohne den Anspruch ergibt `false` (260921-fi3, D-01: keine
* Aussperrwelle). Das Ergebnis ist deshalb immer ein Wahrheitswert.
*/
mustChangePassword: boolean;
}
/**
* Anfrageobjekt nach den beiden globalen Waechtern.
*
* `user` ist ABSICHTLICH wahlfrei: `JwtAuthGuard` laesst oeffentliche Wege
* (`@Public()`: Anmeldung, Gesundheitspruefung, Kennwort-Ruecksetzung) ohne
* Aufrufer durch, und `TenantGuard` beginnt mit genau dieser Pruefung
* (`if (!user) return true`). Die abwehrenden Pruefungen in den Controllern
* ("No user context") bleiben damit lebendiger Code.
*
* `tenantId` setzt `TenantGuard`: eine Zeichenkette, ODER `null` fuer einen
* SUPER_ADMIN ohne Mandantenbezug — und gar nicht auf oeffentlichen Wegen,
* auf denen der Waechter vorzeitig zurueckkehrt. Alle drei Faelle stehen
* hier, weil alle drei vorkommen koennen.
*/
export interface AuthenticatedRequest extends Request {
user?: AuthUser;
tenantId?: string | null;
}
/**
* Anmeldeanfrage nach `AuthGuard('local')`.
*
* Hier ist `user` NICHT wahlfrei: die lokale Passport-Strategie wirft
* `UnauthorizedException`, wenn `validateUser()` nichts liefert — der Rumpf
* der Route laeuft nur mit gesetztem Aufrufer.
*/
export interface LocalAuthenticatedRequest extends Request {
user: LoginUser;
}
/**
* Was `AuthService.login()` aus dem angemeldeten Benutzer liest — nicht mehr.
* Geliefert wird das von `validateUser()` als Zeile der SQL-Funktion
* `auth_lookup_user_by_username`; `displayName` ist der einzige Unterschied
* zu `AuthUser` und der Grund, warum `login()` nicht `AuthUser` nehmen kann:
* der Anzeigename steht in der Anmeldeantwort, aber in keinem Token.
*/
export interface LoginUser {
id: string;
username: string;
role: Role;
tenantId: string;
/** Spalte `User.displayName` ist wahlfrei (`String?` in schema.prisma). */
displayName: string | null;
mustChangePassword: boolean;
}
/**
* Die Ansprueche des Sitzungstokens.
*
* Doppelrolle: dieselbe Form wird an den beiden Signierstellen geschrieben
* und in `JwtStrategy.validate()` gelesen. Deshalb ist
* `mustChangePassword` wahlfrei — geschrieben wird es immer, aber ein vor
* 260921-fi3 ausgestelltes Token traegt es nicht, und die Pruefung
* `=== true` in `validate()` haengt daran. Wer dieses Feld hier zur Pflicht
* macht, laesst jene Pruefung ueberfluessig aussehen.
*/
export interface JwtPayload {
sub: string;
username: string;
role: Role;
tenantId: string;
mustChangePassword?: boolean;
}
/**
* Eine hochgeladene Datei, so weit der Code sie liest.
*
* `Express.Multer.File` gibt es in diesem Baum nicht (`@types/multer` ist
* nicht installiert, gemessen), und Nachinstallieren ist in diesem Lauf
* ausgeschlossen. Diese Schnittstelle ist trotzdem keine Behauptung, sondern
* belegt: KEIN Aufruf von `FileInterceptor`/`FilesInterceptor` in
* `apps/api/src` setzt eine `storage`-Option (nachgezaehlt: sechs Aufrufe,
* alle nur mit `limits`). Damit gilt multers Voreinstellung memoryStorage,
* und damit ist `buffer` ein Buffer und keine Vermutung.
*
* Die Schnittstelle ersetzt keine Pruefung: die Groessengrenzen bleiben in
* den Interceptor-Optionen, die PNG-Signaturpruefung bleibt in
* `bug-reports.service.ts` (T-M34-04).
*/
export interface UploadedFileLike {
buffer: Buffer;
originalname: string;
mimetype: string;
size: number;
}
@@ -1,6 +1,7 @@
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { BugReportsService } from './bug-reports.service';
import { BugReportDto } from './dto/bug-report.dto';
@@ -30,9 +31,9 @@ export class BugReportsController {
FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }),
)
async submit(
@CurrentUser() user: any,
@CurrentUser() user: AuthUser,
@Body() dto: BugReportDto,
@UploadedFile() file?: any,
@UploadedFile() file?: UploadedFileLike,
) {
return this.service.submit(user, dto, file);
}
@@ -4,6 +4,7 @@ import {
ConflictException,
HttpException,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { BugReportsService } from './bug-reports.service';
@@ -37,7 +38,7 @@ const PNG_1x1 = Buffer.from(
'base64',
);
const sessionUser = { id: 'u1', username: 'anna', role: 'USER', tenantId: 't1' };
const sessionUser = { id: 'u1', username: 'anna', role: Role.USER, tenantId: 't1' };
const baseDto = {
page: '/admin/users?tab=x',
+17 -12
View File
@@ -14,6 +14,7 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { SettingsService } from '../settings/settings.service';
import { BugReportDto } from './dto/bug-report.dto';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { describeOrigin } from './origin';
/**
@@ -58,18 +59,22 @@ const WINDOW_MS = 10 * 60 * 1000;
const MAX_PER_WINDOW = 5;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
interface SessionUser {
id: string;
username: string;
role: string;
tenantId: string;
}
/**
* Der Aufrufer, so weit dieser Dienst ihn liest. Seit quick-260921-m34 aus
* `AuthUser` abgeleitet statt danebengestellt: es soll nicht zwei
* Beschreibungen desselben Sitzungsobjekts geben, die auseinanderlaufen
* koennen. Ausgelassen wird `mustChangePassword` — dieser Dienst liest es
* nicht.
*/
type SessionUser = Pick<AuthUser, 'id' | 'username' | 'role' | 'tenantId'>;
interface UploadedPng {
buffer: Buffer;
size: number;
mimetype?: string;
}
/**
* Das Bildschirmfoto, so weit dieser Dienst es liest — abgeleitet aus
* `UploadedFileLike`, damit die Hochladewege eine gemeinsame Beschreibung
* haben. `originalname` fehlt bewusst: der Anhangname ist hier fest
* vorgegeben (T-M97-04), der eingereichte Name wird nie benutzt.
*/
type UploadedPng = Pick<UploadedFileLike, 'buffer' | 'size' | 'mimetype'>;
@Injectable()
export class BugReportsService {
@@ -118,7 +123,7 @@ export class BugReportsService {
// (4) Benutzerzeile: gebunden an den Sitzungs-Mandanten, nie an Rumpfdaten
// (T-M97-06; Zeile in docs/mandantentrennung-zugriffsklassifikation.md).
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, user.tenantId);
const row = await tenantPrisma.user.findUnique({
where: { id: user.id },
select: { username: true, displayName: true, email: true, role: true },
+10 -10
View File
@@ -10,7 +10,7 @@ import {
Query,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CalendarService } from './calendar.service';
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
@@ -45,10 +45,10 @@ import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto';
export class CalendarController {
constructor(private readonly calendarService: CalendarService) {}
private extractContext(req: Request) {
const userId = (req as any).user?.id;
private extractContext(req: AuthenticatedRequest) {
const userId = req.user?.id;
const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId;
req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
@@ -66,7 +66,7 @@ export class CalendarController {
* T-05-09: Uses Prisma select to exclude credentials; returns hasCredentials boolean.
*/
@Get('sources')
async getSources(@Req() req: Request) {
async getSources(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.calendarService.getSources(userId, tenantId);
}
@@ -78,7 +78,7 @@ export class CalendarController {
*/
@Post('sources')
async addSource(
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Body() dto: CreateCalendarSourceDto,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -93,7 +93,7 @@ export class CalendarController {
@Patch('sources/:id')
async updateSource(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Body() dto: UpdateCalendarSourceDto,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -107,7 +107,7 @@ export class CalendarController {
@Delete('sources/:id')
async deleteSource(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
return this.calendarService.deleteSource(id, userId, tenantId);
@@ -132,7 +132,7 @@ export class CalendarController {
@Post('sources/:id/test')
async testSource(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
return this.calendarService.testConnection(id, userId, tenantId);
@@ -144,7 +144,7 @@ export class CalendarController {
*/
@Get('events')
async getEvents(
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Query() query: CalendarEventsQueryDto,
) {
const { userId, tenantId } = this.extractContext(req);
+15 -6
View File
@@ -4,6 +4,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CryptoService } from '../crypto/crypto.service';
@@ -185,7 +186,7 @@ export class CalendarService {
// Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type
if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url);
const data: Record<string, unknown> = {
const data: Prisma.CalendarSourceUncheckedCreateInput = {
userId,
tenantId,
name: dto.name,
@@ -203,7 +204,7 @@ export class CalendarService {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const created = await tenantPrisma.calendarSource.create({
data: data as any,
data,
select: SOURCE_SAFE_SELECT,
});
@@ -234,7 +235,7 @@ export class CalendarService {
await this.validateUrlNotPrivate(dto.url);
}
const data: Record<string, unknown> = {};
const data: Prisma.CalendarSourceUncheckedUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.type !== undefined) data.type = dto.type;
if (dto.url !== undefined) data.url = dto.url;
@@ -252,7 +253,7 @@ export class CalendarService {
const updated = await tenantPrisma.calendarSource.update({
where: { id },
data: data as any,
data,
select: {
...SOURCE_SAFE_SELECT,
encryptedPassword: true,
@@ -338,8 +339,16 @@ export class CalendarService {
try {
// Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type
if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url);
} catch (e: any) {
return { success: false, error: e?.message ?? 'URL not allowed' };
} catch (e: unknown) {
// validateUrlNotPrivate() wirft ausschliesslich ForbiddenException
// (gemessen: jeder Wurf in der Methode ist eine, und der eigene
// catch-Block dort setzt jeden Fremdfehler in eine um). instanceof
// Error trifft deshalb genau dieselben Faelle wie das bisherige
// e?.message; der Ersatzzweig bleibt derselbe (D-03).
return {
success: false,
error: e instanceof Error ? e.message : 'URL not allowed',
};
}
const provider = this.getProvider(dto.type);
@@ -1,9 +1,34 @@
import { Injectable, Logger } from '@nestjs/common';
import type { AuthProviderCallback } from '@microsoft/microsoft-graph-client';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
/** Optionen, die ntlmPost() unten uebergibt — nichts darueber hinaus. */
interface NtlmOptions {
url: string;
username: string;
password: string;
domain: string;
workstation: string;
body: string;
headers: Record<string, string>;
}
/**
* Antwortform von httpntlm.post, beschrieben aus dem, was gelesen wird.
*
* `body` ist `Buffer | string`: httpreq (unter httpntlm) liefert eine
* Zeichenkette, solange `binary` nicht gesetzt ist (gemessen,
* httpreq@1.1.1/lib/httpreq.js:391) — hier wird es nicht gesetzt. Siehe die
* ausfuehrliche Begruendung in inbox/exchange-inbox.provider.ts.
*/
interface NtlmResponse {
statusCode: number;
body?: Buffer | string;
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const httpntlm = require('httpntlm') as {
post: (opts: any, cb: (err: Error | null, res: any) => void) => void;
post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void;
};
const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
@@ -47,15 +72,14 @@ function extractAttr(xml: string, tag: string, attr: string): string {
return attrMatch ? attrMatch[1] : '';
}
function ntlmPost(opts: {
url: string; username: string; password: string;
domain: string; workstation: string; body: string;
headers: Record<string, string>;
}): Promise<{ statusCode: number; body: string }> {
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
httpntlm.post(opts, (err, res) => {
if (err) return reject(err);
resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' });
resolve({
statusCode: res.statusCode,
body: typeof res.body === 'string' ? res.body : (res.body?.toString('utf-8') ?? ''),
});
});
});
}
@@ -154,7 +178,7 @@ export class ExchangeProvider implements CalendarProvider {
);
const client = GraphClient.init({
authProvider: (done: (error: any, token: string) => void) => {
authProvider: (done: AuthProviderCallback) => {
// Use the password as the access token (OAuth bearer token)
// Users configure their OAuth token in the password field for Graph API
done(null, source.password || '');
@@ -310,7 +334,7 @@ export class ExchangeProvider implements CalendarProvider {
);
const client = GraphClient.init({
authProvider: (done: (error: any, token: string) => void) => {
authProvider: (done: AuthProviderCallback) => {
done(null, source.password || '');
},
});
@@ -9,6 +9,7 @@ import {
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { UseModule } from '../module-registry/module.guard';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { CertManagerService } from './cert-manager.service';
/**
@@ -39,7 +40,7 @@ export class CertManagerController {
}),
)
async parseCert(
@UploadedFile() file: any,
@UploadedFile() file: UploadedFileLike | undefined,
@Body('password') password?: string,
@Body('pemText') pemText?: string,
) {
@@ -60,7 +61,7 @@ export class CertManagerController {
}),
)
async splitCerts(
@UploadedFile() file: any,
@UploadedFile() file: UploadedFileLike | undefined,
@Body('password') password?: string,
) {
if (!file) {
@@ -81,7 +82,7 @@ export class CertManagerController {
}),
)
async mergeCerts(
@UploadedFiles() files: any[],
@UploadedFiles() files: UploadedFileLike[],
@Body('outputFormat') outputFormat: string,
@Body('password') password?: string,
) {
@@ -107,7 +108,7 @@ export class CertManagerController {
}),
)
async convertCert(
@UploadedFile() file: any,
@UploadedFile() file: UploadedFileLike | undefined,
@Body('targetFormat') targetFormat: string,
@Body('password') password?: string,
@Body('pemText') pemText?: string,
+103 -45
View File
@@ -1,6 +1,29 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as forge from 'node-forge';
/**
* Was `forge.pkcs7.messageFromPem()` bzw. `messageFromAsn1()` zurueckgeben —
* der mitgelieferte Typ aus `@types/node-forge`, nicht ein eigener.
*
* Nur die signierte Form traegt `certificates`; die Lesestellen grenzen
* deshalb mit `'certificates' in p7` ein. Das ist verhaltensgleich zum
* bisherigen `p7.certificates ?? []`: bei einer enveloped-Nachricht fehlt
* das Feld, und beide Schreibweisen liefern dann die leere Liste.
*/
type P7Message = forge.pkcs7.Captured<
forge.pkcs7.PkcsEnvelopedData | forge.pkcs7.PkcsSignedData
>;
import type { UploadedFileLike } from '../auth/types/auth-user';
/**
* Eine hochgeladene Zertifikatsdatei, so weit dieser Dienst sie liest:
* Inhalt und eingereichter Name (der Name geht ausschliesslich in
* `detectFormat` und in Fehlermeldungen). Abgeleitet aus `UploadedFileLike`
* statt daneben erfunden (quick-260921-m34); `mimetype` und `size` bleiben
* bewusst draussen, weil kein Zweig dieses Dienstes sie liest.
*/
type CertFileLike = Pick<UploadedFileLike, 'buffer' | 'originalname'>;
// ---------------------------------------------------------------------------
// CertDetails — the structured result returned by parseCert
// ---------------------------------------------------------------------------
@@ -162,7 +185,7 @@ export class CertManagerService {
* - Wrong PFX password → generic 400 message (password value never logged or echoed)
*/
async parseCert(input: {
file?: any;
file?: CertFileLike;
pemText?: string;
password?: string;
}): Promise<CertDetails> {
@@ -179,11 +202,11 @@ export class CertManagerService {
}
cert = certs[0];
} else if (file) {
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
const format = this.detectFormat(file.originalname, file.buffer);
if (format === 'pem') {
// ── PEM file ───────────────────────────────────────────────────────
const pemStr = (file.buffer as Buffer).toString('utf-8');
const pemStr = file.buffer.toString('utf-8');
const certs = this.parsePemChain(pemStr);
if (certs.length === 0) {
throw new Error('No certificate block found in PEM file');
@@ -192,12 +215,12 @@ export class CertManagerService {
} else if (format === 'der') {
// ── DER binary file ────────────────────────────────────────────────
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
cert = forge.pki.certificateFromAsn1(asn1);
} else if (format === 'pfx') {
// ── PFX/PKCS12 file ───────────────────────────────────────────────
// wrong password → forge throws → caught below → BadRequestException (T-09-02)
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const bags = certBags[forge.pki.oids.certBag] ?? [];
@@ -217,18 +240,20 @@ export class CertManagerService {
cert = parsedCert;
} else {
// ── P7B/PKCS7 file — PEM-wrapped or binary DER (Pitfall 4) ────────
const isPemP7b = (file.buffer as Buffer)
const isPemP7b = file.buffer
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
let p7: any;
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
let p7: P7Message;
if (isPemP7b) {
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
} else {
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
const p7Certs: forge.pki.Certificate[] = p7.certificates ?? [];
const p7Certs: forge.pki.Certificate[] =
'certificates' in p7 ? p7.certificates : [];
if (p7Certs.length === 0) {
throw new Error('No certificate found in P7B/PKCS7');
}
@@ -260,6 +285,14 @@ export class CertManagerService {
);
// Key type and size
// BLEIBT als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02):
// @types/node-forge kennt nur `PublicKey = rsa.PublicKey | ed25519.Key`
// (index.d.ts:232). Der EC-Zweig unten liest `curve` und
// `params.curve.q.bitLength()` — Felder, die node-forge zur Laufzeit
// liefert, die der mitgelieferte Typ aber GAR NICHT kennt. Eine
// Umdeutung ueber zwei Stufen wuerde dieselbe Luecke verdecken und
// zusaetzlich so aussehen, als sei sie geprueft. Ein ehrliches any mit
// dieser Zeile ist hier das bessere Ergebnis.
const pubKey = cert.publicKey as any;
let keyType = 'RSA';
let keyBits = 0;
@@ -273,13 +306,24 @@ export class CertManagerService {
}
// Subject Alternative Names
//
// BLEIBEN als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02):
// @types/node-forge deklariert `Certificate.extensions` als `any[]`
// (index.d.ts:435) und sagt damit ueber den Inhalt einer Erweiterung
// NICHTS aus. Jede Schnittstelle, die wir hier selbst fuer `altNames`
// schrieben, waere unbelegt — der Compiler koennte sie an keiner
// Stelle gegen etwas pruefen, sie saehe aber geprueft aus. Die drei
// any-Stellen dieses Blocks bleiben deshalb sichtbar stehen, statt
// gegen eine Behauptung getauscht zu werden.
const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName');
const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`,
);
// Signature algorithm — OID → human-readable name
const sigOid = (cert.siginfo as any)?.algorithmOid ?? '';
// @types/node-forge deklariert siginfo.algorithmOid als string — die
// Zusicherung war ueberfluessig. Das ?. bleibt woertlich erhalten.
const sigOid = cert.siginfo?.algorithmOid ?? '';
const signatureAlgorithm = REVERSE_OIDS[sigOid] ?? sigOid;
// Fingerprints
@@ -332,7 +376,7 @@ export class CertManagerService {
* - File size limit 5 MB enforced by FileInterceptor in the controller
*/
async splitCerts(input: {
file?: any;
file?: CertFileLike;
password?: string;
}): Promise<SplitResponse> {
const { file } = input;
@@ -344,31 +388,33 @@ export class CertManagerService {
let certs: forge.pki.Certificate[];
try {
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
const format = this.detectFormat(file.originalname, file.buffer);
if (format === 'pem') {
// ── PEM chain (fullchain.pem, .crt — both map to 'pem' in detectFormat) ─
const pemStr = (file.buffer as Buffer).toString('utf-8');
const pemStr = file.buffer.toString('utf-8');
certs = this.parsePemChain(pemStr);
if (certs.length === 0) {
throw new Error('No certificate blocks found in PEM file');
}
} else if (format === 'p7b') {
// ── P7B/PKCS7 bundle — PEM-wrapped or binary DER (Pitfall 4) ─────────
const isPemP7b = (file.buffer as Buffer)
const isPemP7b = file.buffer
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
let p7: any;
// Der mitgelieferte Typ traegt hier: messageFromPem/messageFromAsn1
// liefern beide Captured<PkcsEnvelopedData | PkcsSignedData>.
let p7: P7Message;
if (isPemP7b) {
// PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----)
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
} else {
// Binary DER PKCS7
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
certs = (p7.certificates as forge.pki.Certificate[]) ?? [];
certs = 'certificates' in p7 ? p7.certificates : [];
if (certs.length === 0) {
throw new Error('No certificates found in P7B/PKCS7 bundle');
}
@@ -434,7 +480,7 @@ export class CertManagerService {
* No fallback to lower-level certBag construction was needed.
*/
async mergeCerts(input: {
files?: any[];
files?: CertFileLike[];
outputFormat: string;
password?: string;
}): Promise<FileResponse> {
@@ -453,26 +499,23 @@ export class CertManagerService {
let certs: forge.pki.Certificate[];
try {
certs = (files as any[]).flatMap((file: any) => {
const format = this.detectFormat(
file.originalname as string,
file.buffer as Buffer,
);
certs = files.flatMap((file) => {
const format = this.detectFormat(file.originalname, file.buffer);
if (format === 'pem') {
const pemStr = (file.buffer as Buffer).toString('utf-8');
const pemStr = file.buffer.toString('utf-8');
const parsed = this.parsePemChain(pemStr);
if (parsed.length === 0) {
throw new Error(`No certificate block found in ${file.originalname as string}`);
throw new Error(`No certificate block found in ${file.originalname}`);
}
return parsed;
} else if (format === 'der') {
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
return [forge.pki.certificateFromAsn1(asn1)];
} else if (format === 'pfx') {
// Extract all certs from the PFX bag
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const bags = certBags[forge.pki.oids.certBag] ?? [];
@@ -488,24 +531,25 @@ export class CertManagerService {
const missingCertIndex = bagCerts.findIndex((c) => c === undefined || c === null);
if (missingCertIndex !== -1) {
throw new BadRequestException(
`Certificate bag in "${file.originalname as string}" does not contain a readable X.509 certificate`,
`Certificate bag in "${file.originalname}" does not contain a readable X.509 certificate`,
);
}
return bagCerts.filter((c): c is forge.pki.Certificate => c !== undefined && c !== null);
} else {
// P7B/PKCS7 — PEM-wrapped or binary DER (Pitfall 4)
const isPemP7b = (file.buffer as Buffer)
const isPemP7b = file.buffer
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
let p7: any;
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
let p7: P7Message;
if (isPemP7b) {
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
} else {
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
return (p7.certificates as forge.pki.Certificate[]) ?? [];
return 'certificates' in p7 ? p7.certificates : [];
}
});
} catch (err) {
@@ -536,6 +580,12 @@ export class CertManagerService {
// Open Question 1 resolution: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0
// null as the private key produces a cert-only PKCS12 bundle (no key bag — cert bag only)
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
// BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen
// fehlenden Schluessel an und erzeugt ein reines
// Zertifikatsbuendel; @types/node-forge schliesst null aus. Die
// mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle
// also nachweislich falsch — ein erzwungener Typ waere eine
// Behauptung ueber etwas, das nicht stimmt.
null as any, // cert-only PFX — null key accepted by node-forge 1.4.0
certs,
password!,
@@ -571,7 +621,7 @@ export class CertManagerService {
* - Password is never passed to the logger (T-09-02)
*/
async convertCert(input: {
file?: any;
file?: CertFileLike;
pemText?: string;
targetFormat: string;
password?: string;
@@ -602,10 +652,10 @@ export class CertManagerService {
}
cert = certs[0];
} else if (file) {
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
const format = this.detectFormat(file.originalname, file.buffer);
if (format === 'pem') {
const pemStr = (file.buffer as Buffer).toString('utf-8');
const pemStr = file.buffer.toString('utf-8');
const certs = this.parsePemChain(pemStr);
if (certs.length === 0) {
throw new Error('No certificate block found in PEM file');
@@ -613,11 +663,11 @@ export class CertManagerService {
cert = certs[0];
} else if (format === 'der') {
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
cert = forge.pki.certificateFromAsn1(asn1);
} else if (format === 'pfx') {
// PFX/PKCS12 — extract first cert bag (wrong password → BadRequestException)
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const bags = certBags[forge.pki.oids.certBag] ?? [];
@@ -637,18 +687,20 @@ export class CertManagerService {
cert = parsedCert;
} else {
// P7B — extract first cert
const isPemP7b = (file.buffer as Buffer)
const isPemP7b = file.buffer
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
let p7: any;
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
let p7: P7Message;
if (isPemP7b) {
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
} else {
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
const p7Certs: forge.pki.Certificate[] = p7.certificates ?? [];
const p7Certs: forge.pki.Certificate[] =
'certificates' in p7 ? p7.certificates : [];
if (p7Certs.length === 0) {
throw new Error('No certificate found in P7B/PKCS7');
}
@@ -691,6 +743,12 @@ export class CertManagerService {
} else {
// PFX — cert-only PKCS12 bundle (Open Question 1: null key works in node-forge 1.4.0)
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
// BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen
// fehlenden Schluessel an und erzeugt ein reines
// Zertifikatsbuendel; @types/node-forge schliesst null aus. Die
// mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle
// also nachweislich falsch — ein erzwungener Typ waere eine
// Behauptung ueber etwas, das nicht stimmt.
null as any, // cert-only PFX — null key accepted by node-forge 1.4.0
[cert],
password!,
+28 -17
View File
@@ -10,7 +10,7 @@ import {
Put,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { DashboardService } from './dashboard.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto';
@@ -39,42 +39,53 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
private extractContext(req: Request) {
const userId = (req as any).user?.id;
/**
* BEFUND quick-260921-m34 (D-03, gemeldet nicht repariert): `getWidgets`
* las die Rolle vorher als `req.user?.role` NACH dieser Pruefung und gab
* sie an `DashboardService.getWidgets(role: Role)` weiter, das eine Rolle
* zwingend verlangt. Der Bestandscode nahm also an, dass an dieser Stelle
* immer ein Aufrufer vorliegt. Die Annahme stimmt — die Pruefung "No user
* context" direkt darunter erzwingt sie seit jeher —, aber der Compiler
* konnte die beiden Stellen nicht verbinden, weil sie in zwei Methoden
* standen. Deshalb gibt diese Methode die Rolle jetzt MIT zurueck: keine
* neue Pruefung, kein erfundener Wert, gleiche Reihenfolge, gleiche
* Meldungen, gleiches Verhalten — nur sichtbar statt angenommen.
*/
private extractContext(req: AuthenticatedRequest) {
const user = req.user;
const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId;
req.tenantId ?? user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
if (!userId) {
if (!user?.id) {
throw new ForbiddenException('No user context');
}
return { userId, tenantId };
return { userId: user.id, tenantId, role: user.role };
}
@Get('layout')
async getLayout(@Req() req: Request) {
async getLayout(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getLayout(userId, tenantId);
}
@Put('layout')
async saveLayout(@Req() req: Request, @Body() dto: SaveLayoutDto) {
async saveLayout(@Req() req: AuthenticatedRequest, @Body() dto: SaveLayoutDto) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.saveLayout(userId, tenantId, dto);
}
@Get('widgets')
async getWidgets(@Req() req: Request) {
const { userId, tenantId } = this.extractContext(req);
const role = (req as any).user?.role;
async getWidgets(@Req() req: AuthenticatedRequest) {
const { userId, tenantId, role } = this.extractContext(req);
return this.dashboardService.getWidgets(userId, tenantId, role);
}
@Post('widgets')
async addWidget(@Req() req: Request, @Body() dto: CreateWidgetDto) {
async addWidget(@Req() req: AuthenticatedRequest, @Body() dto: CreateWidgetDto) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.addWidget(userId, tenantId, dto);
}
@@ -82,7 +93,7 @@ export class DashboardController {
@Patch('widgets/:id/config')
async updateWidgetConfig(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Body() dto: UpdateWidgetConfigDto,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -92,7 +103,7 @@ export class DashboardController {
@Delete('widgets/:id')
async removeWidget(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.removeWidget(id, userId, tenantId);
@@ -101,14 +112,14 @@ export class DashboardController {
// --- Search Providers (05-02, D-15) ---
@Get('search-providers')
async getSearchProviders(@Req() req: Request) {
async getSearchProviders(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getSearchProviders(userId, tenantId);
}
@Post('search-providers')
async addSearchProvider(
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Body() dto: CreateSearchProviderDto,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -118,7 +129,7 @@ export class DashboardController {
@Delete('search-providers/:id')
async removeSearchProvider(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.removeSearchProvider(id, userId, tenantId);
@@ -133,6 +133,14 @@ export class DkvSchedulerService implements OnModuleInit {
// Cast required: our minimal CronJob type doesn't match cron's full type signature.
// At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die
// Zusicherung meldet tsc, dass das lokale `job` nur die Form
// `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen
// CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm-
// Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das
// aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine
// Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue
// Abhaengigkeit. Beides ist hier verboten (D-03/D-04).
this.schedulerRegistry.addCronJob(jobName, job as any);
job.start();
+21 -16
View File
@@ -17,6 +17,11 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type {
AuthenticatedRequest,
UploadedFileLike,
} from '../auth/types/auth-user';
import type { Response } from 'express';
import { DkvSchedulerService } from './dkv-scheduler.service';
import { DkvService } from './dkv.service';
import { DkvConfigDto } from './dto/dkv-config.dto';
@@ -58,7 +63,7 @@ export class DkvController {
/** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */
@Get('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: any) {
async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
const config = await this.dkvService.getConfigForApi(tenantId);
if (!config) {
@@ -75,7 +80,7 @@ export class DkvController {
*/
@Put('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveConfig(@Req() req: any, @Body() dto: DkvConfigDto) {
async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
const result = await this.dkvService.saveConfig(tenantId, dto);
@@ -94,7 +99,7 @@ export class DkvController {
/** POST /dkv/check-now — immediately run the inbox processing pipeline. */
@Post('check-now')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async checkNow(@Req() req: any) {
async checkNow(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.checkNow(tenantId);
}
@@ -105,7 +110,7 @@ export class DkvController {
*/
@Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: any, @Body() dto: DkvConfigDto) {
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.testConnection(tenantId, dto);
}
@@ -118,7 +123,7 @@ export class DkvController {
*/
@Get('history')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getHistory(@Req() req: any, @Query() query: DkvHistoryQueryDto) {
async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
const tenantId = this._requireTenant(req);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
@@ -137,9 +142,9 @@ export class DkvController {
@Get('exports/:filename')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async downloadExport(
@Req() req: any,
@Req() req: AuthenticatedRequest,
@Param('filename') filename: string,
@Res() res: any,
@Res() res: Response,
) {
const tenantId = this._requireTenant(req);
@@ -164,7 +169,7 @@ export class DkvController {
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */
@Get('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listVehicles(@Req() req: any) {
async listVehicles(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.listVehicles(tenantId);
}
@@ -172,7 +177,7 @@ export class DkvController {
/** POST /dkv/vehicles — create a new vehicle master record. */
@Post('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createVehicle(@Req() req: any, @Body() dto: CreateVehicleDto) {
async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.createVehicle(tenantId, dto);
}
@@ -181,7 +186,7 @@ export class DkvController {
@Put('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async updateVehicle(
@Req() req: any,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateVehicleDto,
) {
@@ -192,7 +197,7 @@ export class DkvController {
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
@Delete('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async deleteVehicle(@Req() req: any, @Param('id') id: string) {
async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this._requireTenant(req);
return this.dkvService.deleteVehicle(tenantId, id);
}
@@ -213,8 +218,8 @@ export class DkvController {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
}))
async importVehicles(
@Req() req: any,
@UploadedFile() file: any,
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
@Body('mode') mode: string,
) {
const tenantId = this._requireTenant(req);
@@ -223,7 +228,7 @@ export class DkvController {
throw new BadRequestException('No CSV file uploaded (field name must be "file")');
}
const csvText = (file.buffer as Buffer).toString('utf-8');
const csvText = file.buffer.toString('utf-8');
const importMode = mode === 'replace' ? 'replace' : 'merge';
return this.dkvService.importVehiclesCsv(tenantId, csvText, importMode);
@@ -232,8 +237,8 @@ export class DkvController {
// ─── Private helpers ───────────────────────────────────────────────────────
/** Extract and validate tenantId from request; throw BadRequestException when absent. */
private _requireTenant(req: any): string {
const tenantId = req.tenantId as string | undefined;
private _requireTenant(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
+16 -16
View File
@@ -118,7 +118,7 @@ export class DkvService {
* 260914-eym systemgebunden, eine Zeile je aktivem Mandanten).
*/
async loadConfig(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.dkvModuleConfig.findUnique({
where: { tenantId },
select: CONFIG_SAFE_SELECT,
@@ -153,7 +153,7 @@ export class DkvService {
* (T-07-12) — der Planer braucht nur tenantId und pollIntervalMin.
*/
async loadActiveConfigsForScheduler() {
const systemPrisma = forSystem(this.prisma) as any;
const systemPrisma = forSystem(this.prisma);
return systemPrisma.dkvModuleConfig.findMany({
where: { isActive: true },
select: CONFIG_SAFE_SELECT,
@@ -171,7 +171,7 @@ export class DkvService {
* Methode).
*/
async getConfigForApi(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const safe = await tenantPrisma.dkvModuleConfig.findUnique({
where: { tenantId },
select: CONFIG_SAFE_SELECT,
@@ -214,7 +214,7 @@ export class DkvService {
* Bindung als zweite Schicht bestehen.
*/
async saveConfig(tenantId: string, dto: DkvConfigDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
let encryptedInboxCreds: string | undefined;
const credChanged = (dto.password && dto.password.length > 0) ||
@@ -277,7 +277,7 @@ export class DkvService {
* Rueckgriff auf die gespeicherten Zugangsdaten.
*/
async testConnection(tenantId: string, dto: DkvConfigDto): Promise<{ success: boolean; message?: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
let password: string | undefined = dto.password;
// If no password in DTO, fall back to the stored one
@@ -362,7 +362,7 @@ export class DkvService {
private async _runPipeline(tenantId: string): Promise<void> {
// Load raw config (need encryptedInboxCreds for decryption).
// Mandantengebunden (260909-mir).
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const config = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
if (!config) {
this.logger.warn(`DKV processInbox: no config for tenant ${tenantId}`);
@@ -448,7 +448,7 @@ export class DkvService {
// Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide
// dkvInvoiceHistory.create()-Aufrufe dieser Methode (Erfolgsfall UND
// Zerlegungsfehler-Fall).
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
// D-10: Up to 3 parse retries
let parseResult: Awaited<ReturnType<typeof this.parser.parsePdf>> | null = null;
@@ -546,7 +546,7 @@ export class DkvService {
// ─── Vehicle CRUD ────────────────────────────────────────────────────────────
async listVehicles(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.dkvVehicleMaster.findMany({
where: { tenantId },
orderBy: { kennzeichen: 'asc' },
@@ -554,7 +554,7 @@ export class DkvService {
}
async createVehicle(tenantId: string, dto: CreateVehicleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.dkvVehicleMaster.create({
data: { tenantId, ...dto },
});
@@ -571,7 +571,7 @@ export class DkvService {
* Schreibzugriff dahinter waere genau die Luecke, nicht die Loesung.
*/
async updateVehicle(tenantId: string, id: string, dto: UpdateVehicleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.dkvVehicleMaster.findFirst({
where: { id, tenantId },
});
@@ -581,7 +581,7 @@ export class DkvService {
/** Mandantengebunden (260909-mir, Befund G) — siehe updateVehicle() oben. */
async deleteVehicle(tenantId: string, id: string): Promise<{ deleted: boolean }> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.dkvVehicleMaster.findFirst({
where: { id, tenantId },
});
@@ -611,7 +611,7 @@ export class DkvService {
csvText: string,
mode: 'merge' | 'replace',
): Promise<{ imported: number; mode: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const vehicles = _parseVehicleCsv(csvText);
if (vehicles.length === 0) {
throw new BadRequestException(
@@ -661,7 +661,7 @@ export class DkvService {
page: number;
limit: number;
}> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
tenantPrisma.dkvInvoiceHistory.findMany({
@@ -714,7 +714,7 @@ export class DkvService {
// Stage 2 (NEW, 260909-mir): the ownership gate. A bound read — the
// only tenant-scoped statement of who this file belongs to.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const owningHistoryRow = await tenantPrisma.dkvInvoiceHistory.findFirst({
where: { tenantId, exportFilename: filename },
});
@@ -751,8 +751,8 @@ export class DkvService {
vehicleFormatString: string,
): Promise<{ lieferdatum: string; fahrzeug: string; fahrer: string; ort: string; kilometerstand: number | null }[]> {
// Batch load vehicle master to avoid N+1 queries
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const masters: any[] = await tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId } });
const tenantPrisma = forTenant(this.prisma, tenantId);
const masters = await tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId } });
// Normalize keys: DKV PDF may omit hyphens or use spaces ("GP JL 740E" vs "GP-JL 740E")
const masterMap = new Map(masters.map((m) => [_normalizeKennzeichen(m.kennzeichen), m]));
+11 -10
View File
@@ -13,7 +13,8 @@ import {
Req,
Res,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { Response } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CreateFavoriteDto } from './dto/create-favorite.dto';
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
@@ -46,10 +47,10 @@ import { FavoritesService } from './favorites.service';
export class FavoritesController {
constructor(private readonly favoritesService: FavoritesService) {}
private extractContext(req: Request) {
const userId = (req as any).user?.id;
private extractContext(req: AuthenticatedRequest) {
const userId = req.user?.id;
const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId;
req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
@@ -64,7 +65,7 @@ export class FavoritesController {
@Get()
async list(
@Query('widgetId', ParseUUIDPipe) widgetId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -74,7 +75,7 @@ export class FavoritesController {
@Post()
async create(
@Body() dto: CreateFavoriteDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -91,7 +92,7 @@ export class FavoritesController {
@Put('order')
async reorder(
@Body() dto: ReorderFavoritesDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -110,7 +111,7 @@ export class FavoritesController {
@Get(':id/icon')
async getIcon(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Res() res: Response,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -135,7 +136,7 @@ export class FavoritesController {
async update(
@Param('id') id: string,
@Body() dto: UpdateFavoriteDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
@@ -145,7 +146,7 @@ export class FavoritesController {
@Delete(':id')
async remove(
@Param('id') id: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractContext(req);
+6 -6
View File
@@ -65,7 +65,7 @@ export class FavoritesService {
async list(tenantId: string, userId: string, widgetId: string) {
if (!widgetId) throw new BadRequestException('widgetId is required');
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.favoriteLink.findMany({
where: { userId, widgetId },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
@@ -79,7 +79,7 @@ export class FavoritesService {
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
*/
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
@@ -123,7 +123,7 @@ export class FavoritesService {
* Accepts null as an explicit value for iconUrl (clears stored icon).
*/
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) {
@@ -164,7 +164,7 @@ export class FavoritesService {
* Verifies userId ownership before deleting (T-08-06).
*/
async remove(tenantId: string, id: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) {
@@ -210,7 +210,7 @@ export class FavoritesService {
throw new BadRequestException('ids must match the favorites of this widget exactly');
}
return withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
const existing = await tx.favoriteLink.findMany({
where: { userId, widgetId: dto.widgetId },
select: { id: true },
@@ -258,7 +258,7 @@ export class FavoritesService {
id: string,
userId: string,
): Promise<{ contentType: string; body: Buffer }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || !link.iconUrl) {
+11 -11
View File
@@ -11,7 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AddGroupMembersDto } from './dto/add-group-members.dto';
@@ -30,8 +30,8 @@ import { GroupsService } from './groups.service';
export class GroupsController {
constructor(private readonly groupsService: GroupsService) {}
private getTenantId(req: Request): string {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
private getTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
@@ -45,7 +45,7 @@ export class GroupsController {
@Get()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async list(@Req() req: Request) {
async list(@Req() req: AuthenticatedRequest) {
return this.groupsService.listForTenant(this.getTenantId(req));
}
@@ -56,7 +56,7 @@ export class GroupsController {
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateGroupDto, @Req() req: Request) {
async create(@Body() dto: CreateGroupDto, @Req() req: AuthenticatedRequest) {
return this.groupsService.create(this.getTenantId(req), dto);
}
@@ -70,7 +70,7 @@ export class GroupsController {
async update(
@Param('id') id: string,
@Body() dto: UpdateGroupDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
return this.groupsService.update(this.getTenantId(req), id, dto);
}
@@ -82,7 +82,7 @@ export class GroupsController {
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Param('id') id: string, @Req() req: Request) {
async remove(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.groupsService.remove(this.getTenantId(req), id);
}
@@ -93,7 +93,7 @@ export class GroupsController {
@Get(':id/impact')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async impact(@Param('id') id: string, @Req() req: Request) {
async impact(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.groupsService.getImpact(this.getTenantId(req), id);
}
@@ -104,7 +104,7 @@ export class GroupsController {
@Get(':id/members')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async members(@Param('id') id: string, @Req() req: Request) {
async members(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.groupsService.listMembers(this.getTenantId(req), id);
}
@@ -118,7 +118,7 @@ export class GroupsController {
async addMembers(
@Param('id') id: string,
@Body() dto: AddGroupMembersDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
return this.groupsService.addMembers(this.getTenantId(req), id, dto.userIds);
}
@@ -134,7 +134,7 @@ export class GroupsController {
async removeMember(
@Param('id') id: string,
@Param('userId') userId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
await this.groupsService.removeMember(this.getTenantId(req), id, userId);
return { success: true };
+34 -34
View File
@@ -7,6 +7,7 @@ import {
import { MembershipSource } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
/**
* Name der automatisch angelegten Standardgruppe (D-13). Geteilte Wahrheit
@@ -53,20 +54,14 @@ export class GroupsService {
* Mitgliederzahl. Ein Mandant ohne Gruppen liefert ein leeres Array.
*/
async listForTenant(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
// Explizit als any[] annotiert (nicht nur der Rueckgabewert von await):
// ohne diese Array-Verankerung inferiert TypeScript den Rueckgabewert
// dieser Methode als bloss `any` statt `any[]`, und Aufrufer, die auf
// dem Ergebnis `.find()` aufrufen, wuerden TS7006 (impliziter any-Typ
// im Callback-Parameter) melden, obwohl der gebundene Client bewusst
// `any` ist (siehe forTenant()-Aufrufe in dieser Datei).
const groups: any[] = await tenantPrisma.group.findMany({
const tenantPrisma = forTenant(this.prisma, tenantId);
const groups = await tenantPrisma.group.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
include: { _count: { select: { memberships: true } } },
});
return groups.map((g: any) => ({
return groups.map((g) => ({
id: g.id,
tenantId: g.tenantId,
name: g.name,
@@ -92,13 +87,13 @@ export class GroupsService {
throw new BadRequestException('Gruppenname darf nicht leer sein');
}
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
try {
return await tenantPrisma.group.create({
data: { tenantId, name },
});
} catch (err: any) {
if (err?.code === 'P2002') {
} catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') {
throw new ConflictException(
`Eine Gruppe mit dem Namen '${name}' existiert bereits in diesem Mandanten`,
);
@@ -112,7 +107,7 @@ export class GroupsService {
* Mandanten liefert NotFoundException statt eines Treffers.
*/
private async findOwned(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const group = await tenantPrisma.group.findFirst({
where: { id, tenantId },
});
@@ -188,7 +183,7 @@ export class GroupsService {
try {
if (data.isDefault === true) {
const updated = await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
const updated = await withTenantTransaction(this.prisma, tenantId, async (tx) => {
await tx.group.updateMany({
where: { tenantId, isDefault: true },
data: { isDefault: false },
@@ -205,13 +200,13 @@ export class GroupsService {
updateData.isDefault = false;
}
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return await tenantPrisma.group.update({
where: { id },
data: updateData,
});
} catch (err: any) {
if (err?.code === 'P2002') {
} catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') {
throw new ConflictException(
`Eine Gruppe mit dem Namen '${updateData.name}' existiert bereits in diesem Mandanten`,
);
@@ -227,7 +222,7 @@ export class GroupsService {
async getImpact(tenantId: string, id: string) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const [memberCount, grantCount] = await Promise.all([
tenantPrisma.groupMembership.count({ where: { groupId: id } }),
tenantPrisma.moduleGrant.count({ where: { groupId: id } }),
@@ -247,11 +242,11 @@ export class GroupsService {
async remove(tenantId: string, id: string) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
try {
return await tenantPrisma.group.delete({ where: { id } });
} catch (err: any) {
if (err?.code === 'P2025') {
} catch (err: unknown) {
if (prismaErrorCode(err) === 'P2025') {
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
}
throw err;
@@ -265,7 +260,7 @@ export class GroupsService {
async listMembers(tenantId: string, id: string) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.groupMembership.findMany({
where: { groupId: id },
include: {
@@ -286,12 +281,12 @@ export class GroupsService {
async addMembers(tenantId: string, id: string, userIds: string[]) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const validUsers = await tenantPrisma.user.findMany({
where: { id: { in: userIds }, tenantId },
select: { id: true },
});
const validIds = validUsers.map((u: any) => u.id);
const validIds = validUsers.map((u) => u.id);
if (validIds.length === 0) {
return { added: 0 };
}
@@ -316,7 +311,7 @@ export class GroupsService {
async removeMember(tenantId: string, id: string, userId: string) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
await tenantPrisma.groupMembership.deleteMany({
where: { groupId: id, userId, source: MembershipSource.MANUAL },
});
@@ -354,14 +349,14 @@ export class GroupsService {
* propagieren.
*/
async ensureDefaultGroup(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const existingCount = await tenantPrisma.group.count({ where: { tenantId } });
if (existingCount > 0) {
return null;
}
try {
return await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
return await withTenantTransaction(this.prisma, tenantId, async (tx) => {
const group = await tx.group.create({
data: { tenantId, name: DEFAULT_GROUP_NAME, isDefault: true },
});
@@ -372,6 +367,9 @@ export class GroupsService {
});
if (users.length > 0) {
await tx.groupMembership.createMany({
// u: any bleibt (gemessen, Aufgabe 1 260921-m34) - tx ist selbst
// any (siehe Begruendung an withTenantTransaction()), any.map()
// gibt hier keine kontextuelle Typisierung des Parameters.
data: users.map((u: any) => ({
groupId: group.id,
userId: u.id,
@@ -387,6 +385,8 @@ export class GroupsService {
});
if (activations.length > 0) {
await tx.moduleGrant.createMany({
// a: any bleibt (gemessen, Aufgabe 1 260921-m34) - selbe Ursache
// wie bei `u` oben: tx ist any.
data: activations.map((a: any) => ({
tenantId,
moduleId: a.moduleId,
@@ -399,8 +399,8 @@ export class GroupsService {
return group;
});
} catch (err: any) {
if (err?.code === 'P2002') {
} catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') {
return null;
}
throw err;
@@ -435,7 +435,7 @@ export class GroupsService {
* werfen — exakt das Muster aus ensureDefaultGroup().
*/
async reassignDefaultBeforeDelete(tenantId: string, groupId: string): Promise<boolean> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const group = await tenantPrisma.group.findFirst({
where: { id: groupId, tenantId },
@@ -458,7 +458,7 @@ export class GroupsService {
}
try {
await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
await tx.group.updateMany({
where: { tenantId, isDefault: true },
data: { isDefault: false },
@@ -469,8 +469,8 @@ export class GroupsService {
});
});
return true;
} catch (err: any) {
if (err?.code === 'P2002') {
} catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') {
return false;
}
throw err;
@@ -497,7 +497,7 @@ export class GroupsService {
* Treffer folgenlos zurückkehren statt zu werfen.
*/
async addUserToDefaultGroup(tenantId: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const defaultGroup = await tenantPrisma.group.findFirst({
where: { tenantId, isDefault: true },
});
@@ -10,7 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { CreateModuleGrantDto } from './dto/create-module-grant.dto';
@@ -31,8 +31,8 @@ import { ModuleGrantsService } from './module-grants.service';
export class ModuleGrantsController {
constructor(private readonly moduleGrantsService: ModuleGrantsService) {}
private getTenantId(req: Request): string {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
private getTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
@@ -46,7 +46,7 @@ export class ModuleGrantsController {
@Get('matrix')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async matrix(@Req() req: Request) {
async matrix(@Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.getMatrix(this.getTenantId(req));
}
@@ -61,7 +61,7 @@ export class ModuleGrantsController {
@Get('users/:userId')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async userAccess(@Param('userId') userId: string, @Req() req: Request) {
async userAccess(@Param('userId') userId: string, @Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.getUserAccess(this.getTenantId(req), userId);
}
@@ -72,7 +72,7 @@ export class ModuleGrantsController {
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateModuleGrantDto, @Req() req: Request) {
async create(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.grant(this.getTenantId(req), dto);
}
@@ -84,7 +84,7 @@ export class ModuleGrantsController {
@Delete()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Body() dto: CreateModuleGrantDto, @Req() req: Request) {
async remove(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) {
await this.moduleGrantsService.revoke(this.getTenantId(req), dto);
return { success: true };
}
+19 -20
View File
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
/**
* Schreibseite der Modul-Freigaben (PERM-03): Grants für Gruppen und für
@@ -48,7 +49,7 @@ export class ModuleGrantsService {
groupId?: string,
userId?: string,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
if (groupId) {
const group = await tenantPrisma.group.findFirst({
where: { id: groupId, tenantId },
@@ -107,7 +108,7 @@ export class ModuleGrantsService {
// Datenbank" entfallen.
await this.assertTargetBelongsToTenant(tenantId, groupId, userId);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
where: { tenantId_moduleId: { tenantId, moduleId } },
});
@@ -132,8 +133,8 @@ export class ModuleGrantsService {
`Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`,
);
return created;
} catch (err: any) {
if (err?.code === 'P2002') {
} catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') {
const existing = await tenantPrisma.moduleGrant.findFirst({
where: {
tenantId,
@@ -166,7 +167,7 @@ export class ModuleGrantsService {
const { moduleId, groupId, userId } = data;
const target = groupId ? `group=${groupId}` : `user=${userId}`;
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
await tenantPrisma.moduleGrant.deleteMany({
where: {
tenantId,
@@ -189,7 +190,7 @@ export class ModuleGrantsService {
* hinweg stabil.
*/
async getMatrix(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const [activations, groups, groupGrants] = await Promise.all([
tenantPrisma.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
@@ -206,18 +207,17 @@ export class ModuleGrantsService {
]);
const modules = activations
.map((a: any) => a.module)
.map((a) => a.module)
.sort(
(a: any, b: any) =>
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
(a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
);
return {
modules,
groups,
grants: groupGrants.map((g: any) => ({
moduleId: g.moduleId as string,
groupId: g.groupId as string,
grants: groupGrants.map((g) => ({
moduleId: g.moduleId,
groupId: g.groupId,
})),
};
}
@@ -246,7 +246,7 @@ export class ModuleGrantsService {
async getUserAccess(tenantId: string, userId: string) {
await this.assertTargetBelongsToTenant(tenantId, undefined, userId);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const [activations, groupGrants, directGrants, memberships] = await Promise.all([
tenantPrisma.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
@@ -275,9 +275,9 @@ export class ModuleGrantsService {
}),
]);
const directModuleIds = new Set(directGrants.map((g: any) => g.moduleId as string));
const directModuleIds = new Set(directGrants.map((g) => g.moduleId));
const groupNamesByModule = new Map<string, string[]>();
for (const g of groupGrants as any[]) {
for (const g of groupGrants) {
if (!g.group) continue;
const names = groupNamesByModule.get(g.moduleId) ?? [];
names.push(g.group.internalName ?? g.group.name);
@@ -285,13 +285,12 @@ export class ModuleGrantsService {
}
const modules = activations
.map((a: any) => a.module)
.map((a) => a.module)
.sort(
(a: any, b: any) =>
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
(a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
);
const groups = (memberships as any[])
const groups = memberships
.filter((m) => m.group)
.map((m) => ({
id: m.group.id as string,
@@ -302,7 +301,7 @@ export class ModuleGrantsService {
return {
groups,
modules: modules.map((module: any) => ({
modules: modules.map((module) => ({
module,
viaGroups: groupNamesByModule.get(module.id) ?? [],
direct: directModuleIds.has(module.id),
+28 -3
View File
@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const httpntlm = require('httpntlm') as { post: (opts: any, cb: (err: Error | null, res: any) => void) => void };
const httpntlm = require('httpntlm') as {
post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void;
};
import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
import type { InboxProvider } from './inbox-provider.interface';
@@ -236,11 +238,34 @@ interface NtlmOptions {
rejectUnauthorized?: boolean;
}
/**
* Antwortform von httpntlm.post, beschrieben aus dem, was der Aufrufer
* unten liest — mehr nicht.
*
* BEFUND (260921-m34, Aufgabe 3c, D-03): `body` ist bewusst
* `Buffer | string`. httpntlm reicht an httpreq durch, und httpreq gibt den
* Rumpf als ZEICHENKETTE zurueck, solange die Option `binary` nicht gesetzt
* ist (gemessen in httpreq@1.1.1/lib/httpreq.js:391) — keiner der beiden
* Aufrufer in diesem Baum setzt sie. Die Testdoppel reichen dagegen einen
* Buffer herein. Der Bestand rief hier unbesehen `.toString('utf-8')` auf;
* das funktioniert bei einer Zeichenkette nur, weil String.toString() sein
* Argument ignoriert. Beide Formen kommen also wirklich vor, der Typ nennt
* beide, und die Fallunterscheidung unten liefert fuer jede exakt dasselbe
* Ergebnis wie zuvor. Verhalten unveraendert.
*/
interface NtlmResponse {
statusCode: number;
body?: Buffer | string;
}
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
(httpntlm as any).post(opts, (err: Error | null, res: any) => {
httpntlm.post(opts, (err, res) => {
if (err) return reject(err);
resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' });
resolve({
statusCode: res.statusCode,
body: typeof res.body === 'string' ? res.body : (res.body?.toString('utf-8') ?? ''),
});
});
});
}
+32 -7
View File
@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Readable } from 'node:stream';
import { ImapFlow, MessageStructureObject } from 'imapflow';
import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
import type { InboxProvider } from './inbox-provider.interface';
@@ -14,9 +15,7 @@ const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB
* Converts a Node.js Readable stream into a Buffer.
* Accumulates chunks up to MAX_ATTACHMENT_BYTES; throws if limit exceeded.
*/
async function streamToBuffer(
stream: NodeJS.ReadableStream,
): Promise<Buffer> {
async function streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
@@ -24,8 +23,12 @@ async function streamToBuffer(
stream.on('data', (chunk: Buffer) => {
total += chunk.length;
if (total > MAX_ATTACHMENT_BYTES) {
// Destroy the stream to prevent further data emission
(stream as any).destroy?.();
// Destroy the stream to prevent further data emission.
// Readable statt NodeJS.ReadableStream: alle drei Aufrufer reichen
// client.download().content herein, und imapflow deklariert das als
// Readable (imap-flow.d.ts:521). Readable traegt destroy(), also
// braucht der Aufruf keine Zusicherung mehr.
stream.destroy?.();
reject(
new Error(
`Attachment exceeds maximum allowed size of ${MAX_ATTACHMENT_BYTES} bytes (T-07-05)`,
@@ -59,10 +62,23 @@ function collectPdfParts(
const type = node.type?.toLowerCase() ?? '';
// Some mail clients (e.g. Outlook) send PDFs as application/octet-stream.
// Fall back to checking the filename from Content-Disposition or Content-Type parameters.
// BLEIBT als any, mit Befund (260921-m34, Aufgabe 3c, D-01/D-03):
// imapflow deklariert `disposition` als ZEICHENKETTE (imap-flow.d.ts:448,
// also "attachment"/"inline"), und die zugehoerigen Parameter liegen in
// einem eigenen Feld `dispositionParameters` (:450). Der Ausdruck unten
// liest `.parameters` von einer Zeichenkette und ist damit zur Laufzeit
// IMMER undefined — dispositionFilename ist stets ''. Das ist ein Befund
// im Bestandscode, kein Typproblem: ihn hier auf `dispositionParameters`
// umzubiegen waere eine Verhaltensaenderung (Outlook-Anhaenge als
// application/octet-stream wuerden ab dann erstmals erkannt), und die ist
// in dieser Aufgabe verboten. Gemeldet im SUMMARY, Entscheidung beim
// Menschen. Die Zusicherung bleibt sichtbar stehen, damit der Befund
// nicht verschwindet.
const dispositionFilename =
((node as any).disposition?.parameters?.filename as string | undefined)?.toLowerCase() ?? '';
const typeFilename =
((node as any).parameters?.name as string | undefined)?.toLowerCase() ?? '';
// Hier dagegen war die Zusicherung schlicht ueberfluessig: imapflow
// deklariert `parameters?: { [key: string]: string }` (imap-flow.d.ts:438).
const typeFilename = node.parameters?.name?.toLowerCase() ?? '';
const looksLikePdf =
type === 'application/pdf' ||
(type === 'application/octet-stream' &&
@@ -374,6 +390,15 @@ export class ImapProvider implements InboxProvider {
: undefined,
// T-07-03: suppress imapflow verbose logs — they include auth credentials
logger: false,
// BLEIBT als Zusicherung, mit Befund (260921-m34, Aufgabe 3c, D-03):
// `requireTLS` oben kommt in imapflow 1.4.3 NIRGENDS vor — weder in
// ImapFlowOptions (lib/imap-flow.d.ts) noch im Laufzeitcode
// (lib/imap-flow.js), beides durchsucht. Die Option wird also still
// verworfen; STARTTLS wird nicht durch sie erzwungen. Genau diese
// Zusicherung hat das bisher verdeckt. Sie bleibt trotzdem stehen:
// die Option zu entfernen waere eine stille Reparatur einer falschen
// Annahme (verboten), und der `any`-Befund haelt die Stelle in der
// Zaehlung sichtbar, bis ein Mensch entscheidet. Gemeldet im SUMMARY.
} as any);
}
}
+9 -9
View File
@@ -68,7 +68,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
*/
async onApplicationBootstrap(): Promise<void> {
try {
const systemPrisma = forSystem(this.prisma) as any;
const systemPrisma = forSystem(this.prisma);
const configs: { id: string; tenantId: string; encryptedBindPassword: string | null }[] =
await systemPrisma.ldapConfig.findMany({
select: { id: true, tenantId: true, encryptedBindPassword: true },
@@ -84,7 +84,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
for (const config of legacy) {
// Schreiben je Altzeile GEBUNDEN an den Mandanten der Zeile — unter
// Systemkontext wuerde die Datenbank das Update abweisen (P2025).
const tenantPrisma = forTenant(this.prisma, config.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, config.tenantId);
await tenantPrisma.ldapConfig.update({
where: { id: config.id },
data: {
@@ -146,7 +146,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
* bewusst ueber alle Mandanten liest.
*/
async getConfig(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const config = await tenantPrisma.ldapConfig.findUnique({
where: { tenantId },
include: { fieldMappings: true },
@@ -166,7 +166,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
* gemessen.
*/
async createConfig(tenantId: string, dto: CreateLdapConfigDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const created = await tenantPrisma.ldapConfig.create({
data: {
tenantId,
@@ -209,7 +209,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc).
*/
async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const updated = await tenantPrisma.ldapConfig.update({
where: { tenantId },
data: {
@@ -257,7 +257,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
configId: string,
dto: CreateFieldMappingDto,
) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.ldapFieldMapping.create({
data: {
ldapConfigId: configId,
@@ -283,7 +283,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
* Loeschung.
*/
async removeFieldMapping(tenantId: string, mappingId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const mapping = await tenantPrisma.ldapFieldMapping.findUnique({
where: { id: mappingId },
});
@@ -319,11 +319,11 @@ export class LdapConfigService implements OnApplicationBootstrap {
* den es dann nicht gibt.
*/
async getAllActiveConfigs() {
const systemPrisma = forSystem(this.prisma) as any;
const systemPrisma = forSystem(this.prisma);
const configs = await systemPrisma.ldapConfig.findMany({
where: { isActive: true },
include: { tenant: true, fieldMappings: true },
});
return configs.map((config: any) => this.withDecryptedPassword(config));
return configs.map((config) => this.withDecryptedPassword(config));
}
}
+12 -11
View File
@@ -13,6 +13,7 @@ import {
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import {
CreateFieldMappingDto,
CreateLdapConfigDto,
@@ -41,7 +42,7 @@ export class LdapController {
*/
@Get('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: any) {
async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -65,7 +66,7 @@ export class LdapController {
*/
@Post('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createConfig(@Req() req: any, @Body() dto: CreateLdapConfigDto) {
async createConfig(@Req() req: AuthenticatedRequest, @Body() dto: CreateLdapConfigDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -92,7 +93,7 @@ export class LdapController {
*/
@Patch('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async updateConfig(@Req() req: any, @Body() dto: UpdateLdapConfigDto) {
async updateConfig(@Req() req: AuthenticatedRequest, @Body() dto: UpdateLdapConfigDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -123,7 +124,7 @@ export class LdapController {
*/
@Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: any, @Body() dto: TestConnectionDto) {
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: TestConnectionDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -158,7 +159,7 @@ export class LdapController {
*/
@Get('groups')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listGroups(@Req() req: any) {
async listGroups(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -189,7 +190,7 @@ export class LdapController {
*/
@Post('groups/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async importGroups(@Req() req: any, @Body() dto: ImportGroupsDto) {
async importGroups(@Req() req: AuthenticatedRequest, @Body() dto: ImportGroupsDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -225,7 +226,7 @@ export class LdapController {
*/
@Get('users/search')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async searchUsers(@Req() req: any, @Query('q') q: string) {
async searchUsers(@Req() req: AuthenticatedRequest, @Query('q') q: string) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -256,7 +257,7 @@ export class LdapController {
*/
@Post('users/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async importUsers(@Req() req: any, @Body() dto: ImportUsersDto) {
async importUsers(@Req() req: AuthenticatedRequest, @Body() dto: ImportUsersDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -292,7 +293,7 @@ export class LdapController {
*/
@Post('sync')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async triggerSync(@Req() req: any) {
async triggerSync(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -326,7 +327,7 @@ export class LdapController {
*/
@Post('config/mappings')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async addFieldMapping(@Req() req: any, @Body() dto: CreateFieldMappingDto) {
async addFieldMapping(@Req() req: AuthenticatedRequest, @Body() dto: CreateFieldMappingDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
@@ -351,7 +352,7 @@ export class LdapController {
*/
@Delete('config/mappings/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async removeFieldMapping(@Req() req: any, @Param('id') id: string) {
async removeFieldMapping(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
+17 -16
View File
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Client, EqualityFilter, Entry } from 'ldapts';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode, prismaErrorTarget } from '../prisma/prisma-error';
import { GroupsService } from '../groups/groups.service';
import { UserService } from '../user/user.service';
@@ -298,7 +299,7 @@ export class LdapService {
// Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die
// "bereits importiert"-Markierung darf nur die Gruppen DIESES Mandanten
// sehen.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
try {
await this.bind(client, config.bindDn, config.bindPassword);
@@ -471,7 +472,7 @@ export class LdapService {
// Mandantengescopter Identitaets-/Schreibpfad (WINDOWS #20 Etappe 2,
// 260909-ipc). Nicht zu verwechseln mit resolveEmailForWrite() oben, die
// bewusst ungebunden bleibt.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const existingByDn = await tenantPrisma.user.findFirst({
where: { ldapDn: dn, tenantId },
});
@@ -566,7 +567,7 @@ export class LdapService {
// Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die
// "bereits importiert"-Markierung darf nur die Konten DIESES Mandanten
// sehen.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const first = (v: unknown): string =>
Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : '';
@@ -663,7 +664,7 @@ export class LdapService {
);
// Mandantengescopter Dedup-/Schreibpfad (WINDOWS #20 Etappe 2,
// 260909-ipc).
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
try {
await this.bind(client, config.bindDn, config.bindPassword);
@@ -791,7 +792,7 @@ export class LdapService {
);
// Mandantengescopter Schreibpfad (T-16-02): app.current_tenant wird vor
// jedem group.create() gesetzt, RLS ist das zweite Netz.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
try {
await this.bind(client, config.bindDn, config.bindPassword);
@@ -842,9 +843,9 @@ export class LdapService {
data: { tenantId, name, ldapDn: entry.dn, ldapObjectGuid },
});
result.imported++;
} catch (createError: any) {
if (createError?.code === 'P2002') {
const target = createError?.meta?.target;
} catch (createError: unknown) {
if (prismaErrorCode(createError) === 'P2002') {
const target = prismaErrorTarget(createError);
const targetsGuid = Array.isArray(target)
? target.includes('ldapObjectGuid')
: String(target ?? '').includes('ldapObjectGuid');
@@ -934,7 +935,7 @@ export class LdapService {
);
// Create tenant-scoped Prisma client per Pitfall 2
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
try {
// 1. Bind with service account (anonymous when not configured)
@@ -1208,7 +1209,7 @@ export class LdapService {
tenantId: string,
result: LdapSyncResult,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const boundGroups: { id: string; name: string; ldapDn: string | null }[] =
await tenantPrisma.group.findMany({
@@ -1371,7 +1372,7 @@ export class LdapService {
tenantId: string,
result: LdapSyncResult,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const candidates: {
id: string;
@@ -1526,8 +1527,8 @@ export class LdapService {
data: { name, ldapDn: dn },
});
result.groupsRenamed++;
} catch (updateError: any) {
if (updateError?.code === 'P2002') {
} catch (updateError: unknown) {
if (prismaErrorCode(updateError) === 'P2002') {
// WR-02 (16-REVIEW.md): this update() writes BOTH name and
// ldapDn in one call — @@unique([tenantId, name]) AND
// @@unique([tenantId, ldapDn]) are both potential triggers
@@ -1535,7 +1536,7 @@ export class LdapService {
// collision when updateError.meta.target says so. Mirrors
// the discrimination importGroupsByDn() already does above
// for its own create() call.
const target = updateError?.meta?.target;
const target = prismaErrorTarget(updateError);
const targetsName = Array.isArray(target)
? target.includes('name')
: String(target ?? '').includes('name');
@@ -1591,8 +1592,8 @@ export class LdapService {
await tenantPrisma.group.delete({ where: { id: group.id } });
result.groupsDeleted++;
anyDeleted = true;
} catch (deleteError: any) {
if (deleteError?.code !== 'P2025') {
} catch (deleteError: unknown) {
if (prismaErrorCode(deleteError) !== 'P2025') {
throw deleteError;
}
// Already gone (e.g. a concurrent manual delete) — not
+10 -2
View File
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { SettingsService } from '../settings/settings.service';
/**
@@ -69,7 +70,14 @@ export type BugReportMail = Pick<OutgoingMail, 'subject' | 'text' | 'attachments
interface ResolvedTransport {
source: 'tenant' | 'env';
options: nodemailer.TransportOptions & Record<string, unknown>;
/**
* SMTPTransport.Options statt TransportOptions & Record<string, unknown>:
* beide Zweige von resolveTransport() bauen reine SMTP-Optionen (host,
* port, secure, requireTLS, auth) — und genau das nimmt createTransport()
* ohne Zusicherung entgegen. Die bisherige Kombination war zu weit und
* brauchte deshalb ein `as any` an der Uebergabe.
*/
options: SMTPTransport.Options;
from: string;
}
@@ -162,7 +170,7 @@ export class MailService {
let transport: nodemailer.Transporter | null = null;
try {
const resolved = await this.resolveTransport(tenantId);
transport = nodemailer.createTransport(resolved.options as any);
transport = nodemailer.createTransport(resolved.options);
await transport.sendMail({
from: resolved.from,
to: mail.to,
@@ -54,7 +54,7 @@ export class ModuleAccessService {
// bestehen: der Schalter ist weiterhin aus (#18), die Datenbankregel
// wirkt heute nicht, und die Anwendungspruefung ist bis zum
// Scharfschalten der einzige tatsaechliche Schutz.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
if (role === 'ADMIN' || role === 'SUPER_ADMIN') {
const activations = await tenantPrisma.tenantModuleActivation.findMany({
@@ -142,7 +142,7 @@ export class ModuleAccessService {
// erzeugt ihren EIGENEN Klienten (dieselbe Konvention wie
// `module-grants.service.ts`: gebundene Klienten werden nicht zwischen
// Methoden weitergereicht). Beide laufen wie bisher nebenlaeufig.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const [activations, accessibleIds] = await Promise.all([
tenantPrisma.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
@@ -8,7 +8,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleAccessService } from './module-access.service';
@@ -53,10 +53,10 @@ export class ModuleRegistryController {
* catalog (mandantenweite Sicht, kein Benutzerfilter).
*/
@Get('active')
async findActive(@Req() req: Request) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const userId = (req as any).user?.id;
const role = (req as any).user?.role;
async findActive(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId ?? req.user?.tenantId;
const userId = req.user?.id;
const role = req.user?.role;
if (!tenantId || !userId || !role) {
throw new ForbiddenException('No user context');
}
@@ -74,10 +74,10 @@ export class ModuleRegistryController {
* Erreichbar für jeden authentifizierten Benutzer, wie GET /modules.
*/
@Get('catalog')
async findCatalog(@Req() req: Request) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const userId = (req as any).user?.id;
const role = (req as any).user?.role;
async findCatalog(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId ?? req.user?.tenantId;
const userId = req.user?.id;
const role = req.user?.role;
if (!tenantId || !userId || !role) {
throw new ForbiddenException('No user context');
}
@@ -105,9 +105,9 @@ export class ModuleRegistryController {
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async activate(
@Param('moduleId') moduleId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
@@ -124,9 +124,9 @@ export class ModuleRegistryController {
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async deactivate(
@Param('moduleId') moduleId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
@@ -47,7 +47,7 @@ export class ModuleRegistryService {
* Returns all active modules for a given tenant.
*/
async findActiveForTenant(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const activations = await tenantPrisma.tenantModuleActivation.findMany({
where: {
tenantId,
@@ -77,7 +77,7 @@ export class ModuleRegistryService {
throw new NotFoundException(`Module with id '${moduleId}' not found`);
}
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.tenantModuleActivation.upsert({
where: {
tenantId_moduleId: {
@@ -117,7 +117,7 @@ export class ModuleRegistryService {
// EIN gebundener Klient fuer beide Aktivierungszugriffe dieser Methode
// (Lesen, Schreiben) — nicht ein Klient je Zugriff (260910-exd,
// Aufgabe 3, dieselbe Konvention wie `module-access.service.ts`).
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
// Check if activation record exists
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
@@ -172,7 +172,7 @@ export class ModuleRegistryService {
return false;
}
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
where: {
tenantId_moduleId: {
+48
View File
@@ -0,0 +1,48 @@
/**
* Eingrenzung gefangener Fehlerwerte auf die Form, die der Bestandscode an
* Prisma-Fehlern tatsaechlich liest — `code` und `meta.target`, sonst nichts.
*
* Warum kein `instanceof Prisma.PrismaClientKnownRequestError`:
* Der Bestand prueft ueberall `err?.code === 'P2002'` und trifft damit JEDES
* Objekt, das ein `code`-Feld traegt. Genau darauf bauen die Testdoppel in
* `apps/api` auf — sie werfen `const err = new Error(...); err.code = 'P2002'`
* (groups, user, ldap, tenders, module-grants, admin-seed) und an einer Stelle
* ein reines `{ code: 'P2002' }` (dashboard.service.spec.ts:451). Ein
* `instanceof`-Test wuerde alle diese Werte in den jeweils ANDEREN Zweig
* schicken. Das waere eine Verhaltensaenderung und ist deshalb verboten
* (Plan 260921-m34, D-03 und T-M34-06).
*
* Diese Funktionen sind daher bewusst Form-Pruefungen ("duck typing") und
* keine Klassenpruefungen: sie bilden `err?.code` bzw. `err?.meta?.target`
* eins zu eins ab, nur eben ohne `any`.
*/
/**
* Liefert den Prisma-Fehlercode (z.B. `P2002`, `P2025`), wenn der gefangene
* Wert ein Objekt mit einem Zeichenketten-Feld `code` ist — sonst `undefined`.
*
* Ein Feld `code`, das keine Zeichenkette ist, liefert `undefined`. Auch das
* ist verhaltensgleich: ein Vergleich `err.code === 'P2002'` waere dort
* ohnehin nie wahr geworden.
*/
export function prismaErrorCode(err: unknown): string | undefined {
if (typeof err !== 'object' || err === null) return undefined;
const code = (err as { code?: unknown }).code;
return typeof code === 'string' ? code : undefined;
}
/**
* Liefert `meta.target` eines Prisma-Fehlers als `unknown`.
*
* Bewusst `unknown` und nicht `string[]`: Prisma liefert hier je nach
* Fehlerart ein Feld-Array ODER den Indexnamen als Zeichenkette, und der
* Bestand in `ldap.service.ts` behandelt genau diese beiden Faelle bereits
* getrennt (`Array.isArray(target) ? ... : String(target ?? '')`). Ein
* engerer Rueckgabetyp waere hier eine Behauptung statt einer Messung.
*/
export function prismaErrorTarget(err: unknown): unknown {
if (typeof err !== 'object' || err === null) return undefined;
const meta = (err as { meta?: unknown }).meta;
if (typeof meta !== 'object' || meta === null) return undefined;
return (meta as { target?: unknown }).target;
}
+15 -13
View File
@@ -197,13 +197,11 @@ import { PrismaClient } from '@prisma/client';
export function forTenant(prisma: PrismaClient, tenantId: string, userId?: string) {
return prisma.$extends({
query: {
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
const setContext = (prisma as any)
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`;
$allOperations({ args, query }) {
const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`;
return (prisma as any)
.$transaction([setContext, query(args)])
.then((results: any[]) => results[1]);
return prisma.$transaction([setContext, query(args)])
.then((results: unknown[]) => results[1]);
},
},
});
@@ -226,13 +224,11 @@ export function forTenant(prisma: PrismaClient, tenantId: string, userId?: strin
export function forSystem(prisma: PrismaClient) {
return prisma.$extends({
query: {
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
const setContext = (prisma as any)
.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`;
$allOperations({ args, query }) {
const setContext = prisma.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`;
return (prisma as any)
.$transaction([setContext, query(args)])
.then((results: any[]) => results[1]);
return prisma.$transaction([setContext, query(args)])
.then((results: unknown[]) => results[1]);
},
},
});
@@ -259,9 +255,15 @@ export function forSystem(prisma: PrismaClient) {
export function withTenantTransaction<T>(
prisma: PrismaClient,
tenantId: string,
// tx bleibt `any` (gemessen, Aufgabe 1 260921-m34): `Prisma.TransactionClient`
// erzwingt an den vier Aufrufstellen (groups.service.ts, favorites.service.ts)
// vollstaendige Prisma-Erzeugungstypen und bricht deren Testdoppel in
// prisma-tenant.extension.spec.ts (TS2322 auf einem absichtlich unvollstaendigen
// Fake-Objekt). Das waere eine Verhaltensaenderung an einer Teststruktur,
// nicht ehrliches Typisieren (D-02/D-03) - bleibt.
fn: (tx: any) => Promise<T>,
): Promise<T> {
return (prisma as any).$transaction(async (tx: any) => {
return prisma.$transaction(async (tx: any) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.system_context', '', true)`;
return fn(tx);
});
+7 -6
View File
@@ -9,6 +9,7 @@ import {
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { SmtpConfigDto } from './dto/smtp-config.dto';
import { SettingsService } from './settings.service';
@@ -29,8 +30,8 @@ export class SettingsController {
*/
@Get('smtp')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getSmtpConfig(@Req() req: any) {
const tenantId = req.tenantId as string | undefined;
async getSmtpConfig(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
@@ -50,8 +51,8 @@ export class SettingsController {
*/
@Put('smtp')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId as string | undefined;
async saveSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
@@ -67,8 +68,8 @@ export class SettingsController {
*/
@Post('smtp/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId as string | undefined;
async testSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
+4 -4
View File
@@ -41,7 +41,7 @@ export class SettingsService {
* Methode, wie die restlichen Anfragewege dieser Datei.
*/
async getSmtpConfig(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.smtpConfig.findUnique({
where: { tenantId },
select: {
@@ -61,7 +61,7 @@ export class SettingsService {
* Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma`.
*/
async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
// Determine the encrypted password to store
let encryptedPassword: string | undefined;
@@ -104,7 +104,7 @@ export class SettingsService {
* schmalem `select` — das verschluesselte Kennwort wird hier nie geladen.
*/
async getBugReportRecipient(tenantId: string): Promise<string | null> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.smtpConfig.findUnique({
where: { tenantId },
select: { bugReportRecipient: true },
@@ -134,7 +134,7 @@ export class SettingsService {
fromAddress: string;
decryptedPassword: string | null;
} | null> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const config = await tenantPrisma.smtpConfig.findUnique({
where: { tenantId },
});
+11 -5
View File
@@ -10,7 +10,7 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Role, type Tenant } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension';
@@ -66,9 +66,15 @@ export class TenantController {
orderBy: { name: 'asc' },
});
const results: any[] = [];
// Elementtyp aus dem hergeleitet, was die Schleife unten tatsaechlich
// hineinlegt: die fuenf uebernommenen Tenant-Spalten plus die gezaehlte
// Benutzerzahl. Kein erfundenes Feld.
const results: (Pick<
Tenant,
'id' | 'name' | 'slug' | 'isActive' | 'createdAt'
> & { userCount: number })[] = [];
for (const tenant of tenants) {
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
const tenantPrisma = forTenant(this.prisma, tenant.id);
const userCount = await tenantPrisma.user.count({
where: { tenantId: tenant.id },
});
@@ -99,7 +105,7 @@ export class TenantController {
throw new NotFoundException('Tenant not found');
}
const tenantPrisma = forTenant(this.prisma, id) as any;
const tenantPrisma = forTenant(this.prisma, id);
const userCount = await tenantPrisma.user.count({
where: { tenantId: id },
});
@@ -160,7 +166,7 @@ export class TenantController {
throw new NotFoundException('Tenant not found');
}
const tenantPrisma = forTenant(this.prisma, id) as any;
const tenantPrisma = forTenant(this.prisma, id);
const activeUserCount = await tenantPrisma.user.count({
where: { tenantId: id, isActive: true },
});
@@ -83,6 +83,14 @@ export class TenderDigestScheduler implements OnModuleInit {
// type signature. At runtime the object IS a full CronJob —
// SchedulerRegistry only calls stop() on it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die
// Zusicherung meldet tsc, dass das lokale `job` nur die Form
// `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen
// CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm-
// Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das
// aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine
// Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue
// Abhaengigkeit. Beides ist hier verboten (D-03/D-04).
this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
job.start();
@@ -121,7 +129,7 @@ export class TenderDigestScheduler implements OnModuleInit {
// `distinct(['userId'])` liefert dann nur EINE der moeglichen
// tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge
// abhaengig. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md.
const systemPrisma = forSystem(this.prisma) as any;
const systemPrisma = forSystem(this.prisma);
const candidates: { userId: string; tenantId: string }[] = await systemPrisma.tenderMatch.findMany({
where: { notifiedAt: null },
select: { userId: true, tenantId: true },
@@ -145,7 +153,7 @@ export class TenderDigestScheduler implements OnModuleInit {
// `userId` sieht dieser Zugriff den ganzen Mandanten, exakt wie vor
// der Migration. Ein Systemkontext fuer Hintergrunddienste ist
// Etappe 3c, nicht Teil dieser Aenderung.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
const pref = await tenantPrisma.tenderNotificationPref.findUnique({
where: { userId },
@@ -6,6 +6,7 @@ import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interfa
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
/**
@@ -102,7 +103,7 @@ export class TenderEmailConfigService {
* by userId (T-17-01) — a user only ever reads their own mailbox.
*/
async getConfigForApi(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const safe = await tenantPrisma.tenderEmailConfig.findUnique({
where: { userId },
select: EMAIL_CONFIG_SAFE_SELECT,
@@ -152,7 +153,7 @@ export class TenderEmailConfigService {
*/
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
const { userId, tenantId } = ctx;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
let encryptedInboxCreds: string | undefined;
const credChanged =
@@ -204,8 +205,8 @@ export class TenderEmailConfigService {
update: { tenantId, ...data },
select: EMAIL_CONFIG_SAFE_SELECT,
});
} catch (error: any) {
if (error?.code === 'P2002') {
} catch (error: unknown) {
if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException(
'Die Postfach-Konfiguration konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
);
@@ -240,7 +241,7 @@ export class TenderEmailConfigService {
if (!username || !password) {
try {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
@@ -72,7 +72,7 @@ export class TenderMatchingService {
// und wuerde stumm. Treffer-Anlage und Sofortmeldung bleiben je Profil
// GEBUNDEN (unten); der Katalog-Lesezugriff (`tender`, D-03) bleibt
// ungebunden. Eine LEERE Profilliste ist Nichtstun (keine Treffer).
const systemPrisma = forSystem(this.prisma) as any;
const systemPrisma = forSystem(this.prisma);
const savedSearches: Prisma.TenderSavedSearchGetPayload<Record<string, never>>[] =
await systemPrisma.tenderSavedSearch.findMany();
@@ -95,7 +95,7 @@ export class TenderMatchingService {
// Gebunden an den Mandanten DIESES Profils (260909-laa, Aufgabe 3)
// — EIN gebundener Client je Profil, nicht je Treffer, sonst
// entstuende pro Zeile eine eigene Transaktion.
const tenantPrisma = forTenant(this.prisma, search.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, search.tenantId);
for (const hit of hits) {
await tenantPrisma.tenderMatch.upsert({
@@ -134,7 +134,7 @@ export class TenderMatchingService {
try {
// Gebunden an den Mandanten DIESES Profils (260909-laa, Aufgabe 3)
// — EIN gebundener Client je Profil.
const tenantPrisma = forTenant(this.prisma, profile.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, profile.tenantId);
const fresh = await tenantPrisma.tenderMatch.findMany({
where: {
@@ -156,7 +156,7 @@ export class TenderMatchingService {
{ email: user.email },
profile.tenantId,
{ name: profile.name },
fresh.map((match: { tender: unknown }) => match.tender),
fresh.map((match) => match.tender),
);
if (sent) {
@@ -1,6 +1,7 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
/**
* Service for managing the per-user Tender digest interval preference
@@ -45,7 +46,7 @@ export class TenderNotificationPrefService {
* autowrite needed to represent "using the default".
*/
async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.tenderNotificationPref.findUnique({
where: { userId },
});
@@ -63,15 +64,15 @@ export class TenderNotificationPrefService {
* than creating a new one.
*/
async setForUser(userId: string, tenantId: string, digestInterval: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
try {
return await tenantPrisma.tenderNotificationPref.upsert({
where: { userId },
create: { userId, tenantId, digestInterval },
update: { digestInterval },
});
} catch (error: any) {
if (error?.code === 'P2002') {
} catch (error: unknown) {
if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException(
'Die Benachrichtigungseinstellung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
);
+1 -1
View File
@@ -44,7 +44,7 @@ const MAX_FAV_IDS = 500;
export function buildTenderWhere(
dto: TenderQueryDto,
favIds?: string[],
ownerTenantId?: string,
ownerTenantId?: string | null,
): Prisma.TenderWhereInput {
const where: Prisma.TenderWhereInput = {};
const AND: Prisma.TenderWhereInput[] = [];
@@ -69,7 +69,7 @@ export class TenderRssFeedSourceService {
* Bindung nicht überflüssig, sondern das zweite Netz.
*/
async listForUser(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.tenderRssFeedSource.findMany({
where: { OR: [{ userId: null }, { userId }] },
orderBy: { createdAt: 'asc' },
@@ -93,7 +93,7 @@ export class TenderRssFeedSourceService {
) {
this.assertUrlAllowed(dto.url);
const tenantPrisma = forTenant(this.prisma, ctx.tenantId, ctx.userId) as any;
const tenantPrisma = forTenant(this.prisma, ctx.tenantId, ctx.userId);
const existingCount = await tenantPrisma.tenderRssFeedSource.count({
where: { userId: ctx.userId },
});
@@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
/**
@@ -44,7 +45,7 @@ export class TenderSavedSearchService {
* strictly by userId (V4/IDOR) — a foreign userId sees nothing.
*/
async list(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.tenderSavedSearch.findMany({
where: { userId },
orderBy: { name: 'asc' },
@@ -58,7 +59,7 @@ export class TenderSavedSearchService {
* users, since the uniqueness is scoped per-user.
*/
async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
try {
return await tenantPrisma.tenderSavedSearch.create({
data: {
@@ -69,8 +70,8 @@ export class TenderSavedSearchService {
...(dto.instantAlert !== undefined && { instantAlert: dto.instantAlert }),
},
});
} catch (error: any) {
if (error?.code === 'P2002') {
} catch (error: unknown) {
if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException(
'Ein Suchprofil mit diesem Namen existiert bereits.',
);
@@ -87,7 +88,7 @@ export class TenderSavedSearchService {
* leaking whether another user's profile exists).
*/
async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
where: { id },
});
@@ -108,8 +109,8 @@ export class TenderSavedSearchService {
where: { id },
data,
});
} catch (error: any) {
if (error?.code === 'P2002') {
} catch (error: unknown) {
if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException(
'Ein Suchprofil mit diesem Namen existiert bereits.',
);
@@ -124,7 +125,7 @@ export class TenderSavedSearchService {
* update().
*/
async remove(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
where: { id },
});
@@ -132,6 +132,14 @@ export class TenderSchedulerService implements OnApplicationBootstrap {
// Cast required: our minimal CronJob type doesn't match cron's full type signature.
// At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die
// Zusicherung meldet tsc, dass das lokale `job` nur die Form
// `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen
// CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm-
// Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das
// aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine
// Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue
// Abhaengigkeit. Beides ist hier verboten (D-03/D-04).
this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
job.start();
@@ -1,6 +1,7 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
/**
* Partial triage update accepted by setTriage(). Both fields are optional
@@ -75,7 +76,7 @@ export class TenderTriageService {
update.favoritedAt = dto.isFavorite ? now : null;
}
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
try {
return await tenantPrisma.tenderTriage.upsert({
where: { userId_tenderId: { userId, tenderId } },
@@ -90,8 +91,8 @@ export class TenderTriageService {
favoritedAt: dto.isFavorite ? now : null,
},
});
} catch (error: any) {
if (error?.code === 'P2002') {
} catch (error: unknown) {
if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException(
'Der Bearbeitungsstand zu dieser Ausschreibung konnte nicht gespeichert werden. Bitte die Seite neu laden und es erneut versuchen.',
);
@@ -111,7 +112,7 @@ export class TenderTriageService {
*/
async listForUser(userId: string, tenantId: string, tenderIds: string[]) {
if (!tenderIds.length) return [];
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.tenderTriage.findMany({
where: { userId, tenderId: { in: tenderIds } },
});
@@ -123,7 +124,7 @@ export class TenderTriageService {
* tender-query.builder.ts's buildTenderWhere.
*/
async favoriteIds(userId: string, tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const rows = await tenantPrisma.tenderTriage.findMany({
where: { userId, isFavorite: true },
select: { tenderId: true },
+27 -25
View File
@@ -13,7 +13,7 @@ import {
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { UseModule } from '../module-registry/module.guard';
import { PrismaService } from '../prisma/prisma.service';
@@ -115,10 +115,10 @@ export class TendersController {
* (`roles.guard.ts`) — one single spot in this controller resolves
* account data from the request.
*/
private extractTriageContext(req: Request) {
const userId = (req as any).user?.id;
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const role = (req as any).user?.role;
private extractTriageContext(req: AuthenticatedRequest) {
const userId = req.user?.id;
const tenantId = req.tenantId ?? req.user?.tenantId;
const role = req.user?.role;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
@@ -138,8 +138,10 @@ export class TendersController {
* only" (fail-closed, tender-query.builder.ts) rather than 403 when no
* tenant context is present.
*/
private resolveRequestingTenantId(req?: Request): string | undefined {
return (req as any)?.user?.tenantId ?? (req as any)?.tenantId;
private resolveRequestingTenantId(
req?: AuthenticatedRequest,
): string | null | undefined {
return req?.user?.tenantId ?? req?.tenantId;
}
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
@@ -164,7 +166,7 @@ export class TendersController {
*/
@Get()
@UseModule('tender-radar')
async listTenders(@Query() query: TenderQueryDto, @Req() req?: Request) {
async listTenders(@Query() query: TenderQueryDto, @Req() req?: AuthenticatedRequest) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
@@ -175,7 +177,7 @@ export class TendersController {
// optional type only accommodates unit tests that call this method
// directly without favOnly set (T-11-10: extractTriageContext
// throws ForbiddenException if req/user context is genuinely absent).
const { userId, tenantId } = this.extractTriageContext(req as Request);
const { userId, tenantId } = this.extractTriageContext(req as AuthenticatedRequest);
favIds = await this.tenderTriage.favoriteIds(userId, tenantId);
}
@@ -263,11 +265,11 @@ export class TendersController {
*/
@Get('rss-feeds')
@UseModule('tender-radar')
async listRssFeeds(@Req() req: Request) {
async listRssFeeds(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
const feeds = await this.tenderRssFeedSource.listForUser(userId, tenantId);
return feeds.map(({ userId: ownerUserId, ...rest }: any) => ({
return feeds.map(({ userId: ownerUserId, ...rest }) => ({
...rest,
isPlatformWide: ownerUserId === null,
}));
@@ -286,7 +288,7 @@ export class TendersController {
*/
@Post('rss-feeds')
@UseModule('tender-radar')
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: Request) {
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId, role } = this.extractTriageContext(req);
if (dto.scope === 'platform') {
@@ -319,7 +321,7 @@ export class TendersController {
*/
@Delete('rss-feeds/:feedId')
@UseModule('tender-radar')
async removeRssFeed(@Param('feedId') feedId: string, @Req() req: Request) {
async removeRssFeed(@Param('feedId') feedId: string, @Req() req: AuthenticatedRequest) {
const { userId, role } = this.extractTriageContext(req);
const isAdmin = role === Role.ADMIN || role === Role.SUPER_ADMIN;
return this.tenderRssFeedSource.remove(feedId, { userId, isAdmin });
@@ -342,7 +344,7 @@ export class TendersController {
*/
@Get('email-config')
@UseModule('tender-radar')
async getEmailConfig(@Req() req: Request) {
async getEmailConfig(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.getConfigForApi(userId, tenantId);
}
@@ -356,7 +358,7 @@ export class TendersController {
*/
@Put('email-config')
@UseModule('tender-radar')
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto);
}
@@ -381,7 +383,7 @@ export class TendersController {
*/
@Post('email-config/test')
@UseModule('tender-radar')
async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.testConnection(userId, tenantId, dto);
}
@@ -456,7 +458,7 @@ export class TendersController {
*/
@Get('triage')
@UseModule('tender-radar')
async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) {
async listTriage(@Query('ids') ids: string | undefined, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
const tenderIds = (ids ?? '')
.split(',')
@@ -480,7 +482,7 @@ export class TendersController {
*/
@Put('triage')
@UseModule('tender-radar')
async setTriage(@Body() dto: TenderTriageDto, @Req() req: Request) {
async setTriage(@Body() dto: TenderTriageDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderTriage.setTriage(userId, tenantId, dto.tenderId, {
@@ -502,7 +504,7 @@ export class TendersController {
*/
@Get('saved-searches')
@UseModule('tender-radar')
async listSavedSearches(@Req() req: Request) {
async listSavedSearches(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.list(userId, tenantId);
}
@@ -517,7 +519,7 @@ export class TendersController {
@UseModule('tender-radar')
async createSavedSearch(
@Body() dto: CreateSavedSearchDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.create(userId, tenantId, dto);
@@ -535,7 +537,7 @@ export class TendersController {
async updateSavedSearch(
@Param('searchId') searchId: string,
@Body() dto: UpdateSavedSearchDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.update(searchId, userId, tenantId, dto);
@@ -550,7 +552,7 @@ export class TendersController {
@UseModule('tender-radar')
async removeSavedSearch(
@Param('searchId') searchId: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractTriageContext(req);
await this.tenderSavedSearch.remove(searchId, userId, tenantId);
@@ -571,7 +573,7 @@ export class TendersController {
*/
@Get('notification-pref')
@UseModule('tender-radar')
async getNotificationPref(@Req() req: Request) {
async getNotificationPref(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderNotificationPref.getForUser(userId, tenantId);
}
@@ -586,7 +588,7 @@ export class TendersController {
@UseModule('tender-radar')
async setNotificationPref(
@Body() dto: UpdateNotificationPrefDto,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderNotificationPref.setForUser(userId, tenantId, dto.digestInterval);
@@ -606,7 +608,7 @@ export class TendersController {
*/
@Get(':id')
@UseModule('tender-radar')
async getTender(@Param('id') id: string, @Req() req?: Request) {
async getTender(@Param('id') id: string, @Req() req?: AuthenticatedRequest) {
const tender = await this.prisma.tender.findUnique({
where: { id },
include: {
+4 -3
View File
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { GroupsService } from '../groups/groups.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import { PrismaService } from '../prisma/prisma.service';
/**
@@ -104,7 +105,7 @@ export class AdminSeedService implements OnApplicationBootstrap {
// `user-ungebundenes-einfuegen-abgelehnt`): eine FRISCHE Installation
// haette ihren allerersten Administrator gar nicht anlegen koennen.
const passwordHash = await argon2.hash(password);
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
const tenantPrisma = forTenant(this.prisma, tenant.id);
try {
await tenantPrisma.user.create({
data: {
@@ -117,7 +118,7 @@ export class AdminSeedService implements OnApplicationBootstrap {
isActive: true,
},
});
} catch (err: any) {
} catch (err: unknown) {
// Entschaerfung der Startsperre (260910-das, Befund I): trifft die
// Erstanlage die plattformweite Eindeutigkeit von username/email
// (P2002), bedeutet das an DIESER Stelle exakt dasselbe wie ein
@@ -129,7 +130,7 @@ export class AdminSeedService implements OnApplicationBootstrap {
// festgehaltenen Absicht (seedAdmin() bleibt bewusst ungekapselt) --
// JEDER ANDERE Fehler bricht den Start weiterhin ab. Nur dieser eine,
// an dieser Stelle gleichbedeutende Fall wird ergaenzt.
if (err?.code === 'P2002') {
if (prismaErrorCode(err) === 'P2002') {
this.logger.log(
`Admin user "${username}" seed skipped: uniqueness collision on username/email (an administrator with this identity already exists, currently invisible under this tenant context) — see docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt "Bereich user"`,
);
+21 -16
View File
@@ -153,7 +153,7 @@ describe('UserController', () => {
lastLoginAt: null,
});
const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1' });
const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
expect(result.map((u: any) => u.username)).toEqual(['alice']);
expectBoundCall(prisma, 't1', 'user', 'findMany');
@@ -170,6 +170,8 @@ describe('UserController', () => {
role: Role.SUPER_ADMIN,
tenantId: 't1',
id: 'super1',
username: 'super1',
mustChangePassword: false,
});
expect(result).toBe(expected);
@@ -183,11 +185,11 @@ describe('UserController', () => {
const targetUser = { id: 'u-x', username: 'x', tenantId: 't1' };
userService.findById.mockResolvedValue(targetUser);
await controller.findOne('u-x', { role: Role.ADMIN, tenantId: 't1', id: 'admin1' });
await controller.findOne('u-x', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
userService.findByIdForPlatformAdmin.mockResolvedValue(targetUser);
await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1' });
await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1', username: 'super1', mustChangePassword: false });
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-x');
});
@@ -195,14 +197,14 @@ describe('UserController', () => {
userService.findById.mockResolvedValue(null);
await expect(
controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }),
controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false }),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('create', () => {
it('Test 5: ein Mandanten-Administrator kann weiterhin keine oberste Rolle vergeben, und die Anlage eines Benutzers landet weiterhin im Mandanten des Aufrufers, wenn dieser nicht die oberste Rolle trägt', async () => {
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
await expect(
controller.create(
@@ -224,7 +226,7 @@ describe('UserController', () => {
describe('remove — Selbstlöschriegel (Befund H)', () => {
it('Test 6: der Riegel gegen das Löschen des eigenen Kontos greift', async () => {
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'admin1', tenantId: 't1' });
await expect(controller.remove('admin1', currentUser)).rejects.toBeInstanceOf(
@@ -235,7 +237,7 @@ describe('UserController', () => {
});
describe('Selbstbedienungswege (Befund G)', () => {
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me' };
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me', username: 'me', mustChangePassword: false };
beforeEach(() => {
prisma.__seedUser({
@@ -247,7 +249,10 @@ describe('UserController', () => {
});
it('Test 7: alle fünf Zugriffe der vier Selbstbedienungswege stehen gebunden im Protokoll, mit der Mandantenkennung aus dem Sitzungsnachweis', async () => {
await controller.uploadAvatar({ buffer: Buffer.from('x'), mimetype: 'image/png' }, currentUser);
await controller.uploadAvatar(
{ buffer: Buffer.from('x'), mimetype: 'image/png', originalname: 'x.png', size: 1 },
currentUser,
);
await controller.deleteAvatar(currentUser);
await controller.updateAccentColor({ color: '#ff00aa' }, currentUser);
@@ -280,7 +285,7 @@ describe('UserController', () => {
describe('update/remove — Zielrolle SUPER_ADMIN (WINDOWS #29)', () => {
it('Test 9: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten weder übernehmen (Kennwort setzen), noch aussperren (isActive=false), noch herabstufen (role=USER) — alle drei Angriffsformen werden mit der Zielrollen-Ausnahme abgelehnt, und der Dienst wird in keinem der drei Fälle aufgerufen', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
await expect(
@@ -300,7 +305,7 @@ describe('UserController', () => {
});
it('Test 10: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin ändern — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1' };
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
userService.findByIdForPlatformAdmin.mockResolvedValue({
id: 'boss',
tenantId: 't1',
@@ -324,7 +329,7 @@ describe('UserController', () => {
});
it('Test 11: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin ändern — Regressionsschutz, der Zielrollen-Riegel engt bestehende Wege nicht zusätzlich ein', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
userService.update.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
@@ -338,7 +343,7 @@ describe('UserController', () => {
});
it('Test 12: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — ein Administrator, der (bei einer fehlerhaften Auflösung) ein Ziel eines fremden Mandanten mit der obersten Rolle erhält, bekommt die Mandanten-Meldung, nicht die Zielrollen-Meldung, und erfährt so nichts über die Rolle des fremden Benutzers', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
await expect(
@@ -348,7 +353,7 @@ describe('UserController', () => {
});
it('Test 13: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten nicht löschen — die Zielrollen-Ausnahme greift, und der Dienst wird nicht aufgerufen', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
await expect(controller.remove('boss', admin)).rejects.toThrow(
@@ -358,7 +363,7 @@ describe('UserController', () => {
});
it('Test 14: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin löschen — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1' };
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
userService.findByIdForPlatformAdmin.mockResolvedValue({
id: 'boss',
tenantId: 't1',
@@ -373,7 +378,7 @@ describe('UserController', () => {
});
it('Test 15: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin löschen — Regressionsschutz', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
userService.delete.mockResolvedValue({ message: 'User deleted' });
@@ -383,7 +388,7 @@ describe('UserController', () => {
});
it('Test 16: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — beim Löschen bekommt ein Administrator mit einem fremdmandantigen Ziel der obersten Rolle die Mandanten-Meldung, nicht die Zielrollen-Meldung', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
await expect(controller.remove('boss2', admin)).rejects.toThrow(
+19 -18
View File
@@ -21,6 +21,7 @@ import * as path from 'node:path';
import { Response } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
@@ -65,7 +66,7 @@ export class UserController {
* uebergreifende Sicht von der mandantengebundenen unterscheidet, und sie
* darf nicht eingeebnet werden.
*/
private async resolveTargetUser(currentUser: any, id: string) {
private async resolveTargetUser(currentUser: AuthUser, id: string) {
if (currentUser.role === Role.SUPER_ADMIN) {
return this.userService.findByIdForPlatformAdmin(id);
}
@@ -79,7 +80,7 @@ export class UserController {
*/
@Get()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findAll(@CurrentUser() currentUser: any) {
async findAll(@CurrentUser() currentUser: AuthUser) {
if (currentUser.role === Role.SUPER_ADMIN) {
// Plattform-Administratorsicht (Befund F): die bestehende, gewollte
// Funktion der obersten Rolle bleibt erhalten, laeuft aber ueber die
@@ -92,7 +93,7 @@ export class UserController {
// Mandantenbedingung im where BLEIBT erhalten -- nicht entfernen mit
// dem Argument, das mache jetzt die Datenbank; dieselbe Regel, die die
// Bereiche `tenders` und `dkv` aufgestellt haben.
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
return tenantPrisma.user.findMany({
where: { tenantId: currentUser.tenantId },
select: {
@@ -115,7 +116,7 @@ export class UserController {
*/
@Get(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) {
async findOne(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
throw new NotFoundException('User not found');
@@ -139,7 +140,7 @@ export class UserController {
*/
@Post()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: any) {
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: AuthUser) {
// ADMIN can only create users in own tenant
const tenantId =
currentUser.role === Role.SUPER_ADMIN && dto.tenantId
@@ -173,7 +174,7 @@ export class UserController {
async update(
@Param('id') id: string,
@Body() dto: UpdateUserDto,
@CurrentUser() currentUser: any,
@CurrentUser() currentUser: AuthUser,
) {
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
@@ -228,7 +229,7 @@ export class UserController {
*/
@Delete(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) {
async remove(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
const user = await this.resolveTargetUser(currentUser, id);
if (!user) {
throw new NotFoundException('User not found');
@@ -290,14 +291,14 @@ export class UserController {
FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }),
)
async uploadAvatar(
@UploadedFile() file: any,
@CurrentUser() currentUser: any,
@UploadedFile() file: UploadedFileLike | undefined,
@CurrentUser() currentUser: AuthUser,
) {
if (!file?.buffer) {
throw new BadRequestException('No file provided');
}
const ext = AVATAR_MIME_TO_EXT[file.mimetype as string];
const ext = AVATAR_MIME_TO_EXT[file.mimetype];
if (!ext) {
throw new BadRequestException(
'Invalid file type. Allowed: image/png, image/jpeg, image/webp',
@@ -318,11 +319,11 @@ export class UserController {
}
}
fs.writeFileSync(filePath, file.buffer as Buffer);
fs.writeFileSync(filePath, file.buffer);
// Persist relative path (relative to monorepo root)
const relativePath = path.join('user-files', 'avatars', filename);
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
await tenantPrisma.user.update({
where: { id: currentUser.id },
data: { avatarPath: relativePath },
@@ -336,8 +337,8 @@ export class UserController {
* Remove the current user's profile picture.
*/
@Delete('me/avatar')
async deleteAvatar(@CurrentUser() currentUser: any) {
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
async deleteAvatar(@CurrentUser() currentUser: AuthUser) {
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id },
select: { avatarPath: true },
@@ -366,13 +367,13 @@ export class UserController {
@Patch('me/accent-color')
async updateAccentColor(
@Body() body: { color: string | null },
@CurrentUser() currentUser: any,
@CurrentUser() currentUser: AuthUser,
) {
if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) {
throw new BadRequestException('Invalid color format. Use hex (#rrggbb).');
}
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
await tenantPrisma.user.update({
where: { id: currentUser.id },
data: { accentColor: body.color ?? null },
@@ -388,10 +389,10 @@ export class UserController {
*/
@Get('me/avatar')
async getAvatar(
@CurrentUser() currentUser: any,
@CurrentUser() currentUser: AuthUser,
@Res() res: Response,
) {
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id },
select: { avatarPath: true },
+70 -38
View File
@@ -1,7 +1,9 @@
import { ConflictException, Injectable, Logger } from '@nestjs/common';
import * as argon2 from 'argon2';
import type { User } from '@prisma/client';
import { GroupsService } from '../groups/groups.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import { PrismaService } from '../prisma/prisma.service';
/**
@@ -17,6 +19,51 @@ import { PrismaService } from '../prisma/prisma.service';
* im selben Commit auf die neue Signatur umgestellt (260910-das, Aufgabe 3),
* damit die Typpruefung nach jeder Aufgabe sauber bleibt.
*/
/**
* Spaltenauswahl der plattformweiten Benutzerliste. Als eigene Konstante,
* damit der Elementtyp der Sammelliste unten mit `Prisma.UserGetPayload`
* aus GENAU dieser Auswahl hergeleitet wird — eine zweite Beschreibung
* derselben Felder waere eine Behauptung, die beim naechsten Feld
* auseinanderlaeuft.
*/
const PLATFORM_USER_SELECT = {
id: true,
username: true,
email: true,
displayName: true,
role: true,
isActive: true,
tenantId: true,
createdAt: true,
lastLoginAt: true,
} as const;
/**
* Elementtyp der Sammelliste, aus PLATFORM_USER_SELECT hergeleitet statt
* daneben beschrieben. Bewusst `Pick<User, keyof typeof ...>` und NICHT
* `Prisma.UserGetPayload<{ select: ... }>`: die zweite Form traegt das Wort
* select in eine Typangabe, und der Erkenner in rls-access-inventory.spec.ts
* zaehlt jede select-Angabe ausserhalb eines Modellaufrufs als Verstoss
* (gemessen, 260921-m34 Aufgabe 3b). Der Erkenner ist die Mandantenkontrolle
* und wird nicht fuer eine Typschreibweise aufgeweicht (T-M34-03).
*/
type PlatformUserRow = Pick<User, keyof typeof PLATFORM_USER_SELECT>;
/**
* Felder, die `UserService.update()` entgegennimmt. Als eigener Typ, damit
* das intern zusammengebaute `updateData` unten daraus abgeleitet werden
* kann statt daneben noch einmal von Hand beschrieben zu werden.
*/
interface UpdateUserInput {
username?: string;
email?: string;
password?: string;
displayName?: string;
role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
isActive?: boolean;
mustChangePassword?: boolean;
}
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
@@ -61,7 +108,7 @@ export class UserService {
* (Aufgabe 1, `user-gebunden-nur-eigener-mandant`).
*/
async findById(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.user.findUnique({ where: { id } });
}
@@ -105,9 +152,12 @@ export class UserService {
ldapDn?: string;
}) {
const { password, ...rest } = data;
const tenantPrisma = forTenant(this.prisma, data.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, data.tenantId);
let created: any;
// Der Rueckgabewert von user.create() ist das vollstaendige User-Modell.
// Die Zuweisung steht im try, der catch endet ausnahmslos mit throw —
// nach dem Block ist `created` deshalb belegt, ohne Behauptung.
let created: User;
try {
created = await tenantPrisma.user.create({
data: {
@@ -116,8 +166,8 @@ export class UserService {
passwordHash: password ? await argon2.hash(password) : null,
},
});
} catch (err: any) {
if (err?.code === 'P2002') {
} catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') {
throw new ConflictException(
'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.',
);
@@ -144,21 +194,13 @@ export class UserService {
* weil auch ein Namens- oder Adresswechsel auf denselben plattformweiten
* Schluessel treffen kann.
*/
async update(
tenantId: string,
id: string,
data: {
username?: string;
email?: string;
password?: string;
displayName?: string;
role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
isActive?: boolean;
mustChangePassword?: boolean;
},
) {
async update(tenantId: string, id: string, data: UpdateUserInput) {
const { password, ...rest } = data;
const updateData: any = { ...rest };
// Aus der Signatur hergeleitet: alles ausser `password`, dafuer der
// daraus berechnete `passwordHash`. Nichts erfunden, nichts weggelassen.
const updateData: Omit<UpdateUserInput, 'password'> & {
passwordHash?: string;
} = { ...rest };
if (updateData.username) {
updateData.username = updateData.username.toLowerCase();
@@ -168,14 +210,14 @@ export class UserService {
updateData.passwordHash = await argon2.hash(password);
}
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
try {
return await tenantPrisma.user.update({
where: { id },
data: updateData,
});
} catch (err: any) {
if (err?.code === 'P2002') {
} catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') {
throw new ConflictException(
'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.',
);
@@ -189,7 +231,7 @@ export class UserService {
* Mandanten.
*/
async deactivate(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.user.update({
where: { id },
data: { isActive: false },
@@ -200,7 +242,7 @@ export class UserService {
* Hard delete a user, gebunden an den uebergebenen Mandanten.
*/
async delete(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.user.delete({ where: { id } });
}
@@ -231,22 +273,12 @@ export class UserService {
async findAllForPlatformAdmin() {
const tenants = await this.prisma.tenant.findMany({ select: { id: true } });
const results: any[] = [];
const results: PlatformUserRow[] = [];
for (const tenant of tenants) {
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
const tenantPrisma = forTenant(this.prisma, tenant.id);
const users = await tenantPrisma.user.findMany({
where: { tenantId: tenant.id },
select: {
id: true,
username: true,
email: true,
displayName: true,
role: true,
isActive: true,
tenantId: true,
createdAt: true,
lastLoginAt: true,
},
select: PLATFORM_USER_SELECT,
});
results.push(...users);
}
@@ -265,7 +297,7 @@ export class UserService {
const tenants = await this.prisma.tenant.findMany({ select: { id: true } });
for (const tenant of tenants) {
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
const tenantPrisma = forTenant(this.prisma, tenant.id);
const user = await tenantPrisma.user.findUnique({ where: { id } });
if (user) {
return user;
+1 -1
View File
@@ -5,7 +5,7 @@ import * as matchers from '@testing-library/jest-dom/matchers';
// Using the explicit extend approach (not @testing-library/jest-dom/vitest) for
// compatibility with vitest@4.x (the /vitest barrel doesn't extend the same
// expect instance in vitest 4's module resolution).
expect.extend(matchers as any);
expect.extend(matchers);
// Type augmentation for the custom matchers (adds toBeInTheDocument, etc.)
// This import only carries type declarations — no runtime effect.