Merge main checkpoint c01a388 into feat/erp-inventar-export

Brings the ERP inventory export up to date with the platform backbone and
field capture work. Conflicts in ARCHITECTURE.md, the API key service and
the API key settings tab resolved by keeping both sides:
- read and write keys stay separate; erp-inventory:read is read-only and
  cannot be combined with a write scope
- the failed-attempt lockout counts in the shared ThrottlerHit table so it
  holds across API pods
- the UI offers only scopes the user holds and creates one scope per key

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 22:49:53 +02:00
commit 53fab102f2
347 files changed
+26087 -2141

No files matched your search

-1
View File
@@ -54,7 +54,6 @@ jobs:
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=semver,pattern={{version}}
-1
View File
@@ -54,7 +54,6 @@ jobs:
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=semver,pattern={{version}}
+74
View File
@@ -0,0 +1,74 @@
name: Manifests
# Prueft deploy/k8s ohne Cluster-Zugriff: Overlay und jeden Rollout-Schritt
# rendern, gegen die Kubernetes-Schemas validieren und die Regeln testen
# (NetworkPolicies, Pod-Haertung, Ressourcen, CronJobs, Digests, Live-Selektoren).
#
# Bewusst kein pull_request-Trigger: der Runner laeuft im Cluster, und ein
# Fork-PR braechte eigenen Code (auch eine geaenderte Fassung dieser Datei,
# also ohne jeden if-Waechter) darauf zur Ausfuehrung. push setzt
# Schreibrechte am Repo voraus; jeder Branch-Push wird geprueft, damit auch
# Aenderungen vor dem Merge nach main durch die CI gehen.
on:
push:
paths: ['deploy/k8s/**', '.gitea/workflows/manifests.yml']
workflow_dispatch:
jobs:
validate:
runs-on: docker-builder
env:
# Werkzeuge mit festen Versionen; jede Datei wird gegen ihre SHA-256 von
# der offiziellen Release-Seite geprueft, bevor sie laeuft:
# kubectl: https://dl.k8s.io/release/<v>/bin/linux/amd64/kubectl.sha256
# kubeconform: CHECKSUMS im GitHub-Release yannh/kubeconform
# yq: checksums (Spalte SHA-256) im GitHub-Release mikefarah/yq
# Ein Update aendert Version und Pruefsumme zusammen.
KUBECTL_VERSION: v1.36.5
KUBECTL_SHA256: 33bc88a24c3b09cf55bfd59e6ca977881b61fc3f25ab65bc80c65b430248648e
KUBECONFORM_VERSION: v0.8.0
KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883
YQ_VERSION: v4.53.6
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Node einrichten
uses: actions/setup-node@v4
with:
node-version: 22
- name: kubectl, kubeconform und yq laden
run: |
mkdir -p "$RUNNER_TEMP/bin"
cd "$RUNNER_TEMP"
curl -fsSLo kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
echo "${KUBECTL_SHA256} kubectl" | sha256sum -c -
curl -fsSLo kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz"
echo "${KUBECONFORM_SHA256} kubeconform.tar.gz" | sha256sum -c -
tar -xzf kubeconform.tar.gz kubeconform
curl -fsSLo yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "${YQ_SHA256} yq" | sha256sum -c -
install -m 0755 kubectl kubeconform yq "$RUNNER_TEMP/bin/"
- name: Overlay rendern
run: |
"$RUNNER_TEMP/bin/kubectl" kustomize deploy/k8s/overlays/encry > "$RUNNER_TEMP/rendered.yaml"
"$RUNNER_TEMP/bin/yq" ea -o=json '[.]' "$RUNNER_TEMP/rendered.yaml" > "$RUNNER_TEMP/rendered.json"
- name: Rollout-Schritte rendern
# Runbook Schritte 1, 2, 3 und 5: Overlay mit den bis dahin
# freigegebenen Komponenten (siehe render-stages.mjs).
run: |
export KUBECTL="$RUNNER_TEMP/bin/kubectl" YQ="$RUNNER_TEMP/bin/yq"
node deploy/k8s/tests/render-stages.mjs "$RUNNER_TEMP/stages"
- name: Gegen Kubernetes-Schemas pruefen
run: |
"$RUNNER_TEMP/bin/kubeconform" -strict -summary "$RUNNER_TEMP/rendered.yaml" "$RUNNER_TEMP"/stages/*.yaml
- name: Regeln testen
run: |
export RENDERED_MANIFESTS="$RUNNER_TEMP/rendered.json" RENDERED_STAGES="$RUNNER_TEMP/stages/stages.json"
node --test deploy/k8s/tests/manifest-policy.test.mjs
+112 -10
View File
@@ -103,32 +103,110 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`).
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein Schlüssel mit einem Lese- und einem Buchungsrecht 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. Die Oberfläche legt je Schlüssel genau ein Recht an und bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Fehlversuche mit ungültigen Schlüsseln: Annahme 24.
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen.
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel `BACKUP_ENCRYPTION_KEY` (sonst per HKDF aus `JWT_ACCESS_SECRET`). Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). Webhook-Secrets liegen verschlüsselt in der DB (Schlüssel aus `JWT_ACCESS_SECRET`) – für eine Wiederherstellung in einem anderen Deployment mit anderem `JWT_ACCESS_SECRET` müssen Webhook-Secrets und MFA neu eingerichtet werden.
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, Job `rekey` laufen lassen, 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).
24. **Fehlversuchs-Sperre für API-Schlüssel, über alle Pods**: Ungültige Schlüssel (unbekannt, widerrufen, abgelaufen) zählen je Client-Netz – IPv4-Adresse bzw. IPv6-/64, wie der Throttler (`rateLimitSubject`). Nach 20 ungültigen Schlüsseln in einem Fenster von 15 min (ab dem ersten Fehlversuch) bekommt das Netz für weitere ungültige Schlüssel 15 min lang `429` (`src/api-keys/failed-attempt-limiter.ts`, Grenzen `API_KEY_FAILURE_LIMIT`). Ein gültiger Schlüssel wird davon nie abgewiesen: Erst kommt die Suche über den Hash, nur ein ungültiger Schlüssel wird gezählt oder gesperrt. Sonst könnte jeder, der eine gemeinsame Adresse (Cluster-Ausgang, Proxy) nutzt oder vortäuscht, ERPNext oder den Shop aussperren; bei 256 Zufallsbits je Schlüssel kostet das nichts. Falsch aufgebaute Tokens werden ohne Datenbankzugriff mit 401 abgewiesen und zählen nicht. Die Zähler liegen wie die des Throttlers in der UNLOGGED-Tabelle `ThrottlerHit` (über `PrismaThrottlerStorage`: eigener Throttler-Name `api-key-failures`, Schlüssel-Präfix `api-key-failures:`, eine Upsert-Anweisung, nur DB-Uhr `now()`) und gelten damit für alle api-Pods gemeinsam; kein zweiter Zähler im Prozessspeicher. Bei einem DB-Fehler schlägt die Anfrage fehl (fail-closed, wie der Throttler). Abgewiesene Schlüssel werden nur mit Präfix und IP protokolliert, das Erreichen der Grenze einmal je Fenster.
## Concerns
1. **Rollout order (blocking for production).** The live `apicore-backend-secret` must contain `MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY` and `BACKUP_ENCRYPTION_KEY` *before* this image runs with `NODE_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.
2. **JWT rotation after Task 21 will strand old backup files.** Backups sealed before `BACKUP_ENCRYPTION_KEY` was set (JWT-derived keyId) become unrestorable once `JWT_ACCESS_SECRET` rotates.
- 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.
3. **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.
4. **Not checked in production:** `*_PREVIOUS` values 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.
5. **Not run:** whole-project `npx eslint "{src,test}/**/*.ts"` (I linted only my files). Typecheck was project-wide and clean.
## 7. Kubernetes
`deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`).
`deploy/k8s/` 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):
`deploy/k8s/postgres/` enthält das PostgreSQL-`StatefulSet` (ein Replica, `volumeClaimTemplates` für persistente Daten, `postgres:18-alpine`), den zugehörigen headless `Service` (`apicore-postgres`, DNS-Name fürs Backend) sowie `secret.example.yaml` für `POSTGRES_USER`/`POSTGRES_PASSWORD`/`POSTGRES_DB`. `deploy/k8s/backend/secret.example.yaml`s `DATABASE_URL` zeigt auf diesen Service.
- `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 `emptyDir`s 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. Der Backend-Container führt `migrate deploy` und danach `npm run seed` selbst beim Start aus, bevor der Server Verbindungen annimmt (Dockerfile-`CMD`: `npm run migrate:deploy && npm run seed && node dist/main.js`) – kein separater Kubernetes-`Job`. Bei `replicas: 2` starten mehrere Pods potenziell gleichzeitig; das ist unproblematisch, da Prisma für `migrate deploy` einen datenbankseitigen Advisory-Lock nutzt und `prisma/seed.ts` per Unique-Constraint auf `User.email` idempotent ist (verlierender Pod bekommt einen `P2002`-Fehler, den `seed.ts` abfängt und als No-op behandelt statt den Container abstürzen zu lassen) – eine Instanz wendet die Migrationen/den Seed an, die anderen finden danach nichts mehr zu tun. `deploy/k8s/backend/deployment.yaml` hat dafür eine `startupProbe` mit großzügigem `failureThreshold`, damit liveness/readiness den Container nicht neu starten, während die (beim allerersten Deploy ggf. länger laufende) Migration noch durchläuft.
**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) und IdempotencyRecord (Annahme 11); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
- **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:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
@@ -145,13 +223,37 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Lesen und Buchen sind getrennte Schlüssel: `ApiKeysService.create` lehnt einen Schlüssel mit Lese- und Buchungsrecht (z. B. beiden Shop-Rechten) mit 400 ab; bereits ausgestellte kombinierte Schlüssel bleiben gültig und werden in der Oberfläche mit Hinweis markiert. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 24); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
| Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
| 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):**
- Rate-Limits zählen je Pod im Speicher; bei 2 Replikas verdoppelt sich das Limit. Brute-Force-Schutz hängt nicht daran, sondern an der Kontosperre in der DB. Ein gemeinsamer Speicher (Redis) wäre der nächste Schritt, falls Lastspitzen ein Thema werden.
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
## 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.
**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.
```
+3
View File
@@ -1,5 +1,8 @@
node_modules
dist
# A stale build info makes `nest build` emit only changed files into the
# freshly deleted dist (tsconfig.json: incremental).
*.tsbuildinfo
generated
coverage
.devdb
+31 -7
View File
@@ -2,8 +2,10 @@
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe
# src/runtime/startup-checks.ts) - signiert jedes Access-/MFA-/SSO-Token.
# TOTP- und Webhook-Secrets aus der Zeit vor den eigenen Schluesseln (unten)
# sind noch damit verschluesselt: erst nach dem rekey-Job rotieren.
# Erzeugen z.B. mit: openssl rand -hex 32
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
JWT_REFRESH_SECRET="change-me-refresh-secret"
@@ -22,7 +24,7 @@ FRONTEND_ORIGIN="http://localhost:3000"
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
# (see deploy/k8s/backend/configmap.example.yaml); "1" would yield the
# (see deploy/k8s/base/backend/configmap.example.yaml); "1" would yield the
# Cloudflare edge IP that many clients share.
TRUST_PROXY=1
@@ -54,11 +56,25 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
# jedem Lauf geloescht (Standard: 14).
BACKUP_RETENTION_COUNT=14
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
# Set the same value on another deployment to restore there. At least 32
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with
# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in
# production: at least 32 characters each, all three different from each
# other and from JWT_ACCESS_SECRET/JWT_REFRESH_SECRET, e.g.
# "openssl rand -hex 32". Outside production a missing key falls back to one
# derived from JWT_ACCESS_SECRET.
# Rotation: move the old value to <NAME>_PREVIOUS (still opens), set a new
# <NAME>, run the rekey job, then drop <NAME>_PREVIOUS. Backup files are not
# resealed: keep BACKUP_ENCRYPTION_KEY_PREVIOUS while older files must restore.
# Set the same BACKUP_ENCRYPTION_KEY on another deployment to restore its
# backups there (MFA_/WEBHOOK_ENCRYPTION_KEY too, to keep MFA and webhook
# secrets). Keep the backup key outside the backup storage.
# MFA_ENCRYPTION_KEY=""
# WEBHOOK_ENCRYPTION_KEY=""
# BACKUP_ENCRYPTION_KEY=""
# MFA_ENCRYPTION_KEY_PREVIOUS=""
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
@@ -84,3 +100,11 @@ OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
ENABLE_SWAGGER=false
# Laufrolle des Prozesses: api (nur HTTP), worker, job (CLI) oder all (API +
# Worker in einem Prozess, nur fuer lokale Entwicklung und Tests). Ohne Wert
# gilt der Standard des Einstiegspunkts (main: all, worker: worker, cli: job).
# In Produktion Pflicht und "all" ist dort verboten.
# APICORE_ROLE=api
# Port des Health-Endpunkts des Worker-Prozesses.
# WORKER_HEALTH_PORT=3002
+24 -10
View File
@@ -1,6 +1,10 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
# Base-Image per Digest gepinnt (node:22-alpine, Multi-Arch-Index vom
# 2026-09-23): ein Rebuild zieht nie ungeprueft ein neues Image. Ein Update
# ist ein bewusster Commit mit neuem Digest, ermittelt per
# docker buildx imagetools inspect node:22-alpine
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
@@ -14,16 +18,16 @@ RUN npx prisma generate
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
RUN npm run build
FROM node:22-alpine AS runtime
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/generated ./generated
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer
# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt
# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer den
# initContainer `migrate` (siehe Kommentar ueber CMD unten).
# seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
@@ -31,9 +35,19 @@ COPY --from=builder /app/generated ./generated
# fuehrte zu sporadischen OOM-Crashes beim Start.
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
EXPOSE 3001
# 3001: HTTP der api-Rolle. 3002: /health der worker-Rolle.
EXPOSE 3001 3002
USER node
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) -
# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas.
CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"]
# Migration und Seed laufen nicht mehr hier, sondern als initContainer im
# Pod (deploy/k8s/components/backend-roles): `migrate` in api und worker
# (`node node_modules/prisma/build/index.js migrate deploy`, idempotent mit
# DB-seitigem Advisory-Lock), `seed` nur in der api (`node dist/seed.js`,
# idempotent per Unique-Constraint auf User.email). Scheitert die Migration,
# startet der neue Pod nicht und der alte laeuft weiter. Der Hauptprozess
# startet ohne Prisma-CLI; worker und Jobs ueberschreiben das Kommando
# (dist/worker.js, dist/cli.js <job>), die Rolle waehlt APICORE_ROLE.
# Dieses Image darf darum nur zusammen mit components/backend-roles live
# gehen (Runbook Schritt 3). Fehlt APICORE_ROLE, bricht der Start in
# Produktion absichtlich ab. APICORE_ROLE nicht von Hand in ein Deployment
# ohne die initContainer setzen: dann liefe keine Migration.
CMD ["node", "dist/main.js"]
+17 -15
View File
@@ -30,6 +30,7 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"pg": "^8.23.1",
"prisma": "^7.9.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
@@ -49,6 +50,7 @@
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
@@ -10087,14 +10089,14 @@
"license": "MIT"
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-connection-string": "^2.14.1",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-protocol": "^1.16.1",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
@@ -10102,7 +10104,7 @@
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
"pg-cloudflare": "^1.4.1"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
@@ -10114,16 +10116,16 @@
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
"license": "MIT"
},
"node_modules/pg-int8": {
@@ -10145,9 +10147,9 @@
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
"license": "MIT"
},
"node_modules/pg-types": {
+4
View File
@@ -12,6 +12,8 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"worker": "node dist/worker.js",
"job": "node dist/cli.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"typecheck": "tsc --noEmit",
"test": "jest",
@@ -44,6 +46,7 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"pg": "^8.23.1",
"prisma": "^7.9.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
@@ -63,6 +66,7 @@
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
@@ -0,0 +1,139 @@
-- Platform backbone: domain-event outbox (DomainEvent + per-handler
-- DomainEventDelivery), webhook delivery queue, cron job run history and
-- throttler counters, plus two small columns (Apiary.publicLocationLabel,
-- SiteSettings.originCountry).
-- ThrottlerHit is UNLOGGED on purpose: no WAL, emptied after a crash - only
-- rate-limit counters get lost. DomainEvent.dedupeKey is a nullable UNIQUE
-- (not a partial index) so the schema and the database cannot drift apart.
-- CreateEnum
CREATE TYPE "DomainEventStatus" AS ENUM ('PENDING', 'PROCESSING', 'DONE', 'FAILED');
-- CreateEnum
CREATE TYPE "DomainEventDeliveryStatus" AS ENUM ('PENDING', 'DONE', 'FAILED');
-- CreateEnum
CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'FAILED');
-- CreateEnum
CREATE TYPE "JobRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
-- AlterTable
ALTER TABLE "Apiary" ADD COLUMN "publicLocationLabel" TEXT;
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "originCountry" TEXT NOT NULL DEFAULT 'Deutschland';
-- CreateTable
CREATE TABLE "DomainEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"aggregateType" TEXT NOT NULL,
"aggregateId" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"actorUserId" TEXT,
"correlationId" TEXT NOT NULL,
"dedupeKey" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" "DomainEventStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"processedAt" TIMESTAMP(3),
"lastError" TEXT,
CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainEventDelivery" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"handler" TEXT NOT NULL,
"status" "DomainEventDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainEventDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookDelivery" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"body" TEXT NOT NULL,
"status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"lastStatusCode" INTEGER,
"lastError" TEXT,
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JobRun" (
"id" TEXT NOT NULL,
"job" TEXT NOT NULL,
"status" "JobRunStatus" NOT NULL DEFAULT 'RUNNING',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
"result" JSONB,
"error" TEXT,
CONSTRAINT "JobRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE UNLOGGED TABLE "ThrottlerHit" (
"key" TEXT NOT NULL,
"throttlerName" TEXT NOT NULL,
"hits" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"blockedUntil" TIMESTAMP(3),
CONSTRAINT "ThrottlerHit_pkey" PRIMARY KEY ("key","throttlerName")
);
-- CreateIndex
CREATE UNIQUE INDEX "DomainEvent_dedupeKey_key" ON "DomainEvent"("dedupeKey");
-- CreateIndex
CREATE INDEX "DomainEvent_status_availableAt_idx" ON "DomainEvent"("status", "availableAt");
-- CreateIndex
CREATE INDEX "DomainEvent_correlationId_idx" ON "DomainEvent"("correlationId");
-- CreateIndex
CREATE INDEX "DomainEvent_aggregateType_aggregateId_idx" ON "DomainEvent"("aggregateType", "aggregateId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainEventDelivery_eventId_handler_key" ON "DomainEventDelivery"("eventId", "handler");
-- CreateIndex
CREATE INDEX "WebhookDelivery_status_availableAt_idx" ON "WebhookDelivery"("status", "availableAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_subscriptionId_createdAt_idx" ON "WebhookDelivery"("subscriptionId", "createdAt");
-- CreateIndex
CREATE INDEX "JobRun_job_startedAt_idx" ON "JobRun"("job", "startedAt");
-- CreateIndex
CREATE INDEX "JobRun_status_idx" ON "JobRun"("status");
-- CreateIndex
CREATE INDEX "ThrottlerHit_expiresAt_idx" ON "ThrottlerHit"("expiresAt");
-- AddForeignKey
ALTER TABLE "DomainEventDelivery" ADD CONSTRAINT "DomainEventDelivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "DomainEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TABLE "Inspection" ADD COLUMN "broodFrames" INTEGER;
+152 -29
View File
@@ -78,8 +78,8 @@ model User {
// TOTP-Secret AES-256-GCM-verschluesselt gespeichert (siehe
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
mfaEnabled Boolean @default(false)
mfaSecret String?
mfaEnabled Boolean @default(false)
mfaSecret String?
// Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer)
// wird danach nie wieder akzeptiert (Replay-Schutz).
mfaLastTimeStep Int?
@@ -88,7 +88,7 @@ model User {
// ab 5 ist das Konto bis authLockedUntil gesperrt.
failedAuthAttempts Int @default(0)
authLockedUntil DateTime?
avatarPath String?
avatarPath String?
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
// die vor diesem Zeitpunkt ausgestellt wurden (siehe
@@ -170,16 +170,16 @@ model ConsumedToken {
}
model RefreshToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
revokedAt DateTime?
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
revokedAt DateTime?
// Anmeldezeitpunkt der Sitzung, an jede rotierte Nachfolge-Token vererbt:
// spaetestens SESSION_MAX_AGE danach ist neu anzumelden.
sessionStartedAt DateTime @default(now())
sessionStartedAt DateTime @default(now())
@@index([userId])
}
@@ -223,23 +223,25 @@ enum ApiaryStatus {
}
model Apiary {
id String @id @default(cuid())
name String
description String?
location String?
latitude Float?
longitude Float?
address String?
status ApiaryStatus @default(ACTIVE)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
id String @id @default(cuid())
name String
description String?
location String?
latitude Float?
longitude Float?
address String?
// Coarse, public-safe place name for storefront/shop output (never the exact address).
publicLocationLabel String?
status ApiaryStatus @default(ACTIVE)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
@@index([status])
@@ -454,6 +456,7 @@ model Inspection {
weather String?
temperature Float?
colonyStrength Int?
broodFrames Int?
broodAmount String?
broodPattern Int?
openBroodPresent Boolean?
@@ -761,8 +764,8 @@ model Harvest {
notes String?
createdAt DateTime @default(now())
colonies HarvestColony[]
extractions ExtractionHarvest[]
colonies HarvestColony[]
extractions ExtractionHarvest[]
rawMaterialLots RawMaterialLot[]
@@index([apiaryId, date])
@@ -1300,6 +1303,8 @@ model WebhookSubscription {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deliveries WebhookDelivery[]
@@index([active])
}
@@ -1403,6 +1408,8 @@ model SiteSettings {
// part of any public page.
holderName String?
holderAddress String?
// Country of origin printed on labels and shop output.
originCountry String @default("Deutschland")
updatedAt DateTime @updatedAt
}
@@ -1521,3 +1528,119 @@ model CalendarSyncOutboxEntry {
@@unique([sourceType, sourceId])
@@index([status])
}
// ---------------------------------------------------------------------------
// Plattform-Rückgrat: Domain-Events (Outbox), Webhook-Zustellung, Job-Läufe,
// Throttler-Zähler. Alle fünf Modelle sind vom Backup ausgenommen.
// ---------------------------------------------------------------------------
enum DomainEventStatus {
PENDING
PROCESSING
DONE
FAILED
}
model DomainEvent {
id String @id @default(cuid())
type String
aggregateType String
aggregateId String
payload Json
actorUserId String?
correlationId String
// Nullable @unique instead of a partial index: avoids the migration drift
// described in the backbone spec (section 3.5).
dedupeKey String? @unique
occurredAt DateTime @default(now())
status DomainEventStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
processedAt DateTime?
lastError String?
deliveries DomainEventDelivery[]
@@index([status, availableAt])
@@index([correlationId])
@@index([aggregateType, aggregateId])
}
enum DomainEventDeliveryStatus {
PENDING
DONE
FAILED
}
model DomainEventDelivery {
id String @id @default(cuid())
eventId String
event DomainEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
handler String
status DomainEventDeliveryStatus @default(PENDING)
attempts Int @default(0)
lastError String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, handler])
}
enum WebhookDeliveryStatus {
PENDING
PROCESSING
DELIVERED
FAILED
}
model WebhookDelivery {
id String @id @default(cuid())
subscriptionId String
subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
event String
body String
status WebhookDeliveryStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
lastStatusCode Int?
lastError String?
deliveredAt DateTime?
createdAt DateTime @default(now())
@@index([status, availableAt])
@@index([subscriptionId, createdAt])
}
enum JobRunStatus {
RUNNING
SUCCEEDED
FAILED
}
model JobRun {
id String @id @default(cuid())
job String
status JobRunStatus @default(RUNNING)
startedAt DateTime @default(now())
finishedAt DateTime?
result Json?
error String?
@@index([job, startedAt])
@@index([status])
}
// UNLOGGED in the migration: no WAL, emptied after a crash - only counters get lost.
model ThrottlerHit {
key String
throttlerName String
hits Int
expiresAt DateTime
blockedUntil DateTime?
@@id([key, throttlerName])
@@index([expiresAt])
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AppModule } from './app.module';
import { WorkerRuntimeModule } from './worker/worker-runtime.module';
/**
* Role "all": the API plus the worker loops in one process, for local
* development and tests. Production refuses it (runtime/role.ts) and runs
* api and worker as separate pods. No worker health server here - the
* API's /health endpoints serve the process.
*/
@Module({
imports: [AppModule, WorkerRuntimeModule],
})
export class AllInOneModule {}
@@ -19,6 +19,24 @@ export const API_KEY_SCOPES = [
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
/**
* Scopes that book (change data). Every other scope only reads. A new key
* never mixes both kinds: a leaked read key cannot book, a leaked booking
* key cannot read (ARCHITECTURE.md, Annahme 14).
*/
export const API_KEY_WRITE_SCOPES = [
'shop-sync:write',
] as const satisfies readonly ApiKeyScope[];
export function isApiKeyScope(value: string): value is ApiKeyScope {
return (API_KEY_SCOPES as readonly string[]).includes(value);
}
function isWriteScope(scope: string): boolean {
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
}
/** True if the scopes hold at least one reading and one booking right. */
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
}
@@ -1,12 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
// ThrottlerStorageModule: the failure counters share the rate-limit table,
// so the limit holds across all api pods.
@Global()
@Module({
imports: [ThrottlerStorageModule],
controllers: [ApiKeysController],
providers: [ApiKeysService],
providers: [ApiKeysService, FailedAttemptLimiter],
exports: [ApiKeysService],
})
export class ApiKeysModule {}
@@ -1,13 +1,19 @@
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
HttpException,
Logger,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
import {
API_KEY_FAILURE_LIMIT,
ApiKeysService,
extractApiKeyToken,
} from './api-keys.service';
FailedAttemptLimiter,
} from './failed-attempt-limiter';
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
@@ -31,6 +37,38 @@ function storedKey(overrides: Record<string, unknown> = {}) {
};
}
/**
* Stand-in for the shared counter table with the same counting rule as
* PrismaThrottlerStorage within one window: count up to the limit, the hit
* after it blocks, a blocked source keeps its count. Time and the database
* itself are covered by the e2e tests.
*/
function inMemoryStorage(): PrismaThrottlerStorage {
const rows = new Map<string, { hits: number; blocked: boolean }>();
return {
increment: (
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
) => {
const id = `${throttlerName}|${key}`;
const row = rows.get(id) ?? { hits: 0, blocked: false };
const next = row.blocked
? row
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
rows.set(id, next);
return Promise.resolve({
totalHits: next.hits,
timeToExpire: ttl / 1000,
isBlocked: next.blocked,
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
});
},
} as unknown as PrismaThrottlerStorage;
}
describe('ApiKeysService.authenticate', () => {
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
let service: ApiKeysService;
@@ -47,6 +85,7 @@ describe('ApiKeysService.authenticate', () => {
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
@@ -166,6 +205,18 @@ describe('ApiKeysService.authenticate', () => {
).resolves.toBe(null);
});
it('meldet das Erreichen der Grenze einmal im Log', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
const limitMessages = warn.mock.calls.filter(([message]) =>
String(message).includes('get 429'),
);
expect(limitMessages).toHaveLength(1);
});
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
@@ -210,6 +261,79 @@ describe('ApiKeysService.authenticate', () => {
});
});
describe('ApiKeysService.create', () => {
let service: ApiKeysService;
let prisma: { apiKey: { create: jest.Mock } };
beforeEach(() => {
prisma = {
apiKey: {
create: jest.fn((args: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }),
),
},
};
const permissionResolver = {
getPermissions: jest
.fn()
.mockResolvedValue(
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
),
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
});
it('refuses a new key with both shop scopes', async () => {
await expect(
service.create(
{
name: 'Shop',
scopes: ['shop-sync:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('refuses the read-only ERP scope together with booking', async () => {
await expect(
service.create(
{
name: 'ERPNext',
scopes: ['erp-inventory:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
'still creates a key with only %s',
async (scope) => {
const created = await service.create(
{ name: 'Shop', scopes: [scope] },
'user-1',
);
expect(created.apiKey.scopes).toEqual([scope]);
},
);
});
describe('extractApiKeyToken', () => {
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
@@ -1,4 +1,5 @@
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
@@ -11,11 +12,18 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { ApiKey } from '../../generated/prisma';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
import {
ApiKeyScope,
combinesReadAndWrite,
isApiKeyScope,
} from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
import {
API_KEY_FAILURE_LIMIT,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
@@ -38,21 +46,6 @@ const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
// revealing its secret part.
const LOGGABLE_PREFIX_LENGTH = 12;
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys for the rest of the window - the same
* budget as the shop's ERP export. It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
maxTrackedSources: 10_000,
} as const;
type RejectReason = 'unknown' | 'revoked' | 'expired';
/** What a request authenticated by an API key may do - it is not a user. */
@@ -91,12 +84,13 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
// Counters shared by all api pods (ThrottlerHit), not process memory.
private readonly failures: FailedAttemptLimiter,
) {}
async findAll(): Promise<ApiKeySummary[]> {
@@ -110,6 +104,14 @@ export class ApiKeysService {
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// Reading and booking are separate keys: a leaked read key cannot book,
// a leaked booking key cannot read. Existing combined keys stay valid.
// erp-inventory:read is a read scope too.
if (combinesReadAndWrite(dto.scopes)) {
throw new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
);
}
// Nobody hands a machine more than they may do themselves.
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
@@ -224,20 +226,21 @@ export class ApiKeysService {
if (reason === null && apiKey) {
return this.accept(apiKey, clientIp, now);
}
this.recordRejection(token, reason ?? 'unknown', clientIp);
await this.recordRejection(token, reason ?? 'unknown', clientIp);
return null;
}
/** Counts an invalid key; 429 once the client network is over the limit. */
private recordRejection(
private async recordRejection(
token: string,
reason: RejectReason,
clientIp: string | undefined,
): void {
): Promise<void> {
// Counted per client network like the throttler (IPv6 /64), so rotating
// addresses inside one's own prefix does not reset the budget.
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
if (this.failures.isBlocked(subject)) {
const verdict = await this.failures.recordFailure(subject);
if (verdict.blocked) {
// Same body shape as the built-in 4xx exceptions (JSON object, not a
// bare string), so clients can read `message` uniformly.
throw new HttpException(
@@ -254,9 +257,9 @@ export class ApiKeysService {
this.logger.warn(
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
);
if (this.failures.recordFailure(subject)) {
if (verdict.limitReached) {
this.logger.warn(
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
);
}
}
@@ -1,112 +1,91 @@
import { FailedAttemptLimiter } from './failed-attempt-limiter';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import {
API_KEY_FAILURE_LIMIT,
API_KEY_FAILURE_THROTTLER,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
describe('FailedAttemptLimiter', () => {
let now: number;
let limiter: FailedAttemptLimiter;
const increment = jest.fn();
const limiter = new FailedAttemptLimiter({
increment,
} as unknown as PrismaThrottlerStorage);
function record(totalHits: number, isBlocked: boolean) {
return {
totalHits,
timeToExpire: 600,
isBlocked,
timeToBlockExpire: isBlocked ? 900 : 0,
};
}
beforeEach(() => {
now = 1_000_000;
limiter = new FailedAttemptLimiter({
maxFailures: 3,
windowMs: 60_000,
maxTrackedSources: 100,
clock: () => now,
increment.mockReset();
increment.mockResolvedValue(record(1, false));
});
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
await limiter.recordFailure('203.0.113.7');
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
expect(increment).toHaveBeenCalledTimes(1);
expect(increment).toHaveBeenCalledWith(
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_LIMIT.maxFailures,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_THROTTLER,
);
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
});
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
expect(API_KEY_FAILURE_LIMIT).toEqual({
maxFailures: 20,
windowMs: 15 * 60 * 1000,
});
});
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
it('sperrt nicht unterhalb der Grenze', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
);
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
});
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
limiter.recordFailure('203.0.113.7');
limiter.recordFailure('203.0.113.7');
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
});
it('trennt Quellen voneinander', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
});
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
now += 60_001;
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
});
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: false,
});
small.recordFailure('a');
small.recordFailure('b');
now += 60_001;
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
small.recordFailure('c');
expect(small.trackedSources()).toBe(1);
expect(small.isBlocked('c')).toBe(true);
});
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
const small = new FailedAttemptLimiter({
maxFailures: 5,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
expect(small.trackedSources()).toBe(2);
});
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 2,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('angreifer');
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
small.recordFailure('neu');
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
);
expect(small.trackedSources()).toBe(2);
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
expect(small.isBlocked('angreifer')).toBe(true);
// Verdrängt wurde der nicht gesperrte Eintrag.
expect(small.recordFailure('harmlos')).toBe(false);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: true,
});
});
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
);
expect(small.trackedSources()).toBe(2);
expect(small.isBlocked('a')).toBe(false);
expect(small.isBlocked('b')).toBe(true);
expect(small.isBlocked('c')).toBe(true);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: true,
limitReached: false,
});
});
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
increment.mockRejectedValueOnce(new Error('db down'));
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
'db down',
);
});
});
@@ -1,94 +1,57 @@
export interface FailedAttemptLimiterOptions {
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
maxFailures: number;
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
windowMs: number;
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
maxTrackedSources: number;
clock?: () => number;
}
import { Injectable } from '@nestjs/common';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
interface Window {
failures: number;
startedAt: number;
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys - the same budget the ERP export
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
} as const;
/**
* Throttler name and key prefix of these counters in "ThrottlerHit", so
* they never mix with the request rate limits stored in the same table.
*/
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
export interface FailedAttemptVerdict {
/** The source is over the limit: refuse the request with 429. */
blocked: boolean;
/** This failure is the one that reached the limit (once per window). */
limitReached: boolean;
}
/**
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
*
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
* das ist für eine Bremse gegen Durchprobieren ausreichend.
* Counts invalid API keys per source (client network) in the shared,
* database-backed rate-limit storage, so the limit holds across all api
* pods. One upsert per failure, every time comparison on the database
* clock (PrismaThrottlerStorage). The window starts with the first failure;
* the failure after the limit blocks the source for one window length.
* A database error propagates: the request fails instead of passing.
*/
@Injectable()
export class FailedAttemptLimiter {
private readonly windows = new Map<string, Window>();
private readonly clock: () => number;
constructor(private readonly storage: PrismaThrottlerStorage) {}
constructor(private readonly options: FailedAttemptLimiterOptions) {
this.clock = options.clock ?? Date.now;
}
isBlocked(source: string): boolean {
const window = this.activeWindow(source);
return window !== undefined && window.failures >= this.options.maxFailures;
}
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
recordFailure(source: string): boolean {
const now = this.clock();
const current = this.activeWindow(source);
if (!current) {
this.makeRoom(now);
}
const failures = (current?.failures ?? 0) + 1;
this.windows.set(source, {
failures,
startedAt: current?.startedAt ?? now,
});
return failures === this.options.maxFailures;
}
trackedSources(): number {
return this.windows.size;
}
private activeWindow(source: string): Window | undefined {
const window = this.windows.get(source);
if (window && this.clock() - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
return undefined;
}
return window;
}
private makeRoom(now: number): void {
if (this.windows.size < this.options.maxTrackedSources) return;
for (const [source, window] of this.windows) {
if (now - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
}
}
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
// die Einfügereihenfolge).
if (this.windows.size >= this.options.maxTrackedSources) {
const victim =
this.oldestWhere(
(window) => window.failures < this.options.maxFailures,
) ?? this.oldestWhere(() => true);
if (victim !== undefined) this.windows.delete(victim);
}
}
private oldestWhere(
matches: (window: Window) => boolean,
): string | undefined {
for (const [source, window] of this.windows) {
if (matches(window)) return source;
}
return undefined;
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
const record = await this.storage.increment(
`${API_KEY_FAILURE_THROTTLER}:${source}`,
windowMs,
maxFailures,
windowMs,
API_KEY_FAILURE_THROTTLER,
);
return {
blocked: record.isBlocked,
limitReached: !record.isBlocked && record.totalHits === maxFailures,
};
}
}
@@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@ApiTags('apiaries')
@ApiBearerAuth()
@Controller('apiaries')
export class ApiariesController {
constructor(private readonly apiariesService: ApiariesService) {}
constructor(
private readonly apiariesService: ApiariesService,
private readonly apiaryFieldService: ApiaryFieldService,
) {}
@RequirePermission('apiaries:read')
@Get()
@@ -31,6 +35,24 @@ export class ApiariesController {
return this.apiariesService.findAll({ status, location });
}
@RequirePermission('colonies:read')
@Get(':id/colony-overview')
colonyOverview(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiaryFieldService.colonyOverview(id, actor.id);
}
@RequirePermission('colonies:read')
@Get(':id/offline-pack')
offlinePack(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiaryFieldService.offlinePack(id, actor.id);
}
@RequirePermission('apiaries:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@Module({
controllers: [ApiariesController],
providers: [ApiariesService],
providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService],
})
export class ApiariesModule {}
@@ -0,0 +1,206 @@
import { NotFoundException } from '@nestjs/common';
import { ApiaryFieldService } from './apiary-field.service';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('ApiaryFieldService', () => {
let service: ApiaryFieldService;
let prisma: {
apiary: { findUnique: jest.Mock };
colony: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
notification: { groupBy: jest.Mock };
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
const colonyRow = (id: string, code: string, queenId: string | null) => ({
id,
code,
apiaryId: 'apiary-1',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
hiveType: null,
strength: 5,
broodStatus: null,
feedStatus: null,
healthStatus: null,
currentQueenId: queenId,
notes: null,
});
const grant = (...keys: string[]) =>
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
beforeEach(() => {
prisma = {
apiary: {
findUnique: jest.fn().mockResolvedValue({
id: 'apiary-1',
name: 'Heimstand',
location: 'Garten',
status: 'ACTIVE',
}),
},
colony: {
findMany: jest
.fn()
.mockResolvedValue([
colonyRow('c10', 'V-10', 'q1'),
colonyRow('c2', 'V-2', null),
]),
},
inspection: {
groupBy: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([]),
},
notification: { groupBy: jest.fn().mockResolvedValue([]) },
queen: { findMany: jest.fn().mockResolvedValue([]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
service = new ApiaryFieldService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
);
});
it('throws NotFoundException for an unknown apiary', async () => {
grant('colonies:read');
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.colonyOverview('x', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('sorts living colonies by code with numeric collation', async () => {
grant('colonies:read');
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows.map((row) => row.code)).toEqual(['V-2', 'V-10']);
expect(prisma.colony.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ apiaryId: 'apiary-1' }) as unknown,
}),
);
});
it('leaves out inspections, queens and tasks without their read permission', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.inspections).toBeNull();
expect(pack.queens).toBeNull();
expect(pack.tasks).toBeNull();
expect(pack.overview.inspectionsVisible).toBe(false);
expect(pack.overview.rows.every((r) => r.lastInspectionDate === null)).toBe(
true,
);
expect(prisma.inspection.groupBy).not.toHaveBeenCalled();
expect(prisma.inspection.findMany).not.toHaveBeenCalled();
expect(prisma.queen.findMany).not.toHaveBeenCalled();
expect(prisma.task.findMany).not.toHaveBeenCalled();
});
it('fills the permitted sections and the last inspection date', async () => {
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
const date = new Date('2026-05-01T10:00:00.000Z');
prisma.inspection.groupBy.mockResolvedValue([
{ colonyId: 'c2', _max: { date } },
]);
prisma.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-1' }]);
prisma.task.findMany.mockResolvedValue([{ id: 't1', title: 'Futter' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.overview.inspectionsVisible).toBe(true);
expect(pack.overview.rows[0].lastInspectionDate).toBe(date.toISOString());
expect(pack.overview.rows[1].lastInspectionDate).toBeNull();
expect(pack.queens).toEqual([{ id: 'q1', code: 'K-1' }]);
expect(prisma.queen.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: { in: ['q1'] } } }),
);
expect(pack.tasks).toEqual([{ id: 't1', title: 'Futter' }]);
expect(prisma.task.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: 'apiary-1' },
{ relatedType: 'COLONY', relatedId: { in: ['c2', 'c10'] } },
],
},
}),
);
});
it('keeps at most three inspections per colony, newest first', async () => {
grant('colonies:read', 'inspections:read');
prisma.inspection.findMany.mockResolvedValue([{ id: 'i1' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(prisma.inspection.findMany).toHaveBeenCalledTimes(2);
expect(prisma.inspection.findMany).toHaveBeenCalledWith({
where: { colonyId: 'c2' },
take: 3,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
});
expect(pack.inspections).toHaveLength(2);
});
it('never returns coordinates, address, apiary notes or inspection userId', async () => {
grant('colonies:read', 'inspections:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(Object.keys(pack.apiary).sort()).toEqual([
'id',
'location',
'name',
'status',
]);
const apiarySelect = (
prisma.apiary.findUnique.mock.calls[0] as [{ select: object }]
)[0].select;
expect(apiarySelect).not.toHaveProperty('latitude');
expect(apiarySelect).not.toHaveProperty('longitude');
expect(apiarySelect).not.toHaveProperty('address');
expect(apiarySelect).not.toHaveProperty('notes');
const inspectionArgs = (
prisma.inspection.findMany.mock.calls[0] as [{ omit: object }]
)[0];
expect(inspectionArgs.omit).toEqual({ userId: true });
});
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => {
grant('colonies:read');
prisma.notification.groupBy.mockResolvedValue([
{ relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } },
{ relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } },
]);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(prisma.notification.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: ['c2', 'c10'] },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId: 'u1' }, { userId: null }],
},
}),
);
expect(overview.rows[0]).toMatchObject({
code: 'V-2',
openWarnings: 2,
openCritical: 1,
});
expect(overview.rows[1]).toMatchObject({
openWarnings: 0,
openCritical: 0,
});
});
});
@@ -0,0 +1,230 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import type {
ColonyOverview,
ColonyOverviewRow,
OfflinePackV1,
} from './apiary-field.types';
const INSPECTIONS_PER_COLONY = 3;
type PackColony = OfflinePackV1['colonies'][number];
interface FieldData {
apiary: OfflinePackV1['apiary'];
colonies: PackColony[];
permissions: ReadonlySet<string>;
overview: ColonyOverview;
}
/**
* Read models for work at the apiary: the colony traffic-light overview and
* the offline pack the PWA caches before the beekeeper loses reception.
* Sections beyond the colonies are only included for callers who may read them.
*/
@Injectable()
export class ApiaryFieldService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
) {}
async colonyOverview(
apiaryId: string,
userId: string,
): Promise<ColonyOverview> {
return (await this.load(apiaryId, userId)).overview;
}
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview } = await this.load(
apiaryId,
userId,
);
const [queens, inspections, tasks] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read')
? this.loadInspections(colonies)
: null,
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
]);
return {
version: 1,
generatedAt: new Date().toISOString(),
apiary,
overview,
colonies,
queens,
inspections,
tasks,
};
}
private async load(apiaryId: string, userId: string): Promise<FieldData> {
// Explicit select: coordinates, address and notes of an apiary are
// theft-sensitive and must never reach the field payloads.
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
select: { id: true, name: true, location: true, status: true },
});
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
const permissions = await this.permissionResolver.getPermissions(userId);
const found = await this.prisma.colony.findMany({
where: { ...LIVING_COLONY_WHERE, apiaryId },
select: {
id: true,
code: true,
apiaryId: true,
status: true,
colonyType: true,
hiveType: true,
strength: true,
broodStatus: true,
feedStatus: true,
healthStatus: true,
currentQueenId: true,
notes: true,
},
});
const colonies = [...found].sort((a, b) =>
a.code.localeCompare(b.code, 'de', { numeric: true }),
);
const inspectionsVisible = permissions.has('inspections:read');
const [lastInspections, warnings] = await Promise.all([
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
this.warningCounts(colonies, userId),
]);
const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id,
code: colony.code,
status: colony.status,
strength: colony.strength,
broodStatus: colony.broodStatus,
feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus,
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0,
openCritical: warnings.get(colony.id)?.KRITISCH ?? 0,
}));
return {
apiary,
colonies,
permissions,
overview: { apiaryId, inspectionsVisible, rows },
};
}
private async lastInspectionDates(
colonies: PackColony[],
): Promise<Map<string, string>> {
const groups = await this.prisma.inspection.groupBy({
by: ['colonyId'],
where: { colonyId: { in: colonies.map((colony) => colony.id) } },
_max: { date: true },
});
const dates = new Map<string, string>();
for (const group of groups) {
if (group._max.date) {
dates.set(group.colonyId, group._max.date.toISOString());
}
}
return dates;
}
private async warningCounts(
colonies: PackColony[],
userId: string,
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
const groups = await this.prisma.notification.groupBy({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId }, { userId: null }],
},
_count: { _all: true },
});
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
for (const group of groups) {
if (
group.relatedId &&
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
) {
counts.set(group.relatedId, {
...counts.get(group.relatedId),
[group.priority]: group._count._all,
});
}
}
return counts;
}
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [],
);
return this.prisma.queen.findMany({
where: { id: { in: queenIds } },
select: {
id: true,
code: true,
hatchYear: true,
breed: true,
markingColor: true,
markingNumber: true,
status: true,
},
});
}
private async loadInspections(
colonies: PackColony[],
): Promise<OfflinePackV1['inspections']> {
const perColony = await Promise.all(
colonies.map((colony) =>
this.prisma.inspection.findMany({
where: { colonyId: colony.id },
take: INSPECTIONS_PER_COLONY,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
}),
),
);
return perColony.flat();
}
private loadTasks(
apiaryId: string,
colonies: PackColony[],
): Promise<OfflinePackV1['tasks']> {
return this.prisma.task.findMany({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: apiaryId },
{
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
},
],
},
select: {
id: true,
title: true,
description: true,
dueDate: true,
priority: true,
status: true,
relatedType: true,
relatedId: true,
},
orderBy: [{ dueDate: 'asc' }, { createdAt: 'asc' }],
});
}
}
@@ -0,0 +1,91 @@
import type {
ApiaryStatus,
Colony,
ColonyCondition,
ColonyStatus,
Inspection,
Queen,
Task,
} from '../../generated/prisma';
/** One living colony on the apiary page, reduced to what the traffic light needs. */
export interface ColonyOverviewRow {
id: string;
code: string;
status: ColonyStatus;
strength: number | null;
broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null;
/** ISO timestamp; always null when the caller may not read inspections. */
lastInspectionDate: string | null;
/** Unread WARNUNG notifications visible to the caller. */
openWarnings: number;
/** Unread KRITISCH notifications visible to the caller. */
openCritical: number;
}
export interface ColonyOverview {
apiaryId: string;
inspectionsVisible: boolean;
rows: ColonyOverviewRow[];
}
/**
* Everything the offline apiary mode needs. It deliberately carries no
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
* and no inspection userId. Sections the caller has no read permission for
* are null.
*/
export interface OfflinePackV1 {
version: 1;
generatedAt: string;
apiary: {
id: string;
name: string;
location: string | null;
status: ApiaryStatus;
};
overview: ColonyOverview;
colonies: Pick<
Colony,
| 'id'
| 'code'
| 'apiaryId'
| 'status'
| 'colonyType'
| 'hiveType'
| 'strength'
| 'broodStatus'
| 'feedStatus'
| 'healthStatus'
| 'currentQueenId'
| 'notes'
>[];
queens:
| Pick<
Queen,
| 'id'
| 'code'
| 'hatchYear'
| 'breed'
| 'markingColor'
| 'markingNumber'
| 'status'
>[]
| null;
inspections: Omit<Inspection, 'userId'>[] | null;
tasks:
| Pick<
Task,
| 'id'
| 'title'
| 'description'
| 'dueDate'
| 'priority'
| 'status'
| 'relatedType'
| 'relatedId'
>[]
| null;
}
@@ -1,5 +1,11 @@
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
import {
IsLatitude,
IsLongitude,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
export class CreateApiaryDto {
@ApiProperty()
@@ -35,4 +41,13 @@ export class CreateApiaryDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({
description:
'Ort für die öffentliche Herkunftsseite, z. B. "Nordhessen". Nie die genaue Adresse.',
})
@IsOptional()
@IsString()
@MaxLength(100)
publicLocationLabel?: string;
}
+2 -2
View File
@@ -19,7 +19,7 @@ import { noStoreMiddleware } from './common/middleware/no-store.middleware';
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
* deploy/k8s/base/backend/configmap.example.yaml). A plain number ("1") is a hop
* count and stays the default when the variable is unset.
*/
export function trustProxySetting(): number | string {
@@ -58,7 +58,7 @@ export function configureApp(app: NestExpressApplication): void {
// proxy, so rate limits, API-key failure limits and IP allowlists act
// on a shared address instead of the client.
bootstrapLogger.warn(
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/base/backend/configmap.example.yaml',
);
}
// No ETags: they let caches and proxies revalidate (and keep) personal
+12 -1
View File
@@ -3,10 +3,13 @@ import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule } from '@nestjs/throttler';
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage';
import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
import { DomainEventsModule } from './domain-events/domain-events.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { RolesModule } from './roles/roles.module';
@@ -61,10 +64,18 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
ThrottlerModule.forRootAsync({
imports: [ThrottlerStorageModule],
inject: [PrismaThrottlerStorage],
useFactory: (storage: PrismaThrottlerStorage) => ({
throttlers: [{ name: 'default', ttl: 60000, limit: 100 }],
storage,
}),
}),
PrismaModule,
PermissionsModule,
AuditModule,
DomainEventsModule,
HealthModule,
UsersModule,
AuthModule,
@@ -43,6 +43,18 @@ describe('AuditLogService', () => {
expect(createArg.data.userId).toBe('user-1');
});
it('writes through the transaction client when one is given', async () => {
const tx = { auditLog: { create: jest.fn() } };
await service.record(
{ entityType: 'Inspection', entityId: 'i1', action: 'CREATE' },
tx as never,
);
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it('findAll filters by entity type and id', async () => {
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
+13 -3
View File
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
interface RecordAuditLogInput {
export interface RecordAuditLogInput {
entityType: string;
entityId: string;
action: AuditAction;
@@ -20,8 +21,17 @@ export interface AuditLogFilter {
export class AuditLogService {
constructor(private readonly prisma: PrismaService) {}
async record(input: RecordAuditLogInput): Promise<void> {
await this.prisma.auditLog.create({
/**
* With `tx` the entry commits or rolls back together with the change it
* describes - no audited change without its entry, and no entry for a
* change that never happened.
*/
async record(
input: RecordAuditLogInput,
tx?: Prisma.TransactionClient,
): Promise<void> {
const db: DbClient = tx ?? this.prisma;
await db.auditLog.create({
data: {
entityType: input.entityType,
entityId: input.entityId,
@@ -1,37 +1,114 @@
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import { Logger } from '@nestjs/common';
import { KEY_ID_PATTERN } from '../../common/crypto/key-ring';
import {
decryptMfaSecret,
encryptMfaSecret,
mfaSecretNeedsRekey,
} from './mfa-crypto.util';
// Written by the code before the key ring (AES key sha256(JWT_ACCESS_SECRET),
// no key id) with JWT_ACCESS_SECRET = 'x' * 40.
const LEGACY_SECRET =
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
const PLAINTEXT = 'JBSWY3DPEHPK3PXP';
describe('mfa-crypto.util', () => {
const originalSecret = process.env.JWT_ACCESS_SECRET;
const originalEnv = { ...process.env };
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40);
delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS;
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
process.env.JWT_ACCESS_SECRET = originalSecret;
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('round-trips a TOTP secret through encrypt/decrypt', () => {
const plaintext = 'JBSWY3DPEHPK3PXP';
const ciphertext = encryptMfaSecret(plaintext);
expect(ciphertext).not.toContain(plaintext);
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
it('round-trips a TOTP secret and prefixes the key id', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
expect(ciphertext).toMatch(KEY_ID_PATTERN);
expect(ciphertext).not.toContain(PLAINTEXT);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
});
it('produces a different ciphertext every time (random IV)', () => {
const plaintext = 'JBSWY3DPEHPK3PXP';
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext));
expect(encryptMfaSecret(PLAINTEXT)).not.toBe(encryptMfaSecret(PLAINTEXT));
});
it('no longer depends on JWT_ACCESS_SECRET', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
});
it('fails to decrypt with the wrong key', () => {
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
expect(() => decryptMfaSecret(ciphertext)).toThrow();
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
expect(() => decryptMfaSecret(ciphertext)).toThrow('unknown key id');
});
it('opens a secret sealed with MFA_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = process.env.MFA_ENCRYPTION_KEY;
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
expect(mfaSecretNeedsRekey(ciphertext)).toBe(true);
});
it('does not flag a secret sealed with the current key', () => {
expect(mfaSecretNeedsRekey(encryptMfaSecret(PLAINTEXT))).toBe(false);
});
it('still opens a legacy MFA secret encrypted with the JWT-derived key', () => {
expect(decryptMfaSecret(LEGACY_SECRET)).toBe(PLAINTEXT);
expect(mfaSecretNeedsRekey(LEGACY_SECRET)).toBe(true);
});
it('fails closed on a legacy secret once JWT_ACCESS_SECRET changed', () => {
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(() => decryptMfaSecret(LEGACY_SECRET)).toThrow(
'ciphertext failed authentication',
);
});
it('never retries a k…: value that fails authentication with the legacy key', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
const currentKeyId = encryptMfaSecret(PLAINTEXT).split(':')[0];
// A valid legacy ciphertext behind the current key id: only a retry with
// sha256(JWT_ACCESS_SECRET) could open it.
const disguised = `${currentKeyId}:${LEGACY_SECRET}`;
expect(() => decryptMfaSecret(disguised)).toThrow(
'ciphertext failed authentication',
);
expect(() => decryptMfaSecret(`k00000000:${LEGACY_SECRET}`)).toThrow(
'unknown key id',
);
expect(warn).not.toHaveBeenCalled();
});
it('fails on a tampered ciphertext', () => {
const [keyId, body] = encryptMfaSecret(PLAINTEXT).split(':');
const [iv, tag, data] = body.split('.');
const bytes = Buffer.from(data, 'base64');
bytes[0] ^= 0xff;
expect(() =>
decryptMfaSecret(`${keyId}:${iv}.${tag}.${bytes.toString('base64')}`),
).toThrow('ciphertext failed authentication');
});
it('rejects a malformed ciphertext', () => {
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
'Invalid MFA secret ciphertext',
'malformed ciphertext',
);
});
});
+21 -43
View File
@@ -1,53 +1,31 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
function getKey(): Buffer {
const secret = process.env.JWT_ACCESS_SECRET;
if (!secret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return createHash('sha256').update(secret).digest();
}
KEY_ID_PATTERN,
isSealedWithCurrentKey,
loadKeyRing,
openLegacyCiphertext,
openWithRing,
sealWithRing,
} from '../../common/crypto/key-ring';
/**
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
* are one-way hashed) so the server can compute the expected code on every
* login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret
* out of the DB in plaintext without introducing a new secret to manage.
* login. They are sealed with the MFA key ring (MFA_ENCRYPTION_KEY, see
* common/crypto/key-ring.ts). Secrets stored before the key ring have no key
* id and were encrypted with sha256(JWT_ACCESS_SECRET); they stay readable
* until the rekey job reseals them.
*/
export function encryptMfaSecret(plaintext: string): string {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return [iv, authTag, encrypted]
.map((buffer) => buffer.toString('base64'))
.join('.');
return sealWithRing(loadKeyRing('mfa'), plaintext);
}
export function decryptMfaSecret(ciphertext: string): string {
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
if (!ivB64 || !authTagB64 || !dataB64) {
throw new Error('Invalid MFA secret ciphertext');
}
const decipher = createDecipheriv(
ALGORITHM,
getKey(),
Buffer.from(ivB64, 'base64'),
);
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(dataB64, 'base64')),
decipher.final(),
]).toString('utf8');
export function decryptMfaSecret(stored: string): string {
return KEY_ID_PATTERN.test(stored)
? openWithRing(loadKeyRing('mfa'), stored).plaintext
: openLegacyCiphertext('mfa', stored);
}
/** True for secrets the rekey job should reseal with the current key. */
export function mfaSecretNeedsRekey(stored: string): boolean {
return !isSealedWithCurrentKey(loadKeyRing('mfa'), stored);
}
@@ -0,0 +1,41 @@
import {
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
varroaThresholdOf,
} from './varroa-threshold';
describe('varroaThresholdOf', () => {
it('uses the default when no rule exists yet (default rule is active)', () => {
expect(varroaThresholdOf(null)).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10);
});
it('returns null for an inactive rule', () => {
expect(
varroaThresholdOf({
active: false,
config: { thresholdMitesPerDay: 7 },
}),
).toBeNull();
});
it('returns the configured threshold of an active rule', () => {
expect(
varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }),
).toBe(7);
});
it.each([
['a string', { thresholdMitesPerDay: 'x' }],
['zero', { thresholdMitesPerDay: 0 }],
['a negative number', { thresholdMitesPerDay: -1 }],
['NaN', { thresholdMitesPerDay: Number.NaN }],
['a missing key', {}],
['a null config', null],
])('falls back to the default for %s', (_label, config) => {
expect(varroaThresholdOf({ active: true, config })).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
});
});
@@ -0,0 +1,30 @@
import type { AutomationRule } from '../../generated/prisma';
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10;
/**
* Effective varroa threshold of the VARROA_THRESHOLD rule.
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
* - inactive rule: null (no threshold in force)
* - active rule: the configured value, or the default if it is not a
* positive finite number (a hand-edited config must never disable the check).
*/
export function varroaThresholdOf(
rule: Pick<AutomationRule, 'active' | 'config'> | null,
): number | null {
if (rule === null) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
if (!rule.active) {
return null;
}
const config = rule.config;
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
@@ -1,5 +1,41 @@
import { BadRequestException } from '@nestjs/common';
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
import { BadRequestException, Logger } from '@nestjs/common';
import {
isSealedBackup,
openBackup,
sealBackup,
type SealedBackup,
} from './backup-crypto';
// Sealed by the code before the key ring, JWT_ACCESS_SECRET = 'x' * 40:
// LEGACY_JWT_BACKUP without BACKUP_ENCRYPTION_KEY (key derived from the JWT
// secret), KEY_BACKUP with BACKUP_ENCRYPTION_KEY = 'k' * 40.
const FIXTURE_BUNDLE = {
version: 3,
exportedAt: '2026-10-01T09:00:00.000Z',
data: { user: [{ id: 'u1' }] },
};
const LEGACY_JWT_BACKUP: SealedBackup = {
format: 'apicore-backup',
version: 3,
keyId: '6ac8404ff5ae',
exportedAt: '2026-10-01T09:00:00.000Z',
algorithm: 'A256GCM+gzip',
iv: 'vD+47m3e5IFJAexz',
tag: 'LOgrmURu9vAMalQmlduDpw==',
ciphertext:
'yvuAGhVALAxXkqZM8FpeiPnGHD9llp/SVtdQ6S58oS4ezussfP7Lqj8ZDuNbxabwjUP4QBJvVdEyhKCFZ9w5YRbCRjDozizJyE6YwjqiCsAXWPyVZ2lZ6Fo8/G+/0HsoQg==',
};
const KEY_BACKUP: SealedBackup = {
format: 'apicore-backup',
version: 3,
keyId: '14b11a29dc33',
exportedAt: '2026-10-01T09:00:00.000Z',
algorithm: 'A256GCM+gzip',
iv: 'NKe1zNJfRPyBqeYL',
tag: 'zU/oVHyV4bj5+wwF0lvGiA==',
ciphertext:
'ArjmmWq18F3OkPQYfx6x9XcjWdqP1SZC9zarUCRfV7bo6KRYx3pi5sNMRro6mg0eqlr4+C4jRKn3y+110kaWEFrY6J/ashF76pWSl1gzfiewkpxdqoyjkXRxrd4AnPrnfw==',
};
describe('backup-crypto', () => {
const originalEnv = { ...process.env };
@@ -12,10 +48,13 @@ describe('backup-crypto', () => {
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
delete process.env.BACKUP_ENCRYPTION_KEY;
delete process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS;
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('round-trips a bundle and hides its contents', () => {
@@ -45,7 +84,17 @@ describe('backup-crypto', () => {
).toThrow(BadRequestException);
});
it('rejects a file with a truncated auth tag', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
const tag = Buffer.from(sealed.tag, 'base64').subarray(0, 4);
expect(() =>
openBackup({ ...sealed, tag: tag.toString('base64') }),
).toThrow(BadRequestException);
});
it('rejects a file sealed with another key', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
@@ -64,4 +113,80 @@ describe('backup-crypto', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'short';
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
});
it('opens a backup sealed with BACKUP_ENCRYPTION_KEY_PREVIOUS', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS = 'k'.repeat(40);
expect(openBackup(sealed)).toEqual(bundle);
expect(sealBackup(bundle, bundle.exportedAt).keyId).not.toBe(sealed.keyId);
});
it('still opens files sealed before the key ring (file format v3 unchanged)', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
expect(openBackup(KEY_BACKUP)).toEqual(FIXTURE_BUNDLE);
expect(openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE);
});
it('only reads with the JWT-derived key, never seals with it once BACKUP_ENCRYPTION_KEY is set', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
expect(sealBackup(bundle, bundle.exportedAt).keyId).toBe(KEY_BACKUP.keyId);
});
it('fails closed on a JWT-derived backup once JWT_ACCESS_SECRET changed', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(() => openBackup(LEGACY_JWT_BACKUP)).toThrow(BadRequestException);
});
it('refuses to seal in production without BACKUP_ENCRYPTION_KEY', () => {
process.env.NODE_ENV = 'production';
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow(
'BACKUP_ENCRYPTION_KEY is not set',
);
});
it('warns once that a development key stands in for BACKUP_ENCRYPTION_KEY', () => {
jest.isolateModules(() => {
// A fresh module copy, so earlier tests cannot have used up the warning.
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./backup-crypto')>('./backup-crypto');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const sealed = fresh.sealBackup(bundle, bundle.exportedAt);
fresh.sealBackup(bundle, bundle.exportedAt);
fresh.openBackup(sealed);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
'BACKUP_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
);
});
});
it('does not warn when only reading a legacy file next to a configured key', () => {
jest.isolateModules(() => {
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./backup-crypto')>('./backup-crypto');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
expect(fresh.openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE);
expect(warn).not.toHaveBeenCalled();
});
});
});
+67 -54
View File
@@ -1,6 +1,17 @@
import { BadRequestException } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as zlib from 'node:zlib';
import {
ENCRYPTION_KEY_VARIABLES,
devFallbackSecret,
findRingKey,
gcmOpen,
gcmSeal,
readRingSecrets,
requireJwtAccessSecret,
type KeyRing,
type RingKey,
} from '../common/crypto/key-ring';
/**
* A backup holds everything - password hashes, encrypted MFA secrets,
@@ -9,17 +20,17 @@ import * as zlib from 'node:zlib';
* keeps it unreadable outside ApiCore AND makes any change to it (or a
* hand-made file) fail authentication before a single row is touched.
*
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
* can always restore its own backups. Either way the AES key comes from
* HKDF with a backup-specific label, never the raw secret.
* Keys follow the key ring (common/crypto/key-ring.ts): BACKUP_ENCRYPTION_KEY
* seals, BACKUP_ENCRYPTION_KEY_PREVIOUS still opens, and files sealed before
* the key was mandatory - with a key derived from JWT_ACCESS_SECRET - are
* still read (never written, except outside production without
* BACKUP_ENCRYPTION_KEY). The file names its key in `keyId`. Either way the
* AES key comes from HKDF with a backup-specific label, never the raw
* secret; derivation and file format v3 are unchanged, so older files open.
*/
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
export const SEALED_BACKUP_VERSION = 3;
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const MIN_KEY_LENGTH = 32;
// Decompressed size cap: a small file must not unpack into gigabytes.
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
@@ -35,25 +46,9 @@ export interface SealedBackup {
ciphertext: string;
}
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
if (backupKey) {
if (backupKey.length < MIN_KEY_LENGTH) {
throw new Error(
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
);
}
return { secret: backupKey, source: 'backup' };
}
const jwtSecret = process.env.JWT_ACCESS_SECRET;
if (!jwtSecret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return { secret: jwtSecret, source: 'jwt' };
}
type KeySource = 'backup' | 'jwt';
function deriveKey(): { key: Buffer; keyId: string } {
const { secret, source } = rootSecret();
function deriveBackupKey(secret: string, source: KeySource): RingKey {
const key = Buffer.from(
crypto.hkdfSync(
'sha256',
@@ -63,12 +58,35 @@ function deriveKey(): { key: Buffer; keyId: string } {
32,
),
);
const keyId = crypto
.createHash('sha256')
.update(key)
.digest('hex')
.slice(0, 12);
return { key, keyId };
const id = crypto.createHash('sha256').update(key).digest('hex').slice(0, 12);
return { id, key };
}
function legacyBackupKey(): RingKey {
return deriveBackupKey(requireJwtAccessSecret(), 'jwt');
}
function loadBackupRing(): KeyRing {
const variable = ENCRYPTION_KEY_VARIABLES.backup;
const secrets = readRingSecrets(variable);
return {
current: secrets.current
? deriveBackupKey(secrets.current, 'backup')
: deriveBackupKey(devFallbackSecret(variable), 'jwt'),
previous: secrets.previous
? deriveBackupKey(secrets.previous, 'backup')
: null,
};
}
/** The ring's keys first; the JWT-derived key of older files only as a last resort for reading. */
function keyForReading(keyId: string): RingKey | null {
const ringKey = findRingKey(loadBackupRing(), keyId);
if (ringKey) {
return ringKey;
}
const legacy = legacyBackupKey();
return legacy.id === keyId ? legacy : null;
}
function associatedData(
@@ -80,24 +98,21 @@ function associatedData(
}
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
const { key, keyId } = deriveKey();
const iv = crypto.randomBytes(IV_BYTES);
const { current } = loadBackupRing();
const header = {
format: SEALED_BACKUP_FORMAT,
version: SEALED_BACKUP_VERSION,
keyId,
keyId: current.id,
exportedAt,
} as const;
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
cipher.setAAD(associatedData(header));
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
const sealed = gcmSeal(current.key, compressed, associatedData(header));
return {
...header,
algorithm: 'A256GCM+gzip',
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
iv: sealed.iv.toString('base64'),
tag: sealed.tag.toString('base64'),
ciphertext: sealed.ciphertext.toString('base64'),
};
}
@@ -112,32 +127,30 @@ export function isSealedBackup(value: unknown): value is SealedBackup {
);
}
/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */
/** The bundle inside a sealed backup; throws if no known key made the file or it was changed. */
export function openBackup(sealed: SealedBackup): unknown {
if (sealed.version !== SEALED_BACKUP_VERSION) {
throw new BadRequestException(
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
);
}
const { key, keyId } = deriveKey();
if (sealed.keyId !== keyId) {
const ringKey = keyForReading(sealed.keyId);
if (!ringKey) {
throw new BadRequestException(
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY bzw. BACKUP_ENCRYPTION_KEY_PREVIOUS prüfen)',
);
}
let compressed: Buffer;
try {
const decipher = crypto.createDecipheriv(
ALGORITHM,
key,
Buffer.from(sealed.iv, 'base64'),
compressed = gcmOpen(
ringKey.key,
{
iv: Buffer.from(sealed.iv, 'base64'),
tag: Buffer.from(sealed.tag, 'base64'),
ciphertext: Buffer.from(sealed.ciphertext, 'base64'),
},
associatedData(sealed),
);
decipher.setAAD(associatedData(sealed));
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
compressed = Buffer.concat([
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
decipher.final(),
]);
} catch {
throw new BadRequestException(
'Backup-Datei ist beschädigt oder wurde verändert',
@@ -216,6 +216,27 @@ describe('BackupService', () => {
);
expect(documents.find((d) => d.id === 'doc-2')?.contentBase64).toBeNull();
});
it('exports and restores Inspection.broodFrames', async () => {
const row = {
id: 'inspection-1',
colonyId: 'colony-1',
userId: 'known-user',
broodFrames: 5,
};
prisma.inspection.findMany.mockResolvedValue([row]);
const bundle = await service.exportAll();
const exported = bundle.data.inspection as { broodFrames: number }[];
expect(exported[0]?.broodFrames).toBe(5);
await service.importAll(bundle, 'admin-1', TRUSTED);
const [[call]] = tx.inspection.upsert.mock.calls as [
[{ create: { broodFrames: number } }],
];
expect(call.create.broodFrames).toBe(5);
});
});
describe('importAll', () => {
@@ -127,6 +127,14 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.',
IdempotencyRecord:
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
DomainEvent:
'Operational outbox - side effects of restored data must never be replayed after a restore.',
DomainEventDelivery:
'Per-handler progress of DomainEvent rows, excluded with them.',
WebhookDelivery:
'Delivery log and retry queue - restoring it would resend old events to receivers.',
JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.",
ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.',
};
const DOCUMENT_ENTITY_TYPES = new Set<string>(
@@ -12,7 +12,10 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { CalendarSyncService } from './calendar-sync.service';
import {
CalendarSyncService,
CalendarSyncStatusReport,
} from './calendar-sync.service';
import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settings.dto';
@ApiTags('calendar-sync')
@@ -45,13 +48,27 @@ export class CalendarSyncController {
return this.calendarSyncService.getStatus();
}
// Starts a pass and answers at once (202) - the pass itself can take
// minutes. The returned status says running: true; GET status follows it.
// Queues a pass and answers at once (202): FAILED entries are reset and
// the worker is woken - the API itself never calls Graph. GET status
// reports running from the worker's live claims; until the worker has
// claimed its first entry, a due pass counts as running, so the client
// starts following it.
@RequirePermission('calendar:configure')
@Post('run-now')
@HttpCode(HttpStatus.ACCEPTED)
runNow() {
this.calendarSyncService.startRun();
return this.calendarSyncService.getStatus();
async runNow(): Promise<CalendarSyncStatusReport> {
await this.calendarSyncService.requestRun();
const status = await this.calendarSyncService.getStatus();
return { ...status, running: status.running || isPassDue(status) };
}
}
/** The woken worker syncs every PENDING entry - if syncing is set up. */
function isPassDue(status: CalendarSyncStatusReport): boolean {
return (
status.configured &&
status.enabled &&
Boolean(status.groupId) &&
status.pendingCount > 0
);
}
@@ -22,6 +22,7 @@ describe('CalendarSyncService', () => {
feeding: { findUnique: jest.Mock };
harvest: { findUnique: jest.Mock };
queenColonyHistory: { findUnique: jest.Mock };
$executeRaw: jest.Mock;
};
let settingsService: { get: jest.Mock };
let graphClient: {
@@ -93,6 +94,7 @@ describe('CalendarSyncService', () => {
feeding: { findUnique: jest.fn() },
harvest: { findUnique: jest.fn() },
queenColonyHistory: { findUnique: jest.fn() },
$executeRaw: jest.fn().mockResolvedValue(1),
};
settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) };
graphClient = {
@@ -284,6 +286,44 @@ describe('CalendarSyncService', () => {
);
});
// Worker shutdown: the entry in progress is finished and settled (a cut
// off one is only taken over after the stale-claim window), but no
// further entry is claimed.
it('stops between entries once the signal aborts', async () => {
const stop = new AbortController();
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
pendingEntry({ id: 'entry-2', sourceId: 'task-2' }),
]);
prisma.task.findUnique.mockResolvedValue(dueTask);
graphClient.createEvent.mockImplementation(() => {
stop.abort();
return Promise.resolve('graph-event-1');
});
await service.processOutbox(stop.signal);
expect(graphClient.createEvent).toHaveBeenCalledTimes(1);
expectSettled({
status: 'SYNCED',
graphEventId: 'graph-event-1',
lastError: null,
});
expect(updateManyCalls().map((call) => call.where.id)).not.toContain(
'entry-2',
);
});
it('reads nothing once the signal has aborted', async () => {
const stop = new AbortController();
stop.abort();
await service.processOutbox(stop.signal);
expect(settingsService.get).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
});
it('claims a row with a conditional update before working on it', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
@@ -626,22 +666,42 @@ describe('CalendarSyncService', () => {
});
});
describe('runNow', () => {
it('gives FAILED entries a fresh set of attempts before processing', async () => {
await service.runNow();
// "Jetzt synchronisieren" only queues the pass: the worker runs it, so the
// API request neither talks to Graph nor waits for up to 20 entries.
describe('requestRun', () => {
it('requestRun resets FAILED entries and wakes the worker without calling Graph', async () => {
await service.requestRun();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [readOrder] =
prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(readOrder);
expect(prisma.$executeRaw).toHaveBeenCalled(); // pg_notify('calendar_sync')
expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(graphClient.updateEvent).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
});
it('leaves FAILED entries alone on a regular cron tick', async () => {
it("wakes the calendar outbox with pg_notify('calendar_sync', '') - never data in the channel", async () => {
await service.requestRun();
const [[strings, ...values]] = prisma.$executeRaw.mock.calls as [
[TemplateStringsArray, ...unknown[]],
];
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
expect(values).toEqual(['calendar_sync']);
});
it('resets before waking, so the woken worker finds the entries PENDING', async () => {
await service.requestRun();
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [wakeOrder] = prisma.$executeRaw.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(wakeOrder);
});
it('leaves FAILED entries alone on a regular worker tick', async () => {
await service.processOutbox();
expect(
@@ -649,127 +709,7 @@ describe('CalendarSyncService', () => {
).not.toHaveBeenCalledWith(
expect.objectContaining({ where: { status: 'FAILED' } }),
);
});
});
// "Jetzt synchronisieren" must not hold the HTTP request for a whole pass
// (up to 20 entries with several Graph calls each): it only starts it.
describe('startRun', () => {
const flush = async () => {
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
};
/** Lets the pass hang on reading the outbox until released. */
const holdOutbox = () => {
let release: () => void = () => undefined;
prisma.calendarSyncOutboxEntry.findMany.mockReturnValue(
new Promise((resolve) => {
release = () => resolve([]);
}),
);
return () => release();
};
it('returns while the pass is still running and reports it as running', async () => {
const release = holdOutbox();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
expect((await service.getStatus()).running).toBe(true);
release();
await flush();
expect((await service.getStatus()).running).toBe(false);
});
it('starts only one pass per pod at a time', async () => {
const release = holdOutbox();
service.startRun();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
release();
await flush();
});
it('logs a failed pass without query values and clears the indicator', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
service.startRun();
await flush();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect((await service.getStatus()).running).toBe(false);
errorSpy.mockRestore();
});
it('lets shutdown wait for a running pass', async () => {
const release = holdOutbox();
service.startRun();
let destroyed = false;
const shutdown = service.onModuleDestroy().then(() => {
destroyed = true;
});
await flush();
expect(destroyed).toBe(false);
release();
await shutdown;
expect(destroyed).toBe(true);
});
});
// The cron tick's own errors would otherwise reach the scheduler's logger
// with the full Prisma message and stack.
describe('scheduledRun (cron tick)', () => {
it('runs the regular pass', async () => {
await service.scheduledRun();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
});
it('never throws and logs a failed tick without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
await expect(service.scheduledRun()).resolves.toBeUndefined();
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
errorSpy.mockRestore();
expect(prisma.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -794,8 +734,8 @@ describe('CalendarSyncService', () => {
});
});
// Another pod (its cron tick or its "Jetzt synchronisieren") may be the
// one working - its live claims show that across replicas.
// The pass runs in a worker process, never in the API that answers -
// only the worker's live claims can show it, across replicas.
it('reports running while any worker holds an entry', async () => {
prisma.calendarSyncOutboxEntry.count
.mockResolvedValueOnce(0)
@@ -1,9 +1,9 @@
import { createHash } from 'node:crypto';
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { runAfterCommit } from '../common/after-commit';
import { loggableError } from '../common/loggable-error';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import {
GraphClientService,
@@ -17,8 +17,8 @@ import {
Prisma,
} from '../../generated/prisma';
// Bounded per tick so a large backlog can't turn one cron run into a
// long-running request that overlaps the next tick.
// Bounded per tick so a large backlog can't turn one worker tick into a
// long-running pass that delays the other wake-ups.
const BATCH_SIZE = 20;
// A row that keeps failing this many times stops retrying automatically
// (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue
@@ -73,7 +73,7 @@ export interface CalendarSyncStatusReport {
pendingCount: number;
failedCount: number;
syncedCount: number;
/** A pass is under way - on this pod, or any pod holding an entry. */
/** A pass is under way: some worker holds an entry (a live claim). */
running: boolean;
}
@@ -82,10 +82,8 @@ function getErrorMessage(error: unknown): string {
}
@Injectable()
export class CalendarSyncService implements OnModuleDestroy {
export class CalendarSyncService {
private readonly logger = new Logger(CalendarSyncService.name);
// The pass started by "Jetzt synchronisieren" on this pod, if one runs.
private activeRun: Promise<void> | null = null;
constructor(
private readonly prisma: PrismaService,
@@ -209,20 +207,15 @@ export class CalendarSyncService implements OnModuleDestroy {
}
}
// The cron tick. An error is logged here, Prisma errors by name/code/model
// only (see loggableError) - left uncaught, the scheduler would log the
// full Prisma message and stack.
@Cron(CronExpression.EVERY_MINUTE)
async scheduledRun(): Promise<void> {
try {
await this.processOutbox();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Calendar sync tick failed: ${message}`, stack);
// One pass, run by the worker's calendar-outbox loop (worker/
// worker-runtime.service.ts): every 60 s and on a calendar_sync wake-up.
// The loop logs a failed pass via loggableError. Once `signal` aborts
// (worker shutdown), no further entry is claimed; the entry in progress
// is finished, since a cut-off one waits for the stale-claim window.
async processOutbox(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return;
}
}
async processOutbox(): Promise<void> {
const settings = await this.settingsService.get();
if (
!settings.enabled ||
@@ -232,15 +225,18 @@ export class CalendarSyncService implements OnModuleDestroy {
return;
}
// Several workers may run at once - one cron tick per pod, plus "Jetzt
// synchronisieren". Each entry is claimed right before it is written,
// so exactly one of them talks to Graph about it.
// Several workers may run at once - one loop per worker pod. Each entry
// is claimed right before it is written, so exactly one of them talks
// to Graph about it.
const candidates = await this.prisma.calendarSyncOutboxEntry.findMany({
where: { status: 'PENDING', ...claimableNow() },
orderBy: { updatedAt: 'asc' },
take: BATCH_SIZE,
});
for (const candidate of candidates) {
if (signal?.aborted) {
return;
}
const claimed = await this.claim(candidate);
if (claimed) {
await this.processEntry(claimed, settings.groupId);
@@ -248,41 +244,19 @@ export class CalendarSyncService implements OnModuleDestroy {
}
}
// "Jetzt synchronisieren" also gives FAILED entries a fresh set of
// attempts - besides editing the source record the documented way out of
// FAILED. A FAILED row is never claimed (settle() clears the claim with
// it), so this cannot pull a row from under a running worker. The regular
// cron tick leaves FAILED alone.
async runNow(): Promise<void> {
// "Jetzt synchronisieren": gives FAILED entries a fresh set of attempts -
// besides editing the source record the documented way out of FAILED -
// and wakes the worker, which runs the pass. The API never calls Graph
// for it, so the request returns at once. A FAILED row is never claimed
// (settle() clears the claim with it), so this cannot pull a row from
// under a running worker. The regular tick leaves FAILED alone. Reset
// first: the woken worker must find the entries PENDING.
async requestRun(): Promise<void> {
await this.prisma.calendarSyncOutboxEntry.updateMany({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
await this.processOutbox();
}
// What the API's "Jetzt synchronisieren" calls: starts runNow() in the
// background and returns at once - a pass over up to BATCH_SIZE entries
// with several Graph calls each must not hold the HTTP request. One such
// pass per pod at a time; passes on other pods (and the cron tick) stay
// safe through the per-entry claims. getStatus().running follows it.
startRun(): void {
if (!this.activeRun) {
this.activeRun = this.runNow()
.catch((error: unknown) => {
const { message, stack } = loggableError(error);
this.logger.error(`Calendar sync run failed: ${message}`, stack);
})
.finally(() => {
this.activeRun = null;
});
}
}
// A pass cut off mid-entry would only be retaken after STALE_CLAIM_MS -
// shutdown lets it finish (each Graph request is bounded by its timeout).
async onModuleDestroy(): Promise<void> {
await this.activeRun;
await notifyWorker(this.prisma, WAKE_CHANNELS.calendarSync);
}
// Compare-and-set on the row as it was read: the update only matches while
@@ -534,7 +508,8 @@ export class CalendarSyncService implements OnModuleDestroy {
this.prisma.calendarSyncOutboxEntry.count({
where: { status: 'SYNCED' },
}),
// Live claims: some worker is writing an entry right now.
// Live claims: some worker is writing an entry right now. The pass
// runs in a worker process, so nothing local can tell.
this.prisma.calendarSyncOutboxEntry.count({
where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } },
}),
@@ -546,7 +521,7 @@ export class CalendarSyncService implements OnModuleDestroy {
pendingCount,
failedCount,
syncedCount,
running: this.activeRun !== null || heldCount > 0,
running: heldCount > 0,
};
}
}
@@ -0,0 +1,370 @@
import { Logger } from '@nestjs/common';
import { createHash, hkdfSync } from 'node:crypto';
import {
KEY_ID_PATTERN,
isSealedWithCurrentKey,
legacyJwtKey,
loadKeyRing,
openLegacyCiphertext,
openWithRing,
readRingSecrets,
sealWithRing,
} from './key-ring';
const JWT_SECRET = 'j'.repeat(40);
const KEY_A = 'a'.repeat(40);
const KEY_B = 'b'.repeat(40);
// Written by the code before the key ring: AES-256-GCM with
// sha256(JWT_ACCESS_SECRET) as key, no key id. JWT_ACCESS_SECRET = 'x' * 40.
const LEGACY_JWT_SECRET = 'x'.repeat(40);
const LEGACY_CIPHERTEXT =
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
const LEGACY_PLAINTEXT = 'JBSWY3DPEHPK3PXP';
function expectedKeyId(secret: string, purpose: string): string {
const key = Buffer.from(
hkdfSync('sha256', secret, 'apicore-key-ring', `apicore-${purpose}-v1`, 32),
);
return `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
}
/** Applies `change` to one base64 part of `<id>:<iv>.<tag>.<data>`. */
function tamper(
stored: string,
part: 0 | 1 | 2,
change: (bytes: Buffer) => Buffer,
): string {
const [keyId, body] = stored.split(':');
const parts = body.split('.');
parts[part] = change(Buffer.from(parts[part], 'base64')).toString('base64');
return `${keyId}:${parts.join('.')}`;
}
function flipFirstByte(bytes: Buffer): Buffer {
const copy = Buffer.from(bytes);
copy[0] ^= 0xff;
return copy;
}
describe('key-ring', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('loadKeyRing', () => {
it('derives the key per purpose with HKDF and names it by its hash', () => {
const ring = loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A });
expect(ring.current.id).toBe(expectedKeyId(KEY_A, 'mfa'));
expect(ring.current.key).toHaveLength(32);
expect(ring.previous).toBeNull();
});
it('derives different keys for mfa and webhook from the same dev fallback', () => {
const env = { JWT_ACCESS_SECRET: JWT_SECRET };
const mfa = loadKeyRing('mfa', env);
const webhook = loadKeyRing('webhook', env);
expect(mfa.current.id).toBe(expectedKeyId(JWT_SECRET, 'mfa'));
expect(webhook.current.id).toBe(expectedKeyId(JWT_SECRET, 'webhook'));
expect(mfa.current.key.equals(webhook.current.key)).toBe(false);
expect(mfa.current.key.equals(legacyJwtKey(env))).toBe(false);
});
it('loads the previous key from *_PREVIOUS', () => {
const ring = loadKeyRing('webhook', {
WEBHOOK_ENCRYPTION_KEY: KEY_B,
WEBHOOK_ENCRYPTION_KEY_PREVIOUS: KEY_A,
});
expect(ring.current.id).toBe(expectedKeyId(KEY_B, 'webhook'));
expect(ring.previous?.id).toBe(expectedKeyId(KEY_A, 'webhook'));
});
it('requires the key in production instead of falling back', () => {
expect(() =>
loadKeyRing('webhook', {
NODE_ENV: 'production',
JWT_ACCESS_SECRET: JWT_SECRET,
}),
).toThrow('WEBHOOK_ENCRYPTION_KEY is not set');
});
it('refuses keys shorter than 32 characters, the previous one too', () => {
expect(() => loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: 'short' })).toThrow(
'MFA_ENCRYPTION_KEY must be at least 32 characters',
);
expect(() =>
loadKeyRing('mfa', {
MFA_ENCRYPTION_KEY: KEY_A,
MFA_ENCRYPTION_KEY_PREVIOUS: 'short',
}),
).toThrow('MFA_ENCRYPTION_KEY_PREVIOUS must be at least 32 characters');
});
it('needs JWT_ACCESS_SECRET for the dev fallback', () => {
expect(() => loadKeyRing('mfa', {})).toThrow(
'JWT_ACCESS_SECRET is not set',
);
});
});
describe('readRingSecrets', () => {
it('returns the configured secrets of one variable', () => {
expect(
readRingSecrets('BACKUP_ENCRYPTION_KEY', {
BACKUP_ENCRYPTION_KEY: KEY_A,
BACKUP_ENCRYPTION_KEY_PREVIOUS: KEY_B,
}),
).toEqual({ current: KEY_A, previous: KEY_B });
expect(readRingSecrets('BACKUP_ENCRYPTION_KEY', {})).toEqual({
current: null,
previous: null,
});
});
it('never puts the key into its errors', () => {
const shortKey = 'too-short-but-secret';
let message = '';
try {
readRingSecrets('BACKUP_ENCRYPTION_KEY', {
BACKUP_ENCRYPTION_KEY: shortKey,
});
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain('BACKUP_ENCRYPTION_KEY');
expect(message).not.toContain(shortKey);
});
});
describe('sealWithRing / openWithRing', () => {
const ring = loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A });
it('seals with the current key and prefixes its id', () => {
const stored = sealWithRing(ring, 's');
expect(stored).toMatch(KEY_ID_PATTERN);
expect(stored.startsWith(`${ring.current.id}:`)).toBe(true);
});
it('round-trips a value and reports the key it was sealed with', () => {
const stored = sealWithRing(ring, LEGACY_PLAINTEXT);
expect(stored).not.toContain(LEGACY_PLAINTEXT);
expect(openWithRing(ring, stored)).toEqual({
plaintext: LEGACY_PLAINTEXT,
keyId: ring.current.id,
});
});
it('uses a fresh IV for every value', () => {
expect(sealWithRing(ring, 's')).not.toBe(sealWithRing(ring, 's'));
});
it('opens a value sealed with the previous key', () => {
const stored = sealWithRing(ring, 'secret');
const rotated = loadKeyRing('mfa', {
MFA_ENCRYPTION_KEY: KEY_B,
MFA_ENCRYPTION_KEY_PREVIOUS: KEY_A,
});
expect(openWithRing(rotated, stored)).toEqual({
plaintext: 'secret',
keyId: ring.current.id,
});
});
it('tells whether a value is sealed with the current key', () => {
const rotated = loadKeyRing('mfa', {
MFA_ENCRYPTION_KEY: KEY_B,
MFA_ENCRYPTION_KEY_PREVIOUS: KEY_A,
});
expect(isSealedWithCurrentKey(rotated, sealWithRing(ring, 's'))).toBe(
false,
);
expect(isSealedWithCurrentKey(rotated, sealWithRing(rotated, 's'))).toBe(
true,
);
expect(isSealedWithCurrentKey(rotated, LEGACY_CIPHERTEXT)).toBe(false);
});
it('refuses an unknown key id', () => {
expect(() => openWithRing(ring, 'k00000000:a.b.c')).toThrow(
'unknown key id',
);
});
it('refuses a value sealed for another purpose', () => {
const webhookRing = loadKeyRing('webhook', {
WEBHOOK_ENCRYPTION_KEY: KEY_A,
});
expect(() =>
openWithRing(webhookRing, sealWithRing(ring, 'secret')),
).toThrow('unknown key id');
});
it('fails on a tampered ciphertext (GCM)', () => {
const stored = sealWithRing(ring, 'secret');
for (const part of [0, 1, 2] as const) {
expect(() =>
openWithRing(ring, tamper(stored, part, flipFirstByte)),
).toThrow('ciphertext failed authentication');
}
});
it('refuses a truncated auth tag', () => {
const stored = sealWithRing(ring, 'secret');
const truncated = tamper(stored, 1, (tag) => tag.subarray(0, 4));
expect(() => openWithRing(ring, truncated)).toThrow(
'malformed ciphertext',
);
});
it('fails with the right key id but a different key', () => {
const stored = sealWithRing(ring, 'secret');
const forged = {
current: { id: ring.current.id, key: Buffer.alloc(32, 1) },
previous: null,
};
expect(() => openWithRing(forged, stored)).toThrow(
'ciphertext failed authentication',
);
});
it('refuses values without key id or with a malformed body', () => {
expect(() => openWithRing(ring, LEGACY_CIPHERTEXT)).toThrow(
'missing key id',
);
expect(() => openWithRing(ring, `${ring.current.id}:abc`)).toThrow(
'malformed ciphertext',
);
});
it('never puts key or plaintext into its errors', () => {
const stored = sealWithRing(ring, 'plaintext-value');
const tampered = tamper(stored, 2, flipFirstByte);
let message = '';
try {
openWithRing(ring, tampered);
} catch (error) {
message = (error as Error).message;
}
expect(message).not.toBe('');
expect(message).not.toContain('plaintext-value');
expect(message).not.toContain(KEY_A);
expect(message).not.toContain(ring.current.key.toString('base64'));
});
});
describe('development fallback', () => {
it('warns once per purpose that a development key stands in, without values', () => {
jest.isolateModules(() => {
// A fresh module copy, so earlier tests cannot have used up the warning.
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./key-ring')>('./key-ring');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const env = { JWT_ACCESS_SECRET: JWT_SECRET };
fresh.loadKeyRing('mfa', env);
fresh.loadKeyRing('mfa', env);
fresh.loadKeyRing('webhook', env);
fresh.loadKeyRing('webhook', { ...env, WEBHOOK_ENCRYPTION_KEY: KEY_A });
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenNthCalledWith(
1,
'MFA_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
);
expect(warn).toHaveBeenNthCalledWith(
2,
'WEBHOOK_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
);
});
});
it('stays silent when the key is configured', () => {
jest.isolateModules(() => {
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./key-ring')>('./key-ring');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
fresh.loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A });
expect(warn).not.toHaveBeenCalled();
});
});
});
describe('legacy ciphertexts', () => {
const legacyEnv = { JWT_ACCESS_SECRET: LEGACY_JWT_SECRET };
it('takes sha256(JWT_ACCESS_SECRET) as the legacy key', () => {
expect(legacyJwtKey(legacyEnv)).toEqual(
createHash('sha256').update(LEGACY_JWT_SECRET).digest(),
);
});
it('still opens a value sealed before the key ring', () => {
expect(openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv)).toBe(
LEGACY_PLAINTEXT,
);
});
it('fails closed once JWT_ACCESS_SECRET changed', () => {
expect(() =>
openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, {
JWT_ACCESS_SECRET: JWT_SECRET,
}),
).toThrow('ciphertext failed authentication');
});
it('warns once per process and purpose, without values', () => {
jest.isolateModules(() => {
// A fresh module copy, so earlier tests cannot have used up the warning.
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./key-ring')>('./key-ring');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
fresh.openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv);
fresh.openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv);
fresh.warnLegacyRead('webhook');
fresh.warnLegacyRead('webhook');
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenNthCalledWith(
1,
'legacy ciphertext read – run the rekey job (mfa)',
);
expect(warn).toHaveBeenNthCalledWith(
2,
'legacy ciphertext read – run the rekey job (webhook)',
);
});
});
});
});
@@ -0,0 +1,286 @@
import { Logger } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createHash,
hkdfSync,
randomBytes,
} from 'node:crypto';
/**
* Secrets the server must read back (TOTP secrets, webhook signing secrets,
* backup files) are encrypted with a key of their own per purpose, never
* with a JWT secret. Each purpose has a key ring: the current key from
* `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still
* opens, so a key can be rotated without losing data. A sealed value names
* its key: `k<8 hex>:<iv>.<tag>.<data>` (base64 parts, AES-256-GCM).
*
* The AES key comes from HKDF-SHA256 with a per-purpose label, so even the
* same secret never yields the same key for two purposes. Outside
* production a missing variable falls back to JWT_ACCESS_SECRET (still per
* purpose, logged once); production refuses to start without the variables
* (runtime/startup-checks.ts).
*
* Values from before the key ring carry no key id and were encrypted with
* sha256(JWT_ACCESS_SECRET). They stay readable (never written) until the
* rekey job reseals them - and only as long as JWT_ACCESS_SECRET is unchanged.
*/
export const MIN_ENCRYPTION_KEY_LENGTH = 32;
export const KEY_ID_PATTERN = /^k[0-9a-f]{8}:/;
export const PREVIOUS_KEY_SUFFIX = '_PREVIOUS';
export const ENCRYPTION_KEY_VARIABLES = {
mfa: 'MFA_ENCRYPTION_KEY',
webhook: 'WEBHOOK_ENCRYPTION_KEY',
backup: 'BACKUP_ENCRYPTION_KEY',
} as const;
export type EncryptionKeyVariable =
(typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES];
/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */
export type SecretPurpose = 'mfa' | 'webhook';
export interface RingKey {
id: string;
key: Buffer;
}
export interface KeyRing {
current: RingKey;
previous: RingKey | null;
}
/** The configured secrets behind one ring, before key derivation. */
export interface RingSecrets {
current: string | null;
previous: string | null;
}
/** AES-256-GCM output, in parts, so each format chooses its own encoding. */
export interface GcmSealed {
iv: Buffer;
tag: Buffer;
ciphertext: Buffer;
}
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
const KEY_BYTES = 32;
const HKDF_SALT = 'apicore-key-ring';
const LEGACY_READ_WARNING = 'legacy ciphertext read – run the rekey job';
const DEV_FALLBACK_WARNING =
'not set – using development key derived from JWT_ACCESS_SECRET; never in production';
const logger = new Logger('KeyRing');
const issuedWarnings = new Set<string>();
/** Each warning once per process: they describe configuration, not events. */
function warnOnce(message: string): void {
if (issuedWarnings.has(message)) {
return;
}
issuedWarnings.add(message);
logger.warn(message);
}
function readKey(name: string, env: NodeJS.ProcessEnv): string | null {
const value = env[name];
if (!value) {
return null;
}
if (value.length < MIN_ENCRYPTION_KEY_LENGTH) {
throw new Error(
`${name} must be at least ${MIN_ENCRYPTION_KEY_LENGTH} characters - generate one with e.g. "openssl rand -hex 32"`,
);
}
return value;
}
/**
* `<variable>` and `<variable>_PREVIOUS`, each at least 32 characters when
* set. The current one is mandatory in production. Errors name the
* variable, never its value.
*/
export function readRingSecrets(
variable: EncryptionKeyVariable,
env: NodeJS.ProcessEnv = process.env,
): RingSecrets {
const current = readKey(variable, env);
const previous = readKey(`${variable}${PREVIOUS_KEY_SUFFIX}`, env);
if (!current && env.NODE_ENV === 'production') {
throw new Error(`${variable} is not set`);
}
return { current, previous };
}
export function requireJwtAccessSecret(
env: NodeJS.ProcessEnv = process.env,
): string {
const secret = env.JWT_ACCESS_SECRET;
if (!secret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return secret;
}
/**
* The stand-in secret for an unset `variable` outside production (in
* production readRingSecrets() has already refused). Logged once per
* variable, without values.
*/
export function devFallbackSecret(
variable: EncryptionKeyVariable,
env: NodeJS.ProcessEnv = process.env,
): string {
const secret = requireJwtAccessSecret(env);
warnOnce(`${variable} ${DEV_FALLBACK_WARNING}`);
return secret;
}
function deriveRingKey(purpose: SecretPurpose, secret: string): RingKey {
const key = Buffer.from(
hkdfSync('sha256', secret, HKDF_SALT, `apicore-${purpose}-v1`, KEY_BYTES),
);
const id = `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
return { id, key };
}
/** The ring of one purpose; outside production a missing key falls back to JWT_ACCESS_SECRET. */
export function loadKeyRing(
purpose: SecretPurpose,
env: NodeJS.ProcessEnv = process.env,
): KeyRing {
const variable = ENCRYPTION_KEY_VARIABLES[purpose];
const secrets = readRingSecrets(variable, env);
return {
current: deriveRingKey(
purpose,
secrets.current ?? devFallbackSecret(variable, env),
),
previous: secrets.previous
? deriveRingKey(purpose, secrets.previous)
: null,
};
}
export function findRingKey(ring: KeyRing, keyId: string): RingKey | null {
if (ring.current.id === keyId) {
return ring.current;
}
return ring.previous?.id === keyId ? ring.previous : null;
}
export function gcmSeal(
key: Buffer,
plaintext: Buffer,
associatedData?: Buffer,
): GcmSealed {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_BYTES,
});
if (associatedData) {
cipher.setAAD(associatedData);
}
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return { iv, tag: cipher.getAuthTag(), ciphertext };
}
/**
* Fails closed: a short tag (which GCM would otherwise accept), a wrong key
* or any changed byte throws, and the error carries no input.
*/
export function gcmOpen(
key: Buffer,
sealed: GcmSealed,
associatedData?: Buffer,
): Buffer {
if (sealed.iv.length !== IV_BYTES || sealed.tag.length !== AUTH_TAG_BYTES) {
throw new Error('malformed ciphertext');
}
try {
const decipher = createDecipheriv(ALGORITHM, key, sealed.iv, {
authTagLength: AUTH_TAG_BYTES,
});
if (associatedData) {
decipher.setAAD(associatedData);
}
decipher.setAuthTag(sealed.tag);
return Buffer.concat([
decipher.update(sealed.ciphertext),
decipher.final(),
]);
} catch {
throw new Error('ciphertext failed authentication');
}
}
function sealPayload(key: Buffer, plaintext: string): string {
const sealed = gcmSeal(key, Buffer.from(plaintext, 'utf8'));
return [sealed.iv, sealed.tag, sealed.ciphertext]
.map((part) => part.toString('base64'))
.join('.');
}
function openPayload(key: Buffer, payload: string): string {
const parts = payload.split('.');
if (parts.length !== 3) {
throw new Error('malformed ciphertext');
}
const [iv, tag, ciphertext] = parts.map((part) =>
Buffer.from(part, 'base64'),
);
return gcmOpen(key, { iv, tag, ciphertext }).toString('utf8');
}
/** `${id}:${iv}.${tag}.${data}`, always with the current key. */
export function sealWithRing(ring: KeyRing, plaintext: string): string {
return `${ring.current.id}:${sealPayload(ring.current.key, plaintext)}`;
}
export function openWithRing(
ring: KeyRing,
stored: string,
): { plaintext: string; keyId: string } {
if (!KEY_ID_PATTERN.test(stored)) {
throw new Error('missing key id');
}
const separator = stored.indexOf(':');
const keyId = stored.slice(0, separator);
const ringKey = findRingKey(ring, keyId);
if (!ringKey) {
throw new Error('unknown key id');
}
return {
plaintext: openPayload(ringKey.key, stored.slice(separator + 1)),
keyId,
};
}
/** False for anything the rekey job should reseal: previous key, legacy or plain values. */
export function isSealedWithCurrentKey(ring: KeyRing, stored: string): boolean {
return stored.startsWith(`${ring.current.id}:`);
}
/** sha256(JWT_ACCESS_SECRET), the key of values from before the key ring - only ever used to read. */
export function legacyJwtKey(env: NodeJS.ProcessEnv = process.env): Buffer {
return createHash('sha256').update(requireJwtAccessSecret(env)).digest();
}
/** Logs once per process and purpose that a pre-key-ring value was read, without the value. */
export function warnLegacyRead(purpose: SecretPurpose): void {
warnOnce(`${LEGACY_READ_WARNING} (${purpose})`);
}
/** Opens a pre-key-ring `<iv>.<tag>.<data>` value sealed with legacyJwtKey(). */
export function openLegacyCiphertext(
purpose: SecretPurpose,
payload: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const plaintext = openPayload(legacyJwtKey(env), payload);
warnLegacyRead(purpose);
return plaintext;
}
@@ -0,0 +1,26 @@
import type { NextFunction, Request, Response } from 'express';
import { noStoreMiddleware } from './no-store.middleware';
function run(path: string) {
const setHeader = jest.fn();
const next = jest.fn() as unknown as NextFunction;
noStoreMiddleware(
{ path } as Request,
{ setHeader } as unknown as Response,
next,
);
return { setHeader, next };
}
describe('noStoreMiddleware', () => {
it.each(['/api/v1/apiaries', '/api/v1/public/batches/WH-1'])(
'marks %s no-store',
(path) => {
const { setHeader, next } = run(path);
expect(setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(setHeader).toHaveBeenCalledWith('Pragma', 'no-cache');
expect(next).toHaveBeenCalledTimes(1);
},
);
});
@@ -1,22 +1,17 @@
import type { NextFunction, Request, Response } from 'express';
// The public origin pages of honey lots are the only answers meant for
// everyone; they may be cached briefly.
const CACHEABLE_PREFIX = '/api/v1/public/';
/**
* Personal data, tokens and exports must not end up in a browser's
* back/forward cache or a shared proxy. Every API answer is no-store unless
* it is one of the public, anonymous ones.
* back/forward cache or a shared proxy. Every API answer starts out no-store;
* the one anonymous origin page overrides this on its 200 only (see
* PublicController).
*/
export function noStoreMiddleware(
req: Request,
_req: Request,
res: Response,
next: NextFunction,
): void {
if (!req.path.startsWith(CACHEABLE_PREFIX)) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
}
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
next();
}
@@ -0,0 +1,204 @@
import { Prisma } from '../../../generated/prisma';
import { DbClient } from '../../prisma/db-client';
import { PrismaService } from '../../prisma/prisma.service';
import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
/**
* Claim, settle and stale release for every outbox table. One worker owns
* an entry from the claim (status PROCESSING, claimedAt = now()) until it
* settles it; settling is a compare-and-set on id + claimedAt, so a worker
* whose claim was released in the meantime changes nothing. Every time
* comparison and calculation runs in SQL against now() - the database
* clock - never against a JS Date.
*/
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
export interface ClaimOptions {
limit: number;
/** Extra condition, ANDed to the claim (e.g. a correlation id filter). */
filter?: Prisma.Sql;
orderBy: OutboxOrderColumn;
}
interface OutboxTableSql {
table: Prisma.Sql;
statusType: Prisma.Sql;
settledAtColumn: Prisma.Sql;
successStatus: 'DONE' | 'DELIVERED';
}
/**
* Table, enum and column names cannot be bound as parameters. They come
* only from these closed maps - never from caller input - which is what
* makes Prisma.raw safe here.
*/
const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
DomainEvent: {
table: Prisma.raw('"DomainEvent"'),
statusType: Prisma.raw('"DomainEventStatus"'),
settledAtColumn: Prisma.raw('"processedAt"'),
successStatus: 'DONE',
},
WebhookDelivery: {
table: Prisma.raw('"WebhookDelivery"'),
statusType: Prisma.raw('"WebhookDeliveryStatus"'),
settledAtColumn: Prisma.raw('"deliveredAt"'),
successStatus: 'DELIVERED',
},
};
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
occurredAt: Prisma.raw('"occurredAt"'),
createdAt: Prisma.raw('"createdAt"'),
};
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
function outboxTable(table: OutboxTable): OutboxTableSql {
if (!Object.hasOwn(OUTBOX_TABLES, table)) {
throw new Error(`Unknown outbox table "${String(table)}"`);
}
return OUTBOX_TABLES[table];
}
function orderColumn(column: OutboxOrderColumn): Prisma.Sql {
if (!Object.hasOwn(ORDER_COLUMNS, column)) {
throw new Error(`Unknown outbox order column "${String(column)}"`);
}
return ORDER_COLUMNS[column];
}
/**
* Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets
* several workers claim side by side without waiting for or overlapping
* each other. Returns the claimed rows (all columns) in claim order.
* Uses the root client: a claim must commit on its own.
*/
export async function claimBatch<Row>(
db: PrismaService,
table: OutboxTable,
options: ClaimOptions,
): Promise<Row[]> {
if (!Number.isInteger(options.limit) || options.limit < 1) {
throw new RangeError(
`Claim limit must be a positive integer, got ${options.limit}`,
);
}
const { table: name } = outboxTable(table);
const orderBy = orderColumn(options.orderBy);
const filter = options.filter
? Prisma.sql`AND ${options.filter}`
: Prisma.empty;
return db.$queryRaw<Row[]>(Prisma.sql`
WITH claimed AS (
UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now()
WHERE "id" IN (
SELECT "id" FROM ${name}
WHERE "status" = 'PENDING' AND "availableAt" <= now() ${filter}
ORDER BY ${orderBy}
LIMIT ${options.limit}
FOR UPDATE SKIP LOCKED
)
RETURNING *
)
SELECT * FROM claimed ORDER BY ${orderBy}`);
}
/** An entry a stale release put back (PENDING) or gave up on (FAILED). */
export interface ReleasedClaim {
id: string;
status: 'PENDING' | 'FAILED';
}
/**
* Puts entries whose claim is older than STALE_CLAIM_MS (their worker died)
* back to PENDING right away, with attempts + 1 - or to FAILED once that
* reaches MAX_ATTEMPTS, so an entry that kills its worker cannot loop
* forever. Returns the released entries, for follow-up work on FAILED ones.
*/
export async function releaseStaleClaimRows(
db: PrismaService,
table: OutboxTable,
): Promise<ReleasedClaim[]> {
const { table: name, statusType } = outboxTable(table);
return db.$queryRaw<ReleasedClaim[]>(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(CASE WHEN "attempts" + 1 >= ${MAX_ATTEMPTS} THEN 'FAILED' ELSE 'PENDING' END AS ${statusType}),
"attempts" = "attempts" + 1,
"claimedAt" = NULL,
"availableAt" = now(),
"lastError" = ${STALE_CLAIM_ERROR}
WHERE "status" = 'PROCESSING'
AND "claimedAt" < now() - (${STALE_CLAIM_MS} * interval '1 millisecond')
RETURNING "id", "status"`);
}
/** releaseStaleClaimRows for callers that only need the count. */
export async function releaseStaleClaims(
db: PrismaService,
table: OutboxTable,
): Promise<number> {
return (await releaseStaleClaimRows(db, table)).length;
}
/**
* Settles a claimed entry as done. Returns false if the claim was lost
* (released as stale, maybe claimed again) - the current owner settles it.
* lastError stays as history of earlier failures.
*/
export async function settleSuccess(
db: DbClient,
table: OutboxTable,
id: string,
claimedAt: Date,
status: 'DONE' | 'DELIVERED',
): Promise<boolean> {
const sql = outboxTable(table);
if (status !== sql.successStatus) {
throw new Error(`${table} settles as ${sql.successStatus}, not ${status}`);
}
const count = await db.$executeRaw(Prisma.sql`
UPDATE ${sql.table}
SET "status" = CAST(${status} AS ${sql.statusType}),
"claimedAt" = NULL,
${sql.settledAtColumn} = now()
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return count === 1;
}
/**
* Settles a claimed entry after a failed attempt: back to PENDING after
* retryDelayMs(attempts) by the database clock, or FAILED once attempts
* reaches MAX_ATTEMPTS. `lastError` must come from loggableError().
*
* The outcome also holds if the claim was lost: only a stale release
* changes a claimed row, and it applies the same attempts + 1 >=
* MAX_ATTEMPTS rule to the same attempt count.
*/
export async function settleFailure(
db: DbClient,
table: OutboxTable,
id: string,
claimedAt: Date,
attemptsBefore: number,
lastError: string,
): Promise<'retry' | 'failed'> {
const { table: name, statusType } = outboxTable(table);
const attempts = attemptsBefore + 1;
const outcome = attempts >= MAX_ATTEMPTS ? 'failed' : 'retry';
const nextAttempt =
outcome === 'retry'
? Prisma.sql`, "availableAt" = now() + (${retryDelayMs(attempts)} * interval '1 millisecond')`
: Prisma.empty;
await db.$executeRaw(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(${outcome === 'retry' ? 'PENDING' : 'FAILED'} AS ${statusType}),
"attempts" = "attempts" + 1,
"claimedAt" = NULL,
"lastError" = ${lastError}
${nextAttempt}
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return outcome;
}
@@ -0,0 +1,29 @@
import {
MAX_ATTEMPTS,
RETRY_DELAYS_MS,
retryDelayMs,
STALE_CLAIM_MS,
} from './retry-policy';
describe('retry policy', () => {
it('waits 30 s, 2 min, 10 min, 1 h and from the 5th failure on 6 h', () => {
expect([1, 2, 3, 4, 5, 6, 7].map(retryDelayMs)).toEqual([
30_000, 120_000, 600_000, 3_600_000, 21_600_000, 21_600_000, 21_600_000,
]);
});
it('gives up after 8 attempts and treats a claim older than 5 min as stale', () => {
expect(MAX_ATTEMPTS).toBe(8);
expect(STALE_CLAIM_MS).toBe(300_000);
expect(RETRY_DELAYS_MS).toEqual([
30_000, 120_000, 600_000, 3_600_000, 21_600_000,
]);
});
it.each([0, -1, 1.5, Number.NaN])(
'refuses %p failed attempts - a delay exists only after a failure',
(attempts) => {
expect(() => retryDelayMs(attempts)).toThrow(RangeError);
},
);
});
@@ -0,0 +1,23 @@
/**
* The one retry rule for every outbox (domain events, webhook deliveries):
* after the n-th failed attempt the entry waits RETRY_DELAYS_MS[n - 1],
* from the 5th failure on 6 h each; the MAX_ATTEMPTS-th failure ends in
* FAILED. A claim older than STALE_CLAIM_MS belongs to a worker that died.
* The delays are applied in SQL against now() (database clock), see
* outbox-claims.ts.
*/
export const RETRY_DELAYS_MS = [
30_000, 120_000, 600_000, 3_600_000, 21_600_000,
] as const;
export const MAX_ATTEMPTS = 8;
export const STALE_CLAIM_MS = 300_000;
/** Delay after `attempts` failed attempts (1 = the first failure). */
export function retryDelayMs(attempts: number): number {
if (!Number.isInteger(attempts) || attempts < 1) {
throw new RangeError(
`Retry delay needs a positive number of failed attempts, got ${attempts}`,
);
}
return RETRY_DELAYS_MS[Math.min(attempts, RETRY_DELAYS_MS.length) - 1];
}
@@ -0,0 +1,108 @@
import { PrismaService } from '../../prisma/prisma.service';
import { PrismaThrottlerStorage } from './prisma-throttler.storage';
describe('PrismaThrottlerStorage', () => {
const queryRaw = jest.fn<
Promise<unknown>,
[readonly string[], ...unknown[]]
>();
const prisma = { $queryRaw: queryRaw } as unknown as PrismaService;
const row = {
totalHits: 3,
timeToExpire: 42,
isBlocked: false,
timeToBlockExpire: 0,
};
const sqlText = (): string => queryRaw.mock.calls[0][0].join('?');
const sqlValues = (): unknown[] => queryRaw.mock.calls[0].slice(1);
beforeEach(() => {
queryRaw.mockReset();
queryRaw.mockResolvedValue([row]);
});
it('uses only the database clock (no JS dates in the statement)', async () => {
await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
);
expect(sqlValues().some((v) => v instanceof Date)).toBe(false);
expect(sqlText()).toContain('now()');
});
it('blocks on the very first hit when the limit is below 1', async () => {
await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
0,
60000,
'default',
);
expect(sqlText()).toMatch(/VALUES[\s\S]*?CASE\s+WHEN 1 > /);
});
it('runs exactly one statement as an upsert on the composite key', async () => {
await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
);
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(sqlText()).toContain('INSERT INTO "ThrottlerHit"');
expect(sqlText()).toContain('ON CONFLICT ("key", "throttlerName")');
expect(sqlText()).toContain('RETURNING');
});
it('prefixes the stored key with the namespace and passes the limits as parameters', async () => {
await new PrismaThrottlerStorage(prisma, 'ns-1').increment(
'abc',
60000,
10,
120000,
'default',
);
const values = sqlValues();
expect(values).toContain('ns-1:abc');
expect(values).toContain('default');
expect(values).toContain(60000);
expect(values).toContain(10);
expect(values).toContain(120000);
});
it('returns the record computed by the database', async () => {
const result = await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
);
expect(result).toEqual(row);
});
it('propagates database errors so the request fails closed', async () => {
queryRaw.mockRejectedValue(new Error('db down'));
await expect(
new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
),
).rejects.toThrow('db down');
});
});
@@ -0,0 +1,88 @@
import { Inject, Injectable } from '@nestjs/common';
import { ThrottlerStorage } from '@nestjs/throttler';
import { PrismaService } from '../../prisma/prisma.service';
// The package does not export the record type from its root.
type ThrottlerStorageRecord = Awaited<
ReturnType<ThrottlerStorage['increment']>
>;
/**
* Key prefix that separates counter sets inside the shared table. Production
* uses '' (all pods share one set); tests give every app its own value.
*/
export const THROTTLER_NAMESPACE = Symbol('THROTTLER_NAMESPACE');
/**
* Rate-limit counters in the UNLOGGED table "ThrottlerHit", so the limits hold
* across all api pods. One upsert statement does the whole bookkeeping, and
* every time comparison uses the database clock (now()), never a JS date.
* A database error is not caught here: the guard fails and the request is
* rejected (fail closed).
*/
@Injectable()
export class PrismaThrottlerStorage implements ThrottlerStorage {
constructor(
private readonly prisma: PrismaService,
@Inject(THROTTLER_NAMESPACE) private readonly namespace: string,
) {}
async increment(
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
): Promise<ThrottlerStorageRecord> {
const storedKey = `${this.namespace}:${key}`;
// ttl and blockDuration are milliseconds. In ON CONFLICT DO UPDATE every
// expression reads the OLD row: "active block" keeps the hits, an expired
// window or block starts again at 1, everything else counts up.
const rows = await this.prisma.$queryRaw<ThrottlerStorageRecord[]>`
INSERT INTO "ThrottlerHit" AS t ("key", "throttlerName", "hits", "expiresAt", "blockedUntil")
VALUES (
${storedKey}, ${throttlerName}, 1,
now() + (${ttl}::double precision * interval '1 millisecond'),
CASE
WHEN 1 > ${limit}::int
THEN now() + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
)
ON CONFLICT ("key", "throttlerName") DO UPDATE SET
"hits" = CASE
WHEN t."blockedUntil" > now() THEN t."hits"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN 1
ELSE t."hits" + 1
END,
"expiresAt" = CASE
WHEN t."blockedUntil" > now() THEN t."expiresAt"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL
THEN now() + (${ttl}::double precision * interval '1 millisecond')
ELSE t."expiresAt"
END,
"blockedUntil" = CASE
WHEN t."blockedUntil" > now() THEN t."blockedUntil"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN
CASE
WHEN 1 > ${limit}::int
THEN now() + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
WHEN t."hits" + 1 > ${limit}::int
THEN now() + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
RETURNING
"hits"::int AS "totalHits",
GREATEST(ceil(EXTRACT(EPOCH FROM ("expiresAt" - now()))), 0)::int AS "timeToExpire",
COALESCE("blockedUntil" > now(), false) AS "isBlocked",
CASE
WHEN "blockedUntil" > now()
THEN GREATEST(ceil(EXTRACT(EPOCH FROM ("blockedUntil" - now()))), 0)::int
ELSE 0
END AS "timeToBlockExpire"
`;
return rows[0];
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import {
PrismaThrottlerStorage,
THROTTLER_NAMESPACE,
} from './prisma-throttler.storage';
@Module({
providers: [
// Production: one shared counter set for all pods.
{ provide: THROTTLER_NAMESPACE, useValue: '' },
PrismaThrottlerStorage,
],
exports: [PrismaThrottlerStorage],
})
export class ThrottlerStorageModule {}
@@ -22,6 +22,7 @@ import { UpdateQueenEvaluationDto } from '../../queens/dto/update-queen-evaluati
import { UpdateQueenDto } from '../../queens/dto/update-queen.dto';
import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto';
import { UpdateRoleDto } from '../../roles/dto/update-role.dto';
import { UpdateSiteSettingsDto } from '../../site-settings/dto/update-site-settings.dto';
import { UpdateStockItemDto } from '../../storage/dto/update-stock-item.dto';
import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto';
import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto';
@@ -53,6 +54,7 @@ const contracts: Record<string, NullContract> = {
'longitude',
'address',
'notes',
'publicLocationLabel',
],
},
UpdateAutomationRuleDto: {
@@ -152,6 +154,7 @@ const contracts: Record<string, NullContract> = {
'weather',
'temperature',
'colonyStrength',
'broodFrames',
'broodAmount',
'broodPattern',
'openBroodPresent',
@@ -243,6 +246,13 @@ const contracts: Record<string, NullContract> = {
notNull: ['key', 'name', 'permissionKeys'],
clearable: ['description'],
},
// The link fields clear with "" (see the DTO), not null; originCountry is
// NOT NULL with a default.
UpdateSiteSettingsDto: {
dto: UpdateSiteSettingsDto,
notNull: ['originCountry'],
clearable: [],
},
UpdateStockItemDto: {
dto: UpdateStockItemDto,
notNull: ['name', 'category', 'unit', 'quantity'],
@@ -0,0 +1,37 @@
import { withTimeout } from './with-timeout';
describe('withTimeout', () => {
afterEach(() => {
jest.useRealTimers();
});
it('resolves with the value when the promise settles in time', async () => {
await expect(withTimeout(Promise.resolve(42), 1000, 'late')).resolves.toBe(
42,
);
});
it('rejects with the original error when the promise rejects in time', async () => {
await expect(
withTimeout(Promise.reject(new Error('boom')), 1000, 'late'),
).rejects.toThrow('boom');
});
it('rejects with the given message when the promise takes too long', async () => {
jest.useFakeTimers();
const pending = withTimeout(
new Promise<never>(() => undefined),
500,
'too slow',
);
const assertion = expect(pending).rejects.toThrow('too slow');
await jest.advanceTimersByTimeAsync(500);
await assertion;
});
it('clears its timer once the promise settled', async () => {
jest.useFakeTimers();
await withTimeout(Promise.resolve(1), 500, 'late');
expect(jest.getTimerCount()).toBe(0);
});
});
@@ -0,0 +1,16 @@
/** Rejects with `message` if `promise` has not settled within `ms`. */
export async function withTimeout<T>(
promise: Promise<T>,
ms: number,
message: string,
): Promise<T> {
let timer: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timer!);
}
}
@@ -0,0 +1,24 @@
import { DbClient } from '../prisma/db-client';
/** LISTEN/NOTIFY channels the worker loops wait on, one per outbox. */
export const WAKE_CHANNELS = {
domainEvents: 'domain_event',
calendarSync: 'calendar_sync',
webhookDeliveries: 'webhook_delivery',
} as const;
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
/**
* Wakes the worker loop behind `channel` before its next poll. The payload
* is always empty: the loop reads its work from the table, so nothing can
* leak through the channel or be slipped in as work. On a transaction
* client Postgres sends the notification only on commit - a rolled-back
* write wakes nobody. A lost notification only delays work until the poll.
*/
export async function notifyWorker(
db: DbClient,
channel: WakeChannel,
): Promise<void> {
await db.$executeRaw`SELECT pg_notify(${channel}, '')`;
}
@@ -24,6 +24,7 @@ import { isAllowedDocumentMimeType } from './document-mime-types';
import { CreateDocumentDto } from './dto/create-document.dto';
import { UpdateDocumentDto } from './dto/update-document.dto';
import { DocumentsService } from './documents.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
@@ -60,6 +61,7 @@ export class DocumentsController {
}
@RequirePermission('documents:upload')
@Idempotent()
@Post()
@ApiConsumes('multipart/form-data')
@UseInterceptors(
@@ -0,0 +1,10 @@
import { AsyncLocalStorage } from 'node:async_hooks';
/**
* Set by the dispatcher while a handler runs: events the handler records
* inherit the correlation id of the event being handled, so one business
* action and all its follow-up events can be traced together.
*/
export const domainEventContext = new AsyncLocalStorage<{
correlationId: string;
}>();
@@ -0,0 +1,589 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { domainEventContext } from './domain-event-context';
import {
DOMAIN_EVENT_BATCH_SIZE,
DomainEventDispatcher,
DRAIN_MAX_ROUNDS,
HANDLER_TX_TIMEOUT_MS,
} from './domain-event-dispatcher';
import type {
DomainEventRegistry,
RegisteredDomainEventHandler,
} from './domain-event-registry';
interface ClaimedRow {
id: string;
type: string;
aggregateType: string;
aggregateId: string;
payload: unknown;
actorUserId: string | null;
correlationId: string;
occurredAt: Date;
attempts: number;
claimedAt: Date;
}
type DeliveryStatus = 'PENDING' | 'DONE' | 'FAILED';
interface UpsertArgs {
where: unknown;
create: { lastError: string };
update: { lastError: string };
}
const CLAIMED_AT = new Date('2026-10-13T08:00:01.234Z');
function row(overrides: Partial<ClaimedRow> = {}): ClaimedRow {
return {
id: 'e1',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
actorUserId: null,
correlationId: 'corr-1',
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
attempts: 0,
claimedAt: CLAIMED_AT,
...overrides,
};
}
function handler(
id: string,
invoke: RegisteredDomainEventHandler['invoke'] = () => Promise.resolve(),
): RegisteredDomainEventHandler & { invoke: jest.Mock } {
return { id, invoke: jest.fn(invoke) };
}
function text(query: Prisma.Sql): string {
return query.sql.replace(/\s+/g, ' ').trim();
}
/**
* Stands in for Postgres: a queue of claim results, the delivery rows the
* lock query finds, and every statement the dispatcher sent.
*/
class FakeDb {
readonly claims: ClaimedRow[][] = [];
/** Rows the stale release returns, one list per call. */
readonly releases: { id: string; status: 'PENDING' | 'FAILED' }[][] = [];
readonly deliveries = new Map<string, DeliveryStatus>();
readonly tx = {
domainEventDelivery: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
},
$queryRaw: jest.fn((query: Prisma.Sql) => {
const handlerId = query.values[1] as string;
return Promise.resolve([
{
id: `d-${handlerId}`,
status: this.deliveries.get(handlerId) ?? 'PENDING',
},
]);
}),
$executeRaw: jest.fn().mockResolvedValue(1),
};
readonly prisma = {
$queryRaw: jest.fn((query: Prisma.Sql) =>
Promise.resolve(
(query.sql.includes('SKIP LOCKED')
? this.claims.shift()
: this.releases.shift()) ?? [],
),
),
$executeRaw: jest.fn().mockResolvedValue(1),
// Called as (fn, { timeout }); the options only matter to Prisma.
$transaction: jest.fn((fn: (tx: unknown) => Promise<unknown>) =>
fn(this.tx),
),
domainEventDelivery: {
upsert: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
};
claimQuery(call = 0): Prisma.Sql {
return (this.prisma.$queryRaw.mock.calls as unknown as [Prisma.Sql][])[
call
][0];
}
/** Statements on the root client: settle and stale release. */
rootStatements(): Prisma.Sql[] {
return (this.prisma.$executeRaw.mock.calls as [Prisma.Sql][]).map(
([query]) => query,
);
}
/** Statements inside handler transactions after the lock: delivery DONE. */
deliveryDoneStatements(): Prisma.Sql[] {
return (this.tx.$executeRaw.mock.calls as [Prisma.Sql][]).map(
([query]) => query,
);
}
upsertArgs(call = 0): UpsertArgs {
return (
this.prisma.domainEventDelivery.upsert.mock.calls as [UpsertArgs][]
)[call][0];
}
}
describe('DomainEventDispatcher', () => {
let db: FakeDb;
let handlers: RegisteredDomainEventHandler[];
let dispatcher: DomainEventDispatcher;
let logWarn: jest.SpyInstance;
beforeEach(() => {
db = new FakeDb();
handlers = [];
const registry = {
handlersFor: jest.fn(() => handlers),
} as unknown as DomainEventRegistry;
dispatcher = new DomainEventDispatcher(
db.prisma as unknown as PrismaService,
registry,
);
logWarn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('pins batch size, handler transaction timeout and drain rounds', () => {
expect(DOMAIN_EVENT_BATCH_SIZE).toBe(50);
expect(HANDLER_TX_TIMEOUT_MS).toBe(10_000);
expect(DRAIN_MAX_ROUNDS).toBe(20);
});
it('claims at most 50 pending events, oldest first, skipping locked rows, by the database clock', async () => {
await dispatcher.runOnce();
const claim = db.claimQuery();
expect(text(claim)).toContain(
`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`,
);
expect(text(claim)).toContain(
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`,
);
expect(text(claim)).toContain(
'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED',
);
expect(claim.values).toEqual([50]);
});
it('restricts the claim to the given correlation ids', async () => {
await dispatcher.runOnce({ correlationIds: ['c1', 'c2'] });
const claim = db.claimQuery();
expect(text(claim)).toContain(
`"availableAt" <= now() AND "correlationId" = ANY(?) ORDER BY`,
);
expect(claim.values).toEqual([['c1', 'c2'], 50]);
});
it('marks an event DONE when every handler succeeded', async () => {
handlers = [handler('a.first'), handler('b.second')];
db.claims.push([row()]);
await expect(dispatcher.runOnce()).resolves.toBe(1);
expect(handlers[0].invoke).toHaveBeenCalledTimes(1);
expect(handlers[1].invoke).toHaveBeenCalledTimes(1);
expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([
['d-a.first'],
['d-b.second'],
]);
const [settle] = db.rootStatements();
expect(text(settle)).toContain('"processedAt" = now()');
expect(text(settle)).toContain(
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
);
expect(settle.values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('marks an event without handlers DONE', async () => {
db.claims.push([row({ type: 'VarroaMeasured' })]);
await dispatcher.runOnce();
expect(db.prisma.$transaction).not.toHaveBeenCalled();
expect(db.rootStatements()[0].values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('marks the delivery DONE with attempts + 1 in the handler transaction, by the database clock', async () => {
handlers = [handler('a.first')];
db.claims.push([row()]);
await dispatcher.runOnce();
const [done] = db.deliveryDoneStatements();
expect(text(done)).toBe(
`UPDATE "DomainEventDelivery" SET "status" = 'DONE', "attempts" = "attempts" + 1, "completedAt" = now(), "updatedAt" = now() WHERE "id" = ?`,
);
});
it('retries only the failed handler and schedules the event 30 s ahead after the first failure', async () => {
const ok = handler('a.ok');
const flaky = handler('b.flaky');
flaky.invoke.mockRejectedValueOnce(new Error('boom'));
handlers = [ok, flaky];
db.claims.push([row({ attempts: 0 })]);
await dispatcher.runOnce();
expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([
['d-a.ok'],
]);
expect(db.prisma.domainEventDelivery.upsert).toHaveBeenCalledWith({
where: { eventId_handler: { eventId: 'e1', handler: 'b.flaky' } },
create: {
eventId: 'e1',
handler: 'b.flaky',
attempts: 1,
lastError: 'boom',
},
update: { attempts: { increment: 1 }, lastError: 'boom' },
});
const [settle] = db.rootStatements();
expect(text(settle)).toContain(
`"availableAt" = now() + (? * interval '1 millisecond')`,
);
expect(text(settle)).toContain('"attempts" = "attempts" + 1');
expect(settle.values).toEqual([
'PENDING',
'b.flaky: boom',
30_000,
'e1',
CLAIMED_AT,
]);
expect(db.prisma.domainEventDelivery.updateMany).not.toHaveBeenCalled();
// The next run: a.ok committed its delivery, so only b.flaky runs again.
db.deliveries.set('a.ok', 'DONE');
db.claims.push([row({ attempts: 1 })]);
await dispatcher.runOnce();
expect(ok.invoke).toHaveBeenCalledTimes(1);
expect(flaky.invoke).toHaveBeenCalledTimes(2);
expect(db.rootStatements()[1].values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('marks the event and its open deliveries FAILED after the 8th failed attempt', async () => {
handlers = [handler('a.broken', () => Promise.reject(new Error('boom')))];
db.claims.push([row({ attempts: 7 })]);
await dispatcher.runOnce();
const [settle] = db.rootStatements();
expect(settle.values).toEqual([
'FAILED',
'a.broken: boom',
'e1',
CLAIMED_AT,
]);
expect(text(settle)).not.toContain('interval');
expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledWith({
where: { eventId: { in: ['e1'] }, status: 'PENDING' },
data: { status: 'FAILED' },
});
});
it('keeps the first failure as lastError when several handlers fail', async () => {
handlers = [
handler('a.first', () => Promise.reject(new Error('one'))),
handler('b.second', () => Promise.reject(new Error('two'))),
];
db.claims.push([row()]);
await dispatcher.runOnce();
expect(db.prisma.domainEventDelivery.upsert).toHaveBeenCalledTimes(2);
expect(db.rootStatements()[0].values).toContain('a.first: one');
});
it('skips a handler whose delivery is already DONE', async () => {
handlers = [handler('a.done'), handler('b.open')];
db.deliveries.set('a.done', 'DONE');
db.claims.push([row()]);
await dispatcher.runOnce();
expect(handlers[0].invoke).not.toHaveBeenCalled();
expect(handlers[1].invoke).toHaveBeenCalledTimes(1);
expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([
['d-b.open'],
]);
expect(db.rootStatements()[0].values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('runs the handler again when its delivery FAILED before (manual retry)', async () => {
handlers = [handler('a.failed-before')];
db.deliveries.set('a.failed-before', 'FAILED');
db.claims.push([row()]);
await dispatcher.runOnce();
expect(handlers[0].invoke).toHaveBeenCalledTimes(1);
});
it('runs the handler inside the delivery transaction with the event correlation id', async () => {
const seen: unknown[] = [];
handlers = [
handler('a.first', (event, tx) => {
seen.push(event, tx, domainEventContext.getStore());
return Promise.resolve();
}),
];
db.claims.push([row({ correlationId: 'corr-42' })]);
await dispatcher.runOnce();
expect(db.prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: HANDLER_TX_TIMEOUT_MS,
});
expect(seen[1]).toBe(db.tx);
expect(seen[2]).toEqual({ correlationId: 'corr-42' });
expect(seen[0]).toEqual({
id: 'e1',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
actorUserId: null,
correlationId: 'corr-42',
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
attempts: 0,
});
expect(domainEventContext.getStore()).toBeUndefined();
// Delivery row first (idempotent), then the row lock, then the handler.
expect(db.tx.domainEventDelivery.createMany).toHaveBeenCalledWith({
data: [{ eventId: 'e1', handler: 'a.first' }],
skipDuplicates: true,
});
const [lock] = db.tx.$queryRaw.mock.calls[0];
expect(text(lock)).toBe(
'SELECT "id", "status" FROM "DomainEventDelivery" WHERE "eventId" = ? AND "handler" = ? FOR UPDATE',
);
expect(lock.values).toEqual(['e1', 'a.first']);
expect(
db.tx.domainEventDelivery.createMany.mock.invocationCallOrder[0],
).toBeLessThan(db.tx.$queryRaw.mock.invocationCallOrder[0]);
expect(db.tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
(handlers[0].invoke as jest.Mock).mock.invocationCallOrder[0],
);
});
it('advances lastTickAt after every dispatched event, so a long batch never looks stale', async () => {
const start = new Date('2026-10-13T08:00:00.000Z');
jest.useFakeTimers({
now: start,
doNotFake: [
'hrtime',
'nextTick',
'performance',
'queueMicrotask',
'setImmediate',
'clearImmediate',
'setInterval',
'clearInterval',
'setTimeout',
'clearTimeout',
],
});
const seen: (Date | null)[] = [];
// Every event takes 100 s, e.g. waiting on locks.
handlers = [
handler('a.slow', () => {
seen.push(dispatcher.lastTickAt);
jest.setSystemTime(Date.now() + 100_000);
return Promise.resolve();
}),
];
db.claims.push([row({ id: 'e1' }), row({ id: 'e2' }), row({ id: 'e3' })]);
try {
await dispatcher.runOnce();
} finally {
jest.useRealTimers();
}
expect(seen.map((tick) => tick?.toISOString())).toEqual([
'2026-10-13T08:00:00.000Z',
'2026-10-13T08:01:40.000Z',
'2026-10-13T08:03:20.000Z',
]);
expect(dispatcher.lastTickAt?.toISOString()).toBe(
'2026-10-13T08:05:00.000Z',
);
});
it('updates lastTickAt even when nothing was claimed', async () => {
expect(dispatcher.lastTickAt).toBeNull();
await expect(dispatcher.runOnce()).resolves.toBe(0);
expect(dispatcher.lastTickAt).toBeInstanceOf(Date);
});
it('stores lastError without the Prisma message', async () => {
handlers = [
handler('a.db', () =>
Promise.reject(
new Prisma.PrismaClientKnownRequestError(
"Can't reach database server at secret-host:5432",
{ code: 'P1001', clientVersion: '7.9.1' },
),
),
),
];
db.claims.push([row()]);
await dispatcher.runOnce();
const upsert = db.upsertArgs();
expect(upsert.create.lastError).toBe('PrismaClientKnownRequestError P1001');
expect(upsert.update.lastError).toBe('PrismaClientKnownRequestError P1001');
const settleValues = db.rootStatements()[0].values;
expect(settleValues).toContain('a.db: PrismaClientKnownRequestError P1001');
expect(JSON.stringify(settleValues)).not.toContain('secret-host');
});
it('keeps dispatching the batch when one event cannot be settled', async () => {
db.claims.push([row({ id: 'e1' }), row({ id: 'e2' })]);
db.prisma.$executeRaw.mockRejectedValueOnce(new Error('connection lost'));
await expect(dispatcher.runOnce()).resolves.toBe(2);
expect(db.rootStatements().map((query) => query.values[1])).toEqual([
'e1',
'e2',
]);
});
it('only logs when the claim was lost before settling (stale release)', async () => {
db.claims.push([row()]);
db.prisma.$executeRaw.mockResolvedValueOnce(0);
await expect(dispatcher.runOnce()).resolves.toBe(1);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('e1'));
});
describe('drain', () => {
it('repeats rounds until a round claims nothing and returns the total', async () => {
db.claims.push(
[row({ id: 'e1' }), row({ id: 'e2' })],
[row({ id: 'e3' })],
);
await expect(dispatcher.drain({ correlationIds: ['c1'] })).resolves.toBe(
3,
);
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(3);
expect(db.claimQuery(2).values).toEqual([['c1'], 50]);
});
it('stops after maxRounds even if work remains', async () => {
for (let i = 0; i < 5; i++) {
db.claims.push([row({ id: `e${i}` })]);
}
await expect(dispatcher.drain(undefined, 2)).resolves.toBe(2);
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(2);
});
it('makes at most 20 rounds by default', async () => {
for (let i = 0; i < 25; i++) {
db.claims.push([row({ id: `e${i}` })]);
}
await expect(dispatcher.drain()).resolves.toBe(DRAIN_MAX_ROUNDS);
});
// Worker shutdown: the claimed batch is finished (its events would
// otherwise sit PROCESSING until the stale-claim release), but no
// further batch is claimed.
it('finishes the claimed batch but claims no further one once the signal aborts', async () => {
const stop = new AbortController();
handlers = [
handler('a.stopper', () => {
stop.abort();
return Promise.resolve();
}),
];
db.claims.push(
[row({ id: 'e1' }), row({ id: 'e2' })],
[row({ id: 'e3' })],
);
await expect(
dispatcher.drain(undefined, DRAIN_MAX_ROUNDS, stop.signal),
).resolves.toBe(2);
expect(handlers[0].invoke).toHaveBeenCalledTimes(2);
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(1);
});
it('claims nothing once the signal has aborted', async () => {
const stop = new AbortController();
stop.abort();
db.claims.push([row()]);
await expect(
dispatcher.drain(undefined, DRAIN_MAX_ROUNDS, stop.signal),
).resolves.toBe(0);
expect(db.prisma.$queryRaw).not.toHaveBeenCalled();
});
});
it('releases stale claims with attempts + 1, FAILED from the 8th attempt on', async () => {
db.releases.push([
{ id: 'e1', status: 'PENDING' },
{ id: 'e2', status: 'PENDING' },
{ id: 'e3', status: 'PENDING' },
]);
await expect(dispatcher.releaseStaleClaims()).resolves.toBe(3);
const [release] = db.prisma.$queryRaw.mock.calls[0];
expect(text(release)).toBe(
`UPDATE "DomainEvent" SET "status" = CAST(CASE WHEN "attempts" + 1 >= ? THEN 'FAILED' ELSE 'PENDING' END AS "DomainEventStatus"), "attempts" = "attempts" + 1, "claimedAt" = NULL, "availableAt" = now(), "lastError" = ? WHERE "status" = 'PROCESSING' AND "claimedAt" < now() - (? * interval '1 millisecond') RETURNING "id", "status"`,
);
expect(release.values).toEqual([
8,
'claim expired (worker restarted?)',
300_000,
]);
expect(db.prisma.domainEventDelivery.updateMany).not.toHaveBeenCalled();
});
it('marks the open deliveries FAILED when a stale release ends an event in FAILED', async () => {
db.releases.push([
{ id: 'e1', status: 'FAILED' },
{ id: 'e2', status: 'PENDING' },
{ id: 'e3', status: 'FAILED' },
]);
await expect(dispatcher.releaseStaleClaims()).resolves.toBe(3);
expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledTimes(1);
expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledWith({
where: { eventId: { in: ['e1', 'e3'] }, status: 'PENDING' },
data: { status: 'FAILED' },
});
});
});
@@ -0,0 +1,269 @@
import { Injectable, Logger } from '@nestjs/common';
import { DomainEventDeliveryStatus, Prisma } from '../../generated/prisma';
import { loggableError } from '../common/loggable-error';
import {
claimBatch,
releaseStaleClaimRows,
settleFailure,
settleSuccess,
} from '../common/outbox/outbox-claims';
import { PrismaService } from '../prisma/prisma.service';
import { domainEventContext } from './domain-event-context';
import {
DomainEventRegistry,
RegisteredDomainEventHandler,
} from './domain-event-registry';
import { AnyDomainEventEnvelope, DomainEventType } from './domain-event-types';
export const DOMAIN_EVENT_BATCH_SIZE = 50;
export const HANDLER_TX_TIMEOUT_MS = 10_000;
export const DRAIN_MAX_ROUNDS = 20;
export interface DispatchFilter {
/** Only events of these correlations (e2e tests: no foreign events). */
correlationIds?: readonly string[];
}
/** A claimed DomainEvent row, as RETURNING * yields it. */
interface ClaimedDomainEvent {
id: string;
type: string;
aggregateType: string;
aggregateId: string;
payload: Prisma.JsonValue;
actorUserId: string | null;
correlationId: string;
occurredAt: Date;
attempts: number;
claimedAt: Date;
}
/**
* Delivers claimed domain events to their registered handlers, exactly once
* per (event, handler): each handler runs in its own transaction that first
* locks its DomainEventDelivery row and marks it DONE on commit. A handler
* whose delivery is DONE is skipped, so a retry, a second worker or a
* stale-released event never repeats an effect that was committed.
*/
@Injectable()
export class DomainEventDispatcher {
private readonly logger = new Logger(DomainEventDispatcher.name);
private tickAt: Date | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly registry: DomainEventRegistry,
) {}
/**
* When the dispatcher last made progress - a claim (empty or not) or a
* dispatched event - for the worker health. Advancing per event keeps a
* long batch (lock waits, slow handlers) from looking stale.
*/
get lastTickAt(): Date | null {
return this.tickAt;
}
/** One batch: claims up to 50 events and returns how many it claimed. */
async runOnce(filter?: DispatchFilter): Promise<number> {
const events = await claimBatch<ClaimedDomainEvent>(
this.prisma,
'DomainEvent',
{
limit: DOMAIN_EVENT_BATCH_SIZE,
filter: claimFilter(filter),
orderBy: 'occurredAt',
},
);
this.tickAt = new Date();
for (const event of events) {
await this.dispatchSafely(event);
this.tickAt = new Date();
}
return events.length;
}
/**
* Runs batches until one claims nothing - follow-up events that handlers
* record are picked up in the next round - but at most `maxRounds`, so
* one tick never blocks the worker for long. Returns the claimed total.
* Once `signal` aborts (worker shutdown), no further batch is claimed;
* the batch already claimed is finished, since its events would
* otherwise wait for the stale-claim release.
*/
async drain(
filter?: DispatchFilter,
maxRounds = DRAIN_MAX_ROUNDS,
signal?: AbortSignal,
): Promise<number> {
let total = 0;
for (let round = 0; round < maxRounds && !signal?.aborted; round++) {
const claimed = await this.runOnce(filter);
if (claimed === 0) {
break;
}
total += claimed;
}
return total;
}
/**
* Releases stale claims; an event that thereby reaches FAILED gets its
* open deliveries FAILED as well, like a failed settle.
*/
async releaseStaleClaims(): Promise<number> {
const released = await releaseStaleClaimRows(this.prisma, 'DomainEvent');
const failed = released
.filter((claim) => claim.status === 'FAILED')
.map((claim) => claim.id);
if (failed.length > 0) {
await this.failOpenDeliveries(failed);
this.logger.error(
`Domain events FAILED, an expired claim used up their attempts: ${failed.join(', ')}`,
);
}
return released.length;
}
private async failOpenDeliveries(eventIds: readonly string[]): Promise<void> {
await this.prisma.domainEventDelivery.updateMany({
where: { eventId: { in: [...eventIds] }, status: 'PENDING' },
data: { status: 'FAILED' },
});
}
/** One event must not stop the batch; an unsettled event goes stale. */
private async dispatchSafely(event: ClaimedDomainEvent): Promise<void> {
try {
await this.dispatch(event);
} catch (error) {
this.logger.error(
`Domain event ${event.id} (${event.type}) not settled: ${loggableError(error).message}`,
);
}
}
private async dispatch(event: ClaimedDomainEvent): Promise<void> {
const envelope = toEnvelope(event);
const failures: string[] = [];
for (const handler of this.registry.handlersFor(
event.type as DomainEventType,
)) {
const failure = await this.deliver(envelope, handler);
if (failure !== null) {
failures.push(`${handler.id}: ${failure}`);
}
}
if (failures.length === 0) {
const settled = await settleSuccess(
this.prisma,
'DomainEvent',
event.id,
event.claimedAt,
'DONE',
);
if (!settled) {
this.logger.warn(
`Domain event ${event.id} (${event.type}): claim lost before settling, left to its current owner`,
);
}
return;
}
const outcome = await settleFailure(
this.prisma,
'DomainEvent',
event.id,
event.claimedAt,
event.attempts,
failures[0],
);
if (outcome === 'failed') {
await this.failOpenDeliveries([event.id]);
this.logger.error(
`Domain event ${event.id} (${event.type}) FAILED after ${event.attempts + 1} attempts: ${failures.join('; ')}`,
);
return;
}
this.logger.warn(
`Domain event ${event.id} (${event.type}) attempt ${event.attempts + 1} failed, retrying: ${failures.join('; ')}`,
);
}
/**
* Runs one handler in its delivery transaction. Returns null on success
* or skip, else the loggable error message (already recorded on the
* delivery row, outside the rolled-back transaction).
*/
private async deliver(
event: AnyDomainEventEnvelope,
handler: RegisteredDomainEventHandler,
): Promise<string | null> {
try {
await this.prisma.$transaction(
async (tx) => {
await tx.domainEventDelivery.createMany({
data: [{ eventId: event.id, handler: handler.id }],
skipDuplicates: true,
});
const [delivery] = await tx.$queryRaw<
{ id: string; status: DomainEventDeliveryStatus }[]
>(
Prisma.sql`SELECT "id", "status" FROM "DomainEventDelivery" WHERE "eventId" = ${event.id} AND "handler" = ${handler.id} FOR UPDATE`,
);
if (delivery.status === 'DONE') {
return;
}
await domainEventContext.run(
{ correlationId: event.correlationId },
() => handler.invoke(event, tx),
);
await tx.$executeRaw(
Prisma.sql`UPDATE "DomainEventDelivery" SET "status" = 'DONE', "attempts" = "attempts" + 1, "completedAt" = now(), "updatedAt" = now() WHERE "id" = ${delivery.id}`,
);
},
{ timeout: HANDLER_TX_TIMEOUT_MS },
);
return null;
} catch (error) {
const message = loggableError(error).message;
await this.prisma.domainEventDelivery.upsert({
where: { eventId_handler: { eventId: event.id, handler: handler.id } },
create: {
eventId: event.id,
handler: handler.id,
attempts: 1,
lastError: message,
},
update: { attempts: { increment: 1 }, lastError: message },
});
return message;
}
}
}
function claimFilter(filter?: DispatchFilter): Prisma.Sql | undefined {
if (filter?.correlationIds === undefined) {
return undefined;
}
return Prisma.sql`"correlationId" = ANY(${[...filter.correlationIds]})`;
}
/**
* The row as the handler sees it. The producer paired type and payload
* (DomainEventsService only accepts AnyNewDomainEvent), so the cast holds.
*/
function toEnvelope(event: ClaimedDomainEvent): AnyDomainEventEnvelope {
return {
id: event.id,
type: event.type,
aggregateType: event.aggregateType,
aggregateId: event.aggregateId,
payload: event.payload,
actorUserId: event.actorUserId,
correlationId: event.correlationId,
occurredAt: event.occurredAt,
attempts: event.attempts,
} as AnyDomainEventEnvelope;
}
@@ -0,0 +1,456 @@
import { Injectable, Provider, Scope } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { Prisma } from '../../generated/prisma';
import { DomainEventRegistry } from './domain-event-registry';
import { DomainEventsModule } from './domain-events.module';
import type {
AnyDomainEventEnvelope,
DomainEventEnvelope,
DomainEventHandlerFn,
DomainEventType,
} from './domain-event-types';
import { HANDLER_ID_PATTERN, OnDomainEvent } from './on-domain-event.decorator';
interface FakeWrapper {
instance: unknown;
isAlias?: boolean;
isStatic?: boolean;
isTransient?: boolean;
}
function registryWith(...wrappers: FakeWrapper[]): DomainEventRegistry {
const discovery = {
getProviders: () =>
wrappers.map(
({
instance,
isAlias = false,
isStatic = true,
isTransient = false,
}) => ({
instance,
isAlias,
isTransient,
isDependencyTreeStatic: () => isStatic,
}),
),
};
return new DomainEventRegistry(
discovery as unknown as DiscoveryService,
new MetadataScanner(),
);
}
function registryFor(...instances: unknown[]): DomainEventRegistry {
return registryWith(...instances.map((instance) => ({ instance })));
}
function envelope<T extends DomainEventType>(
type: T,
payload: DomainEventEnvelope<T>['payload'],
): DomainEventEnvelope<T> {
return {
id: 'e1',
type,
aggregateType: 'Inspection',
aggregateId: 'i1',
payload,
actorUserId: null,
correlationId: 'corr-1',
occurredAt: new Date('2026-10-13T08:00:00Z'),
attempts: 0,
};
}
class CalendarHandlers {
readonly seen: string[] = [];
@OnDomainEvent(['InspectionRecorded', 'TreatmentRecorded'], 'calendar.sync')
sync(event: DomainEventEnvelope): Promise<void> {
this.seen.push(event.type);
return Promise.resolve();
}
notAHandler(): void {}
}
class AutomationHandlers {
@OnDomainEvent('InspectionRecorded', 'automation.queenless')
@OnDomainEvent('InspectionCorrected', 'automation.queenless-corrected')
evaluate(): Promise<void> {
return Promise.resolve();
}
}
describe('DomainEventRegistry', () => {
it('finds handlers by type, also when one method serves several types', async () => {
const calendar = new CalendarHandlers();
const registry = registryFor(calendar, new AutomationHandlers());
registry.onModuleInit();
expect(
registry.handlersFor('InspectionRecorded').map((handler) => handler.id),
).toEqual(['automation.queenless', 'calendar.sync']);
expect(
registry.handlersFor('TreatmentRecorded').map((handler) => handler.id),
).toEqual(['calendar.sync']);
expect(registry.handlersFor('VarroaMeasured')).toEqual([]);
const [handler] = registry.handlersFor('TreatmentRecorded');
await handler.invoke(
envelope('TreatmentRecorded', { colonyId: 'c1' }),
{} as Prisma.TransactionClient,
);
expect(calendar.seen).toEqual(['TreatmentRecorded']);
});
it('registers stacked decorators on one method separately', () => {
const registry = registryFor(new AutomationHandlers());
registry.onModuleInit();
expect(
registry.handlersFor('InspectionCorrected').map((handler) => handler.id),
).toEqual(['automation.queenless-corrected']);
expect(registry.handlerIds()).toEqual([
'automation.queenless',
'automation.queenless-corrected',
]);
});
it('passes event and transaction client to the handler bound to its provider', async () => {
const received: unknown[] = [];
class Recorder {
readonly name = 'recorder';
@OnDomainEvent('TaskDue', 'tasks.reminder')
handle(event: DomainEventEnvelope, tx: Prisma.TransactionClient) {
received.push(this.name, event.id, tx);
return Promise.resolve();
}
}
const registry = registryFor(new Recorder());
registry.onModuleInit();
const tx = { marker: true } as unknown as Prisma.TransactionClient;
await registry
.handlersFor('TaskDue')[0]
.invoke(envelope('TaskDue', { dueOn: '2026-10-13' }), tx);
expect(received).toEqual(['recorder', 'e1', tx]);
});
it('lists every handler id once', () => {
const registry = registryFor(
new CalendarHandlers(),
new AutomationHandlers(),
);
registry.onModuleInit();
expect(registry.handlerIds()).toEqual([
'automation.queenless',
'automation.queenless-corrected',
'calendar.sync',
]);
});
it('ignores providers without an instance or without handlers', () => {
const registry = registryFor(undefined, null, 'value', { plain: true });
registry.onModuleInit();
expect(registry.handlerIds()).toEqual([]);
});
it('refuses two handlers with the same id for one type', () => {
class OtherCalendarHandlers {
@OnDomainEvent('InspectionRecorded', 'calendar.sync')
again(): Promise<void> {
return Promise.resolve();
}
}
const registry = registryFor(
new CalendarHandlers(),
new OtherCalendarHandlers(),
);
expect(() => registry.onModuleInit()).toThrow(
'Duplicate domain event handler',
);
});
it('refuses the same provider registered twice', () => {
const registry = registryFor(
new CalendarHandlers(),
new CalendarHandlers(),
);
expect(() => registry.onModuleInit()).toThrow(
'Duplicate domain event handler',
);
});
it.each([
'calendar',
'Calendar.sync',
'calendar.sync.extra',
'calendar.',
'calendar sync',
'.sync',
])(
'rejects a handler id that is not "<consumer>.<name>": %p',
(handlerId) => {
class BadId {
@OnDomainEvent('TaskDue', handlerId)
handle(): Promise<void> {
return Promise.resolve();
}
}
const registry = registryFor(new BadId());
expect(HANDLER_ID_PATTERN.test(handlerId)).toBe(false);
expect(() => registry.onModuleInit()).toThrow(
'Invalid domain event handler id',
);
},
);
it('rejects an event type outside the catalog', () => {
class UnknownType {
@OnDomainEvent('ColonyCreated' as DomainEventType, 'colonies.welcome')
handle(): Promise<void> {
return Promise.resolve();
}
}
expect(() => registryFor(new UnknownType()).onModuleInit()).toThrow(
'Unknown domain event type',
);
});
it('rejects a handler without any event type', () => {
class NoType {
@OnDomainEvent([], 'colonies.nothing')
handle(): Promise<void> {
return Promise.resolve();
}
}
expect(() => registryFor(new NoType()).onModuleInit()).toThrow(
'serves no domain event type',
);
});
it('skips alias providers - the aliased provider is registered once', () => {
const calendar = new CalendarHandlers();
const registry = registryWith(
{ instance: calendar },
{ instance: calendar, isAlias: true },
);
registry.onModuleInit();
expect(
registry.handlersFor('InspectionRecorded').map((handler) => handler.id),
).toEqual(['calendar.sync']);
});
it('refuses a handler on a request-scoped provider (dependency tree not static)', () => {
const registry = registryWith({
instance: new CalendarHandlers(),
isStatic: false,
});
expect(() => registry.onModuleInit()).toThrow(
'must be on a static (singleton) provider',
);
});
it('refuses a handler on a transient provider even with a static dependency tree', () => {
// Nest reports isDependencyTreeStatic() === true here, but the
// discovered instance is an unconstructed Object.create(prototype).
const registry = registryWith({
instance: new CalendarHandlers(),
isStatic: true,
isTransient: true,
});
expect(() => registry.onModuleInit()).toThrow(
'must be on a static (singleton) provider',
);
});
it('ignores a transient provider without handlers', () => {
class TransientService {
run(): void {}
}
const registry = registryWith({
instance: new TransientService(),
isTransient: true,
});
expect(() => registry.onModuleInit()).not.toThrow();
});
it('ignores a non-static provider without handlers', () => {
class RequestScopedService {
run(): void {}
}
const registry = registryWith({
instance: new RequestScopedService(),
isStatic: false,
});
expect(() => registry.onModuleInit()).not.toThrow();
expect(registry.handlerIds()).toEqual([]);
});
describe('order', () => {
class ManyHandlers {
@OnDomainEvent('TaskDue', 'calendar1.a')
@OnDomainEvent('TaskDue', 'calendar.sync')
@OnDomainEvent('TaskDue', 'calendar-sync.a')
@OnDomainEvent('TaskDue', 'calendar.a')
handle(): Promise<void> {
return Promise.resolve();
}
}
const CODE_POINT_ORDER = [
'calendar-sync.a',
'calendar.a',
'calendar.sync',
'calendar1.a',
];
afterEach(() => jest.restoreAllMocks());
it('sorts by code point in handlersFor and handlerIds, whatever the locale collation says', () => {
// A collation that reverses everything: the order must not change.
jest
.spyOn(String.prototype, 'localeCompare')
.mockImplementation(function (this: string, other: string) {
if (this > other) return -1;
return this < other ? 1 : 0;
});
const registry = registryFor(new ManyHandlers());
registry.onModuleInit();
expect(
registry.handlersFor('TaskDue').map((handler) => handler.id),
).toEqual(CODE_POINT_ORDER);
expect(registry.handlerIds()).toEqual(CODE_POINT_ORDER);
});
});
});
describe('handler envelope typing', () => {
const harvest = envelope('HarvestRecorded', {
apiaryId: 'a1',
colonyIds: ['c1', 'c2'],
});
const inspection = envelope('InspectionRecorded', { colonyId: 'c3' });
it('ties every event type to its payload, so a multi-type handler narrows by type', async () => {
const seen: string[] = [];
// Compiles only because the envelope is a distributive union.
const handler: DomainEventHandlerFn<
'InspectionRecorded' | 'HarvestRecorded'
> = (event) => {
seen.push(
...(event.type === 'HarvestRecorded'
? event.payload.colonyIds
: [event.payload.colonyId]),
);
return Promise.resolve();
};
await handler(harvest, {} as Prisma.TransactionClient);
await handler(inspection, {} as Prisma.TransactionClient);
expect(seen).toEqual(['c1', 'c2', 'c3']);
});
it('refuses an envelope whose payload belongs to another type', () => {
const misfit = { ...harvest, type: 'TaskDue' } as const;
// @ts-expect-error - TaskDue carries { dueOn }, not a harvest payload
const wrong: AnyDomainEventEnvelope = misfit;
expect(wrong.type).toBe('TaskDue');
});
});
describe('DomainEventRegistry with Nest DI', () => {
@Injectable()
class Clock {
today(): string {
return '2026-10-13';
}
}
@Injectable()
class SingletonHandlers {
readonly seen: string[] = [];
constructor(private readonly clock: Clock) {}
@OnDomainEvent('TaskDue', 'di.singleton')
handle(): Promise<void> {
this.seen.push(this.clock.today());
return Promise.resolve();
}
}
@Injectable({ scope: Scope.TRANSIENT })
class TransientHandlers {
constructor(readonly clock: Clock) {}
@OnDomainEvent('TaskDue', 'di.transient')
handle(): Promise<void> {
return Promise.resolve();
}
}
@Injectable({ scope: Scope.REQUEST })
class RequestHandlers {
@OnDomainEvent('TaskDue', 'di.request')
handle(): Promise<void> {
return Promise.resolve();
}
}
async function compile(...providers: Provider[]): Promise<TestingModule> {
return Test.createTestingModule({
imports: [DomainEventsModule],
providers: [Clock, ...providers],
}).compile();
}
it('registers a singleton handler once, also behind an alias, bound to the DI instance', async () => {
const moduleRef = await compile(SingletonHandlers, {
provide: 'HANDLERS_ALIAS',
useExisting: SingletonHandlers,
});
await moduleRef.init();
const registry = moduleRef.get(DomainEventRegistry);
expect(
registry.handlersFor('TaskDue').map((handler) => handler.id),
).toEqual(['di.singleton']);
await registry
.handlersFor('TaskDue')[0]
.invoke(
envelope('TaskDue', { dueOn: '2026-10-13' }),
{} as Prisma.TransactionClient,
);
expect(moduleRef.get(SingletonHandlers).seen).toEqual(['2026-10-13']);
await moduleRef.close();
});
it.each([
['transient', TransientHandlers],
['request-scoped', RequestHandlers],
])('stops the boot for a handler on a %s provider', async (_scope, type) => {
const moduleRef = await compile(type);
// No close(): it re-throws the failed init, and nothing holds resources.
await expect(moduleRef.init()).rejects.toThrow(
'must be on a static (singleton) provider',
);
});
});
@@ -0,0 +1,174 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import {
DOMAIN_EVENT_TYPES,
DomainEventHandlerFn,
DomainEventType,
} from './domain-event-types';
import {
domainEventHandlerMetadata,
DomainEventHandlerMetadata,
HANDLER_ID_PATTERN,
} from './on-domain-event.decorator';
export interface RegisteredDomainEventHandler {
id: string;
invoke: DomainEventHandlerFn;
}
interface DiscoveredHandler extends RegisteredDomainEventHandler {
types: readonly DomainEventType[];
/** "<Class>.<method>" for boot error messages. */
source: string;
}
const NO_HANDLERS: readonly RegisteredDomainEventHandler[] = Object.freeze([]);
/**
* Collects every @OnDomainEvent method of every provider at boot. A wrong
* declaration - invalid id, unknown type, the same id twice for one type -
* stops the boot: a handler that silently never runs, or runs under
* another handler's delivery record, would lose or swallow effects.
*/
@Injectable()
export class DomainEventRegistry implements OnModuleInit {
private handlers = new Map<
DomainEventType,
readonly RegisteredDomainEventHandler[]
>();
constructor(
private readonly discovery: DiscoveryService,
private readonly scanner: MetadataScanner,
) {}
onModuleInit(): void {
this.handlers = groupByType(this.discover());
}
/** Sorted by handler id (compareIds), so handlers run in a stable order. */
handlersFor(type: DomainEventType): readonly RegisteredDomainEventHandler[] {
return this.handlers.get(type) ?? NO_HANDLERS;
}
handlerIds(): string[] {
const ids = new Set<string>();
for (const handlers of this.handlers.values()) {
handlers.forEach((handler) => ids.add(handler.id));
}
return [...ids].sort(compareIds);
}
/**
* Aliases are skipped - they point at a provider that is scanned anyway.
* A handler must sit on a static (singleton) provider: the worker calls
* it outside any request. For a request-scoped provider (dependency tree
* not static) and for a transient one (Nest reports its tree as static)
* the discovered instance is only an unconstructed Object.create of the
* prototype, without dependencies - the handler would fail at dispatch.
*/
private discover(): DiscoveredHandler[] {
return this.discovery.getProviders().flatMap((wrapper) => {
const instance: unknown = wrapper.instance;
if (wrapper.isAlias || !instance || typeof instance !== 'object') {
return [];
}
const handlers = this.handlersOf(instance);
const isSingleton =
!wrapper.isTransient && wrapper.isDependencyTreeStatic();
if (handlers.length > 0 && !isSingleton) {
const [first] = handlers;
throw new Error(
`Domain event handler "${first.id}" on ${first.source} must be on a static (singleton) provider: the worker calls handlers outside any request`,
);
}
return handlers;
});
}
private handlersOf(instance: object): DiscoveredHandler[] {
const prototype = Object.getPrototypeOf(instance) as object | null;
return this.scanner.getAllMethodNames(prototype).flatMap((methodName) => {
const method: unknown = (prototype as Record<string, unknown>)[
methodName
];
if (typeof method !== 'function') {
return [];
}
const source = `${instance.constructor.name}.${methodName}`;
return domainEventHandlerMetadata(method).map((entry) => {
assertValid(entry, source);
return {
id: entry.handlerId,
types: entry.types,
source,
invoke: (method as DomainEventHandlerFn).bind(
instance,
) as DomainEventHandlerFn,
};
});
});
}
}
function assertValid(entry: DomainEventHandlerMetadata, source: string): void {
if (!HANDLER_ID_PATTERN.test(entry.handlerId)) {
throw new Error(
`Invalid domain event handler id "${entry.handlerId}" on ${source}: expected "<consumer>.<name>" in lowercase letters, digits and hyphens`,
);
}
if (entry.types.length === 0) {
throw new Error(
`Domain event handler "${entry.handlerId}" on ${source} serves no domain event type`,
);
}
const unknown = entry.types.find(
(type) => !DOMAIN_EVENT_TYPES.includes(type),
);
if (unknown !== undefined) {
throw new Error(
`Unknown domain event type "${unknown}" for handler "${entry.handlerId}" on ${source}`,
);
}
}
function groupByType(
handlers: readonly DiscoveredHandler[],
): Map<DomainEventType, readonly RegisteredDomainEventHandler[]> {
const byType = new Map<DomainEventType, DiscoveredHandler[]>();
for (const handler of handlers) {
for (const type of handler.types) {
const existing = byType.get(type) ?? [];
const duplicate = existing.find((other) => other.id === handler.id);
if (duplicate) {
throw new Error(
`Duplicate domain event handler "${handler.id}" for ${type} (${duplicate.source} and ${handler.source})`,
);
}
byType.set(type, [...existing, handler]);
}
}
return new Map(
[...byType].map(([type, list]) => [
type,
Object.freeze(
[...list]
.sort((a, b) => compareIds(a.id, b.id))
.map(({ id, invoke }) => ({ id, invoke })),
),
]),
);
}
/**
* Ordinal comparison by UTF-16 code unit - for handler ids, which
* HANDLER_ID_PATTERN restricts to ASCII, that is code-point order. Unlike
* localeCompare it does not depend on the runtime's locale or ICU data, so
* every pod runs handlers in the same order.
*/
function compareIds(a: string, b: string): number {
if (a < b) {
return -1;
}
return a > b ? 1 : 0;
}
@@ -0,0 +1,173 @@
import {
ColonyCondition,
ColonyStatus,
Prisma,
RawMaterialKind,
TaskRelatedType,
TaskStatus,
} from '../../generated/prisma';
export interface ColonyStateSnapshot {
status: ColonyStatus;
healthStatus: ColonyCondition | null;
}
/**
* The complete event catalog. A payload carries only ids and the values a
* handler needs - never personal data or secrets; dates are 'YYYY-MM-DD'
* strings. Handlers read anything else from the database.
*/
export interface DomainEventPayloads {
InspectionRecorded: { colonyId: string };
InspectionCorrected: { colonyId: string };
VarroaMeasured: { colonyId: string };
VarroaCorrected: { colonyId: string };
TreatmentRecorded: { colonyId: string };
TreatmentCorrected: { colonyId: string };
FeedingRecorded: { colonyId: string };
FeedingCorrected: { colonyId: string };
HarvestRecorded: { apiaryId: string; colonyIds: string[] };
HarvestCorrected: { apiaryId: string; colonyIds: string[] };
ColonyStatusChanged: { from: ColonyStateSnapshot; to: ColonyStateSnapshot };
ColonyEnded: { status: ColonyStatus };
BatchBottled: { honeyBatchId: string };
BottlingCorrected: { honeyBatchId: string; removed: boolean };
StockChanged: {
bottlingBatchIds: string[];
productIds: string[];
rawMaterialKinds: RawMaterialKind[];
};
TaskCreated: {
relatedType: TaskRelatedType;
relatedId: string | null;
dueDate: string | null;
};
TaskUpdated: { status: TaskStatus; dueDate: string | null };
TaskCompleted: { relatedType: TaskRelatedType; relatedId: string | null };
WithdrawalEnded: { colonyId: string; endedOn: string };
TaskDue: { dueOn: string };
QueenAged: { hatchYear: number; colonyId: string | null };
VarroaMeasurementDue: { dueOn: string; lastMeasuredOn: string | null };
InspectionOverdue: { dueOn: string; lastInspectedOn: string | null };
}
export type DomainEventType = keyof DomainEventPayloads;
// A Record over the union: the compiler refuses a missing or unknown type.
const CATALOG: Record<DomainEventType, true> = {
InspectionRecorded: true,
InspectionCorrected: true,
VarroaMeasured: true,
VarroaCorrected: true,
TreatmentRecorded: true,
TreatmentCorrected: true,
FeedingRecorded: true,
FeedingCorrected: true,
HarvestRecorded: true,
HarvestCorrected: true,
ColonyStatusChanged: true,
ColonyEnded: true,
BatchBottled: true,
BottlingCorrected: true,
StockChanged: true,
TaskCreated: true,
TaskUpdated: true,
TaskCompleted: true,
WithdrawalEnded: true,
TaskDue: true,
QueenAged: true,
VarroaMeasurementDue: true,
InspectionOverdue: true,
};
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
Object.keys(CATALOG) as DomainEventType[],
);
export type AggregateType =
| 'Inspection'
| 'VarroaMeasurement'
| 'Treatment'
| 'Feeding'
| 'Harvest'
| 'Colony'
| 'BottlingBatch'
| 'ProductionBatch'
| 'BatchMovement'
| 'ShopStockRequest'
| 'Task'
| 'Queen';
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
type: T;
aggregateType: AggregateType;
aggregateId: string;
payload: DomainEventPayloads[T];
/** The acting user; null or absent for the system or a job. */
actorUserId?: string | null;
/** Time-based events only, see dailyDedupeKey. */
dedupeKey?: string;
}
/**
* One NewDomainEvent per type in T, so `type` and `payload` stay paired: a
* TaskDue with an InspectionRecorded payload does not compile.
* NewDomainEvent<A | B> alone would accept either payload with either type.
*/
export type AnyNewDomainEvent<T extends DomainEventType = DomainEventType> = {
[K in T]: NewDomainEvent<K>;
}[T];
/** What a handler receives: the stored event. */
export interface DomainEventEnvelope<
T extends DomainEventType = DomainEventType,
> {
id: string;
type: T;
aggregateType: AggregateType;
aggregateId: string;
payload: DomainEventPayloads[T];
actorUserId: string | null;
correlationId: string;
occurredAt: Date;
attempts: number;
}
/**
* One DomainEventEnvelope per type in T, paired like AnyNewDomainEvent: a
* handler for several types narrows the payload by checking `event.type`.
*/
export type AnyDomainEventEnvelope<
T extends DomainEventType = DomainEventType,
> = {
[K in T]: DomainEventEnvelope<K>;
}[T];
/**
* A handler writes only through `tx` (its delivery transaction) and makes
* no network calls; reading through `tx` or PrismaService is fine.
*/
export type DomainEventHandlerFn<T extends DomainEventType = DomainEventType> =
(
event: AnyDomainEventEnvelope<T>,
tx: Prisma.TransactionClient,
) => Promise<void>;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/**
* Unique key of a time-based event: at most one per type, aggregate and
* (UTC) day of the business event, however often the job runs. A full
* timestamp instead of the day would create a new event on every run, so
* it is refused.
*/
export function dailyDedupeKey(
type: DomainEventType,
aggregateId: string,
day: string,
): string {
if (!DAY_PATTERN.test(day)) {
throw new Error('Daily dedupe key needs a YYYY-MM-DD day');
}
return `${type}:${aggregateId}:${day}`;
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { PrismaModule } from '../prisma/prisma.module';
import { DomainEventDispatcher } from './domain-event-dispatcher';
import { DomainEventRegistry } from './domain-event-registry';
import { DomainEventsService } from './domain-events.service';
@Global()
@Module({
imports: [DiscoveryModule, PrismaModule],
providers: [DomainEventsService, DomainEventRegistry, DomainEventDispatcher],
exports: [DomainEventsService, DomainEventRegistry, DomainEventDispatcher],
})
export class DomainEventsModule {}
@@ -0,0 +1,265 @@
import { DomainEventsService } from './domain-events.service';
import { domainEventContext } from './domain-event-context';
import {
AnyNewDomainEvent,
DOMAIN_EVENT_TYPES,
dailyDedupeKey,
NewDomainEvent,
} from './domain-event-types';
type CreateManyArg = {
data: {
type: string;
aggregateId: string;
actorUserId: string | null;
correlationId: string;
dedupeKey: string | null;
payload: unknown;
}[];
skipDuplicates: boolean;
};
describe('DomainEventsService', () => {
let service: DomainEventsService;
let tx: {
domainEvent: { createMany: jest.Mock };
$executeRaw: jest.Mock;
};
const event: NewDomainEvent<'InspectionRecorded'> = {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
};
const daily: NewDomainEvent<'TaskDue'> = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
dedupeKey: dailyDedupeKey('TaskDue', 't1', '2026-10-13'),
};
function createManyArg(call = 0): CreateManyArg {
const calls = tx.domainEvent.createMany.mock.calls as [CreateManyArg][];
return calls[call][0];
}
beforeEach(() => {
tx = {
domainEvent: {
createMany: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }),
),
},
$executeRaw: jest.fn().mockResolvedValue(1),
};
service = new DomainEventsService();
});
it('records the event and the wake-up on the same transaction client', async () => {
await service.record(tx as never, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
actorUserId: 'u1',
});
expect(tx.domainEvent.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
type: 'InspectionRecorded',
aggregateId: 'i1',
actorUserId: 'u1',
correlationId: expect.any(String) as unknown,
}),
],
skipDuplicates: true,
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it("wakes the dispatcher with pg_notify('domain_event', '') - never data in the channel", async () => {
await service.record(tx as never, event);
const [[strings, ...values]] = tx.$executeRaw.mock.calls as [
[TemplateStringsArray, ...unknown[]],
];
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
expect(values).toEqual(['domain_event']);
});
it('refuses the root PrismaService (must run inside the business transaction)', async () => {
const prismaServiceMock = {
...tx,
$connect: jest.fn(),
$transaction: jest.fn(),
};
await expect(
service.record(prismaServiceMock as never, event),
).rejects.toThrow(
'DomainEventsService.record needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('names recordAll when recordAll gets the root client', async () => {
const prismaServiceMock = { ...tx, $connect: jest.fn() };
await expect(
service.recordAll(prismaServiceMock as never, [event]),
).rejects.toThrow(
'DomainEventsService.recordAll needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('records through a transaction client that can open nested transactions (Prisma 7)', async () => {
const nestingTx = { ...tx, $transaction: jest.fn() };
await service.record(nestingTx as never, event);
expect(tx.domainEvent.createMany).toHaveBeenCalledTimes(1);
});
it('inherits the correlation id of the event being handled', async () => {
await domainEventContext.run({ correlationId: 'corr-1' }, () =>
service.record(tx as never, event),
);
expect(createManyArg().data[0].correlationId).toBe('corr-1');
});
it('starts a new correlation id per call outside a handler, shared by all events of the call', async () => {
await service.recordAll(tx as never, [event, daily]);
await service.record(tx as never, event);
const [first, second] = createManyArg(0).data;
expect(first.correlationId).toBe(second.correlationId);
expect(createManyArg(1).data[0].correlationId).not.toBe(
first.correlationId,
);
});
it('stores missing actor and dedupe key as null and keeps the payload as given', async () => {
await service.recordAll(tx as never, [event, daily]);
const [plain, withKey] = createManyArg().data;
expect(plain.actorUserId).toBeNull();
expect(plain.dedupeKey).toBeNull();
expect(plain.payload).toEqual({ colonyId: 'c1' });
expect(withKey.dedupeKey).toBe('TaskDue:t1:2026-10-13');
});
it('wakes the worker only when something was inserted', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 0 });
await expect(service.recordAll(tx as never, [daily])).resolves.toBe(0);
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('returns the number of inserted events', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 1 });
await expect(service.recordAll(tx as never, [event, daily])).resolves.toBe(
1,
);
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it('does nothing for an empty list', async () => {
await expect(service.recordAll(tx as never, [])).resolves.toBe(0);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
});
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(23);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
'InspectionRecorded',
'StockChanged',
'TaskDue',
'InspectionOverdue',
]),
);
});
it('builds the daily dedupe key as "<type>:<aggregateId>:<YYYY-MM-DD>"', () => {
expect(dailyDedupeKey('QueenAged', 'q1', '2026-10-13')).toBe(
'QueenAged:q1:2026-10-13',
);
});
it.each(['2026-10-13T00:00:00.000Z', '13.10.2026', '2026-1-3', ''])(
'refuses %p as day - a timestamp would make every run a new event',
(day) => {
expect(() => dailyDedupeKey('TaskDue', 't1', day)).toThrow(
'Daily dedupe key needs a YYYY-MM-DD day',
);
},
);
});
// The @ts-expect-error lines are checked by `npm run typecheck`: if the
// type stopped refusing the mismatch, the unused directive fails it.
describe('domain event typing', () => {
const tx = {
domainEvent: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
const service = new DomainEventsService();
it('ties every event type to its payload in recordAll', async () => {
const recorded = service.recordAll(tx as never, [
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
},
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
},
]);
await expect(recorded).resolves.toBe(0);
});
it('refuses a stored event value whose payload belongs to another type', () => {
const misfit = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { colonyId: 'c1' },
} as const;
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
const wrong: AnyNewDomainEvent = misfit;
expect(wrong.type).toBe('TaskDue');
});
it('keeps record<T> checking the payload against the literal type', async () => {
await service.record(tx as never, {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
});
expect(tx.domainEvent.createMany).toHaveBeenCalled();
});
});
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../generated/prisma';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { assertTransactionClient } from '../prisma/db-client';
import { domainEventContext } from './domain-event-context';
import {
AnyNewDomainEvent,
DomainEventType,
NewDomainEvent,
} from './domain-event-types';
/**
* Writes domain events into the outbox. Always inside the business
* transaction: no commit, no event - and no commit without its event.
*/
@Injectable()
export class DomainEventsService {
async record<T extends DomainEventType>(
tx: Prisma.TransactionClient,
event: NewDomainEvent<T>,
): Promise<void> {
assertTransactionClient(tx, 'DomainEventsService.record');
await this.insert(tx, [event]);
}
/**
* Returns how many events were inserted - an event whose dedupeKey
* already exists is skipped. The worker is woken only if something new
* was written; Postgres delivers the wake-up on commit.
*/
async recordAll(
tx: Prisma.TransactionClient,
events: readonly AnyNewDomainEvent[],
): Promise<number> {
assertTransactionClient(tx, 'DomainEventsService.recordAll');
return this.insert(tx, events);
}
private async insert(
tx: Prisma.TransactionClient,
events: readonly NewDomainEvent[],
): Promise<number> {
if (events.length === 0) {
return 0;
}
const correlationId =
domainEventContext.getStore()?.correlationId ?? randomUUID();
const { count } = await tx.domainEvent.createMany({
data: events.map((event) => ({
type: event.type,
aggregateType: event.aggregateType,
aggregateId: event.aggregateId,
payload: event.payload as Prisma.InputJsonValue,
actorUserId: event.actorUserId ?? null,
correlationId,
dedupeKey: event.dedupeKey ?? null,
})),
skipDuplicates: true,
});
if (count > 0) {
await notifyWorker(tx, WAKE_CHANNELS.domainEvents);
}
return count;
}
}
@@ -0,0 +1,209 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
/**
* Guard for the handler contract (Review Focus 2): a domain event handler
* runs inside its delivery transaction and may be repeated after a crash,
* so it only writes to the database. Network work belongs to an outbox of
* its own (calendar sync, WebhooksService.enqueueDeliveries) that a worker
* loop drains. A static source scan:
* - every @OnDomainEvent handler lives in a `*.handlers.ts` file, and
* - a handler file imports no network client: no http/https/fetch library,
* no `fetch(` call, nothing from graph-client or webhook-delivery, and no
* src module that itself makes network calls (a fetch wrapper).
*/
const SRC = join(__dirname, '..');
const DECORATOR_FILE = join(
SRC,
'domain-events',
'on-domain-event.decorator.ts',
);
interface SourceFile {
path: string;
source: string;
}
const NETWORK_PACKAGES = new Set([
'http',
'https',
'http2',
'net',
'tls',
'dgram',
'undici',
'axios',
'node-fetch',
'cross-fetch',
'got',
'ky',
'superagent',
]);
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery/;
const FETCH_CALL = /\bfetch\s*\(/;
const IMPORT_SPECIFIER =
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
function importsOf(source: string): string[] {
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]);
}
function isNetworkPackage(specifier: string): boolean {
return NETWORK_PACKAGES.has(specifier.replace(/^node:/, ''));
}
/** A module that talks to the network itself. */
function makesNetworkCalls(file: SourceFile): boolean {
return (
FETCH_CALL.test(file.source) ||
importsOf(file.source).some(isNetworkPackage)
);
}
function resolveImport(from: string, specifier: string): string | undefined {
if (!specifier.startsWith('.')) {
return undefined;
}
const base = resolve(dirname(from), specifier);
return [`${base}.ts`, join(base, 'index.ts')].find(existsSync) ?? base;
}
/** Files declaring @OnDomainEvent handlers outside a `*.handlers.ts` file. */
function misplacedHandlerFiles(files: readonly SourceFile[]): string[] {
return files
.filter(
(file) =>
file.path !== DECORATOR_FILE &&
!file.path.endsWith('.spec.ts') &&
file.source.includes('@OnDomainEvent(') &&
!file.path.endsWith('.handlers.ts'),
)
.map((file) => file.path);
}
/** What a handler file must not use; empty when it keeps the contract. */
function networkViolations(
handlerFile: SourceFile,
networkModules: ReadonlySet<string>,
): string[] {
const violations = importsOf(handlerFile.source).filter((specifier) => {
const resolved = resolveImport(handlerFile.path, specifier);
return (
isNetworkPackage(specifier) ||
NETWORK_MODULE_NAME.test(specifier) ||
(resolved !== undefined && networkModules.has(resolved))
);
});
return FETCH_CALL.test(handlerFile.source)
? [...violations, 'fetch(']
: violations;
}
function sourceFiles(): SourceFile[] {
return readdirSync(SRC, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.ts'))
.map((file) => join(SRC, file))
.map((path) => ({ path, source: readFileSync(path, 'utf8') }));
}
function networkModulesOf(files: readonly SourceFile[]): Set<string> {
return new Set(
files
.filter((file) => !file.path.endsWith('.spec.ts'))
.filter(makesNetworkCalls)
.map((file) => file.path),
);
}
describe('domain event handler contract', () => {
const files = sourceFiles();
const networkModules = networkModulesOf(files);
it('every @OnDomainEvent handler lives in a *.handlers.ts file', () => {
expect(misplacedHandlerFiles(files)).toEqual([]);
});
it('handler files never import network clients', () => {
for (const file of files.filter((f) => f.path.endsWith('.handlers.ts'))) {
expect({
file: file.path,
violations: networkViolations(file, networkModules),
}).toEqual({
file: file.path,
violations: [],
});
}
});
it('knows the network modules of the code base (fetch wrappers included)', () => {
const relative = [...networkModules].map((path) =>
path
.slice(SRC.length + 1)
.split(sep)
.join('/'),
);
expect(relative).toEqual(
expect.arrayContaining([
'calendar-sync/graph-client.service.ts',
'webhooks/webhook-delivery.ts',
'weather/weather.service.ts',
'common/password/password-policy.ts',
]),
);
expect(relative).not.toContain('webhooks/webhooks.service.ts');
});
describe('with planted violations', () => {
const handlerPath = join(SRC, 'colonies', 'colonies.handlers.ts');
it('flags a handler declared outside a *.handlers.ts file', () => {
const planted = {
path: join(SRC, 'colonies', 'colonies.service.ts'),
source: "@OnDomainEvent('TaskDue', 'colonies.due')\n",
};
expect(misplacedHandlerFiles([planted])).toEqual([planted.path]);
expect(
misplacedHandlerFiles([
{ path: handlerPath, source: planted.source },
{
path: `${planted.path.slice(0, -3)}.spec.ts`,
source: planted.source,
},
{ path: DECORATOR_FILE, source: planted.source },
]),
).toEqual([]);
});
it.each([
"import https from 'https';",
"import { request } from 'http';",
"import { get } from 'node:https';",
"const http = require('node:http');",
"import { fetch as undiciFetch } from 'undici';",
"import axios from 'axios';",
'const response = await fetch(url);',
"import { GraphClientService } from '../calendar-sync/graph-client.service';",
"import { postWebhook } from '../webhooks/webhook-delivery';",
"import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';",
"import { WeatherService } from '../weather/weather.service';",
"const { checkPassword } = await import('../common/password/password-policy');",
])('flags %s', (line) => {
expect(
networkViolations({ path: handlerPath, source: line }, networkModules),
).not.toEqual([]);
});
it.each([
"import { WebhooksService } from '../webhooks/webhooks.service';",
"import { Prisma } from '../../generated/prisma';",
"import type { DomainEventEnvelope } from '../domain-events/domain-event-types';",
'const prefetched = await tx.task.findMany();',
"const scheme = 'https';",
])('lets a database-only line pass: %s', (line) => {
expect(
networkViolations({ path: handlerPath, source: line }, networkModules),
).toEqual([]);
});
});
});
@@ -0,0 +1,50 @@
import { DomainEventType } from './domain-event-types';
/**
* "<consumer>.<name>", e.g. "calendar.sync-source". The id is stored per
* delivery, so it must stay stable once events were delivered to it.
*/
export const HANDLER_ID_PATTERN = /^[a-z0-9-]+\.[a-z0-9-]+$/;
const DOMAIN_EVENT_HANDLER_METADATA = 'domain-event-handler';
export interface DomainEventHandlerMetadata {
types: readonly DomainEventType[];
handlerId: string;
}
/**
* Marks a provider method as handler for one or more event types; the
* DomainEventRegistry finds it at boot and validates the id. Decorators
* stack - each adds its own entry.
*/
export function OnDomainEvent(
types: DomainEventType | readonly DomainEventType[],
handlerId: string,
): MethodDecorator {
const entry: DomainEventHandlerMetadata = {
types: typeof types === 'string' ? [types] : [...types],
handlerId,
};
return (_target, _propertyKey, descriptor) => {
const method = descriptor.value as object;
Reflect.defineMetadata(
DOMAIN_EVENT_HANDLER_METADATA,
[...domainEventHandlerMetadata(method), entry],
method,
);
return descriptor;
};
}
export function domainEventHandlerMetadata(
method: object,
): readonly DomainEventHandlerMetadata[] {
const entries: unknown = Reflect.getMetadata(
DOMAIN_EVENT_HANDLER_METADATA,
method,
);
return Array.isArray(entries)
? (entries as DomainEventHandlerMetadata[])
: [];
}
@@ -6,27 +6,14 @@ import {
VERSION_NEUTRAL,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { PrismaService } from '../prisma/prisma.service';
import { Public } from '../auth/decorators/public.decorator';
import { loggableError } from '../common/loggable-error';
import { withTimeout } from '../common/with-timeout';
const READINESS_QUERY_TIMEOUT_MS = 5000;
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error('Readiness query timed out')),
ms,
);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timer!);
}
}
@ApiTags('health')
@Public()
@Controller({ path: 'health', version: VERSION_NEUTRAL })
@@ -35,6 +22,10 @@ export class HealthController {
constructor(private readonly prisma: PrismaService) {}
// Liveness must not touch the throttler: it needs the database, and a database
// outage must not turn this probe red (kubelet would restart all pods).
// Readiness stays throttled and may fail with the database.
@SkipThrottle()
@Get()
liveness(): { status: 'ok' } {
return { status: 'ok' };
@@ -46,6 +37,7 @@ export class HealthController {
await withTimeout(
this.prisma.$queryRaw`SELECT 1`,
READINESS_QUERY_TIMEOUT_MS,
'Readiness query timed out',
);
return { status: 'ok' };
} catch (error: unknown) {
@@ -35,6 +35,12 @@ export class CreateInspectionDto {
@Min(0)
colonyStrength?: number;
@ApiPropertyOptional({ description: 'Anzahl Brutwaben' })
@IsOptional()
@IsInt()
@Min(0)
broodFrames?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
+12 -45
View File
@@ -1,55 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { configureApp } from './app.config';
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
// JWT_ACCESS_SECRET is the root of trust for both authentication (every
// access/MFA-challenge/SSO-completion token is signed with it) and MFA
// secret confidentiality (mfa-crypto.util.ts derives the AES key that
// encrypts stored TOTP secrets from it) - a short or guessable value here
// would let an attacker forge tokens for any user AND decrypt everyone's
// stored TOTP secret, not just guess a password. 32 chars matches the
// output of "openssl rand -hex 32" / "-base64 32", the standard way to
// generate one.
const MIN_JWT_SECRET_LENGTH = 32;
function assertProductionSettings(): void {
if (process.env.NODE_ENV !== 'production') {
return;
}
// Without it CORS and SSO redirects silently fall back to localhost.
if (!process.env.FRONTEND_ORIGIN?.startsWith('https://')) {
throw new Error(
'FRONTEND_ORIGIN must be set to an https:// origin in production',
);
}
if (process.env.COOKIE_SECURE === 'false') {
throw new Error('COOKIE_SECURE=false is for local HTTP development only');
}
}
function assertRequiredEnvVars(): void {
const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variable(s): ${missing.join(', ')}`,
);
}
const jwtSecret = process.env.JWT_ACCESS_SECRET as string;
if (jwtSecret.length < MIN_JWT_SECRET_LENGTH) {
throw new Error(
`JWT_ACCESS_SECRET must be at least ${MIN_JWT_SECRET_LENGTH} characters (got ${jwtSecret.length}) - generate one with e.g. "openssl rand -hex 32"`,
);
}
}
import { resolveRole } from './runtime/role';
import { isHttpRole, rootModuleFor } from './runtime/root-module';
import { assertStartupEnvironment } from './runtime/startup-checks';
async function bootstrap() {
assertRequiredEnvVars();
assertProductionSettings();
const role = resolveRole('main');
// resolveRole('main') admits no other role; this only narrows the type.
if (!isHttpRole(role)) {
throw new Error(`APICORE_ROLE=${role} cannot start dist/main.js`);
}
assertStartupEnvironment(role);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const app = await NestFactory.create<NestExpressApplication>(
rootModuleFor(role),
);
app.enableShutdownHooks();
configureApp(app);
@@ -2,25 +2,35 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { PrismaService } from '../prisma/prisma.service';
type CreateManyArg = { data: { userId: string; message: string }[] };
describe('NotificationsService', () => {
let service: NotificationsService;
let prisma: {
notification: {
create: jest.Mock;
createMany: jest.Mock;
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
user: { findMany: jest.Mock };
};
beforeEach(() => {
prisma = {
notification: {
create: jest.fn(),
createMany: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }),
),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new NotificationsService(prisma as unknown as PrismaService);
});
@@ -44,6 +54,93 @@ describe('NotificationsService', () => {
});
});
it('records through the transaction client when one is given', async () => {
const tx = { notification: { create: jest.fn() } };
await service.record(
{ priority: 'HINWEIS', message: 'Aufgabe fällig' },
tx as never,
);
expect(tx.notification.create).toHaveBeenCalledTimes(1);
expect(prisma.notification.create).not.toHaveBeenCalled();
});
describe('recordForPermission', () => {
it('notifies every active user whose active role holds the permission', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
await expect(
service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'Job „App-Backup“ fehlgeschlagen',
}),
).resolves.toBe(2);
const [[createArg]] = prisma.notification.createMany.mock.calls as [
[CreateManyArg],
];
expect(createArg.data.map((n) => n.userId)).toEqual(['u1', 'u2']);
expect(createArg.data[0]).toEqual({
userId: 'u1',
priority: 'WARNUNG',
message: 'Job „App-Backup“ fehlgeschlagen',
});
});
it('selects recipients like the permission guard: active user, active role, granted key', async () => {
await service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'x',
});
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: {
some: { permission: { key: 'backup:configure' } },
},
},
},
select: { id: true },
});
});
it('writes nothing and returns 0 when nobody holds the permission', async () => {
await expect(
service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'x',
}),
).resolves.toBe(0);
expect(prisma.notification.createMany).not.toHaveBeenCalled();
});
it('reads and writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
},
};
await expect(
service.recordForPermission(
'backup:configure',
{ priority: 'WARNUNG', message: 'x' },
tx as never,
),
).resolves.toBe(1);
expect(tx.notification.createMany).toHaveBeenCalledTimes(1);
expect(prisma.user.findMany).not.toHaveBeenCalled();
expect(prisma.notification.createMany).not.toHaveBeenCalled();
});
});
it('lists notifications for a user including broadcast notifications', async () => {
await service.findAll({ userId: 'user-1' });
@@ -4,7 +4,13 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Notification, NotificationPriority } from '../../generated/prisma';
import { DbClient } from '../prisma/db-client';
import { PermissionKey } from '../auth/permissions/permission-catalog';
import {
Notification,
NotificationPriority,
Prisma,
} from '../../generated/prisma';
export interface CreateNotificationInput {
userId?: string;
@@ -23,8 +29,42 @@ export interface NotificationFilter {
export class NotificationsService {
constructor(private readonly prisma: PrismaService) {}
async record(input: CreateNotificationInput): Promise<Notification> {
return this.prisma.notification.create({ data: { ...input } });
async record(
input: CreateNotificationInput,
tx?: Prisma.TransactionClient,
): Promise<Notification> {
const db: DbClient = tx ?? this.prisma;
return db.notification.create({ data: { ...input } });
}
/**
* One notification per user who may act on it: active account, active
* role, permission granted - the same rule the PermissionsGuard applies.
* Returns how many were written.
*/
async recordForPermission(
permission: PermissionKey,
input: Omit<CreateNotificationInput, 'userId'>,
tx?: Prisma.TransactionClient,
): Promise<number> {
const db: DbClient = tx ?? this.prisma;
const recipients = await db.user.findMany({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: { some: { permission: { key: permission } } },
},
},
select: { id: true },
});
if (recipients.length === 0) {
return 0;
}
const { count } = await db.notification.createMany({
data: recipients.map((recipient) => ({ ...input, userId: recipient.id })),
});
return count;
}
async findAll(filter: NotificationFilter = {}): Promise<Notification[]> {
@@ -0,0 +1,25 @@
import { assertTransactionClient } from './db-client';
describe('assertTransactionClient', () => {
it('refuses the root client and names the caller', () => {
const root = { $connect: jest.fn(), $transaction: jest.fn() };
expect(() =>
assertTransactionClient(root as never, 'DomainEventsService.recordAll'),
).toThrow('DomainEventsService.recordAll needs the transaction client');
});
it('accepts a transaction client that can open nested transactions (Prisma 7)', () => {
const nestingTx = { $transaction: jest.fn(), $executeRaw: jest.fn() };
expect(() =>
assertTransactionClient(nestingTx as never, 'caller'),
).not.toThrow();
});
it('accepts a plain transaction client', () => {
expect(() =>
assertTransactionClient({ $executeRaw: jest.fn() } as never, 'caller'),
).not.toThrow();
});
});
+23
View File
@@ -0,0 +1,23 @@
import { Prisma } from '../../generated/prisma';
import { PrismaService } from './prisma.service';
/**
* The root client or the client of an interactive transaction, for helpers
* that work on either (e.g. notifyWorker). A helper that must run inside the
* caller's business transaction takes Prisma.TransactionClient instead and
* checks it with assertTransactionClient.
*/
export type DbClient = PrismaService | Prisma.TransactionClient;
/**
* Throws if `db` is the root client: a write through it would commit on its
* own, even if the caller's business transaction rolls back. `$connect` is
* withheld from transaction clients; `$transaction` does not tell the two
* apart, because a Prisma 7 transaction client can open nested transactions.
* `caller` names the method in the error, e.g. "DomainEventsService.recordAll".
*/
export function assertTransactionClient(db: DbClient, caller: string): void {
if ('$connect' in db) {
throw new Error(`${caller} needs the transaction client`);
}
}
@@ -0,0 +1,130 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
DB_SESSION_OPTIONS,
DB_SESSION_TIME_ZONE,
dbSessionConfig,
} from './db-session';
describe('dbSessionConfig', () => {
const url = 'postgresql://apicore:s3cret@db:5432/apicore?schema=public';
it('pins every session to UTC through the startup options', () => {
expect(DB_SESSION_TIME_ZONE).toBe('UTC');
expect(DB_SESSION_OPTIONS).toBe('-c TimeZone=UTC');
expect(dbSessionConfig(url)).toEqual({
connectionString: url,
options: '-c TimeZone=UTC',
});
});
it('keeps a missing connection string for pg to report', () => {
expect(dbSessionConfig(undefined)).toEqual({
connectionString: undefined,
options: '-c TimeZone=UTC',
});
});
it.each([
'postgresql://apicore:s3cret@db/apicore?options=-c%20TimeZone%3DEurope%2FBerlin',
'postgresql://apicore:s3cret@db/apicore?schema=public&OPTIONS=-c%20x%3Dy',
'postgresql://apicore:s3cret@db/apicore?options',
'postgresql://apicore:s3cret@db/apicore?options&schema=public',
])(
'refuses a URL with its own options - pg would let them override the pin: %s',
(withOptions) => {
expect(() => dbSessionConfig(withOptions)).toThrow(
'DATABASE_URL must not set "options"',
);
},
);
it.each([
'postgresql://apicore:s3cret@db/apicore?schema=public&optionsx=1',
'postgresql://apicore:s3cret@db/options?schema=public',
])('accepts a URL that only looks similar: %s', (similar) => {
expect(dbSessionConfig(similar).options).toBe('-c TimeZone=UTC');
});
it('never echoes the connection string (password) in the error', () => {
let message = '';
try {
dbSessionConfig('postgresql://apicore:s3cret@db/apicore?options=x');
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain('DATABASE_URL must not set "options"');
expect(message).not.toContain('s3cret');
});
});
/**
* Guard: every Postgres client in src gets its settings from
* dbSessionConfig, so no session escapes the UTC pin. prisma/seed.ts is the
* documented exception: it lives outside src (its build cannot import from
* src) and writes only through Prisma, whose timestamps do not depend on
* the session time zone.
*/
describe('database clients in src', () => {
const SRC = join(__dirname, '..');
const CLIENT_CONSTRUCTION =
/new\s+(PrismaPg|Pool|Client)\s*\(([\s\S]{0,120})/g;
const FROM_SESSION_CONFIG = /^\s*(?:\{\s*\.\.\.)?\s*dbSessionConfig\(/;
const IMPORTS_PG = /from\s+['"]pg['"]/;
/** `new X(` calls that do not start from dbSessionConfig(...). */
function clientsWithoutSessionConfig(source: string): string[] {
const usesPg = IMPORTS_PG.test(source);
return [...source.matchAll(CLIENT_CONSTRUCTION)]
.filter(([, type]) => type === 'PrismaPg' || usesPg)
.filter(([, , args]) => !FROM_SESSION_CONFIG.test(args))
.map(([, type]) => type);
}
it('builds every PrismaPg, pg Pool and pg Client from dbSessionConfig', () => {
const offenders = readdirSync(SRC, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.ts') && !file.endsWith('.spec.ts'))
.flatMap((file) =>
clientsWithoutSessionConfig(readFileSync(join(SRC, file), 'utf8')).map(
(client) => `${file}: new ${client}`,
),
);
expect(offenders).toEqual([]);
});
it.each([
[
'a pg Pool',
"import { Pool } from 'pg';\nconst pool = new Pool({ connectionString: url });",
],
[
'a pg Client',
"import { Client } from 'pg';\nconst client = new Client({ connectionString, keepAlive: true });",
],
[
'a PrismaPg adapter',
'const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });',
],
])('flags %s built without the session config', (_name, source) => {
expect(clientsWithoutSessionConfig(source)).toHaveLength(1);
});
it.each([
[
'the Prisma adapter',
'const adapter = new PrismaPg(dbSessionConfig(process.env.DATABASE_URL));',
],
[
'the LISTEN client',
"import { Client } from 'pg';\nreturn new Client({\n ...dbSessionConfig(connectionString),\n keepAlive: true,\n});",
],
[
'a Client that is not pg',
"import { Client } from 'openid-client';\nconst oidc = new Client(metadata);",
],
])('accepts %s', (_name, source) => {
expect(clientsWithoutSessionConfig(source)).toEqual([]);
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Every database session runs in UTC. Columns are `timestamp without time
* zone`: Prisma fills @default(now()) and writes Dates as UTC wall clock,
* while SQL now() (claims, backoff, throttler) is stored in the session's
* time zone. Only a UTC session keeps both on one clock, whatever the
* server or database default is.
*/
export const DB_SESSION_TIME_ZONE = 'UTC';
export const DB_SESSION_OPTIONS = `-c TimeZone=${DB_SESSION_TIME_ZONE}`;
export interface DbSessionConfig {
connectionString: string | undefined;
/** Startup options sent with every new connection. */
options: string;
}
/**
* Connection settings for every Postgres client of the app (Prisma pool,
* LISTEN client). pg lets an `options` parameter in the URL override the
* config, which would silently drop the UTC pin - such a URL is refused.
*/
export function dbSessionConfig(
connectionString: string | undefined,
): DbSessionConfig {
if (
connectionString !== undefined &&
/[?&]options(?:=|&|$)/i.test(connectionString)
) {
throw new Error(
'DATABASE_URL must not set "options": the session time zone is pinned to UTC in db-session.ts',
);
}
return { connectionString, options: DB_SESSION_OPTIONS };
}
+3 -1
View File
@@ -1,6 +1,7 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../../generated/prisma';
import { dbSessionConfig } from './db-session';
@Injectable()
export class PrismaService
@@ -9,7 +10,8 @@ export class PrismaService
{
constructor() {
super({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
// Every pooled connection runs in UTC, see db-session.ts.
adapter: new PrismaPg(dbSessionConfig(process.env.DATABASE_URL)),
});
}
@@ -1,4 +1,5 @@
import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { Controller, Get, NotFoundException, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { Throttle } from '@nestjs/throttler';
import { ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/decorators/public.decorator';
@@ -7,6 +8,11 @@ import { PublicBatch, PublicService } from './public.service';
// What a printed lot number can look like - anything else is not a lot.
const LOT_NUMBER_PATTERN = /^[A-Za-z0-9._/-]{1,64}$/;
// Nothing on this page is personal or stand-specific (see PublicOrigin), so
// a successful answer may sit in the browser and at the Cloudflare edge. Only
// the 200 gets this header - errors keep the global no-store.
export const PUBLIC_BATCH_CACHE_CONTROL = 'public, max-age=300, s-maxage=3600';
@ApiTags('public')
@Public()
@Controller('public/batches')
@@ -17,10 +23,16 @@ export class PublicController {
// so it gets a tighter per-IP limit than logged-in traffic.
@Throttle({ default: { limit: 30, ttl: 60000 } })
@Get(':lotNumber')
findByLot(@Param('lotNumber') lotNumber: string): Promise<PublicBatch> {
async findByLot(
@Param('lotNumber') lotNumber: string,
@Res({ passthrough: true }) res: Response,
): Promise<PublicBatch> {
if (!LOT_NUMBER_PATTERN.test(lotNumber)) {
throw new NotFoundException();
}
return this.publicService.findBatchByLot(lotNumber);
const batch = await this.publicService.findBatchByLot(lotNumber);
res.setHeader('Cache-Control', PUBLIC_BATCH_CACHE_CONTROL);
res.removeHeader('Pragma');
return batch;
}
}
@@ -3,6 +3,26 @@ import { PublicService } from './public.service';
import { PrismaService } from '../prisma/prisma.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
function lotWithApiaries(apiaries: Record<string, unknown>[]) {
return {
lotNumber: 'WH-1',
date: new Date('2026-08-20'),
bestBeforeDate: new Date('2028-08-20'),
jarSize: 500,
product: null,
honeyBatch: {
honeyType: null,
waterContent: null,
extraction: {
date: null,
harvests: apiaries.map((apiary) => ({
harvest: { date: new Date('2026-08-12'), apiary },
})),
},
},
};
}
describe('PublicService', () => {
let service: PublicService;
let prisma: { bottlingBatch: { findUnique: jest.Mock } };
@@ -15,6 +35,7 @@ describe('PublicService', () => {
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
originCountry: 'Deutschland',
}),
};
service = new PublicService(
@@ -30,7 +51,7 @@ describe('PublicService', () => {
);
});
it('shapes the traceability chain into public-safe fields, deduplicating apiaries', async () => {
it('shapes the traceability chain into public-safe fields, deduplicating regions', async () => {
const harvestDate1 = new Date('2026-08-05');
const harvestDate2 = new Date('2026-08-12');
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
@@ -51,7 +72,11 @@ describe('PublicService', () => {
apiary: {
id: 'apiary-1',
name: 'Stand Wald',
location: 'Wald',
location: 'Waldweg 3',
address: 'Waldweg 3, 12345 Ort',
latitude: 51.2,
longitude: 9.4,
publicLocationLabel: ' Nordhessen ',
},
},
},
@@ -61,7 +86,11 @@ describe('PublicService', () => {
apiary: {
id: 'apiary-1',
name: 'Stand Wald',
location: 'Wald',
location: 'Waldweg 3',
address: 'Waldweg 3, 12345 Ort',
latitude: 51.2,
longitude: 9.4,
publicLocationLabel: ' Nordhessen ',
},
},
},
@@ -75,7 +104,10 @@ describe('PublicService', () => {
expect(result.lotNumber).toBe('WH-2026-001');
expect(result.productName).toBe('Sommerhonig 500g');
expect(result.waterContent).toBe(16.8);
expect(result.apiaries).toEqual([{ name: 'Stand Wald', location: 'Wald' }]);
expect(result.origin).toEqual({
regions: ['Nordhessen'],
country: 'Deutschland',
});
expect(result.harvestDates).toEqual([harvestDate1, harvestDate2]);
});
@@ -135,4 +167,56 @@ describe('PublicService', () => {
expect(serialized).not.toContain('Nick Muster');
expect(serialized).not.toContain('Musterweg');
});
it('never returns apiary name, location, address or coordinates', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(
lotWithApiaries([
{
id: 'a1',
name: 'Stand Wald',
location: 'Waldweg 3',
address: 'Waldweg 3, 12345 Ort',
latitude: 51.2,
longitude: 9.4,
publicLocationLabel: 'Nordhessen',
},
]),
);
const json = JSON.stringify(await service.findBatchByLot('WH-1'));
for (const secret of ['Stand Wald', 'Waldweg 3', '51.2', '9.4', 'Ort']) {
expect(json).not.toContain(secret);
}
expect(json).not.toContain('apiaries');
});
it('returns distinct sorted labels plus the country', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(
lotWithApiaries([
{ id: 'a1', publicLocationLabel: 'Südhessen' },
{ id: 'a2', publicLocationLabel: ' Nordhessen' },
{ id: 'a3', publicLocationLabel: 'Südhessen ' },
{ id: 'a4', publicLocationLabel: ' ' },
{ id: 'a5', publicLocationLabel: null },
]),
);
const result = await service.findBatchByLot('WH-1');
expect(result.origin).toEqual({
regions: ['Nordhessen', 'Südhessen'],
country: 'Deutschland',
});
});
it('returns only the country when no label is set', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(
lotWithApiaries([{ id: 'a1', publicLocationLabel: null }]),
);
const result = await service.findBatchByLot('WH-1');
expect(result.origin).toEqual({ regions: [], country: 'Deutschland' });
});
});
+15 -12
View File
@@ -2,9 +2,12 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
export interface PublicApiaryOrigin {
name: string;
location: string | null;
// Honey apiaries are a theft target: the public page names a region
// (publicLocationLabel) and a country - never a stand's name, address or
// coordinates.
export interface PublicOrigin {
regions: string[];
country: string;
}
export interface PublicSocialLinks {
@@ -25,7 +28,7 @@ export interface PublicBatch {
waterContent: number | null;
extractedDate: Date | null;
harvestDates: Date[];
apiaries: PublicApiaryOrigin[];
origin: PublicOrigin;
socialLinks: PublicSocialLinks;
}
@@ -61,13 +64,13 @@ export class PublicService {
const harvests = batch.honeyBatch.extraction.harvests.map(
(entry) => entry.harvest,
);
const apiaryByName = new Map<string, PublicApiaryOrigin>();
for (const harvest of harvests) {
apiaryByName.set(harvest.apiary.id, {
name: harvest.apiary.name,
location: harvest.apiary.location,
});
}
const regions = [
...new Set(
harvests
.map((harvest) => harvest.apiary.publicLocationLabel?.trim() ?? '')
.filter((label) => label !== ''),
),
].sort((a, b) => a.localeCompare(b, 'de'));
const harvestDates = [...new Set(harvests.map((h) => h.date.getTime()))]
.sort((a, b) => a - b)
.map((time) => new Date(time));
@@ -85,7 +88,7 @@ export class PublicService {
waterContent: batch.honeyBatch.waterContent,
extractedDate: batch.honeyBatch.extraction.date,
harvestDates,
apiaries: [...apiaryByName.values()],
origin: { regions, country: settings.originCountry },
socialLinks: {
youtube: settings.youtubeUrl,
instagram: settings.instagramUrl,
+58
View File
@@ -0,0 +1,58 @@
import { resolveRole } from './role';
describe('resolveRole', () => {
it('defaults per entry point outside production', () => {
expect(resolveRole('main', {})).toBe('all');
expect(resolveRole('worker', {})).toBe('worker');
expect(resolveRole('cli', {})).toBe('job');
});
it('treats a blank APICORE_ROLE as unset', () => {
expect(resolveRole('main', { APICORE_ROLE: ' ' })).toBe('all');
});
it('requires APICORE_ROLE in production', () => {
expect(() => resolveRole('main', { NODE_ENV: 'production' })).toThrow(
'APICORE_ROLE is required in production',
);
});
it('rejects all in production', () => {
expect(() =>
resolveRole('main', { NODE_ENV: 'production', APICORE_ROLE: 'all' }),
).toThrow('APICORE_ROLE=all is for local development and tests only');
});
it('rejects an unknown role', () => {
expect(() => resolveRole('main', { APICORE_ROLE: 'admin' })).toThrow(
'APICORE_ROLE must be one of api, worker, job, all',
);
});
it('rejects a role that does not belong to the entry point', () => {
expect(() => resolveRole('main', { APICORE_ROLE: 'worker' })).toThrow(
'APICORE_ROLE=worker cannot start dist/main.js',
);
expect(() => resolveRole('worker', { APICORE_ROLE: 'api' })).toThrow(
'APICORE_ROLE=api cannot start dist/worker.js',
);
expect(() => resolveRole('cli', { APICORE_ROLE: 'all' })).toThrow(
'APICORE_ROLE=all cannot start dist/cli.js',
);
});
it('accepts api for main in production', () => {
expect(
resolveRole('main', { NODE_ENV: 'production', APICORE_ROLE: 'api' }),
).toBe('api');
});
it('accepts worker and job for their entry points in production', () => {
expect(
resolveRole('worker', { NODE_ENV: 'production', APICORE_ROLE: 'worker' }),
).toBe('worker');
expect(
resolveRole('cli', { NODE_ENV: 'production', APICORE_ROLE: 'job' }),
).toBe('job');
});
});
+57
View File
@@ -0,0 +1,57 @@
export const APICORE_ROLES = ['api', 'worker', 'job', 'all'] as const;
export type ApicoreRole = (typeof APICORE_ROLES)[number];
export type EntryPoint = 'main' | 'worker' | 'cli';
interface EntryPointRules {
readonly file: string;
readonly defaultRole: ApicoreRole;
readonly allowed: readonly ApicoreRole[];
}
const ENTRY_POINTS: Readonly<Record<EntryPoint, EntryPointRules>> = {
main: { file: 'dist/main.js', defaultRole: 'all', allowed: ['api', 'all'] },
worker: {
file: 'dist/worker.js',
defaultRole: 'worker',
allowed: ['worker'],
},
cli: { file: 'dist/cli.js', defaultRole: 'job', allowed: ['job'] },
};
function isApicoreRole(value: string): value is ApicoreRole {
return (APICORE_ROLES as readonly string[]).includes(value);
}
/**
* Resolves the role this process runs as from APICORE_ROLE and the entry
* point it was started from. Outside production the entry point's default
* applies when the variable is unset; production deployments must name the
* role explicitly (and never "all", which bundles API and worker in one
* process) so a misconfigured pod fails loudly instead of running the wrong
* workload.
*/
export function resolveRole(
entry: EntryPoint,
env: NodeJS.ProcessEnv = process.env,
): ApicoreRole {
const rules = ENTRY_POINTS[entry];
const isProduction = env.NODE_ENV === 'production';
const raw = env.APICORE_ROLE?.trim();
if (!raw) {
if (isProduction) {
throw new Error('APICORE_ROLE is required in production');
}
return rules.defaultRole;
}
if (!isApicoreRole(raw)) {
throw new Error(`APICORE_ROLE must be one of ${APICORE_ROLES.join(', ')}`);
}
if (isProduction && raw === 'all') {
throw new Error('APICORE_ROLE=all is for local development and tests only');
}
if (!rules.allowed.includes(raw)) {
throw new Error(`APICORE_ROLE=${raw} cannot start ${rules.file}`);
}
return raw;
}
@@ -0,0 +1,109 @@
import { forwardRef, Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { AllInOneModule } from '../all-in-one.module';
import { AppModule } from '../app.module';
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
import { WorkerModule } from '../worker/worker.module';
import { WorkerRuntimeModule } from '../worker/worker-runtime.module';
import { isHttpRole, rootModuleFor } from './root-module';
const importsOf = (module: object): unknown[] =>
(Reflect.getMetadata('imports', module) as unknown[] | undefined) ?? [];
/**
* Every module class reachable from `root` through imports - including
* the imports of dynamic modules (also async ones like
* ConfigModule.forRoot) and forwardRef()s.
*/
async function reachableModules(root: unknown): Promise<Set<unknown>> {
const seen = new Set<unknown>();
async function visit(entry: unknown): Promise<void> {
const resolved: unknown = await entry;
// A circular file import can leave an undefined slot behind.
if (typeof resolved !== 'object' && typeof resolved !== 'function') {
return;
}
if (resolved === null) {
return;
}
if ('forwardRef' in resolved) {
return visit((resolved as { forwardRef: () => unknown }).forwardRef());
}
if ('module' in resolved) {
const dynamic = resolved as { module: unknown; imports?: unknown[] };
await visit(dynamic.module);
for (const imported of dynamic.imports ?? []) {
await visit(imported);
}
return;
}
if (seen.has(resolved)) {
return;
}
seen.add(resolved);
for (const imported of importsOf(resolved)) {
await visit(imported);
}
}
await visit(root);
return seen;
}
// The API role never starts background loops: only the worker and "all"
// (local development and tests) load the worker runtime.
describe('rootModuleFor', () => {
it('the api module never imports the worker runtime, not even transitively', async () => {
const reachable = await reachableModules(AppModule);
expect(reachable).not.toContain(WorkerRuntimeModule);
// The walk really goes deep: into feature modules and dynamic modules.
expect(reachable).toContain(DiscoveryModule);
expect(reachable).toContain(ThrottlerStorageModule);
});
it('api starts the API module alone', () => {
expect(rootModuleFor('api')).toBe(AppModule);
});
it('all adds the worker runtime to the API', () => {
expect(rootModuleFor('all')).toBe(AllInOneModule);
expect(importsOf(AllInOneModule)).toEqual([AppModule, WorkerRuntimeModule]);
});
it('the worker runs the runtime without the HTTP API', async () => {
const reachable = await reachableModules(WorkerModule);
expect(reachable).toContain(WorkerRuntimeModule);
expect(reachable).not.toContain(AppModule);
});
});
describe('reachableModules', () => {
@Module({ imports: [WorkerRuntimeModule] })
class InnerModule {}
@Module({ imports: [forwardRef(() => InnerModule)] })
class ViaForwardRefModule {}
@Module({
imports: [Promise.resolve({ module: class {}, imports: [InnerModule] })],
})
class ViaAsyncDynamicModule {}
it.each([
['a plain import chain', InnerModule],
['a forwardRef', ViaForwardRefModule],
['the imports of an async dynamic module', ViaAsyncDynamicModule],
])('finds the worker runtime behind %s', async (_case, root) => {
expect(await reachableModules(root)).toContain(WorkerRuntimeModule);
});
});
describe('isHttpRole', () => {
it('admits exactly the roles dist/main.js starts', () => {
expect(isHttpRole('api')).toBe(true);
expect(isHttpRole('all')).toBe(true);
expect(isHttpRole('worker')).toBe(false);
expect(isHttpRole('job')).toBe(false);
});
});
@@ -0,0 +1,20 @@
import type { Type } from '@nestjs/common';
import { AllInOneModule } from '../all-in-one.module';
import { AppModule } from '../app.module';
import type { ApicoreRole } from './role';
/** The roles dist/main.js starts; resolveRole('main') admits no other. */
export type HttpRole = Extract<ApicoreRole, 'api' | 'all'>;
export function isHttpRole(role: ApicoreRole): role is HttpRole {
return role === 'api' || role === 'all';
}
/**
* The root module of dist/main.js. "api" is the HTTP API alone and never
* starts background loops, so API pods scale freely; "all" adds the worker
* runtime for local development and tests.
*/
export function rootModuleFor(role: HttpRole): Type<unknown> {
return role === 'all' ? AllInOneModule : AppModule;
}
@@ -0,0 +1,324 @@
import { assertStartupEnvironment } from './startup-checks';
const SECRET = 'x'.repeat(32);
const BASE_ENV = { DATABASE_URL: 'postgres://db', JWT_ACCESS_SECRET: SECRET };
const KEYS = {
MFA_ENCRYPTION_KEY: 'm'.repeat(32),
WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32),
BACKUP_ENCRYPTION_KEY: 'b'.repeat(32),
};
const PROD_ENV = {
...BASE_ENV,
...KEYS,
JWT_REFRESH_SECRET: 'r'.repeat(32),
NODE_ENV: 'production',
FRONTEND_ORIGIN: 'https://app.example.com',
};
describe('assertStartupEnvironment', () => {
it('accepts a complete development environment', () => {
expect(() => assertStartupEnvironment('api', BASE_ENV)).not.toThrow();
});
it('lists missing DATABASE_URL', () => {
expect(() =>
assertStartupEnvironment('api', { JWT_ACCESS_SECRET: SECRET }),
).toThrow('Missing required environment variable(s): DATABASE_URL');
});
it('lists every missing variable', () => {
expect(() => assertStartupEnvironment('worker', {})).toThrow(
'Missing required environment variable(s): DATABASE_URL, JWT_ACCESS_SECRET',
);
});
it('rejects a JWT secret shorter than 32 characters', () => {
expect(() =>
assertStartupEnvironment('api', {
...BASE_ENV,
JWT_ACCESS_SECRET: 'short',
}),
).toThrow('JWT_ACCESS_SECRET must be at least 32 characters (got 5)');
});
it('accepts a complete production environment', () => {
expect(() => assertStartupEnvironment('api', PROD_ENV)).not.toThrow();
});
it('requires an https FRONTEND_ORIGIN in production', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
FRONTEND_ORIGIN: 'http://app.example.com',
}),
).toThrow(
'FRONTEND_ORIGIN must be set to an https:// origin in production',
);
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
FRONTEND_ORIGIN: undefined,
}),
).toThrow(
'FRONTEND_ORIGIN must be set to an https:// origin in production',
);
});
it('rejects COOKIE_SECURE=false in production', () => {
expect(() =>
assertStartupEnvironment('api', { ...PROD_ENV, COOKIE_SECURE: 'false' }),
).toThrow('COOKIE_SECURE=false is for local HTTP development only');
});
it('allows an http FRONTEND_ORIGIN and COOKIE_SECURE=false outside production', () => {
expect(() =>
assertStartupEnvironment('api', {
...BASE_ENV,
FRONTEND_ORIGIN: 'http://localhost:3000',
COOKIE_SECURE: 'false',
}),
).not.toThrow();
});
describe('encryption keys', () => {
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY', () => {
expect(() =>
assertStartupEnvironment('worker', {
...PROD_ENV,
MFA_ENCRYPTION_KEY: undefined,
WEBHOOK_ENCRYPTION_KEY: undefined,
BACKUP_ENCRYPTION_KEY: undefined,
}),
).toThrow(
'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY',
);
expect(() =>
assertStartupEnvironment('job', {
...PROD_ENV,
WEBHOOK_ENCRYPTION_KEY: '',
}),
).toThrow(
'Missing required environment variable(s): WEBHOOK_ENCRYPTION_KEY',
);
});
it('refuses keys shorter than 32 characters, *_PREVIOUS included', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
MFA_ENCRYPTION_KEY: 'short',
}),
).toThrow('MFA_ENCRYPTION_KEY must be at least 32 characters');
expect(() =>
assertStartupEnvironment('api', {
...BASE_ENV,
BACKUP_ENCRYPTION_KEY_PREVIOUS: 'short',
}),
).toThrow(
'BACKUP_ENCRYPTION_KEY_PREVIOUS must be at least 32 characters',
);
});
it('refuses two purposes sharing one key or a key equal to JWT_ACCESS_SECRET', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY,
}),
).toThrow(
'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY and BACKUP_ENCRYPTION_KEY must be pairwise different',
);
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
BACKUP_ENCRYPTION_KEY: SECRET,
}),
).toThrow(
'BACKUP_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET',
);
});
it('refuses a key equal to JWT_REFRESH_SECRET', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
MFA_ENCRYPTION_KEY: PROD_ENV.JWT_REFRESH_SECRET,
}),
).toThrow(
'MFA_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET',
);
});
it('refuses a *_PREVIOUS key equal to a JWT secret, like a current key', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
WEBHOOK_ENCRYPTION_KEY_PREVIOUS: SECRET,
BACKUP_ENCRYPTION_KEY_PREVIOUS: PROD_ENV.JWT_REFRESH_SECRET,
}),
).toThrow(
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS, BACKUP_ENCRYPTION_KEY_PREVIOUS must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET',
);
});
it('accepts *_PREVIOUS keys of their own', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
MFA_ENCRYPTION_KEY_PREVIOUS: 'p'.repeat(32),
}),
).not.toThrow();
});
it('never puts a key into its errors', () => {
const shared = 'shared-secret-value-0123456789abcdef';
let message = '';
try {
assertStartupEnvironment('api', {
...PROD_ENV,
MFA_ENCRYPTION_KEY: shared,
WEBHOOK_ENCRYPTION_KEY: shared,
});
} catch (error) {
message = (error as Error).message;
}
expect(message).not.toBe('');
expect(message).not.toContain(shared);
});
});
describe('example placeholders', () => {
const PLACEHOLDER =
'change-me-to-a-random-value-at-least-32-characters-long';
it.each([
'JWT_ACCESS_SECRET',
'JWT_REFRESH_SECRET',
'MFA_ENCRYPTION_KEY',
'WEBHOOK_ENCRYPTION_KEY',
'BACKUP_ENCRYPTION_KEY',
'MFA_ENCRYPTION_KEY_PREVIOUS',
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS',
'BACKUP_ENCRYPTION_KEY_PREVIOUS',
])('production refuses a change-me placeholder in %s', (name) => {
expect(() =>
assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }),
).toThrow(
`Secret(s) still set to an example placeholder: ${name} - generate real values, e.g. "openssl rand -hex 32"`,
);
});
it('matches the placeholder case-insensitively and lists every hit', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
JWT_REFRESH_SECRET: 'Change-Me-refresh-secret',
MFA_ENCRYPTION_KEY: 'CHANGE-ME-to-a-third-random-value-at-least-32',
}),
).toThrow(
'Secret(s) still set to an example placeholder: JWT_REFRESH_SECRET, MFA_ENCRYPTION_KEY',
);
});
it('names the variable, never the value', () => {
let message = '';
try {
assertStartupEnvironment('api', {
...PROD_ENV,
BACKUP_ENCRYPTION_KEY: PLACEHOLDER,
});
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain('BACKUP_ENCRYPTION_KEY');
expect(message).not.toContain(PLACEHOLDER);
});
it('production refuses a change-me OIDC_CLIENT_SECRET when SSO is configured', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
OIDC_CLIENT_SECRET: 'change-me-entra-client-secret',
}),
).toThrow(
'Secret(s) still set to an example placeholder: OIDC_CLIENT_SECRET',
);
});
it('accepts production without SSO (OIDC_CLIENT_SECRET unset)', () => {
expect(PROD_ENV).not.toHaveProperty('OIDC_CLIENT_SECRET');
expect(() => assertStartupEnvironment('api', PROD_ENV)).not.toThrow();
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
OIDC_CLIENT_SECRET: 'a-real-entra-client-secret-value',
}),
).not.toThrow();
});
it('production refuses a change-me password inside DATABASE_URL, without echoing the URL', () => {
const url =
'postgresql://apicore:Change-Me-postgres-password@apicore-postgres:5432/apicore?schema=public';
let message = '';
try {
assertStartupEnvironment('api', { ...PROD_ENV, DATABASE_URL: url });
} catch (error) {
message = (error as Error).message;
}
expect(message).toBe(
'Secret(s) still set to an example placeholder: DATABASE_URL - generate real values, e.g. "openssl rand -hex 32"',
);
expect(message).not.toContain('postgres-password');
expect(message).not.toContain('apicore-postgres');
});
it('finds a percent-encoded change-me password in DATABASE_URL', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
DATABASE_URL: 'postgresql://u:%63hange-me-pw@db:5432/apicore',
}),
).toThrow('Secret(s) still set to an example placeholder: DATABASE_URL');
});
it('accepts a DATABASE_URL with a real password, none, or one it cannot parse', () => {
for (const DATABASE_URL of [
'postgresql://apicore:s3cr3t-generated-value@db:5432/apicore',
'postgresql://change-me-user@db:5432/apicore',
'postgresql://db/apicore',
'not a url',
]) {
expect(() =>
assertStartupEnvironment('api', { ...PROD_ENV, DATABASE_URL }),
).not.toThrow();
}
});
it('lists DATABASE_URL together with other placeholder secrets', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
DATABASE_URL: 'postgresql://u:change-me@db:5432/apicore',
OIDC_CLIENT_SECRET: 'change-me-entra-client-secret',
}),
).toThrow(
'Secret(s) still set to an example placeholder: DATABASE_URL, OIDC_CLIENT_SECRET',
);
});
it('leaves placeholders alone outside production', () => {
expect(() =>
assertStartupEnvironment('api', {
...BASE_ENV,
JWT_ACCESS_SECRET: PLACEHOLDER,
MFA_ENCRYPTION_KEY: `${PLACEHOLDER}-mfa`,
OIDC_CLIENT_SECRET: 'change-me-entra-client-secret',
DATABASE_URL: 'postgresql://u:change-me@db:5432/apicore',
}),
).not.toThrow();
});
});
});
@@ -0,0 +1,145 @@
import {
ENCRYPTION_KEY_VARIABLES,
PREVIOUS_KEY_SUFFIX,
readRingSecrets,
} from '../common/crypto/key-ring';
import type { ApicoreRole } from './role';
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
// Production must not fall back to keys derived from JWT_ACCESS_SECRET:
// rotating the JWT secret would then make stored secrets unreadable, and a
// leaked JWT secret would expose them too (common/crypto/key-ring.ts).
const ENCRYPTION_KEYS = Object.values(ENCRYPTION_KEY_VARIABLES);
const PREVIOUS_ENCRYPTION_KEYS = ENCRYPTION_KEYS.map(
(name) => `${name}${PREVIOUS_KEY_SUFFIX}`,
);
// The example files (.env.example, secret.example.yaml) ship "change-me…"
// values long enough to pass every length rule; production must not run on
// them. OIDC_CLIENT_SECRET only counts when set (SSO is optional).
const PLACEHOLDER_PATTERN = /^change-me/i;
const SECRET_VARIABLES = [
'JWT_ACCESS_SECRET',
'JWT_REFRESH_SECRET',
...ENCRYPTION_KEYS,
...PREVIOUS_ENCRYPTION_KEYS,
'OIDC_CLIENT_SECRET',
];
// JWT_ACCESS_SECRET signs every access/MFA-challenge/SSO-completion token -
// a short or guessable value would let an attacker forge tokens for any
// user. It also still opens MFA and webhook secrets stored before the key
// ring (until the rekey job reseals them). 32 chars matches the output of
// "openssl rand -hex 32" / "-base64 32", the standard way to generate one.
const MIN_JWT_SECRET_LENGTH = 32;
function requiredEnvVars(env: NodeJS.ProcessEnv): readonly string[] {
return env.NODE_ENV === 'production'
? [...REQUIRED_ENV_VARS, ...ENCRYPTION_KEYS]
: REQUIRED_ENV_VARS;
}
function assertProductionSettings(env: NodeJS.ProcessEnv): void {
if (env.NODE_ENV !== 'production') {
return;
}
// Without it CORS and SSO redirects silently fall back to localhost.
if (!env.FRONTEND_ORIGIN?.startsWith('https://')) {
throw new Error(
'FRONTEND_ORIGIN must be set to an https:// origin in production',
);
}
if (env.COOKIE_SECURE === 'false') {
throw new Error('COOKIE_SECURE=false is for local HTTP development only');
}
}
/**
* The password inside DATABASE_URL. An unparsable URL counts as "no
* password" here - Prisma reports it - and nothing of the URL is echoed.
*/
function databasePassword(env: NodeJS.ProcessEnv): string {
try {
return decodeURIComponent(new URL(env.DATABASE_URL ?? '').password);
} catch {
return '';
}
}
function assertNoPlaceholderSecrets(env: NodeJS.ProcessEnv): void {
if (env.NODE_ENV !== 'production') {
return;
}
const placeholders = [
...(PLACEHOLDER_PATTERN.test(databasePassword(env))
? ['DATABASE_URL']
: []),
...SECRET_VARIABLES.filter((name) =>
PLACEHOLDER_PATTERN.test(env[name] ?? ''),
),
];
if (placeholders.length > 0) {
throw new Error(
`Secret(s) still set to an example placeholder: ${placeholders.join(', ')} - generate real values, e.g. "openssl rand -hex 32"`,
);
}
}
function assertRequiredEnvVars(env: NodeJS.ProcessEnv): void {
const missing = requiredEnvVars(env).filter((name) => !env[name]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variable(s): ${missing.join(', ')}`,
);
}
const jwtSecret = env.JWT_ACCESS_SECRET as string;
if (jwtSecret.length < MIN_JWT_SECRET_LENGTH) {
throw new Error(
`JWT_ACCESS_SECRET must be at least ${MIN_JWT_SECRET_LENGTH} characters (got ${jwtSecret.length}) - generate one with e.g. "openssl rand -hex 32"`,
);
}
}
/**
* Lengths are checked like at runtime (incl. *_PREVIOUS), only earlier. In
* production every purpose needs a key of its own, so one leaked or rotated
* secret never touches another purpose. Errors name variables, never values.
*/
function assertEncryptionKeys(env: NodeJS.ProcessEnv): void {
for (const variable of ENCRYPTION_KEYS) {
readRingSecrets(variable, env);
}
if (env.NODE_ENV !== 'production') {
return;
}
const keys = ENCRYPTION_KEYS.map((name) => env[name]);
if (new Set(keys).size !== keys.length) {
throw new Error(
`${ENCRYPTION_KEYS.slice(0, -1).join(', ')} and ${ENCRYPTION_KEYS.at(-1)} must be pairwise different`,
);
}
const jwtSecrets = [env.JWT_ACCESS_SECRET, env.JWT_REFRESH_SECRET];
// *_PREVIOUS keys still open data, so the same rule applies to them.
const reused = [...ENCRYPTION_KEYS, ...PREVIOUS_ENCRYPTION_KEYS].filter(
(name) => env[name] && jwtSecrets.includes(env[name]),
);
if (reused.length > 0) {
throw new Error(
`${reused.join(', ')} must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET`,
);
}
}
/**
* Startup checks shared by every entry point (API, worker, CLI job). All
* roles load the same modules and secrets today, so the checks do not differ
* per role yet; the role is part of the signature so role-specific checks can
* be added without touching the callers.
*/
export function assertStartupEnvironment(
_role: ApicoreRole,
env: NodeJS.ProcessEnv = process.env,
): void {
assertRequiredEnvVars(env);
assertProductionSettings(env);
assertNoPlaceholderSecrets(env);
assertEncryptionKeys(env);
}
@@ -3,9 +3,12 @@ import {
IsOptional,
IsString,
IsUrl,
Matches,
MaxLength,
MinLength,
ValidateIf,
} from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
// An empty string clears the link (stored as null); anything else must be a
// real URL - ValidateIf skips the @IsUrl check only for the empty-string
@@ -52,4 +55,15 @@ export class UpdateSiteSettingsDto {
@IsString()
@MaxLength(300)
holderAddress?: string;
@ApiPropertyOptional({
description: 'Herkunftsland auf der öffentlichen Herkunftsseite',
})
@IsOptionalNotNull()
@IsString()
@MinLength(2)
@MaxLength(80)
// At least two visible characters - the value is trimmed before saving.
@Matches(/^\s*\S.*\S\s*$/s)
originCountry?: string;
}
@@ -60,6 +60,7 @@ export class SiteSettingsService {
dto.holderAddress !== undefined
? emptyToNull(dto.holderAddress.trim())
: undefined,
originCountry: dto.originCountry?.trim(),
},
});
await this.auditLog.record({
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export const MAX_PRODUCT_LENGTH = 200;
function trimString({ value }: TransformFnParams): unknown {
return typeof value === 'string' ? value.trim() : (value as unknown);
}
export class LastByProductQueryDto {
@ApiProperty({
description: 'Präparatename (ohne Beachtung der Groß-/Kleinschreibung)',
example: 'Oxuvar',
maxLength: MAX_PRODUCT_LENGTH,
})
@Transform(trimString)
@IsString({ message: 'product muss ein Text sein' })
@IsNotEmpty({ message: 'product darf nicht leer sein' })
@MaxLength(MAX_PRODUCT_LENGTH, {
message: `product darf höchstens ${MAX_PRODUCT_LENGTH} Zeichen lang sein`,
})
product!: string;
}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
/** Fields of a previous treatment that a new one of the same product reuses. */
export interface TreatmentPrefill {
sourceTreatmentId: string;
sourceDate: string;
product: string;
activeIngredient: string | null;
withdrawalPeriodDays: number | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
}
@Injectable()
export class TreatmentPrefillService {
constructor(private readonly prisma: PrismaService) {}
/**
* The caller's own newest treatment with this product (case-insensitive).
* Strictly scoped to `userId`: supplier, proof and vet are the caller's
* own purchase data and must never leak across users.
*/
async lastByProduct(
userId: string,
product: string,
): Promise<TreatmentPrefill | null> {
const treatment = await this.prisma.treatment.findFirst({
where: {
userId,
product: { equals: product.trim(), mode: 'insensitive' },
},
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
select: {
id: true,
date: true,
product: true,
activeIngredient: true,
withdrawalPeriodDays: true,
supplier: true,
purchaseProof: true,
prescribingVet: true,
},
});
if (!treatment) {
return null;
}
const { id, date, ...rest } = treatment;
return { sourceTreatmentId: id, sourceDate: date.toISOString(), ...rest };
}
}
@@ -14,6 +14,8 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
import { WithdrawalStatusQueryDto } from './dto/withdrawal-status-query.dto';
import { LastByProductQueryDto } from './dto/last-by-product-query.dto';
import { TreatmentPrefillService } from './treatment-prefill.service';
import { TreatmentsService } from './treatments.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@@ -21,7 +23,10 @@ import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiBearerAuth()
@Controller('treatments')
export class TreatmentsController {
constructor(private readonly treatmentsService: TreatmentsService) {}
constructor(
private readonly treatmentsService: TreatmentsService,
private readonly treatmentPrefillService: TreatmentPrefillService,
) {}
@RequirePermission('treatments:read')
@Get()
@@ -39,6 +44,21 @@ export class TreatmentsController {
);
}
/** The caller's newest treatment with this product, to prefill a new one. */
@RequirePermission('treatments:create')
@Get('last-by-product')
async getLastByProduct(
@Query() query: LastByProductQueryDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return {
treatment: await this.treatmentPrefillService.lastByProduct(
actor.id,
query.product,
),
};
}
@RequirePermission('treatments:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { TreatmentsController } from './treatments.controller';
import { ColonyTreatmentsController } from './colony-treatments.controller';
import { TreatmentPrefillService } from './treatment-prefill.service';
import { TreatmentsService } from './treatments.service';
@Module({
imports: [AutomationModule],
controllers: [TreatmentsController, ColonyTreatmentsController],
providers: [TreatmentsService],
providers: [TreatmentsService, TreatmentPrefillService],
exports: [TreatmentsService],
})
export class TreatmentsModule {}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { varroaThresholdOf } from '../automation/varroa-threshold';
import { PrismaService } from '../prisma/prisma.service';
export interface VarroaThreshold {
// null when the VARROA_THRESHOLD rule is switched off.
thresholdMitesPerDay: number | null;
}
@Injectable()
export class VarroaThresholdService {
constructor(private readonly prisma: PrismaService) {}
async current(): Promise<VarroaThreshold> {
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
select: { active: true, config: true },
});
return { thresholdMitesPerDay: varroaThresholdOf(rule) };
}
}
@@ -6,13 +6,17 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
import { VarroaService } from './varroa.service';
import { VarroaThresholdService } from './varroa-threshold.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('varroa-measurements')
@ApiBearerAuth()
@Controller('varroa-measurements')
export class VarroaController {
constructor(private readonly varroaService: VarroaService) {}
constructor(
private readonly varroaService: VarroaService,
private readonly varroaThresholdService: VarroaThresholdService,
) {}
@RequirePermission('varroa:read')
@Get()
@@ -20,6 +24,13 @@ export class VarroaController {
return this.varroaService.findAll();
}
/** Effective alarm threshold; declared before :id so it is not shadowed. */
@RequirePermission('varroa:read', 'varroa:create')
@Get('threshold')
getThreshold() {
return this.varroaThresholdService.current();
}
@RequirePermission('varroa:read')
@Get(':id')
findOne(@Param('id') id: string) {
+2 -1
View File
@@ -3,11 +3,12 @@ import { AutomationModule } from '../automation/automation.module';
import { VarroaController } from './varroa.controller';
import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller';
import { VarroaService } from './varroa.service';
import { VarroaThresholdService } from './varroa-threshold.service';
@Module({
imports: [AutomationModule],
controllers: [VarroaController, ColonyVarroaMeasurementsController],
providers: [VarroaService],
providers: [VarroaService, VarroaThresholdService],
exports: [VarroaService],
})
export class VarroaModule {}
@@ -9,10 +9,15 @@ function openMeteoResponse(
tempMin: number | null;
precipitation: number | null;
wind?: number | null;
weatherCode?: number | null;
}[],
): unknown {
const withCodes = days.some((day) => day.weatherCode !== undefined);
return {
daily: {
...(withCodes
? { weather_code: days.map((day) => day.weatherCode ?? null) }
: {}),
time: days.map((day) => day.date),
temperature_2m_max: days.map((day) => day.tempMax),
temperature_2m_min: days.map((day) => day.tempMin),
@@ -91,6 +96,7 @@ describe('WeatherService', () => {
tempMinC: 12,
precipitationMm: 0,
windSpeedMaxKmh: 10,
weatherCode: null,
inspectionSuitable: true,
},
]);
@@ -99,6 +105,61 @@ describe('WeatherService', () => {
expect(url.toString()).toContain('longitude=13.4');
});
it('maps daily weather_code to weatherCode', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(
openMeteoResponse([
{
date: '2026-06-01',
tempMax: 22,
tempMin: 12,
precipitation: 0,
weatherCode: 61,
},
]),
),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily[0].weatherCode).toBe(61);
const [url] = fetchMock.mock.calls[0] as [URL];
expect(url.searchParams.get('daily')).toContain('weather_code');
});
it('weatherCode is null when Open-Meteo omits it', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(
openMeteoResponse([
{
date: '2026-06-01',
tempMax: 22,
tempMin: 12,
precipitation: 0,
},
]),
),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily[0].weatherCode).toBeNull();
});
it.each([
['too cold', { tempMax: 10, tempMin: 4, precipitation: 0, wind: 5 }],
['raining', { tempMax: 20, tempMin: 12, precipitation: 3, wind: 5 }],
@@ -30,6 +30,9 @@ export interface DailyWeather {
tempMinC: number | null;
precipitationMm: number | null;
windSpeedMaxKmh: number | null;
// WMO weather interpretation code (Open-Meteo `weather_code`); null when
// the provider omits it.
weatherCode: number | null;
// null when a required value is missing (rather than a misleading guess).
inspectionSuitable: boolean | null;
}
@@ -54,6 +57,7 @@ interface OpenMeteoDaily {
temperature_2m_min: (number | null)[];
precipitation_sum: (number | null)[];
wind_speed_10m_max: (number | null)[];
weather_code?: (number | null)[];
}
interface OpenMeteoResponse {
@@ -108,7 +112,7 @@ export class WeatherService {
url.searchParams.set('longitude', String(longitude));
url.searchParams.set(
'daily',
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,weather_code',
);
url.searchParams.set('wind_speed_unit', 'kmh');
url.searchParams.set('past_days', String(PAST_DAYS));
@@ -136,6 +140,7 @@ export class WeatherService {
temperature_2m_min,
precipitation_sum,
wind_speed_10m_max,
weather_code,
} = body.daily;
return time.map((date, index) => {
const tempMaxC = temperature_2m_max[index] ?? null;
@@ -147,6 +152,7 @@ export class WeatherService {
tempMinC: temperature_2m_min[index] ?? null,
precipitationMm,
windSpeedMaxKmh,
weatherCode: weather_code?.[index] ?? null,
inspectionSuitable:
tempMaxC === null ||
precipitationMm === null ||
@@ -0,0 +1,91 @@
import { Logger } from '@nestjs/common';
import { encryptMfaSecret } from '../auth/mfa/mfa-crypto.util';
import { KEY_ID_PATTERN } from '../common/crypto/key-ring';
import {
openWebhookSecret,
sealWebhookSecret,
webhookSecretNeedsRekey,
} from './webhook-secret';
// Written by the code before the key ring: "enc:v1:" + AES-256-GCM with
// sha256(JWT_ACCESS_SECRET), JWT_ACCESS_SECRET = 'x' * 40.
const LEGACY_SEALED =
'enc:v1:MlOPzAuy67RTMMb9.YslrBRO2dTYumby+P28INg==.KFmilRVOsJtQ';
// Rows from before webhook secrets were encrypted at all.
const LEGACY_PLAIN = '3f9c2a7d41e8b6c05a1d9e7f3b2c8a64';
describe('webhook-secret', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40);
delete process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS;
delete process.env.MFA_ENCRYPTION_KEY;
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('seals with the webhook key and a key id', () => {
const stored = sealWebhookSecret('topsecret');
expect(stored).toMatch(KEY_ID_PATTERN);
expect(stored).not.toContain('topsecret');
expect(openWebhookSecret(stored)).toBe('topsecret');
expect(webhookSecretNeedsRekey(stored)).toBe(false);
});
it('opens legacy enc:v1 and plaintext webhook secrets and flags them for rekey', () => {
expect(openWebhookSecret(LEGACY_SEALED)).toBe('topsecret');
expect(openWebhookSecret(LEGACY_PLAIN)).toBe(LEGACY_PLAIN);
expect(webhookSecretNeedsRekey(LEGACY_SEALED)).toBe(true);
expect(webhookSecretNeedsRekey(LEGACY_PLAIN)).toBe(true);
});
it('opens a secret sealed with WEBHOOK_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
const stored = sealWebhookSecret('topsecret');
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS =
process.env.WEBHOOK_ENCRYPTION_KEY;
process.env.WEBHOOK_ENCRYPTION_KEY = 'v'.repeat(40);
expect(openWebhookSecret(stored)).toBe('topsecret');
expect(webhookSecretNeedsRekey(stored)).toBe(true);
});
it('fails closed on a legacy enc:v1 secret once JWT_ACCESS_SECRET changed', () => {
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(() => openWebhookSecret(LEGACY_SEALED)).toThrow(
'ciphertext failed authentication',
);
});
it('never retries a k…: value that fails authentication as legacy or plain secret', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
const currentKeyId = sealWebhookSecret('topsecret').split(':')[0];
// A valid legacy ciphertext behind the current key id: only a retry with
// sha256(JWT_ACCESS_SECRET) could open it, and only the plaintext
// fallback would hand the stored string back.
const legacyPayload = LEGACY_SEALED.slice('enc:v1:'.length);
expect(() => openWebhookSecret(`${currentKeyId}:${legacyPayload}`)).toThrow(
'ciphertext failed authentication',
);
expect(() => openWebhookSecret(`k00000000:${legacyPayload}`)).toThrow(
'unknown key id',
);
expect(warn).not.toHaveBeenCalled();
});
it('keeps MFA and webhook keys apart, even on the shared dev fallback', () => {
delete process.env.WEBHOOK_ENCRYPTION_KEY;
expect(() => openWebhookSecret(encryptMfaSecret('topsecret'))).toThrow(
'unknown key id',
);
});
});
+27 -10
View File
@@ -1,19 +1,36 @@
import {
decryptMfaSecret,
encryptMfaSecret,
} from '../auth/mfa/mfa-crypto.util';
KEY_ID_PATTERN,
isSealedWithCurrentKey,
loadKeyRing,
openLegacyCiphertext,
openWithRing,
sealWithRing,
warnLegacyRead,
} from '../common/crypto/key-ring';
// Webhook signing secrets must stay reversible (the server signs with them),
// so they are stored AES-256-GCM-encrypted like TOTP secrets. Rows written
// before this carry the plain secret and no prefix - still readable.
const PREFIX = 'enc:v1:';
// so they are sealed with the webhook key ring (WEBHOOK_ENCRYPTION_KEY, see
// common/crypto/key-ring.ts). Two older forms stay readable until the rekey
// job reseals them: "enc:v1:" + a ciphertext under sha256(JWT_ACCESS_SECRET),
// and, from before encryption, the plain secret without any prefix.
const LEGACY_PREFIX = 'enc:v1:';
export function sealWebhookSecret(secret: string): string {
return `${PREFIX}${encryptMfaSecret(secret)}`;
return sealWithRing(loadKeyRing('webhook'), secret);
}
export function openWebhookSecret(stored: string): string {
return stored.startsWith(PREFIX)
? decryptMfaSecret(stored.slice(PREFIX.length))
: stored;
if (KEY_ID_PATTERN.test(stored)) {
return openWithRing(loadKeyRing('webhook'), stored).plaintext;
}
if (stored.startsWith(LEGACY_PREFIX)) {
return openLegacyCiphertext('webhook', stored.slice(LEGACY_PREFIX.length));
}
warnLegacyRead('webhook');
return stored;
}
/** True for secrets the rekey job should reseal with the current key. */
export function webhookSecretNeedsRekey(stored: string): boolean {
return !isSealedWithCurrentKey(loadKeyRing('webhook'), stored);
}
@@ -0,0 +1,86 @@
import { WinterChecksService } from './winter-checks.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
describe('WinterChecksService', () => {
const livingColony = { id: 'c1', status: 'ACTIVE', apiaryId: 'a1' };
const lostCheck: CreateWinterCheckDto = {
colonyId: 'c1',
season: 'SPRING',
date: '2027-03-20',
outcome: 'LOST',
lossCause: 'DEAD_OR_EMPTY',
markColonyDeceased: true,
};
let service: WinterChecksService;
let tx: {
winterCheck: { create: jest.Mock };
colony: { update: jest.Mock };
auditLog: { create: jest.Mock };
};
let prisma: {
colony: { findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
tx = {
winterCheck: {
create: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve({ id: 'w1', ...data }),
),
},
colony: {
update: jest
.fn()
.mockResolvedValue({ ...livingColony, status: 'DECEASED' }),
},
auditLog: { create: jest.fn() },
};
prisma = {
colony: { findUnique: jest.fn().mockResolvedValue(livingColony) },
$transaction: jest
.fn()
.mockImplementation((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
};
auditLog = { record: jest.fn() };
service = new WinterChecksService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('audits a colony marked deceased through the audit service inside the transaction', async () => {
await service.create(lostCheck, 'u1');
expect(auditLog.record).toHaveBeenCalledWith(
{
entityType: 'Colony',
entityId: 'c1',
action: 'UPDATE',
userId: 'u1',
beforeData: livingColony,
afterData: { ...livingColony, status: 'DECEASED' },
},
tx,
);
expect(tx.auditLog.create).not.toHaveBeenCalled();
});
it('writes no colony audit when the colony is not to be marked deceased', async () => {
await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1');
expect(tx.colony.update).not.toHaveBeenCalled();
const auditedTypes = (
auditLog.record.mock.calls as [{ entityType: string }][]
).map(([input]) => input.entityType);
expect(auditedTypes).toEqual(['WinterCheck']);
});
});
@@ -226,8 +226,8 @@ export class WinterChecksService {
where: { id: colony.id },
data: { status: 'DECEASED', deactivatedAt: check.date },
});
await tx.auditLog.create({
data: {
await this.auditLog.record(
{
entityType: 'Colony',
entityId: colony.id,
action: 'UPDATE',
@@ -235,7 +235,8 @@ export class WinterChecksService {
beforeData: colony,
afterData: after,
},
});
tx,
);
}
private async withDuplicateMessage<T>(run: () => Promise<T>): Promise<T> {
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff. Show more