Files
ApiCore/ARCHITECTURE.md
2026-10-13 05:58:30 +02:00

30 KiB
Raw Permalink Blame History

ApiCore – Architektur

Basiert auf docs/lastenheft.md. Diese Datei beschreibt, WIE die fachlichen Anforderungen technisch umgesetzt werden.

1. Backend – Modulschnitt (NestJS)

Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge in TASKS.md:

Modul Zweck Abhängig von
AuthModule Login, Refresh, Passwort-Reset, JWT-Strategien –
UsersModule Benutzerverwaltung, Rollen Auth
AuditModule Audit-Log-Grundstruktur (Service AuditLogService, wird von anderen Modulen injiziert) Users
ApiariesModule Bienenstände Auth
ColoniesModule Völker, Standortwechsel-Historie, Ableger/Vereinigung Apiaries
QueensModule Königinnen, Volk-Historie Colonies
InspectionsModule Durchsichten Colonies
TasksModule Aufgaben, polymorph verknüpft –
VarroaModule Varroamessungen Colonies
TreatmentsModule Behandlungen Colonies
FeedingsModule Fütterungen Colonies
HarvestsModule Ernten Apiaries, Colonies
ExtractionsModule Schleuderungen Harvests
HoneyBatchesModule Honigchargen, Honigmessungen Extractions
StorageModule Lagerbehälter, einfache Lagerverwaltung (StockItem/StockMovement) HoneyBatches
DashboardModule Aggregations-Endpunkte alle o.g.
SearchModule globale Suche, Filter-Bausteine alle o.g.
ExportModule CSV/JSON/PDF-Export alle o.g.
ErpExportModule Nur lesender Inventar-Spiegel für ERPNext (/api/v1/export/inventar/..., Recht erp-inventory:read), Vertrag in docs/erp-inventar-export.md Products, Bottling, ProductionBatches, Stock, Harvests

