- 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>
89 KiB
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
- Aufbau des Monorepos
- Lokale Entwicklungsumgebung
- Architektur im Überblick
- Das Modulsystem
- Mandantentrennung
- Berechtigungen
- Datenbank und Migrationen
- Tests
- Sicherheitsprüfungen
- 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 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 (
packageManagerinpackage.jsonpinntpnpm@9.15.0)
Die produktiven Dockerfiles 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 isttessera_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 mitopenssl 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
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
Das startet:
- web — Next.js mit Turbopack (
next dev --turbopack), Port3000 - api — NestJS mit Watch-Modus (
nest start --watch), Port3001 - db — Postgres 16 (Alpine), ohne Host-Port
- mailhog — SMTP-Testserver, UI auf
8025, SMTP auf1025 - openldap / phpldapadmin — für LDAP-Sync-Entwicklung, Ports
389/636bzw.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).
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 Abbilderweb,apiunddbaus der Registry; der Kanal (betaoderlive) kommt ausIMAGE_TAGin der.envdes jeweiligen Servers (Vorgabebeta).docker-compose.ci.yml— beschreibt den Gitea-Runner (act_runner), der die CI-Aufträge in Containern ausführt. Details dazu stehen indocs/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:
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
- Auf Ubuntu/Debian folgende Systempakete:
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:
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:
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:
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) — Details im Abschnitt Das Modulsystem.
Weitere Bereiche hinter dem Login:
/changelog— die Seite „Was ist neu“ (siehe Konventionen und Fallstricke)./admin— der Bereich „Administration“ (Benutzer, Gruppen, Module, Verzeichnisanbindung). Unterbereiche:users,groups,modules(mitgrantsfür die Freigaben undcategoriesfür die Modulkategorien),custom-modules,ldap,smtp,welcome-mailundtenants./settings— persönliche Einstellungen mitgeneral(Konto, Desktop),dashboardundcustom-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):
- Eine Server- oder Client-Komponente unter
apps/web/src/app/(portal)/...ruft die API überfetchgegenNEXT_PUBLIC_API_URL(Browser) bzw.API_INTERNAL_URL(Server-Komponenten, zeigt intern aufhttp://api:3001) auf. - Die Anfrage trifft in
apps/api/src/main.tsauf die globaleValidationPipeund läuft dann durch die drei global registriertenAPP_GUARDs ausapp.module.ts, in genau dieser Reihenfolge:JwtAuthGuard(Auth) →TenantGuard(setztreq.tenantIdaus dem JWT) →RolesGuard(prüft@Roles()). - Trägt der Controller zusätzlich
@UseModule('slug'), prüft anschließendModuleGuard(apps/api/src/module-registry/module.guard.ts) Modulzugriff überModuleAccessService. - 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 perforTenant()gebundenen Client auf Postgres zugreift. - 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, 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:
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.
<ordner>/<seed-name>.changelog.tsanlegen und eine erste Version1.0.0mit dem heutigen Datum eintragen (Export<SLUG_MIT_UNTERSTRICH>_CHANGELOG, TypModuleChangelog).- Den Changelog in
module-changelog.registry.tseintragen (alphabetisch nach Slug). - 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:
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:
- 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. - Grant pro Gruppe oder Benutzer (
ModuleGrant) — erst wenn das Modul aktiv ist, entscheidet ein Grant, wer es tatsächlich sieht.ModuleGrantträgt eine Freigabestufe, das Feldlevel(EnumModuleGrantLevel, VorgabeUSE):USEheißt Benutzen,MANAGEheiß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:
@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/proxmoxundmodules/tender-radar— mit eigenen Unterrouten (z. B.dkv-fleet/vehicles,tender-radar/my-sources,*/settings). Jedes davon hat zugleich einen Eintrag inMODULE_REGISTRY. Daneben gibt esmodules/custom— die bewusste Ausnahme ohne Gate-Layout für die Eigenen 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:
// 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/):
domaincheck.service.ts— die eigentliche fachliche Logik.dto/check-domain.dto.ts— Validierung des Request-Body perclass-validator.domaincheck.controller.ts—@Controller('modules/domaincheck')mit@UseModule('domaincheck')auf Klassenebene, ein@Post('check')-Handler.domaincheck.seed.ts— dieseedDomaincheckModule()-Funktion mit dem Manifest (slug,name,version,category,description,isSystem). Die Version kommt aus dem Modul-Changelog, siehe Modulversion und Modul-Changelog pflegen.domaincheck.module.ts— bindet Controller/Service zusammen, importiertModuleRegistryModule, ruft inonModuleInit()den Seed auf.- Eintrag des neuen Moduls in
apps/api/src/app.module.tsunterimports.
Frontend (apps/web/src/app/(portal)/modules/domaincheck/):
page.tsx— die eigentliche Modulseite (Client-Komponente mit den Formular-/Ergebnis-Teilen).layout.tsx—ModuleAccessGate moduleSlug="domaincheck"um{children}.actions.ts— Server Actions, die die Backend-Route aufrufen.components/—DomainInput.tsx,ResultList.tsx.- Eintrag in
MODULE_REGISTRY(apps/web/src/lib/module-loader.ts) mit demdynamic()-Import aufpage.tsx. - Übersetzungsschlüssel in
apps/web/src/messages/de.jsonunden.json(siehe 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@Rolesnoch@UseModule. Die Kategorien kommen ausapps/api/src/module-categories/. - Frontend: die Rahmenseite
modules/custom/[id](bewusst ohneModuleAccessGate; der statische Ordnercustomhat im App Router Vorrang vor[category]/[moduleSlug]), die Bausteineapps/web/src/components/modules/custom-module-*.tsx, die Pflege der gemeinsamen Einträge für Administratoren unter/admin/custom-modulesund die persönlichen Einträge unter/settings/custom-modules. - Wächter:
apps/web/src/messages/module-categories.spec.tsprüft, dass jede Modulkategorie einen Anzeigenamen in beiden Sprachen hat;module-layouts.test.tsxführtcustomals 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):
- Die Kachel-Komponente schreiben —
apps/web/src/components/dashboard/widgets/<name>-widget.tsx, nimmt dieWidgetPropsauswidget-registry.tsx(instanceId,config,isEditMode) entgegen. - Den Typ eintragen — in
WIDGET_TYPESinpackages/shared/src/index.ts. Gehört die Kachel zu einem Modul, zusätzlichWIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>'in derselben Datei. Das ist die einzige Liste: die API validiertPOST /dashboard/widgetsper@IsIngegen genau sie, und das Frontend leitet Registry und Katalog davon ab. - Anmelden —
registerWidget('<typ>', <Name>Widget)inapps/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.tsregistrierten 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 ausschema.prismaund den Migrationen, welche Modelle einetenantIdtragen 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 indocs/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 Datenbankrolletessera_app(ohne Superuser- und BYPASSRLS-Recht) rein textuell.rls-preflight.spec.ts— prüftapps/api/scripts/rls-preflight.mjsin der Betriebsart--print-plan, also ohne Datenbankverbindung. Das Skript selbst misst gegen eine Datenbankrolle (Verbindung ausTESSERA_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):
@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:
schema.prismaanpassen.- Migration erzeugen (im
api-Container oder mit Zugriff auf die DB — siehe Datenbank vom Host erreichen):pnpm --filter @tessera/api exec prisma migrate dev --name <beschreibender-name> prisma generateläuft automatisch alspostinstall-Skript von@tessera/api("postinstall": "test -f prisma/schema.prisma && prisma generate || true"), muss also nachpnpm installnicht 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 für den aktuellen Stand, welche Tabellen das betrifft.
Tests
Beide Apps nutzen Vitest, aber mit unterschiedlicher Umgebung:
apps/api—environment: 'node', suchtsrc/**/*.spec.ts,passWithNoTests: true.pnpm --filter @tessera/api test # einmalig pnpm --filter @tessera/api test:watch # Watch-Modusapps/web—environment: 'jsdom'mit@testing-library/react,setupFiles: ['./src/test/setup.ts'].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) |
Desktop-Modul (apps/api/src/desktop):
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):
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. 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 alllaufen lassen. - Das Ergebnis erscheint im Protokoll des Laufs als Zeilen
SECURITY-SUMMARY ...(nur Zahlen) und, soweit der Runner es zulässt, als Downloadsicherheitsberichte. Einzelheiten und Fehlersuche stehen im CI/CD-Runbook.
Prüfungen von Hand wiederholen
Vom Hauptordner des Repositorys aus:
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.tomlträgt eine deutschedescription, 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:
- 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.
- 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).undicibleibt dabei exakt gepinnt, ohne^(siehe die Dispatcher-Falle im Kapitel Konventionen). - Mittelbare Abhängigkeiten werden über
pnpm.overridesangehoben. Steckt der verwundbare Baustein tief in einem anderen Paket, tragen Sie im Wurzel-package.jsonunterpnpm→overrideseinen 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. Danachpnpm installund mitpnpm why nameprüfen, dass nur noch bereinigte Fassungen übrig sind. - 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.
- 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. - 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. - Die Desktop-App hat eine eigene Paketliste (
apps/desktop/src-tauri/Cargo.lock). Dort heben Sie einen Baustein mitcargo update -p name --precise x.y.zinnerhalb seiner Versionslinie an und prüfen mitcargo checkundcargo 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:
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:
@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.