Files
tessera-ctl/docs/anleitung-entwicklung.md
T
schalli fceec19ded fix(quick-261009-p0m): Schutz-Kopfzeilen in der Weboberfläche, X-Powered-By abgeschaltet
- next.config.ts: nosniff, Referrer-Policy, X-Frame-Options SAMEORIGIN, CSP nur frame-ancestors 'self', Permissions-Policy (Kamera, Mikrofon, Standort, Zahlung, USB), COOP same-origin-allow-popups, poweredByHeader aus
- API: X-Powered-By (Express) in configureHttp abgeschaltet
- Tests: next-config.test.ts, http-setup.spec.ts
- Sicherheitsprotokoll (Zeilen der Außenprüfung), Entwicklungsanleitung, CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 20:29:07 +02:00

1313 lines
89 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# 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 <db-container-name> \
| 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@<container-ip>: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 `<ordner>/<seed-name>.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(<NAME>_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. `<ordner>/<seed-name>.changelog.ts` anlegen und eine erste Version `1.0.0` mit dem heutigen Datum
eintragen (Export `<SLUG_MIT_UNTERSTRICH>_CHANGELOG`, Typ `ModuleChangelog`).
2. Den Changelog in `module-changelog.registry.ts` eintragen (alphabetisch nach Slug).
3. In der Seed-Datei `version: latestVersion(<NAME>_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 <letzter-tag>..HEAD -- apps/api/src/<ordner> \
"apps/web/src/app/(portal)/modules/<slug>" apps/web/src/components/<name>
```
Zusätzliche Modulpfade sind die Dashboard-Kachel `apps/web/src/components/dashboard/widgets/<slug>-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 <ModuleAccessGate moduleSlug="dkv-fleet">{children}</ModuleAccessGate>;
}
```
**Regel für neue Module:** Ein neues fest verdrahtetes Modulverzeichnis unter `modules/<slug>/`
braucht **immer** ein eigenes `layout.tsx` mit `ModuleAccessGate`, genau wie sein Backend-Controller
`@UseModule('<slug>')` 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/<name>-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['<typ>'] = '<modul-slug>'` 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('<typ>', <Name>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** (`<typ>.name` und
`<typ>.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('<slug>')` 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 <beschreibender-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
**Laufzeitabbilder ohne Entwicklungswerkzeuge (quick-261009-p0m):** Das Server-Abbild
(`apps/api/Dockerfile`) hat eine eigene Stufe `prod-deps`: dort läuft
`pnpm install --frozen-lockfile --prod --filter=@tessera/api...`, und der Prisma-Client wird in
genau dieser Stufe erzeugt (der `postinstall` von `apps/api` braucht das Schema vor der
Installation, deshalb wird `apps/api/prisma` vorher kopiert). Ein fester Pfad aus der
Builder-Stufe wäre fehleranfällig, weil der Ordnername von `@prisma/client` im pnpm-Speicher von
den aufgelösten Peer-Paketen abhängt. Die Laufzeitstufe beider Abbilder (`api` und `web`) kommt
direkt aus `node:24-alpine` und entfernt als Erstes npm, npx, corepack und yarn. Im Container gibt
es dadurch kein `pnpm` und kein `npm` mehr; Befehle laufen mit `node` (der Prisma-Aufruf des
Startskripts liegt unter `apps/api/node_modules/.bin/prisma`). **Falle:** Importiert Laufzeit-Code
ein Paket, das nur unter `devDependencies` steht, scheitert das erst im Container – die Unit-Tests
laufen mit allen Abhängigkeiten und fangen es nicht. Dann gehört das Paket in `dependencies`.
**Beweis nach jeder Änderung an den Abbildern:** ein Start gegen eine frische, leere Datenbank (alle
Migrationen laufen, die Zeile „Tessera API running“ erscheint) plus die Rauchtests am neu gebauten
Stack – so wie es `checks/fresh-db-start.sh` im Auftrag quick-261009-p0m vormacht.
**Schutz-Kopfzeilen der Weboberfläche (quick-261009-p0m):** `apps/web/next.config.ts` sendet für
alle Pfade `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options: SAMEORIGIN`, eine
Content-Security-Policy, die **nur** `frame-ancestors 'self'` enthält (keine vollständige
Richtlinie – sie braucht eine eigene Prüfung der Inline-Skripte von Next.js), eine
`Permissions-Policy` (Kamera, Mikrofon, Standort, Zahlung, USB gesperrt) und
`Cross-Origin-Opener-Policy: same-origin-allow-popups`; `poweredByHeader` ist aus. **Vor jeder
Erweiterung prüfen:** Die Zwischenablage (`clipboard-write` für „Link kopieren“) darf nicht in der
Permissions-Policy gesperrt werden. Neue Fenster müssen weiter mit `noopener` geöffnet werden;
wer `window.opener` oder `postMessage` zwischen Fenstern braucht, muss die Opener-Richtlinie
überdenken. Eingebettete fremde Seiten (XFrame-Kachel, eigene Module) sind von diesen Kopfzeilen
nicht betroffen. Die API schaltet `X-Powered-By` in `configureHttp` ab (`apps/api/src/http-setup.ts`).
Geprüft werden die Kopfzeilen durch `apps/web/src/next-config.test.ts` und durch die Außenprüfung
(ZAP, siehe „Sicherheitsprüfungen“).
**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<sha>`, 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.<userId>` 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.<code>` ü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.