feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25529
-1642
No files matched your search
@@ -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}}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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
|
||||
+111
-10
@@ -102,32 +102,109 @@ 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`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
|
||||
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`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`): Ein Schlüssel mit beiden Rechten wird beim Anlegen mit 400 abgelehnt; bereits ausgestellte kombinierte Schlüssel bleiben gültig, die Oberfläche markiert sie und empfiehlt zwei getrennte Schlüssel (neue anlegen, in Wabenhain eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen.
|
||||
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).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -144,13 +221,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, Scope-Allowlist (`shop-sync:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben |
|
||||
| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`, `shop-sync:write`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Lesen und Buchen sind getrennte Schlüssel: `ApiKeysService.create` lehnt einen Schlüssel mit beiden Shop-Rechten mit 400 ab; bereits ausgestellte kombinierte Schlüssel bleiben gültig und werden in der Oberfläche mit Hinweis markiert |
|
||||
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
|
||||
| Transport/Antworten | `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.
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
Generated
+17
-15
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -230,6 +230,8 @@ model Apiary {
|
||||
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())
|
||||
@@ -454,6 +456,7 @@ model Inspection {
|
||||
weather String?
|
||||
temperature Float?
|
||||
colonyStrength Int?
|
||||
broodFrames Int?
|
||||
broodAmount String?
|
||||
broodPattern Int?
|
||||
openBroodPresent Boolean?
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
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'])),
|
||||
};
|
||||
service = new ApiKeysService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record: jest.fn() } as unknown as AuditLogService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
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.each(['shop-sync:read', 'shop-sync:write'])(
|
||||
'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]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
@@ -85,6 +86,16 @@ 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.
|
||||
if (
|
||||
dto.scopes.includes('shop-sync:read') &&
|
||||
dto.scopes.includes('shop-sync:write')
|
||||
) {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -60,10 +63,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' });
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
export function decryptMfaSecret(stored: string): string {
|
||||
return KEY_ID_PATTERN.test(stored)
|
||||
? openWithRing(loadKeyRing('mfa'), stored).plaintext
|
||||
: openLegacyCiphertext('mfa', stored);
|
||||
}
|
||||
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');
|
||||
|
||||
/** 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { bootstrapWorker } from './worker';
|
||||
import { WorkerModule } from './worker/worker.module';
|
||||
|
||||
const VALID_ENV: NodeJS.ProcessEnv = {
|
||||
DATABASE_URL: 'postgresql://worker:secret@db/app',
|
||||
JWT_ACCESS_SECRET: 'a'.repeat(32),
|
||||
};
|
||||
|
||||
describe('bootstrapWorker', () => {
|
||||
let createContext: jest.SpyInstance;
|
||||
let app: { enableShutdownHooks: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
app = { enableShutdownHooks: jest.fn() };
|
||||
createContext = jest
|
||||
.spyOn(NestFactory, 'createApplicationContext')
|
||||
.mockResolvedValue(app as unknown as INestApplicationContext);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
// A misconfigured pod must fail before any module connects anywhere.
|
||||
it.each([
|
||||
[
|
||||
'a role of another entry point',
|
||||
{ ...VALID_ENV, APICORE_ROLE: 'api' },
|
||||
'APICORE_ROLE=api cannot start dist/worker.js',
|
||||
],
|
||||
[
|
||||
'"all" in production',
|
||||
{ ...VALID_ENV, NODE_ENV: 'production', APICORE_ROLE: 'all' },
|
||||
'APICORE_ROLE=all is for local development and tests only',
|
||||
],
|
||||
[
|
||||
'no role in production',
|
||||
{ ...VALID_ENV, NODE_ENV: 'production' },
|
||||
'APICORE_ROLE is required in production',
|
||||
],
|
||||
[
|
||||
'a missing DATABASE_URL',
|
||||
{ JWT_ACCESS_SECRET: VALID_ENV.JWT_ACCESS_SECRET },
|
||||
'Missing required environment variable(s): DATABASE_URL',
|
||||
],
|
||||
[
|
||||
'a short JWT secret',
|
||||
{ ...VALID_ENV, JWT_ACCESS_SECRET: 'too-short' },
|
||||
'JWT_ACCESS_SECRET must be at least 32 characters',
|
||||
],
|
||||
])(
|
||||
'refuses %s before creating the context',
|
||||
async (_case, env: NodeJS.ProcessEnv, message: string) => {
|
||||
await expect(bootstrapWorker(env)).rejects.toThrow(message);
|
||||
|
||||
expect(createContext).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('starts WorkerModule as an application context with shutdown hooks', async () => {
|
||||
await expect(bootstrapWorker(VALID_ENV)).resolves.toBe(app);
|
||||
|
||||
expect(createContext).toHaveBeenCalledWith(WorkerModule);
|
||||
expect(app.enableShutdownHooks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { resolveRole } from './runtime/role';
|
||||
import { assertStartupEnvironment } from './runtime/startup-checks';
|
||||
import { WorkerModule } from './worker/worker.module';
|
||||
|
||||
/**
|
||||
* Entry point of the worker role: domain events, calendar outbox and their
|
||||
* wake-ups, plus GET /health on WORKER_HEALTH_PORT - no HTTP API. Role and
|
||||
* environment are checked before any module is created, so a misconfigured
|
||||
* pod fails before it connects anywhere. `env` is for tests; the modules
|
||||
* themselves read process.env.
|
||||
*/
|
||||
export async function bootstrapWorker(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<INestApplicationContext> {
|
||||
const role = resolveRole('worker', env);
|
||||
assertStartupEnvironment(role, env);
|
||||
|
||||
const app = await NestFactory.createApplicationContext(WorkerModule);
|
||||
app.enableShutdownHooks();
|
||||
return app;
|
||||
}
|
||||
|
||||
// Started as `node dist/worker.js`; importing this file starts nothing.
|
||||
if (require.main === module) {
|
||||
void bootstrapWorker();
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Client } from 'pg';
|
||||
import { ListenClient, PgListener } from './pg-listener';
|
||||
import type { WakeChannel } from '../common/worker-wakeup';
|
||||
|
||||
jest.mock('pg', () => ({ Client: jest.fn() }));
|
||||
|
||||
const CONNECTION_STRING = 'postgresql://user:secret@db/app';
|
||||
const CHANNELS: readonly WakeChannel[] = [
|
||||
'domain_event',
|
||||
'calendar_sync',
|
||||
'webhook_delivery',
|
||||
];
|
||||
|
||||
class FakeClient implements ListenClient {
|
||||
connect = jest.fn<Promise<void>, []>().mockResolvedValue(undefined);
|
||||
query = jest.fn<Promise<unknown>, [string]>().mockResolvedValue(undefined);
|
||||
end = jest.fn<Promise<void>, []>().mockResolvedValue(undefined);
|
||||
private readonly handlers = new Map<string, (arg: unknown) => void>();
|
||||
|
||||
on(
|
||||
event: 'notification' | 'error' | 'end',
|
||||
cb: (arg: unknown) => void,
|
||||
): void {
|
||||
this.handlers.set(event, cb);
|
||||
}
|
||||
|
||||
emit(event: 'notification' | 'error' | 'end', arg?: unknown): void {
|
||||
this.handlers.get(event)?.(arg);
|
||||
}
|
||||
}
|
||||
|
||||
describe('PgListener', () => {
|
||||
let clients: FakeClient[];
|
||||
let connectFails: boolean;
|
||||
let logger: {
|
||||
error: jest.Mock<void, unknown[]>;
|
||||
warn: jest.Mock<void, unknown[]>;
|
||||
log: jest.Mock<void, unknown[]>;
|
||||
};
|
||||
let onNotify: jest.Mock;
|
||||
let listener: PgListener;
|
||||
|
||||
function createListener(
|
||||
clientFactory: (connectionString: string) => ListenClient,
|
||||
): PgListener {
|
||||
return new PgListener({
|
||||
connectionString: CONNECTION_STRING,
|
||||
channels: CHANNELS,
|
||||
onNotify,
|
||||
logger: logger as unknown as Logger,
|
||||
clientFactory,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
clients = [];
|
||||
connectFails = false;
|
||||
logger = {
|
||||
error: jest.fn<void, unknown[]>(),
|
||||
warn: jest.fn<void, unknown[]>(),
|
||||
log: jest.fn<void, unknown[]>(),
|
||||
};
|
||||
onNotify = jest.fn();
|
||||
listener = createListener(() => {
|
||||
const client = new FakeClient();
|
||||
if (connectFails) {
|
||||
client.connect.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
}
|
||||
clients.push(client);
|
||||
return client;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await listener.stop();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('LISTENs on every channel after connecting', async () => {
|
||||
await listener.start();
|
||||
|
||||
expect(clients).toHaveLength(1);
|
||||
expect(clients[0].connect).toHaveBeenCalledTimes(1);
|
||||
expect(clients[0].query.mock.calls.map((c) => c[0])).toEqual([
|
||||
'LISTEN "domain_event"',
|
||||
'LISTEN "calendar_sync"',
|
||||
'LISTEN "webhook_delivery"',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes the connection string to the client factory', async () => {
|
||||
const factory = jest.fn<ListenClient, [string]>(() => new FakeClient());
|
||||
const other = createListener(factory);
|
||||
await other.start();
|
||||
expect(factory).toHaveBeenCalledWith(CONNECTION_STRING);
|
||||
await other.stop();
|
||||
});
|
||||
|
||||
it('forwards notifications of known channels and ignores others', async () => {
|
||||
await listener.start();
|
||||
|
||||
clients[0].emit('notification', { channel: 'calendar_sync', payload: '' });
|
||||
clients[0].emit('notification', { channel: 'something_else' });
|
||||
clients[0].emit('notification', 'garbage');
|
||||
|
||||
expect(onNotify).toHaveBeenCalledTimes(1);
|
||||
expect(onNotify).toHaveBeenCalledWith('calendar_sync');
|
||||
});
|
||||
|
||||
it('reconnects after an error with 1 s, 2 s, 4 s … at most 30 s', async () => {
|
||||
await listener.start();
|
||||
connectFails = true;
|
||||
clients[0].emit('error', new Error('connection lost'));
|
||||
|
||||
for (const delay of [1000, 2000, 4000, 8000, 16000, 30000, 30000]) {
|
||||
const before = clients.length;
|
||||
await jest.advanceTimersByTimeAsync(delay - 1);
|
||||
expect(clients).toHaveLength(before);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(clients).toHaveLength(before + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('resets the backoff after a successful reconnect', async () => {
|
||||
await listener.start();
|
||||
clients[0].emit('error', new Error('lost'));
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(clients).toHaveLength(2);
|
||||
|
||||
clients[1].emit('end');
|
||||
await jest.advanceTimersByTimeAsync(999);
|
||||
expect(clients).toHaveLength(2);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(clients).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('reconnects only once when error and end both fire', async () => {
|
||||
await listener.start();
|
||||
clients[0].emit('error', new Error('lost'));
|
||||
clients[0].emit('end');
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(clients).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('retries when the initial connect fails and logs no credentials', async () => {
|
||||
connectFails = true;
|
||||
await listener.start();
|
||||
expect(clients).toHaveLength(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(clients).toHaveLength(2);
|
||||
|
||||
const logged = JSON.stringify([
|
||||
...logger.error.mock.calls,
|
||||
...logger.warn.mock.calls,
|
||||
]);
|
||||
expect(logged).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('stop() ends the client and does not reconnect', async () => {
|
||||
await listener.start();
|
||||
await listener.stop();
|
||||
|
||||
expect(clients[0].end).toHaveBeenCalledTimes(1);
|
||||
clients[0].emit('end');
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(clients).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('flushes a backlog by notifying every channel after a reconnect only', async () => {
|
||||
await listener.start();
|
||||
expect(onNotify).not.toHaveBeenCalled();
|
||||
|
||||
clients[0].emit('error', new Error('lost'));
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(
|
||||
(onNotify.mock.calls as [WakeChannel][]).map(([channel]) => channel),
|
||||
).toEqual([...CHANNELS]);
|
||||
});
|
||||
|
||||
it('stop() does not hang on a client whose end() never settles', async () => {
|
||||
await listener.start();
|
||||
clients[0].end.mockReturnValue(new Promise<void>(() => undefined));
|
||||
|
||||
const stopping = listener.stop();
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
await expect(stopping).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not recycle the connection when onNotify throws', async () => {
|
||||
onNotify.mockImplementation(() => {
|
||||
throw new Error('consumer failed');
|
||||
});
|
||||
await listener.start();
|
||||
|
||||
clients[0].emit('notification', { channel: 'domain_event' });
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(clients).toHaveLength(1);
|
||||
expect(clients[0].end).not.toHaveBeenCalled();
|
||||
|
||||
clients[0].emit('error', new Error('lost'));
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(clients).toHaveLength(2);
|
||||
// Flush calls all threw, yet the new connection stays.
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(onNotify).toHaveBeenCalledTimes(1 + CHANNELS.length);
|
||||
expect(clients).toHaveLength(2);
|
||||
expect(clients[1].end).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the default pg client with keep-alive settings', async () => {
|
||||
const created = new FakeClient();
|
||||
(Client as unknown as jest.Mock).mockReturnValue(created);
|
||||
const defaulted = new PgListener({
|
||||
connectionString: CONNECTION_STRING,
|
||||
channels: CHANNELS,
|
||||
onNotify,
|
||||
logger: logger as unknown as Logger,
|
||||
});
|
||||
await defaulted.start();
|
||||
|
||||
expect(Client).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionString: CONNECTION_STRING,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 10_000,
|
||||
}),
|
||||
);
|
||||
await defaulted.stop();
|
||||
});
|
||||
|
||||
it('pins the LISTEN session to UTC like every Prisma connection', async () => {
|
||||
(Client as unknown as jest.Mock).mockReturnValue(new FakeClient());
|
||||
const defaulted = new PgListener({
|
||||
connectionString: CONNECTION_STRING,
|
||||
channels: CHANNELS,
|
||||
onNotify,
|
||||
logger: logger as unknown as Logger,
|
||||
});
|
||||
await defaulted.start();
|
||||
|
||||
expect(Client).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionString: CONNECTION_STRING,
|
||||
options: '-c TimeZone=UTC',
|
||||
}),
|
||||
);
|
||||
await defaulted.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Client } from 'pg';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { withTimeout } from '../common/with-timeout';
|
||||
import type { WakeChannel } from '../common/worker-wakeup';
|
||||
import { dbSessionConfig } from '../prisma/db-session';
|
||||
|
||||
const RECONNECT_INITIAL_DELAY_MS = 1000;
|
||||
const RECONNECT_MAX_DELAY_MS = 30_000;
|
||||
const CONNECTION_TIMEOUT_MS = 10_000;
|
||||
const KEEP_ALIVE_INITIAL_DELAY_MS = 10_000;
|
||||
const END_TIMEOUT_MS = 5000;
|
||||
|
||||
export interface ListenClient {
|
||||
connect(): Promise<void>;
|
||||
query(sql: string): Promise<unknown>;
|
||||
on(event: 'notification' | 'error' | 'end', cb: (arg: unknown) => void): void;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PgListenerOptions {
|
||||
connectionString: string;
|
||||
channels: readonly WakeChannel[];
|
||||
onNotify: (channel: WakeChannel) => void;
|
||||
logger: Logger;
|
||||
clientFactory?: (connectionString: string) => ListenClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same connection string and session settings (UTC) as Prisma, so host,
|
||||
* TLS and time zone match.
|
||||
*/
|
||||
function createPgClient(connectionString: string): ListenClient {
|
||||
return new Client({
|
||||
...dbSessionConfig(connectionString),
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: KEEP_ALIVE_INITIAL_DELAY_MS,
|
||||
connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
|
||||
}) as unknown as ListenClient;
|
||||
}
|
||||
|
||||
function notificationChannel(arg: unknown): string | undefined {
|
||||
if (typeof arg !== 'object' || arg === null) return undefined;
|
||||
const { channel } = arg as { channel?: unknown };
|
||||
return typeof channel === 'string' ? channel : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated Postgres connection that LISTENs on the wake-up channels and
|
||||
* reconnects with exponential backoff (1 s, doubled, at most 30 s). A missed
|
||||
* notification is harmless: the polling loops catch up on their next tick.
|
||||
*/
|
||||
export class PgListener {
|
||||
private readonly createClient: (connectionString: string) => ListenClient;
|
||||
private client: ListenClient | undefined;
|
||||
private reconnectTimer: NodeJS.Timeout | undefined;
|
||||
private reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS;
|
||||
private stopped = true;
|
||||
|
||||
constructor(private readonly options: PgListenerOptions) {
|
||||
this.createClient = options.clientFactory ?? createPgClient;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!this.stopped) return;
|
||||
this.stopped = false;
|
||||
await this.connect(false);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
const client = this.client;
|
||||
this.client = undefined;
|
||||
if (client) await this.endQuietly(client);
|
||||
}
|
||||
|
||||
private async connect(isReconnect: boolean): Promise<void> {
|
||||
const client = this.createClient(this.options.connectionString);
|
||||
this.client = client;
|
||||
client.on('notification', (arg) => this.handleNotification(arg));
|
||||
client.on('error', (error) => this.handleLoss(client, error));
|
||||
client.on('end', () => this.handleLoss(client));
|
||||
try {
|
||||
await client.connect();
|
||||
for (const channel of this.options.channels) {
|
||||
await client.query(`LISTEN "${channel}"`);
|
||||
}
|
||||
this.reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS;
|
||||
// Notifications sent while disconnected are gone; wake everything.
|
||||
if (isReconnect) {
|
||||
this.options.channels.forEach((channel) => this.notify(channel));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.handleLoss(client, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** A failing consumer must never be mistaken for a lost connection. */
|
||||
private notify(channel: WakeChannel): void {
|
||||
try {
|
||||
this.options.onNotify(channel);
|
||||
} catch (error: unknown) {
|
||||
const { message } = loggableError(error);
|
||||
this.options.logger.error(`onNotify failed for ${channel}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotification(arg: unknown): void {
|
||||
const channel = notificationChannel(arg);
|
||||
const known = this.options.channels.find((c) => c === channel);
|
||||
if (known) this.notify(known);
|
||||
}
|
||||
|
||||
private handleLoss(client: ListenClient, error?: unknown): void {
|
||||
// error and end fire for the same loss; only the current client counts.
|
||||
if (this.client !== client) return;
|
||||
this.client = undefined;
|
||||
if (error !== undefined) {
|
||||
const { message } = loggableError(error);
|
||||
this.options.logger.warn(`LISTEN connection lost: ${message}`);
|
||||
}
|
||||
void this.endQuietly(client);
|
||||
if (this.stopped) return;
|
||||
const delay = this.reconnectDelayMs;
|
||||
this.reconnectDelayMs = Math.min(delay * 2, RECONNECT_MAX_DELAY_MS);
|
||||
this.options.logger.log(`Reconnecting LISTEN connection in ${delay} ms`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
void this.connect(true);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private async endQuietly(client: ListenClient): Promise<void> {
|
||||
try {
|
||||
await withTimeout(
|
||||
client.end(),
|
||||
END_TIMEOUT_MS,
|
||||
'LISTEN client end timed out',
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const { message } = loggableError(error);
|
||||
this.options.logger.warn(`Closing LISTEN connection failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user