# Tessera — Anleitung für Entwickler Diese Anleitung richtet sich an Entwicklerinnen und Entwickler, die neu zu Tessera stoßen. Sie kennen TypeScript, aber nichts von diesem Repository. Ziel ist, Sie von einem frischen Checkout bis zu einem eigenen, lauffähigen Modul zu bringen — alle Angaben sind aus dem tatsächlichen Code geprüft, nicht aus einer geplanten Architektur abgeleitet. ## Inhaltsverzeichnis 1. [Aufbau des Monorepos](#aufbau-des-monorepos) 2. [Lokale Entwicklungsumgebung](#lokale-entwicklungsumgebung) 3. [Architektur im Überblick](#architektur-im-überblick) 4. [Das Modulsystem](#das-modulsystem) 5. [Mandantentrennung](#mandantentrennung) 6. [Berechtigungen](#berechtigungen) 7. [Datenbank und Migrationen](#datenbank-und-migrationen) 8. [Tests](#tests) 9. [Sicherheitsprüfungen](#sicherheitsprüfungen) 10. [Konventionen und Fallstricke](#konventionen-und-fallstricke) --- ## Aufbau des Monorepos Tessera ist ein pnpm-Workspace (`pnpm-workspace.yaml`), orchestriert über Turborepo (`turbo.json`). Der Paketmanager ist mit `packageManager: "pnpm@9.15.0"` in der Root-`package.json` fest verankert. ``` apps/ api/ @tessera/api — NestJS-Backend web/ @tessera/web — Next.js-Frontend desktop/ @tessera/desktop — Tauri-Desktop-Client (Windows/Linux), fertiges Produkt packages/ shared/ @tessera/shared — geteilte Konstanten/Typen, inzwischen auch die Manifest-Typen der Desktop-Pakete module-sdk/ — — TypeScript-Interfaces für den Modul-Vertrag (TesseraModule, ModuleManifest) ``` `apps/desktop` ist der fertige Desktop-Client (Tauri 2), kein Grundgerüst mehr: `src-tauri/src/lib.rs` bündelt Tray-Menü, die beiden Erststart-Kommandos (`check_server`/`save_server_url`) und die Versionsprüfung gegen den Server; `src/setup.html` ist die eigenständige Erststart-Seite (kein Bundler, spricht nur über `window.__TAURI__.core.invoke`). Die fertigen Installationspakete (Windows-`.exe`, Linux-`.AppImage`) entstehen nicht lokal, sondern im CI-Job `desktop` (siehe [Desktop-App lokal bauen](#desktop-app-lokal-bauen) für den lokalen Linux-Bau). `packages/shared` bleibt schlank, trägt inzwischen aber zusätzlich zu Konstante und Health-Interface auch die Typen für das Desktop-Paket-Manifest (`DesktopPlatform`, `DesktopManifest`, `DesktopLatestResponse`). **Root-Skripte** (`package.json`, laufen über Turborepo durch alle Workspaces): | Skript | Bedeutung | |---|---| | `pnpm dev` | `turbo dev` — startet alle `dev`-Tasks (bei API/Web persistent, ungecached) | | `pnpm build` | `turbo build` | | `pnpm lint` | `turbo lint` | | `pnpm test` | `turbo test` | | `pnpm type-check` | `turbo type-check` | Linting/Formatierung laufen über **Biome** (`biome.json`, Zeilenlänge 100, 2 Spaces, `organizeImports` aktiv) — es gibt kein ESLint/Prettier im Projekt. `pnpm lint` führt seit dem Vorgang #35 in jedem der fünf Workspaces tatsächlich `biome lint .` aus, und der CI-Schritt „Lint" prüft damit echt statt nur grün zu melden. Das Tor blockiert nur auf echte Fehler; Stilhinweise laufen als Warnungen mit und stoppen den Lauf nicht. Seit dem Vorgang 260921-bi2 trägt `biome.json` genau zwei gezielte Ausnahmen, sonst keine: In Testdateien (`**/*.spec.ts`, `**/*.spec.tsx`, `**/*.test.ts`, `**/*.test.tsx`) ist `suspicious/noExplicitAny` abgeschaltet, weil `any` dort ausnahmslos an Attrappen hängt und eine Umschreibung viel Bewegung bei null Gewinn wäre. In `apps/api/**` ist `style/useImportType` abgeschaltet, weil die dortige Korrektur die von NestJS über `emitDecoratorMetadata` erzeugten Abhängigkeitsdaten zerstört — Biome räumt das in der eigenen Regelbeschreibung ein (`biome explain useImportType`, Abschnitt „Caveat with TypeScript experimental decorators") und rät dort selbst zum Abschalten bei solchen Dekoratoren; der Schaden würde von keinem Tor dieses Projekts bemerkt, da kein Test `createTestingModule` aufruft. Nach Abschluss des gesamten Vorgangs 260921-bi2 (Konfiguration, maschineller Durchgang plus toter Code, und von Hand gepflegte Barrierefreiheit) steht der Rückstand bei **465 Warnungen (386 in echtem Quelltext, 79 in Testdateien)**, Fehlerstufe durchgehend 0 — vorher waren es 2923. Sechs namentlich benannte Klassen bleiben absichtlich offen, weil jede eine Gestaltungsentscheidung oder einen Verhaltenswechsel verlangt, den ein reiner Aufräum-Vorgang nicht treffen darf: `a11y/noNoninteractiveElementInteractions` (11 Fundstellen) und `a11y/noStaticElementInteractions` (5) verlangen die Entscheidung, ob ein geklickter Bereich eine echte Bedienung wird oder der Klick verschwindet; `a11y/useKeyWithClickEvents` (5) verlangt einen bisher fehlenden Tastaturweg; `a11y/noAutofocus` (4) würde den Eingabefokus beim Seitenaufruf verschieben; `a11y/useAriaPropsSupportedByRole` (5) verlangt einen Blick auf jede Rolle einzeln; und `complexity/noUselessSwitchCase` (1, in `apps/api/src/tenders/tender-normalizer.service.ts:60`) wurde bewusst nicht angewendet, weil der Vorschlag eine Fallmarke streichen würde, die einen Kommentar zur Absicht des Standardzweigs trägt — eine Unterdrückung wäre hier unehrlicher als das sichtbare Stehenlassen. Dazu vier gemeldete, nicht reparierte Symptome aus dem toten-Code-Durchgang (D-03): `force-password-change.interceptor.ts` prüft das HTTP-Verfahren nicht, bevor es eine Route freigibt; `change-password/page.tsx` leitet nach erzwungenem Passwortwechsel nicht weiter und frischt die Benutzerablage nicht auf; `VehicleTable.tsx` hat keinen Besetztzustand auf der Löschschaltfläche; `SplitTab.tsx` trägt fest verdrahtete Texte statt Übersetzungen. Alle vier sind als eigene Folgeaufgaben zu planen. `pnpm lint` formatiert dabei nichts und besteht auch nicht auf Formatierung; für Formatierung gibt es getrennt `biome format --write`, das absichtlich von Hand angestoßen wird. Testrunner ist **Vitest** in beiden Apps (`apps/api/vitest.config.ts`, `apps/web/vitest.config.ts`); für `apps/web` läuft die `jsdom`-Umgebung mit `@testing-library/react`. ## Lokale Entwicklungsumgebung ### Voraussetzungen - Docker und Docker Compose - pnpm 9.x (`packageManager` in `package.json` pinnt `pnpm@9.15.0`) Die produktiven `Dockerfile`s ziehen `node:24-alpine` — das ist die verbindliche Node-Version für Container-Builds. ### Umgebungsvariablen Kopieren Sie `.env.example` nach `.env`. Zwei Werte sind praxisrelevant: - `DB_PASSWORD` — Postgres-Passwort, Default in Compose ist `tessera_dev`, falls nicht gesetzt. - `TESSERA_ENCRYPTION_KEY` — verschlüsselt alle gespeicherten Zugangsdaten (LDAP-Bind, Kalender- und Postfach-Logins). **Pflichtfeld**, der Stack startet ohne diesen Wert nicht. Erzeugen mit `openssl rand -hex 32`. Geht der Wert verloren, sind alle gespeicherten Zugangsdaten unwiederbringlich — der Schlüssel gehört zu jedem Datenbank-Backup dazu, aber getrennt davon aufbewahrt. Alle übrigen Variablen (JWT-Secret, SMTP für den lokalen `mailhog`, Admin-Zugangsdaten) haben in `docker-compose.yml`/`docker-compose.dev.yml` brauchbare Entwicklungs-Defaults. ### Stack starten ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build ``` Das startet: - **web** — Next.js mit Turbopack (`next dev --turbopack`), Port `3000` - **api** — NestJS mit Watch-Modus (`nest start --watch`), Port `3001` - **db** — Postgres 16 (Alpine), **ohne Host-Port** - **mailhog** — SMTP-Testserver, UI auf `8025`, SMTP auf `1025` - **openldap** / **phpldapadmin** — für LDAP-Sync-Entwicklung, Ports `389`/`636` bzw. `6443` `docker compose up` ohne `--build`/`--force-recreate` baut bestehende Images **nicht** neu — nach Änderungen an Dockerfiles oder Dependencies muss `--build` explizit mitgegeben werden. Die API läuft im Dev-Stack im Watch-Modus (`nest start --watch`). Dasselbe Skript gibt es für den Betrieb außerhalb von Compose als `pnpm --filter @tessera/api start:dev`; `pnpm --filter @tessera/api start` startet dagegen den fertig gebauten Stand (`node dist/main.js`). Im produktiven Image startet die API nicht direkt, sondern über `apps/api/scripts/migrate-and-start.sh` (siehe [Datenbank und Migrationen](#datenbank-und-migrationen)). Neben den beiden Dateien für die Entwicklung liegen zwei weitere Compose-Dateien im Repository, die Sie lokal nicht brauchen: - `docker-compose.prod.yml` — der produktive Stack. Er zieht die fertigen Abbilder `web`, `api` und `db` aus der Registry; der Kanal (`beta` oder `live`) kommt aus `IMAGE_TAG` in der `.env` des jeweiligen Servers (Vorgabe `beta`). - `docker-compose.ci.yml` — beschreibt den Gitea-Runner (`act_runner`), der die CI-Aufträge in Containern ausführt. Details dazu stehen in `docs/ci-cd-setup.md`. `web`, `api`, `db` und `mailhog` tragen `restart: unless-stopped`: Nach einem Neustart des Rechners kommen sie von selbst wieder hoch, sofern sie vorher liefen. Mit `docker compose stop` angehaltene Container bleiben aus. ### Datenbank vom Host erreichen (wichtig) Der `db`-Service hat **keinen Host-Port** — `docker-compose.yml` exponiert für `db` bewusst nichts nach außen, nur die internen Container-Netze. Ein `psql` oder `prisma`-Aufruf **vom Host** kann sich also nicht über `localhost:5432` verbinden. Stattdessen über die Container-IP: ```bash docker compose ps # Namen des db-Containers ermitteln docker inspect \ | grep -A1 '"Networks"' | grep IPAddress # Container-IP im internen Netz ``` Verbindung dann mit den Zugangsdaten aus Compose (`tessera` / `tessera_dev` im Dev-Setup): ``` postgresql://tessera:tessera_dev@:5432/tessera ``` Das ist relevant, sobald Sie `prisma migrate dev`, `prisma studio` oder ein manuelles `psql` **vom Host aus** statt aus dem `api`-Container heraus ausführen wollen. ### Desktop-App lokal bauen Voraussetzungen zusätzlich zu oben: - Rust (stable) über [rustup](https://rustup.rs/) - Auf Ubuntu/Debian folgende Systempakete: ```bash sudo apt-get install -y libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \ libayatana-appindicator3-dev librsvg2-dev libgtk-3-dev libssl-dev patchelf ``` Version setzen und Linux-Paket bauen: ```bash sh .gitea/scripts/desktop-version.sh pnpm --filter @tessera/desktop exec tauri build --bundles appimage --no-sign ``` `desktop-version.sh` schreibt die Version des letzten Freigabe-Tags in `tauri.conf.json`/`Cargo.toml` — die im Repository eingecheckten Versionsdateien sind nur eine Basislinie, nicht die tatsächliche Freigabeversion. Das fertige Paket liegt danach unter `apps/desktop/src-tauri/target/release/bundle/appimage/`. **`--no-sign` ist lokal Pflicht.** Seit der Update-Funktion in der App verlangt `tauri build` den Signierschlüssel (Umgebungsvariable `TAURI_SIGNING_PRIVATE_KEY`), weil `bundle.createUpdaterArtifacts` und der öffentliche Schlüssel `plugins.updater.pubkey` in `tauri.conf.json` gesetzt sind — ohne Schlüssel bricht der Bau mit „A public key has been found, but no private key" ab. Mit `--no-sign` entsteht keine `.sig`-Datei; `desktop-collect.sh` warnt dann nur (Kanal `dev`) und lässt das Feld `signature` weg, und die API antwortet auf `GET /desktop/update` mit `204`. Der In-App-Update-Weg lässt sich lokal also nur mit dem echten Schlüssel durchspielen (Betriebshandbuch, Kapitel 10). Zwei Hinweise für `tauri dev`: Dort läuft die App ohne AppImage — den Update-Download (`download_and_install`) nie auslösen, er würde die Binary in `target/` überschreiben; die Versionsprüfung selbst ist im Debug-Bau auch gegen `http://localhost` erlaubt (das Plugin warnt nur, der Release-Bau lehnt `http` ab). Um das Paket so einzusammeln, wie die API es ausliefern würde: ```bash sh .gitea/scripts/desktop-collect.sh --require linux ``` Das schreibt `desktop-dist/` (per `.gitignore` vom Git ausgeschlossen, bis auf einen Platzhalter) samt `manifest.json`. Starten Sie danach den lokalen Docker-Stack (`docker compose build api` genügt für die API allein), liefert `GET /desktop/latest` die dort abgelegten Pakete aus. **Der Windows-Installer wird nur im CI gebaut** (`cargo-xwin`-Cross-Bau, NSIS aus dem Ubuntu-Paket `nsis` — siehe `docs/ci-cd-setup.md`, Abschnitt 4). Lokal genügt für Rust-Änderungen `cargo check`/`cargo clippy` in `apps/desktop/src-tauri`; einen Windows-Installer lokal zu bauen ist nicht vorgesehen. Sprache, Symbol und Bilder des Installers stehen in `apps/desktop/src-tauri/tauri.conf.json` unter `bundle.windows.nsis` (Deutsch ohne Sprachauswahl, `icons/nsis-header.bmp` 150×57 und `icons/nsis-sidebar.bmp` 164×314 als 24-Bit-BMP); Änderungen daran lassen sich nur über den CI-Bau auf einem Windows-Rechner prüfen, lokal validiert `cargo check` lediglich die Schlüssel. **Im CI wird die Desktop-App nur gebaut, wenn sich etwas an ihr geändert hat.** Der Job `desktop` vergleicht einen Stempel aus Versionsnummer und letztem Commit an `apps/desktop/`, `desktop-version.sh`, `desktop-collect.sh`, `desktop-stamp.sh`, `appimage-strip-wayland.sh` und `ci.yml` mit dem Zwischenspeicher des Runners und übernimmt bei Treffer die zuletzt gebauten Pakete (Details: `docs/ci-cd-setup.md`, Abschnitt 4). Eine Änderung außerhalb dieser Pfade – etwa nur in `pnpm-lock.yaml` – löst keinen Desktop-Bau aus; soll trotzdem neu gebaut werden, genügt eine Änderung unter `apps/desktop/`. Stempel lokal ansehen: ```bash DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh stamp ``` ## Architektur im Überblick **Frontend** (`apps/web/src/app`, Next.js App Router): ``` (auth)/ — /login, /reset-password — öffentliche Routen (portal)/ — alles hinter Login: /admin, /changelog, /marketplace, /modules, /settings, /change-password ``` Die Route-Groups `(auth)` und `(portal)` teilen sich kein gemeinsames Layout im URL-Pfad, tragen aber unterschiedliche Layout-Bäume. Innerhalb von `(portal)` liegt `modules/[category]/[moduleSlug]` als generische Route für beliebige Module sowie zehn fest verdrahtete Modulverzeichnisse (`cert-manager`, `dkv-fleet`, `domaincheck`, `domains`, `handelsware-datev`, `kantine-datev`, `nextcloud-files`, `nextcloud-status`, `proxmox`, `tender-radar`) mit eigenen `layout.tsx`-Dateien. Dazu kommt `modules/custom/[id]` für die „Eigenen Module“ (ohne Gate-Layout, siehe [Eigene Module](#eigene-module)) — Details im Abschnitt [Das Modulsystem](#das-modulsystem). Weitere Bereiche hinter dem Login: - `/changelog` — die Seite „Was ist neu“ (siehe [Konventionen und Fallstricke](#konventionen-und-fallstricke)). - `/admin` — der Bereich „Administration“ (Benutzer, Gruppen, Module, Verzeichnisanbindung). Unterbereiche: `users`, `groups`, `modules` (mit `grants` für die Freigaben und `categories` für die Modulkategorien), `custom-modules`, `ldap`, `smtp`, `welcome-mail` und `tenants`. - `/settings` — persönliche Einstellungen mit `general` (Konto, Desktop), `dashboard` und `custom-modules`. **Backend** (`apps/api/src`, NestJS): ein Modul pro fachlicher Domäne. Die Plattform-Bausteine sind `auth`, `user`, `tenant`, `groups`, `module-registry`, `module-categories`, `custom-modules`, `dashboard`, `favorites`, `settings`, `reminders`, `bug-reports`, `desktop`, `ldap`, `mail`, `inbox` (gemeinsame IMAP-/Exchange-Postfachanbindung, die mehrere Fachmodule nutzen), `crypto`, `health` und `prisma`. Die Fachmodule sind `domaincheck`, `domains`, `dkv`, `cert-manager`, `tenders`, `calendar`, `proxmox`, `nextcloud-files`, `nextcloud-status`, `handelsware-datev` und `kantine-datev`; `accounting` enthält Hilfsfunktionen (CSV- und Dateinamen-Dekodierung), die Handelsware und Kantinenabrechnung gemeinsam nutzen. `common` ist ein reiner Hilfsordner, kein Fachmodul. Jedes Domänen-Modul folgt dem NestJS-Muster `*.module.ts` / `*.controller.ts` / `*.service.ts`. Der `desktop`-Controller liefert die Desktop-Pakete aus (`GET /desktop/latest`, `GET /desktop/update`, `GET /desktop/download/:platform`). **Weg einer Anfrage** (Beispiel: eine Modulseite lädt Daten): 1. Eine Server- oder Client-Komponente unter `apps/web/src/app/(portal)/...` ruft die API über `fetch` gegen `NEXT_PUBLIC_API_URL` (Browser) bzw. `API_INTERNAL_URL` (Server-Komponenten, zeigt intern auf `http://api:3001`) auf. 2. Die Anfrage trifft in `apps/api/src/main.ts` auf die globale `ValidationPipe` und läuft dann durch die drei global registrierten `APP_GUARD`s aus `app.module.ts`, in genau dieser Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId` aus dem JWT) → `RolesGuard` (prüft `@Roles()`). 3. Trägt der Controller zusätzlich `@UseModule('slug')`, prüft anschließend `ModuleGuard` (`apps/api/src/module-registry/module.guard.ts`) Modulzugriff über `ModuleAccessService`. 4. Der Controller ruft den zugehörigen Service auf, der über `PrismaService` (`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über einen dienst-intern per `forTenant()` gebundenen Client auf Postgres zugreift. 5. Die Antwort geht als JSON zurück; das Frontend rendert sie in der jeweiligen Server- oder Client-Komponente. ## Das Modulsystem Module sind das zentrale Organisationsprinzip von Tessera: fachliche Werkzeuge (Domaincheck, Domains, Zertifikatsmanager, DKV-Rechnung, Ausschreibungs-Radar, Proxmox, Dateien, Nextcloud-Status, Handelsware und Kantinenabrechnung), die im Marktplatz erscheinen, pro Mandant aktiviert und dann einzelnen Gruppen oder Benutzern freigegeben werden. Daneben gibt es die [Eigenen Module](#eigene-module), die ohne Aktivierung und Freigabe auskommen. ### Registrierung Jedes Modul-`Module` (NestJS) seedet sich beim Start selbst in die Datenbanktabelle `Module` — über `OnModuleInit` und eine `seed*Module()`-Funktion, siehe `apps/api/src/domaincheck/domaincheck.seed.ts`: ```ts await moduleRegistryService.seedModule({ slug: 'domaincheck', name: 'Domaincheck', version: latestVersion(DOMAINCHECK_CHANGELOG), category: 'domain-tools', description: { de: '...', en: '...' }, isSystem: true, }); ``` `ModuleRegistryService.seedModule` (`apps/api/src/module-registry/module-registry.service.ts`) macht daraus ein Upsert auf `slug` — bei jedem API-Start wird der Registry-Eintrag aktualisiert, nicht dupliziert. Ein Modul erscheint im Marktplatz (`GET /modules`, `GET /modules/catalog`), sobald dieser Seed einmal gelaufen ist — unabhängig von der Mandanten-Aktivierung. ### Modulversion und Modul-Changelog pflegen Jedes eingebaute Modul hat einen eigenen Changelog. Er ist die **einzige Quelle** für die Versionsnummer des Moduls: In keiner Seed-Datei steht eine fest eingetragene Versionszeichenkette. **Wo was liegt.** Der Changelog liegt als `/.changelog.ts` direkt neben der Seed-Datei (zum Beispiel `apps/api/src/dkv/dkv.changelog.ts`, `apps/api/src/tenders/tenders.changelog.ts`). Die Typen (`ModuleChangelog`, `ModuleChangelogRelease`, `ModuleChangelogItem`) sowie `compareSemver` und `latestVersion` stehen in `apps/api/src/module-registry/module-changelog.ts`, das Register `MODULE_CHANGELOGS` (Slug auf Changelog) in `module-changelog.registry.ts`. Die Seed-Datei liest ihre Version ausschließlich mit `latestVersion(_CHANGELOG)`. Der Marktplatz zeigt die Einträge auf der Detailseite über `GET /modules/changelog/:slug`; unbekannte und eigene Module liefern eine leere Liste, die Detailseite zeigt dann keinen Abschnitt „Änderungen“. **Die Regel.** Jede für Benutzer sichtbare Moduländerung bekommt einen Eintrag. Ein Eintrag mit Neu-Punkt ergibt einen Minor-Sprung (1.2.0 auf 1.3.0). Enthält er nur Behoben- oder Geändert-Punkte (Fehler, Texte, Aussehen), ist es ein Patch-Sprung (1.2.0 auf 1.2.1). Ein Major-Sprung kommt nur bei einem grundlegenden Umbau vor. Reine Test-, Refactor-, Doku- und Chore-Änderungen ohne sichtbare Wirkung brauchen keinen Eintrag. **Höchstens ein Sprung je Modul zwischen zwei Tessera-Freigaben.** Ein Eintrag gilt als unveröffentlicht, solange sein Datum nach dem Datum der letzten Tessera-Version in `CHANGELOG.md` liegt. Kommt eine weitere Änderung hinzu, solange der oberste Eintrag noch unveröffentlicht ist, ergänzen Sie diesen Eintrag, statt eine neue Version anzulegen; kommt dabei ein Neu-Punkt hinzu, heben Sie die Stufe von Patch auf Minor an. Bei der Freigabe einer Tessera-Version bekommen unveröffentlichte Einträge das Freigabedatum (siehe Betriebsanleitung, „Eine Version freigeben“). **Schreibweise.** Kurz (ein Satz, höchstens zwei), aus Sicht der Benutzer, in Alltagssprache und mit „Sie“. Jeder Punkt hat einen deutschen (`de`) und einen gleichwertigen englischen (`en`) Text, die Art ist `new`, `changed` oder `fixed`. Deutsche Texte mit echten Umlauten, ohne Mandanten- oder Lizenzbegriffe. Die Texte erscheinen nur als einfacher Text, ohne HTML oder Markdown. **Checkliste für ein neues Modul.** 1. `/.changelog.ts` anlegen und eine erste Version `1.0.0` mit dem heutigen Datum eintragen (Export `_CHANGELOG`, Typ `ModuleChangelog`). 2. Den Changelog in `module-changelog.registry.ts` eintragen (alphabetisch nach Slug). 3. In der Seed-Datei `version: latestVersion(_CHANGELOG)` verwenden. **Der Wächter.** `apps/api/src/module-registry/module-changelog.spec.ts` findet alle `apps/api/src/*/*.seed.ts` selbstständig, ruft jede `seed*Module`-Funktion mit einer Attrappe auf und schlägt fehl, wenn ein Seed-Modul keinen Changelog hat, ein Changelog zu keinem Seed gehört, die Seed-Version nicht dem obersten Eintrag entspricht, Versionen nicht streng absteigen, ein Datum kein echtes Kalenderdatum ist oder nicht absteigt, ein Eintrag keinen Text in `de` oder `en` hat, Ersatzschreibungen (zum Beispiel „fuer“, „Aenderung“) oder Mandanten- und Lizenzbegriffe vorkommen. So lässt sich die Versionsanpassung nicht vergessen, ohne dass ein Test rot wird. **Warum keine CI-Prüfung „Modulordner geändert, Changelog nicht“.** Sie ließe sich nicht ohne Fehlalarme bauen: Reine Test-, Refactor- und Kommentar-Commits verlangen keinen Versionssprung, plattformweite Commits berühren viele Modulordner zugleich, und ein Push bündelt mehrere Commits (gebündeltes Pushen ist hier Projektpraxis). Stattdessen läuft vor jeder Freigabe dieser Prüfbefehl je Modul, und jedes Modul mit Treffern braucht einen unveröffentlichten Eintrag: ```bash git log --oneline ..HEAD -- apps/api/src/ \ "apps/web/src/app/(portal)/modules/" apps/web/src/components/ ``` Zusätzliche Modulpfade sind die Dashboard-Kachel `apps/web/src/components/dashboard/widgets/-widget.tsx` (Proxmox), `apps/web/src/lib/nextcloud-files-api.ts` (Dateien) sowie bei Handelsware und Kantinenabrechnung `apps/api/src/accounting` und `apps/web/src/components/accounting`. Treffer beurteilen Sie nach der Regel oben: Steckt eine sichtbare Änderung dahinter, gehört ein Eintrag in den Changelog. ### Zweistufiges Zugriffsmodell Zugriff auf ein Modul besteht aus zwei unabhängigen Stufen: 1. **Mandanten-Aktivierung** (`TenantModuleActivation`) — ein Admin schaltet das Modul für den gesamten Mandanten frei/aus (`POST /modules/:moduleId/activate|deactivate`, nur ADMIN/ SUPER_ADMIN). Ohne Aktivierung ist das Modul für niemanden im Mandanten erreichbar, auch nicht über einen Grant. 2. **Grant pro Gruppe oder Benutzer** (`ModuleGrant`) — erst wenn das Modul aktiv ist, entscheidet ein Grant, wer es tatsächlich sieht. `ModuleGrant` trägt eine Freigabestufe, das Feld `level` (Enum `ModuleGrantLevel`, Vorgabe `USE`): `USE` heißt Benutzen, `MANAGE` heißt Benutzen und zusätzlich die Einstellungen des Moduls ändern. Freigaben erteilen bleibt Administratoren vorbehalten. Ein Grant gilt für eine Gruppe **oder** einen Benutzer, nie beides (D-04). Beide Stufen werden ausschließlich an einer Stelle aufgelöst: `ModuleAccessService.getModuleAccessLevels(tenantId, userId, role)` (`apps/api/src/module-registry/module-access.service.ts`). Die Funktion liefert je zugänglichem Modul die Stufe. ADMIN und SUPER_ADMIN umgehen die Grant-Prüfung und bekommen automatisch alle mandantenweit aktiven Module mit der Stufe `MANAGE`. Für die Rolle USER ist es die Vereinigungsmenge aus Direkt-Grants und Grants über Gruppenmitgliedschaft, geschnitten mit den aktiven Modulen des Mandanten; besteht der Zugriff über mehrere Wege, gewinnt die höhere Stufe. `getAccessibleModuleIds(tenantId, userId, role)` ist nur die Schlüsselmenge davon — es liefert also die Modul-IDs ohne Stufe. Diese eine Auflösung versorgt drei Stellen — den `ModuleGuard` im Backend, `GET /modules/active` (Sidebar) und `GET /modules/catalog` (Marktplatz) — damit keine dieser Stellen unabhängig voneinander driften kann. Der Dekorator `@UseModule(slug)` verlangt Zugriff auf das Modul; `@ModuleManage(slug)` (ebenfalls in `module.guard.ts`) verlangt zusätzlich die Stufe `MANAGE` und ersetzt `@Roles(ADMIN, SUPER_ADMIN)` bei Routen, die nur dieses eine Modul konfigurieren. Beide nie zusammen mit `@Roles` am selben Handler einsetzen. ### Vom Backend-Endpunkt zur Seite im Portal Ein Modul-Controller schützt seine Routen mit dem `@UseModule(slug)`-Dekorator: ```ts @Controller('modules/domaincheck') @UseModule('domaincheck') export class DomaincheckController { ... } ``` `UseModule` (`apps/api/src/module-registry/module.guard.ts`) setzt Metadaten und hängt `ModuleGuard` als `CanActivate` ein. **Ohne diesen Dekorator gibt `ModuleGuard` bewusst `true` zurück** — die Durchsetzung hängt vollständig am Dekorator, jeder neue Modul-Controller muss ihn tragen. Im Frontend gibt es zwei Wege, wie eine Modulseite unter `/modules/...` erreichbar ist: - **Generische Route** `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx` — für jedes Modul über `[category]`/`[moduleSlug]` erreichbar. - **Zehn fest verdrahtete Modulverzeichnisse**: `modules/cert-manager`, `modules/dkv-fleet`, `modules/domaincheck`, `modules/domains`, `modules/handelsware-datev`, `modules/kantine-datev`, `modules/nextcloud-files`, `modules/nextcloud-status`, `modules/proxmox` und `modules/tender-radar` — mit eigenen Unterrouten (z. B. `dkv-fleet/vehicles`, `tender-radar/my-sources`, `*/settings`). Jedes davon hat zugleich einen Eintrag in `MODULE_REGISTRY`. Daneben gibt es `modules/custom` — die bewusste Ausnahme ohne Gate-Layout für die [Eigenen Module](#eigene-module). Beide Wege rendern denselben Baustein: die Server-Komponente `ModuleAccessGate` (`apps/web/src/components/modules/module-access-gate.tsx`). Sie ruft `checkModuleAccess(moduleSlug)` auf, was `GET /modules/active` mit dem Session-Cookie anfragt — dieselbe `ModuleAccessService`-Auflösung, die auch Sidebar und `ModuleGuard` benutzen. Nur bei explizit `true` werden die `children` gerendert; jeder andere Ausgang (verweigert, oder die Prüfung wirft einen Fehler) zeigt eine gemeinsame 403-Ansicht. **Bekannter Fallstrick (behoben, aber lehrreich):** Ursprünglich saß dieser Zugriffs-Check nur in der generischen `[category]/[moduleSlug]`-Route. Die damals vier fest verdrahteten Modulverzeichnisse (`cert-manager`, `dkv-fleet`, `domaincheck`, `tender-radar`) hatten **keinen eigenen** `ModuleAccessGate` und liefen an der Prüfung vorbei — ein direkter Aufruf von z. B. `/modules/dkv-fleet` umging die Freigabeprüfung vollständig, obwohl die generische Route korrekt geschützt war. Der Fix (Commit `74a30fb`/`5504931`) gab jedem dieser Verzeichnisse ein eigenes `layout.tsx`, das denselben `ModuleAccessGate` einbindet; heute tragen es alle zehn fest verdrahteten Modulverzeichnisse: ```ts // apps/web/src/app/(portal)/modules/dkv-fleet/layout.tsx export default function DkvFleetLayout({ children }: { children: ReactNode }) { return {children}; } ``` **Regel für neue Module:** Ein neues fest verdrahtetes Modulverzeichnis unter `modules//` braucht **immer** ein eigenes `layout.tsx` mit `ModuleAccessGate`, genau wie sein Backend-Controller `@UseModule('')` braucht. Beide Prüfungen sind unabhängig voneinander — die eine ersetzt nicht die andere; das Frontend-Gate ist Komfort/UX (keine leere Seite ohne Erklärung), das Backend-Gate ist die tatsächliche Zugriffskontrolle. Den Wächter dafür stellt `apps/web/src/app/(portal)/modules/module-layouts.test.tsx`: Er prüft für die dort aufgeführten Layouts, dass sie das `ModuleAccessGate` mit genau dem Slug ihres Ordnernamens einbinden (`proxmox` fehlt in dieser Liste bisher), und dass **jedes** Modulverzeichnis (außer den dynamischen `[...]`-Ordnern und dem bewusst ausgenommenen `custom`) ein `layout.tsx` besitzt. Wer ein neues Modulverzeichnis ohne Layout anlegt, bekommt einen roten Test; wer ein Layout anlegt, trägt es dort zusätzlich in die Slug-Tabelle ein. Zusätzlich läuft im Frontend eine dritte, unabhängige Absicherung: `ModuleShell` (`apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx`) lädt die Modul-Komponente nur, wenn ihr Slug in `MODULE_REGISTRY` (`apps/web/src/lib/module-loader.ts`) als lazy-geladenes `dynamic()`-Import gelistet ist — ein beliebiger Slug aus der URL löst sonst keinen Import aus. ### So entsteht ein neues Modul — Walkthrough am Beispiel Domaincheck Domaincheck ist ein kleines, überschaubares Modul und eignet sich als Vorlage. Die realen Dateien: **Backend** (`apps/api/src/domaincheck/`): 1. `domaincheck.service.ts` — die eigentliche fachliche Logik. 2. `dto/check-domain.dto.ts` — Validierung des Request-Body per `class-validator`. 3. `domaincheck.controller.ts` — `@Controller('modules/domaincheck')` mit `@UseModule('domaincheck')` auf Klassenebene, ein `@Post('check')`-Handler. 4. `domaincheck.seed.ts` — die `seedDomaincheckModule()`-Funktion mit dem Manifest (`slug`, `name`, `version`, `category`, `description`, `isSystem`). Die Version kommt aus dem Modul-Changelog, siehe [Modulversion und Modul-Changelog pflegen](#modulversion-und-modul-changelog-pflegen). 5. `domaincheck.module.ts` — bindet Controller/Service zusammen, importiert `ModuleRegistryModule`, ruft in `onModuleInit()` den Seed auf. 6. Eintrag des neuen Moduls in `apps/api/src/app.module.ts` unter `imports`. **Frontend** (`apps/web/src/app/(portal)/modules/domaincheck/`): 1. `page.tsx` — die eigentliche Modulseite (Client-Komponente mit den Formular-/Ergebnis-Teilen). 2. `layout.tsx` — `ModuleAccessGate moduleSlug="domaincheck"` um `{children}`. 3. `actions.ts` — Server Actions, die die Backend-Route aufrufen. 4. `components/` — `DomainInput.tsx`, `ResultList.tsx`. 5. Eintrag in `MODULE_REGISTRY` (`apps/web/src/lib/module-loader.ts`) mit dem `dynamic()`-Import auf `page.tsx`. 6. Übersetzungsschlüssel in `apps/web/src/messages/de.json` **und** `en.json` (siehe [Konventionen und Fallstricke](#konventionen-und-fallstricke)). Für ein Modul mit Unterrouten (Einstellungsseite, Verwaltungsansicht) orientieren Sie sich an `dkv-fleet` oder `tender-radar` — beide haben zusätzliche `settings/page.tsx` bzw. weitere Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden. **Ein Modul mit Fremdsystem-Zugängen und Hintergrundabfrage:** `proxmox` (260923-dhh) ist die Vorlage dafür — mehrere verschlüsselte Fremdsystem-Zugänge je Mandant (`ProxmoxServer`, Vorbild `CalendarSource`, nicht `DkvModuleConfig`), ein Zwischenlager, das ein Hintergrunddienst beschreibt und das die Modulseite ausschließlich liest (`ProxmoxServerStatus`), sowie ein Planer, der `onApplicationBootstrap` statt `onModuleInit` nutzt und je Mandant einen eigenen Cron-Auftrag registriert (`proxmox-scheduler.service.ts`, kombiniert die Muster von `DkvSchedulerService` und `TenderSchedulerService`). **Die `undici`-Dispatcher-Falle unter Node 24:** wer aus Gewohnheit das globale `fetch` statt `import { fetch as undiciFetch } from 'undici'` verwendet, bekommt beim Kompilieren KEINEN Fehler, sondern eine zur Laufzeit STILLSCHWEIGEND ignorierte `dispatcher`-Option — ein selbstsigniertes Zertifikat wird dann trotz bewusst abgeschalteter Prüfung weiterhin abgelehnt, was beim ersten Test verwirrend aussieht, als sei die Datenbank-Einstellung falsch gelesen worden. Node 24 bündelt intern eine eigene `undici`-Kopie, die vom global gepatchten `fetch` verwendet wird — ein `Agent` aus dem npm-Paket `undici` ist eine ANDERE Klasse und wird von diesem globalen `fetch` ignoriert. Gemessen und dokumentiert in `apps/api/src/favorites/icon-discovery.service.ts:33-40` (erstes Auftreten) und in `apps/api/src/proxmox/proxmox-client.service.ts` (zweites, unabhängig davon konstruiertes Auftreten mit demselben Befund). ### Eigene Module Neben den eingebauten Modulen gibt es die „Eigenen Module“: Seitenleisten-Einträge, die eine externe `https`-Seite im Rahmen des Portals anzeigen. Sie sind keine Zeilen der Tabelle `Module`, hängen deshalb an keiner Mandanten-Aktivierung und keinem Grant und sind für alle angemeldeten Benutzer sichtbar. Die Teile: - **Backend** `apps/api/src/custom-modules/` (`@Controller('custom-modules')`): Es gibt gemeinsame Einträge (nur Administratoren legen sie an, ändern und löschen sie) und persönliche Einträge (nur der Besitzer sieht und ändert sie; für fremde Kennungen antwortet die API immer mit 404). Die Rollenentscheidung trifft der Dienst, weil sie vom Eintrag abhängt, nicht von der Route — deshalb tragen die Routen weder `@Roles` noch `@UseModule`. Die Kategorien kommen aus `apps/api/src/module-categories/`. - **Frontend**: die Rahmenseite `modules/custom/[id]` (bewusst ohne `ModuleAccessGate`; der statische Ordner `custom` hat im App Router Vorrang vor `[category]/[moduleSlug]`), die Bausteine `apps/web/src/components/modules/custom-module-*.tsx`, die Pflege der gemeinsamen Einträge für Administratoren unter `/admin/custom-modules` und die persönlichen Einträge unter `/settings/custom-modules`. - **Wächter**: `apps/web/src/messages/module-categories.spec.ts` prüft, dass jede Modulkategorie einen Anzeigenamen in beiden Sprachen hat; `module-layouts.test.tsx` führt `custom` als einzige begründete Ausnahme von der Layout-Pflicht. ### Eine Kachel zum Modul Ein Modul kann zusätzlich als Kachel auf dem Dashboard erscheinen. `WIDGET_TYPES` kennt zwölf Kachel-Typen: `clock`, `search`, `calendar`, `note`, `calculator`, `favorites`, `stopwatch`, `picture-frame`, `xframe`, `proxmox`, `reminder` und `nextcloud-status`. Davon sind nur `proxmox` und `nextcloud-status` an ein Modul gebunden; alle übrigen (auch `reminder`) sind Plattform-Kacheln, die immer sichtbar sind. Seit `quick-260922-m1h` sind für eine neue Kachel **drei** Stellen nötig (vorher waren es sieben): 1. **Die Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/-widget.tsx`, nimmt die `WidgetProps` aus `widget-registry.tsx` (`instanceId`, `config`, `isEditMode`) entgegen. 2. **Den Typ eintragen** — in `WIDGET_TYPES` in `packages/shared/src/index.ts`. Gehört die Kachel zu einem Modul, zusätzlich `WIDGET_MODULE_SLUGS[''] = ''` in derselben Datei. Das ist die einzige Liste: die API validiert `POST /dashboard/widgets` per `@IsIn` gegen genau sie, und das Frontend leitet Registry und Katalog davon ab. 3. **Anmelden** — `registerWidget('', Widget)` in `apps/web/src/app/(portal)/page.tsx`, neben den übrigen Aufrufen. Der Aufruf steht dort und nicht in der Registry, weil die Kachel über den Wrapper wieder die Registry importiert — ein Import aus der Registry heraus wäre ein Zirkelimport. Dazu kommen wie bei jeder Oberfläche die **Übersetzungsschlüssel** (`.name` und `.description` unter `widgets` in `de.json` **und** `en.json`), ein **Symbol** als Inline-SVG und die **Größenvorgaben** in `WIDGET_CONSTRAINTS` (`minW`/`minH` = kleinste noch bedienbare Kachel, `defaultW`/`defaultH` = Startgröße) — beides in `widget-registry.tsx`. Ein Test in `widget-registry.test.tsx` prüft, dass `WIDGET_TYPES`, Registry und Constraints deckungsgleich sind; vergisst man eine Stelle, schlägt er fehl. **Was eine Kachel mit `moduleSlug` automatisch tut:** Sie verschwindet für Benutzer, die das Modul nicht nutzen dürfen — aus dem Katalog („Widget hinzufügen", `visibleWidgetTypes`) und aus dem Dashboard selbst. Ist die Modulliste unbekannt, weil ihr Abruf fehlschlug, bleibt die Kachel ebenfalls verborgen (fail-closed). Eine bereits angelegte Kachel eines gesperrten Moduls rendert nicht mehr leer, sondern zeigt den Hinweis `widgets.unavailable`. **Wie beim Modul-Gate gilt auch hier:** Der Katalogfilter ist Komfort, nicht Zugriffskontrolle. Die verbindliche Prüfung sitzt serverseitig in `DashboardService.getWidgets()` (filtert Kacheln gesperrter Module fail-closed aus `GET /dashboard/widgets`) — und die Daten, die eine Modul-Kachel anzeigt, holt sie über die Endpunkte ihres Moduls, die `@UseModule('')` tragen müssen. **Erstes echtes Beispiel: Proxmox (`quick-260924-i8v`).** Die Kachel steht in `apps/web/src/components/dashboard/widgets/proxmox-widget.tsx`, ihre reinen Modellfunktionen (`resolveProxmoxWidgetConfig`, `selectServers`, `healthSummary`, `widgetKeyFigure`) in `proxmox-widget-model.ts` daneben; `WIDGET_MODULE_SLUGS` trägt `proxmox: 'proxmox'`. Die Statuslogik, die Modulseite und Kachel teilen (`proxmox-status.ts` samt `formatPercent`/`formatCount`, `HealthBar.tsx` mit `variant="compact"`, `status-styles.ts`, das Auswahl-Bauteil `proxmox-server-picker.tsx`), liegt seit 260924-i8v unter `apps/web/src/components/proxmox/` — eine Kachel unter `components/` greift so nicht in einen Routenordner unter `app/`. Das Einstellungsformular für Einstellungen > Dashboard ist `components/settings/proxmox-widget-config-form.tsx`. Zwei Regeln, die für jede weitere Modul-Kachel gelten: Die Kachel liest nur Daten, die das Modul ohnehin vorhält (hier das Zwischenlager über `GET /modules/proxmox/servers`), und löst nie selbst eine Abfrage beim Fremdsystem aus — ein Minutentakt je Kachel und Benutzer wäre sonst eine Last auf den Proxmox-Hosts. Und Zeilen, die im Ansichtsmodus Links sind, werden im Bearbeitungsmodus zu schlichten Elementen ohne Ziel: Links stehen im Abbruch-Selektor von `dashboard-grid.tsx`, eine Kachel aus Links ließe sich sonst kaum noch ziehen. **Zweites Beispiel: Nextcloud-Status (`quick-261002-k67`).** Die zweite modulgebundene Kachel folgt demselben Muster: `WIDGET_MODULE_SLUGS` trägt `'nextcloud-status': 'nextcloud-status'`, die Komponente ist `apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx`, und die Daten kommen über die Endpunkte des Moduls (`/modules/nextcloud-status/*`, im Frontend gebündelt in `apps/web/src/lib/nextcloud-status-api.ts`, das sich an `proxmox-api.ts` orientiert). Der Katalog- Test `widget-catalog-modal.test.tsx` prüft, dass beide Modul-Kacheln nur bei Zugriff auf das jeweilige Modul erscheinen. ## Mandantentrennung Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als `APP_GUARD` in `app.module.ts` registriert — er läuft nach `JwtAuthGuard`, weil `req.user` erst dann gesetzt ist. `TenantGuard` liest `tenantId` aus dem JWT-Claim des Anfragenden, erlaubt SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend AUSSCHLIESSLICH `req.tenantId` (260911-e2s). Die Bindung an den Mandanten geschieht dienst-intern, je Service-Methode neu, über das Bindungshilfsmittel `forTenant()` (`apps/api/src/prisma/prisma-tenant.extension.ts`), das vor **jeder** Query in einer Transaktion eine einzige `SELECT set_config(...)`-Anweisung ausführt. Sie setzt drei Werte zugleich: `app.current_tenant` (der Mandant), `app.current_user` (der optionale dritte Parameter `userId`, leer, wenn keiner übergeben wird) und `app.system_context` (wird dabei auf leer gesetzt). Der Guard selbst erzeugt keinen Prisma-Client mehr und veröffentlicht keinen auf dem Anfrageobjekt. > Ein früherer Entwurf veröffentlichte zusätzlich einen gebundenen Prisma-Client auf dem > Anfrageobjekt, dupliziert in einer gleichnamigen, nie in `app.module.ts` registrierten > Express-Middleware mit identischer Logik — beides wurde mit 260911-e2s entfernt, nachdem eine > Volltextsuche keinen Leser dieser Eigenschaft außerhalb der beiden Dateien fand. `app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies liegen seit `20260909140000_rls_remaining_tenant_tables` auf 23 Tabellen (4 aus `20260618112133_rls_policies`, 3 aus `20260804130918_groups_rls_policies`, 16 aus der `_rls_remaining_tenant_tables`-Migration selbst — `grep -c "ENABLE ROW LEVEL SECURITY"` über die drei Migrationen, zur Ausführungszeit nachzählen), darunter `FavoriteLink` und `SmtpConfig`. Ohne eigene `tenantId`-Spalte bzw. bewusst plattformweit bleiben `Module`, `Tenant`, `Tender`, `TenderSource` und `TenderSourcePollConfig` (siehe die Bestandsaufnahme in `docs/mandantentrennung-zugriffsklassifikation.md`, Klasse `keine-mandantengebundene-tabelle`, für die vollständige, maschinell geprüfte Liste — von dort ableiten, nicht raten). **Was ein Entwickler nie vergessen darf:** jeder Zugriff auf eine mandantengebundene Tabelle läuft dienst-intern über einen mit `forTenant()` gebundenen Klienten `tenantPrisma` (`apps/api/src/prisma/prisma-tenant.extension.ts`) — `forTenant(prisma, tenantId, userId?)` trägt seit Migration `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b, 260911-nke) einen optionalen dritten Parameter: zehn persönliche Tabellen (CalendarSource, DashboardLayout, FavoriteLink, SearchProvider, TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance) tragen die Benutzerdimension in der Regel (`current_user_id() IS NULL OR "userId" = current_user_id()`), vier Tabellen mit `userId`-Spalte aber ohne persönliche Daten (GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch) nicht. Nur Nutzer-CRUD-Aufrufer setzen `userId`; Hintergrunddienste und Verwaltungswege rufen weiterhin ohne ihn — das macht die `IS NULL OR`-Form fuer sie wirkungslos, keine Verschlechterung. Die zusätzlichen `where`-Filter über `userId` im Anwendungscode bleiben in JEDEM Fall bestehen — zweites Netz, kein Ersatz (siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`). Bei den Tabellen ohne eigene `tenantId` (oben) filtert die Anwendung stattdessen — wo relevant — über den zutreffenden Bezug (z. B. plattformweiter Katalog, kein Mandantenfilter nötig); siehe `docs/mandantentrennung-zugriffsklassifikation.md` für den vollständigen Stand je Datei/Modell. Bei den RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über den globalen, ungebundenen `PrismaService`. **Systemkontext für Hintergrunddienste:** Ein Dienst, der ohne Anfrage einmal über **alle** Mandanten lesen muss und danach je Mandant gebunden handelt (zum Beispiel die Hintergrunddienste von DKV, Proxmox, Nextcloud-Status, Erinnerungs-Mails und Ausschreibungs-Zusammenfassung, der LDAP-Abgleich und das Ausschreibungs-Matching), nutzt `forSystem()` aus derselben Datei. Es setzt `app.system_context` auf `'true'` und leert dabei `app.current_tenant` und `app.current_user`. Geöffnet ist nur das **Lesen** (eine zusätzliche `FOR SELECT`-Regel); jedes Schreiben scheitert weiterhin an der Mandantenregel. Ein Anfrageweg darf `forSystem()` nie aufrufen — es würde an jeder Mandantenregel vorbeilesen. Welche Dateien es rufen dürfen, steht mit der genauen Zahl der Aufrufe je Datei in `FORSYSTEM_ALLOWED_CALL_SITES` in `rls-access-inventory.spec.ts`; jeder weitere Aufruf macht diesen Test rot. **Wächter-Tests für die Mandantentrennung** (`apps/api/src/prisma/`): Sie lesen Schema, Migrationen und Quelltext und werden rot, sobald etwas auseinanderläuft: - `rls-coverage.spec.ts` — misst aus `schema.prisma` und den Migrationen, welche Modelle eine `tenantId` tragen und ob für sie eine RLS-Regel existiert; bleibt auch für künftige Modelle gültig. - `rls-access-inventory.spec.ts` — ermittelt alle Zugriffe auf Modelle im Quelltext (gebunden, ungebunden, gemischt, systemgebunden) und vergleicht sie mit der Bestandsaufnahme in `docs/mandantentrennung-zugriffsklassifikation.md`. Eine neue Fundstelle ohne Eintrag oder ein Eintrag ohne Fundstelle lässt den Test fehlschlagen — deshalb gehört die Doku-Zeile in dieselbe Änderung wie der neue Zugriff. - `rls-app-role.spec.ts` — prüft die Migration der Datenbankrolle `tessera_app` (ohne Superuser- und BYPASSRLS-Recht) rein textuell. - `rls-preflight.spec.ts` — prüft `apps/api/scripts/rls-preflight.mjs` in der Betriebsart `--print-plan`, also ohne Datenbankverbindung. Das Skript selbst misst gegen eine Datenbankrolle (Verbindung aus `TESSERA_PREFLIGHT_DATABASE_URL`), ob die Mandantentrennung unter ihr tatsächlich greift; es liest nur und schreibt nichts. ## Berechtigungen Rollen kommen aus dem Prisma-`enum Role { SUPER_ADMIN, ADMIN, USER }` und stecken im JWT — nie aus Body oder Query-Parametern, sondern ausschließlich `req.user.role`. Rollenschutz auf Controller-Ebene läuft über zwei Dekoratoren (`apps/api/src/auth/decorators/roles.decorator.ts`, `apps/api/src/auth/guards/roles.guard.ts`): ```ts @Roles(Role.ADMIN, Role.SUPER_ADMIN) ``` `RolesGuard` ist ebenfalls global als `APP_GUARD` registriert; ohne `@Roles()`-Metadaten lässt er jede Anfrage durch — die Einschränkung entsteht ausschließlich durch das explizite Setzen des Dekorators auf Handler oder Controller. Für den Modulzugriff kommt das oben beschriebene Grant-Modell hinzu: `Group` (mandantenintern, optional an ein AD-Objekt über `ldapDn`/`ldapObjectGuid` gebunden), `GroupMembership` (Benutzer-zu-Gruppe, `source: MANUAL | LDAP`) und `ModuleGrant` (Gruppe **oder** Benutzer, XOR, mit Freigabestufe `level` = `USE` oder `MANAGE`). Die Schreibseite dafür ist `apps/api/src/groups/module-grants.controller.ts`, ausschließlich für ADMIN/SUPER_ADMIN: | Route | Zweck | |---|---| | `GET /module-grants/matrix` | Module × Gruppen-Matrix bestehender Grants | | `GET /module-grants/users/:userId` | Gruppenmitgliedschaften + geerbte/direkte Modulzugriffe eines Benutzers | | `POST /module-grants` | Grant anlegen | | `DELETE /module-grants` | Grant entziehen (Ziel im Body, nicht im Pfad) | ## Datenbank und Migrationen Prisma ist die einzige Zugriffsschicht (`apps/api/prisma/schema.prisma`, Provider `postgresql`). Der Workflow für eine Schemaänderung: 1. `schema.prisma` anpassen. 2. Migration erzeugen (im `api`-Container oder mit Zugriff auf die DB — siehe [Datenbank vom Host erreichen](#datenbank-vom-host-erreichen-wichtig)): ```bash pnpm --filter @tessera/api exec prisma migrate dev --name ``` 3. `prisma generate` läuft automatisch als `postinstall`-Skript von `@tessera/api` (`"postinstall": "test -f prisma/schema.prisma && prisma generate || true"`), muss also nach `pnpm install` nicht separat aufgerufen werden. **Migrationen laufen automatisch beim API-Start.** Das produktive `Dockerfile` (`apps/api/Dockerfile`) setzt als `CMD`: ``` sh apps/api/scripts/migrate-and-start.sh ``` Das Skript führt zuerst `prisma migrate deploy --schema apps/api/prisma/schema.prisma` aus und startet danach die API (`node apps/api/dist/main.js`, per `exec`, damit Signale wie `SIGTERM` den Node-Prozess erreichen). Ein neu gestarteter `api`-Container wendet also jede noch ausstehende Migration selbst an, bevor die Anwendung überhaupt hochfährt — es gibt keinen separaten manuellen Migrationsschritt beim Deployment. Das Skript trennt dabei die Verbindung für die Migration von der Verbindung für den Betrieb: Für `prisma migrate deploy` gilt `TESSERA_MIGRATE_DATABASE_URL`, falls gesetzt (Rechte des Tabelleneigentümers für DDL), sonst `DATABASE_URL`; die API selbst läuft immer mit `DATABASE_URL`. Ohne gesetzte Migrationsverbindung nutzen beide Schritte `DATABASE_URL` — der bisherige Ablauf, unverändert für lokale Entwicklung und CI. Die Hintergründe zur Rollentrennung stehen in `docs/mandantentrennung-datenbankrolle.md`; `apps/api/src/prisma/start-script.spec.ts` prüft das Skript in der Betriebsart `--print-plan` ohne Datenbank. RLS-Policies werden nicht von Prisma selbst verwaltet, sondern als reines SQL innerhalb regulärer Migrationsdateien mitgeliefert (`CREATE POLICY ...` in `migration.sql`) — siehe [Mandantentrennung](#mandantentrennung) für den aktuellen Stand, welche Tabellen das betrifft. ## Tests Beide Apps nutzen **Vitest**, aber mit unterschiedlicher Umgebung: - `apps/api` — `environment: 'node'`, sucht `src/**/*.spec.ts`, `passWithNoTests: true`. ```bash pnpm --filter @tessera/api test # einmalig pnpm --filter @tessera/api test:watch # Watch-Modus ``` - `apps/web` — `environment: 'jsdom'` mit `@testing-library/react`, `setupFiles: ['./src/test/setup.ts']`. ```bash pnpm --filter @tessera/web test ``` `pnpm test` im Root führt über Turborepo beide Suiten aus. Es gibt kein separates End-to-End-Test-Setup (kein Playwright-Config im Repository) — Tests sind Unit-/Integrationstests gegen Services, Controller-Logik und React-Komponenten. Guard-artige Spezifikationen wie `module.guard.spec.ts` oder die i18n-Wächter (siehe unten) sind das Vorbild für Regressionsschutz gegen bereits einmal aufgetretene Fehler — wiederkehrende Fallstricke werden in diesem Projekt durch einen Test abgesichert, nicht nur durch einen Kommentar. **Wächter-Tests (Regressionsschutz):** Mehrere Tests sind keine Funktionstests, sondern halten eine Regel fest, die einmal verletzt wurde. Die wichtigsten: | Test | Was er absichert | |---|---| | `apps/web/src/app/(portal)/modules/module-layouts.test.tsx` | Jedes Modulverzeichnis hat ein `layout.tsx` mit `ModuleAccessGate` (Ausnahme: `custom` und dynamische `[...]`-Ordner) | | `apps/web/src/messages/umlaut-guard.spec.ts` | Keine Ersatzschreibung („fuer“, „loeschen“) in `de.json`/`en.json` | | `apps/web/src/messages/tenderRadar-parity.spec.ts` | `de.json` und `en.json` haben im Namensraum `tenderRadar` dieselben Schlüssel | | `apps/web/src/messages/module-categories.spec.ts` | Jede Modulkategorie hat einen Anzeigenamen in beiden Sprachen | | `apps/web/src/components/dashboard/widget-registry.test.tsx` | `WIDGET_TYPES`, Registry und Größenvorgaben der Kacheln sind deckungsgleich | | `apps/api/src/module-registry/module-changelog.spec.ts` | Jedes Seed-Modul hat einen Changelog, die Version stimmt mit dem obersten Eintrag | | `apps/api/src/prisma/rls-*.spec.ts` | Mandantentrennung: Abdeckung, Zugriffsinventar, Datenbankrolle, Preflight (siehe [Mandantentrennung](#mandantentrennung)) | **Desktop-Modul (`apps/api/src/desktop`):** ```bash pnpm --filter @tessera/api exec vitest run src/desktop ``` Die Tests laufen als echter HTTP-Durchstich über `NestFactory.create()` + `app.listen(0)` gegen ein echtes temporäres Verzeichnis (kein `fs`-Mock) — Manifest lesen, 404 ohne Manifest, Plattform-Whitelist, Pfad-Traversal abgewiesen. **Rust (`apps/desktop/src-tauri`):** ```bash cargo check cargo clippy ``` Beide laufen auch im CI-Job `desktop` (D-16); ein grüner `cargo clippy` ohne Warnungen ist Voraussetzung für den Bauschritt. ## Sicherheitsprüfungen Tessera führt ein eigenes [Sicherheitsprotokoll](sicherheitsprotokoll.md). Es hält für die Inhaberin oder den Inhaber und für Kunden fest, was geprüft wurde, was dabei herauskam und was daraus geworden ist. Dieser Abschnitt sagt, wie Sie es aktuell halten, was die Pipeline automatisch prüft und wie Sie die Prüfung selbst wiederholen. ### So pflegen Sie dieses Protokoll Jede der folgenden Gelegenheiten bekommt im selben Auftrag einen Eintrag unter „Verlauf“ in `docs/sicherheitsprotokoll.md` (neuester Eintrag oben): - jede **Sicherheitsprüfung** (Code-Prüfung nach einer größeren Änderung, Bedrohungsbetrachtung, neue Prüf-Tests mit Sicherheitsbezug), - jede **festgehaltene Messung** der automatischen Prüfung: die erste, jede nach dem Beheben von Befunden und jede, bei der sich die Zahlen der Pipeline auffällig ändern (zum Beispiel ein neuer kritischer Fund), - jeder **Lauf der Außenprüfung** vor einer Freigabe. Der Eintrag nennt das Datum, was geprüft wurde, die Zahlen und was behoben oder bewusst hingenommen wurde. Im selben Auftrag werden „Auf einen Blick“ und die Tabelle „Einordnung der Befunde“ auf den neuen Stand gebracht. Jeder Befund trägt einen der drei Stände „behoben“, „bewusst akzeptiert“ oder „offen“, und bei „offen“ und „bewusst akzeptiert“ steht immer ein Grund. Das Protokoll enthält nie Geheimnisse (Passwörter, Schlüssel, Zugangscodes) und nie Einzelheiten, mit denen sich ein Fehler ausnutzen ließe; Bausteine und Schweregrade werden in Alltagssprache genannt. Schreibweise wie in allen Anleitungen: Sie-Form, echte Umlaute, Fachbegriffe beim ersten Auftreten erklärt. ### Die Prüfung in der Pipeline Nach dem Bau der Abbilder läuft in `.gitea/workflows/ci.yml` der Job `security` („Sicherheitsprüfung (nur Bericht)“). Er läuft nur bei Pushes auf `main` und bei Marken `v*`, bekommt kein Secret, ist als `continue-on-error` markiert und wird von keinem anderen Job abgewartet: Er kann weder Bau noch Tests noch die Veröffentlichung aufhalten. Alles Eigentliche steckt in `.gitea/scripts/security-scan.sh`: - Die Quellprüfungen (Abhängigkeiten, Semgrep, Trivy) lesen einen Export des eingecheckten Stands (`git archive HEAD`), gitleaks liest die Git-Geschichte. Nicht eingecheckte Dateien eines Arbeitsordners erreichen nie ein Werkzeug. - Die Werkzeuge sind auf feste Fassungen festgelegt (gitleaks, Trivy, osv-scanner, Semgrep, pnpm). Die geladenen Programme werden vor jeder Benutzung gegen eine SHA256-Prüfsumme im Skript geprüft; bei einer abweichenden Summe oder fehlendem Netz wird nur dieses Werkzeug übersprungen. **Eine Fassung anheben:** im Kopf des Skripts Version, Adresse und Prüfsumme zusammen austauschen (die Summe stammt aus der offiziellen Prüfsummendatei der Veröffentlichung) und einmal lokal `sh .gitea/scripts/security-scan.sh all` laufen lassen. - Das Ergebnis erscheint im Protokoll des Laufs als Zeilen `SECURITY-SUMMARY ...` (nur Zahlen) und, soweit der Runner es zulässt, als Download `sicherheitsberichte`. Einzelheiten und Fehlersuche stehen im [CI/CD-Runbook](ci-cd-setup.md). ### Prüfungen von Hand wiederholen Vom Hauptordner des Repositorys aus: ```bash sh .gitea/scripts/security-scan.sh --print-plan # zeigt Werkzeuge und Abbilder, ohne Netz sh .gitea/scripts/security-scan.sh all # installiert fehlende Werkzeuge und prüft alles SCAN_IMAGES="tessera-ctl-api:latest" sh .gitea/scripts/security-scan.sh run # ein lokales Abbild ``` Das Skript prüft nur den **eingecheckten** Stand; ungespeicherte Änderungen sehen die Werkzeuge nicht. Mit der Umgebungsvariable `SCAN_IMAGES` (Abbilder durch Leerzeichen getrennt) prüfen Sie lokal gebaute Abbilder. Die Berichte liegen im Ordner `security-reports/` (von Git und vom Docker-Build ausgeschlossen); die Zeilen `SECURITY-SUMMARY` stehen auch in `security-reports/summary.txt`. Das Skript beendet sich immer mit Exit 0; ob ein Fund wichtig ist, entscheiden Sie anhand der Zahlen. ### Ausnahmelisten Zwei Dateien nehmen geprüfte Fehlalarme aus den Berichten: `.gitleaks.toml` (Zugangsdaten) und `.semgrepignore` (statische Code-Analyse). Die Regeln: - Aufgenommen wird nur, was von Hand als **Fehlalarm** geprüft wurde. Ein neuer Treffer wird nie vorsorglich freigegeben; ein echtes Geheimnis wird gemeldet und gemeinsam mit der Inhaberin oder dem Inhaber behandelt, nicht eingetragen. - Die Ausnahme ist so **eng wie möglich**: eine einzelne Datei mit verankertem Pfad und der betroffenen Regel, bei Dateien, die keine Tests oder Notizen sind, zusätzlich die eine geprüfte Zeile. Nur der Ordner mit den Testschlüsseln des Zertifikatsmanagers (`apps/api/src/cert-manager/__fixtures__/`) ist als ganzer Ordner freigegeben, denn er existiert, um Testschlüssel zu enthalten. - Jeder Eintrag in `.gitleaks.toml` trägt eine deutsche `description`, die sagt, warum es ein Fehlalarm ist. - Nie ganze Verzeichnisse außerhalb der Ordner mit Testdaten freigeben. ### Abhängigkeiten aktualisieren Die Prüfung der Pipeline (`pnpm audit --prod`, osv-scanner, Trivy) meldet bekannte Schwachstellen in Bausteinen anderer Hersteller. So gehen Sie damit um: 1. **Nur innerhalb der Hauptversion.** Patch- und Nebenstände werden angehoben, ein Sprung auf eine neue Hauptversion nie nebenbei. Zurzeit gelten: Next.js 15, NestJS 11, React 19, Prisma exakt 6.19.3. Eine Meldung, deren Korrektur erst in einer neuen Hauptversion liegt (heute zum Beispiel nodemailer 10 oder sharp 0.35), wird im Sicherheitsprotokoll als „offen“ mit Grund geführt und nicht umgangen. 2. **Direkte Abhängigkeiten werden angehoben, nicht überschrieben.** Steht der Baustein in einer `package.json`, ändern Sie dort die Version (`pnpm --filter @tessera/api add paket@^x.y.z`). `undici` bleibt dabei **exakt** gepinnt, ohne `^` (siehe die Dispatcher-Falle im Kapitel Konventionen). 3. **Mittelbare Abhängigkeiten werden über `pnpm.overrides` angehoben.** Steckt der verwundbare Baustein tief in einem anderen Paket, tragen Sie im Wurzel-`package.json` unter `pnpm` → `overrides` einen Eintrag der Form `"name@>=4 <5": "^4.28.7"` ein: links die Versionslinie, die verwundbar ist, rechts die kleinste bereinigte Fassung. Ein Eintrag gilt **nur innerhalb derselben Hauptversion** (bei Fassungen unter 1.0 derselben Nebenversion) und hebt nur die verwundbare Linie an. Danach `pnpm install` und mit `pnpm why name` prüfen, dass nur noch bereinigte Fassungen übrig sind. 4. **Jeder Override wird im Protokoll vermerkt** (Abschnitt „Verlauf“, Eintrag zur Aktualisierung) und **entfernt**, sobald das übergeordnete Paket die bereinigte Fassung selbst mitbringt. Ein Override ist eine Überbrückung, kein Dauerzustand. 5. **Neue Paketnamen brauchen einen bekannten Vater.** Vergleichen Sie die Paketnamen vor und nach der Aktualisierung in `pnpm-lock.yaml`. Taucht ein Name auf, der vorher nicht da war, muss ein aktualisiertes Paket ihn selbst deklarieren (`npm view paket@version dependencies optionalDependencies`). Ein Name ohne solchen Vater deutet auf einen unterschobenen oder vertippten Baustein; die Aktualisierung, die ihn mitbrachte, wird zurückgenommen. 6. **Prüfen Sie danach** `pnpm audit --prod` (vorher und nachher notieren), Typprüfung, Lint, beide Testläufe, den neu gebauten lokalen Stack und die Rauchtests. Macht ein einzelner Baustein Probleme, nehmen Sie nur ihn zurück und führen ihn als „offen“ mit Grund. 7. **Die Desktop-App** hat eine eigene Paketliste (`apps/desktop/src-tauri/Cargo.lock`). Dort heben Sie einen Baustein mit `cargo update -p name --precise x.y.z` innerhalb seiner Versionslinie an und prüfen mit `cargo check` und `cargo clippy`. ### Prüfung von außen (ZAP) Vor einer Freigabe wird alpha mit dem OWASP-ZAP-Abbild von außen angesehen (Ablauf: Betriebsanleitung, Kapitel 9, „Eine Version freigeben“). Vom Hauptordner des Repositorys aus: ```bash sh .gitea/scripts/zap-baseline.sh --print-plan # zeigt Ziel, Abbild und Aufruf, ohne Netz sh .gitea/scripts/zap-baseline.sh # Lauf gegen https://alpha.tessera.ctl.de ZAP_TARGET=http://HOST:3000 sh .gitea/scripts/zap-baseline.sh # direkt gegen die Anwendung, ohne Proxy ``` Umgebungsvariablen: `ZAP_TARGET` (Ziel; Standard ist die öffentliche Adresse von alpha), `ZAP_SPIDER_MINUTES` (Höchstdauer der Spinne, Standard 3), `ZAP_REPORT_DIR` (Standard `security-reports/zap-{Zeit}`), `ZAP_DOCKER_NETWORK` (optional, Docker-Netz des Containers) und `ZAP_BASIC_AUTH_FILE` (siehe unten). Ergebnis sind `zap.html`, `zap.md` und `zap.json` im Berichtsordner sowie die Zeile `ZAP-SUMMARY ziel=… hoch=… mittel=… niedrig=… info=…` mit je einer Zeile `ZAP-MELDUNG` pro Meldungsart. Das Skript endet mit Exit 0, wenn der Bericht entstanden ist, und mit 3, wenn nicht; so bemerkt der Freigabeschritt einen fehlenden Bericht. Es prüft vorab `GET /login` des Ziels: Antwortet es nicht mit 200, bricht es mit einer deutschen Meldung ab. **Warum nur passiv.** Das Skript nutzt ausschließlich die klassische Spinne, die Links folgt. Den AJAX-Spider gibt es nicht im Aufruf, weil er Formulare der Seite ausfüllen und absenden würde, und für Formulare schaltet der Haken `.gitea/scripts/zap-hooks.py` die Verarbeitung und das Absenden der Spinne ausdrücklich ab. Aktive Regeln laufen nicht. Der Beweis, dass dabei kein POST entsteht, wurde vor dem ersten Lauf gegen einen lokalen Testserver mit Anmeldeformular geführt (0 POST bei allen Anfragen). Hinweis: ZAP 2.17 ignoriert bei diesem Aufruf die Option `-z`; deshalb läuft die Einstellung über den Haken. **Anmeldedatei für den Ausnahmefall.** Der Zugangsschutz (Basic Auth) vor alpha bleibt bestehen. Fragt alpha den Entwicklungsrechner einmal doch nach einer Anmeldung (die Vorabprüfung meldet dann Status 401), legen Sie eine Datei **außerhalb des Repositorys** mit einer Zeile `benutzer:passwort` an und rufen das Skript mit `ZAP_BASIC_AUTH_FILE=Pfad` auf. Der Kopf gelangt über eine temporäre Datei mit Modus 0600 (wird beim Beenden entfernt) in den Container und nie auf eine Befehlszeile oder in eine Ausgabe. Achtung: Der Bericht enthält dann die gesendeten Kopfzeilen und damit die Zugangsdaten; geben Sie ihn nicht weiter. Der Ordner `security-reports/` ist von Git ausgeschlossen. ## Konventionen und Fallstricke **NestJS-Routenreihenfolge:** NestJS matcht Routen in Deklarationsreihenfolge. Eine statische Route wie `@Get('source-config')` **muss vor** einem `@Get(':id')`-Platzhalter derselben Klasse stehen — sonst interpretiert der Platzhalter den literalen Pfadteil als `id` und "beschattet" die statische Route (404 auf die eigentlich vorhandene Route). Das betrifft ausschließlich denselben HTTP-Verb: ein `GET :id` kann niemals eine `POST`-Route beschatten. `apps/api/src/tenders/tenders.controller.ts` dokumentiert das an jeder betroffenen Stelle explizit im Kommentar (`source-config`, `rss-feeds`, `email-config`, `coverage`, `denylisted-portals`, `triage`, `saved-searches`, `notification-pref` — alle vor `@Get(':id')` deklariert) und `module-grants.controller.ts` hält `matrix` bewusst vor `users/:userId`. **Unit-Tests fangen diesen Fehler nicht** — sie rufen üblicherweise die Handler-Methode direkt auf, nicht den tatsächlichen Routing-Mechanismus. Bei jedem neuen `@Get(':id')`/`@Put(':id')`/`@Delete(':id')` in einem Controller mit weiteren statischen GET-Routen: statische Routen zuerst deklarieren. **Änderungsliste (`CHANGELOG.md`):** Jede Änderung, die Anwender oder Betrieb bemerken, wird sofort im selben Auftrag in `CHANGELOG.md` unter „Unveröffentlicht“ eingetragen — in Alltagssprache für Anwender, Sie-Form, echte Umlaute, gegliedert in „Neu“, „Geändert“ und „Behoben“; keine Dateinamen, keine Commit-Kürzel, keine unerklärten Fachbegriffe. Bei der Freigabe wird der Abschnitt in „X.Y.Z – JJJJ-MM-TT“ umbenannt und darüber ein neues leeres „Unveröffentlicht“ angelegt (siehe Betriebshandbuch Kapitel 9). Die Seite „Was ist neu“ (`apps/web/src/app/(portal)/changelog/page.tsx`) liest den Text zur Bauzeit aus `env.TESSERA_CHANGELOG_MD`, das `apps/web/next.config.ts` aus der Datei befüllt — deshalb steht `COPY CHANGELOG.md ./` im Web-Dockerfile und `!CHANGELOG.md` als Ausnahme in `.dockerignore`. Nur Server-Code darf `@/lib/changelog` importieren: `page.tsx` und die `'use server'`-Datei `apps/web/src/lib/release-notice-actions.ts` — nie eine Client-Komponente, auch nicht `apps/web/src/lib/release-notes.ts` (dessen Typen nutzen Client-Komponenten). So bleibt der Text im Server-Bundle und gelangt nicht in öffentlich abrufbare Client-Chunks. Die Kanalregel (Live ohne „Unveröffentlicht“, Beta/Entwicklung mit „Noch nicht freigegeben (Beta)“) liegt in `filterChangelogForChannel` (`apps/web/src/lib/changelog.ts`) mit Tests. Beim Tag `vX.Y.Z` schneidet `.gitea/scripts/publish-release.sh` den Abschnitt der Version heraus und legt daraus den Gitea-Release an — fehlt der Abschnitt, bricht dieser CI-Schritt mit Exit 1 ab. **„Was ist neu“-Fenster nach einem Versionswechsel (quick-260925-bow):** Beim ersten Laden des Portal-Rahmens nach einem Versionswechsel zeigt `ReleaseNoticeHost` (in `AppShell`, also nie auf der Anmeldeseite; nicht auf `/change-password`) einmal ein Fenster mit den Gruppen Neu / Geändert (angezeigt als „Verbessert“) / Behoben der verpassten Versionen, höchstens drei, neueste zuerst. Einzige Quelle der laufenden Version ist `APP_VERSION` der API, gelesen über `getRunningRelease()` in `apps/api/src/health/app-version.ts` — nicht `NEXT_PUBLIC_APP_VERSION` des Webs. Grund: der LDAP-Abgleich (Zeitplan) und der Erst-Administrator (API-Start) legen Benutzer ohne jede Web-Anfrage an und tragen dabei die laufende Version ein; ebenso prüft der Merk-Endpunkt gegen diesen Wert. Endpunkte: `GET /users/me/release-notice` liefert `currentRelease` und den gemerkten Stand; `POST /users/me/release-seen` mit `{ version }` nimmt nur die kanonische Form `X.Y.Z` an, die nicht über der laufenden Version liegt, senkt einen gemerkten Stand nie ab und schreibt ausschließlich die eigene Zeile (`forTenant()`, `where: { id: currentUser.id }`). Gemerkt wird erst beim Schließen, nie beim Öffnen. Spalte `User.lastSeenReleaseVersion`: `null` = Bestandsbenutzer, dann zeigt das Fenster nur die laufende Version; `UserService.create()` und der Admin-Seed tragen bei der Anlage die laufende Version ein. `parseReleaseVersion`/`compareReleaseVersions` stehen einmal in `packages/shared/src/index.ts` (Laufzeit-Import in API und Web). Die Auswahl der Abschnitte macht `selectReleaseNotice` in `apps/web/src/lib/release-notes.ts`. Folge für die Freigabe: erst ein Tag `vX.Y.Z` (auf der Beta dessen Describe-Stand `vX.Y.Z-N-g`, gekürzt auf `X.Y.Z`) löst das Fenster aus; Punkte unter „Unveröffentlicht“ erscheinen darin nie. Lokal steht `APP_VERSION` auf `dev` — dann erscheint nie ein Fenster; zum Ausprobieren beim Bau `--build-arg APP_VERSION=1.4.0` setzen. Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds, entsteht kein Fenster und nichts wird gemerkt. **Dashboard-Hintergrund pro Benutzer (quick-260928-ujj):** Die Hintergrundwahl des Designs „Mosaik“ steht in der Spalte `User.dashboardBackground` (JSONB, Migration `20260928120000_user_dashboard_background`): `null` = nie gewählt, sonst ein normalisiertes Objekt `{ kind: 'none' }`, `{ kind: 'preset', id }` oder `{ kind: 'image', imageId }`. Geschrieben wird nur über `PATCH /users/me/dashboard-background` mit `{ background }` — ohne Kennungsparameter, ausschließlich die eigene Zeile (`forTenant()`, `where: { id: currentUser.id }`); ein ungültiger Wert ergibt 400 und schreibt nichts. Gelesen wird der Wert mit der Sitzungsantwort (`GET /auth/me`, `AuthService.getMe`, neben `accentColor`) und landet über die `setUser`-Abbildung in `header.tsx` im Auth-Store; `useDashboardBackground()` in `apps/web/src/components/dashboard/dashboard-background.tsx` liest von dort und speichert über `updateDashboardBackgroundAction` (optimistisch, bei Fehlschlag zurückgesetzt). Einzige Prüfregel ist `parseDashboardBackground` in `packages/shared/src/index.ts` (Laufzeit-Import in API und Web, angewendet beim Schreiben UND beim Lesen): `kind` und die Preset-Kennungen (`DASHBOARD_BACKGROUND_PRESET_IDS`) aus einer festen Liste, `imageId` nur als UUID, weil das Web den Wert als CSS-Hintergrund `url("...")` rendert. Wer ein neues Preset ergänzt, trägt es dort und in `BACKGROUND_PRESETS` (`apps/web/src/lib/dashboard-background.ts`) ein — ein Test prüft, dass beide Listen deckungsgleich sind. Der frühere localStorage-Schlüssel `tessera.dashboardBackground.` wird von `takeLegacyDashboardBackground` einmal gelesen, entfernt und nur übernommen, wenn der Server-Wert noch `null` ist. **i18n — Schlüsselparität zwischen de.json und en.json:** Jeder benutzersichtbare Text gehört in beide Sprachdateien, `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json`. Ein strukturelle Wächter-Test, `apps/web/src/messages/tenderRadar-parity.spec.ts`, prüft für den `tenderRadar`-Namensraum automatisiert, dass beide Dateien exakt denselben (rekursiv aufgeschlüsselten) Schlüsselsatz besitzen und jeder Blattwert eine nicht-leere Zeichenkette ist — ein Schlüssel, der nur in einer Sprache ergänzt wird, lässt den Test fehlschlagen. Zusätzlich prüft `apps/web/src/messages/umlaut-guard.spec.ts` ausschließlich das geparste JSON von `de.json`/`en.json` gegen ein Wörterbuch aus `umlaut-dictionary.ts`: keine ae/oe/ue/ss-Ersatzschreibweise („fuer“, „loeschen“) darf mehr vorkommen, außer sie steht auf einer Allowlist korrekter deutscher Wörter, die zufällig `ae/oe/ue/ss` enthalten (z. B. „Passwörter“, „ausschließen“). Beide Wächter lesen bewusst nur das geparste JSON, nie den Quellcode-Baum — ein repo-weiter Grep würde am Wörterbuch selbst scheitern, weil dessen Schlüssel notwendigerweise die falschen Schreibweisen enthalten. **Tailwind 4 — der `dark:`-Selektor muss explizit an `.dark` gebunden werden:** Tailwind 4 bindet `dark:` standardmäßig an `prefers-color-scheme`, also an die Betriebssystem-Einstellung. Tessera schaltet den Modus aber über `next-themes` mit `attribute="class"` um — der Benutzer wählt hell/dunkel im Portal, unabhängig vom System. `apps/web/src/app/globals.css` bindet den Selektor deshalb explizit an die `.dark`-Klasse: ```css @custom-variant dark (&:where(.dark, .dark *)); ``` Fehlt diese Zeile, schalten die Farbtoken unter `.dark` weiter unten in derselben Datei zwar korrekt um, aber **jede einzelne `dark:`-Utility im Quellcode bleibt wirkungslos**, sobald System- und Portal-Einstellung nicht zufällig übereinstimmen. Der Fehler fällt dabei nicht sofort auf, weil Hintergrund- und Textfarbe über die CSS-Variablen laufen, nicht über `dark:`-Utilities — die Oberfläche wird also grundsätzlich dunkel, nur Feinheiten (Status-, Warn- und Fehlerfarben, Hinweisboxen, Badges, wie im Projekt bereits an über 100 Stellen betroffen) bleiben falsch. Jede neue `dark:`-Utility-Klasse im Projekt setzt voraus, dass diese Zeile in `globals.css` unverändert bleibt. **Dateien (Nextcloud), Teilen (quick-261009-dkv):** Das Teilen läuft über eine eigene OCS-Schicht, `apps/api/src/nextcloud-files/nextcloud-shares.ts`, und nicht über den Anmelde-Helfer `ocsRequest` aus `nextcloud-auth-client.ts`. Der Anmeldecode wertet einen 403 als „App-Passwort vorhanden“ und wirft den Text jeder Fehlerantwort weg; beim Teilen steht genau dort die Begründung der Nextcloud (`ocs.meta.message`), und ein 403 heißt „nicht erlaubt“. Die Schicht liest den Antwortkörper deshalb bei jedem Status (Erfolg bis 8 MiB, Fehler bis 64 KiB, Fähigkeiten bis 1 MiB), bleibt sonst aber bei den Regeln der Etappe 1: fester Pfadanfang `/ocs/v2.php/`, jedes Segment einzeln codiert, keine Weiterleitungen, keine Cookies, und es wird nie eine Adresse aus einer Nextcloud-Antwort aufgerufen (weder die `url` einer Freigabe noch `api.generate`). Die Parser bauen kleine eigene Ansichten; Passwort (`hasPassword` statt Wert), Kennung und Adressen fremder Links verlassen sie nie. Der Browser schickt nie Bitmasken. Die Routen (`shares/policy`, `shares/by-path`, `sharees`, `shares/mine`, `shares/received`, `POST shares`, `PUT/DELETE shares/:id`, `POST shares/:id/accept`) nehmen nur die Aufzählungen `kind` (`user`, `group`, `link`) und `access` (`view`, `edit`, `upload`); `permissionsFor` rechnet sie selbst in Bits um, Eintragsart und Schreibrecht kommen aus einer eigenen PROPFIND- bzw. GET-Abfrage, das Teilen-Bit 16 wird nie gesendet. Die statischen Routen stehen vor den `:id`-Routen (das Controller-Spec prüft die Reihenfolge). Die Freigaberegeln (Links erlaubt, Passwortpflicht, Ablaufdatum mit Tagen, öffentliches Hochladen, Gruppen, Mindestlänge) liest der Dienst bei **jedem** Schreibzugriff frisch aus `cloud/capabilities` des jeweiligen Benutzers und prüft vorab; es gibt keinen Zwischenspeicher. Nextcloud selbst beantwortet die Fähigkeiten nach einer `occ`-Änderung noch einige Sekunden aus dem Zwischenspeicher (der Live-Test wartet darauf mit `policy_wait`). Fehlerabbildung (`mapShareFailure`, immer nach Aufruf, Status und gesendeten Feldern, **nie nach dem Text** der Nextcloud, der übersetzt sein kann): Nextcloud verschweigt bei `PUT` die Gründe, und Ablauffehler kommen als **404**, nicht als 400 (gemessen: `PUT` mit Ablauf jenseits des Höchstwerts ergibt 404, ein schwaches Passwort 400). Daraus folgt: 400 mit gesendetem Passwort ist `sharePasswordRejected`, 404 oder (beim Ändern) 400 mit gesendetem Ablaufdatum ist `shareExpiryInvalid`, 404 ohne beides ist beim Anlegen `shareRecipientInvalid` (Link: `notFound`), beim Ändern `shareNotFound`; 401 setzt die Verbindung auf „abgelaufen“, 429 und ab 500 laufen unverändert durch `mapNcFailure`, und ein 403 der Nextcloud kommt als `shareRejected` (422) beim Browser an. 401 und 403 verlassen die API nie. Die Datums-Eingabe prüft der DTO streng (`YYYY-MM-DD` plus echte Datumsprüfung `isRealDate`), weil Nextcloud Daten großzügig parst (`31.12.2026x` würde angenommen). Zwei Fallen beim Anlegen: Ein `POST` für einen Empfänger, der die Freigabe schon hat, liefert die alte Freigabe zurück und **verschickt die Benachrichtigung erneut**. Der Dienst prüft deshalb vorab über `shares/by-path`, ob es sie schon gibt (`shareAlreadyExists`, 409), und ändert Berechtigungen immer per `PUT`. Außerdem begrenzt Tessera selbst auf 10 neue Freigaben je Benutzer in 10 Minuten (`checkShareCreate`, im Arbeitsspeicher eines Prozesses, zählt auch Versuche, die die Nextcloud ablehnt; unmittelbar vor dem `POST`, nach allen Vorprüfungen). Davor zählt `checkShareAttempt` jeden Versuch, auch abgelehnte (40 in 10 Minuten), und zwar vor den Abfragen an die Nextcloud: so können wiederholte, abgelehnte Anfragen („gibt es schon“) sie nicht beliebig oft belasten. Beide Zähler räumen leere Einträge regelmäßig weg. Die Zusicherung gilt nur je Prozess ohne Neustart, und Freigaben, die direkt in der Nextcloud entstehen, zählen gegen deren Limit, ohne dass Tessera es weiß; deshalb 10 statt 20. Das liegt absichtlich unter dem Limit der Nextcloud (20 in 600 s): Deren 429 würde die Aufrufsperre des Ursprungs auslösen, und die hielte die Anfragen aller Benutzer 15 Minuten an. Die Oberfläche legt immer nur eine Freigabe zugleich an. Gemessen nach der Prüfung (Nextcloud 34.0.4): `uid_owner` einer Freigabe ist der **Freigebende**, `uid_file_owner` der Eigentümer der Datei. Gibt Ben einen Ordner weiter, den Anna ihm geteilt hat, steht bei ihm `uid_owner = ben`, `uid_file_owner = anna`, `path` im Baum von Ben und `file_target` im Baum des Empfängers. Tessera behandelt das als eigene Freigabe von Ben (Pfad = `path`, Hinweis „Von Ihnen weitergegeben“); als eingehend gilt nur, was ein anderer freigegeben hat. Pfade, Ziele und Empfängerkennungen gehen unverändert zurück an die Nextcloud und werden nie bereinigt (`verbatimId`; ein doppeltes oder nachgestelltes Leerzeichen gehört zum Namen); nur Anzeigetexte laufen durch `cleanText`. Eine offene Freigabe lehnt man mit `DELETE shares/{id}` ab (200; `DELETE shares/pending/{id}` ergibt 405). Die Berechtigungsbuchstaben eines Ordners kommen mit seiner eigenen Zeile in der Listenantwort (`permissions` der Liste): eigener Ordner `RGDNVCK`, zum Ansehen geteilter Ordner selbst `SGDN`, sein Inhalt `SG`; die Dateiansicht blendet damit „Neuer Ordner“ (`K`), „Hochladen“ und Ablegen (`C`) und je Eintrag Umbenennen (`N`), Verschieben (`V`) und Löschen (`D`) aus. Fehlen die Buchstaben, bleibt alles sichtbar (unbekannt ist nie verboten). Live-Test: `.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh [people|links|received|version|all]` gegen die Test-Nextcloud (`nc-test-setup.sh` der Etappe 1 vorher). Er legt Benutzer `ben` und Gruppe `tessera-team` an, schaltet die Ratenbegrenzung der Nextcloud (`ratelimit.protection.enabled`) nur für den Lauf aus und stellt die Regeln über `occ config:app:set core …` um (`shareapi_enforce_links_password`, `shareapi_default_expire_date`, `shareapi_enforce_expire_date`, `shareapi_expire_after_n_days`; für die offenen Freigaben `user:setting anna files_sharing default_accept`). Alles setzt der `trap` zurück. Der Zähler von Tessera liegt im Prozess: `all` verbraucht 7 der 10 Plätze, nach jedem `all`-Lauf `docker compose restart api`. **Zertifikatsmanager, Arbeitsbereich und Ketten (quick-261009-ikt):** Der Zertifikatsmanager (`apps/api/src/cert-manager`, `apps/web/src/app/(portal)/modules/cert-manager`) hält **nichts** auf dem Server. Die Oberfläche führt eine Liste von Dateien nur im Arbeitsspeicher des Browsers (`use-cert-workspace.ts`, rein mit `working-set.ts`) und schickt bei jeder Änderung die ganze Liste an `POST analyze` (multipart, höchstens 30 Dateien zu je 5 MiB, zusammen 20 MiB); ältere Antworten verwirft ein Anfragezähler. Es gibt genau drei POST-Routen, alle ohne Zustand: `analyze`, `build` (JSON) und, ab dem Abschnitt zum Nachladen fehlender Zertifikate, `fetch-issuer`. Fehler tragen immer einen Code im Körper (`{ code, message }`, Liste in `cert-types.ts`), den die Oberfläche unter `certManager.errors.` übersetzt. *Ein Parser.* **`node:crypto` entscheidet alles über Zertifikate und Schlüssel** (`X509Certificate`, `createPrivateKey`, `createPublicKey`, `KeyObject#export`), denn nur so laufen RSA **und** EC durch denselben Weg. `node-forge` bleibt ausschließlich für PKCS#12 (Lesen und Schreiben) und als allgemeiner ASN.1-Leser und -Schreiber (PKCS#7 lesen und bauen, Zertifikatsanfragen lesen). Kein Produktivcode ruft `certificateFromPem`, `certificateFromAsn1`, `certificationRequestFromAsn1`, `messageFromPem` oder `messageFromAsn1` auf, denn diese forge-Leser können nur RSA; das prüft das Gate in der Aufgabenkette per `grep`. Die Erkennung (`cert-model.ts`, `detectBlob`) ist eine feste Stufenfolge (ZIP, PEM-Blöcke, DER-Zertifikat, PKCS#12, PKCS#7, Schlüssel, Anfrage), jede Stufe in `try/catch`: eine kaputte Datei wird `unknown`, nie ein Fehler der Anfrage. Erkannt wird am Inhalt, nie an der Dateiendung. *Ketten.* `cert-chain.ts` (`buildChains`) nimmt als Aussteller nur Zertifikate, für die `C.checkIssued(I)` **und** `C.verify(I.publicKey)` gelten. `checkIssued` allein vergleicht nur Namen und Schlüsselkennungen; ein gleichnamiges Zwischenzertifikat mit anderem Schlüssel (Fixture `rsa-inter-decoy`) besteht es und würde ohne die Unterschriftsprüfung fälschlich genommen. Selbstsigniert heißt: Unterschrift mit dem eigenen Schlüssel stimmt und (`checkIssued` gegen sich selbst oder Aussteller gleich Inhaber). Mehrere Wege rangiert die Funktion fest (endet bei einer Wurzel der Liste, weniger abgelaufene, kürzer, späteres Ablaufdatum, dann SHA-256), damit das Ergebnis nie vom Zufall abhängt. Schlüssel und Anfragen ordnet `matchKeys` mit `checkPrivateKey` bzw. dem Vergleich der öffentlichen Schlüssel zu, nie nach Namen. `build` baut die Reihenfolge **immer neu** aus den gesendeten Zertifikaten; eine vom Browser mitgeschickte Reihenfolge gäbe es nicht einmal als Feld. *PFX schreiben.* forge schreibt von sich aus nur RSA. `cert-pkcs12.ts` (`writePkcs12`) tauscht deshalb für die Dauer **eines** synchronen Aufrufs drei Funktionen von `forge.pki` (`privateKeyToAsn1`, `wrapRsaPrivateKey`, `certificateToAsn1`) gegen Durchreicher aus und stellt sie im `finally` wieder her; ASN.1 für Zertifikate und Schlüssel kommt aus `node:crypto`, die Verschlüsselung und den MAC macht weiterhin forge. Das ist sicher, weil Node einfädig ist und der Aufruf nicht abgibt; das Spec prüft die Wiederherstellung auch nach einem Fehler. **Passwörter:** forge leitet den AES-Schlüssel (PBES2/PBKDF2) aus einem Byte je UTF-16-Einheit ab, OpenSSL 3, Windows und Java nehmen die UTF-8-Bytes; mit Umlaut oder `€` wäre ein „modernes“ PFX sonst für jedes andere Programm unlesbar (und umgekehrt, Review CR-03). Darum ersetzt `withForgeKdf` (Lesen **und** Schreiben) zusätzlich `forge.pkcs5.pbkdf2` durch eine Ableitung mit `node:crypto` aus den UTF-8-Bytes (auch SHA-256/512 nativ statt in reinem JavaScript). Die PKCS#12-eigene Ableitung (3DES, RC2, MAC) bleibt, wie sie ist: sie nimmt BMPString (UTF-16) aus den Zeichen, genau wie OpenSSL. Dieselbe Stelle zählt die Ableitungsrunden (siehe „Grenzen je Anfrage“). Profile: `compat` (3DES, SHA-1, Vorgabe, lesbar bis Windows Server 2016) und `modern` (AES-256, der MAC bleibt bei forge SHA-1). Die Vorlagen für IIS und Tomcat nutzen dieselbe Funktion (`cert-templates.ts`); die Vorlagen für Dateien bauen aus denselben Bausteinen wie `buildOutput` und kennen `cert-output.ts` bewusst nicht (sonst entstünde eine Importschleife). *ZIP-Grenzen.* `zip-expand.ts`: höchstens 100 Einträge, 1 MiB je Eintrag, Verhältnis entpackt zu gepackt höchstens 100, Summe 20 MiB, nur eine Ebene (ein ZIP im ZIP wird gemeldet, nicht geöffnet), verschlüsselte Einträge werden gemeldet. Die Kopfdaten (deklarierte Größe im Zentralverzeichnis) sind nur Angreiferwunsch: adm-zip begrenzt die Ausgabe bei deklarierter Größe 0 gar nicht, ein 300-kB-ZIP entpackte dort zu 300 MiB (Review CR-01). Darum entpackt das Modul selbst (`inflateRawSync` mit `maxOutputLength` = Einzelgrenze, höchstens die Restgrenze der Summe) und prüft Größe, Verhältnis und CRC am echten Ergebnis; weicht die echte Größe von der deklarierten ab, gilt der Eintrag als `suspicious`. Eintragsnamen dienen nur der Anzeige und werden nie als Dateipfad benutzt. *Grenzen je Anfrage.* `cert-budget.ts` (`RequestBudget`, in `analyzeWorkingSet` einmal je Anfrage angelegt und über `DetectContext.budget` durch alle Stufen gereicht): höchstens 200 Zertifikate, 50 Schlüssel, 50 Zertifikatsanfragen (413 `tooManyItems`; der Kettenbau ist quadratisch). Der Aufwand des Passwortschutzes ist begrenzt (Review WR-04): eine Ableitung höchstens 1 000 000 Runden, alle zusammen höchstens 6 000 000 je Anfrage; Runden werden vor dem Rechnen gemeldet (PKCS#12 über die drei Ableitungsfunktionen von forge, verschlüsseltes PKCS#8 über `pkcs8Iterations` aus den Parametern), zu teuer ergibt `protectionTooExpensive` für diese Datei. Der PEM-Scanner `pem-scan.ts` ist linear (Review CR-02): die alte Regex `BEGIN … END` war bei vielen BEGIN-Zeilen ohne END quadratisch und blockierte die API (5 MiB: Minuten); der Scanner sammelt die END-Stellen je Etikett in einem Durchlauf und begrenzt die Blöcke auf 1000 je Text. Stellen, die Lesefehler bewusst verschlucken, reichen die Anfragegrenzen mit `rethrowRequestError` weiter. Beim Empfang zählt `cert-upload.ts` (eigener multer-Speicher) die Summe mit und bricht bei 20 MiB mit 413 `tooLarge` ab, statt erst nach 30 vollständig gepufferten Dateien (Review WR-06). Die HTTP-Einrichtung (`http-setup.ts`, aus `main.ts`) hängt den `build`-Leser **hinter** `enableCors` ein, damit auch seine 413/400-Antworten CORS-Kopfzeilen tragen (Review WR-01; das Spec prüft die Reihenfolge). *Grenze des Anfragekörpers von `build` (D-26).* Die Express-Voreinstellung von 100 kB reicht für die größte erlaubte `build`-Anfrage nicht (Zertifikat, 20 Pool-Zertifikate, Schlüssel und Anfrage zu je 16 384 Zeichen ergeben rund 384 kB). Deshalb hat nur diese Route einen eigenen JSON-Leser mit 512 KiB (`cert-json-body.ts`), den `configureHttp` in `http-setup.ts` per `app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors)` nach `enableCors` und **vor** `app.listen` einhängt (Nest registriert seine eigenen Leser erst in `init()`). Zwei Fallen: Erstens liegt `express` nicht in `apps/api/node_modules`, der Leser kommt deshalb über `createRequire(...)` aus der Kopie, die auch `@nestjs/platform-express` lädt. Zweitens **darf die Funktion nicht `jsonParser` heißen**: Nests `ExpressAdapter` überspringt seinen eigenen JSON-Leser für **alle** Routen, sobald schon eine Schicht mit diesem Funktionsnamen im Router liegt (`isMiddlewareApplied`); deshalb heißt sie `certBuildJsonBody`. Eine größere Anfrage bekommt 413 mit Code `tooLarge`, kaputtes JSON 400 mit `invalidInput`; alle anderen Routen behalten die 100 kB. Das Spec `cert-json-body.spec.ts` rechnet die größte DTO-Anfrage aus den Konstanten in `dto/cert-build.dto.ts` aus und bleibt rot, wenn jemand dort eine Obergrenze erhöht, ohne die Grenze mitzuziehen. *Fixtures.* Die Testdaten liegen unter `apps/api/src/cert-manager/__fixtures__/` und entstehen mit `make-fixtures.sh` (OpenSSL 3.4 oder neuer; Passwort aller geschützten Dateien `Test-Pass-123`, die Schlüssel der CAs werden nach der Erzeugung gelöscht). Dateinamen enden **nie** auf `.key`, denn die `.gitignore` ignoriert `*.key` wegen des Updater-Schlüssels; Schlüssel heißen `-key.pem` oder `-key-….der`. Die Specs lesen die Dateien mit `readFileSync` und rufen OpenSSL nie auf (die CI hat es nicht); die Live-Prüfung mit OpenSSL-Gegenprobe (`openssl verify`, `pkcs12 -info`, `pkcs7 -print_certs`, `pkey`) liegt im Skript `e2e-cert.sh` der Aufgabe. *Fehlendes Zertifikat holen und der gemeinsame Adressschutz.* `POST fetch-issuer` (`cert-aia.ts`, `dto/cert-fetch-issuer.dto.ts`) nimmt **nur** `pem`; die Adresse liest der Server selbst aus dem Zertifikat (`toLegacyObject().infoAccess['CA Issuers - URI']`), der Browser nennt nie eine (die `ValidationPipe` mit `whitelist` entfernt jedes weitere Feld, das Controller-Spec prüft es). Es werden höchstens drei Adressen der Reihe nach versucht: nur `http`/`https`, ohne Zugangsdaten, höchstens 2048 Zeichen, nur der Standardport (ein Abruf auf `:8080` ist ein Portscanner). Die Schleife ist die von `nextcloud-status/nextcloud-logo-fetch.ts` (Adressschutz vor der ersten Anfrage und vor jeder Weiterleitung, `redirect: 'manual'`, höchstens drei Sprünge, ein Zeitlimit von 8 s mit Wette gegen den Abbruch, 256 KiB, `content-length` vorab und beim Lesen), dazu zwei Unterschiede: Erstens läuft der echte Abruf über einen eigenen `undici`-`Agent`, dessen `connect.lookup` (`createGuardedLookup`) jede aufgelöste Adresse prüft und bei einer nicht öffentlichen abbricht. Das schließt für diese Funktion das DNS-Rebinding-Fenster, das die anderen Nutzer des Adressschutzes bewusst offen lassen (Name wird vorher aufgelöst und beim Verbinden noch einmal). Zweitens wird eine Antwort nur angenommen, wenn `target.checkIssued(c)` **und** `target.verify(c.publicKey)` gelten (DER-Zertifikat, PKCS#7 als DER oder PEM, PEM-Text; sonst `aiaNotIssuer`). Der bewusst akzeptierte Rest steht im Kopfkommentar von `cert-aia.ts`: Jeder angemeldete Benutzer des Moduls kann den API-Server dazu bringen, einen einzigen GET an eine öffentliche Adresse zu senden, die in einem von ihm hochgeladenen Zertifikat steht. Die Oberfläche holt nie von selbst etwas (`ChainView`, Knopf), das geholte Zertifikat wird als Eintrag mit Herkunft `fetched` und Server (`working-set.ts`, `addFetched`) angehängt und beim nächsten Durchlauf mit allem anderen analysiert. Der **gemeinsame Adressschutz** `common/public-url-guard.ts` (Favoriten-Symbole, Logo-Abruf von Nextcloud-Status und dieser Abruf) wurde dafür gehärtet: `isPrivateIpv6` zerlegt eine Adresse vollständig in acht Gruppen (`expandIpv6`) und erkennt damit auch versteckte Schreibweisen interner Adressen, nämlich IPv4-gemappt in Hex-Form (`::ffff:7f00:1`), IPv4-kompatibel (`::7f00:1`), NAT64 (`64:ff9b::/96` nach eingebetteter IPv4, `64:ff9b:1::/48` immer), 6to4 (`2002::/16`), Teredo, Dokumentationsbereiche, `100::/64`, Unique-Local, Link-Local (auch mit Zonenkennung `%eth0`), Site-Local und Multicast; nicht lesbare Adressen bleiben gesperrt. `isPrivateIpAddress` ist jetzt exportiert, das neue Spec `public-url-guard.spec.ts` prüft jede dieser Schreibweisen und `isPublicHttpUrl` mit einer nachgebildeten Namensauflösung. Wer den Schutz erweitert, lässt die Specs von `favorites` und `nextcloud-status` mitlaufen.