fceec19ded
- 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>
1313 lines
89 KiB
Markdown
1313 lines
89 KiB
Markdown
<!-- 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.
|