diff --git a/.env.example b/.env.example index 6e87875..fe89faf 100644 --- a/.env.example +++ b/.env.example @@ -22,17 +22,39 @@ DATABASE_URL=postgresql://mpm:@postgres:5432/mpm # Session-Gültigkeit in Minuten (kurz halten) SESSION_TTL_MINUTES=120 # "true" sobald die Plattform hinter HTTPS/TLS betrieben wird -COOKIE_SECURE=false +COOKIE_SECURE=true # "true", wenn ein Reverse Proxy (Nginx im Container) vorgeschaltet ist BEHIND_PROXY=true +# GID der Docker-Socket-Gruppe auf dem Host (Docker Desktop meist 0). +# MPM braucht den Socket, um eigene Modul-Container zu verwalten. +DOCKER_SOCKET_GID=0 + +# GID der Docker-Socket-Gruppe auf dem Host (Docker Desktop meist 0). +# MPM benötigt den Docker-Socket, um Modul-Stacks zu verwalten. +DOCKER_SOCKET_GID=0 + # --- Initialer Admin (nur beim ersten Start angelegt) ----------- ADMIN_USERNAME=admin ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD= +# --- Marketplace OAuth (optional) -------------------------------- +# Lokal: Host-Adresse einschließlich APP_PORT; Callback-Pfad wird von MPM ergänzt. +MARKETPLACE_PUBLIC_URL=http://127.0.0.1:8080 +# Zufälliger, dauerhafter Wert (mindestens 32 Zeichen), z. B. openssl rand -base64 32. +MARKETPLACE_TOKEN_ENCRYPTION_KEY= +# OAuth-App Callback: http://127.0.0.1:8080/api/v1/marketplace/oauth/github/callback +GITHUB_OAUTH_CLIENT_ID= +GITHUB_OAUTH_CLIENT_SECRET= +# Für eine selbst gehostete Instanz jeweils alle drei Werte setzen. +GITEA_BASE_URL= +GITEA_OAUTH_CLIENT_ID= +GITEA_OAUTH_CLIENT_SECRET= +FORGEJO_BASE_URL= +FORGEJO_OAUTH_CLIENT_ID= +FORGEJO_OAUTH_CLIENT_SECRET= + # --- Login-Schutz (optional, mit Defaults) ---------------------- -# LOGIN_MAX_ATTEMPTS=5 -# LOGIN_LOCKOUT_MINUTES=15 # LOGIN_RATE_LIMIT_ATTEMPTS=10 -# LOGIN_RATE_LIMIT_WINDOW_MINUTES=5 \ No newline at end of file +# LOGIN_RATE_LIMIT_WINDOW_MINUTES=5 diff --git a/Dockerfile b/Dockerfile index ac15978..e9c3559 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,8 @@ # ============================================================= # MPM – Management-Container -# Ein einzelner Container, der als unprivilegierter Benutzer -# folgende Prozesse verwaltet (via Supervisor): +# Single container with a restricted-capability root supervisor. +# Backend, Nginx workers, and modules otherwise run unprivileged: # - Nginx (Reverse Proxy, Port 8080) # - NestJS Management-Backend (127.0.0.1:3000) # Ab Phase 3 laufen hier zusätzlich die Modul-Prozesse. @@ -19,22 +19,26 @@ COPY apps/platform-frontend/ ./ RUN npm run build # ---------- Backend-Build ---------- +FROM node:24-slim AS backend-runtime-deps +WORKDIR /build +COPY apps/platform-backend/package.json apps/platform-backend/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund + FROM node:24-slim AS backend-build WORKDIR /build COPY apps/platform-backend/package.json apps/platform-backend/package-lock.json ./ RUN npm ci COPY apps/platform-backend/ ./ RUN npm run build -RUN npm prune --omit=dev # ---------- Laufzeit-Image ---------- FROM node:24-slim AS runtime RUN apt-get update \ - && apt-get install -y --no-install-recommends nginx supervisor \ + && apt-get install -y --no-install-recommends nginx supervisor util-linux docker.io docker-compose \ && rm -rf /var/lib/apt/lists/* -# Unprivilegierter Benutzer für alle Prozesse im Container +# Unprivileged service account for backend and module processes RUN groupadd --gid 1001 app \ && useradd --uid 1001 --gid 1001 --create-home --shell /usr/sbin/nologin app \ && mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor /app/data/modules /app/data/logs \ @@ -44,14 +48,14 @@ COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf COPY --from=backend-build --chown=app:app /build/dist /app/platform-backend/dist -COPY --from=backend-build --chown=app:app /build/node_modules /app/platform-backend/node_modules +COPY --from=backend-runtime-deps --chown=app:app /build/node_modules /app/platform-backend/node_modules COPY --from=backend-build --chown=app:app /build/package.json /app/platform-backend/package.json COPY --from=frontend-build --chown=app:app /build/dist /app/public -USER app +USER root EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:8080/api/v1/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" -CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"] \ No newline at end of file +CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/KI_SETUP.md b/KI_SETUP.md new file mode 100644 index 0000000..ea8dc07 --- /dev/null +++ b/KI_SETUP.md @@ -0,0 +1,113 @@ +# Arbeitsplatz-Setup für KI-Assistenten + +Diese Datei beschreibt, wie MPM auf einem neuen Arbeitsplatz eingerichtet und angepasst wird. Lies sie zusammen mit `README.md`, `docs/ARCHITECTURE.md` und `docs/MODULE-MARKETPLACE.md`, bevor du Änderungen vornimmst. + +## Projektziel und Architektur + +MPM ist eine Management-Plattform für Benutzer, Rollen und externe Module. Das Frontend ist React/Vite/TypeScript, das Backend NestJS/TypeScript, die Plattformdaten liegen in PostgreSQL. Die Plattform wird mit Docker Compose gestartet. Installierte Module laufen in eigenen Compose-Projekten mit eigenen Containern und persistenten Volumes; MPM verwaltet deren Start, Stop, Health, Disable und Remove. Der Docker-Socket wird nur vom MPM-Backend verwendet und niemals an Modulcontainer weitergereicht. + +Wichtige Verzeichnisse: + +- `apps/platform-frontend`: React-Oberfläche +- `apps/platform-backend`: API, Authentifizierung, Marketplace und Modulverwaltung +- `packages/platform-module-sdk`: SDK-Vertrag für Module +- `modules/demo`: Referenzmodul +- `docker`: Nginx- und Supervisor-Konfiguration +- `docs`: Architektur und Modul-/Marketplace-Verträge + +Änderungen an Modulinstallation oder Container-Lifecycle müssen den Compose-Stack, persistente Daten, Gateway-Netzwerk und Fehler-/Recovery-Pfade berücksichtigen. Lies dafür `module-container-manager.ts`, `module-installer.ts`, `modules.service.ts` sowie die Modul-Dokumentation. + +## Voraussetzungen + +Für den üblichen Betrieb: + +- Git +- Docker Desktop mit Linux-Containern und Docker Compose v2; unter Windows ist ein aktiviertes WSL2-Backend empfehlenswert +- VS Code und ein KI-Assistent mit Zugriff auf den geöffneten Projektordner + +Für lokale Frontend-/Backend-Entwicklung außerhalb von Docker zusätzlich Node.js 24 und npm. Die Module haben eigene Laufzeit- und Build-Anforderungen gemäß ihrem Manifest. + +## Beim ersten Öffnen auf einem neuen Arbeitsplatz + +1. Repository auschecken und den aktuellen Arbeitsbranch verwenden. Den vorhandenen Branch nicht ungefragt auf `main` umbenennen oder lokale Änderungen verwerfen. +2. `git status --short --branch` prüfen. Nicht committete Änderungen gehören möglicherweise dem Nutzer; niemals resetten, stashen, überschreiben oder entfernen, ohne vorher genau zu prüfen. +3. Docker Desktop starten und sicherstellen, dass Linux-Container und Compose v2 funktionieren. +4. `.env` nur anlegen, wenn sie noch nicht vorhanden ist: `.env.example` kopieren und ausschließlich lokale Entwicklungswerte eintragen. Eine vorhandene `.env` nie ersetzen oder ausgeben. +5. Secrets dieses Arbeitsplatzes getrennt halten. Keine Passwörter, OAuth-Secrets, Zugriffstokens, Cookies oder privaten Schlüssel in Quellcode, Dokumentation, Kommandoausgaben, Commits oder Issues übernehmen. Beispielwerte in `.env.example` sind Platzhalter. +6. Bei rein lokaler HTTP-Entwicklung `NODE_ENV=development` und `COOKIE_SECURE=false` verwenden. In Produktion muss HTTPS aktiv sein und `COOKIE_SECURE=true` gesetzt werden. +7. Für OAuth-Entwicklung sind pro Provider eigene OAuth-Clientdaten mit passender Callback-URL nötig. Ohne OAuth-Konfiguration können die übrigen Plattformfunktionen lokal verwendet werden; Provider dürfen nicht mit unvollständiger Konfiguration gesetzt werden. Bei aktivem OAuth einen dauerhaften `MARKETPLACE_TOKEN_ENCRYPTION_KEY` mit mindestens 32 Zeichen lokal generieren und geheim halten. +8. `APP_PORT` bei Bedarf anpassen, falls 8080 belegt ist. `MARKETPLACE_PUBLIC_URL` muss die vom Browser erreichbare Basisadresse samt Port enthalten, etwa `http://127.0.0.1:8080`. +9. `DOCKER_SOCKET_GID` ist hostabhängig. Docker Desktop verwendet häufig `0`; bei Linux ist die tatsächliche Gruppe des Docker-Sockets zu verwenden. Änderungen daran erst nach Prüfung der Docker-Berechtigungen vornehmen. + +Die Datenbankverbindung innerhalb des Compose-Netzwerks verwendet den Hostnamen `postgres`. Bei Backend-Ausführung direkt auf dem Host muss `DATABASE_URL` auf `127.0.0.1:5432` zeigen. Niemals den Compose-internen Hostnamen `postgres` für einen Backendprozess auf dem Host verwenden. + +## Plattform mit Docker starten + +Im Projektstamm: + +```powershell +docker compose up --build -d +docker compose ps +``` + +Danach die in `APP_PORT` konfigurierte Adresse öffnen (Standard `http://localhost:8080`). Das initiale Admin-Konto wird beim ersten Datenbankstart aus `ADMIN_USERNAME`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` angelegt. Spätere Änderungen dieser Variablen ändern ein bereits angelegtes Datenbankkonto nicht automatisch. + +Logs und Neustart: + +```powershell +docker compose logs -f platform +docker compose logs -f postgres +docker compose restart platform +``` + +Für normale Neustarts oder Updates kein `docker compose down -v` verwenden: `-v` löscht persistente Datenbank- und Moduldaten. + +## Lokale Entwicklung ohne Plattform-Container + +PostgreSQL zuerst starten: + +```powershell +docker compose up -d postgres +``` + +Dann in separaten Terminals: + +```powershell +Set-Location apps/platform-backend +npm ci +npm run start:dev +``` + +```powershell +Set-Location apps/platform-frontend +npm ci +npm run dev +``` + +Der Backendprozess benötigt gültige Variablen aus `.env`; beim lokalen Start muss `DATABASE_URL` auf `127.0.0.1` zeigen. Falls die Entwicklungsumgebung `.env` nicht automatisch lädt, Variablen sicher über die lokale Shell/VS-Code-Konfiguration einlesen; keine Secrets in Startskripte committieren. + +## Arbeitsregeln für Änderungen + +- Vor Änderungen relevante `AGENTS.md`-Dateien, Dokumentation und betroffene Implementierungen lesen. +- Vor jedem Commit Status und Diff prüfen. Nur die beabsichtigten Dateien aufnehmen; Nutzeränderungen nicht stillschweigend verwerfen. +- Keine echten Zugangsdaten in Ausgaben oder Dokumentation schreiben. Wenn ein Secret versehentlich in einen Commit gelangt ist, es als kompromittiert behandeln und rotieren; bloßes Löschen aus der aktuellen Datei reicht nicht. +- API-Änderungen auf DTO/Validierung, Authentifizierung, RBAC, CSRF, Audit und Frontend-Verwendung prüfen. +- Containeränderungen auf Windows/Docker Desktop und Linux, Restart/Stop/Remove, Netzwerk-Neuerstellung, Datenpersistenz und Logs prüfen. +- Datenbankänderungen als neue Migration ergänzen; bestehende Migrationen nicht nachträglich umschreiben, wenn sie schon angewendet sein könnten. +- UI-Änderungen an bestehenden Komponenten und Dark-/Light-Theme-Konventionen ausrichten. +- Abhängigkeiten nur bei Bedarf ändern und Lockfiles konsistent halten. +- Keine Builds, Tests, Deployments, Commits oder Pushes ausführen, wenn der Nutzer das nicht angefordert hat. Wenn er Verifikation verlangt, die tatsächlich ausgeführten Befehle und Ergebnisse angeben. +- Bei einem gewünschten Push Ziel-Remote und Branch verifizieren, den kompletten Commit-Diff auf Secrets prüfen und keine Force-Pushes ausführen, außer der Nutzer weist sie ausdrücklich an. + +## Häufige Arbeitsplatzprobleme + +- **Port belegt:** `APP_PORT` in `.env` ändern und `MARKETPLACE_PUBLIC_URL` synchron anpassen. PostgreSQL-Port 5432 kann für reine Compose-Nutzung bei Bedarf ebenfalls hostseitig angepasst werden; dann muss die lokale `DATABASE_URL` mitziehen. +- **Backend startet nicht:** Prüfen, ob alle Pflichtvariablen gesetzt sind, `ADMIN_PASSWORD` mindestens 10 Zeichen hat, `DATABASE_URL` den richtigen Host verwendet und PostgreSQL gesund ist. +- **OAuth-Callback schlägt fehl:** Externe Callback-URL muss exakt zur Provider-Konfiguration passen, inklusive Schema, Host, Port und Pfad `/api/v1/marketplace/oauth//callback`. Redirect-URL und `MARKETPLACE_PUBLIC_URL` müssen übereinstimmen. +- **Modulcontainer lassen sich nicht steuern:** Docker Desktop muss laufen; Socket-Mount und `DOCKER_SOCKET_GID` prüfen. Docker-Socket-Zugriff ist privilegiert; keine Erhöhung für Modulcontainer aktivieren. +- **Modul kann nach Stop nicht starten:** MPM-Logs und den Modul-Compose-Stack prüfen; Containerstatus, Gateway-Netz und Volume-Status erfassen. Nicht als Erstes Datenvolumes löschen. +- **Windows-Dateirechte oder Pfade:** Docker Compose läuft in Linux-Containern; Datei- und Socketpfade aus Docker Desktop/WSL berücksichtigen und möglichst nicht zwischen Windows- und WSL-Dateisystemen hin- und herkopieren. + +## Dokumente zum Modulvertrag + +Vor Implementierung oder Anpassung eines Marketplace-Moduls außerdem `docs/MODULE-MARKETPLACE.md`, `modules/README.md` und `packages/platform-module-sdk/README.md` lesen. Das Modulmanifest und dessen Compose-/Health-/Datenvolume-Angaben sind Teil der Integrationsschnittstelle. diff --git a/README.md b/README.md index 0ad1c27..cc94baf 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applika Internet │ ▼ -Docker Container (mpm-platform, unprivilegierter Benutzer "app") +Docker container (restricted-capability root supervisor; services run as app) ┌─────────────────────────────────────────────┐ │ Supervisor (Prozessmanager) │ │ ├── Nginx (Reverse Proxy, :8080) │ @@ -24,8 +24,9 @@ Docker Container (mpm-platform, unprivilegierter Benutzer "app") PostgreSQL (eigener Container, persistentes Volume) ``` -- **Ein** Applikationscontainer, **kein** Docker-in-Docker, **kein** Docker-Socket. -- Module laufen ab Phase 3 als interne Prozesse im selben Container (eigene Ports, nur über den Reverse Proxy erreichbar). +- Der MPM-Managementcontainer verwaltet Modul-Stacks über den Docker-Socket. Modulcode erhält selbst keinen Socketzugriff. +- Neue Module laufen in eigenen Compose-Stacks. Die App und optionale Datenbanken haben getrennte Container und persistente Volumes. +- Der Docker-Socket ermöglicht weitreichende Hoststeuerung. Daher dürfen nur vertrauenswürdige Administratoren Module installieren; der Socket wird nie in Modulcontainer durchgereicht. - PostgreSQL liegt außerhalb des Applikationscontainers in einem persistenten Volume. Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Phasen: [`docs/PHASES.md`](docs/PHASES.md) @@ -76,7 +77,7 @@ MPM/ ├── docs/ # Architektur- & Phasen-Dokumentation ├── modules/ # Installierbare Module (ab Phase 3) ├── Dockerfile # Multi-Stage-Build des Management-Containers -└── docker-compose.yml # PostgreSQL + Management-Container +└── docker-compose.yml # PostgreSQL + MPM; Module erhalten eigene Compose-Stacks ``` ## Tech-Stack @@ -86,19 +87,19 @@ MPM/ | Frontend | React 19, TypeScript, Vite, Tailwind CSS, React Router, TanStack Query, Zod | | Backend | NestJS 11, TypeScript, REST `/api/v1`, OpenAPI/Swagger | | Datenbank | PostgreSQL 18 (Schemas: `management`, ab Phase 3 pro Modul) | -| Betrieb | Docker, Nginx, Supervisor, unprivilegierter Benutzer | -| Sicherheit | Argon2id, HttpOnly/Secure/SameSite-Cookies, serverseitige Sessions, CSRF-Schutz, Rate Limiting, Account Lockout, Audit-Log, Helmet, RBAC | +| Betrieb | Docker Compose, Nginx, MPM-Managementcontainer und separate Modul-Stacks | +| Sicherheit | Argon2id, HttpOnly/Secure/SameSite-Cookies, serverseitige Sessions, CSRF-Schutz, IP-basiertes Rate Limiting, Audit-Log, Helmet, RBAC | ## Funktionen ### Phase 1 – Grundgerüst -Login/Logout mit serverseitigen Sessions, Rollen (ADMIN/USER), Health-Monitoring, Audit-Log, Migrationen mit Advisory-Lock, responsive Management-UI mit Design-System. +Login/Logout mit serverseitigen Sessions, Rollen (ADMIN/USER), IP-basiertes Rate Limiting, Health-Monitoring, Audit-Log, Migrationen mit Advisory-Lock, responsive Management-UI mit Design-System. ### Phase 2 – Benutzerverwaltung Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letzten Admins, sofortiger Session-Sperrung bei Deaktivierung, Passwort-Reset, eigenes Passwort ändern, Benutzerverwaltungs-UI (Tabelle, Modals, Toasts) und Profil-Seite. ### Phase 3 – Modul-System -Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, Prozess-Manager (Kindprozesse mit minimaler ENV, eigene Logs), Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Volumes für Modul-Dateien. +Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, eigene Compose-Stacks je Modul, Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Datenvolumes. ### Phase 4 – Gateway & Routing Dynamisches Routing `/slug` über Nginx → Modul-Gateway (Middleware): Session-Check, Modul-Status-Check, Permission-Check (fail-closed), Proxy zu internen Ports. Sichere Identitätsübergabe über Header, Startup-Recovery mit Autostart nach Container-Neustarts. @@ -117,4 +118,4 @@ Audit-Log-UI (Filter + Paginierung), Systemeinstellungen (Whitelist-Schlüssel, ## Annahme -„ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`). \ No newline at end of file +„ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`). diff --git a/apps/platform-backend/package-lock.json b/apps/platform-backend/package-lock.json index 6cc3b6b..8afdd04 100644 --- a/apps/platform-backend/package-lock.json +++ b/apps/platform-backend/package-lock.json @@ -22,6 +22,7 @@ "pg": "^8.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "yaml": "^2.9.1", "zod": "^3.24.0" }, "devDependencies": { @@ -6618,9 +6619,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", - "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", + "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", "funding": [ { "type": "github", @@ -9544,6 +9545,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/apps/platform-backend/package.json b/apps/platform-backend/package.json index c771378..b2d9804 100644 --- a/apps/platform-backend/package.json +++ b/apps/platform-backend/package.json @@ -28,6 +28,7 @@ "pg": "^8.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "yaml": "^2.9.1", "zod": "^3.24.0" }, "devDependencies": { @@ -49,5 +50,10 @@ "ts-jest": "^29.2.5", "typescript": "^5.7.0", "typescript-eslint": "^8.0.0" + }, + "overrides": { + "@nestjs/swagger": { + "js-yaml": "5.4.1" + } } } diff --git a/apps/platform-backend/src/auth/auth.controller.ts b/apps/platform-backend/src/auth/auth.controller.ts index 292cc67..8a92a90 100644 --- a/apps/platform-backend/src/auth/auth.controller.ts +++ b/apps/platform-backend/src/auth/auth.controller.ts @@ -55,20 +55,21 @@ export class AuthController { ipAddress: request.ip ?? null, }); - const cookieMaxAgeSeconds = this.config.security.sessionTtlMinutes * 60; + // Express expects cookie maxAge in milliseconds (the DB TTL is in minutes). + const cookieMaxAgeMs = this.config.security.sessionTtlMinutes * 60 * 1000; response.cookie('mpm_session', result.sessionToken, { httpOnly: true, secure: this.config.security.cookieSecure, sameSite: 'lax', path: '/', - maxAge: cookieMaxAgeSeconds, + maxAge: cookieMaxAgeMs, }); response.cookie('mpm_csrf', result.session.csrfToken, { httpOnly: false, secure: this.config.security.cookieSecure, sameSite: 'lax', path: '/', - maxAge: cookieMaxAgeSeconds, + maxAge: cookieMaxAgeMs, }); return { user: toAuthUserResponse(result.user) }; @@ -95,4 +96,4 @@ export class AuthController { async me(@CurrentUser() user: AuthUser): Promise<{ user: AuthUserResponse }> { return { user: toAuthUserResponse(user) }; } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/auth/auth.service.spec.ts b/apps/platform-backend/src/auth/auth.service.spec.ts index f0ea5d2..8e1caed 100644 --- a/apps/platform-backend/src/auth/auth.service.spec.ts +++ b/apps/platform-backend/src/auth/auth.service.spec.ts @@ -26,6 +26,7 @@ function createConfig(overrides: Partial = {}): AppConfig }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' }, + marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, }; } @@ -52,7 +53,7 @@ function createUserRecord(overrides: Partial = {}): UserRecord { class MockUserRepository { public findByUsernameResult: UserRecord | null = null; public updateLoginSuccessCalls: string[] = []; - public updateLoginFailureCalls: Array<{ userId: string; attempts: number; shouldLock: boolean; lockoutMinutes: number }> = []; + public updateLoginFailureCalls: string[] = []; async findByUsername(): Promise { return this.findByUsernameResult; @@ -62,8 +63,8 @@ class MockUserRepository { this.updateLoginSuccessCalls.push(userId); } - async updateLoginFailure(userId: string, attempts: number, shouldLock: boolean, lockoutMinutes: number): Promise { - this.updateLoginFailureCalls.push({ userId, attempts, shouldLock, lockoutMinutes }); + async updateLoginFailure(userId: string): Promise { + this.updateLoginFailureCalls.push(userId); } } @@ -158,12 +159,12 @@ describe('AuthService', () => { ).rejects.toThrow(UnauthorizedException); expect(userRepository.updateLoginFailureCalls).toEqual([ - { userId: 'user-1', attempts: 1, shouldLock: false, lockoutMinutes: 15 }, + 'user-1', ]); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED); }); - it('sperrt das Konto nach Erreichen der maximalen Fehlversuche', async () => { + it('verhindert Loginversuche nicht durch Kontosperren', async () => { const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1'); userRepository.findByUsernameResult = createUserRecord({ passwordHash, @@ -175,9 +176,9 @@ describe('AuthService', () => { ).rejects.toThrow(UnauthorizedException); expect(userRepository.updateLoginFailureCalls).toEqual([ - { userId: 'user-1', attempts: 3, shouldLock: true, lockoutMinutes: 15 }, + 'user-1', ]); - expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_LOCKED); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED); }); it('lehnt gesperrte Benutzer ab', async () => { @@ -187,11 +188,12 @@ describe('AuthService', () => { lockedUntil: new Date(Date.now() + 60_000), }); - await expect( - authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1' }, - )).rejects.toThrow(UnauthorizedException); - - expect(auditService.records.at(-1)?.details).toEqual({ reason: 'ACCOUNT_LOCKED' }); + const result = await authService.login({ + username: 'max', + password: 'Sicheres-Passwort-1', + ipAddress: '127.0.0.1', + }); + expect(result.user.username).toBe('max'); }); it('lehnt deaktivierte Benutzer ab', async () => { @@ -226,7 +228,7 @@ describe('AuthService', () => { expect(auditService.records.at(-1)?.details).toEqual({ reason: 'RATE_LIMITED' }); }); - it('setzt das Rate-Limit-Fenster nach erfolgreichem Login zurück', async () => { + it('setzt das Rate-Limit-Fenster nach erfolgreichem Login nicht zurück', async () => { const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1'); userRepository.findByUsernameResult = createUserRecord({ passwordHash }); @@ -243,13 +245,11 @@ describe('AuthService', () => { }); expect(result.user.username).toBe('max'); - // Nach Reset ist ein neuer Login sofort wieder möglich. - const secondResult = await authService.login({ + await expect(authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1', - }); - expect(secondResult.user.username).toBe('max'); + })).rejects.toThrow('Zu viele Anmeldeversuche. Bitte später erneut versuchen.'); }); }); @@ -268,4 +268,4 @@ describe('AuthService', () => { expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGOUT); }); }); -}); \ No newline at end of file +}); diff --git a/apps/platform-backend/src/auth/auth.service.ts b/apps/platform-backend/src/auth/auth.service.ts index 34a5b0b..c58d2b1 100644 --- a/apps/platform-backend/src/auth/auth.service.ts +++ b/apps/platform-backend/src/auth/auth.service.ts @@ -20,7 +20,7 @@ const INVALID_CREDENTIALS_MESSAGE = 'Benutzername oder Passwort ist falsch'; /** * Authentifizierungs-Logik (Domain/Application): - * Login mit Rate Limiting, Account Lockout, Argon2id-Verifikation, + * Login mit IP-basiertem Rate Limiting, Argon2id-Verifikation, * Session-Erstellung und Audit-Logging. */ @Injectable() @@ -72,32 +72,14 @@ export class AuthService { throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE); } - if (user.lockedUntil && user.lockedUntil > new Date()) { + const passwordValid = await this.passwordHasher.verify(user.passwordHash, input.password); + if (!passwordValid) { + await this.userRepository.updateLoginFailure(user.id); await this.auditService.record({ userId: user.id, username: user.username, action: 'LOGIN_FAILED', - details: { reason: 'ACCOUNT_LOCKED' }, - ipAddress: input.ipAddress, - }); - throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE); - } - - const passwordValid = await this.passwordHasher.verify(user.passwordHash, input.password); - if (!passwordValid) { - const attempts = user.failedLoginAttempts + 1; - const shouldLock = attempts >= security.loginMaxAttempts; - await this.userRepository.updateLoginFailure( - user.id, - attempts, - shouldLock, - security.loginLockoutMinutes, - ); - await this.auditService.record({ - userId: user.id, - username: user.username, - action: shouldLock ? 'LOGIN_FAILED_LOCKED' : 'LOGIN_FAILED', - details: { reason: 'INVALID_PASSWORD', attempts }, + details: { reason: 'INVALID_PASSWORD' }, ipAddress: input.ipAddress, }); throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE); @@ -115,7 +97,6 @@ export class AuthService { } await this.userRepository.updateLoginSuccess(user.id); - this.rateLimiter.reset(rateLimitKey); const { token, data } = await this.sessionService.create( user.id, @@ -151,4 +132,4 @@ export class AuthService { ipAddress, }); } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/auth/guards/csrf.guard.ts b/apps/platform-backend/src/auth/guards/csrf.guard.ts index a5c0db3..b9ff7f5 100644 --- a/apps/platform-backend/src/auth/guards/csrf.guard.ts +++ b/apps/platform-backend/src/auth/guards/csrf.guard.ts @@ -13,7 +13,7 @@ const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); * * Requests ohne Session (z. B. Login) sind ausgenommen: Sie besitzen * kein Session-CSRF-Token. Das Login ist stattdessen durch Rate - * Limiting, Account Lockout und SameSite=Lax-Cookies geschützt. + * Limiting und SameSite=Lax-Cookies geschützt. * Ungültige Sessions werden bereits vom SessionGuard mit 401 abgewiesen. */ @Injectable() diff --git a/apps/platform-backend/src/auth/guards/session.guard.ts b/apps/platform-backend/src/auth/guards/session.guard.ts index 8a15408..749939d 100644 --- a/apps/platform-backend/src/auth/guards/session.guard.ts +++ b/apps/platform-backend/src/auth/guards/session.guard.ts @@ -16,13 +16,16 @@ export function extractSessionToken(request: RequestWithCookieHeader): string | if (!cookieHeader) { return null; } + let sessionToken: string | null = null; for (const part of cookieHeader.split(';')) { const [name, ...value] = part.trim().split('='); if (name === 'mpm_session') { - return decodeURIComponent(value.join('=')); + // Browsers may send same-name cookies from an older, narrower Path + // before the current Path=/ cookie. The last value is the root cookie. + sessionToken = decodeURIComponent(value.join('=')); } } - return null; + return sessionToken; } /** @@ -79,4 +82,4 @@ export class SessionGuard implements CanActivate { }; return true; } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/auth/rate-limiter.service.ts b/apps/platform-backend/src/auth/rate-limiter.service.ts index d4bf206..a5abbe1 100644 --- a/apps/platform-backend/src/auth/rate-limiter.service.ts +++ b/apps/platform-backend/src/auth/rate-limiter.service.ts @@ -13,6 +13,9 @@ interface RateLimitEntry { @Injectable() export class RateLimiterService { private readonly entries = new Map(); + private readonly maxEntries = 10_000; + private readonly cleanupIntervalMs = 60_000; + private lastCleanupAt = 0; /** * Prüft, ob ein Request innerhalb des Limits liegt. @@ -21,6 +24,17 @@ export class RateLimiterService { isAllowed(key: string, limit: number, windowMinutes: number): boolean { const now = Date.now(); const windowMs = windowMinutes * 60_000; + if (now - this.lastCleanupAt >= this.cleanupIntervalMs) { + for (const [entryKey, entry] of this.entries) { + const recentTimestamps = entry.timestamps.filter((timestamp) => now - timestamp < windowMs); + if (recentTimestamps.length === 0) { + this.entries.delete(entryKey); + } else if (recentTimestamps.length !== entry.timestamps.length) { + this.entries.set(entryKey, { timestamps: recentTimestamps }); + } + } + this.lastCleanupAt = now; + } const entry = this.entries.get(key) ?? { timestamps: [] }; const recent = entry.timestamps.filter((timestamp) => now - timestamp < windowMs); @@ -30,6 +44,10 @@ export class RateLimiterService { } recent.push(now); + if (!this.entries.has(key) && this.entries.size >= this.maxEntries) { + const oldestKey = this.entries.keys().next().value; + if (oldestKey !== undefined) this.entries.delete(oldestKey); + } this.entries.set(key, { timestamps: recent }); return true; } @@ -38,4 +56,4 @@ export class RateLimiterService { reset(key: string): void { this.entries.delete(key); } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/config/configuration.ts b/apps/platform-backend/src/config/configuration.ts index 29f51c3..b9a1858 100644 --- a/apps/platform-backend/src/config/configuration.ts +++ b/apps/platform-backend/src/config/configuration.ts @@ -17,7 +17,9 @@ export interface SecurityConfig { readonly sessionTtlMinutes: number; readonly cookieSecure: boolean; readonly behindProxy: boolean; + /** @deprecated Account lockout was removed to prevent attacker-triggered account denial. */ readonly loginMaxAttempts: number; + /** @deprecated Account lockout was removed to prevent attacker-triggered account denial. */ readonly loginLockoutMinutes: number; readonly loginRateLimitAttempts: number; readonly loginRateLimitWindowMinutes: number; @@ -32,6 +34,23 @@ export interface AdminSeedConfig { export interface RuntimeConfig { readonly modulesDir: string; readonly logsDir: string; + readonly moduleUidBase?: number; +} + +export interface MarketplaceProviderConfig { + readonly clientId: string; + readonly clientSecret: string; + readonly baseUrl: string; +} + +export interface MarketplaceConfig { + readonly publicUrl: string; + readonly tokenEncryptionKey: string; + readonly providers: { + readonly github?: MarketplaceProviderConfig; + readonly gitea?: MarketplaceProviderConfig; + readonly forgejo?: MarketplaceProviderConfig; + }; } export interface AppConfig { @@ -41,6 +60,7 @@ export interface AppConfig { readonly security: SecurityConfig; readonly adminSeed: AdminSeedConfig; readonly runtime: RuntimeConfig; + readonly marketplace: MarketplaceConfig; } const booleanFromString = z @@ -63,7 +83,80 @@ const environmentSchema = z.object({ ADMIN_EMAIL: z.string().trim().email(), ADMIN_PASSWORD: z.string().min(10, 'ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein').max(200), MODULES_DIR: z.string().min(1).default('./data/modules'), + MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'), LOGS_DIR: z.string().min(1).default('./data/logs'), + MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(), + MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'), + MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''), + GITHUB_OAUTH_CLIENT_ID: z.string().default(''), + GITHUB_OAUTH_CLIENT_SECRET: z.string().default(''), + GITEA_BASE_URL: z.string().default(''), + GITEA_OAUTH_CLIENT_ID: z.string().default(''), + GITEA_OAUTH_CLIENT_SECRET: z.string().default(''), + FORGEJO_BASE_URL: z.string().default(''), + FORGEJO_OAUTH_CLIENT_ID: z.string().default(''), + FORGEJO_OAUTH_CLIENT_SECRET: z.string().default(''), +}).superRefine((environment, context) => { + if (environment.NODE_ENV === 'production' && !environment.COOKIE_SECURE) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['COOKIE_SECURE'], + message: 'COOKIE_SECURE muss in production auf true gesetzt sein', + }); + } + if (environment.NODE_ENV === 'production' && environment.MODULE_UID_BASE === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['MODULE_UID_BASE'], + message: 'MODULE_UID_BASE ist in production erforderlich, damit Module getrennte UIDs erhalten', + }); + } + const oauthFields = [ + environment.GITHUB_OAUTH_CLIENT_ID, + environment.GITHUB_OAUTH_CLIENT_SECRET, + environment.GITEA_BASE_URL, + environment.GITEA_OAUTH_CLIENT_ID, + environment.GITEA_OAUTH_CLIENT_SECRET, + environment.FORGEJO_BASE_URL, + environment.FORGEJO_OAUTH_CLIENT_ID, + environment.FORGEJO_OAUTH_CLIENT_SECRET, + ]; + if (oauthFields.some(Boolean) && environment.MARKETPLACE_TOKEN_ENCRYPTION_KEY.length < 32) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['MARKETPLACE_TOKEN_ENCRYPTION_KEY'], + message: 'Bei aktivierten OAuth-Anbietern ist ein Schlüssel mit mindestens 32 Zeichen erforderlich', + }); + } + for (const [provider, fields] of [ + ['GITHUB', [environment.GITHUB_OAUTH_CLIENT_ID, environment.GITHUB_OAUTH_CLIENT_SECRET]], + ['GITEA', [environment.GITEA_BASE_URL, environment.GITEA_OAUTH_CLIENT_ID, environment.GITEA_OAUTH_CLIENT_SECRET]], + ['FORGEJO', [environment.FORGEJO_BASE_URL, environment.FORGEJO_OAUTH_CLIENT_ID, environment.FORGEJO_OAUTH_CLIENT_SECRET]], + ] as const) { + if (fields.some(Boolean) && fields.some((field) => !field)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [`${provider}_OAUTH_CLIENT_ID`], + message: `OAuth-Konfiguration für ${provider} ist unvollständig`, + }); + } + } + for (const [field, value] of [['GITEA_BASE_URL', environment.GITEA_BASE_URL], ['FORGEJO_BASE_URL', environment.FORGEJO_BASE_URL]] as const) { + if (!value) continue; + try { + const url = new URL(value); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) { + throw new Error('protocol'); + } + if (url.username || url.password || url.search || url.hash) throw new Error('url'); + } catch { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: 'Forge-URL muss HTTPS verwenden (HTTP ist nur lokal zulässig) und darf keine Zugangsdaten enthalten', + }); + } + } }); /** Lädt und validiert die Konfiguration aus den Umgebungsvariablen. */ @@ -91,6 +184,24 @@ export function loadConfiguration(): AppConfig { runtime: { modulesDir: path.resolve(environment.MODULES_DIR), logsDir: path.resolve(environment.LOGS_DIR), + ...(environment.MODULE_UID_BASE !== undefined + ? { moduleUidBase: environment.MODULE_UID_BASE } + : {}), + }, + marketplace: { + publicUrl: environment.MARKETPLACE_PUBLIC_URL.replace(/\/$/, ''), + tokenEncryptionKey: environment.MARKETPLACE_TOKEN_ENCRYPTION_KEY, + providers: { + ...(environment.GITHUB_OAUTH_CLIENT_ID && environment.GITHUB_OAUTH_CLIENT_SECRET + ? { github: { clientId: environment.GITHUB_OAUTH_CLIENT_ID, clientSecret: environment.GITHUB_OAUTH_CLIENT_SECRET, baseUrl: 'https://github.com' } } + : {}), + ...(environment.GITEA_BASE_URL && environment.GITEA_OAUTH_CLIENT_ID && environment.GITEA_OAUTH_CLIENT_SECRET + ? { gitea: { clientId: environment.GITEA_OAUTH_CLIENT_ID, clientSecret: environment.GITEA_OAUTH_CLIENT_SECRET, baseUrl: environment.GITEA_BASE_URL.replace(/\/$/, '') } } + : {}), + ...(environment.FORGEJO_BASE_URL && environment.FORGEJO_OAUTH_CLIENT_ID && environment.FORGEJO_OAUTH_CLIENT_SECRET + ? { forgejo: { clientId: environment.FORGEJO_OAUTH_CLIENT_ID, clientSecret: environment.FORGEJO_OAUTH_CLIENT_SECRET, baseUrl: environment.FORGEJO_BASE_URL.replace(/\/$/, '') } } + : {}), + }, }, }; -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/database/migrations/index.ts b/apps/platform-backend/src/database/migrations/index.ts index c7bfda8..a711a10 100644 --- a/apps/platform-backend/src/database/migrations/index.ts +++ b/apps/platform-backend/src/database/migrations/index.ts @@ -2,6 +2,12 @@ import { migration001CoreSchema } from './001-core-schema'; import { migration002Modules } from '../../modules/migrations/002-modules'; import { migration003ModulePermissions } from '../../modules/migrations/003-module-permissions'; import { migration004SystemSettings } from '../../settings/migrations/004-system-settings'; +import { migration005ModulePortUnique } from '../../modules/migrations/005-module-port-unique'; +import { migration006MarketplaceConnections } from '../../modules/migrations/006-marketplace-connections'; +import { migration007MarketplaceCatalog } from '../../modules/migrations/007-marketplace-catalog'; +import { migration008MarketplaceSourceBranch } from '../../modules/migrations/008-marketplace-source-branch'; +import { migration009MarketplaceInstallations } from '../../modules/migrations/009-marketplace-installations'; +import { migration010ModuleContainers } from '../../modules/migrations/010-module-containers'; /** Registrierte Migrationen in aufsteigender Reihenfolge. */ export const MIGRATIONS = [ @@ -9,4 +15,10 @@ export const MIGRATIONS = [ migration002Modules, migration003ModulePermissions, migration004SystemSettings, -]; \ No newline at end of file + migration005ModulePortUnique, + migration006MarketplaceConnections, + migration007MarketplaceCatalog, + migration008MarketplaceSourceBranch, + migration009MarketplaceInstallations, + migration010ModuleContainers, +]; diff --git a/apps/platform-backend/src/main.ts b/apps/platform-backend/src/main.ts index bebf1ed..e1669bb 100644 --- a/apps/platform-backend/src/main.ts +++ b/apps/platform-backend/src/main.ts @@ -4,6 +4,7 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; +import type { NextFunction, Request, Response } from 'express'; import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { loadConfiguration } from './config/config.tokens'; @@ -26,6 +27,10 @@ async function bootstrap(): Promise { app.use(helmet()); app.use(cookieParser()); + app.use('/api', (_request: Request, response: Response, next: NextFunction) => { + response.setHeader('Cache-Control', 'no-store'); + next(); + }); if (config.security.behindProxy) { app.set('trust proxy', 1); @@ -46,4 +51,4 @@ async function bootstrap(): Promise { logger.log(`Management-Backend läuft auf Port ${config.port}`); } -void bootstrap(); \ No newline at end of file +void bootstrap(); diff --git a/apps/platform-backend/src/modules/manifest.types.ts b/apps/platform-backend/src/modules/manifest.types.ts index 6437e46..b2a1a3d 100644 --- a/apps/platform-backend/src/modules/manifest.types.ts +++ b/apps/platform-backend/src/modules/manifest.types.ts @@ -52,6 +52,8 @@ export const moduleManifestSchema = z.object({ .max(MODULE_PORT_MAX, `Port muss zwischen ${MODULE_PORT_MIN} und ${MODULE_PORT_MAX} liegen`), healthcheck: z.string().regex(/^\/[A-Za-z0-9\-./]*$/, 'Healthcheck muss ein Pfad sein'), apiVersion: z.literal('v1'), + composeFile: z.string().min(1).max(200).optional(), + appService: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/).optional(), }); export type ModuleManifest = z.infer; @@ -71,4 +73,6 @@ export interface ModuleRecord { readonly enabled: boolean; readonly createdAt: Date; readonly updatedAt: Date; -} \ No newline at end of file + readonly composeFile?: string | null; + readonly appService?: string | null; +} diff --git a/apps/platform-backend/src/modules/marketplace.controller.ts b/apps/platform-backend/src/modules/marketplace.controller.ts new file mode 100644 index 0000000..601d22c --- /dev/null +++ b/apps/platform-backend/src/modules/marketplace.controller.ts @@ -0,0 +1,123 @@ +import { BadRequestException, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { Public } from '../common/decorators/public.decorator'; +import { Roles } from '../common/decorators/roles.decorator'; +import type { AuthUser } from '../users/user.types'; +import { SessionService } from '../auth/session.service'; +import type { AuthenticatedRequest } from '../auth/authenticated-request'; +import { MarketplaceService } from './marketplace.service'; +import { ModulesService } from './modules.service'; + +@ApiTags('Marketplace') +@Controller({ path: 'api/v1/marketplace' }) +export class MarketplaceController { + constructor( + private readonly marketplaceService: MarketplaceService, + private readonly sessionService: SessionService, + private readonly modulesService: ModulesService, + ) {} + + @Get('providers') + @Roles('ADMIN') + providers(): Promise>> { + return this.marketplaceService.providers(); + } + + @Get('repositories/:provider') + @Roles('ADMIN') + repositories(@Param('provider') provider: string): ReturnType { + return this.marketplaceService.repositories(provider); + } + + @Post('repositories/:provider/:owner/:repository/install') + @Roles('ADMIN') + async installRepository( + @Param('provider') provider: string, + @Param('owner') owner: string, + @Param('repository') repository: string, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest, + ): Promise<{ module: { + id: string; moduleId: string; name: string; slug: string; version: string; description: string; + author: string; status: string; internalPort: number; healthcheckUrl: string; enabled: boolean; createdAt: string; + } }> { + const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository); + const manifest = await this.modulesService.validatePackage(archive); + const module = await this.modulesService.findByModuleId(manifest.id) ?? + await this.modulesService.install(archive, actor, request.ip ?? null); + await this.marketplaceService.recordInstallation(provider, owner, repository, module.id); + return { + module: { + id: module.id, + moduleId: module.moduleId, + name: module.name, + slug: module.slug, + version: module.version, + description: module.description, + author: module.author, + status: module.status, + internalPort: module.internalPort, + healthcheckUrl: module.healthcheckUrl, + enabled: module.enabled, + createdAt: module.createdAt.toISOString(), + }, + }; + } + + @Post('connections/:provider/start') + @Roles('ADMIN') + async startConnection( + @Param('provider') provider: string, + @CurrentUser() user: AuthUser, + @Req() request: AuthenticatedRequest, + ): Promise<{ authorizationUrl: string }> { + if (!request.session?.id) throw new BadRequestException('Session konnte nicht geprüft werden'); + return { authorizationUrl: await this.marketplaceService.beginConnection(provider, user.id, request.session.id) }; + } + + @Delete('connections/:provider') + @Roles('ADMIN') + async disconnect(@Param('provider') provider: string): Promise<{ success: true }> { + await this.marketplaceService.disconnect(provider); + return { success: true }; + } + + @Get('oauth/:provider/callback') + @Public() + async callback( + @Param('provider') provider: string, + @Query('code') code: string | undefined, + @Query('state') state: string | undefined, + @Query('error') error: string | undefined, + @Req() request: Request, + @Res() response: Response, + ): Promise { + const destination = new URL('/admin/modules', this.marketplaceService.frontendUrl()); + if (error) { + destination.searchParams.set('marketplace', 'denied'); + } else if (!code || !state) { + destination.searchParams.set('marketplace', 'error'); + destination.searchParams.set('reason', 'callback'); + } else { + try { + const token = request.cookies?.mpm_session as string | undefined; + const session = token ? await this.sessionService.findValid(token) : null; + if (!session) { + destination.searchParams.set('marketplace', 'error'); + destination.searchParams.set('reason', 'session'); + response.redirect(302, destination.toString()); + return; + } + await this.marketplaceService.completeConnection(provider, code, state, session?.id ?? null); + destination.searchParams.set('marketplace', 'connected'); + destination.searchParams.set('provider', provider); + } catch { + destination.searchParams.set('marketplace', 'error'); + destination.searchParams.set('reason', 'oauth'); + } + } + response.redirect(302, destination.toString()); + } +} diff --git a/apps/platform-backend/src/modules/marketplace.service.ts b/apps/platform-backend/src/modules/marketplace.service.ts new file mode 100644 index 0000000..ba91d8e --- /dev/null +++ b/apps/platform-backend/src/modules/marketplace.service.ts @@ -0,0 +1,411 @@ +import { + BadGatewayException, + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { createCipheriv, createHash, randomBytes } from 'node:crypto'; +import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; +import { Inject } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; + +export const MARKETPLACE_PROVIDERS = ['github', 'gitea', 'forgejo'] as const; +export type MarketplaceProvider = (typeof MARKETPLACE_PROVIDERS)[number]; + +interface ProviderStatus { + provider: MarketplaceProvider; + label: string; + configured: boolean; + connected: boolean; + accountLogin: string | null; + baseUrl: string; + callbackUrl: string; +} + +interface OAuthStateRow { + readonly provider: MarketplaceProvider; + readonly user_id: string; + readonly session_id: string; + readonly code_verifier: string; +} + +interface ProviderIdentity { + readonly id: string | number; + readonly login?: string; + readonly username?: string; +} + +const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024; + +function isProvider(value: string): value is MarketplaceProvider { + return MARKETPLACE_PROVIDERS.includes(value as MarketplaceProvider); +} + +function safeBaseUrl(value: string): string { + const url = new URL(value); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '::1'].includes(url.hostname))) { + throw new BadRequestException('Forge-URL muss HTTPS verwenden (HTTP ist nur für lokale Entwicklung erlaubt)'); + } + if (url.username || url.password || url.search || url.hash) { + throw new BadRequestException('Forge-URL darf keine Zugangsdaten oder URL-Parameter enthalten'); + } + return url.toString().replace(/\/$/, ''); +} + +@Injectable() +export class MarketplaceService { + constructor( + private readonly database: DatabaseService, + @Inject(APP_CONFIG) private readonly config: AppConfig, + ) {} + + async providers(): Promise { + const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>( + 'SELECT provider, account_login FROM marketplace_connections', + ); + const connectedByProvider = new Map(connected.rows.map((row) => [row.provider, row.account_login])); + const labels: Record = { + github: 'GitHub', gitea: 'Gitea', forgejo: 'Forgejo', + }; + return MARKETPLACE_PROVIDERS.map((provider) => { + const configured = this.config.marketplace.providers[provider]; + return { + provider, + label: labels[provider], + configured: Boolean(configured), + connected: connectedByProvider.has(provider), + accountLogin: connectedByProvider.get(provider) ?? null, + baseUrl: configured?.baseUrl ?? (provider === 'github' ? 'https://github.com' : ''), + callbackUrl: this.callbackUrl(provider), + }; + }); + } + + async beginConnection(providerParam: string, userId: string, sessionId: string): Promise { + const provider = this.requireProvider(providerParam); + const providerConfig = this.config.marketplace.providers[provider]; + if (!providerConfig) { + throw new BadRequestException(`${provider} ist noch nicht konfiguriert`); + } + if (this.config.marketplace.tokenEncryptionKey.length < 32) { + throw new InternalServerErrorException('MARKETPLACE_TOKEN_ENCRYPTION_KEY muss mindestens 32 Zeichen lang sein'); + } + + const state = randomBytes(32).toString('base64url'); + const verifier = randomBytes(48).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const callback = this.callbackUrl(provider); + await this.database.query( + `INSERT INTO marketplace_oauth_states(state_hash, provider, user_id, session_id, code_verifier, expires_at) + VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes')`, + [this.hash(state), provider, userId, sessionId, verifier], + ); + await this.database.query('DELETE FROM marketplace_oauth_states WHERE expires_at < now()'); + + const authorizeUrl = new URL( + provider === 'github' + ? '/login/oauth/authorize' + : `${new URL(providerConfig.baseUrl).pathname.replace(/\/$/, '')}/login/oauth/authorize`, + providerConfig.baseUrl, + ); + authorizeUrl.searchParams.set('client_id', providerConfig.clientId); + authorizeUrl.searchParams.set('redirect_uri', callback); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('code_challenge', challenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + authorizeUrl.searchParams.set('scope', 'read:user'); + return authorizeUrl.toString(); + } + + async completeConnection(providerParam: string, code: string, state: string, sessionId: string | null): Promise { + const provider = this.requireProvider(providerParam); + if (!code || code.length > 4096 || !state || state.length > 256 || !sessionId) { + throw new BadRequestException('OAuth-Rückgabe ist ungültig'); + } + const result = await this.database.query( + `DELETE FROM marketplace_oauth_states + WHERE state_hash = $1 AND provider = $2 AND session_id = $3 AND expires_at > now() + RETURNING provider, user_id, session_id, code_verifier`, + [this.hash(state), provider, sessionId], + ); + const oauthState = result.rows[0]; + if (!oauthState) { + throw new UnauthorizedException('OAuth-Status ist ungültig oder abgelaufen. Bitte erneut verbinden.'); + } + + const providerConfig = this.config.marketplace.providers[provider]; + if (!providerConfig) throw new BadRequestException(`${provider} ist nicht konfiguriert`); + const callback = this.callbackUrl(provider); + const token = await this.exchangeCode(provider, providerConfig, code, callback, oauthState.code_verifier); + const identity = await this.fetchIdentity(provider, providerConfig.baseUrl, token); + const login = identity.login ?? identity.username; + if (!login || identity.id === undefined || identity.id === null) { + throw new BadGatewayException('Der Forge hat keine gültige Benutzeridentität zurückgegeben'); + } + const encrypted = this.encryptToken(token); + await this.database.query( + `INSERT INTO marketplace_connections + (provider, account_id, account_login, token_ciphertext, token_iv, token_tag, connected_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (provider) DO UPDATE SET + account_id = EXCLUDED.account_id, + account_login = EXCLUDED.account_login, + token_ciphertext = EXCLUDED.token_ciphertext, + token_iv = EXCLUDED.token_iv, + token_tag = EXCLUDED.token_tag, + connected_by = EXCLUDED.connected_by, + connected_at = now()`, + [provider, String(identity.id), login, encrypted.ciphertext, encrypted.iv, encrypted.tag, oauthState.user_id], + ); + } + + async disconnect(providerParam: string): Promise { + const provider = this.requireProvider(providerParam); + await this.database.query('DELETE FROM marketplace_connections WHERE provider = $1', [provider]); + } + + async repositories(providerParam: string): Promise> { + const provider = this.requireProvider(providerParam); + const providerConfig = this.config.marketplace.providers[provider]; + if (!providerConfig) throw new BadRequestException(`${provider} ist nicht konfiguriert`); + const connection = await this.database.query<{ account_login: string }>( + 'SELECT account_login FROM marketplace_connections WHERE provider = $1', [provider], + ); + const login = connection.rows[0]?.account_login; + if (!login) throw new BadRequestException(`${provider} ist nicht verbunden`); + const path = provider === 'github' + ? `/users/${encodeURIComponent(login)}/repos?type=owner&sort=updated&per_page=50` + : `${new URL(providerConfig.baseUrl).pathname.replace(/\/$/, '')}/api/v1/users/${encodeURIComponent(login)}/repos?limit=50&sort=updated`; + const response = await this.forgeJson>>(provider, providerConfig.baseUrl, null, path); + const repositories = response.filter((repo) => repo.private !== true).map((repo) => { + const owner = typeof repo.owner === 'object' && repo.owner !== null + ? String((repo.owner as Record).login ?? (repo.owner as Record).username ?? login) + : login; + const repository = String(repo.name ?? ''); + return { + owner, + repository, + htmlUrl: String(repo.html_url ?? ''), + description: String(repo.description ?? ''), + defaultBranch: String(repo.default_branch ?? 'main'), + }; + }).filter((repo) => repo.repository && repo.htmlUrl); + const installed = await this.database.query<{ owner: string; repository: string }>( + 'SELECT owner, repository FROM marketplace_module_installations WHERE provider = $1', + [provider], + ); + const installedRepositories = new Set(installed.rows.map((row) => `${row.owner.toLowerCase()}/${row.repository.toLowerCase()}`)); + return repositories.map((repo) => ({ + ...repo, + installed: installedRepositories.has(`${repo.owner.toLowerCase()}/${repo.repository.toLowerCase()}`), + })); + } + + async recordInstallation(providerParam: string, owner: string, repository: string, moduleId: string): Promise { + const provider = this.requireProvider(providerParam); + await this.database.query( + `INSERT INTO marketplace_module_installations (provider, owner, repository, module_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (provider, owner, repository) DO UPDATE SET module_id = EXCLUDED.module_id`, + [provider, owner, repository, moduleId], + ); + } + + async downloadRepositoryArchive(providerParam: string, owner: string, repository: string): Promise { + const provider = this.requireProvider(providerParam); + if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) { + throw new BadRequestException('Repository-Angabe ist ungueltig'); + } + const providerConfig = this.config.marketplace.providers[provider]; + if (!providerConfig) throw new BadRequestException('Forge-Anbieter ist nicht konfiguriert'); + const repo = await this.forgeJson>( + provider, + providerConfig.baseUrl, + null, + this.repositoryApiPath(provider, owner, repository), + ); + if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt'); + const defaultBranch = String(repo.default_branch ?? 'main'); + if (!defaultBranch || defaultBranch.length > 200) throw new BadRequestException('Standard-Branch ist ungueltig'); + const archivePath = provider === 'github' + ? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(defaultBranch) + : new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(defaultBranch) + '.zip'; + const archiveUrl = provider === 'github' + ? new URL(archivePath, 'https://api.github.com').toString() + : new URL(archivePath, providerConfig.baseUrl).toString(); + const allowedHosts = this.downloadHosts(providerConfig.baseUrl, provider); + const headers: Record = provider === 'github' ? { 'User-Agent': 'MPM-Module-Marketplace' } : {}; + const archive = await this.downloadBounded(archiveUrl, headers, allowedHosts, MAX_MARKETPLACE_DOWNLOAD); + return this.normalizeRepositoryArchive(archive); + } + + private async normalizeRepositoryArchive(archive: Buffer): Promise { + const AdmZip = (await import('adm-zip')).default; + const input = new AdmZip(archive); + const entries = input.getEntries(); + const files = entries.filter((entry) => !entry.isDirectory); + if (!files.length || files.length > 2000) throw new BadRequestException('Repository-Archiv enthaelt keine gueltigen Moduldateien'); + const firstPath = files[0].entryName; + const firstSlash = firstPath.indexOf('/'); + const candidateRoot = firstSlash > 0 ? firstPath.slice(0, firstSlash) : ''; + const hasRootManifest = files.some((entry) => entry.entryName === 'module.json'); + const root = hasRootManifest ? '' : candidateRoot; + if (!root && !hasRootManifest) throw new BadRequestException('Repository-Archiv muss module.json im Stammverzeichnis enthalten'); + let totalUncompressed = 0; + for (const entry of files) { + const name = entry.entryName; + if (name.includes(String.fromCharCode(92)) || name.startsWith('/') || name.split('/').includes('..')) throw new BadRequestException('Unsicherer Pfad im Repository-Archiv'); + if (root && !name.startsWith(root + '/')) throw new BadRequestException('Repository-Archiv hat mehrere Stammverzeichnisse'); + const unixType = (entry.header.attr >>> 16) & 0xf000; + if (unixType === 0xa000) throw new BadRequestException('Symlinks sind in Marketplace-Modulen nicht erlaubt'); + totalUncompressed += entry.header.size; + if (totalUncompressed > 50 * 1024 * 1024) throw new BadRequestException('Repository-Archiv ist entpackt zu gross'); + } + const output = new AdmZip(); + for (const entry of files) { + const relative = root ? entry.entryName.slice(root.length + 1) : entry.entryName; + if (relative) output.addFile(relative, entry.getData()); + } + const normalized = output.toBuffer(); + if (normalized.length > MAX_MARKETPLACE_DOWNLOAD) throw new BadRequestException('Modul-Paket ueberschreitet 10 MB'); + return normalized; + } + + private repositoryApiPath(provider: MarketplaceProvider, owner: string, repository: string): string { + return provider === 'github' + ? `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}` + : `${new URL(this.config.marketplace.providers[provider]!.baseUrl).pathname.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`; + } + + private async forgeJson(provider: MarketplaceProvider, baseUrl: string, token: string | null, path: string): Promise { + const url = provider === 'github' ? new URL(path, 'https://api.github.com') : new URL(path, baseUrl); + const response = await fetch(url, { + headers: { Accept: 'application/json', ...this.providerHeaders(provider, token) }, + signal: AbortSignal.timeout(15_000), + }).catch(() => { throw new BadGatewayException('Forge-Katalog konnte nicht geladen werden'); }); + if (!response.ok) throw new BadGatewayException('Forge-Katalog konnte nicht geladen werden'); + return response.json() as Promise; + } + + private providerHeaders(provider: MarketplaceProvider, token: string | null): Record { + return { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(provider === 'github' ? { 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'MPM-Module-Marketplace' } : {}), + }; + } + + private downloadHosts(baseUrl: string, provider: MarketplaceProvider): string[] { + const host = new URL(baseUrl).hostname.toLowerCase(); + return provider === 'github' ? [host, 'api.github.com', 'codeload.github.com'] : [host]; + } + + private async downloadBounded(urlValue: string, headers: Record, allowedHosts: string[], maxBytes: number): Promise { + let url = new URL(urlValue); + for (let redirects = 0; redirects <= 3; redirects += 1) { + if (url.protocol !== 'https:' || !allowedHosts.includes(url.hostname.toLowerCase())) throw new BadRequestException('Forge hat eine nicht vertrauenswürdige Download-Adresse geliefert'); + const response = await fetch(url, { headers, redirect: 'manual', signal: AbortSignal.timeout(30_000) }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = response.headers.get('location'); + if (!location || redirects === 3) throw new BadGatewayException('Forge-Download konnte nicht aufgelöst werden'); + url = new URL(location, url); + continue; + } + if (!response.ok || !response.body) throw new BadGatewayException('Forge-Download ist fehlgeschlagen'); + const size = Number(response.headers.get('content-length') ?? 0); + if (size > maxBytes) throw new BadRequestException('Forge-Paket überschreitet die erlaubte Größe'); + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + total += chunk.value.byteLength; + if (total > maxBytes) { await reader.cancel(); throw new BadRequestException('Forge-Download überschreitet die erlaubte Größe'); } + chunks.push(Buffer.from(chunk.value)); + } + return Buffer.concat(chunks, total); + } + throw new BadGatewayException('Forge-Download konnte nicht aufgelöst werden'); + } + + callbackUrl(provider: MarketplaceProvider): string { + return `${this.config.marketplace.publicUrl}/api/v1/marketplace/oauth/${provider}/callback`; + } + + frontendUrl(): string { + return this.config.marketplace.publicUrl; + } + + private requireProvider(provider: string): MarketplaceProvider { + if (!isProvider(provider)) throw new NotFoundException('Unbekannter Forge-Anbieter'); + return provider; + } + + private hash(value: string | Buffer): string { + return createHash('sha256').update(value).digest('hex'); + } + + private encryptToken(token: string): { ciphertext: string; iv: string; tag: string } { + const key = createHash('sha256').update(this.config.marketplace.tokenEncryptionKey).digest(); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]); + return { + ciphertext: ciphertext.toString('base64'), + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + }; + } + + private async exchangeCode( + provider: MarketplaceProvider, + config: NonNullable, + code: string, + redirectUri: string, + verifier: string, + ): Promise { + const tokenUrl = new URL( + provider === 'github' + ? '/login/oauth/access_token' + : `${new URL(config.baseUrl).pathname.replace(/\/$/, '')}/login/oauth/access_token`, + config.baseUrl, + ); + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + code_verifier: verifier, + }), + signal: AbortSignal.timeout(12_000), + }).catch(() => { throw new BadGatewayException('Token-Austausch beim Forge ist fehlgeschlagen'); }); + const body = await response.json().catch(() => null) as { access_token?: unknown; error?: unknown } | null; + if (!response.ok || !body || typeof body.access_token !== 'string') { + throw new BadGatewayException('Forge hat die OAuth-Autorisierung abgelehnt'); + } + return body.access_token; + } + + private async fetchIdentity(provider: MarketplaceProvider, baseUrl: string, token: string): Promise { + const userUrl = provider === 'github' + ? 'https://api.github.com/user' + : `${safeBaseUrl(baseUrl)}/api/v1/user`; + const response = await fetch(userUrl, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + ...(provider === 'github' ? { 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'MPM-Module-Marketplace' } : {}), + }, + signal: AbortSignal.timeout(12_000), + }).catch(() => { throw new BadGatewayException('Benutzerkonto beim Forge konnte nicht gelesen werden'); }); + if (!response.ok) throw new BadGatewayException('Benutzerkonto beim Forge konnte nicht gelesen werden'); + return response.json() as Promise; + } +} diff --git a/apps/platform-backend/src/modules/migrations/005-module-port-unique.ts b/apps/platform-backend/src/modules/migrations/005-module-port-unique.ts new file mode 100644 index 0000000..92bfb56 --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/005-module-port-unique.ts @@ -0,0 +1,12 @@ +import type { Migration } from '../../database/migration.types'; + +/** Internal ports map to isolated module UIDs and must be unique. */ +export const migration005ModulePortUnique: Migration = { + id: '005-module-port-unique', + description: 'Eindeutige interne Modul-Ports sicherstellen', + up: async (client) => { + await client.query( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_modules_internal_port_unique ON modules(internal_port)', + ); + }, +}; diff --git a/apps/platform-backend/src/modules/migrations/006-marketplace-connections.ts b/apps/platform-backend/src/modules/migrations/006-marketplace-connections.ts new file mode 100644 index 0000000..d4a9af4 --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/006-marketplace-connections.ts @@ -0,0 +1,35 @@ +import type { Migration } from '../../database/migration.types'; + +export const migration006MarketplaceConnections: Migration = { + id: '006-marketplace-connections', + description: 'OAuth-Verbindungen für Modulquellen speichern', + up: async (client) => { + await client.query(` + CREATE TABLE marketplace_oauth_states ( + state_hash TEXT PRIMARY KEY, + provider TEXT NOT NULL CHECK (provider IN ('github', 'gitea', 'forgejo')), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + code_verifier TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + await client.query(` + CREATE INDEX idx_marketplace_oauth_states_expiry + ON marketplace_oauth_states(expires_at) + `); + await client.query(` + CREATE TABLE marketplace_connections ( + provider TEXT PRIMARY KEY CHECK (provider IN ('github', 'gitea', 'forgejo')), + account_id TEXT NOT NULL, + account_login TEXT NOT NULL, + token_ciphertext TEXT NOT NULL, + token_iv TEXT NOT NULL, + token_tag TEXT NOT NULL, + connected_by UUID REFERENCES users(id) ON DELETE SET NULL, + connected_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + }, +}; diff --git a/apps/platform-backend/src/modules/migrations/007-marketplace-catalog.ts b/apps/platform-backend/src/modules/migrations/007-marketplace-catalog.ts new file mode 100644 index 0000000..350f92d --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/007-marketplace-catalog.ts @@ -0,0 +1,22 @@ +import type { Migration } from '../../database/migration.types'; + +export const migration007MarketplaceCatalog: Migration = { + id: '007-marketplace-catalog', + description: 'Ausgewählte Repository-Quellen für den Modul-Marktplatz', + up: async (client) => { + await client.query(` + CREATE TABLE marketplace_catalog_sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider TEXT NOT NULL CHECK (provider IN ('github', 'gitea', 'forgejo')), + owner TEXT NOT NULL, + repository TEXT NOT NULL, + html_url TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + added_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (provider, owner, repository) + ) + `); + }, +}; diff --git a/apps/platform-backend/src/modules/migrations/008-marketplace-source-branch.ts b/apps/platform-backend/src/modules/migrations/008-marketplace-source-branch.ts new file mode 100644 index 0000000..ff77e8a --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/008-marketplace-source-branch.ts @@ -0,0 +1,12 @@ +import type { Migration } from '../../database/migration.types'; + +export const migration008MarketplaceSourceBranch: Migration = { + id: '008-marketplace-source-branch', + description: 'Standard-Branch für direkt installierbare Marketplace-Quellen speichern', + up: async (client) => { + await client.query(` + ALTER TABLE marketplace_catalog_sources + ADD COLUMN default_branch TEXT NOT NULL DEFAULT 'main' + `); + }, +}; diff --git a/apps/platform-backend/src/modules/migrations/009-marketplace-installations.ts b/apps/platform-backend/src/modules/migrations/009-marketplace-installations.ts new file mode 100644 index 0000000..903465d --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/009-marketplace-installations.ts @@ -0,0 +1,19 @@ +import type { Migration } from '../../database/migration.types'; + +export const migration009MarketplaceInstallations: Migration = { + id: '009-marketplace-installations', + description: 'Marketplace-Repositories installierten Modulen zuordnen', + up: async (client) => { + await client.query(` + CREATE TABLE marketplace_module_installations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider TEXT NOT NULL CHECK (provider IN ('github', 'gitea', 'forgejo')), + owner TEXT NOT NULL, + repository TEXT NOT NULL, + module_id UUID NOT NULL UNIQUE REFERENCES modules(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (provider, owner, repository) + ) + `); + }, +}; diff --git a/apps/platform-backend/src/modules/migrations/010-module-containers.ts b/apps/platform-backend/src/modules/migrations/010-module-containers.ts new file mode 100644 index 0000000..41d5cec --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/010-module-containers.ts @@ -0,0 +1,13 @@ +import type { Migration } from '../../database/migration.types'; + +export const migration010ModuleContainers: Migration = { + id: '010-module-containers', + description: 'Container-Compose-Konfiguration installierter Module speichern', + up: async (client) => { + await client.query(` + ALTER TABLE modules + ADD COLUMN compose_file TEXT, + ADD COLUMN app_service TEXT + `); + }, +}; diff --git a/apps/platform-backend/src/modules/module-container-manager.ts b/apps/platform-backend/src/modules/module-container-manager.ts new file mode 100644 index 0000000..711c3e3 --- /dev/null +++ b/apps/platform-backend/src/modules/module-container-manager.ts @@ -0,0 +1,244 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { spawn } from 'node:child_process'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { stringify, parseDocument } from 'yaml'; +import { ModuleIdentityService } from './module-identity.service'; +import type { ModuleRecord } from './manifest.types'; + +const SAFE_SERVICE_KEYS = new Set([ + 'image', 'build', 'command', 'entrypoint', 'environment', 'depends_on', 'volumes', + 'healthcheck', 'working_dir', 'user', 'restart', 'expose', 'networks', 'hostname', + 'logging', 'mem_limit', 'cpus', 'pids_limit', 'init', 'tmpfs', 'labels', + 'stop_grace_period', 'read_only', 'tty', 'stdin_open', +]); + +/** Orchestriert einen isolierten Docker-Compose-Stack für jedes Modul. */ +@Injectable() +export class ModuleContainerManager { + private readonly logger = new Logger('ModuleContainers'); + private readonly dockerHost = process.env.MODULE_DOCKER_HOST ?? 'unix:///var/run/docker.sock'; + private readonly mpmContainer = process.env.MPM_CONTAINER_NAME ?? ''; + + constructor(private readonly identityService: ModuleIdentityService) {} + + async start(module: ModuleRecord): Promise { + const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); + // Recreate stopped containers and project networks before each start. This + // prevents Compose v1 from trying to reconcile stale Docker Desktop network + // defaults after a stop; named data volumes are deliberately left untouched. + await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']); + if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); + await this.runDocker(['network', 'rm', gatewayNetwork], true); + await this.runDocker(['network', 'create', gatewayNetwork]); + await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans']); + if (this.mpmContainer) { + await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); + await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]); + } + this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`); + } + + async stop(module: ModuleRecord): Promise { + const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); + await this.runCompose(module.path, projectName, composePath, overridePath, ['stop']); + if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); + this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`); + } + + async remove(module: ModuleRecord): Promise { + const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module); + if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true); + // Compose down removes every app/database container and its networks. Named + // volumes remain, so uninstalling code does not silently destroy database data. + await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']); + await this.runDocker(['network', 'rm', gatewayNetwork], true); + await rm(overridePath, { force: true }); + this.logger.log(`Container für "${module.moduleId}" entfernt; Datenvolumes bleiben erhalten`); + } + + private async prepare(module: ModuleRecord): Promise<{ + composePath: string; + overridePath: string; + projectName: string; + gatewayNetwork: string; + }> { + if (!module.composeFile || !module.appService) { + throw new BadRequestException('Dieses Modul hat keine Docker-Compose-Konfiguration'); + } + const root = path.resolve(module.path); + const composePath = path.resolve(root, module.composeFile); + if (!composePath.startsWith(root + path.sep)) throw new BadRequestException('Compose-Datei liegt außerhalb des Modulpakets'); + const source = await readFile(composePath, 'utf8').catch(() => { + throw new BadRequestException(`Compose-Datei "${module.composeFile}" wurde nicht gefunden`); + }); + const document = parseDocument(source, { uniqueKeys: true }); + if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML'); + const compose = document.toJS() as Record; + this.validateCompose(compose, module); + + const projectName = `mpm-${module.moduleId}`; + const gatewayNetwork = `mpm-module-${module.moduleId}-gateway`; + // Keep generated secrets outside the package/build context so Dockerfiles + // cannot accidentally copy them into an application image. + const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`); + const override = { + version: '3.8', + services: { + [module.appService]: { + container_name: `mpm-${module.moduleId}-app`, + environment: { + PORT: String(module.internalPort), + NODE_ENV: process.env.NODE_ENV ?? 'production', + MPM_MODULE_DATA_DIR: '/var/lib/mpm-module', + MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId), + }, + volumes: ['mpm-runtime-data:/var/lib/mpm-module'], + networks: { + default: {}, + 'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] }, + }, + security_opt: ['no-new-privileges:true'], + }, + }, + volumes: { 'mpm-runtime-data': {} }, + networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } }, + }; + await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 }); + await writeFile(overridePath, stringify(override), { mode: 0o600 }); + return { composePath, overridePath, projectName, gatewayNetwork }; + } + + private validateCompose(compose: Record, module: ModuleRecord): void { + if (!compose || typeof compose !== 'object' || Array.isArray(compose)) { + throw new BadRequestException('Compose-Datei muss ein YAML-Objekt enthalten'); + } + const topLevel = new Set(['version', 'services', 'volumes', 'networks']); + if (Object.keys(compose).some((key) => !topLevel.has(key))) { + throw new BadRequestException('Compose darf nur services, volumes und networks enthalten'); + } + const services = compose.services; + if (!services || typeof services !== 'object' || Array.isArray(services)) { + throw new BadRequestException('Compose benötigt mindestens einen Service'); + } + const serviceMap = services as Record; + if (!Object.hasOwn(serviceMap, module.appService!)) { + throw new BadRequestException(`Compose-Service "${module.appService}" fehlt`); + } + const definedNetworks = this.record(compose.networks, 'networks'); + const definedVolumes = this.record(compose.volumes, 'volumes'); + if (Object.hasOwn(definedNetworks, 'mpm-gateway') || Object.hasOwn(definedVolumes, 'mpm-runtime-data')) { + throw new BadRequestException('Compose verwendet einen für MPM reservierten Netzwerk- oder Volume-Namen'); + } + for (const [name, rawService] of Object.entries(serviceMap)) { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/.test(name) || !rawService || typeof rawService !== 'object' || Array.isArray(rawService)) { + throw new BadRequestException('Compose enthält einen ungültigen Service'); + } + const service = rawService as Record; + if (Object.keys(service).some((key) => !SAFE_SERVICE_KEYS.has(key))) { + throw new BadRequestException(`Compose-Service "${name}" enthält nicht erlaubte Optionen`); + } + if (service.ports !== undefined || service.privileged !== undefined || service.cap_add !== undefined || + service.devices !== undefined || service.network_mode !== undefined || service.pid !== undefined || + service.ipc !== undefined || service.volumes_from !== undefined || service.env_file !== undefined || + service.container_name !== undefined || service.secrets !== undefined || service.configs !== undefined) { + throw new BadRequestException(`Compose-Service "${name}" darf keine Host- oder privilegierten Ressourcen verwenden`); + } + if (service.build !== undefined) this.validateBuild(service.build, module.path); + if (service.volumes !== undefined) this.validateVolumes(service.volumes); + service.security_opt = ['no-new-privileges:true']; + } + for (const [name, volume] of Object.entries(definedVolumes)) { + if (name === 'mpm-runtime-data' || (volume !== undefined && volume !== null && + (!volume || typeof volume !== 'object' || Array.isArray(volume) || Object.keys(volume).length > 0))) { + throw new BadRequestException('Compose darf nur projektlokale Datenvolumes definieren'); + } + } + for (const [name, network] of Object.entries(definedNetworks)) { + const networkConfig = network && typeof network === 'object' && !Array.isArray(network) + ? network as Record + : {}; + if (name === 'mpm-gateway' || (network !== undefined && network !== null && + (!network || typeof network !== 'object' || Array.isArray(network) || + Object.keys(networkConfig).some((key) => !['internal', 'attachable', 'labels'].includes(key))))) { + throw new BadRequestException('Compose darf keine externen Netzwerke verwenden'); + } + } + } + + private record(value: unknown, label: string): Record { + if (value === undefined) return {}; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new BadRequestException(`Compose-${label} muss ein Objekt sein`); + } + return value as Record; + } + + private validateBuild(build: unknown, modulePath: string): void { + const context = typeof build === 'string' + ? build + : build && typeof build === 'object' && !Array.isArray(build) + ? String((build as Record).context ?? '.') + : ''; + if (!context || path.isAbsolute(context) || context.split(/[\\/]/).includes('..')) { + throw new BadRequestException('Build-Kontext muss innerhalb des Modulpakets liegen'); + } + const resolved = path.resolve(modulePath, context); + if (resolved !== modulePath && !resolved.startsWith(modulePath + path.sep)) { + throw new BadRequestException('Build-Kontext liegt außerhalb des Modulpakets'); + } + } + + private validateVolumes(volumes: unknown): void { + if (!Array.isArray(volumes)) throw new BadRequestException('Compose-Volumes müssen als Liste angegeben werden'); + for (const volume of volumes) { + if (typeof volume !== 'string') throw new BadRequestException('Compose-Volume-Angabe ist ungültig'); + const parts = volume.split(':'); + const [source, target, mode] = parts; + if (parts.length > 3 || !target || !target.startsWith('/') || + (source && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(source)) || + (mode !== undefined && !/^(ro|rw)(,ro|,rw)?$/.test(mode))) { + throw new BadRequestException('Compose darf keine Host-Verzeichnisse mounten'); + } + } + } + + private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[]): Promise { + return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd); + } + + private runDocker(args: string[], ignoreFailure = false): Promise { + return this.run('docker', args, process.cwd(), ignoreFailure); + } + + private run(command: string, args: string[], cwd: string, ignoreFailure = false): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: { + PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + // Do not let a root-owned /root/.docker configuration affect a child + // command started by the unprivileged backend user. + HOME: '/tmp', + DOCKER_HOST: this.dockerHost, + }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-2000); }); + child.once('error', (error) => { + if (ignoreFailure) resolve(); + else reject(new Error(`${command} konnte nicht gestartet werden: ${error.message}`)); + }); + child.once('close', (code) => { + if (code === 0 || ignoreFailure) resolve(); + else { + this.logger.error(`${command} ${args[args.length - 1]} schlug mit Status ${code} fehl: ${stderr.trim()}`); + reject(new Error(`${command} schlug mit Status ${code} fehl`)); + } + }); + }); + } +} diff --git a/apps/platform-backend/src/modules/module-filesystem.ts b/apps/platform-backend/src/modules/module-filesystem.ts new file mode 100644 index 0000000..425267f --- /dev/null +++ b/apps/platform-backend/src/modules/module-filesystem.ts @@ -0,0 +1,26 @@ +import { BadRequestException } from '@nestjs/common'; +import { chmod, lstat, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +/** Make module files readable to runtime users, but never writable. */ +export async function secureModuleDirectory(directory: string): Promise { + const rootInfo = await lstat(directory); + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) { + throw new BadRequestException('Modulverzeichnis muss ein echtes Verzeichnis sein'); + } + await chmod(directory, 0o755); + for (const entry of await readdir(directory)) { + const entryPath = path.join(directory, entry); + const info = await lstat(entryPath); + if (info.isSymbolicLink()) { + throw new BadRequestException(`Symbolische Links sind im Modul-Paket nicht erlaubt: ${entry}`); + } + if (info.isDirectory()) { + await secureModuleDirectory(entryPath); + } else if (info.isFile()) { + await chmod(entryPath, 0o644); + } else { + throw new BadRequestException(`Nicht unterstützter Dateityp im Modul-Paket: ${entry}`); + } + } +} diff --git a/apps/platform-backend/src/modules/module-gateway.middleware.ts b/apps/platform-backend/src/modules/module-gateway.middleware.ts index 787ae1e..379d300 100644 --- a/apps/platform-backend/src/modules/module-gateway.middleware.ts +++ b/apps/platform-backend/src/modules/module-gateway.middleware.ts @@ -6,6 +6,7 @@ import { extractSessionToken } from '../auth/guards/session.guard'; import { UserRepository } from '../users/user.repository'; import { ModuleRepository } from './module.repository'; import { ModulePermissionsService } from './module-permissions.service'; +import { ModuleIdentityService } from './module-identity.service'; /** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */ const GATEWAY_PREFIX = '/api/v1/gateway/'; @@ -34,6 +35,7 @@ export class ModuleGatewayMiddleware implements NestMiddleware { private readonly sessionService: SessionService, private readonly userRepository: UserRepository, private readonly permissionsService: ModulePermissionsService, + private readonly identityService: ModuleIdentityService = new ModuleIdentityService(), ) { this.proxy = httpProxy.createProxyServer({ proxyTimeout: 30_000, @@ -52,6 +54,21 @@ export class ModuleGatewayMiddleware implements NestMiddleware { response.end(); } }); + + // Nest/Express may already have consumed JSON request bodies before this + // middleware runs. Replay the parsed body to the module instead of leaving + // the proxied request stream empty (which makes module POST handlers hang). + this.proxy.on('proxyReq', (proxyRequest, request) => { + const contentType = request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase(); + const parsedBody = (request as Request).body; + if (contentType !== 'application/json' || parsedBody === undefined) { + return; + } + + const body = JSON.stringify(parsedBody); + proxyRequest.setHeader('Content-Length', Buffer.byteLength(body)); + proxyRequest.write(body); + }); } async use(request: Request, response: Response, next: NextFunction): Promise { @@ -114,16 +131,19 @@ export class ModuleGatewayMiddleware implements NestMiddleware { } // 5. Identität sicher an das Modul übergeben (Header, nicht URL) + request.url = modulePath; + const signedIdentity = this.identityService.sign(module.moduleId, request.method, request.url, user); request.headers['x-user-id'] = user.id; request.headers['x-user-username'] = user.username; request.headers['x-user-display-name'] = user.displayName; request.headers['x-user-role'] = user.role; + request.headers['x-mpm-identity-timestamp'] = signedIdentity.timestamp; + request.headers['x-mpm-identity-signature'] = signedIdentity.signature; // Session-Cookie niemals an das Modul weiterleiten delete request.headers.cookie; - request.url = modulePath; this.proxy.web(request, response, { - target: `http://127.0.0.1:${module.internalPort}`, + target: `http://${module.composeFile && module.appService ? `mpm-${module.moduleId}` : '127.0.0.1'}:${module.internalPort}`, }); } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/modules/module-health-checker.ts b/apps/platform-backend/src/modules/module-health-checker.ts index 33fa80f..3e0778a 100644 --- a/apps/platform-backend/src/modules/module-health-checker.ts +++ b/apps/platform-backend/src/modules/module-health-checker.ts @@ -18,7 +18,8 @@ export class ModuleHealthChecker { /** Prüft einen Modul-Prozess über seine Healthcheck-URL. */ async check(module: ModuleRecord): Promise { - const url = `http://127.0.0.1:${module.internalPort}${module.healthcheckUrl}`; + const host = module.composeFile && module.appService ? `mpm-${module.moduleId}` : '127.0.0.1'; + const url = `http://${host}:${module.internalPort}${module.healthcheckUrl}`; const start = performance.now(); try { @@ -40,4 +41,4 @@ export class ModuleHealthChecker { return { healthy: false, latencyMs, detail: `unreachable: ${detail}` }; } } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/modules/module-identity.service.ts b/apps/platform-backend/src/modules/module-identity.service.ts new file mode 100644 index 0000000..51618c7 --- /dev/null +++ b/apps/platform-backend/src/modules/module-identity.service.ts @@ -0,0 +1,68 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import type { IncomingHttpHeaders } from 'node:http'; +import type { AuthUser } from '../users/user.types'; + +const TIMESTAMP_HEADER = 'x-mpm-identity-timestamp'; +const SIGNATURE_HEADER = 'x-mpm-identity-signature'; +const MAX_AGE_MS = 30_000; + +/** Issues module-specific, request-bound identities for the local module gateway. */ +@Injectable() +export class ModuleIdentityService { + private readonly masterKey = randomBytes(32); + + keyForModule(moduleId: string): string { + return createHmac('sha256', this.masterKey).update(moduleId).digest('base64url'); + } + + sign(moduleId: string, method: string, url: string, user: AuthUser): { + timestamp: string; + signature: string; + } { + const timestamp = String(Date.now()); + return { + timestamp, + signature: this.signature(this.keyForModule(moduleId), [ + moduleId, method, url, timestamp, user.id, user.username, user.displayName, user.role, + ]), + }; + } + + verify( + moduleId: string, + method: string, + url: string, + headers: IncomingHttpHeaders, + key: string, + ): AuthUser | null { + const userId = this.readHeader(headers, 'x-user-id'); + const username = this.readHeader(headers, 'x-user-username'); + const displayName = this.readHeader(headers, 'x-user-display-name') ?? username; + const role = this.readHeader(headers, 'x-user-role'); + const timestamp = this.readHeader(headers, TIMESTAMP_HEADER); + const suppliedSignature = this.readHeader(headers, SIGNATURE_HEADER); + if (!userId || !username || !displayName || !timestamp || !suppliedSignature || + (role !== 'ADMIN' && role !== 'USER')) return null; + + const timestampNumber = Number(timestamp); + if (!Number.isSafeInteger(timestampNumber) || Math.abs(Date.now() - timestampNumber) > MAX_AGE_MS) { + return null; + } + const expected = Buffer.from(this.signature(key, [ + moduleId, method, url, timestamp, userId, username, displayName, role, + ]), 'hex'); + const supplied = Buffer.from(suppliedSignature, 'hex'); + if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) return null; + return { id: userId, username, displayName, role, email: '' }; + } + + private signature(key: string, fields: readonly string[]): string { + return createHmac('sha256', key).update(JSON.stringify(fields)).digest('hex'); + } + + private readHeader(headers: IncomingHttpHeaders, name: string): string | null { + const value = headers[name]; + return typeof value === 'string' ? value : null; + } +} diff --git a/apps/platform-backend/src/modules/module-installer.ts b/apps/platform-backend/src/modules/module-installer.ts index 555153f..669bb81 100644 --- a/apps/platform-backend/src/modules/module-installer.ts +++ b/apps/platform-backend/src/modules/module-installer.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { moduleManifestSchema, type ModuleManifest } from './manifest.types'; +import { secureModuleDirectory } from './module-filesystem'; /** Maximale Größe eines Modul-Pakets (10 MB). */ const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024; @@ -64,7 +65,19 @@ export class ModuleInstaller { }); } - return result.data; + const manifest = result.data; + if (!manifest.composeFile || !manifest.appService) { + throw new BadRequestException('module.json muss composeFile und appService für den Containerbetrieb enthalten'); + } + if (manifest.composeFile.startsWith('/') || manifest.composeFile.includes('\\') || + manifest.composeFile.split('/').includes('..')) { + throw new BadRequestException('composeFile muss ein relativer Pfad innerhalb des Modulpakets sein'); + } + if (!zip.getEntry(manifest.composeFile)) { + throw new BadRequestException(`Container-Konfiguration ${manifest.composeFile} fehlt im Paket`); + } + + return manifest; } /** @@ -99,6 +112,7 @@ export class ModuleInstaller { await mkdir(directory, { recursive: true }); zip.extractAllTo(resolvedDirectory, true); + await secureModuleDirectory(resolvedDirectory); // Paket-Metadaten für spätere Diagnose speichern. await writeFile( @@ -127,4 +141,4 @@ export class ModuleInstaller { return null; } } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/modules/module-process-manager.ts b/apps/platform-backend/src/modules/module-process-manager.ts index 88d6b25..0890cd9 100644 --- a/apps/platform-backend/src/modules/module-process-manager.ts +++ b/apps/platform-backend/src/modules/module-process-manager.ts @@ -1,10 +1,13 @@ import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common'; import { spawn, type ChildProcess } from 'node:child_process'; -import { mkdir, open } from 'node:fs/promises'; +import { chmod, chown, mkdir, open } from 'node:fs/promises'; import path from 'node:path'; import { APP_CONFIG, type AppConfig } from '../config/config.tokens'; import { Inject } from '@nestjs/common'; -import type { ModuleRecord } from './manifest.types'; +import { MODULE_PORT_MIN, type ModuleRecord } from './manifest.types'; +import { secureModuleDirectory } from './module-filesystem'; +import { ModuleIdentityService } from './module-identity.service'; +import { ModuleContainerManager } from './module-container-manager'; /** Laufende Modul-Prozesse im Speicher (nicht persistent). */ interface RunningProcess { @@ -27,21 +30,63 @@ interface RunningProcess { export class ModuleProcessManager implements OnModuleDestroy { private readonly logger = new Logger('ModuleProcesses'); private readonly running = new Map(); + private readonly containerModules = new Map(); - constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {} + constructor( + @Inject(APP_CONFIG) private readonly config: AppConfig, + private readonly identityService: ModuleIdentityService, + private readonly containerManager: ModuleContainerManager, + ) {} /** Startet einen Modul-Prozess. */ async start(module: ModuleRecord): Promise { - if (this.running.has(module.moduleId)) { + if (this.running.has(module.moduleId) || this.containerModules.has(module.moduleId)) { + return; + } + + if (module.composeFile && module.appService) { + await secureModuleDirectory(module.path); + await this.containerManager.start(module); + this.containerModules.set(module.moduleId, module); return; } const entrypoint = path.join(module.path, 'backend', 'server.js'); const logFilePath = path.join(this.config.runtime.logsDir, `module-${module.moduleId}.log`); - await mkdir(this.config.runtime.logsDir, { recursive: true }); - const logFile = await open(logFilePath, 'a'); + await secureModuleDirectory(module.path); + const moduleUid = + process.platform !== 'win32' && this.config.runtime.moduleUidBase !== undefined + ? this.config.runtime.moduleUidBase + module.internalPort - MODULE_PORT_MIN + : undefined; + const legacyDataDir = path.join(module.path, 'data'); + await mkdir(legacyDataDir, { recursive: true, mode: 0o700 }); + if (moduleUid !== undefined) await chown(legacyDataDir, moduleUid, moduleUid); + await mkdir(this.config.runtime.logsDir, { recursive: true, mode: 0o700 }); + await chmod(this.config.runtime.logsDir, 0o700); + const logFile = await open(logFilePath, 'a', 0o600); + await chmod(logFilePath, 0o600); - const child = spawn(process.execPath, [entrypoint], { + // Unique UID/GID per internal port prevents modules from reading or tracing + // each other's processes. Production Compose grants only SETUID/SETGID. + // The platform backend receives SETUID/SETGID/KILL as ambient capabilities from + // supervisord. Use setpriv to change the module identity, then clear all + // inheritable/ambient capabilities before executing untrusted module code. + const moduleCommand = moduleUid !== undefined ? '/usr/bin/setpriv' : process.execPath; + const moduleArgs = + moduleUid !== undefined + ? [ + `--reuid=${moduleUid}`, + `--regid=${moduleUid}`, + '--clear-groups', + '--inh-caps=-all', + '--ambient-caps=-all', + '--no-new-privs', + '--', + process.execPath, + entrypoint, + ] + : [entrypoint]; + const child = spawn(moduleCommand, moduleArgs, { cwd: module.path, // Log-Datei bleibt offen: Der fd wird vom Kindprozess geerbt und // darf erst nach Prozessende geschlossen werden. @@ -50,6 +95,8 @@ export class ModuleProcessManager implements OnModuleDestroy { PATH: process.env.PATH ?? '', NODE_ENV: this.config.nodeEnv, PORT: String(module.internalPort), + MPM_MODULE_DATA_DIR: legacyDataDir, + MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId), // Modul erhält nur seinen eigenen Kontext – keine Plattform-Secrets. }, detached: false, @@ -68,6 +115,12 @@ export class ModuleProcessManager implements OnModuleDestroy { /** Stoppt einen Modul-Prozess (SIGTERM, dann SIGKILL). */ async stop(moduleId: string): Promise { + const containerModule = this.containerModules.get(moduleId); + if (containerModule) { + await this.containerManager.stop(containerModule); + this.containerModules.delete(moduleId); + return; + } const process_ = this.running.get(moduleId); if (!process_) { return; @@ -93,6 +146,13 @@ export class ModuleProcessManager implements OnModuleDestroy { this.logger.log(`Modul-Prozess "${moduleId}" gestoppt`); } + async remove(module: ModuleRecord): Promise { + if (module.composeFile && module.appService) { + await this.containerManager.remove(module); + this.containerModules.delete(module.moduleId); + } + } + /** Prüft, ob ein Modul-Prozess läuft. */ isRunning(moduleId: string): boolean { return this.running.has(moduleId); @@ -100,11 +160,11 @@ export class ModuleProcessManager implements OnModuleDestroy { /** Stoppt alle Modul-Prozesse (Herunterfahren). */ async stopAll(): Promise { - const moduleIds = [...this.running.keys()]; + const moduleIds = [...this.running.keys(), ...this.containerModules.keys()]; await Promise.all(moduleIds.map((moduleId) => this.stop(moduleId))); } async onModuleDestroy(): Promise { await this.stopAll(); } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/modules/module.repository.ts b/apps/platform-backend/src/modules/module.repository.ts index 79bb1e7..74351b9 100644 --- a/apps/platform-backend/src/modules/module.repository.ts +++ b/apps/platform-backend/src/modules/module.repository.ts @@ -17,10 +17,13 @@ interface ModuleRow { enabled: boolean; created_at: Date; updated_at: Date; + compose_file: string | null; + app_service: string | null; } const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path, - status, internal_port, healthcheck_url, enabled, created_at, updated_at`; + status, internal_port, healthcheck_url, enabled, created_at, updated_at, + compose_file, app_service`; /** * Modul-Repository (Infrastructure): Datenbankzugriffe für die Modul-Registry. @@ -80,8 +83,9 @@ export class ModuleRepository { async create(manifest: ModuleManifest, directory: string): Promise { const result = await this.database.query( `INSERT INTO modules - (module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9) + (module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url, + compose_file, app_service) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11) RETURNING ${MODULE_COLUMNS}`, [ manifest.id, @@ -93,6 +97,8 @@ export class ModuleRepository { directory, manifest.port, manifest.healthcheck, + manifest.composeFile ?? null, + manifest.appService ?? null, ], ); return this.mapRow(result.rows[0]); @@ -132,6 +138,8 @@ export class ModuleRepository { enabled: row.enabled, createdAt: row.created_at, updatedAt: row.updated_at, + composeFile: row.compose_file, + appService: row.app_service, }; } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/modules/modules.module.ts b/apps/platform-backend/src/modules/modules.module.ts index a32476a..7f30cd8 100644 --- a/apps/platform-backend/src/modules/modules.module.ts +++ b/apps/platform-backend/src/modules/modules.module.ts @@ -21,15 +21,21 @@ import { ModuleRepository } from './module.repository'; import { ModuleStartupRecovery } from './module-startup-recovery'; import { ModulesController } from './modules.controller'; import { ModulesService } from './modules.service'; +import { ModuleIdentityService } from './module-identity.service'; +import { MarketplaceController } from './marketplace.controller'; +import { MarketplaceService } from './marketplace.service'; +import { ModuleContainerManager } from './module-container-manager'; /** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */ @Module({ imports: [ConfigModule, DatabaseModule, AuditModule], - controllers: [ModulesController, ModulePermissionsController], + controllers: [ModulesController, ModulePermissionsController, MarketplaceController], providers: [ ModuleRepository, ModuleInstaller, ModuleProcessManager, + ModuleIdentityService, + ModuleContainerManager, ModuleHealthChecker, ModulesService, SessionService, @@ -39,6 +45,7 @@ import { ModulesService } from './modules.service'; ModuleStartupRecovery, ModulePermissionRepository, ModulePermissionsService, + MarketplaceService, ], exports: [ModuleRepository, ModulesService, ModulePermissionsService, ModuleHealthChecker], }) @@ -49,4 +56,4 @@ export class ModulesModule implements NestModule { .apply(ModuleGatewayMiddleware) .forRoutes({ path: '/api/v1/gateway/(.*)', method: RequestMethod.ALL }); } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/modules/modules.service.spec.ts b/apps/platform-backend/src/modules/modules.service.spec.ts index c887d18..3dbc71a 100644 --- a/apps/platform-backend/src/modules/modules.service.spec.ts +++ b/apps/platform-backend/src/modules/modules.service.spec.ts @@ -191,6 +191,7 @@ const TEST_CONFIG: AppConfig = { }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' }, + marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, }; describe('ModulesService', () => { @@ -330,4 +331,4 @@ describe('ModulesService', () => { expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_REMOVED); }); }); -}); \ No newline at end of file +}); diff --git a/apps/platform-backend/src/modules/modules.service.ts b/apps/platform-backend/src/modules/modules.service.ts index 900a63a..a100888 100644 --- a/apps/platform-backend/src/modules/modules.service.ts +++ b/apps/platform-backend/src/modules/modules.service.ts @@ -88,6 +88,14 @@ export class ModulesService { return module; } + async validatePackage(packageBuffer: Buffer) { + return this.installer.validatePackage(packageBuffer); + } + + async findByModuleId(moduleId: string): Promise { + return this.moduleRepository.findByModuleId(moduleId); + } + /** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */ async start(id: string, actor: ActingUser, ipAddress: string | null): Promise { const module = await this.getById(id); @@ -210,6 +218,8 @@ export class ModulesService { await this.stop(id, actor, ipAddress); } + await this.processManager.remove(module); + await this.moduleRepository.delete(id); await this.installer.remove(this.config.runtime.modulesDir, module.moduleId); @@ -249,4 +259,4 @@ export class ModulesService { ipAddress, }); } -} \ No newline at end of file +} diff --git a/apps/platform-backend/src/users/user.repository.ts b/apps/platform-backend/src/users/user.repository.ts index d63e51a..3be09c4 100644 --- a/apps/platform-backend/src/users/user.repository.ts +++ b/apps/platform-backend/src/users/user.repository.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { PasswordHasher } from './password-hasher'; import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types'; @@ -108,17 +108,41 @@ export class UserRepository { setClauses.push('updated_at = now()'); params.push(id); - const result = await this.database.query( - `UPDATE users - SET ${setClauses.join(', ')} - WHERE id = $${parameterIndex} - RETURNING id, username, email, password_hash, display_name, - (SELECT name FROM roles WHERE id = role_id) AS role_name, - is_active, failed_login_attempts, locked_until, - last_login_at, created_at, updated_at`, - params, - ); - return this.mapRow(result.rows[0]); + return this.database.transaction(async (client) => { + await client.query('SELECT pg_advisory_xact_lock(727273)'); + if (changes.role === 'USER' || changes.isActive === false) { + const current = await client.query<{ role_name: RoleName; is_active: boolean }>( + `SELECT r.name AS role_name, u.is_active + FROM users u JOIN roles r ON r.id = u.role_id + WHERE u.id = $1 FOR UPDATE OF u`, + [id], + ); + if (current.rows[0]?.role_name === 'ADMIN' && current.rows[0].is_active) { + const count = await client.query<{ count: number }>( + `SELECT count(*)::int AS count + FROM users u JOIN roles r ON r.id = u.role_id + WHERE r.name = 'ADMIN' AND u.is_active`, + ); + if ((count.rows[0]?.count ?? 0) <= 1) { + throw new BadRequestException( + 'Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden', + ); + } + } + } + const result = await client.query( + `UPDATE users + SET ${setClauses.join(', ')} + WHERE id = $${parameterIndex} + RETURNING id, username, email, password_hash, display_name, + (SELECT name FROM roles WHERE id = role_id) AS role_name, + is_active, failed_login_attempts, locked_until, + last_login_at, created_at, updated_at`, + params, + ); + if (!result.rows[0]) throw new BadRequestException('Benutzer nicht gefunden'); + return this.mapRow(result.rows[0]); + }); } /** Setzt einen neuen Passwort-Hash. */ @@ -138,7 +162,26 @@ export class UserRepository { } async delete(id: string): Promise { - await this.database.query('DELETE FROM users WHERE id = $1', [id]); + await this.database.transaction(async (client) => { + await client.query('SELECT pg_advisory_xact_lock(727273)'); + const current = await client.query<{ role_name: RoleName; is_active: boolean }>( + `SELECT r.name AS role_name, u.is_active + FROM users u JOIN roles r ON r.id = u.role_id + WHERE u.id = $1 FOR UPDATE OF u`, + [id], + ); + if (current.rows[0]?.role_name === 'ADMIN' && current.rows[0].is_active) { + const count = await client.query<{ count: number }>( + `SELECT count(*)::int AS count + FROM users u JOIN roles r ON r.id = u.role_id + WHERE r.name = 'ADMIN' AND u.is_active`, + ); + if ((count.rows[0]?.count ?? 0) <= 1) { + throw new BadRequestException('Der letzte aktive Administrator kann nicht gelöscht werden'); + } + } + await client.query('DELETE FROM users WHERE id = $1', [id]); + }); } /** Anzahl aktiver Administratoren (Schutz vor Verlust des letzten Admins). */ @@ -163,21 +206,13 @@ export class UserRepository { ); } - async updateLoginFailure( - userId: string, - attempts: number, - shouldLock: boolean, - lockoutMinutes: number, - ): Promise { + async updateLoginFailure(userId: string): Promise { await this.database.query( `UPDATE users - SET failed_login_attempts = $2, - locked_until = CASE WHEN $3::boolean - THEN now() + make_interval(mins => $4::int) - ELSE locked_until END, + SET failed_login_attempts = failed_login_attempts + 1, updated_at = now() WHERE id = $1`, - [userId, attempts, shouldLock, lockoutMinutes], + [userId], ); } @@ -197,4 +232,4 @@ export class UserRepository { updatedAt: row.updated_at, }; } -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/components/layout/app-layout.tsx b/apps/platform-frontend/src/components/layout/app-layout.tsx index 131d30c..d064f10 100644 --- a/apps/platform-frontend/src/components/layout/app-layout.tsx +++ b/apps/platform-frontend/src/components/layout/app-layout.tsx @@ -1,30 +1,40 @@ -import { type ReactNode, useState } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { useAuth } from '../../features/auth/auth-context'; -import { Badge } from '../ui/badge'; +import { ProfilePage } from '../../features/profile/profile-page'; +import { Icon, type IconName } from '../ui/icon'; /** Ein Navigationspunkt der Sidebar. */ interface NavItem { to: string; label: string; - icon: string; + icon: IconName; adminOnly?: boolean; } const NAV_ITEMS: NavItem[] = [ - { to: '/', label: 'Dashboard', icon: '⌂' }, - { to: '/profile', label: 'Mein Profil', icon: '👤' }, - { to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true }, - { to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true }, - { to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true }, - { to: '/admin/audit', label: 'Audit-Log', icon: '📋', adminOnly: true }, - { to: '/admin/settings', label: 'Einstellungen', icon: '🔧', adminOnly: true }, + { to: '/', label: 'Dashboard', icon: 'dashboard' }, + { to: '/admin/users', label: 'Benutzer', icon: 'users', adminOnly: true }, + { to: '/admin/modules', label: 'Module', icon: 'modules', adminOnly: true }, + { to: '/admin/system', label: 'Systemstatus', icon: 'system', adminOnly: true }, + { to: '/admin/audit', label: 'Audit-Log', icon: 'audit', adminOnly: true }, ]; +function initialDarkMode(): boolean { + return document.documentElement.dataset.theme === 'dark'; +} + /** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */ export function AppLayout(): ReactNode { const { user, logout } = useAuth(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [profileOpen, setProfileOpen] = useState(false); + const [darkMode, setDarkMode] = useState(initialDarkMode); + + useEffect(() => { + document.documentElement.dataset.theme = darkMode ? 'dark' : 'light'; + localStorage.setItem('mpm-theme', darkMode ? 'dark' : 'light'); + }, [darkMode]); const visibleItems = NAV_ITEMS.filter( (item) => !item.adminOnly || user?.role === 'ADMIN', @@ -70,22 +80,28 @@ export function AppLayout(): ReactNode { }` } > - + {item.label} ))}
-
-
-

- {user?.displayName} -

-

@{user?.username}

-
- {user?.role === 'ADMIN' && Admin} -
+ - - Management-Plattform - + Management-Plattform MPM +
+ {profileOpen && setProfileOpen(false)} />} ); -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/components/route-guards.tsx b/apps/platform-frontend/src/components/route-guards.tsx index 9738731..846024c 100644 --- a/apps/platform-frontend/src/components/route-guards.tsx +++ b/apps/platform-frontend/src/components/route-guards.tsx @@ -13,7 +13,7 @@ export function RequireAuth({ children }: { children: ReactNode }): ReactNode { } if (status === 'unauthenticated') { - return ; + return ; } return children; @@ -32,4 +32,4 @@ export function RequireAdmin({ children }: { children: ReactNode }): ReactNode { } return children; -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/components/ui/card.tsx b/apps/platform-frontend/src/components/ui/card.tsx index a34651f..c872f65 100644 --- a/apps/platform-frontend/src/components/ui/card.tsx +++ b/apps/platform-frontend/src/components/ui/card.tsx @@ -9,7 +9,7 @@ export interface CardProps { export function Card({ children, className = '' }: CardProps): ReactNode { return (
{children}
@@ -25,7 +25,7 @@ export interface CardHeaderProps { /** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */ export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode { return ( -
+

{title}

{description &&

{description}

} @@ -42,5 +42,5 @@ export interface CardBodyProps { /** Karten-Inhalt. */ export function CardBody({ children, className = '' }: CardBodyProps): ReactNode { - return
{children}
; -} \ No newline at end of file + return
{children}
; +} diff --git a/apps/platform-frontend/src/components/ui/icon.tsx b/apps/platform-frontend/src/components/ui/icon.tsx new file mode 100644 index 0000000..4229aee --- /dev/null +++ b/apps/platform-frontend/src/components/ui/icon.tsx @@ -0,0 +1,36 @@ +import { type ReactNode } from 'react'; + +export type IconName = 'dashboard' | 'users' | 'modules' | 'system' | 'audit' | 'moon' | 'sun' | 'user' | 'arrow' | 'close' | 'menu' | 'plus' | 'check'; + +const PATHS: Record = { + dashboard: <>, + users: <>, + modules: <>, + system: <>, + audit: <>, + moon: , + sun: <>, + user: <>, + arrow: <>, + close: <>, + menu: <>, + plus: , + check: , +}; + +export function Icon({ name, className = 'h-5 w-5' }: { name: IconName; className?: string }): ReactNode { + return ( + + ); +} diff --git a/apps/platform-frontend/src/components/ui/modal.tsx b/apps/platform-frontend/src/components/ui/modal.tsx index 543362b..2f836bd 100644 --- a/apps/platform-frontend/src/components/ui/modal.tsx +++ b/apps/platform-frontend/src/components/ui/modal.tsx @@ -7,6 +7,8 @@ export interface ModalProps { onClose: () => void; children: ReactNode; footer?: ReactNode; + panelClassName?: string; + hideHeader?: boolean; } /** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */ @@ -17,6 +19,8 @@ export function Modal({ onClose, children, footer, + panelClassName = 'max-w-md', + hideHeader = false, }: ModalProps): ReactNode { useEffect(() => { if (!open) { @@ -45,16 +49,18 @@ export function Modal({ role="dialog" aria-modal="true" aria-label={title} - className="w-full max-w-md rounded-xl bg-white shadow-xl" + className={`w-full rounded-xl bg-white shadow-xl ${panelClassName}`} onClick={(event) => event.stopPropagation()} > -
-

{title}

- {description &&

{description}

} -
+ {!hideHeader && ( +
+

{title}

+ {description &&

{description}

} +
+ )}
{children}
{footer &&
{footer}
}
); -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/features/admin/audit-page.tsx b/apps/platform-frontend/src/features/admin/audit-page.tsx index e3ba9d1..127a558 100644 --- a/apps/platform-frontend/src/features/admin/audit-page.tsx +++ b/apps/platform-frontend/src/features/admin/audit-page.tsx @@ -76,10 +76,10 @@ export function AuditPage(): ReactNode { const totalPages = auditQuery.data ? Math.ceil(auditQuery.data.total / pageSize) : 0; return ( -
+
-

Audit-Log

-

+

Audit-Log

+

Sicherheitsrelevante Ereignisse der Plattform (neueste zuerst).

diff --git a/apps/platform-frontend/src/features/admin/modules-page.tsx b/apps/platform-frontend/src/features/admin/modules-page.tsx index 4c5576e..5f52da2 100644 --- a/apps/platform-frontend/src/features/admin/modules-page.tsx +++ b/apps/platform-frontend/src/features/admin/modules-page.tsx @@ -1,18 +1,25 @@ import { type ReactNode, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Button } from '../../components/ui/button'; +import { Icon } from '../../components/ui/icon'; import { Modal } from '../../components/ui/modal'; import { useToast } from '../../components/ui/toast'; import { ApiError } from '../../lib/api-client'; import { checkModuleHealth, + beginMarketplaceConnection, + disconnectMarketplaceProvider, + fetchMarketplaceRepositories, + fetchMarketplaceProviders, fetchModules, installModule, + installMarketplaceRepository, removeModule, restartModule, setModuleEnabled, startModule, stopModule, + type MarketplaceProvider, } from '../../lib/modules-api'; import type { Module, ModuleStatus } from '../../lib/schemas'; @@ -43,6 +50,7 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => mutationFn: () => removeModule(module.id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['modules'] }); + void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }); showToast('success', `Modul "${module.name}" wurde entfernt`); onClose(); }, @@ -55,7 +63,7 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => {formError && ( @@ -86,6 +94,8 @@ export function ModulesPage(): ReactNode { const fileInputRef = useRef(null); const [selectedFile, setSelectedFile] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); + const [connectTarget, setConnectTarget] = useState(null); + const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers'); const [healthResults, setHealthResults] = useState>({}); const modulesQuery = useQuery({ @@ -93,6 +103,45 @@ export function ModulesPage(): ReactNode { queryFn: fetchModules, refetchInterval: 15_000, }); + const marketplaceQuery = useQuery({ + queryKey: ['marketplace-providers'], + queryFn: fetchMarketplaceProviders, + }); + const connectedProviders = marketplaceQuery.data?.filter((item) => item.connected) ?? []; + const repositoriesQuery = useQuery({ + queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)], + queryFn: async () => Promise.all(connectedProviders.map(async (item) => ({ + ...item, + repositories: await fetchMarketplaceRepositories(item.provider), + }))), + enabled: connectedProviders.length > 0, + }); + + const connectMutation = useMutation({ + mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => beginMarketplaceConnection(provider), + onSuccess: (authorizationUrl) => window.location.assign(authorizationUrl), + onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht gestartet werden'), + }); + + const disconnectMutation = useMutation({ + mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => disconnectMarketplaceProvider(provider), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['marketplace-providers'] }); + showToast('success', 'Forge-Verbindung getrennt'); + }, + onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht getrennt werden'), + }); + + const marketplaceInstallMutation = useMutation({ + mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) => + installMarketplaceRepository(input.provider, input.owner, input.repository), + onSuccess: (module) => { + void queryClient.invalidateQueries({ queryKey: ['modules'] }); + void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }); + showToast('success', `Modul "${module.name}" wurde installiert. Zum Starten bitte „Start“ wählen.`); + }, + onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen'), + }); const invalidate = (): void => { void queryClient.invalidateQueries({ queryKey: ['modules'] }); @@ -163,190 +212,318 @@ export function ModulesPage(): ReactNode { } return ( -
+
-

Modulverwaltung

-

+

Modulverwaltung

+

Module installieren, starten, stoppen und entfernen.

- {/* Installation */} -
-
- - -

- ZIP-Paket mit module.json (Manifest). Maximal 10 MB. -

-
-
+ {new URLSearchParams(window.location.search).get('marketplace') === 'connected' && ( +

+ Forge-Konto wurde erfolgreich verbunden. +

+ )} + {new URLSearchParams(window.location.search).get('marketplace') === 'denied' && ( +

+ Autorisierung wurde abgebrochen. +

+ )} + {new URLSearchParams(window.location.search).get('marketplace') === 'error' && ( +

+ {new URLSearchParams(window.location.search).get('reason') === 'session' + ? 'MPM konnte deine Sitzung beim OAuth-Rücksprung nicht zuordnen. Verwende vor und nach der Anmeldung dieselbe Adresse (http://127.0.0.1:8081) und starte die Verbindung erneut.' + : new URLSearchParams(window.location.search).get('reason') === 'callback' + ? 'Die OAuth-Rückgabe war unvollständig. Bitte starte die Verbindung erneut.' + : 'Verbindung fehlgeschlagen. Prüfe OAuth-Konfiguration und Callback-URL und starte die Verbindung erneut.'} +

+ )} +
+ {/* Compact provider connectors */} +
+

Verbindungen

+
+ + +
+ {connectionTab === 'providers' ? ( +
+ {(['github', 'gitea', 'forgejo'] as const).map((provider) => { + const connection = marketplaceQuery.data?.find((item) => item.provider === provider); + const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo'); + const statusText = marketplaceQuery.isLoading + ? 'Status wird geladen…' + : connection?.connected + ? `Verbunden als ${connection.accountLogin}` + : connection?.configured + ? 'Marketplace verbinden' + : 'Nicht eingerichtet'; + return ( +
+ + + +
+

{label}

+

{statusText}

+
+ {connection?.connected ? ( + + ) : ( + + )} +
+ ); + })} +
+ ) : ( +
+ + +

ZIP-Paket mit module.json (Manifest). Maximal 10 MB.

+
+ )} +
{/* Modul-Liste */} -
- - +
+

Installierte Module

+
+
+ - - - - + + + + - {modulesQuery.isLoading && ( - - - - )} - {modulesQuery.isError && ( - - - - )} + {modulesQuery.isLoading && } + {modulesQuery.isError && } {modulesQuery.data?.map((module) => ( - - - + + ))}
ModulStatusURLAktionenModulStatusURLAktionen
- Module werden geladen… -
- Module konnten nicht geladen werden. -
Module werden geladen…
Module konnten nicht geladen werden.
-
{module.name}
-
- {module.moduleId} · Version {module.version} - {module.author && ` · ${module.author}`} -
- {module.description && ( -
{module.description}
- )} +
{module.name}
+
{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}
+ {module.description &&
{module.description}
}
- - {module.status} - - {healthResults[module.id] && ( -
- Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail} -
- )} -
- - /{module.slug} - - -
- {module.status !== 'RUNNING' && module.enabled && ( - - )} - {(module.status === 'RUNNING' || module.status === 'STARTING') && ( - - )} - - - - -
+
{module.status} + {healthResults[module.id] &&
Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}
}
/{module.slug}
+ {module.status !== 'RUNNING' && module.enabled && } + {(module.status === 'RUNNING' || module.status === 'STARTING') && } + + + + +
- {modulesQuery.data?.length === 0 && ( -

- Noch keine Module installiert. + {modulesQuery.data?.length === 0 &&

Noch keine Module installiert.

} +
+ +
+
+
+

Marketplace

+

+ Öffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM prüft das Modulmanifest vor der Installation. +

+
+ {connectedProviders.length === 0 && ( +

+ Verbinde zuerst GitHub, Gitea oder Forgejo.

)} -
- + {repositoriesQuery.isLoading &&

Repositories werden geladen...

} + {repositoriesQuery.isError &&

Repositories konnten nicht geladen werden.

} +
+ {repositoriesQuery.data?.map((provider) => ( +
+

{provider.label}

+ {provider.repositories.length === 0 ? ( +

Keine öffentlichen Repositories gefunden.

+ ) : ( +
+ {provider.repositories.map((repo) => ( +
+
+ + {repo.description &&

{repo.description}

} +

Branch: {repo.defaultBranch}

+
+ +
+ ))} +
+ )} +
+ ))} +
+ {removeTarget && ( setRemoveTarget(null)} /> )} + {connectTarget && ( + item.provider === connectTarget)?.label ?? + (connectTarget === 'github' ? 'GitHub' : connectTarget === 'gitea' ? 'Gitea' : 'Forgejo')} + loading={connectMutation.isPending} + onClose={() => setConnectTarget(null)} + onContinue={() => connectMutation.mutate(connectTarget)} + /> + )}
); -} \ No newline at end of file +} + +function ProviderMark({ provider }: { provider: 'github' | 'gitea' | 'forgejo' }): ReactNode { + if (provider === 'github') { + return ( + + ); + } + if (provider === 'gitea') { + return ; + } + return ( + + ); +} + +function ConnectProviderModal({ + provider, + label, + loading, + onClose, + onContinue, +}: { + provider: MarketplaceProvider['provider']; + label: string; + loading: boolean; + onClose: () => void; + onContinue: () => void; +}): ReactNode { + return ( + +
+ + +
+ + + +
+ +
+

{label} verbinden

+

MPM möchte dein {label}-Konto mit dem Marketplace verbinden.

+
+ +
+
+

Berechtigungen werden respektiert

+

+ MPM fordert nur den Zugriff auf deine Kontoinformationen an, um die Verbindung deinem Konto zuzuordnen. Schreibzugriff wird nicht angefordert. +

+
+
+

Du behältst die Kontrolle

+

+ Du kannst die Verbindung jederzeit in MPM trennen oder die App-Berechtigung in den Einstellungen von {label} widerrufen. +

+
+
+

Mit dieser App geteilte Daten

+

+ MPM erhält deine Forge-Konto-ID, deinen Benutzernamen und ein Zugriffstoken. Das Token wird verschlüsselt gespeichert und nur serverseitig verwendet. +

+
+
+ + +
+
+ ); +} diff --git a/apps/platform-frontend/src/features/admin/settings-page.tsx b/apps/platform-frontend/src/features/admin/settings-page.tsx deleted file mode 100644 index 763aa11..0000000 --- a/apps/platform-frontend/src/features/admin/settings-page.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { type FormEvent, type ReactNode, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { apiRequest, ApiError } from '../../lib/api-client'; -import { z } from 'zod'; -import { Card, CardBody, CardHeader } from '../../components/ui/card'; -import { Input } from '../../components/ui/input'; -import { Button } from '../../components/ui/button'; -import { useToast } from '../../components/ui/toast'; -import { ErrorState, Spinner } from '../../components/ui/states'; - -/** Einstellung (API-Vertrag /api/v1/settings). */ -const settingSchema = z.object({ - key: z.string(), - value: z.string(), - updatedAt: z.string(), - updatedBy: z.string().nullable(), -}); - -const settingsResponseSchema = z.object({ - settings: z.array(settingSchema), -}); - -/** Anzeige-Namen für Einstellungs-Schlüssel. */ -const SETTING_LABELS: Record = { - 'platform.name': 'Plattform-Name', - 'platform.description': 'Beschreibung', - 'platform.maintenance_mode': 'Wartungsmodus (true/false)', -}; - -/** Systemeinstellungen-Seite (nur Admin). */ -export function SettingsPage(): ReactNode { - const { showToast } = useToast(); - const queryClient = useQueryClient(); - const [editValues, setEditValues] = useState>({}); - - const settingsQuery = useQuery({ - queryKey: ['settings'], - queryFn: async () => settingsResponseSchema.parse(await apiRequest('/api/v1/settings')), - }); - - const updateMutation = useMutation({ - mutationFn: async (input: { key: string; value: string }) => - apiRequest<{ setting: unknown }>(`/api/v1/settings/${input.key}`, { - method: 'PATCH', - body: { value: input.value }, - }), - onSuccess: (_result, variables) => { - showToast('success', `Einstellung "${variables.key}" gespeichert`); - void queryClient.invalidateQueries({ queryKey: ['settings'] }); - }, - onError: (error) => { - showToast('error', error instanceof ApiError ? error.message : 'Speichern fehlgeschlagen'); - }, - }); - - function handleSubmit(event: FormEvent, key: string): void { - event.preventDefault(); - const value = editValues[key]; - if (value !== undefined && value.trim().length > 0) { - updateMutation.mutate({ key, value: value.trim() }); - } - } - - return ( -
-
-

Einstellungen

-

- Zentrale Konfiguration der Plattform. -

-
- - - - - {settingsQuery.isLoading && } - {settingsQuery.isError && ( - - )} - {settingsQuery.data && settingsQuery.data.settings.length === 0 && ( -

- Noch keine Einstellungen vorhanden. Änderungen legen sie automatisch an. -

- )} - {settingsQuery.data && settingsQuery.data.settings.length > 0 && ( -
- {settingsQuery.data.settings.map((setting) => ( -
handleSubmit(event, setting.key)} - className="flex items-end gap-3" - noValidate - > -
- - setEditValues((current) => ({ - ...current, - [setting.key]: event.target.value, - })) - } - /> -

- Zuletzt geändert von {setting.updatedBy ?? '–'} am{' '} - {new Date(setting.updatedAt).toLocaleString('de-DE')} -

-
- -
- ))} -
- )} -
-
-
- ); -} \ No newline at end of file diff --git a/apps/platform-frontend/src/features/admin/system-status-page.tsx b/apps/platform-frontend/src/features/admin/system-status-page.tsx index ebf422a..5892783 100644 --- a/apps/platform-frontend/src/features/admin/system-status-page.tsx +++ b/apps/platform-frontend/src/features/admin/system-status-page.tsx @@ -45,10 +45,10 @@ export function SystemStatusPage(): ReactNode { }); return ( -
+
-

Systemstatus

-

+

Systemstatus

+

Zustand aller Plattform-Komponenten und Module (aktualisiert alle 30 Sekunden).

diff --git a/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx b/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx index b6ad1db..d945ee2 100644 --- a/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx +++ b/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx @@ -1,6 +1,5 @@ import { type ReactNode, useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Button } from '../../components/ui/button'; import { Modal } from '../../components/ui/modal'; import { useToast } from '../../components/ui/toast'; import { ApiError } from '../../lib/api-client'; @@ -14,7 +13,7 @@ import type { Module, User } from '../../lib/schemas'; /** * Dialog: Modul-Berechtigungen eines Benutzers verwalten. - * Zeigt alle installierten Module mit GRANTED/DENIED-Schaltern. + * Zeigt alle installierten Module mit Freigabe-Checkboxen. */ export function UserPermissionsModal({ user, @@ -84,7 +83,11 @@ export function UserPermissionsModal({ {modulesQuery.isLoading || permissionsQuery.isLoading ? ( @@ -98,36 +101,47 @@ export function UserPermissionsModal({ Noch keine Module installiert.

) : ( -
    - {modulesQuery.data?.map((module) => { - const isGranted = grantedModuleIds.has(module.id); - return ( -
  • -
    -

    {module.name}

    -

    - /{module.slug} · Version {module.version} -

    -
    - -
  • - ); - })} -
+
+ + + + + + + + + {modulesQuery.data?.map((module) => { + const isAdmin = user.role === 'ADMIN'; + const isGranted = isAdmin || grantedModuleIds.has(module.id); + const isSaving = + toggleMutation.isPending && toggleMutation.variables?.module.id === module.id; + + return ( + + + + + ); + })} + +
ModulFreigeben
+

{module.name}

+

+ /{module.slug} · Version {module.version} +

+
+ handleToggle(module)} + /> +
+
)}
); -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/features/admin/users-page.tsx b/apps/platform-frontend/src/features/admin/users-page.tsx index 6a37af5..b8a0aa0 100644 --- a/apps/platform-frontend/src/features/admin/users-page.tsx +++ b/apps/platform-frontend/src/features/admin/users-page.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, type ReactNode, useState } from 'react'; +import { type FormEvent, type ReactNode, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useAuth } from '../auth/auth-context'; import { Button } from '../../components/ui/button'; @@ -49,10 +49,10 @@ function fieldErrorsFromApi(details: Record | undefin return errors; } -/** Datumsformat für Tabellenanzeigen. */ +/** Datumsformat für Tabellenanzeigen. */ function formatDate(isoDate: string | null): string { if (!isoDate) { - return '–'; + return '–'; } return new Date(isoDate).toLocaleDateString('de-DE', { day: '2-digit', @@ -118,7 +118,7 @@ function CreateUserModal({
@@ -244,7 +244,7 @@ function EditUserModal({ user, onClose }: { user: User; onClose: () => void }): ); } -/** Formular: Passwort zurücksetzen. */ +/** Formular: Passwort zurücksetzen. */ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode { const { showToast } = useToast(); const [fieldErrors, setFieldErrors] = useState>({}); @@ -253,7 +253,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void const resetMutation = useMutation({ mutationFn: (input: { newPassword: string }) => resetUserPassword(user.id, input), onSuccess: () => { - showToast('success', `Passwort für "${user.username}" wurde zurückgesetzt`); + showToast('success', `Passwort für "${user.username}" wurde zurückgesetzt`); onClose(); }, onError: (error) => { @@ -261,7 +261,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void setFormError(error.message); setFieldErrors(fieldErrorsFromApi(error.details)); } else { - setFormError('Passwort konnte nicht zurückgesetzt werden'); + setFormError('Passwort konnte nicht zurückgesetzt werden'); } }, }); @@ -285,8 +285,8 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void return ( @@ -308,7 +308,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void Abbrechen
@@ -316,7 +316,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void ); } -/** Bestätigungsdialog: Benutzer löschen. */ +/** Bestätigungsdialog: Benutzer löschen. */ function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode { const { showToast } = useToast(); const queryClient = useQueryClient(); @@ -326,19 +326,19 @@ function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }) mutationFn: () => deleteUser(user.id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['users'] }); - showToast('success', `Benutzer "${user.username}" wurde gelöscht`); + showToast('success', `Benutzer "${user.username}" wurde gelöscht`); onClose(); }, onError: (error) => { - setFormError(error instanceof ApiError ? error.message : 'Löschen fehlgeschlagen'); + setFormError(error instanceof ApiError ? error.message : 'Löschen fehlgeschlagen'); }, }); return ( {formError && ( @@ -355,14 +355,14 @@ function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }) loading={deleteMutation.isPending} onClick={() => deleteMutation.mutate()} > - Endgültig löschen + Endgültig löschen
); } -/** Benutzerverwaltung (nur Admin): Liste, Anlegen, Bearbeiten, Passwort, Löschen. */ +/** Benutzerverwaltung (nur Admin): Liste, Anlegen, Bearbeiten, Passwort, Löschen. */ export function UsersPage(): ReactNode { const { user: currentUser } = useAuth(); const queryClient = useQueryClient(); @@ -390,11 +390,11 @@ export function UsersPage(): ReactNode { }); return ( -
+
-

Benutzerverwaltung

-

+

Benutzerverwaltung

+

Benutzer anlegen, bearbeiten und verwalten.

@@ -416,7 +416,7 @@ export function UsersPage(): ReactNode { {usersQuery.isLoading && ( - Benutzer werden geladen… + Benutzer werden geladen… )} @@ -431,9 +431,7 @@ export function UsersPage(): ReactNode {
{user.displayName}
-
- @{user.username} · {user.email} -
+
@{user.username}
- {user.role === 'ADMIN' ? 'Administrator' : 'Benutzer'} + {user.role === 'ADMIN' ? 'Admin' : 'Benutzer'} @@ -485,7 +483,7 @@ export function UsersPage(): ReactNode { {user.isActive ? 'Deaktivieren' : 'Aktivieren'} )} diff --git a/apps/platform-frontend/src/features/auth/auth-context.tsx b/apps/platform-frontend/src/features/auth/auth-context.tsx index 959e25b..dcf6f6b 100644 --- a/apps/platform-frontend/src/features/auth/auth-context.tsx +++ b/apps/platform-frontend/src/features/auth/auth-context.tsx @@ -6,7 +6,7 @@ import { useMemo, useState, } from 'react'; -import { apiRequest, setUnauthorizedHandler } from '../../lib/api-client'; +import { ApiError, apiRequest, setUnauthorizedHandler } from '../../lib/api-client'; import { authUserSchema, type AuthUser } from '../../lib/schemas'; /** Zustand des Auth-Contexts. */ @@ -48,9 +48,26 @@ export function AuthProvider({ children }: { children: ReactNode }): ReactNode { }, []); useEffect(() => { + let sessionCheck: Promise | null = null; setUnauthorizedHandler(() => { - setUser(null); - setStatus('unauthenticated'); + if (sessionCheck) return; + + sessionCheck = apiRequest<{ user: unknown }>('/api/v1/auth/me') + .then((response) => { + const currentUser = authUserSchema.parse(response.user); + setUser(currentUser); + setStatus('authenticated'); + }) + .catch((error: unknown) => { + if (error instanceof ApiError && error.status === 401) { + setUser(null); + setStatus('unauthenticated'); + } + // Network errors and server errors do not prove the session expired. + }) + .finally(() => { + sessionCheck = null; + }); }); return () => setUnauthorizedHandler(() => undefined); }, []); @@ -89,4 +106,4 @@ export function useAuth(): AuthContextValue { throw new Error('useAuth muss innerhalb von AuthProvider verwendet werden'); } return context; -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/features/dashboard/dashboard-page.tsx b/apps/platform-frontend/src/features/dashboard/dashboard-page.tsx index 8c5f718..51d3666 100644 --- a/apps/platform-frontend/src/features/dashboard/dashboard-page.tsx +++ b/apps/platform-frontend/src/features/dashboard/dashboard-page.tsx @@ -7,6 +7,7 @@ import { healthSchema, type Health } from '../../lib/schemas'; import { Card, CardBody, CardHeader } from '../../components/ui/card'; import { Badge } from '../../components/ui/badge'; import { EmptyState, ErrorState, Spinner } from '../../components/ui/states'; +import { Icon } from '../../components/ui/icon'; /** Dashboard: Begrüßung, eigene Anwendungen (nach Berechtigungen) und Systemstatus. */ export function DashboardPage(): ReactNode { @@ -28,12 +29,12 @@ export function DashboardPage(): ReactNode { }); return ( -
+
-

- Willkommen, {user?.displayName} 👋 +

+ Willkommen, {user?.displayName}

-

+

Ihre zentrale Anlaufstelle für alle freigegebenen Anwendungen.

@@ -56,7 +57,7 @@ export function DashboardPage(): ReactNode {