Files
ApiCore/ARCHITECTURE.md
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00

79 KiB
Raw 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.

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/
│   ├── offline/                 Offline-Seiten (Start, apiary?id=, colony?id=)
│   ├── nav-config.ts            einzige Quelle aller Navigationspunkte
│   └── settings/
│       ├── users-roles/         nur Administrator
│       └── system/              „Verwaltung > System“ (Jobs, Ereignisse, Laufrollen)

Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet ein Auswahl-Sheet mit Icons und 56-px-Zeilen (auch am Desktop; quick-add-sheet.tsx, Einträge aus quick-actions.ts) (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.

Navigation aus einer Quelle: app/(app)/nav-config.ts ist die einzige Quelle für Seitenleiste, Bottom-Nav, „Mehr“-Sheet und „+ Neu“; der Berechtigungsfilter gilt überall gleich. Neue Seiten (auch „Verwaltung > System“) werden nur dort eingetragen. Auf allen new-Seiten führt ein Zurück-Link zur Liste, bei einem Entwurf mit Rückfrage. „Mehr“ und „+ Neu“ teilen sich die Sheet-Hülle components/nav/nav-sheet.tsx.

Offline-Seiten: Ohne Netz zeigen /offline (Start: geladene Stände), /offline/apiary?id=… und /offline/colony?id=… die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (/apiaries/<id>, /colonies/<id>) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt from nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (withPackFallback, networkMode: "always").

Stand-Seite und Standrunde: apiaries/[id] zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.

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: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (BackupModule, Abschnitt 8) und der CronJob apicore-db-backup mit pg_dump (Abschnitt 7), /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 nutzt CalendarSyncOutboxEntry ein Outbox-Muster, das die Worker-Schleife calendar-outbox abarbeitet (alle 60 s und auf pg_notify('calendar_sync', ''), siehe Abschnitt 10): 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. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in buildEventInput nichts mehr anzuzeigen. Hat der Eintrag eine graphEventId, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf SKIPPED mit graphEventId = null. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (calendar-sync.*-Handler über CalendarSyncService.enqueueIn(tx, …)). Nur die Königin-Zuordnung reiht noch nach dem Commit über CalendarSyncService.enqueue ein (Annahme 11). 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, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), 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 ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über dist/cli.js, nie als Timer im Prozess: @nestjs/schedule und ScheduleModule gibt es nicht mehr, der Wächter src/runtime/no-in-process-schedulers.spec.ts hält das fest. runAfterCommit (src/common/after-commit.ts, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik SWARM_MOOD_HIGH und STOCK_ITEM_LOW (AutomationService.evaluateSwarmControlCheck und evaluateStockItem), den Webhook colony.created und den Kalendereintrag der Königin-Zuordnung (CalendarSyncService.enqueue). Nachzügler für eine spätere Stufe, die weiter über WebhooksService.dispatch laufen: Königinnen (queen.created, queen.replaced), Honigchargen (batch.created) und Lager (product.stock_changed). Der Wächter src/common/after-commit-usage.spec.ts hält die Aufrufer fest. Die früheren AutomationService.evaluate*-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. generateNumber, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (SELECT … FOR UPDATE). Den Entwurf des Rückgrats beschreibt die 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. Geprüft wird unter einer Zeilensperre auf der Regel (SELECT … FOR UPDATE), die bis zum Commit gilt. 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. Ihr Standardwert 10 und der Rückfall bei unbrauchbarer Einstellung kommen allein aus varroaThresholdOf (automation/varroa-threshold.ts). 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. Automatik-Aufgaben laufen wie jede andere Aufgabe über TasksService.createInTx(tx, …, null): Audit-Eintrag ohne Benutzer und TaskCreated. Aus dem Ereignis folgen Kalendereintrag (calendar-sync.task) und Webhook task.created (webhooks.forward); die Regel ruft WebhooksService nicht selbst auf. Aufgabe, Warnung (Notification, Priorität WARNUNG) und der Audit-Eintrag der Regel (AutomationRule CREATE) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Ausgewertet wird in den Handlern automation.* (src/automation/automation.handlers.ts) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen evaluate…In(tx, …). Nur SWARM_MOOD_HIGH und STOCK_ITEM_LOW laufen dauerhaft über runAfterCommit in eigener Transaktion (Annahme 11); sie rufen dieselben evaluate…In, werfen aber nie.

  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, derzeit nur shop-sync:read). Verwaltung mit api-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:read bzw. 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.

  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 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 aus JWT_ACCESS_SECRET) bleiben lesbar, solange JWT_ACCESS_SECRET unverä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-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). 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.

  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.

  22. 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 von JWT_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 aus JWT_ACCESS_SECRET zurück. Format k<8 hex>:<iv>.<tag>.<data> (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt apicore-key-ring, Info apicore-<zweck>-v1), Schlüssel-ID = k + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (keyId 12 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 gesetztes OIDC_CLIENT_SECRET und das Passwort in DATABASE_URL; auch *_PREVIOUS muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus JWT_ACCESS_SECRET abgeleiteter Entwicklungsschlüssel einspringt.

  23. Öffentliche Herkunftsseite ohne Standort: Sie nennt nur Apiary.publicLocationLabel (je Stand, z. B. „Nordhessen“) und SiteSettings.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, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job rekey laufen lassen; danach muss rekey --dry-run bei rekeyed, plaintextSealed, changedMeanwhile und failed 0 melden, erst dann *_PREVIOUS entfernen. Backup-Dateien werden nicht umgeschlüsselt: BACKUP_ENCRYPTION_KEY_PREVIOUS bleibt gesetzt, solange ältere Dateien einspielbar sein sollen. Altchiffrate sind nur lesbar (nie geschrieben), und nur, solange JWT_ACCESS_SECRET unverändert ist: MFA-Secrets ohne Präfix und Webhook-Secrets mit enc:v1: (beide AES-Schlüssel sha256(JWT_ACCESS_SECRET)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter keyId. 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). Job rekey (src/jobs/rekey.job.ts, nur von Hand: kubectl create job --from=cronjob/apicore-job-rekey bzw. …-rekey-dry-run): versiegelt jedes MFA-Secret (User.mfaSecret) und jedes Webhook-Secret (WebhookSubscription.secret), das nicht mit dem aktuellen Schlüssel seines Zwecks versiegelt ist, neu – Werte unter *_PREVIOUS, Altchiffrate und Webhook-Secrets im Klartext. Zeile für Zeile, ohne Transaktion um den Lauf: Jede Schreiboperation ist ein Compare-and-Swap auf den gelesenen Wert (UPDATE … WHERE id = … AND "mfaSecret" = <alter Wert>, bei Webhooks secret), damit überschreibt der Job nie, was die App inzwischen geschrieben hat (z. B. eine neue MFA-Einrichtung). Solche Zeilen zählt er als changedMeanwhile; changedMeanwhile > 0 ist ein harter Stopp, kein Erfolg: die Zeile kann noch mit dem alten Schlüssel versiegelt sein, darum Dry-Run (und bei Bedarf Lauf) wiederholen, bis alle Zähler 0 sind, erst dann rotieren. Vor dem Schreiben prüft der Job, dass der neue Wert zum selben Klartext öffnet. Ein abgebrochener Lauf behält, was er umgestellt hat; der nächste überspringt diese Zeilen ungeöffnet (alreadyCurrent). Ergebnis: dryRun und je Zweck mfa.*/webhook.* mit total, rekeyed, alreadyCurrent, plaintextSealed, changedMeanwhile, failed. Eine Zeile, die sich nicht öffnen oder schreiben lässt, behält ihren Wert; der Lauf macht weiter und endet bei failed > 0 immer FAILED (JobRun, Zähler im Fehlertext, das Log nennt nur Zeilen-ID und Grund, z. B. unknown key id). rekey --dry-run schreibt nichts und öffnet jeden umzustellenden Wert, anders als der echte Lauf auch die Zeilen, die schon den aktuellen Schlüssel tragen (ein beschädigter Wert zählt als failed). Andere Argumente lehnen dist/cli.js (Exit 2) und der Job selbst ab. Werte, Chiffrate und Schlüssel-IDs erscheinen nie in Log oder JobRun. updatedAt umgestellter Zeilen ändert sich. Den Klartext-Rückfall in openWebhookSecret kann man entfernen, sobald der Dry-Run in Produktion webhook.plaintextSealed: 0 zeigt.

  24. Offline-Standmodus: „Stand laden“ legt ein Paket je Stand in IndexedDB ab (GET /apiaries/:id/offline-pack: lebende Völker, Königinnen, die letzten 3 Durchsichten je Volk, offene Aufgaben). Es enthält weder Koordinaten noch Adresse des Standes; Königinnen, Durchsichten und Aufgaben fehlen ohne das jeweilige Leserecht (Benutzer-ID der Durchsichten wird weggelassen). Ein Paket gilt nach 24 h als veraltet (Alter immer sichtbar), wird nach 1 h automatisch erneuert und nach 14 Tagen oder nach 403/404 gelöscht. Lehnt der Server einen Eintrag ab (bei der Sammelerfassung mit invalidColonyIds), erzwingt das ein Neuladen der betroffenen Pakete (frühestens nach 5 min). Der Service Worker (public/sw.js, sw-routes.js) speichert Seiten nur zusammen mit ihren Assets und Daten nur als application/json; nie gespeichert werden Auth-Antworten, Anfrage-Header (kein Bearer-Token im Cache), die Antworten, die ein Paket liefert (eine Offline-Datenquelle mit sichtbarem Alter), /api/v1/apiaries und /apiaries/:id (Standort), /export/ und /backup/. Navigation hat routenabhängige Zeitlimits: App-Hülle 4 s, Offline-Seiten zuerst aus dem Cache, Deep Links etwa 10 s, übrige Seiten Netzfehler bzw. erst ab 15 s. Alle API-Anfragen haben eine Gesamtfrist inklusive Antwortkörper (Standard 4 s; Schreibzugriffe ohne Idempotenz-Schlüssel, darunter Login, MFA und Refresh, 15 s; Sammelerfassung 15 s; Upload 30 s; Download 60 s).

  25. Offline-Sitzung: Ohne Netz bleibt der Benutzer mit der zwischengespeicherten Identität (Name, Rechte) 7 Tage angemeldet, nie mit einem Token; der Sync bleibt gesperrt, bis ein Refresh gelingt. Ein Refresh gilt nur für dieselbe Benutzer-ID; das Token eines anderen Kontos löscht das Gerät (Pakete, Warteschlange, Caches). Nur 401/403 beenden die Sitzung, 429/502/503 und Netzfehler zählen als Netzfehler. Ein Abmelden ohne Netz merkt den Widerruf vor und holt ihn beim nächsten Start vor allem anderen nach. Jeder Warteschlangen-Eintrag hat einen Besitzer (ownerUserId); fremde Einträge werden nie gesendet. Beim Abmelden und Benutzerwechsel werden Pakete, Rundenzustand, Query-Cache, Foto-Übergabemarker und die Cache-Storage-Einträge des Service Workers gelöscht (WIPE_CACHES mit Generationszähler, damit eine laufende Anfrage danach nichts mehr schreibt). Beim Sync beendet 401 den Lauf, ohne Einträge zu markieren; 408/429/502/503/504 lassen Einträge wartend; 409 „wird bereits verarbeitet“ heißt „später erneut“.

  26. Fotos: Aufnahmen werden im Browser per Canvas neu kodiert (längste Kante 2560 px, JPEG, ohne EXIF/XMP/GPS), auch im bestehenden Dokument-Upload. Vor dem Upload prüft der Client Metadaten und höchstens 10 MB; der Upload ist idempotent. Ein Foto wartet in IndexedDB auf die Server-ID seiner Durchsicht und wird danach angehängt. Ein Übergabemarker in localStorage (mit createdAt, höchstens 3 Tage alt, je Benutzer) verhindert, dass ein bereits gespeicherter Entwurf samt Fotos ein zweites Mal gesendet wird. Offen: die Prüfung mit exiftool auf einem echten Telefon (auch älteres iOS Safari).

  27. Sammelerfassung am Stand: POST /treatments|feedings|varroa/bulk schreibt alles oder nichts in einer Transaktion (@Idempotent()). Die Völker werden sortiert mit lockRowsForShare (FOR SHARE, src/prisma/row-lock.ts) gesperrt, der Ablauf steckt im Helfer runBulk; das Ereignis je Volk trägt bulkId. Entscheidung K1: Die Chargennummer des Arzneimittels (batchNumber) wird einmal eingegeben und nie erzeugt (rechtlich relevantes Bestandsbuch); gruppiert wird über bulkId. Entscheidung K2: Varroa nimmt je Volk einen eigenen Eintrag, ein gemeinsamer Milbenwert wäre erfundene Messung. Unbekannte oder beendete Völker meldet die 400-Antwort als invalidColonyIds. Der Idempotenz-Schlüssel bindet Benutzer, Schlüssel und Pfad, nicht den Inhalt: Der Client nimmt darum je abweichender Absendung einen neuen Schlüssel.

  28. Skalen und Vorbelegung: Alle Bewertungsskalen der Durchsicht zeigen „5 = gut“ (Gespeichertes bleibt unverändert). Die Schwarmstimmung ist gespeichert umgekehrt (1 = ruhig, 5 = stark) und wird nur in der Anzeige gedreht (RatingScale direction="lower-is-better"; die Beschriftungen nennen die gespeicherten Enden); keine Datenmigration. Waben stehen per Plus/Minus in colonyStrength und neu broodFrames, das Altfeld broodAmount nur im Bearbeiten-Dialog. Werte der letzten Durchsicht erscheinen als sichtbarer Hinweis je Feld mit „Letztes Mal … übernehmen“ und „Alles wie letztes Mal“, nie stillschweigend vorbelegt.

  29. Ampeln: Varroa wird gelb ab der halben Schwelle der aktiven Regel (VARROA_WARN_FRACTION = 0.5, in Milben/Tag), rot ab der Schwelle; der Trend zeigt Milben/Tag mit Schwellenlinie, Zeiträume 1/2/3/7 Tage oder „anders“. Die Ampel eines Volkes (lib/colonies/status-light.ts) nimmt den schlechtesten Befund: rot bei QUEENLESS, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb bei WEAK/CONSPICUOUS, schwachem Zustand, offener Warnung, noch keiner Durchsicht oder mehr als INSPECTION_STALE_DAYS = 14 Tagen ohne Durchsicht (Letzteres nur mit Leserecht auf Durchsichten). Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test.

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/ (Deployment apicore-backend, Service, PVC apicore-data), frontend/ (Deployment, Service), postgres/ (StatefulSet, headless Service apicore-postgres, CronJob apicore-db-backup mit pg_dump). Die Vorlagen configmap.example.yaml und secret.example.yaml stehen nicht unter resources; 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, Postgres app.kubernetes.io/name: apicore-postgres (Pod zusätzlich encry.de/tier: database, Service-Port postgres). Bis 2026-10-13 wich das Repo bei Postgres ab (app: apicore-postgres, ohne Härtung, volumeClaimTemplates). Seit 2026-10-13 ist base/postgres der Live-Stand: StatefulSet mit Härtung (uid/gid 70, nur lesbares Root-Dateisystem, begrenzte emptyDirs für Socket, /tmp und /dev/shm, kein Service-Account-Token, enableServiceLinks: false), Probes, Ressourcen und Affinität wie live, Service und das statisch gebundene PVC apicore-postgres-data (PV pv-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 per kubectl diff prüfen.
  • components/, je Rollout-Schritt einschaltbar. Das Auskommentieren im Overlay ist nur lokal für kubectl diff/apply gedacht und wird nicht committet.
    • network-policies: Default-Deny und die Freigaben aus der Tabelle unten. Setzt selbst das Label db-client: "true" an der api, damit Schritt 2 sie nicht von Postgres abschneidet.
    • pod-hardening: Frontend mit readOnlyRootFilesystem, emptyDir auf /tmp und /app/.next/cache, ohne Service-Account-Token, Ressourcen laut Spec §7.
    • backend-roles: APICORE_ROLE=api samt initContainern migrate und seed im Deployment apicore-backend, Deployment apicore-worker, CronJobs apicore-job-<name>. Das neue Backend-Image (CMD nur node dist/main.js) geht nur zusammen mit dieser Komponente live. Ohne APICORE_ROLE bricht es den Start in Produktion ab, und ohne die initContainer liefe keine Migration.
    • api-hpa: HPA apicore-backend (autoscaling/v2, 1–2 Pods bei 70 % CPU); entfernt replicas aus dem Deployment.
  • overlays/encry/: Namespace apicore, ConfigMaps, Ingresses (Traefik, Middleware apicore-cloudflare-only@kubernetescrd, cert-manager letsencrypt-prod mit HTTP-01), Image-Digests. Hosts, ConfigMap-Werte und Digests sind bis zum Rollout Platzhalter (example.com, sha256:000…).
  • tests/: manifest-policy.test.mjs prü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.mjs rendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (.gitea/workflows/manifests.yml, nur push und workflow_dispatch, kein pull_request, weil der Runner im Cluster läuft) rendert mit kubectl kustomize, validiert mit kubeconform -strict und 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 von db-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-traefik lä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-ingress ist neben dem neuen default-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 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: Kein Timer im API-Prozess. Der CronJob apicore-job-app-backup (0 * * * *, Europe/Berlin, concurrencyPolicy: Forbid) startet stündlich node dist/cli.js app-backup (BackupSchedulerService.runIfDue). Der Job liest Pfad (BACKUP_STORAGE_PATH), Cron-Ausdruck und Aufbewahrung (BACKUP_RETENTION_COUNT) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (isBackupDue, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit { written: false, reason: 'no-path' }; ein Fehler lässt den JobRun fehlschlagen (Benachrichtigung an die Inhaber von backup:configure). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer. GET /backup/schedule liefert zusätzlich lastJobRun (Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h = activeDeadlineSeconds; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht weder idle_in_transaction_session_timeout noch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch auf FAILED; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJob apicore-job-cleanup (Abschnitt 10, „Jobs“).
  • 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 (CronJob apicore-db-backup, Abschnitt 7) nicht 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: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle WebhookDelivery (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine 2xx-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen FAILED); stabile Delivery-ID (X-ApiCore-Delivery = WebhookDelivery.id, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, lastError nur über loggableError()
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:

  1. DomainEventDelivery (eventId, handler) anlegen, falls es sie noch nicht gibt (skipDuplicates).
  2. Die Zeile FOR UPDATE sperren. Ist sie DONE, wird der Handler übersprungen.
  3. Den Handler mit dem Transaktions-Client aufrufen; die Korrelations-ID steht im domainEventContext.
  4. Die Delivery auf DONE setzen, 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.

Aufgaben. Jede Aufgabe, die die App anlegt oder bearbeitet, läuft über TasksService (auch die Aufgaben der Automatik-Regeln, siehe Annahme 12; nur die Backup-Wiederherstellung ist ausgenommen). createInTx(tx, input, actingUserId) schreibt Aufgabe, Audit-Eintrag und TaskCreated (relatedType, relatedId, dueDate als UTC-Tag, utcDay()) in der Transaktion des Aufrufers – Aufgabenseite, Folgeaufgabe aus der Durchsicht, Vorlage. update schreibt TaskUpdated (status, dueDate). TaskCompleted entsteht nur beim Übergang nach DONE, durch „Erledigt“ (complete) ebenso wie durch eine Bearbeitung mit status: DONE: beide setzen Status und completedAt über dasselbe bedingte Update (status <> 'DONE'), sodass die Erledigung auch bei zwei gleichzeitigen Anfragen genau einmal gemeldet wird. Kalender (calendar-sync.task) und Webhooks (webhooks.forward: task.created, task.completed) folgen ausschließlich aus diesen Ereignissen; direkte Aufrufe gibt es für Aufgaben nicht mehr. Wiederkehrende Vorlagen erzeugt TasksService.generateFromTemplates(now) (lazy vor GET /tasks): je Vorlage ein Compare-and-Swap auf nextDueDate und createInTx in einer Transaktion, sodass von mehreren Replicas genau eine das Vorkommen anlegt und ein gescheiterter Schreibversuch es nicht verliert. Solche Aufgaben haben keinen Ersteller, ihr Audit-Eintrag keinen Benutzer, ihr Ereignis actorUserId = null.

Durchsichten und Varroa. InspectionsService und VarroaService schreiben Datensatz, Audit-Eintrag und Ereignis in einer Transaktion: create → InspectionRecorded bzw. VarroaMeasured, update → InspectionCorrected bzw. VarroaCorrected, jeweils mit { colonyId } (das Volk nach der Änderung, auch nach einer Umbuchung). update sperrt die Zeile zuerst (lockRowForUpdate) und liest „vorher“ danach über tx; auch die Tagesrate der Varroamessung wird aus dieser Zeile abgeleitet. Alle Folgen kommen aus Handlern: Regeln über automation.inspection-rules (nur InspectionRecorded; eine korrigierte Durchsicht wertet keine Regel aus) und automation.varroa-threshold (VarroaMeasured und VarroaCorrected, eine korrigierte Zählung kann die Schwelle überschreiten), Kalender über calendar-sync.inspection (beide Durchsicht-Ereignisse), Webhooks inspection.created und varroa.measured über webhooks.forward. Ein Handler wertet den Datensatz so aus, wie er bei der Zustellung in der Datenbank steht.

Behandlungen, Fütterungen und Ernten. Anlegen schreibt Datensatz, Audit-Eintrag und TreatmentRecorded, FeedingRecorded bzw. HarvestRecorded in einer Transaktion, Ändern TreatmentCorrected, FeedingCorrected bzw. HarvestCorrected. Kalendereintrag (calendar-sync.*), Webhook (treatment.created, feeding.created, harvest.created) und Folgekontrolle (automation.treatment-follow-up, automation.feeding-follow-up) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. update sperrt die Zeile und liest „vorher“ danach über tx; die Behandlung sperrt beim Anlegen das Volk und liest es über tx, bei Ernten läuft die Gewichtsprüfung unter der Sperre.

Völker. ColoniesService und WinterChecksService schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich status oder healthStatus (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit markColonyDeceased), entsteht ColonyStatusChanged { from, to }; beim Übergang von lebend nach beendet (DISSOLVED, SOLD, DECEASED, MERGED) zusätzlich ColonyEnded { status }. automation.colony-health hört auf ColonyStatusChanged. Die Operationen sperren das Volk (lockColony: Sperre, dann Neulesen über tx) und prüfen darunter, ob es noch lebt; merge sperrt alle vereinigten Völker mit einer Anweisung (lockRowsForUpdate). colony.created bleibt im Altpfad WebhooksService.dispatch (Annahme 11).

Bestand und Abfüllung. Bestandsbuchungen kündigen sich über StockChanged { bottlingBatchIds, productIds, rawMaterialKinds } an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über StockService.recordChange(tx, change, actingUserId, aggregate). Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin.

  • Entnahme und Inventur hängen das Ereignis an die Bewegung (BatchMovement). Bei einer Rohstoff-Charge steht deren Sorte in rawMaterialKinds.
  • Jede angenommene Shop-Buchung (ShopStockRequest, actorUserId = null) schreibt ihr eigenes Ereignis. Abgelehnte und wiederholte Anfragen schreiben keines.
  • Abfüllungen schreiben beim Anlegen BatchBottled { honeyBatchId }, bei Korrektur und Löschung BottlingCorrected { honeyBatchId, removed }, jeweils zusammen mit StockChanged in einem recordAll, also mit einer Korrelations-ID.
  • Herstellungschargen schreiben StockChanged mit dem Artikel und den verbrauchten bzw. beim Löschen zurückgegebenen Rohstoff-Sorten.

Daraus folgen die Webhooks batch.updated und stock.changed (webhooks.forward; reine Rohstoff-Änderungen gehen nicht an den Shop) sowie die Rohstoff-Mindestmenge (automation.raw-material-low, je Sorte über remainingByRawMaterialKind). Korrekturen und Löschungen von Abfüllung, Herstellungscharge und Rohstoff-Charge sperren die Zeile (lockRowForUpdate) und lesen „vorher“ erst danach über tx; Mengenprüfung und Bewegungs-Check vor dem Löschen laufen ebenfalls unter dieser Sperre. Herstellungschargen sperren zuerst den Artikel (StockService.lock), dann die Charge: dieselbe Reihenfolge wie ein Shop-Verkauf, der den Artikel hält, während seine Bewegung die Charge referenziert. Reines Anlegen oder Ändern einer Rohstoff-Charge schreibt kein StockChanged; die Mindestmenge wird erst bei der nächsten Entnahme, Korrektur oder Verbrauchsbuchung dieser Sorte geprüft.

Sammelerfassung am Stand. POST /treatments/bulk, /feedings/bulk und /varroa-measurements/bulk legen in einer Transaktion (runBulk, src/common/bulk/run-bulk.ts) je Volk einen eigenen Datensatz an: höchstens BULK_MAX_COLONIES = 100 Völker, Timeout BULK_TRANSACTION_TIMEOUT_MS = 15 s. Es gilt alles oder nichts: assertLivingColonies sperrt die Völker per lockRowsForShare (SELECT … FOR SHARE, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in invalidColonyIds; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes TreatmentRecorded, FeedingRecorded bzw. VarroaMeasured; diese Ereignisse tragen optional bulkId (RecordedForColony), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet bulkId. Varroa nimmt entries mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende.

Zeilensperren. src/prisma/row-lock.ts bietet lockRowForUpdate (eine Zeile, liefert, ob sie existiert), lockRowsForUpdate und lockRowsForShare (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über tx neu lesen, nie die Vorab-Lesung verwenden.

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.

Webhook-Zustellung

Einreihen. WebhooksService.enqueueDeliveries(db, event, payload) legt je aktivem Abonnement des Webhook-Ereignisses eine WebhookDelivery an und weckt den Worker mit pg_notify('webhook_delivery', ''). Gesendet wird dabei nichts. Der Body JSON.stringify({ event, data }) wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet. Mit einem Transaktions-Client committen Zeilen und Wecken zusammen mit dem Aufrufer. Zwei Wege führen dorthin:

  • Der Handler webhooks.forward (src/webhooks/webhook.handlers.ts) übersetzt Ereignisse nach DOMAIN_EVENT_WEBHOOKS: InspectionRecorded → inspection.created, VarroaMeasured → varroa.measured, TreatmentRecorded → treatment.created, FeedingRecorded → feeding.created, HarvestRecorded → harvest.created, BatchBottled und BottlingCorrected → batch.updated, StockChanged → stock.changed, TaskCreated → task.created, TaskCompleted → task.completed. data ist wie bisher der Datensatz, gelesen über tx: die Ernte mit ihren Völkern, bei batch.updated die Honigcharge, bei stock.changed nur { bottlingBatchIds, productIds } und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht.
  • Der Altpfad WebhooksService.dispatch(event, payload) für Erzeuger, die noch kein Ereignis schreiben (colony.created, batch.created, queen.created, queen.replaced, product.stock_changed), reiht nach dem Commit über runAfterCommit ein und wirft nie.

Zustellung. Die Worker-Schleife webhook-deliveries (alle 5 s und auf webhook_delivery) ruft WebhookDeliveryWorker.releaseStaleClaims() und dann drain() auf. Claim, Abschluss und Freigabe laufen über outbox-claims.ts: höchstens 50 fällige Zustellungen je Runde, älteste zuerst (createdAt), höchstens 20 Runden je Takt. Höchstens 5 Zustellungen laufen gleichzeitig. Jede wird mit Zeitstempel und ihrer ID signiert (X-ApiCore-Signature: sha256=HMAC(secret, "<t>.<id>.<body>")) und über postWebhook gesendet: keine Weiterleitungen, Ziel-IP beim Verbindungsaufbau geprüft, 5 s Leerlauf-Timeout und eine Frist von 10 s je Versuch. Bei Ablauf der Frist wird die Anfrage samt Verbindung abgebrochen, auch wenn der Empfänger seine Antwort nur tröpfchenweise sendet; so hält er weder einen der 5 Plätze noch einen Socket. X-ApiCore-Delivery ist die WebhookDelivery.id und bleibt über alle Wiederholungen gleich. Zugestellt wird mindestens einmal; Empfänger verwerfen Duplikate anhand dieser ID.

Ergebnis. Nur eine 2xx-Antwort gilt als zugestellt: DELIVERED, deliveredAt = now(). Jede andere Antwort, ein Netzwerkfehler und ein pausiertes Abonnement (subscription inactive, ohne zu senden) sind Fehlversuche nach derselben Regel wie bei Ereignissen: attempts + 1, Wartezeit 30 s, 2 min, 10 min, 1 h, danach 6 h, beim 8. Fehlversuch FAILED. Ein Abonnement, das innerhalb dieses Fensters wieder aktiviert wird, bekommt seine Zustellungen also noch. lastStatusCode hält den Status der letzten Antwort (ohne Antwort null), lastError einen festen Text (Receiver answered HTTP <status>) oder die Meldung aus loggableError(); Antwortkörper, Secret und Payload stehen nie darin. Status-Code und Abschluss werden in einer Transaktion gesetzt, beide als Compare-and-Set auf id + claimedAt. Beim Herunterfahren holt drain() keine weitere Runde; die laufende wird zu Ende zugestellt.

Zustellprotokoll. GET /webhooks/:id/deliveries?limit=1..100 (Standard 50, Recht webhooks:read) liefert die neuesten Zustellungen eines Abonnements ohne Body: id, event, status, attempts, lastStatusCode, lastError, createdAt, deliveredAt.

Tests. e2e-Tests rufen deliverWebhooks(app, [subscriptionId]) aus test/utils/webhooks.ts auf. Das stellt nur die Zustellungen dieser Abonnements zu.

Jobs

Aufruf. node dist/cli.js <job> [args] (src/cli.ts, Rolle job) prüft wie main.ts und worker.ts zuerst Rolle und Umgebung (resolveRole('cli'), assertStartupEnvironment) und startet dann JobsModule per createApplicationContext: die Module, die die Jobs brauchen, ohne HTTP-Schicht und ohne Worker-Schleifen. Jobs (JOB_NAMES in src/jobs/job-names.ts): daily-evaluation, task-templates, cleanup, app-backup, rekey. Weitere Argumente gehen unverändert an den Job, erlaubt sind nur die aus JOB_ALLOWED_ARGS (heute einzig rekey --dry-run). Exit-Code 0 Lauf erfolgreich, 1 Lauf gescheitert (auch wenn er gar nicht erst aufgezeichnet werden konnte, etwa bei einem Modul- oder DI-Fehler – der Kontext startet mit abortOnError: false), 2 falscher Aufruf (kein oder unbekannter Job oder ein nicht erlaubtes Argument wie cleanup --dry-run oder rekey --dry-rn – dann wird nichts verbunden und kein JobRun geschrieben). Shutdown-Hooks gibt es keine: Ein abgebrochener Pod hinterlässt seinen Lauf als RUNNING, cleanup erkennt ihn (siehe unten). JobRegistry prüft beim Start, dass es zu jedem Namen genau einen Job gibt.

JobRun. JobRunService.run(job, work) schreibt je Lauf eine Zeile: RUNNING, danach SUCCEEDED mit result (nur Zähler, Schalter, kurze Namen) oder FAILED mit error aus loggableError(); finishedAt setzt immer die DB (now()), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (JOB_FAILURE_PERMISSION: daily-evaluation und task-templates → automation:update, app-backup → backup:configure, cleanup und rekey → system:manage) mit „Job „“ fehlgeschlagen“ (WARNUNG, relatedType: 'JobRun', relatedId = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche app-backup-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. recent(limit, job?) und latestPerJob() lesen die Läufe für die System-Seite. SystemModule stellt JobRunService für API und Jobs bereit.

cleanup (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen now(); PENDING- und PROCESSING-Einträge bleiben immer stehen.

  • Läufe, die seit mehr als 6 h RUNNING sind (JOB_RUN_STALE_HOURS, weit über jeder activeDeadlineSeconds), werden FAILED mit error: 'aborted (pod ended without result)'; je betroffenem Job geht die übliche Benachrichtigung raus, damit ein Job, der jedes Mal abgeschossen wird, nicht still scheitert.
  • Idempotenz-Einträge nach 90 Tagen und verbrauchte Tokens 1 h nach ihrem Ablauf (IdempotencyCleanupService; die Stunde Puffer deckt Uhrabweichungen der api-Pods ab, die den Ablauf mit ihrer eigenen Uhr prüfen).
  • ThrottlerHit-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen ("expiresAt" < now() und "blockedUntil" leer oder < now()); eine noch laufende Sperre bleibt so erhalten.
  • DONE-Ereignisse und DELIVERED-Zustellungen nach 30 Tagen (occurredAt bzw. createdAt), FAILED-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per ON DELETE CASCADE mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter dedupeKey ein Vorkommen erneut erzeugen.
  • JobRun-Zeilen nach 90 Tagen.

Das Ergebnis zählt jede Kategorie (idempotencyRecords, consumedTokens, throttlerHits, domainEvents.done, domainEvents.failed, webhookDeliveries.delivered, webhookDeliveries.failed, jobRuns.aborted, jobRuns.deleted).

task-templates (täglich 05:00) ruft TasksService.generateFromTemplates() auf und meldet { created }. Wegen des Compare-and-Swap je Vorlage entsteht jedes Vorkommen genau einmal, auch bei zwei Läufen oder parallel zur Lazy-Erzeugung vor GET /tasks.

daily-evaluation (täglich 04:30) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen in src/jobs/daily-evaluation.rules.ts, eine Zeile je Typ in DAILY_EVALUATION_RULES; jede Regel nennt je Vorkommen das Ereignis und den UTC-Tag des fachlichen Ereignisses:

  • WithdrawalEnded (Aggregat Treatment, Payload colonyId, endedOn): (endDate ?? date) + withdrawalPeriodDays, nur mit Wartezeit.
  • TaskDue (Task, dueOn): dueDate offener Aufgaben (OPEN, IN_PROGRESS).
  • QueenAged (Queen, hatchYear, colonyId): Königin ACTIVE mit Jahr − hatchYear ≥ 2 (QUEEN_AGED_MIN_YEARS), Tag <Jahr>-01-01, also höchstens einmal im Jahr und nur im Fenster 1.–8. Januar; wer später im Jahr ACTIVE wird oder sein hatchYear erhält, bekommt in diesem Jahr kein Ereignis.
  • VarroaMeasurementDue (Colony, dueOn, lastMeasuredOn) und InspectionOverdue (Colony, dueOn, lastInspectedOn): lebende Völker (LIVING_COLONY_WHERE), letzte Messung bzw. Durchsicht plus k × 28 bzw. 21 Tage (k ≥ 1; ohne Eintrag ab createdAt), jeweils das späteste solche Datum bis heute. Nur wenn sein Monat in März–Oktober liegt (ACTIVE_SEASON_MONTHS); ein Vorkommen in der Winterpause entfällt, es wird nicht auf den 1. März verschoben.

„Heute“ ist der Tag der DB-Uhr (SELECT now()). Aufgezeichnet wird jedes Vorkommen, dessen Tag im Nachholfenster [heute − 7, heute] liegt (CATCH_UP_DAYS), mit dedupeKey = "<type>:<aggregateId>:<YYYY-MM-DD>" (dailyDedupeKey). Jeder Lauf im Fenster wiederholt denselben Schlüssel, der eindeutige dedupeKey (recordAll, skipDuplicates) hält ein Ereignis je Vorkommen: ausgefallene Läufe bis 7 Tage werden nachgeholt, wiederholte erzeugen nichts. Je Typ eine Transaktion; das Ergebnis zählt die neuen Ereignisse je Typ. Scheitert eine Regel, laufen die übrigen weiter und ihre Ereignisse bleiben; der Lauf scheitert dann mit Daily evaluation failed for: <Typen>. Handler für diese fünf Ereignisse gibt es noch nicht (spätere Stufe); bis dahin werden sie ohne Wirkung DONE, und cleanup löscht sie nach 30 Tagen.

app-backup (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“.

rekey (nur von Hand, CronJobs apicore-job-rekey und apicore-job-rekey-dry-run mit suspend: true): siehe Annahme 22.

Tests. test/jobs.e2e-spec.ts ruft runCli([...]) mit dem echten JobsModule auf, wie node dist/cli.js. test/daily-evaluation.e2e-spec.ts ruft DailyEvaluationJob.evaluate(Tag) mit Tagen im Jahr 2001 auf: Der Job wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine JobRun-Zeilen, die jobs.e2e-spec.ts zählt). test/rekey.e2e-spec.ts setzt nur *_PREVIOUS, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen.

System-Seite

Die Seite „Verwaltung > System“ (Rechte system:read zum Ansehen, system:manage zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (JobRun), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: GET /system/overview, GET /system/job-runs?job=&limit=1..100 (Standard 50), GET /system/domain-events/failed, POST /system/domain-events/:id/retry (204), GET /system/webhook-deliveries/failed, POST /system/webhook-deliveries/:id/retry (204). Wiederholen ist nur für FAILED möglich (409 sonst, 404 unbekannt): Versuche und availableAt werden in SQL (now()) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder PENDING, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per pg_notify geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); lastError steht wie gespeichert (loggableError()). API-Schlüssel erreichen /system nie (Scope-Allowlist).