Jedes Modul: *.controller.ts, *.service.ts, dto/, *.module.ts, *.spec.ts (Unit), test/*.e2e-spec.ts (Integration). Rollenprüfung über @Roles()-Decorator + RolesGuard, angewendet pro Endpunkt – niemals nur UI-seitig.

Alle Routen unter /api/v1/..., versioniert über NestJS URI Versioning. OpenAPI-Doku automatisch über @nestjs/swagger, erreichbar unter /api/v1/docs.

2. Frontend – Seitenstruktur (Next.js App Router)

app/
├── (auth)/login/
├── (app)/                       geschütztes Layout mit Bottom-Nav (mobil) / Sidebar (Desktop)
│   ├── dashboard/
│   ├── apiaries/[id]/
│   ├── colonies/[id]/
│   ├── queens/[id]/
│   ├── inspections/new/
│   ├── tasks/
│   ├── varroa/new/
│   ├── treatments/new/
│   ├── feedings/new/
│   ├── harvests/new/
│   ├── honey-batches/[id]/
│   ├── storage/
│   ├── search/
│   └── settings/users/          nur Administrator

Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet Auswahl-Sheet (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld.

Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein direkter DB-Zugriff, kein Next.js-eigenes Datenbank-Layer) – erzwingt API-first auch praktisch.

3. Auth-Flow

  1. Login (POST /api/v1/auth/login) mit E-Mail/Passwort → Access-Token (15 min, JWT HS256, iss=apicore, aud=apicore:access) + Refresh-Token (in DB als Hash, rotierend). Bei aktiver MFA zuerst ein Zwischen-Token (aud=apicore:mfa-challenge), das nie als Access-Token gilt.
  2. Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie (Secure, SameSite=Lax, Pfad /api/v1/auth).
  3. Bei 401 automatischer Refresh über POST /api/v1/auth/refresh, danach Retry. Rotation atomar; Wiederverwendung eines alten Tokens (nach 10 s Gnadenfrist für parallele Tabs) beendet alle Sitzungen. Spätestens SESSION_MAX_AGE (7 Tage) nach dem Login ist neu anzumelden.
  4. Guards in dieser Reihenfolge: ThrottlerGuard → JwtAuthGuard (Authentifizierung, Gültigkeit frisch aus der DB je Anfrage) → PermissionsGuard (Berechtigung, fail-closed).
  5. Maschinen-Zugriff (Wabenhain) nur per API-Schlüssel mit Scope-Allowlist und optionaler IP-Allowlist, siehe Annahme 14 und Abschnitt 9.

4. Datenfluss / Rückverfolgbarkeit

Kernkette (Abschnitt 16, 29) relational über Fremdschlüssel, nicht über Textfelder:

Apiary → Colony → (Inspection | Treatment | Feeding | VarroaMeasurement)
Apiary/Colony → Harvest → Extraction → HoneyBatch → StorageContainer

HoneyBatch referenziert Extraction, Extraction referenziert n Harvests (m:n über ExtractionHarvest), Harvest referenziert n Colonys (m:n über HarvestColony) + eine Apiary. Damit ist "von welchem Volk stammt Charge X" eine reine Join-Abfrage, kein Freitext.

Historie statt Löschen: alle fachlichen Haupttabellen haben status/deactivatedAt statt Hard-Delete. Verschiebungen (Volk↔Stand) und Königinnenwechsel werden als eigene Historientabellen geführt (ColonyApiaryHistory, QueenColonyHistory), nicht durch Überschreiben des aktuellen Zustands.

5. Technische Leitentscheidungen (bestätigt, keine Abweichung)

  • Validierung: class-validator/class-transformer (nicht Zod) – konsistent mit NestJS-Idiomatik und @nestjs/swagger-Decorators, ein Schema-Ort statt zwei.
  • Ein gemeinsames Git-Repo im ApiCore/-Root für Backend + Frontend (Monorepo-artig, aber zwei unabhängige package.json, kein Turborepo/Nx-Overhead im MVP).
  • Package-Manager: npm (in beiden Projekten, keine Vermischung mit pnpm/yarn).
  • Backend-Tests: Jest (NestJS-Standard). Frontend-Tests: Vitest + React Testing Library.

6. Annahmen & offene Fragen

Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige Vorgabe macht. Bei Bedarf mit dem Fachbereich klären, aktuell aber Grundlage der Umsetzung:

  1. HoneyMeasurement als MVP-Entität: Abschnitt 26 MUSS-Liste nennt Honigmessungen nicht explizit, Abschnitt 4.17 aber als eigenen Unterpunkt und Abschnitt 30 verlangt Wassergehalt schnell abrufbar. Da sie klein und für die Chargen-Kette relevant ist, wird sie als Teil des HoneyBatchesModule im MVP mitgebaut statt auf v1.x verschoben.

  2. Treatment auf Einzelvolk statt "Völkergruppe": Abschnitt 4.11 erlaubt "Volk oder Völkergruppe". MVP modelliert Treatment pro Volk (colonyId Pflichtfeld); eine Gruppenbehandlung wird im MVP als mehrere Treatment-Datensätze mit gleicher batchNumber/Notiz erfasst statt eigener Gruppen-Entität. Spätere Migration kann eine TreatmentGroup-Entität nachziehen, falls das in der Praxis zu unhandlich ist.

  3. Lagerverwaltung MVP-Umfang: "grundlegende Lagerverwaltung" (Abschnitt 26 Punkt 12) wird als generisches StockItem + StockMovement (Kategorie Honig/Verbrauchsmaterial/Imkereimaterial) umgesetzt. Das detaillierte Inventar mit Wartungsdaten (Abschnitt 4.22) bleibt v1.x/"SOLL", wie im Lastenheft vorgesehen.

  4. API-Client-Rolle im MVP: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein User mit Rolle API_CLIENT und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.

  5. Backup-fähige Struktur: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte pg_dump/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), /health-Endpoint für Betriebsüberwachung.

  6. Deviations vom Leitentscheidungs-Rahmen: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.

  7. Dokumentenspeicher als lokales Volume (Modul 29): DocumentsModule speichert Uploads unterhalb von DOCUMENTS_STORAGE_PATH auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle deploy/k8s-Deployment läuft mit replicas: 2 ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein ReadWriteMany-PVC (z.B. NFS- oder EFS-basiert) gemountet und DOCUMENTS_STORAGE_PATH darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.

  8. Entra ID SSO ohne Auto-Provisioning: POST/GET /api/v1/auth/sso/* implementiert Authorization Code + PKCE gegen Entra ID über openid-client. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven User-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-code_verifier wird kurzlebig (5 min) in einem httpOnly-SameSite=Lax-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET/OIDC_REDIRECT_URI kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet GET /api/v1/auth/sso/status enabled: false und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.

  9. M365-Kalender-Synchronisation als einseitiger, asynchroner Push: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als CalendarSyncModule (ApiCore-Backend/src/calendar-sync/): App-only Graph-API-Zugriff per Client-Credentials-Flow (GraphClientService) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von OIDC_ISSUER_URL abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über groupId); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt CalendarSyncOutboxEntry ein Outbox-Muster mit minütlichem @Cron-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag FAILED bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (claimedAt) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. revision sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine transactionId (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, running im Status) und setzt FAILED-Einträge auf PENDING zurück. groupId muss eine Entra-Objekt-ID (GUID) sein; "" oder null löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (src/common/loggable-error.ts), nie mit Abfragewerten. Offene Entscheidung (2026-10-12): Laut Microsoft-Doku (group-post-events) unterstützt POST /groups/{id}/events keine Application-Permission, nur delegiertes Group.ReadWrite.All. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (/users/{upn}/calendar/events, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (isAllDay: true) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.

  10. Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet: Treatment.withdrawalPeriodDays wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (endDate ?? date + withdrawalPeriodDays) noch nicht abgelaufen ist – bezogen auf das Erntedatum (GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT, ohne at gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden.

  11. Idempotenz per Opt-in: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen @Idempotent(). Der Client sendet je Vorgang einen Idempotency-Key; eine Wiederholung liefert die gespeicherte erste Antwort (IdempotencyRecord, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (AutomationService.evaluate*, CalendarSyncService.enqueue/cancel, WebhooksService.dispatch) best-effort über runAfterCommit (src/common/after-commit.ts) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. generateNumber, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (SELECT … FOR UPDATE). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md).

  12. Automatische Aufgaben sind Vorschläge: Task.sourceAutomationTrigger kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional config.dueInDays je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt.

  13. API-Schlüssel für Maschinen-Zugriffe: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel ack_… im Authorization: Bearer-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: JwtAuthGuard setzt request.apiKey statt request.user, PermissionsGuard lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – @AnyAuthenticatedUser-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (api-keys/api-key-scopes.ts: shop-sync:read, shop-sync:write, erp-inventory:read); je angebundenem System ein eigener Schlüssel. Verwaltung mit api-keys:manage. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.

  14. Lebende vs. beendete Völker: Nur DISSOLVED, SOLD, DECEASED, MERGED sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (colonies/colony-status.ts). WEAK, CONSPICUOUS, QUEENLESS bleiben bearbeitbar.

  15. Bestandsbuch aus Behandlungen, nicht als eigenes Register: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus Treatment erzeugt (export/treatment-log.ts); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in SiteSettings. Der Stand am Behandlungstag wird beim Anlegen als Treatment.apiaryId festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus ColonyApiaryHistory abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).

  16. Überwinterung als eigene Einträge statt Volksstatus: WinterCheck (eindeutig je Volk, winterYear, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (markColonyDeceased). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.

  17. Schutz gegen Rechteausweitung in der Benutzerverwaltung: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (UsersService.assertWithinCallerPermissions), analog zur Rollenbearbeitung.

  18. SSO-Bindung an die IdP-Identität: Die erste Microsoft-Anmeldung bindet das Konto an "<iss>|<oid>" (User.ssoSubject); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (common/organizations/consumers) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen.

  19. Backups versiegelt: AES-256-GCM über das gzip-Bundle, Schlüssel BACKUP_ENCRYPTION_KEY (sonst per HKDF aus JWT_ACCESS_SECRET). Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste LEGACY_UNSEALED_TABLES ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). Webhook-Secrets liegen verschlüsselt in der DB (Schlüssel aus JWT_ACCESS_SECRET) – für eine Wiederherstellung in einem anderen Deployment mit anderem JWT_ACCESS_SECRET müssen Webhook-Secrets und MFA neu eingerichtet werden.

  20. Bearbeiten-Regel für null (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, null leert ein Feld, das leer sein darf, und null auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über PartialType(..., { skipNullProperties: false }) in den Update-DTOs, @IsOptionalNotNull() (src/common/validation/is-optional-not-null.ts) für weglassbare, aber nicht leerbare Felder und optionalDate() (src/common/patch-values.ts) für leerbare Datumsfelder; update-dto-null-handling.spec.ts hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über emptyToNull (ApiCore-Frontend/src/lib/forms/empty-to-null.ts) als null.

  21. Lebende Völker: „Lebend“ heißt jeder Status außer DISSOLVED, SOLD, DECEASED, MERGED – also auch WEAK, CONSPICUOUS, QUEENLESS. Backend-Abfragen filtern über LIVING_COLONY_WHERE (colonies/colony-status.ts), das Frontend über isLivingColony (lib/colonies/status.ts); status = ACTIVE steht nur dort, wo wirklich genau dieser Status gemeint ist.

7. Kubernetes

deploy/k8s/ enthält je Projekt: deployment.yaml, service.yaml, ingress.yaml, sowie configmap.example.yaml und secret.example.yaml (Platzhalter, keine echten Werte). Backend exponiert GET /health (liveness) und GET /health/ready (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem node_modules, Runtime-Stage mit npm ci --omit=dev bzw. Next.js output: 'standalone').

deploy/k8s/postgres/ enthält das PostgreSQL-StatefulSet (ein Replica, volumeClaimTemplates für persistente Daten, postgres:18-alpine), den zugehörigen headless Service (apicore-postgres, DNS-Name fürs Backend) sowie secret.example.yaml für POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB. deploy/k8s/backend/secret.example.yamls DATABASE_URL zeigt auf diesen Service.

8. Backup & Restore

Schema-Migrationen (Reproduzierbarkeit): Ausschließlich prisma/migrations/ als Quelle der Wahrheit. Lokal/Entwicklung: npx prisma migrate dev. Gegen jede Produktions- oder Staging-Datenbank ausschließlich npx prisma migrate deploy (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). prisma db push wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. Der Backend-Container führt migrate deploy und danach npm run seed selbst beim Start aus, bevor der Server Verbindungen annimmt (Dockerfile-CMD: npm run migrate:deploy && npm run seed && node dist/main.js) – kein separater Kubernetes-Job. Bei replicas: 2 starten mehrere Pods potenziell gleichzeitig; das ist unproblematisch, da Prisma für migrate deploy einen datenbankseitigen Advisory-Lock nutzt und prisma/seed.ts per Unique-Constraint auf User.email idempotent ist (verlierender Pod bekommt einen P2002-Fehler, den seed.ts abfängt und als No-op behandelt statt den Container abstürzen zu lassen) – eine Instanz wendet die Migrationen/den Seed an, die anderen finden danach nichts mehr zu tun. deploy/k8s/backend/deployment.yaml hat dafür eine startupProbe mit großzügigem failureThreshold, damit liveness/readiness den Container nicht neu starten, während die (beim allerersten Deploy ggf. länger laufende) Migration noch durchläuft.

Erster Admin-Zugang: prisma/seed.ts legt beim allerersten Start (leere User-Tabelle) einen Administrator an (SEED_ADMIN_EMAIL/SEED_ADMIN_PASSWORD, Default [email protected]/ChangeMe123!) und gibt URL (FRONTEND_ORIGIN), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über /settings/users anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in UsersService.assertNotLastSystemRoleUser verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.

Datensicherung: Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges BackupModule (ApiCore-Backend/src/backup/) statt der ursprünglich hier vorgesehenen reinen pg_dump/PITR-Strategie:

  • Export/Import: BackupService.exportAll()/.importAll() sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14) und IdempotencyRecord (Annahme 11); die Liste mit Begründung steht in MODELS_EXCLUDED_FROM_BACKUP, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über key/resource+action statt roher id, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen $transaction.
  • Automatischer Zeitplan: BackupSchedulerService registriert einen @nestjs/schedule-Cron-Job, der die Bundle-Datei periodisch auf BACKUP_STORAGE_PATH schreibt und alte Stände nach BACKUP_RETENTION_COUNT rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei replicas: 2 verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
  • Restore: Über POST /backup/import (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit BACKUP_MAX_IMPORT_BYTES (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.

Ein rohes pg_dump/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.

9. API-Sicherheit (Stand Oktober 2026)

Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung und Begründung je Punkt in docs/pruefung-2026-09.md, Abschnitt „API-Sicherheit“.

Bereich Maßnahme
Authentifizierung JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad
Passwörter mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per PASSWORD_BREACH_CHECK=false)
Sitzungen Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt
Step-up Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP)
Autorisierung fail-closed PermissionsGuard; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten
Maschinen-Zugriff API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (shop-sync:read, shop-sync:write, erp-inventory:read), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) 429 für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Warnung beim Start, wenn TRUST_PROXY in Produktion fehlt; Logs nur mit Schlüssel-Präfix
Ausgehende Aufrufe Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body
Transport/Antworten Cache-Control: no-store (außer öffentliche Chargen-Seite), CSP default-src 'none', frame-ancestors 'none', keine ETags; Produktion startet nur mit https-FRONTEND_ORIGIN und sicheren Cookies
Rate-Limits Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über TRUST_PROXY
Daten Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings

Bekannte Restpunkte (bewusst nicht umgesetzt):

  • Rate-Limits zählen je Pod im Speicher; bei 2 Replikas verdoppelt sich das Limit. Brute-Force-Schutz hängt nicht daran, sondern an der Kontosperre in der DB. Ein gemeinsamer Speicher (Redis) wäre der nächste Schritt, falls Lastspitzen ein Thema werden.
  • Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.