Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
46 KiB
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. |
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
- 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. - Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie (
Secure,SameSite=Lax, Pfad/api/v1/auth). - 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ätestensSESSION_MAX_AGE(7 Tage) nach dem Login ist neu anzumelden. - Guards in dieser Reihenfolge:
ThrottlerGuard→JwtAuthGuard(Authentifizierung, Gültigkeit frisch aus der DB je Anfrage) →PermissionsGuard(Berechtigung, fail-closed). - 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ängigepackage.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:
-
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
HoneyBatchesModuleim MVP mitgebaut statt auf v1.x verschoben. -
Treatment auf Einzelvolk statt "Völkergruppe": Abschnitt 4.11 erlaubt "Volk oder Völkergruppe". MVP modelliert
Treatmentpro Volk (colonyIdPflichtfeld); eine Gruppenbehandlung wird im MVP als mehrere Treatment-Datensätze mit gleicherbatchNumber/Notiz erfasst statt eigener Gruppen-Entität. Spätere Migration kann eineTreatmentGroup-Entität nachziehen, falls das in der Praxis zu unhandlich ist. -
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. -
API-Client-Rolle im MVP: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein
Usermit RolleAPI_CLIENTund einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN. -
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. -
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.
-
Dokumentenspeicher als lokales Volume (Modul 29):
DocumentsModulespeichert Uploads unterhalb vonDOCUMENTS_STORAGE_PATHauf 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 aktuelledeploy/k8s-Deployment läuft mitreplicas: 2ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature einReadWriteMany-PVC (z.B. NFS- oder EFS-basiert) gemountet undDOCUMENTS_STORAGE_PATHdarauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. -
Entra ID SSO ohne Auto-Provisioning:
POST/GET /api/v1/auth/sso/*implementiert Authorization Code + PKCE gegen Entra ID überopenid-client. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktivenUser-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_verifierwird 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_URIkommen ausschließlich aus Environment-Variablen; fehlen sie, meldetGET /api/v1/auth/sso/statusenabled: falseund die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. -
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 vonOIDC_ISSUER_URLabgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert übergroupId); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutztCalendarSyncOutboxEntryein 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 EintragFAILEDbis 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.revisionsorgt 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 einetransactionId(aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202,runningim Status) und setzt FAILED-Einträge auf PENDING zurück.groupIdmuss eine Entra-Objekt-ID (GUID) sein;""odernulllö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ütztPOST /groups/{id}/eventskeine Application-Permission, nur delegiertesGroup.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. -
Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet:
Treatment.withdrawalPeriodDayswird 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, ohneatgilt 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. -
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 einenIdempotency-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 überrunAfterCommit(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 (Specdocs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md). -
Automatische Aufgaben sind Vorschläge:
Task.sourceAutomationTriggerkennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optionalconfig.dueInDaysje 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. -
API-Schlüssel für Maschinen-Zugriffe: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel
ack_…imAuthorization: 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:JwtAuthGuardsetztrequest.apiKeystattrequest.user,PermissionsGuardlä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, derzeit nurshop-sync:read). Verwaltung mitapi-keys:manage. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (shop-sync:readbzw.shop-sync:write): Ein Schlüssel mit beiden Rechten wird beim Anlegen mit 400 abgelehnt; bereits ausgestellte kombinierte Schlüssel bleiben gültig, die Oberfläche markiert sie und empfiehlt zwei getrennte Schlüssel (neue anlegen, in Wabenhain eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. -
Lebende vs. beendete Völker: Nur
DISSOLVED,SOLD,DECEASED,MERGEDsperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (colonies/colony-status.ts).WEAK,CONSPICUOUS,QUEENLESSbleiben bearbeitbar. -
Bestandsbuch aus Behandlungen, nicht als eigenes Register: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus
Treatmenterzeugt (export/treatment-log.ts); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen inSiteSettings. Der Stand am Behandlungstag wird beim Anlegen alsTreatment.apiaryIdfestgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er ausColonyApiaryHistoryabgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). -
Ü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. -
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. -
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. -
Backups versiegelt: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund
BACKUP_ENCRYPTION_KEY(+BACKUP_ENCRYPTION_KEY_PREVIOUS, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF ausJWT_ACCESS_SECRET) bleiben lesbar, solangeJWT_ACCESS_SECRETunverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-ListeLEGACY_UNSEALED_TABLESergä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). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (MFA_ENCRYPTION_KEY,WEBHOOK_ENCRYPTION_KEY) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als*_PREVIOUS), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden. -
Bearbeiten-Regel für
null(Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert,nullleert ein Feld, das leer sein darf, undnullauf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt überPartialType(..., { skipNullProperties: false })in den Update-DTOs,@IsOptionalNotNull()(src/common/validation/is-optional-not-null.ts) für weglassbare, aber nicht leerbare Felder undoptionalDate()(src/common/patch-values.ts) für leerbare Datumsfelder;update-dto-null-handling.spec.tshält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder überemptyToNull(ApiCore-Frontend/src/lib/forms/empty-to-null.ts) alsnull. -
Lebende Völker: „Lebend“ heißt jeder Status außer
DISSOLVED,SOLD,DECEASED,MERGED– also auchWEAK,CONSPICUOUS,QUEENLESS. Backend-Abfragen filtern überLIVING_COLONY_WHERE(colonies/colony-status.ts), das Frontend überisLivingColony(lib/colonies/status.ts);status = ACTIVEsteht nur dort, wo wirklich genau dieser Status gemeint ist. -
Schlüsselbund: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret:
MFA_ENCRYPTION_KEY(TOTP-Secrets),WEBHOOK_ENCRYPTION_KEY(Webhook-Signaturschlüssel),BACKUP_ENCRYPTION_KEY(Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden vonJWT_ACCESS_SECRET/JWT_REFRESH_SECRET– sonst startet kein Prozess (src/runtime/startup-checks.ts). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung ausJWT_ACCESS_SECRETzurück. Formatk<8 hex>:<iv>.<tag>.<data>(Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Saltapicore-key-ring, Infoapicore-<zweck>-v1), Schlüssel-ID =k+ die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (keyId12 Hex), folgen aber denselben Ring-Regeln. Code:src/common/crypto/key-ring.ts. In Produktion lehnt der Start außerdem Platzhalterwerte ab (change-me…, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre*_PREVIOUS, ein gesetztesOIDC_CLIENT_SECRETund das Passwort inDATABASE_URL; auch*_PREVIOUSmuss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein ausJWT_ACCESS_SECRETabgeleiteter Entwicklungsschlüssel einspringt. -
Öffentliche Herkunftsseite ohne Standort: Sie nennt nur
Apiary.publicLocationLabel(je Stand, z. B. „Nordhessen“) undSiteSettings.originCountry. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden. Rotation über*_PREVIOUS: alten Wert nach<NAME>_PREVIOUS(öffnet weiter, versiegelt nie), neuen<NAME>setzen, Jobrekeylaufen lassen, dann*_PREVIOUSentfernen. Backup-Dateien werden nicht umgeschlüsselt:BACKUP_ENCRYPTION_KEY_PREVIOUSbleibt gesetzt, solange ältere Dateien einspielbar sein sollen. Altchiffrate sind nur lesbar (nie geschrieben), und nur, solangeJWT_ACCESS_SECRETunverändert ist: MFA-Secrets ohne Präfix und Webhook-Secrets mitenc:v1:(beide AES-Schlüssel sha256(JWT_ACCESS_SECRET)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteterkeyId. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen →rekey→ neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar).
Concerns
- Rollout order (blocking for production). The live
apicore-backend-secretmust containMFA_ENCRYPTION_KEY,WEBHOOK_ENCRYPTION_KEYandBACKUP_ENCRYPTION_KEYbefore this image runs withNODE_ENV=production. Without them, api, worker and job all refuse to start (by design).- The three keys must be distinct and must not equal the JWT secrets.
- The pods get them via
envFrom: secretRef, so no manifest change is needed. - Per the owner's rule, the user creates the secrets.
- JWT rotation after Task 21 will strand old backup files. Backups sealed before
BACKUP_ENCRYPTION_KEYwas set (JWT-derived keyId) become unrestorable onceJWT_ACCESS_SECRETrotates.- Remedy: take a fresh export after setting the key, before rotating. This is in the Annahme 22 text.
- Task 21 or the runbook should state this ordering explicitly.
- Dev-only edge. Values sealed with the dev fallback (no env key set) become unreadable if a developer later sets an explicit key: the fallback cannot be named as
*_PREVIOUS. This does not affect production, which never uses the fallback. - Not checked in production:
*_PREVIOUSvalues are not checked for equality with the JWT or other keys; only their length is enforced. This matches the brief, which limits that rule to the three main variables. - Not run: whole-project
npx eslint "{src,test}/**/*.ts"(I linted only my files). Typecheck was project-wide and clean.
7. Kubernetes
deploy/k8s/ ist Kustomize und die Quelle der Wahrheit für den Cluster (Spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md §6):
base/: clusterneutral, ohne Namespace, Hosts und Digests.backend/(Deploymentapicore-backend, Service, PVCapicore-data),frontend/(Deployment, Service),postgres/(StatefulSet, headless Serviceapicore-postgres, CronJobapicore-db-backupmitpg_dump). Die Vorlagenconfigmap.example.yamlundsecret.example.yamlstehen nicht unterresources; Secrets bleiben außerhalb des Repos.- Selektoren und Service-Ports entsprechen dem Cluster (ein Test hält sie fest): Backend und Frontend
app: apicore-backend|apicore-frontend, Postgresapp.kubernetes.io/name: apicore-postgres(Pod zusätzlichencry.de/tier: database, Service-Portpostgres). Bis 2026-10-13 wich das Repo bei Postgres ab (app: apicore-postgres, ohne Härtung,volumeClaimTemplates). Seit 2026-10-13 istbase/postgresder Live-Stand: StatefulSet mit Härtung (uid/gid 70, nur lesbares Root-Dateisystem, begrenzteemptyDirs für Socket,/tmpund/dev/shm, kein Service-Account-Token,enableServiceLinks: false), Probes, Ressourcen und Affinität wie live, Service und das statisch gebundene PVCapicore-postgres-data(PVpv-apicore-postgres). Das PV und sein SMB-Secret gehören zur Cluster-Infrastruktur und liegen nicht im Repo. Ein Test verhindert, dass das Repo die Härtung abschwächt; Selektor und Volumes sind unveränderlich, darum Postgres-Änderungen immer zuerst perkubectl diffprüfen. components/, je Rollout-Schritt einschaltbar. Das Auskommentieren im Overlay ist nur lokal fürkubectl diff/applygedacht und wird nicht committet.network-policies: Default-Deny und die Freigaben aus der Tabelle unten. Setzt selbst das Labeldb-client: "true"an der api, damit Schritt 2 sie nicht von Postgres abschneidet.pod-hardening: Frontend mitreadOnlyRootFilesystem,emptyDirauf/tmpund/app/.next/cache, ohne Service-Account-Token, Ressourcen laut Spec §7.backend-roles:APICORE_ROLE=apisamt initContainernmigrateundseedim Deploymentapicore-backend, Deploymentapicore-worker, CronJobsapicore-job-<name>. Das neue Backend-Image (CMD nurnode dist/main.js) geht nur zusammen mit dieser Komponente live. OhneAPICORE_ROLEbricht es den Start in Produktion ab, und ohne die initContainer liefe keine Migration.api-hpa: HPAapicore-backend(autoscaling/v2, 1–2 Pods bei 70 % CPU); entferntreplicasaus dem Deployment.
overlays/encry/: Namespaceapicore, ConfigMaps, Ingresses (Traefik, Middlewareapicore-cloudflare-only@kubernetescrd, cert-managerletsencrypt-prodmit HTTP-01), Image-Digests. Hosts, ConfigMap-Werte und Digests sind bis zum Rollout Platzhalter (example.com,sha256:000…).tests/:manifest-policy.test.mjsprüft das gerenderte Overlay (NetworkPolicies nach Kubernetes-Semantik, Pod-Härtung, Ressourcen, CronJobs, Migration, Digests, Live-Selektoren) und jeden Rollout-Schritt (DNS, Ingress, api → Postgres und alle DB-Clients bleiben erreichbar).render-stages.mjsrendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (.gitea/workflows/manifests.yml, nurpushundworkflow_dispatch, keinpull_request, weil der Runner im Cluster läuft) rendert mitkubectl kustomize, validiert mitkubeconform -strictund führt den Test aus; kubectl, kubeconform und yq mit festen Versionen und SHA-256.
Rollen-Deployments (ein Image, Rolle per APICORE_ROLE):
| Objekt | Rolle | Start | Ports | Pods | CPU Request/Limit | RAM Request/Limit |
|---|---|---|---|---|---|---|
Deployment apicore-backend |
api |
Image-CMD node dist/main.js |
3001 (Service, Ingress) | 1, mit HPA 1–2 | 50m/500m | 192Mi/512Mi |
Deployment apicore-worker |
worker |
node dist/worker.js |
3002 nur /health für die Probes; kein Service, kein Ingress |
1 | 20m/200m | 128Mi/256Mi |
CronJobs apicore-job-<name> |
job |
node dist/cli.js <name> |
keine | je Lauf ein Pod | 50m/500m | 192Mi/512Mi |
Deployment apicore-frontend |
– | node server.js |
3000 | 1 | 30m/300m | 128Mi/256Mi |
initContainer haben die Ressourcen der CronJob-Pods. Alle App-Pods laufen ohne Service-Account-Token, mit nur lesbarem Root-Dateisystem und emptyDir auf /tmp (HOME=/tmp), als uid 1000, mit seccompProfile: RuntimeDefault und ohne Capabilities. api und worker rollen mit maxSurge: 1, maxUnavailable: 0 aus.
CronJobs: timeZone: Europe/Berlin, concurrencyPolicy: Forbid, startingDeadlineSeconds: 600, je 3 erfolgreiche und fehlgeschlagene Jobs, ttlSecondsAfterFinished: 86400, restartPolicy: Never.
| CronJob | Zeitplan | backoffLimit | activeDeadlineSeconds |
|---|---|---|---|
apicore-job-cleanup |
0 4 * * * |
1 | 1800 |
apicore-job-daily-evaluation |
30 4 * * * |
1 | 1800 |
apicore-job-task-templates |
0 5 * * * |
1 | 1800 |
apicore-job-app-backup |
0 * * * *, prüft selbst, ob fällig; apicore-data auf /data |
0 | 3600 |
apicore-job-rekey, apicore-job-rekey-dry-run |
suspend: true, nur von Hand (kubectl create job --from=cronjob/…) |
0 | 1800 |
NetworkPolicies: Default-Deny für Ingress und Egress im Namespace. Kubelet-Probes erlaubt Kubernetes immer.
| Pod (Selektor) | Ingress | Egress |
|---|---|---|
| alle | – | DNS: UDP und TCP 53 zu k8s-app: kube-dns in kube-system |
frontend (app: apicore-frontend) |
Traefik (app.kubernetes.io/name: rke2-traefik in kube-system) auf 3000 |
app: apicore-backend auf 3001 |
api (app: apicore-backend) |
Traefik und frontend auf 3001 | Postgres 5432; TCP 443 zu öffentlichen Adressen |
worker (app: apicore-worker) |
keiner | Postgres 5432; TCP 443 zu öffentlichen Adressen |
Jobs (app in (apicore-job, apicore-db-backup)) |
keiner | Postgres 5432 |
postgres (app.kubernetes.io/name: apicore-postgres) |
nur Pods mit db-client: "true" auf 5432 |
keiner außer DNS |
cert-manager-Solver (acme.cert-manager.io/http01-solver: "true") |
Traefik auf 8089 (HTTP-01-Erneuerung) | keiner außer DNS |
„Öffentlich“ heißt 0.0.0.0/0 ohne 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 169.254.0.0/16, 127.0.0.0/8 und ::/0 ohne fc00::/7, fe80::/10, ::1/128. Canal kennt keine FQDN-Regeln; den SSRF-Schutz für Webhook-Ziele behält die App (webhook-url-guard.ts). db-client: "true" tragen nur api, worker, die App-Jobs und apicore-db-backup.
Übergang: Im Namespace liegen außerdem drei Policies aus dem Repo rke2-cluster (Label encry.de/managed-by: cluster-hardening): default-deny-ingress, allow-from-traefik und allow-same-namespace. NetworkPolicies addieren sich; bis diese drei in Runbook Schritt 2 dort entfernt werden, gelten sie neben den neuen.
allow-same-namespaceöffnet die Ingress-Seite jedes Pods für alle Pods im Namespace. Postgres nimmt dann auf der Ingress-Seite Verbindungen von jedem Pod an, nicht nur vondb-client. Weiter sperrt nur die Egress-Seite: Jeder Pod im Namespace unterliegt dem neuen Default-Deny-Egress, das Frontend kommt also trotzdem nicht an Postgres. Die Absicherung hat dann eine statt zwei Schichten.allow-from-traefiklässt Traefik jeden Pod auf jedem Port erreichen, auch Postgres 5432 und den Worker auf 3002. Die Regel „Traefik nur auf 3000 und 3001“ gilt erst danach; ausnutzbar ist das nur über eine Ingress-Route.default-deny-ingressist neben dem neuendefault-denyüberflüssig, schwächt aber nichts.
Images: Das Base-Image node:22-alpine ist in beiden Dockerfiles per Digest gepinnt; ein Update ist ein bewusster Commit mit neuem Digest. Die CI taggt nur noch sha-<commit> und Versionen, kein latest. Das Overlay setzt die Digests der gebauten Images. Bis Schritt 3 bleibt beim Backend der Digest des laufenden Images.
Rollout: nur nach dem Runbook im Plan docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md, jeder Schritt mit Freigabe des Inhabers: 0 Digests, 1 Overlay auf den Ist-Stand (Diff leer), 2 NetworkPolicies, 3 Rollen und Pod-Härtung, 4 Schlüssel trennen, 5 HPA.
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.
Die Migration läuft nicht mehr im Container-CMD (das ist nur noch node dist/main.js). Sie läuft als initContainer migrate (node node_modules/prisma/build/index.js migrate deploy, HOME=/tmp, CHECKPOINT_DISABLE=1) in den Pods von api und worker (deploy/k8s/components/backend-roles).
Starten mehrere dieser initContainer gleichzeitig, serialisiert sie der datenbankseitige Advisory-Lock von Prisma. Ein Lauf, der den Lock nicht binnen 10 s bekommt, bricht ab. Kubelet startet den initContainer dann neu, und der nächste Lauf findet nichts mehr zu tun. Das ist harmlos.
Der Seed (node dist/seed.js) läuft als zweiter initContainer seed nur im api-Pod. Er ist per Unique-Constraint auf User.email idempotent: Ein verlierender Pod bekommt P2002, seed.ts behandelt das als No-op.
Scheitert die Migration, scheitert der initContainer und der neue Pod startet nicht. Wegen maxSurge: 1 und maxUnavailable: 0 läuft der alte Pod weiter. Die Hauptprozesse starten ohne Prisma-CLI. Die startupProbe der api (bis 5 Minuten) bleibt für das alte Image, das die Migration noch im CMD ausführt.
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), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits); die Liste mit Begründung steht inMODELS_EXCLUDED_FROM_BACKUP, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen überkey/resource+actionstatt roherid, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen$transaction. - Automatischer Zeitplan:
BackupSchedulerServiceregistriert einen@nestjs/schedule-Cron-Job, der die Bundle-Datei periodisch aufBACKUP_STORAGE_PATHschreibt und alte Stände nachBACKUP_RETENTION_COUNTrotiert. 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. Beireplicas: 2verhindert 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-LimitBACKUP_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, Scope-Allowlist (shop-sync:read, shop-sync:write), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Lesen und Buchen sind getrennte Schlüssel: ApiKeysService.create lehnt einen Schlüssel mit beiden Shop-Rechten mit 400 ab; bereits ausgestellte kombinierte Schlüssel bleiben gültig und werden in der Oberfläche mit Hinweis markiert |
| 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 | Alle API-Antworten tragen Cache-Control: no-store und Pragma: no-cache; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort GET /public/batches/:lot mit Cache-Control: public, max-age=300, s-maxage=3600 (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben no-store; 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, Zähler in der UNLOGGED-Tabelle ThrottlerHit und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr now(); 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über TRUST_PROXY (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness GET /health ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job cleanup auf |
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
Bekannte Restpunkte (bewusst nicht umgesetzt):
- 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.
10. Ereignis-Rückgrat und Laufrollen
Ereignisse
Outbox. Fachliche Folgen werden nicht direkt nach dem Commit ausgeführt. Sie werden als DomainEvent geschrieben, in derselben Prisma-Transaktion wie die fachliche Änderung und der Audit-Eintrag (DomainEventsService.record/recordAll, nur mit Transaktions-Client). Kein Commit ohne Ereignis, kein Ereignis ohne Commit. Nach dem Commit weckt pg_notify('domain_event', '') den Worker. Ein verlorenes Wecken verzögert nur bis zum nächsten Takt (5 s).
Claim. DomainEventDispatcher.runOnce() übernimmt höchstens 50 fällige Ereignisse (status = 'PENDING', availableAt <= now()), älteste zuerst (occurredAt). Das geschieht per UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING * → PROCESSING, claimedAt = now(). Mehrere Worker übernehmen so nebeneinander disjunkte Mengen, ohne aufeinander zu warten. drain() wiederholt runOnce(), bis eine Runde nichts übernimmt, höchstens 20 Runden je Takt. Folgeereignisse der Handler kommen so in der nächsten Runde dran. Claim, Abschluss und Freigabe liegen für alle Outboxen in src/common/outbox/outbox-claims.ts, die Wiederholregel in src/common/retry-policy.ts. Alle Zeitvergleiche laufen in SQL gegen now() (DB-Uhr).
Delivery-Sperre. Je Ereignis laufen die Handler nacheinander in fester Reihenfolge (Handler-ID nach Codepunkt), jeder in einer eigenen Transaktion mit 10 s Timeout:
DomainEventDelivery(eventId, handler) anlegen, falls es sie noch nicht gibt (skipDuplicates).- Die Zeile
FOR UPDATEsperren. Ist sieDONE, wird der Handler übersprungen. - Den Handler mit dem Transaktions-Client aufrufen; die Korrelations-ID steht im
domainEventContext. - Die Delivery auf
DONEsetzen,attempts + 1.
Die Wirkung des Handlers und DONE werden gemeinsam committet. Die Exklusivität je (Ereignis, Handler) kommt allein von dieser FOR UPDATE-Sperre der Delivery-Zeile, nicht vom Claim: Ein Ereignis kann nach einer Stale-Freigabe von einem zweiten Worker übernommen werden, während der erste noch läuft. Der zweite Worker wartet dann auf die Sperre und findet danach DONE. So entsteht jede Wirkung höchstens einmal je (Ereignis, Handler), auch bei einem Retry oder einem wieder freigegebenen Ereignis. Scheitert ein Handler, rollt seine Transaktion zurück. Außerhalb davon zählt die Delivery attempts + 1 und speichert lastError; die übrigen Handler laufen trotzdem.
Backoff und FAILED. Sind alle Deliveries DONE (oder hat der Typ keinen Handler), wird das Ereignis DONE (processedAt = now()). Sonst gilt attempts + 1, lastError = "<handlerId>: <Meldung>" (erster gescheiterter Handler) und availableAt = now() + Wartezeit: 30 s, 2 min, 10 min, 1 h, ab dem 5. Fehlversuch 6 h. Beim 8. Fehlversuch wird das Ereignis FAILED, ebenso seine offenen Deliveries. Erledigte Handler laufen beim Wiederholen nicht erneut. Der Abschluss ist ein Compare-and-Set auf id + claimedAt: Wer seinen Claim verloren hat, ändert nichts.
Liegengebliebene Claims. releaseStaleClaims() läuft in jedem Worker-Takt vor drain(). Es setzt Ereignisse, deren claimedAt älter als 5 min ist, sofort wieder auf PENDING, mit attempts + 1 und lastError = 'claim expired (worker restarted?)'; ab 8 Versuchen werden sie FAILED, und ihre offenen Deliveries ebenfalls. So kann ein Ereignis, das seinen Worker abstürzen lässt, nicht endlos kreisen.
Handler-Vertrag. @OnDomainEvent(types, '<consumer>.<name>') steht nur auf Singleton-Providern. Ein Handler schreibt ausschließlich über den übergebenen tx; lesen darf er auch über PrismaService. Er macht keine HTTP- oder Graph-Aufrufe: Netzwerkarbeit kommt in eine eigene Outbox (CalendarSyncOutboxEntry, WebhookDelivery), die eine Worker-Schleife abarbeitet. Der Wächter src/domain-events/handler-contract.spec.ts prüft, dass *.handlers.ts keine Netzwerk-Clients importieren. Fehlertexte gehen nur über loggableError() in lastError und Logs, nie Payload oder Secrets.
Tests. e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage drainDomainEvents(app, [aggregateId]) aus test/utils/domain-events.ts auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören.