Compare commits

...
Author SHA1 Message Date
Nick Szaunig 9c332d4716 Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export
# Conflicts:
#	ARCHITECTURE.md
2026-10-13 05:58:30 +02:00
Nick SzaunigandClaude Opus 5.5 10cbe9bf2d fix: Paket 0 – data integrity, security hardening and field usability
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s
Backend
- Backup covers every Prisma model (bundle v3); a guard test fails for any
  new model that is neither backed up nor explicitly excluded. Unsealed
  bundles may only add rows to 27 legacy master/field tables and never
  overwrite; counters, ledgers, settings, roles etc. need a sealed file.
  Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB)
  with warnings in log and UI; replaced document files are removed after
  commit.
- Post-commit side effects (automation, calendar sync, webhooks) run via
  runAfterCommit and never turn a committed write into a 500, so retries
  with the same Idempotency-Key no longer create duplicates.
- Calendar outbox is multi-replica safe: per-entry claims with stale
  takeover, revision-based settle, new Graph event only on 404/410,
  transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID
  validation for groupId, "run now" in background and resets FAILED.
- Automation tasks are deduplicated under a row lock on the rule.
- Update DTOs follow one null rule: omitted = unchanged, null clears a
  nullable field, null on a required field is a 400.
- Varroa mites/day is recomputed on correction; withdrawal status is
  checked against the harvest date (?at=), bounded colonyIds; inspection
  create is audited; updates re-enqueue calendar entries.
- Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS
  and QUEENLESS colonies as living.
- Prisma errors and body-parser errors are logged without query values or
  request bodies.
- Migration 20261012100000_calendar_sync_claims; schema index name aligned
  with the existing database object.

Frontend
- Sticky save bar sticks again (no scroll container ancestors, card uses
  overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms,
  sticky desktop sidebar.
- Inspections record yes / no / not checked instead of silent defaults.
- All living colonies are selectable; shared RecordSelect, EnumSelect,
  ChoiceGroup and TriStateField replace per-page copies; selects show
  names instead of ids; labels point at real controls.
- Emptied optional fields are sent as null (emptyToNull) so every field
  can be cleared; dates default to the local day and are sent via
  toApiDate.
- Harvest withdrawal warning uses the harvest date; backup page warns when
  a backup outgrows the import limit and lists skipped tables.

Build and docs
- Docker builds fail unless lint, typecheck and unit tests pass.
- ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec
  docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md
  for the backbone (api/worker/job roles, domain event outbox) and the
  feature roadmap T3-T7.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 16:38:55 +02:00
Nick SzaunigandClaude Opus 5.5 6505411761 fix(ui): API-Schlüssel nur mit Rechten anbieten, die der Nutzer selbst hat
Der Server lehnt Rechte ab, die der Anlegende nicht selbst hat. Das UI
bietet jetzt auch nur diese an (z. B. kein shop-sync:write ohne eigenes
Recht) und erklärt, wenn gar keines übrig bleibt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 12:00:03 +02:00
Nick SzaunigandClaude Opus 5.5 fe7b61c3c1 fix(api-keys): gültige Schlüssel nie durch die Fehlversuchs-Sperre abweisen
Befunde aus dem Sicherheitsreview des Branches:

- MEDIUM: Die Sperre lief vor der Schlüsselsuche und zählte auch falsch
  aufgebaute Tokens. Wer die gemeinsame Adresse (Cluster-Ausgang, Proxy)
  nutzt oder per X-Forwarded-For vortäuscht, hätte ERPNext und den Shop
  15 Minuten aussperren können. Jetzt: erst Suche über den Hash; ein
  gültiger, nicht widerrufener, nicht abgelaufener Schlüssel kommt immer
  durch; 429 nur für unbekannte, widerrufene und abgelaufene Schlüssel;
  falsch aufgebaute Tokens sofort 401 ohne Zählung. Beim Start warnt
  ApiCore, wenn TRUST_PROXY in Produktion fehlt (im Cluster gesetzt).
- LOW: Bei vollem Speicher verdrängt der Limiter zuerst nicht gesperrte
  Einträge, gesperrte erst zuletzt.
- LOW: Bewegungs-Cursor mit Jahr außerhalb 2000-2100 ergab 500, jetzt 400.
- LOW/INFO: Vertrag legt fest, dass ERPNext Produkte und Chargen bei jedem
  Lauf vollständig neu liest (Voll-Spiegel) und Bewegungen inkrementell;
  remainingQuantity ist live, Bewegungen kommen 5 Minuten verzögert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 12:00:03 +02:00
Nick SzaunigandClaude Opus 5.5 e28cc8e0f3 fix(api-keys): Fehlversuche je Client-Netz zählen (IPv6 /64)
Wie der Throttler seit 886b4e7: die Sperre nach abgelehnten Schlüsseln
zählt über rateLimitSubject, damit wechselnde Adressen im eigenen
IPv6-/64 das Budget nicht zurücksetzen. Im Log steht weiter die Adresse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:47:05 +02:00
Nick Szaunig e357e8be9a Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export 2026-10-12 11:44:31 +02:00
Nick SzaunigandClaude Opus 5.5 c4cdb63414 feat(ui): Berechtigungen beim Anlegen eines API-Schlüssels wählbar
Bisher vergab die Oberfläche fest shop-sync:read. Jetzt sind alle
freigegebenen Rechte ankreuzbar (Shop lesen, Shop-Buchungen,
Inventar-Spiegel für ERPNext); vorausgewählt bleibt wie bisher nur
Shop lesen, ohne Haken lässt sich nichts anlegen. Die Liste zeigt, was
ein Schlüssel darf, und der Hinweis nach dem Anlegen nennt das Zielsystem.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:43:57 +02:00
Nick SzaunigandClaude Opus 5.5 68f81fabd4 feat(erp-export): nur lesender Inventar-Spiegel für ERPNext (T8)
Datenquelle für den nächtlichen Inventar-Abgleich in ERPNext (ERPNext-
Ausbau T8, Entscheidungen E2/E3). Neues Recht erp-inventory:read, auch als
API-Schlüssel-Scope; es öffnet nur diese vier GET-Endpunkte:

- /api/v1/export/inventar/produkte: Artikel (ohne Beschreibung)
- .../chargen: Abfüllungen und Herstellungschargen mit Losnummer, MHD,
  Ausgangsmenge, Rest aus dem Bewegungsbuch, Füllmenge g und Sorte
- .../bewegungen?seit=&nach=&limit=: Shop-Verkauf, Retoure, Entnahmen mit
  Grund (HOFLADEN = Vertrauensschrank, EIGENBEDARF, BRUCH, PROBE,
  SONSTIGES) und Inventur; Cursor über (createdAt, id), Bewegungen erst
  nach 5 Minuten Wartezeit, damit der Cursor keine offene Buchung überholt
- .../ernten?jahr=: Nettogewicht kg je Kalenderjahr, wie die Statistik

Alle Listen stabil sortiert, seitenweise (1-500), Parameter mit
class-validator geprüft, Cursor je Endpunkt streng validiert. Feld-
Allowlists: keine Notizen, Benutzer, Standorte, Rohstoffe. Die Gründe gab
es im Datenmodell schon - keine Migration; das Recht legt der Katalog-
Sync beim Start an und gibt es der Rolle Administrator.

Vertrag mit Feldern, Einheiten, Fehlercodes und Beispielen:
docs/erp-inventar-export.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:43:57 +02:00
Nick SzaunigandClaude Opus 5.5 1a1e70c94b feat(api-keys): Fehlversuchs-Sperre je IP und Log ohne Schlüssel
Nach 20 abgelehnten API-Schlüsseln (fehlerhaft, unbekannt, widerrufen,
abgelaufen) in 15 Minuten antwortet ApiCore der Absender-IP mit 429, auch
für richtige Schlüssel - vor jeder Datenbankabfrage. Gleiches Budget wie
der ERP-Export des Shops. Die Zählung liegt wie der Throttler im Speicher
des Pods und ist auf 10.000 Adressen begrenzt.

Abgelehnte Schlüssel werden mit Grund, öffentlichem Präfix (ack_xxxxxxxx)
und IP protokolliert, nie mit dem Schlüssel. Die Suche bleibt über den
SHA-256-Hash; neu sind Unit-Tests für authenticate (fehlend, unbekannt,
widerrufen, abgelaufen, Scope-Filter, IP-Allowlist, Sperre).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:43:56 +02:00
Nick SzaunigandClaude Opus 5.5 f5b1964223 fix(k8s): Ingress-Vorlagen auf Traefik statt nginx
Frontend Image / build-and-push (push) Canceled after 0s
Backend Image / build-and-push (push) Successful in 3m48s
Die cloudflare-only-Annotation ist eine Traefik-Annotation und war mit
ingressClassName nginx wirkungslos. Produktion laeuft auf RKE2 mit Traefik
(Ingresses apicore-backend/-frontend), die Vorlagen nennen jetzt dieselbe
Klasse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:26:37 +02:00
Nick SzaunigandClaude Opus 5.5 e53e4fb1cf fix(k8s): Traefik-Middleware cloudflare-only an Ingress-Manifesten
Frontend Image / build-and-push (push) Canceled after 0s
Backend Image / build-and-push (push) Canceled after 0s
Die live gesetzte Annotation traefik.ingress.kubernetes.io/router.middlewares
fehlte in den Manifesten (Backend und Frontend). Sie schuetzt vor
Direktzugriff am Origin vorbei, nicht vor fremden Cloudflare-Zonen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:23:46 +02:00
Nick SzaunigandClaude Opus 5.5 d9670dcda5 fix: TRUST_PROXY-Kommentar aktualisieren und Modus beim Start loggen
Die Kommentare beschrieben noch einen einzelnen Reverse-Proxy-Hop. Sie
beschreiben jetzt die Cloudflare/Traefik-Liste. Beim Start wird einmal
auf info-Level geloggt, ob Hop-Zahl, Liste oder der Default aktiv ist
(nur Anzahl, keine Adressen), damit eine fehlende Produktionseinstellung
auffaellt. Das Standardverhalten bleibt unveraendert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:23:41 +02:00
Nick SzaunigandClaude Opus 5.5 886b4e75e1 fix: Rate-Limit pro IPv6-/64 statt pro Einzeladresse
Der Standard-ThrottlerGuard trackt die volle req.ip. Ein IPv6-Client
kann innerhalb seines /64 beliebig Adressen rotieren und so die
Login-/Refresh-/SSO-Limits umgehen. Neuer ClientIpThrottlerGuard
liefert IPv4 unveraendert, IPv4-mapped als IPv4 und IPv6 als /64-Praefix.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:18:56 +02:00
Nick SzaunigandClaude Opus 5.5 d7f0d2c13b fix(security): TRUST_PROXY ohne uniquelocal, nur Pod-Netz und Cloudflare
Backend Image / build-and-push (push) Successful in 1m19s
Frontend Image / build-and-push (push) Successful in 30s
LAN-Adressen als vertrauenswuerdige Hops waren unnoetig weit: Traefik
verwirft X-Forwarded-For von LAN-Clients ohnehin, sie sind selbst Absender.
Vertraut werden nur noch 10.42.0.0/16 (Traefik-Pods) und die Cloudflare-Netze.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:57:09 +02:00
Nick SzaunigandClaude Opus 5.5 570f4f94e4 fix(security): TRUST_PROXY hinter Cloudflare auf Netzliste statt Hop-Zahl
Frontend Image / build-and-push (push) Successful in 3m26s
Backend Image / build-and-push (push) Successful in 3m33s
Mit TRUST_PROXY=1 (Standard) war req.ip die Cloudflare-Edge, weil Traefik
sie als letzten X-Forwarded-For-Eintrag anhaengt. Rate-Limits trafen damit
alle Clients derselben Edge gemeinsam, und IP-Freigaben von API-Schluesseln
sahen nie die echte Adresse.

Vorlage und .env.example nennen jetzt "loopback, uniquelocal" plus die
Cloudflare-Netze: Express nimmt den ersten Eintrag von rechts ausserhalb
dieser Netze, also den Client, den Cloudflare angehaengt hat. Vom Client
mitgeschickte Eintraege links davon erreicht die Auswertung nie.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:55:06 +02:00
Nick SzaunigandClaude Opus 5.5 04f9363e4b fix(k8s): persist documents and app backups, pin images, run hardened
Backend Image / build-and-push (push) Successful in 3m0s
Frontend Image / build-and-push (push) Successful in 3m12s
Documents, avatars and the encrypted app backups lived in ./uploads and
./backups inside the container and were lost on every restart. The
backend now mounts the apicore-data volume at /data (configmap paths
/data/documents and /data/backups).

Both deployments pin the image to a CI build (sha-<commit>) with
imagePullPolicy IfNotPresent instead of :latest, run as uid 1000 with
seccomp RuntimeDefault, no privilege escalation and all capabilities
dropped. The frontend gets a liveness probe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:25:45 +02:00
Nick SzaunigandClaude Opus 5.5 2359970f48 feat(k8s): daily pg_dump backup of the database
Backend Image / build-and-push (push) Successful in 2m43s
Frontend Image / build-and-push (push) Successful in 2m53s
CronJob apicore-db-backup dumps the database to the Storage Box
(StorageClass smb-backup, SMB 3.1.1 with seal). Keeps 14 daily and
8 weekly dumps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 02:04:28 +02:00
Nick SzaunigandClaude Opus 5.5 cde817845e chore(k8s): drop CPU and memory requests
Backend Image / build-and-push (push) Successful in 3m9s
Frontend Image / build-and-push (push) Canceled after 2m1s
Requests are set to an explicit 0 instead of being removed, because
Kubernetes defaults a missing request to the limit, which would reserve
even more. Limits stay as an upper bound.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 04:08:07 +02:00
Nick SzaunigandClaude Opus 5.5 910ba804a6 chore(k8s): pin postgres to 18.6-alpine
Backend Image / build-and-push (push) Successful in 3m12s
Frontend Image / build-and-push (push) Successful in 3m7s
The floating 18-alpine tag with IfNotPresent kept an old 18.x image
cached on the nodes, so minor releases never reached the cluster.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 01:55:03 +02:00
Nick SzaunigandClaude Opus 5.5 cfd3a187af feat(automation): separate lot number counters per raw material kind
Backend Image / build-and-push (push) Successful in 2m15s
Frontend Image / build-and-push (push) Successful in 2m3s
With {ART} in the raw material number format (default {ART}-{YYYY}-{SEQ:4}),
beeswax and propolis each count on their own: WA-2026-0001, PR-2026-0001.
Counters live in the new AutomationSequence table (one row per rule and
scope). Without {ART} the rule keeps its single shared counter, since
separate counters behind one prefix would hand out duplicate numbers.
Resetting a rule clears all of its counters.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 19:33:10 +02:00
Nick SzaunigandClaude Opus 5.5 5408711b2d feat(automation): number formats for raw material and production batches, raw material minimum
- RAW_MATERIAL_LOT_NUMBER_FORMAT / PRODUCTION_BATCH_NUMBER_FORMAT fill in the
  lot number when it is left empty, like honey batches and bottlings.
- RAW_MATERIAL_LOW: task when the total of a raw material kind drops below
  config.minGrams after consumption, withdrawal or inventory count.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:01:33 +02:00
Nick SzaunigandClaude Opus 5.5 fc887d8431 fix(ui): encode every id in API paths (client-side path traversal)
Backend Image / build-and-push (push) Successful in 1m9s
Frontend Image / build-and-push (push) Successful in 2m2s
Follow-up to a7609ff for the whole frontend: all 30 API helpers in
src/lib now pass path values through encodeURIComponent, so an id taken
from the URL can never leave its own path segment and make the page call
another endpoint with the viewer's token.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:40:25 +02:00
Nick SzaunigandClaude Opus 5.5 a7609ff3f8 fix(ui): encode ids in raw material, stock and production batch API paths
Security review finding (client-side path traversal): ids taken from the
URL were interpolated into API paths unencoded, so a crafted link like
/raw-materials/..%2Fusers made the page call another endpoint with the
viewer's token. Ids are now encodeURIComponent-ed; a regression test pins
"../users" to its own path segment.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:34:07 +02:00
Nick SzaunigandClaude Opus 5.5 44971c23e6 docs(spec): raw materials and production addendum
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:31:38 +02:00
Nick SzaunigandClaude Opus 5.5 569a43d247 feat(ui): raw material lots and production inputs
- Pages for raw material lots (beeswax, propolis): list, create in grams
  with optional apiary, detail with stock card (harvested / used in
  production / withdrawn / remaining) and "used in" batches.
- Production batch form takes the raw material used (lot + grams) and the
  batch page shows what it was made from - traceability both ways.
- Sidebar entry "Rohstoffe".
- Removes backend files that were committed into ApiCore-Frontend/src by
  mistake in e52a605 (copies of ApiCore-Backend/src/raw-materials).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:31:07 +02:00
Nick SzaunigandClaude Opus 5.5 f9899d5621 fix(stock): require raw-material permission to consume, guard quantity edits
Security review findings on the raw material commit:
- Creating a production batch with inputs books raw material off other
  lots; production-batch:create alone no longer suffices, the user also
  needs raw-material:withdraw (403 otherwise).
- Correcting the quantity of a raw lot or production batch below what was
  already consumed, sold or withdrawn is refused (row lock + check in the
  same transaction) instead of silently driving the remaining stock
  negative; shortfalls go through an inventory correction.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:31:06 +02:00
Nick Szaunig e52a6057a2 Bestandsabgleich umgesetzt 2026-10-06 00:02:35 +02:00
Nick SzaunigandClaude Opus 5.5 e73c8b2cb4 feat(raw-materials): wax and propolis lots consumed by production batches
- RawMaterialLot (beeswax, propolis) in grams with origin (apiary,
  harvest); stock in the same movement ledger: withdrawals, inventory
  counts, and the new movement type CONSUMPTION.
- Production batches take inputs (raw lot + grams); batch and consumption
  are one transaction, never more than a lot has left. Deleting a
  mistaken batch gives the material back as its own movement.
- Traceability both ways: batch -> inputs, raw lot -> usedIn.
- Movement belongs to exactly one of bottling / production / raw lot
  (CHECK); raw material changes do not trigger the shop webhook.
- Permissions raw-material:*, granted to roles that maintain production
  batches.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 23:54:43 +02:00
Nick SzaunigandClaude Opus 5.5 41c2af7284 feat(ui): article stock overview and production batch editing
- New article page /products/[id]: remaining stock summed over all
  production batches (or bottlings for honey), each batch linked to its
  own page for withdrawals and counts; "Bestand ansehen" in the list.
- Production batches can be corrected (lot number, date, quantity,
  best-before date, note) via PATCH; the article stays fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 21:28:15 +02:00
Nick SzaunigandClaude Opus 5.5 f57db97578 feat(ui): stock card with withdraw and inventory count, production batches
- Bottling detail shows Abgefüllt / Webshop verkauft / Entnommen / Rest
  (negative rest flagged "Prüfen") and the full movement list; buttons
  "Entnehmen" (reason, note) and "Inventur korrigieren" (counted
  quantity, difference booked).
- Rest column in the bottling list.
- New pages for production batches (list, create, detail with the same
  stock card), sidebar entry with its own permission.
- Products get "Art" (honey vs. manufactured goods) and the webshop
  category; bottlings only offer honey articles.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 06:44:21 +02:00
Nick SzaunigandClaude Opus 5.5 04f93e954a feat(stock): batch movement ledger, production batches and shop write-back API
One remaining quantity per batch, kept in ApiCore and always derived:
initial quantity (jarCount / produced quantity) plus the signed movements
in the new BatchMovement ledger, which is never deleted.

- Production batches for MANUFACTURED articles (candles, propolis
  products, ...); articles get a kind and a shop category.
- Withdrawals (farm shop, own use, breakage, sample) and inventory
  corrections on bottlings and production batches, never more than the
  remaining stock; every write locks the bottling or the article row.
- Shop bookings via POST /shop-sync/movements (new scope
  shop-sync:write): idempotent per externalId, articles allocated oldest
  best-before first, returns mirrored onto the batches of the sale,
  invalid entries rejected one by one.
- GET /shop-sync/stock and remainingCount in the shop feed; webhook
  stock.changed after every change (ids only, receivers fetch numbers).
- Bottlings and production batches with movements cannot be deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 06:38:12 +02:00
Nick SzaunigandClaude Opus 5.5 85f05dc541 docs(spec): stock sync between ApiCore and the Wabenhain shop
One remaining quantity per batch, kept in ApiCore: movement ledger for
bottlings and new production batches, shop write-back of paid sales and
returns, withdrawals in ApiCore reflected in the shop via webhook plus a
10-minute reconcile.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 05:51:58 +02:00
Nick SzaunigandClaude Opus 5.5 e231fb95dc style(test): prettier formatting in export e2e spec
Frontend Image / build-and-push (push) Successful in 2m5s
Backend Image / build-and-push (push) Successful in 2m16s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:44:29 +02:00
Nick SzaunigandClaude Opus 5.5 39f00a105c docs: AuDHD adaptation of all pages - rules, building blocks, open points
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:44:20 +02:00
Nick SzaunigandClaude Opus 5.5 2656fb8328 fix(ui): number keyboard on phones for every weight, amount and percentage field
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:38:55 +02:00
Nick SzaunigandClaude Opus 5.5 c3dc596170 feat(ui): honey batch page with one main action, traceability errors, confirmed removal
- DetailHeader: "Abfüllung anlegen" as the main action, PDF export in
  "Mehr", state as icon + word, retry and way back on load errors.
- Traceability says what it shows and offers a retry on errors instead
  of "Lädt…" forever.
- Removing a container allocation asks first and says what changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:37:37 +02:00
Nick SzaunigandClaude Opus 5.5 86f9291ee3 feat(ui): harvest, extraction, bottling and equipment pages with heading and way back
Same DetailHeader as colonies/queens: an h1, a 44 px back link, state as
icon + word, the obvious next step as the one primary button (harvest:
"Schleuderung erfassen"); a failed load offers a retry and a way back
instead of a bare red line. Colonies of a harvest are tappable links;
changing equipment condition confirms with a toast.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:36:39 +02:00
Nick SzaunigandClaude Opus 5.5 d4b1addb7d fix(security): keep form drafts per user and delete them on logout
Drafts were stored in localStorage by form only, so on a shared phone
the next person was offered the previous user's unfinished entry
(colony observations, treatment and supplier details). Drafts are now
keyed by the signed-in user id and every draft is removed on logout,
like the offline cache already was.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:33:38 +02:00
Nick SzaunigandClaude Opus 5.5 031e47eaad feat(ui): detail pages for colonies, apiaries and queens with one clear main action
New DetailHeader: heading, back link, status as icon + word, ONE primary
action and a "Mehr" menu for exports, QR and the rest.

- Colony: "Durchsicht erfassen" is the main action; sub-cards show a
  load error with retry instead of "Noch keine …".
- Apiary: error states, weather with units.
- Queen: edit card in sections, evaluations as 1-5 tap scales.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 18:32:33 +02:00
Nick SzaunigandClaude Opus 5.5 2bc0db6f33 fix(statistics): feeding totals per unit instead of adding litres to kilograms
The feeding statistics summed every amount regardless of its unit, so
5 l syrup and 2 kg fondant showed as 7. The API now also returns byUnit
(units normalised: "L" = "l"), the old fields stay for compatibility,
and the statistics page shows one list per unit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:27:24 +02:00
Nick SzaunigandClaude Opus 5.5 ed0a000534 feat(ui): remaining capture forms with sections, tap choices and drafts
Varroa, swarm control, harvest, extraction, bottling, task and queen
forms use the form kit: required fields marked, inline errors with focus
on the first problem, rare fields folded, drafts, save in thumb reach.
Single choices (varroa method, swarm mood/action, priority, marking
colour) are large tap buttons; colonies and harvests are picked as
tiles. Plain hints: per-day mite fall vs. threshold, Brutto/Netto,
water content, MHD and lot number, marking colour by year.

Fixed on the way: the queen form suggested the marking colour from the
first digit typed ("2" -> Gelb for 2026); it now waits for a full year.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:25:24 +02:00
Nick SzaunigandClaude Opus 5.5 49f3db5b56 feat(ui): storage, map, search, wintering and statistics without dead ends
- Every query shows loading, an error with "Erneut versuchen", an empty
  state with a next step, then data; statistics no longer hang on
  "Lädt…" after an error, a failed search no longer reads as "no hits".
- Storage: one primary action, stock items as a clearly secondary
  section, low stock as icon + word.
- Wintering: "Gassen" explained, outcome as icon + word, touch-size
  edit buttons; map: tells how many apiaries have coordinates.
- Statistics: units and plain words instead of "Ø", a select instead
  of crowded tabs on phones.
- QR code buttons are touch size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:22:32 +02:00
Nick SzaunigandClaude Opus 5.5 0e46f666aa fix(ui): backup schedule shows a load error with retry, not "not configured"
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:17:56 +02:00
Nick SzaunigandClaude Opus 5.5 d57162e709 feat(ui): undo for deleted label elements, warn before leaving unsaved changes
The Delete key removed a label element for good; it now shows a toast
with "Rückgängig" that puts it back in place. Closing or reloading the
designer with unsaved changes asks first.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:17:36 +02:00
Nick SzaunigandClaude Opus 5.5 b4c8d80c33 fix(ui): number keyboard for intervals in recurring task dialogs
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:16:19 +02:00
Nick SzaunigandClaude Opus 5.5 549a75988e feat(ui): webhooks as cards with events in words, calendar sync without env jargon
- Webhooks: one card per connection, events by their German label
  instead of keys like "product.stock_changed", state as icon + word,
  a one-sentence explanation of what a webhook is for, retry on errors.
- Calendar sync: the group id field says where to find it; the
  "not configured" note says who sets it up instead of listing
  environment variable names.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:15:54 +02:00
Nick SzaunigandClaude Opus 5.5 a21b1665e7 feat(ui): colony edit dialogs with sections and tap ratings, confirmed merge
Inspection, treatment, feeding, varroa and swarm-control edit dialogs use
the form kit (required markers, inline errors, 1-5 as buttons, yes/no
tiles, number keyboards, save button pinned at the dialog bottom).
Moving, splitting and merging colonies report success; merging - which
cannot be undone - asks first and says what happens.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:14:07 +02:00
Nick SzaunigandClaude Opus 5.5 9944aabbc3 feat(ui): list pages with retry on errors, a next step when empty, one main action
Lists load through QueryState: a failed load says so and offers
"Erneut versuchen" instead of looking empty; empty lists link to the
create page; exports move into a "Mehr" menu so "Neu …" is the one
primary action.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:14:07 +02:00
Nick SzaunigandClaude Opus 5.5 62e78498bf feat(ui): capture forms for treatments, feedings, colonies, apiaries, products, equipment
Same structure as the inspection form: required fields marked, errors at
the field, numbered sections with rarely used fields folded, drafts kept
on the device, save button in thumb reach, plain hints for jargon
(Wirkstoff, Wartezeit, Code, SKU, units). The Bestandsbuch fields of a
treatment stay a visible section of their own. Apiaries can take the
coordinates from the phone's location. Offline queue and idempotency
are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:14:07 +02:00
Nick SzaunigandClaude Opus 5.5 832997f885 feat(ui): users and audit log as readable cards with names and retry
- Users: one card per person on every screen size, status and MFA as
  icon plus word, server reasons (e.g. "more rights than you") shown.
- Audit log: names instead of user ids, area as a choice instead of a
  free-text type, optional record id, filter reset, cards instead of a
  5-column table, empty and error states with a next step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 12:13:40 +02:00
Nick SzaunigandClaude Opus 5.5 bd2070436c feat(ui): calendar as a day list on phones, today named, every entry reachable
- Phones get a list of the days that have entries (44 px rows with type
  word, icon and title) instead of a 7-column grid with 0.7 rem chips.
- Month grid on wider screens: days are buttons, the chosen day lists
  all its entries below, so "+N weitere" is no longer a dead end.
- Today is marked with a filled circle and the word "Heute"; a "Heute"
  button jumps back. Month navigation buttons are touch size.
- Load errors offer a retry; an empty month says so.
- Entries are grouped by local day instead of UTC, so late-evening
  entries no longer land on the next day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:38:09 +02:00
Nick SzaunigandClaude Opus 5.5 dff6c364c8 feat(ui): automation settings start with everyday tabs and confirm risky actions
- Tabs ordered from everyday (suggestions, recurring tasks, labels) to
  technical (calendar sync, API keys, webhooks); the page opens on
  "Vorschläge" instead of Webhooks, and says in one sentence what it is.
- Resetting a number counter, deactivating a label template and ending a
  recurring task now ask first and say plainly what happens.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:36:28 +02:00
Nick SzaunigandClaude Opus 5.5 78325af1fe feat(ui): touch-size controls, scrollable dialogs, motion levels everywhere, load states
- Inputs and selects are 44 px on phones (36 px from sm up); default and
  small buttons get 40/36 px on phones.
- Dialogs scroll inside the viewport, so long edit forms keep their
  save button reachable on a phone.
- Dialog zoom, sheet slide, sidebar icon scale and stat-bar growth only
  move at the motion level "Voll" (motion-full), not at "Dezent".
- QueryState / ErrorState / LoadingState: loading, error with "Erneut
  versuchen", empty, data - a failed load can no longer look like an
  empty list. EmptyState takes an action for the obvious next step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:33:50 +02:00
Nick SzaunigandClaude Opus 5.5 32dfd8d8a7 feat(ui): calmer inspection form with sections, tap ratings and drafts
AuDHD-oriented rework of the most-used form, plus shared building blocks
for the remaining forms:

- Field: label, "Pflicht"/"optional", hint and the error right at the
  field instead of a vanishing toast.
- FormSection: numbered steps; optional parts start folded and say how
  many fields they hold.
- RatingScale (1-5 as five 44 px buttons with both ends named),
  ToggleChip (yes/no tiles with tick and colour), StickySubmit (save
  stays in thumb reach on phones).
- useFormDraft + DraftBanner: an unfinished entry is kept on the device
  and offered back explicitly ("Weitermachen" / "Verwerfen"), never
  applied silently; drafts expire after 3 days.

Inspection form: 21 flat fields became 5 steps, only colony and date are
required, observations are single taps, a hint appears when neither queen
nor eggs were seen, server messages show inline.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:32:20 +02:00
Nick SzaunigandClaude Opus 5.5 8b6f48b996 docs: API security model, audit results and operating notes
Auth flow and guard order as built now, new architecture decisions
(user-management escalation guard, SSO identity binding, sealed backups),
a section on API security with the remaining known gaps, and the audit
table with commits, new env variables and steps after the update.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:27:22 +02:00
Nick SzaunigandClaude Opus 5.5 45efb08369 fix(auth): one-time SSO and MFA tokens, permission changes effective on every replica
- Every JWT carries a jti. The SSO completion token and the MFA challenge
  (once a code was accepted) are recorded as used in ConsumedToken; a
  replayed request or copied cookie cannot open a second session. Used
  ids are cleaned up after they expire.
- The token-validity check (active user, active role, password change)
  reads fresh on every request and refreshes the permission cache, so a
  deactivation or role change made on another replica applies to the very
  next request instead of up to 30 s later.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:24:28 +02:00
Nick SzaunigandClaude Opus 5.5 fc6af8737b style(public): drop a needless escape in the lot number pattern
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:20:55 +02:00
Nick SzaunigandClaude Opus 5.5 0899576262 fix(api): tighter public lot endpoint, SSO callback from config, mapped 4xx errors
- Public lot lookup: 30 requests/min per IP and only well-formed lot
  numbers (1-64 chars) reach the database.
- SSO callback URL is built from OIDC_REDIRECT_URI instead of the Host
  header; SSO cookies are cleared with the attributes they were set with.
- Logout is rate-limited like the other auth endpoints.
- Middleware errors with a 4xx status (body too large, malformed JSON)
  answer with that status instead of a 500; error log lines carry the
  path without query string.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:20:33 +02:00
Nick SzaunigandClaude Opus 5.5 eafe925d60 fix(search): only search record kinds the caller may read
search:read alone returned apiary names, colony and queen codes, task
titles and batch numbers to roles without the matching read permission.
Each kind of result is now searched only when apiaries:read,
colonies:read, queens:read, tasks:read or honey-batches:read is held.
The search term is validated (string, at most 100 characters).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:18:33 +02:00
Nick SzaunigandClaude Opus 5.5 0ba8996e3a feat(auth): 12-character minimum, 72-byte maximum and breach check for new passwords
- New passwords (create user, admin reset, own change) need at least 12
  characters (ASVS 2.1.1) and may not exceed 72 bytes, which bcrypt would
  silently cut off.
- They are checked against Have I Been Pwned with k-anonymity (only the
  first 5 SHA-1 characters leave the server, padded responses); an
  unreachable service is logged and does not block. PASSWORD_BREACH_CHECK
  =false disables it for offline deployments.
- Login accepts existing passwords as before, capped at 256 characters to
  bound anonymous bcrypt work.
- Password fields in the UI say the rule up front.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:47:09 +02:00
Nick SzaunigandClaude Opus 5.5 61e32474e4 fix(export): neutralize spreadsheet formulas in CSV exports
Typed-in values (colony codes, batch numbers, honey types) starting with
= + - @ TAB or CR were written as-is and ran as formulas (HYPERLINK, DDE)
when the export was opened in Excel or LibreOffice. Such text cells now
get a leading apostrophe; numbers keep their sign. Carriage returns are
quoted as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:43:44 +02:00
Nick SzaunigandClaude Opus 5.5 f1dc0de3e2 feat(api-keys): restrict keys to source IP addresses
A key can be limited to IP addresses or CIDR networks (e.g. the Wabenhain
server); a stolen key is useless from anywhere else. Requests from other
addresses get 403 "not allowed for this IP" and a log line. The admin UI
asks for the addresses when creating a key, warns when a key works from
anywhere, shows the last source address and can take it over into the
allowlist - changing the list needs no new key (PATCH /api-keys/:id).

Also: a key can only carry scopes its creator holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:41:46 +02:00
Nick SzaunigandClaude Opus 5.5 9ae1d96a32 fix(api): throttle before auth, configurable proxy trust, no-store and a strict CSP
- ThrottlerGuard runs first, so requests with wrong tokens or API keys
  count against the rate limit before any database lookup.
- TRUST_PROXY (hop count or Express list) replaces the hard-coded 1.
- Every API answer except the public lot pages is Cache-Control: no-store
  without ETag; helmet sends default-src 'none' / frame-ancestors 'none'
  (Swagger keeps the page policy when enabled) and same-site CORP.
- Request log lines carry the path only - no query strings (the SSO
  callback's code and state no longer reach the logs).
- In production the server refuses to start without an https
  FRONTEND_ORIGIN or with COOKIE_SECURE=false.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:37:31 +02:00
Nick SzaunigandClaude Opus 5.5 2de93bc087 fix(auth): atomic refresh rotation and a 7-day absolute session limit
- Rotation claims the old token with a conditional update; of two
  concurrent refreshes only one gets a successor, so a stolen token cannot
  fork into two live sessions.
- A token rotated out less than 10 s ago (parallel tab) is refused without
  revoking every session; later reuse still revokes them all.
- Sessions end at most SESSION_MAX_AGE (default 7 days) after login: the
  login time is inherited by every rotated token and caps its expiry.
- The refresh cookie is cleared with the same attributes it was set with.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:30:32 +02:00
Nick SzaunigandClaude Opus 5.5 5c666fe49a fix(auth): lock accounts after failed attempts, one-time TOTP codes, stronger MFA changes
- Per-account lockout: five failed attempts (password, MFA code or a
  step-up confirmation) lock the account for 15 minutes, on top of the
  per-IP throttle - guesses spread over many addresses no longer help.
- A TOTP code is accepted once: the time step is stored and that step or
  any earlier one is refused (also between two concurrent requests).
- Login runs bcrypt on every path and answers unknown email, wrong
  password, deactivated and locked accounts with the same message; failed
  logins for unknown emails no longer write attacker-chosen text into the
  audit log.
- Turning MFA off needs password and a current code; turning it on needs
  the password. Both end all other sessions.
- New audit action ACCOUNT_LOCKED.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:27:47 +02:00
Nick SzaunigandClaude Opus 5.5 79d20ee64e fix(webhooks): close DNS rebinding, time out deliveries, sign with timestamp
- Deliveries connect through a lookup hook that checks the address
  actually dialled, so a host that resolves to a private address after
  registration (DNS rebinding) or a tampered row cannot reach internal
  services. Redirects are still not followed.
- More blocked ranges (CGNAT/100.64/10 incl. cloud metadata, multicast,
  reserved, benchmarking, NAT64, 6to4, site-local, IPv4-mapped IPv6);
  only ports 80/443, no credentials in the URL, https in production.
- 5 s timeout, response body discarded; dispatch no longer waits for
  receivers, so a hanging endpoint cannot stall the user's request.
- Signature covers "timestamp.deliveryId.body" with X-ApiCore-Timestamp
  and X-ApiCore-Delivery headers, so receivers can reject replays.
- Signing secrets are stored AES-256-GCM-encrypted; older plain rows still
  work.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:17:40 +02:00
Nick SzaunigandClaude Opus 5.5 ebeb499a18 fix(backup): keep GUI backup paths under BACKUP_ROOT and limit the schedule
backup:configure could point automatic backups at any writable directory
(e.g. a statically served one) and set a cron of every second; an invalid
expression deleted the running job before failing, silently ending
backups.

- A path entered in the UI is resolved under BACKUP_ROOT (default
  ./backups) and refused if it leaves it; env values stay unrestricted.
- The schedule must be a five-field cron that runs at most hourly.
- reload() validates before replacing the running job.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:14:18 +02:00
Nick SzaunigandClaude Opus 5.5 bd592adf66 fix(backup): seal backups and require step-up auth to restore
Backups carried every password hash, encrypted MFA secret and webhook
secret in plain JSON, and an import upserted arbitrary rows - including
users - so anyone holding backup:import, or tricking an admin into
importing a file, could overwrite the admin account with a known password.

- Export (download and scheduled) is AES-256-GCM over the gzipped bundle,
  key from BACKUP_ENCRYPTION_KEY or derived from JWT_ACCESS_SECRET via
  HKDF; header is authenticated, decompression is capped. Scheduled files
  are written 0600 in a 0700 directory; run-now returns only the file name.
- Import requires the current password (and TOTP when MFA is on).
- Only a sealed file restores users, roles, grants, webhooks and audit
  entries; an old unsealed file restores business data only and reports
  what it skipped.
- Audit entries are append-only on import.
- Imported documents: entityType checked against the enum, MIME type
  allowlisted and matched against the file signature, path kept under the
  storage root (no more directory traversal via entityType).
- Permission cache is cleared after an import.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:12:43 +02:00
Nick SzaunigandClaude Opus 5.5 a25265b88a fix(sso): bind Microsoft sign-in to the IdP identity, not the email claim
SSO picked the local account by the email/preferred_username claim, which
Entra does not verify. Anyone able to present a matching email (another
tenant with a multi-tenant issuer, or an editable attribute) could sign in
as that user without a password.

- The first SSO login links the account to "<iss>|<oid>" (new unique
  column User.ssoSubject); afterwards only that identity signs in, and a
  matching email from another identity is refused.
- Multi-tenant issuers (common/organizations/consumers) disable SSO.
- Emails are compared case-insensitively and stored lower-case, so
  [email protected] and [email protected] can no longer be two accounts; the migration
  lower-cases existing emails where that does not collide.
- An admin changing a user's email clears the link.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 01:20:49 +02:00
Nick SzaunigandClaude Opus 5.5 c4a11b4704 fix(users): never manage or hand out more than the caller holds
A role with users:update could reset an administrator's password or MFA,
change the admin's email to one it controls, or give a colluding account
the ADMIN role (with users:assign-role) - and so become administrator.

Every management action (update, deactivate, activate, reset password,
reset MFA) and every role assignment now requires the target role's grants
to be a subset of the caller's own, like role editing already did. The
target role is read from its grants, so a deactivated admin still counts
as an admin. Changing another user's email revokes their sessions.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 01:16:37 +02:00
Nick SzaunigandClaude Opus 5.5 e0bd7b2095 fix(auth): accept only access tokens as bearer credentials (MFA bypass)
All JWTs (access, MFA setup, MFA challenge, SSO completion) were signed
with the same secret and the access-token strategy checked only signature
and expiry. The MFA challenge token returned after the password step
therefore worked as a full access token: a password alone was enough to
use the API, skipping the second factor.

Every token now carries a pinned issuer, algorithm (HS256) and a
per-purpose audience; the strategy accepts only the access audience and
rejects any payload carrying a helper-token purpose. Existing access
tokens (no audience) stop working; clients renew them via the refresh
cookie.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 01:14:02 +02:00
Nick SzaunigandClaude Opus 5.5 fac66882fa feat: Ein-/Auswinterung und Winterverluste je Volk
- WinterCheck per colony, winter and season (autumn/spring), every field
  editable; loss causes limited to the COLOSS monitoring categories.
- Summary per winter and apiary: loss rate lost/wintered, marked
  provisional while colonies are not yet checked in spring - an unchecked
  colony never counts as survived.
- A loss marks the colony deceased only on explicit request, in the same
  transaction and audited.
- Permissions winter-checks:read/create/update granted by migration to
  beekeeper and staff roles; winter checks included in backups.
- Page "Überwinterung": next action first, one button per row until the
  colony is wintered, German number format.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 17:32:03 +02:00
Nick SzaunigandClaude Opus 5.5 0d23963f07 feat(export): Bestandsbuch für Tierarzneimittel nach Art. 108 VO (EU) 2019/6
- Treatment records supplier, purchase proof and prescribing vet, and
  keeps the apiary the colony stood at on the treatment day.
- Holder name and address in site settings (never in public output;
  regression test in public.service.spec).
- treatment-log.pdf: one sheet per apiary, identical treatments grouped,
  missing mandatory fields printed as FEHLT, uncertain locations marked.
- Shared PDF table helpers extracted from the record card.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 11:10:07 +02:00
Nick SzaunigandClaude Opus 5.5 f263ec79f2 feat(export): Stockkarte - a colony's complete record as PDF
New report colony-record-card (button "Stockkarte (PDF)" on the colony
page): master data, current queen and queen history, every inspection as
a table row (strength, eggs, open/capped brood, queen seen, queen cells,
gentleness, swarm drive, feed, measures, notes), treatments with batch
number and withdrawal period, feedings, varroa counts and harvests.
Landscape A4, table header repeated on each page. Whole history by
default, one calendar year with ?year=.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:27:55 +02:00
Nick SzaunigandClaude Opus 5.5 5cf4e87ca3 feat(automation): suggest follow-up checks after treatments and feedings
Two new rules, "Kontrolle nach Behandlung" and "Kontrolle nach
Fütterung", create a check task when a treatment or feeding is recorded.
The due date counts from the measure itself (end of treatment, else its
date; feeding date) plus the configured number of days, and the task
names the product and date. At most one open check per colony and rule.

Both rules ship switched off and without an interval: the right timing
depends on the product (package leaflet) and season, so the beekeeper
sets it. Every task-creating rule can now carry a "due after N days"
setting in the automation page.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:23:13 +02:00
Nick SzaunigandClaude Opus 5.5 888edf7e1d feat(labels): cut marks in the sheet margin for trimming with a paper cutter
Label sheets printed on plain A4 are cut with a lever paper cutter. Each
template can now print small hairline cut marks in the sheet margin: one
at both ends of every cut line (left/right edge of each column, top/bottom
edge of each row), 1 mm away from the label and never closer than 2 mm to
the paper edge, so they print on normal printers and never touch a label.
Labels without a gap share one mark. Marks are drawn on every sheet for
the full grid, since the cutter cuts across the whole sheet.

On by default (new column cropMarks, default true); switch it off in the
template designer for pre-die-cut label sheets.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:11:03 +02:00
Nick SzaunigandClaude Opus 5.5 445fb3b94b feat: revocable read-only API keys for machine access
Integrations such as the Wabenhain shop sync no longer need a service
account with a password (and without MFA). An admin creates a key under
Verwaltung > Automatisierung > API-Schlüssel.

- Key format ack_<8>_<43>; only its SHA-256 hash is stored, the key is
  shown exactly once. Optional expiry, "last used" timestamp, revocation
  takes effect immediately; create/revoke are audit-logged without the key.
- A key is not a user: JwtAuthGuard sets request.apiKey, PermissionsGuard
  admits it only to routes whose permission is among its scopes and never
  to user-only routes. Scopes come from a short allowlist (shop-sync:read).
- Managing keys requires the new permission api-keys:manage.
- Keys are excluded from backups on purpose; a restore means issuing a new
  key.

Additive migration (new table).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 01:24:17 +02:00
Nick SzaunigandClaude Opus 5.5 ede86c062a feat: dedicated, versioned shop-sync feed for Wabenhain
GET /api/v1/shop-sync returns exactly the data ApiCore releases to the
shop: harvest facts, article master data and bottlings with their
harvest ids. Every query uses an explicit select, so notes, colonies,
treatments and fields added to the models later never leave ApiCore
unless deliberately added to the allowlist.

- New permission shop-sync:read (granted to ADMIN automatically, to no
  other role): the shop's service account needs nothing else, in
  particular no access to harvest or extraction records with notes.
- contractVersion 1; only breaking changes bump it.
- Always the full set: small volume, and a full comparison heals missed
  runs by itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:31:46 +02:00
Nick SzaunigandClaude Opus 5.5 3c7ff1e0b7 docs: document field ownership between ApiCore and Wabenhain
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:15:02 +02:00
Nick SzaunigandClaude Opus 5.5 2d4da96ad8 docs: record review findings, verification state and new architecture decisions
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 13:01:47 +02:00
Nick SzaunigandClaude Opus 5.5 f9374a135a feat: "Heute" start page with one next step, clear task states and motion levels
Start page (AuDHD-oriented, per the agreed "Heute zuerst" layout):
- One highlighted next step: started work first (easy re-entry after an
  interruption), then overdue, due today, then urgent undated tasks.
  Primary action "Erledigt", secondary "Beginnen", link to the related
  colony/apiary/queen.
- "Außerdem heute": at most five further tasks with a large honey-cell
  checkbox; undated automatic suggestions are included so a queenless
  colony does not sit unseen in the full list.
- Completing is the one authored motion moment: the cell fills, the tick
  draws, the row folds away and the next step arrives with a short focus
  pull. Every completion can be undone from the toast (10 s), restoring
  the previous status.
- Status is shown as icon plus word (never colour alone); tasks created by
  automation carry a "Vorschlag" badge, recurring and inspection tasks
  their own - a suggestion never looks like work already done.
- Friendly empty, loading and error states; key figures moved into a
  compact overview below; overdue tasks are no longer listed twice.

Motion: per-device setting Voll / Dezent / Aus in the profile (default
Voll); the OS reduce-motion setting always lowers Voll to Dezent.
New 44 px "touch" button size for gloves at the apiary; themed text
selection and caret.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 13:00:44 +02:00
Nick SzaunigandClaude Opus 5.5 5c2ea7bf1e fix: resend offline-queued entries with an idempotency key
When the server saved an inspection, harvest, treatment, feeding, varroa
measurement or swarm check but the answer was lost (dead zone at the
apiary), the form queued the entry and the sync later created it a second
time - including a second automation task. Each opened form now creates
one Idempotency-Key, sends it with the request and stores it with the
queued entry, so the retry returns the first record. Entries queued before
this change still sync without a key.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:31:04 +02:00
Nick SzaunigandClaude Opus 5.5 5d11756a87 fix: keep quantities, colony/queen states and tasks consistent under retries
Data-integrity fixes found in a systematic review of ApiCore:

- Bottling booked the honey batch amount from a value read outside the
  transaction and wrote an absolute result back, so two concurrent
  bottlings (double tap, two devices) could both pass the check and one
  deduction was lost. Bookings now use a conditional UPDATE inside the
  transaction; corrections and deletions give amounts back atomically.
- Outgoing stock movements could drive stock below zero; the removal is
  now a conditional decrement that refuses when stock is insufficient.
- Weak, conspicuous and queenless colonies were treated as "not active"
  and could not be moved, split or merged - uniting a queenless colony
  is the textbook merge. Only terminal statuses lock a colony now; merging
  a colony into itself and dissolving twice are rejected.
- Moving a queen to another colony failed on the unique currentQueenId
  because the old colony kept pointing at her. The old colony now releases
  her (and becomes QUEENLESS); a queenless colony receiving a queen becomes
  ACTIVE; dissolved colonies cannot receive a queen.
- Completing a task twice moved completedAt and re-sent the webhook;
  reopening a task kept completedAt. Recurring templates created tasks
  without a due date and caught up missed weeks one stale task at a time.
- Automation rules created a new task and warning on every trigger (every
  stock removal below minimum). Tasks now record their source trigger and
  at most one open task per rule and object exists. The varroa threshold
  (mites per day) is only compared with natural-mite-fall rates, not with
  sample-based counts in another unit.
- New opt-in Idempotency-Key support for record-creating endpoints, so
  the offline queue can retry after a lost response without duplicates.
- Permission catalog sync crashed replicas booting concurrently on a
  fresh database (unique violation); it now uses createMany/skipDuplicates.
- Server-side validation for inspection rating scales (1-5), unique
  colony/harvest lists and net weight <= gross weight.
- Stale e2e expectations updated (permission count, trigger list, real PNG
  fixture for the file-signature check).

Migration is additive only (nullable Task column, new table).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:27:45 +02:00
Nick SzaunigandClaude Opus 5 786d929df9 feat: expose the remaining fields the edit forms were withholding
Backend Image / build-and-push (push) Successful in 2m11s
Frontend Image / build-and-push (push) Successful in 1m53s
The backend accepted every field of these records already; the forms simply
did not offer them, so from the app they were still frozen.

- Inventar: only the condition could be changed. A mistyped inventory number,
  a wrong purchase or maintenance date, a renamed item - all stuck for the life
  of the record. New edit dialog covering every field; the inline condition
  select stays as a shortcut.
- Produkt: the status was shown as a badge but could not be set anywhere, so a
  product could never be discontinued or brought back.
- Aufgabe: the status could only ever move to DONE through the "Erledigt"
  button. IN_PROGRESS and CANCELLED were unreachable, and an accidentally
  completed task could not be reopened.
- Aufgabenvorlage: priority, assignee and the active flag were missing.
  Deactivating was one-way, since only a deactivate endpoint was wired up -
  the active checkbox now reactivates through PATCH.
- Königinnen-Bewertung: notes and the evaluated colony were missing.

Verified by extracting the field set each form submits and comparing it with
the matching update DTO: every form now covers its DTO. The one gap left is
re-linking a document to a different record, which still has no UI.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 23:11:12 +02:00
Nick SzaunigandClaude Opus 5 5a5bd4a138 fix: print the jar size on labels in grams, not millilitres
renderLabelsPdf resolved the jarSize placeholder to "500 ml". Everywhere else
in the app jarSize is a fill weight in grams - the bottling forms label it
"Glasgröße (g)", the batch list and the public Herkunft page print "g", and
BottlingService divides by 1000 to get kilograms. Only the printed label said
millilitres, so every label produced so far declares the wrong net quantity for
a food sold by mass.

The placeholder mapping moved into an exported buildPlaceholderValues() so the
wording can be asserted directly. It could not be before: the render output is
a PDF buffer whose text a test cannot read, which is how the wrong unit shipped
unnoticed. Three tests now cover it, including one that rejects any volume unit
outright.

Labels already printed with "500 ml" have to be reprinted - the code change
does not correct paper.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 02:37:00 +02:00
Nick SzaunigandClaude Opus 5 46a2322814 fix: restore the e2e suite, which could not compile at all
Backend Image / build-and-push (push) Successful in 2m4s
Frontend Image / build-and-push (push) Successful in 1m51s
test/jest-e2e.json listed the ESM-only packages ts-jest must still transform,
but @nestjs/schedule (12.0.1, "type": "module") was not among them. Since
AppModule imports ScheduleModule for the backup scheduler, every e2e spec died
on "SyntaxError: Unexpected token 'export'" while loading AppModule - the whole
suite, not one test.

That silently disabled test/authorization-coverage.e2e-spec.ts, the check that
asserts every non-public route carries @RequirePermission or
@AnyAuthenticatedUser. It is exactly the check that would have caught the
displaced decorators fixed in the previous commit, and it had been dead since
the scheduler shipped.

With @nestjs/schedule and its cron dependencies added to the allowlist, the
authorization-coverage spec compiles and passes, confirming no route is left
undecorated. The remaining 36 e2e specs still need a Postgres instance on the
port .env.test points at; they fail on connection, not on code.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 02:22:09 +02:00
Nick SzaunigandClaude Opus 5 b8809eeaa2 fix: restore the authorization decorators three new routes displaced
Inserting the new PATCH handlers directly above an existing route put them
between that route's @RequirePermission decorator and its method, so the
decorator bound to the new handler instead:

- DELETE /documents/:id lost documents:delete
- POST /honey-batches/:id/measurements lost honey-batches:measure
- POST /queens/:id/evaluations lost queens:evaluate

and each new PATCH ended up carrying two @RequirePermission decorators, of
which only one takes effect.

This did not open anything up. PermissionsGuard fails closed by decorator
absence: a non-public route with neither @RequirePermission nor
@AnyAuthenticatedUser is denied outright, so the three routes would have
returned 403 for every caller including an administrator. The effect was a
broken endpoint, not a bypass.

Each decorator is back on the route it belongs to, and the duplicates are
gone. test/authorization-coverage.e2e-spec.ts asserts exactly this contract
across every route and is the check that should run before a route change
ships.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-22 02:12:56 +02:00
Nick SzaunigandClaude Opus 5 9a147ee123 feat: make webhooks and documents editable instead of delete-and-recreate
Both could only be created and deleted. For a webhook that is actively harmful:
recreating it issues a new signing secret, so the receiver that verifies the
X-ApiCore-Signature header against the old one starts rejecting every delivery
without anyone changing it on purpose.

Backend:
- New PATCH /webhooks/:id behind a new webhooks:update permission, covering the
  target URL, the event selection and the active flag - the last of which had
  no way to be set after creation at all, so a misbehaving receiver could only
  be deleted. The signing secret is deliberately left alone and stays out of
  the audit log.
- A changed webhook URL runs through the same assertPublicWebhookUrl SSRF check
  as a new one; without that the edit would have been a way around the guard
  create() applies. Covered by a test.
- New PATCH /documents/:id behind a new documents:update permission, for
  re-linking a document filed against the wrong record and correcting its
  display name. Re-linking validates the new target with the existing
  assertEntityExists; the stored file is untouched.

Frontend:
- New webhook edit dialog. The event labels and the toggle helper moved out of
  the create dialog into lib/webhooks/labels.ts so both use one copy.
- Documents can be renamed from the attachment card.

Re-linking a document to a different record has no UI yet: it needs a two-step
picker (colony, then inspection) that is not worth building unprompted.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-22 02:07:48 +02:00
Nick SzaunigandClaude Opus 5 42ceb33844 feat: allow correcting honey measurements and queen evaluations
Backend Image / build-and-push (push) Successful in 2m3s
Frontend Image / build-and-push (push) Successful in 1m41s
Both records could be created but never changed. A water-content reading
decides whether a batch may be sold as honey at all, and queen ratings drive
breeding decisions - a slipped digit in either is exactly the kind of wrong
value that must not be left standing.

- New PATCH /honey-batches/measurements/:measurementId behind
  honey-batches:measure, and PATCH /queens/evaluations/:evaluationId behind
  queens:evaluate. Both are addressed by the child record's own id, so the
  routes take two path segments and do not collide with PATCH /:id.
- Both log before and after data, like every other correction.
- The measurement list on the honey batch page and the rating list on the queen
  page gained an edit dialog; the rating dialog reuses the existing criteria
  list rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:15:54 +02:00
Nick SzaunigandClaude Opus 5 b0e05daa42 feat: open the last locked fields for editing, keeping their history intact
Four fields were still frozen because changing them would have bypassed the
record that makes the change traceable, and a user's login data could not be
corrected at all. Locking a field is the wrong trade: a wrong value stays in
the system forever. The fix is to let the edit write the same history entry the
dedicated endpoint writes.

- A colony's apiary is editable through PATCH and writes the same
  ColonyApiaryHistory entry POST /colonies/:id/move writes, so the location
  history never skips a stand the colony was recorded on.
- A honey batch's storage container is editable through PATCH and writes the
  same HoneyBatchRelocation entry POST /honey-batches/:id/relocate writes.
- A stock item's quantity is editable and books the difference as an ADJUSTMENT
  movement, so the stored level stays the sum of its movements.
- A role's key is editable (system roles were already rejected outright by
  RolesService.update, which keeps the seed's ADMIN lookup intact).
- A user's e-mail - the login identity - is editable. A typo there previously
  locked the account out for good.
- New POST /users/:id/reset-password lets an administrator set someone else's
  password: there is no self-service reset flow, so a forgotten password was
  the one thing no edit could repair. It revokes every refresh token and clears
  the permission cache exactly as a self-service change does, refuses to act on
  your own account (that goes through /profile/password, which proves ownership
  by asking for the current one), and never writes the password to the log.

Frontend:
- Users had no edit UI at all, although PATCH /users/:id existed. New
  EditUserDialog covers name, e-mail, role and an optional new password.
- Colony, honey batch and stock item forms gained the newly editable fields,
  each with a note saying the correction is recorded as a move, a relocation
  or a stock adjustment.
- The role dialog gained the key field.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:13:00 +02:00
Nick SzaunigandClaude Opus 5 bee2199647 feat: make the parent relation of every record correctable after entry
An inspection, treatment, feeding, varroa measurement or swarm control check
filed against the wrong colony could not be repaired: the colony was frozen at
creation time, and with no delete endpoint either, the only remedy was leaving
a wrong record in the colony's history. The same held for a harvest booked
against the wrong apiary or with a colony forgotten, for an extraction linked
to the wrong harvests, and for a honey batch whose number or source extraction
was mistyped.

Backend:
- The five colony-scoped update DTOs now cover the full create DTO, colonyId
  included.
- Harvest update accepts apiaryId and colonyIds, extraction update accepts
  harvestIds. Both validate the ids first and replace the whole join-row set
  inside the existing transaction; the join rows carry no data of their own,
  so replacing beats diffing.
- Honey batch update accepts batchNumber, extractionId and status. Status was
  previously only ever set to BOTTLED by the bottling module, so SOLD and
  CONSUMED were unreachable through the API.
- Colony and storage container codes, and a task's relatedType/relatedId, are
  editable.
- apiaryId on a colony and storageContainerId on a honey batch stay out of
  PATCH on purpose: both have dedicated endpoints that also write the history
  entry (ColonyApiaryHistory, HoneyBatchRelocation) the traceability chain
  depends on.
- AllExceptionsFilter now also translates Prisma P2003 to a 400 and P2025 to a
  404. Services write straight against the foreign keys with no application
  level existence check, so before this a stale or mistyped relation id came
  back as a bare 500 and filled the log with fake server errors.

Frontend:
- New shared fields: ColonySelectField (used by all five colony-scoped edit
  dialogs), HarvestSelectField (both extraction dialogs) and TaskRelatedField.
- Re-filing a record invalidates the lists of both the colony it left and the
  one it joined, otherwise one of the two stays stale.
- The harvest dialog gained an apiary select and a colony checklist that keeps
  already-linked colonies visible even when they moved or were dissolved; the
  honey batch dialog gained batch number, source extraction and status.
- Extraction and harvest forms refuse to submit an empty relation set instead
  of letting the backend reject it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:01:05 +02:00
Nick SzaunigandClaude Opus 5 2b15795a60 feat: make every field of a bottling batch correctable and deletable
A bottling batch could only be corrected in four of its seven fields after
creation: honey batch, jar size and jar count were frozen at creation time,
an assigned product could not be cleared again, and there was no way to
remove a record entered by mistake at all. A mistyped jar count is a
data-entry error, not a fact of production, so it has to stay correctable.

Backend:
- UpdateBottlingBatchDto now covers every field. `productId: null`
  explicitly clears the product assignment; omitting it leaves it alone.
- BottlingService.update rebalances the affected honey batch amounts:
  the previously bottled amount is given back before the corrected amount
  is taken again, so a correction never double-charges the source batch.
  Moving a batch to a different honey batch credits the old one and debits
  the new one in the same transaction.
- New DELETE /bottling-batches/:id gives the bottled amount back to the
  honey batch and keeps the full record in the audit log (action DELETE,
  beforeData). The honey batch status is deliberately left untouched - the
  status it held before being bottled is not recoverable here.
- New permission bottling:delete, checked server-side. The catalog sync
  grants it to system roles on the next boot.

Frontend:
- The edit dialog exposes all fields, offers "Kein Produkt" to clear the
  assignment, keeps a discontinued product selectable so opening the dialog
  cannot silently drop it, and shows backend errors inline.
- The form is mounted only while the dialog is open, so a cancelled edit
  never leaks its values into the next one.
- New delete button behind a confirmation that names the amount credited
  back and warns that the public origin page for the lot number stops
  resolving.
- Edit and delete are additionally hidden without the matching permission.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 07:50:36 +02:00
Nick SzaunigandClaude Sonnet 5 961ca04a40 fix: translate duplicate-code errors into clean 409 responses
No code anywhere caught Prisma's P2002 (unique constraint violation).
Colony/apiary/queen codes, product SKUs, batch numbers, and equipment
inventory numbers are all @unique DB columns with no application-level
duplicate check ahead of the insert, relying on the constraint itself
- so a beekeeper reusing a code by mistake got a bare 500 "Internal
server error" with zero indication what went wrong, and the incident
polluted server logs as a fake error rather than a routine 4xx.

AllExceptionsFilter now recognizes P2002, returns 409 with a specific
German message naming the conflicting field when it's a single known
column (falling back to a generic duplicate message otherwise), and
logs it at warn level like any other client-side rejection.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:47:28 +02:00
Nick SzaunigandClaude Sonnet 5 dfb4de81f8 fix: surface remaining silent mutation failures
Four more mutations had no error feedback: equipment condition change,
task completion, and notification mark-as-read all failed silently.
More importantly, the offline sync badge computed a result.failed
count from syncPendingInspections() but never displayed it - an entry
rejected by the server (e.g. a validation rule) would sit in the
offline queue indefinitely with zero indication to the beekeeper why
it never syncs. Both the failed-count and outright sync-mutation
rejection now surface a toast.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:41:36 +02:00
Nick SzaunigandClaude Sonnet 5 997dadfaaa fix: surface specific business-rule error messages to beekeepers
Backend business-rule guards (inactive apiary blocking colony
create/move, active colonies blocking apiary deactivation, negative
quantity on IN/OUT stock movements, unavailable queen blocking colony
assignment, inactive colony blocking split/merge/move) threw English,
UUID-laden exception text that the frontend either discarded in favor
of a generic message or - in colony-actions.tsx's move/split/merge/
assign-queen dialogs - didn't handle at all, so the guard fired with
zero visible feedback. Translated the messages to plain German and
wired the affected mutations to display them, so a beekeeper sees the
actual reason a save was rejected instead of silence or "failed".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:38:35 +02:00
Nick SzaunigandClaude Sonnet 5 de5df72491 fix: handle document download and clipboard copy failures
Two unguarded async calls silently swallowed errors: document download
used a bare "void downloadDocument(...)" with no catch, so a failed
download (network error, expired session) did nothing visible; the
webhook secret copy button had no try/catch around
navigator.clipboard.writeText, which can throw on denied permission or
an insecure context. Both now show a toast on failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:31:15 +02:00
Nick SzaunigandClaude Sonnet 5 4ec1468eb4 fix: add error feedback to PDF/CSV export mutations
Export mutations (annual report PDFs, colony/honey-batch CSV exports)
had no onError handler, so a failed download (network error, expired
session) silently did nothing with zero user feedback. Same silent-
failure class as the earlier onError sweep on entity-save mutations,
here applied to the 5 remaining export/download call sites.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:29:01 +02:00
Nick SzaunigandClaude Sonnet 5 b4caa2ad5a fix: show an error toast when editing any entity fails
Nearly every *-edit-dialog.tsx and *-edit-card.tsx in the app - the
save mutation for editing a colony, queen, treatment, inspection,
feeding, varroa measurement, swarm control check, harvest, extraction
(both variants), honey batch, bottling batch, task, stock item,
storage container, or product - had an onSuccess handler but no
onError. A failed save (a validation error, including several added
earlier this session, a permission error, a network hiccup) produced
no feedback at all: no toast, no inline message, the dialog just sat
there having silently done nothing. The user's most likely read on
that is "it saved," when it didn't.

Added the same onError: () => toast.error("<Entity> konnte nicht
gespeichert werden") pattern already used elsewhere in the app (e.g.
apiary deactivate/reactivate) to every one of these mutations. Also
covers assign-colony-dialog.tsx, which is the exact spot where the
queen-availability guard added earlier this session would otherwise
have failed with zero visible feedback.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:22:11 +02:00
Nick SzaunigandClaude Sonnet 5 96207d0096 feat: mirror the new backend numeric bounds as HTML min/max on inputs
The previous two commits added @Min(0)/@Max(100) validation and a
sign-inversion guard on the backend, but the number inputs themselves
had no min/max attributes - a user could still type a negative weight,
quantity, or an out-of-range percentage and only find out via a
generic "failed" toast after submitting, with no indication of why.

Added min={0} (and max={100} on water-content percentages) to every
input for a field the backend now bounds: harvest weights/super
count, honey-batch amount/water content, honey measurements, feeding
amount, extraction amount/water content, stock item quantity/min
threshold, and varroa mite count/duration/calculated value - across
both the create and edit form for each.

The stock-movement quantity input is the one exception requiring
care: min is only applied when type !== "ADJUSTMENT", matching the
backend guard, since a correction movement must still be allowed to
go negative. Added a short explanatory line under that field too.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:12:24 +02:00
Nick SzaunigandClaude Sonnet 5 b208f52fd1 fix: reject negative weights/quantities and out-of-range percentages
None of these numeric business fields had a lower bound, so a
negative honey weight, jar count, feeding amount, mite count, or
stock quantity was silently accepted - each would corrupt whatever
total or statistic it later feeds into. Added @Min(0):

- Harvest: honeySuperCount, grossWeight, netWeight
- HoneyBatch: amount (+ @Max(100) on waterContent, a percentage)
- HoneyMeasurement: waterContent (+ @Max(100))
- Feeding: amount
- Extraction: amount (+ @Max(100) on waterContent)
- StockItem: quantity, minThreshold
- VarroaMeasurement: durationMinutes, miteCount, calculatedValue

Left temperature fields alone (extraction, inspection) - genuinely
can be negative (winter inspections, outdoor storage) and no
confident domain bound exists to apply instead. Update DTOs for these
entities all extend PartialType(PickType(Create...Dto, [...])) from
@nestjs/swagger, which preserves validation decorators, so no
separate change was needed there.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:05:13 +02:00
Nick SzaunigandClaude Sonnet 5 93d0f2b208 fix: reject a negative quantity on IN/OUT stock movements
addMovement() negates quantity internally for OUT (delta = -quantity)
and applies it directly for IN. Nothing stopped a negative quantity
from being submitted, so e.g. { type: 'OUT', quantity: -5 } would
compute delta = -(-5) = +5 - silently ADDING stock while being
recorded in the audit trail as a removal. The inverse held for IN.
ADJUSTMENT is the one type that must stay allowed to be negative,
since that's the only way to correct a stock count downward, so this
is a service-level guard (BadRequestException) rather than a DTO
validator - class-validator's @ValidateIf skips every validator on a
property when its condition is false, including @IsNumber, so it
can't express "always a number, but only sometimes positive" cleanly;
verified this empirically before choosing the service-level approach.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:01:45 +02:00
Nick SzaunigandClaude Sonnet 5 8bf9aa467d feat: add error boundaries so a crash never blanks the whole app
No error.tsx existed anywhere in the app - an uncaught render error on
any page (a beekeeper's most field-critical moment: mid-inspection,
no signal, nowhere to go) showed nothing recoverable, not even a
message, let alone a way back to a working page.

Two boundaries, matching Next.js App Router's error-boundary scoping:
- (app)/error.tsx covers every authenticated page. It only replaces
  the crashed page's content - the sidebar/header/bottom nav stay
  mounted and usable, so the user can navigate to a different page
  without a full reload.
- app/error.tsx is the outer fallback (login, the public Herkunft
  page, or a crash in (app)/layout.tsx itself, which only a strictly
  higher boundary can catch). Its "Zum Dashboard" action is a plain
  <a> reload rather than client-side navigation, since a crash this
  high may have left client state corrupted.

Both are in German, matching the app's existing card/honeycomb visual
language, and explicitly reassure the user that already-saved data is
unaffected.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 10:55:47 +02:00
Nick SzaunigandClaude Sonnet 5 2e6846285d fix: include Wirkstoff, Charge, and Wartezeit in the annual treatment report
The colony annual PDF report's treatment section only ever printed
date/product/amount - activeIngredient, batchNumber, and
withdrawalPeriodDays (added earlier this session specifically for
Wartezeit compliance) existed on the Treatment record but never made
it into the one document a beekeeper would actually hand to a
Veterinäramt inspector or keep as their Bestandsbuch-style compliance
record. Silently missing the withdrawal period from the printed
record undermines the whole point of tracking it.

Also adds the first tests for annual-reports.ts (previously untested).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 10:50:46 +02:00
Nick SzaunigandClaude Sonnet 5 fb4713e320 fix: cache backend API data so beekeepers can view it with no signal
The service worker only ever cached same-origin static assets/pages -
every real data call (colonies, apiaries, tasks, honey batches, ...)
was excluded, both because it targets a different origin (API_URL
points at a separate backend service, per .env.example) and because
of an explicit /api path exclusion. In practice this meant a
beekeeper opening the app at an apiary with no signal saw nothing at
all: not even data fetched minutes earlier on the same device, despite
the offline write-queue built earlier assuming the app itself stays
usable while offline.

The backend can't be matched by origin the way static assets are (the
service worker has no static knowledge of the runtime-configured
API_URL), so it's matched by path instead - every backend call goes
through /api/v1/. /api/runtime-config is explicitly excluded and
always fetched live, since a stale cached value would point the app
at the wrong backend. Error responses (4xx/5xx) are now never cached
either, closing a latent bug that could otherwise serve a stale 401
back after a token refresh.

Since the cache now holds real business data, it's cleared on logout
so it can't leak into the next person's session on a shared device.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:45:11 +02:00
Nick SzaunigandClaude Sonnet 5 3678a01319 fix: prevent new backend guards from ever surfacing as raw errors
The colony-apiary and queen-availability validation just added
backend-side had no matching frontend guard, so a user could still
pick an inactive apiary or open the assign-colony dialog on an
unavailable queen and only find out it fails after submitting:

- Colony create/move apiary pickers now fetch only ACTIVE apiaries.
- The queen "Volk zuweisen" button is disabled once her status is
  LOST/REPLACED/SOLD/DECEASED (QUEEN_UNAVAILABLE_STATUSES, mirroring
  the backend's QUEEN_NO_LONGER_AVAILABLE_STATUSES), instead of only
  failing after the form is filled out and submitted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:39:47 +02:00
Nick SzaunigandClaude Sonnet 5 3e179391b0 fix: block assigning a colony to a queen who is no longer available
assignColony() never checked the queen's own status, so a queen marked
LOST/DECEASED/REPLACED/SOLD - exactly the statuses the queen-loss fix
now treats as "not really available" and releases her from her old
colony for - could still be assigned to a brand new colony, which
makes no domain sense (you can't put a dead queen in a hive).

Rejects with ConflictException up front, before even looking up the
destination colony; correcting a mistaken status update first is the
intended fix path.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:35:32 +02:00
Nick SzaunigandClaude Sonnet 5 5c5c7b756f fix: block creating or moving a colony into an inactive/missing apiary
Colony.apiaryId was never checked against the target apiary at all -
create() would hit a raw Prisma FK-constraint error for a nonexistent
apiary instead of a clean 404, and both create() and move() would
silently succeed against a deactivated apiary, orphaning the colony
the same way (just from the opposite direction) as the apiary-side gap
fixed in the previous commit.

Adds a shared assertApiaryActive() check, following the same
findUnique-then-status pattern already used for cross-entity checks
elsewhere (e.g. QueensService.assertMotherQueenExists), used by both
create() and move().

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:32:53 +02:00
Nick SzaunigandClaude Sonnet 5 961a237335 fix: block deactivating an apiary while it still has active colonies
Colony.apiaryId is a plain FK, never checked against the apiary's own
status. Without a guard, deactivating an apiary silently orphaned any
colonies still living there: they kept showing as ACTIVE, but their
apiary had disappeared from every "active apiaries" list, dashboard
count, and colony-creation picker - a confusing half-state with no
warning at all.

ApiariesService.deactivate() now throws ConflictException when active
colonies remain, so the beekeeper has to move or dissolve them first -
matching the same assertActive-style guard pattern already used
elsewhere (colonies, e.g. move()/merge()/split()).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:30:19 +02:00
Nick SzaunigandClaude Sonnet 5 20981ec1ce fix: release a colony's queen when it is dissolved or merged away
Colony.currentQueenId is unique across colonies. dissolve() and merge()
set status to DISSOLVED/MERGED but never cleared currentQueenId, so a
colony that still had a queen when it was dissolved or merged kept
permanently "owning" her - any later attempt to assign that queen to a
different colony (e.g. requeening another colony with her) would fail
with a unique-constraint conflict, since the dead colony's stale
reference was still occupying that slot.

Both operations now clear currentQueenId and close the open
QueenColonyHistory row for that queen, mirroring the bookkeeping
assignColony() and the queen-status-transition fix already do
elsewhere. The merge survivor keeps its own queen; a merged-away
colony's queen is released, not auto-transferred, since which queen
"wins" a merge is a beekeeper decision.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 20:58:53 +02:00
Nick SzaunigandClaude Sonnet 5 cc46fa4d9e fix: mark a colony QUEENLESS when its queen is lost, dies, or is replaced
A very common real-world event - a queen dying over winter, absconding,
being superseded, sold, or replaced - had no side effect on the colony
record at all. Colony.currentQueenId kept pointing at a queen who was
no longer really there, colony.status stayed whatever it was, and the
QUEENLESS dashboard alert could never fire from this path.

QueensService.update() now detects a transition into
LOST/DECEASED/REPLACED/SOLD and, inside the same transaction, clears
currentQueenId on whichever colony currently points at that queen,
flips its status to QUEENLESS (unless the colony is already in a
terminal lifecycle status like DISSOLVED, which is left alone), and
closes the open QueenColonyHistory row - mirroring the bookkeeping
assignColony() already does when a queen changes colonies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 13:30:18 +02:00
Nick SzaunigandClaude Sonnet 5 32f11e5b39 fix: clamp {SEQ:N} width in number-format to prevent resource exhaustion
formatGeneratedNumber() passed the admin-supplied {SEQ:N} width straight
into String.padStart() with no upper bound. A careless or malicious
format value (e.g. {SEQ:999999999}) would try to allocate a
correspondingly huge string - on the backend that runs on every single
honey-batch/bottling creation once such a format is saved, and in the
newly added frontend preview it would run on every keystroke while
typing the format. Caps the effective width at 20 digits, far beyond
any realistic sequence number, in both the backend implementation and
its frontend port so they stay identical.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:41:49 +02:00
Nick SzaunigandClaude Sonnet 5 1c16db114e feat: show a live example number for active number-format rules
Admins configuring the honey-batch/bottling auto-numbering format
(WH-{YYYY}-{SEQ:4}) had no way to see what it would actually produce
before saving - a typo'd placeholder or unexpected format silently
breaks every batch number from then on. When the rule is active, the
format card now renders the next number it would generate, live as
the format is edited, using the same formatGeneratedNumber logic the
backend uses (ported to the frontend, no shared package between the
two apps) and the real next sequence value (sequenceValue + 1).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:36:06 +02:00
Nick SzaunigandClaude Sonnet 5 1dddc387ba fix: expose every backend-supported field in all edit forms
Audit requested by the user after finding queens couldn't set
hatchYear: many edit dialogs (and some create forms) were missing
fields the backend DTOs already accept, so that data could never be
entered or corrected anywhere in the UI. Fixes per entity:

- Colony: status (WEAK/QUEENLESS/CONSPICUOUS) was never settable
  anywhere, so the COLONY_ATTENTION dashboard alert could never fire.
  Added as a dropdown restricted to the non-terminal statuses;
  DISSOLVED/SOLD/DECEASED/MERGED stay on their dedicated confirm
  actions, which also set deactivatedAt.
- Apiary: description was missing from the edit form.
- Inspection: 10 fields (weather, temperature, broodAmount,
  broodPattern, openBroodPresent, cappedBroodPresent,
  queenCellsPresent, swarmMood, gentleness, combBehavior,
  honeySuperStatus) existed in the schema/DTOs but nowhere in the UI -
  added to both the create and edit forms.
- Varroa measurement: durationMinutes was on the create form but
  missing from edit.
- Feeding: batchNumber missing from both create and edit.
- Treatment: startDate, endDate, activeIngredient, batchNumber
  missing from both create and edit.
- Task: assignedUserId had no UI anywhere; added to create/edit,
  degrading gracefully if the current user's role lacks users:read.
- Extraction (honey-batch traceability variant): amount was missing,
  while the other edit dialog for the same entity already had it.
- Bottling: productId missing from edit.
- Harvest: grossWeight missing from edit; grossWeight and
  honeySuperCount missing from create.
- Queen: breeder, matingType, matingStatus, inseminationData,
  motherLine, fatherLine added to the edit form (hatchYear/
  markingColor/markingNumber were already fixed in a prior commit).

Left alone deliberately: fields the backend DTO itself excludes via
PickType (e.g. HoneyBatch's jarSize/jarCount/totalAmount, Bottling's
quantities) since mutating those post-hoc would desync inventory
deduction, and Colony's terminal statuses/code which have dedicated
lifecycle actions.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:20:18 +02:00
Nick SzaunigandClaude Sonnet 5 792806b967 fix: compute mites/day for natural mite fall varroa measurements
VarroaMeasurement.calculatedValue was already used as "Milben/Tag" by
the VARROA_THRESHOLD automation (thresholdMitesPerDay) and the annual
PDF report, but no client ever actually computed it - the frontend
form only collects raw miteCount and durationMinutes. That meant the
threshold check was comparing a raw, un-normalized mite count against
a per-day threshold: e.g. 12 mites counted over 5 days (2.4/day,
healthy) would incorrectly trigger the same alert as 12 mites counted
over 1 day (a real problem).

VarroaService now derives calculatedValue = miteCount / duration in
days for NATURAL_MITE_FALL measurements, on create and (only when no
value exists yet) on update, without ever overwriting an explicitly
provided or already-stored value. Other methods (Puderzucker,
Waschmethode, Drohnenbrutkontrolle) use a different, non-time-based
metric and are left for manual entry rather than guessing an
unverified formula.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:01:02 +02:00
Nick SzaunigandClaude Sonnet 5 985ea09556 fix: allow entering a queen's hatch year and marking color/number
The backend has always accepted hatchYear/markingColor/markingNumber
(Queen DTOs, Prisma schema), and the queens list even already renders
a "Schlupfjahr" field - but neither the create nor the edit form ever
exposed inputs for them, so no user could actually set hatchYear
through the UI. That silently broke the OLD_QUEEN dashboard alert
just added: it can only ever fire for colonies whose queen has a
hatchYear, and there was no way to record one.

Both forms now include Schlupfjahr, Zeichenfarbe and Zeichennummer.
Marking color is auto-suggested from the hatch year using the
internationally standardized queen marking color convention (last
digit of the year -> white/yellow/red/green/blue), editable like any
other suggestion.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 21:44:29 +02:00
Nick SzaunigandClaude Sonnet 5 edf13d3ae4 feat: warn on the dashboard when a queen is 3+ years old
Queen.hatchYear was already captured but never used anywhere - no
signal existed for "this colony's queen is overdue for evaluation or
replacement," despite that being one of the most common productivity
and swarming risk factors in beekeeping. Adds an OLD_QUEEN dashboard
alert alongside the existing overdue-task/swarm-check ones, flagging
any active colony whose current queen has completed 3+ seasons.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 18:43:37 +02:00
Nick SzaunigandClaude Sonnet 5 8c4ae64e76 docs: record the Wartezeit design assumption in ARCHITECTURE.md
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 02:51:15 +02:00
Nick SzaunigandClaude Sonnet 5 d42f8331f9 feat: warn against harvesting honey during a treatment's Wartezeit
Treatments (Ameisensäure, Oxalsäure, etc.) can require a legally
mandated withdrawal period before honey from that colony may be sold.
Nothing in the app tracked this before, so a beekeeper had no
safeguard against harvesting into an active withdrawal window.

Treatments now optionally record withdrawalPeriodDays (from the
product's Packungsbeilage). The new harvest form fetches each
candidate colony's active withdrawal window and shows an inline
warning naming the product and the date it clears, computed as
max(endDate ?? date) + withdrawalPeriodDays across a colony's
treatments, restricted to windows still in the future.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 02:50:34 +02:00
Nick SzaunigandClaude Sonnet 5 0881a38800 feat: warn on the dashboard when a swarm check is overdue
Schwarmkontrolle already captures nextCheckDate per check, but it was
only ever visible by opening that one colony's tab. During swarm
season a missed follow-up check can mean losing half the colony, so
this now surfaces as a dashboard alert (OVERDUE_SWARM_CHECK) like
overdue tasks already do - only the most recent check per colony is
considered, so a completed follow-up clears the warning.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 02:35:17 +02:00
Nick SzaunigandClaude Sonnet 5 acbf5f16ce fix: add apple-touch-icon for iOS "Add to Home Screen"
iOS Safari ignores the web manifest for home-screen icons entirely and
needs its own apple-touch-icon link, which Next.js only emits when
app/apple-icon.tsx (or a literal apple-icon.png) exists. Without it,
iOS falls back to a screenshot of the open page instead of the brand
mark. Mirrors the existing public/icon.svg design via next/og.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 18:06:22 +02:00
Nick SzaunigandClaude Sonnet 5 81986b3275 feat: seed a starter annual task-template calendar
TaskTemplate had zero seed data - a fresh deployment showed a
completely empty recurring-task list with no hint of what to even put
there. Seeds 12 YEARLY templates, one per month, covering the standard
German-beekeeping annual cycle (Frühjahrsdurchsicht, Schwarmkontrolle,
Einfütterung, Varroa-Restentmilbung/Oxalsäure, Wintersitz-Kontrolle,
etc.) so a new beekeeper gets a ready-made starting point instead of a
blank page. Runs once (guarded by a taskTemplate.count() check, same
idempotency shape as the existing admin-user seed) and only ever adds
rows - admins can edit or deactivate any of them afterward.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 17:58:28 +02:00
Nick SzaunigandClaude Sonnet 5 c4f970f071 feat: add wind speed and a same-day hive-inspection verdict to weather
Temperature and rain were already tracked, but wind matters just as
much for whether it's safe/sensible to open a hive - bees get more
defensive and frames are harder to handle in wind. Adds
wind_speed_10m_max to the Open-Meteo request and a derived
inspectionSuitable flag per day (>=15C, <=1mm rain, <=25km/h wind;
null when a value is missing rather than guessing). The apiary weather
card now shows a clear "good/not good for a Durchsicht today" banner
plus wind alongside each day's forecast.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 17:54:31 +02:00
Nick SzaunigandClaude Sonnet 5 1f67cd5548 feat: extend offline queueing to all quick-capture forms
Only Durchsicht had an offline fallback (queue to IndexedDB, sync on
reconnect); Behandlung, Fütterung, Varroamessung, Schwarmkontrolle and
Ernte just failed outright with no signal - exactly the situation a
beekeeper is in at the actual hive. Generalizes lib/offline/db.ts and
sync.ts to six queues (version-bumped IndexedDB schema, existing queued
inspections preserved), wires the same "server rejected it vs. no
network" fallback into all five remaining */new pages, and updates the
sync badge to count/drain all six.

Also: doc-hygiene pass (ARCHITECTURE.md/TASKS.md) to replace the stale
pg_dump/CronJob backup plan with the in-app BackupModule that actually
shipped earlier this session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 17:45:32 +02:00
Nick SzaunigandClaude Sonnet 5 ac45514356 fix: remove label cut lines and zero-pad printed dates
Backend Image / build-and-push (push) Successful in 2m26s
Frontend Image / build-and-push (push) Successful in 1m56s
The dashed rectangle drawn around every label slot (a cut/crop line)
wasn't needed and is gone, along with the width/height params that
existed only to draw it. Dates on the label (bottling date, MHD) now
use { day: '2-digit', month: '2-digit' } so single-digit values print
with a leading zero (05.03.2026, not 5.3.2026) - the default 'numeric'
formatting was dropping it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 16:44:25 +02:00
Nick SzaunigandClaude Sonnet 5 1054a845ef feat: allow a honey batch to be split across multiple storage containers
Backend Image / build-and-push (push) Successful in 3m9s
Frontend Image / build-and-push (push) Successful in 2m45s
A batch that doesn't fit in one container (e.g. 125 kg across 5 x 25 kg
buckets) had no way to be represented - HoneyBatch.storageContainerId
is a single nullable FK. Adds HoneyBatchContainerAllocation as an
additive join table (container + amount per batch), independent of the
existing single-container/relocate() flow which still covers the
simple one-container case unchanged.

- GET/PUT/DELETE /honey-batches/:id/containers[/:containerId], reusing
  the existing honey-batches:relocate permission.
- Assigning a container is an upsert (@@unique on batch+container), and
  the total assigned is validated against the batch's own amount when
  known.
- New "Aufteilung auf mehrere Behälter" card on the honey batch detail
  page, alongside (not replacing) the existing storage card.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 19:44:33 +02:00
Nick SzaunigandClaude Sonnet 5 c91dc9ebf1 fix: add missing Ernten/Schleuderungen list and detail views
The sidebar nav sent both entries straight to their "new" creation
form, with no list page to ever browse what had been created -
Schleuderung creation didn't even redirect to a detail page, just to
/colonies, so a saved extraction left no trace anywhere in the UI.

- New /harvests and /extractions list pages (mirrors the existing
  products/apiaries list pattern).
- New /extractions/[id] detail page + edit dialog, matching the
  harvests one that already existed but had nothing linking to it.
- Sidebar "Ernten"/"Schleuderungen" now point at the list pages instead
  of the creation forms.
- Extraction creation now redirects to its detail page instead of
  /colonies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 19:44:19 +02:00
Nick SzaunigandClaude Sonnet 5 e133a677ab refactor: reuse the SSO app registration for M365 calendar sync
One Entra enterprise application now covers both interactive login and
the calendar sync's client-credentials Graph access, instead of
requiring a second app registration. GraphClientService reads
OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET (same as SSO) and
derives the tenant ID from the issuer URL's path, rather than three
dedicated M365_CALENDAR_* env vars. The app registration just needs the
"Calendars.ReadWrite" Application permission added alongside its
existing delegated login scopes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 14:35:18 +02:00
Nick SzaunigandClaude Sonnet 5 e7b157ec35 feat: sync calendar entries to a Microsoft 365 group calendar
Backend Image / build-and-push (push) Successful in 8m34s
Frontend Image / build-and-push (push) Successful in 8m41s
Adds a one-way push (ApiCore -> M365) of Aufgaben/Durchsichten/
Behandlungen/Fütterungen/Ernten/Königinnen-Zuordnungen into the calendar
of a Microsoft 365 group, via the Graph API.

- CalendarSyncModule (app-only client-credentials Graph auth, separate
  from the interactive SSO app registration; new env vars
  M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET).
- Outbox + minute-interval cron worker (CalendarSyncOutboxEntry) instead
  of a synchronous push, since Graph calls are too slow/flaky to block a
  domain create request on; failed syncs retry up to 5 times.
- Per-source-type toggle plus a group-ID field, configurable in
  Verwaltung > Automatisierung > Kalender-Sync (Verwaltung > Backup, an
  env var still wins on those fields where one applies to related config).
- Hooked into Tasks/Inspections/Treatments/Feedings/Harvests/Queens at
  their existing create (and Task update/complete) points; entries render
  as all-day Graph events to avoid timezone ambiguity.
- Documents the design decision in ARCHITECTURE.md (Annahme 9), since
  this is a net-new capability the lastenheft doesn't scope.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-04 09:23:35 +02:00
Nick SzaunigandClaude Sonnet 5 28ae0a2723 fix: lay out herkunft social links in a uniform count-aware grid
2 links center side-by-side, 3 sit in one row, and 4 form a centered
2x2 block - previously flex-wrap could leave an uneven number stranded
awkwardly on its own line.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-03 20:13:18 +02:00
Nick Szaunig 412d033d8d feat: add website social link, fix herkunft origin display, GUI backup schedule, and format-based batch numbering
Backend Image / build-and-push (push) Successful in 2m15s
Frontend Image / build-and-push (push) Successful in 2m17s
- SiteSettings gains websiteUrl (e.g. online shop link) alongside the
  existing social links, editable in Verwaltung > Automatisierung.
- /herkunft/[lot] now shows the apiary's Standort instead of its internal
  Name (so multiple apiaries at one location read as one origin), adds
  small icons per social link, and surfaces previously-fetched-but-unused
  data (Honigsorte, Abfülldatum, stat cards) to make the page a richer
  customer-facing info page.
- Backup schedule (path/cron/retention) is now also configurable via the
  GUI (Verwaltung > Backup), stored in a new BackupSettings singleton;
  an env var always wins over the GUI value per field, and the scheduler
  reloads its cron job live when GUI settings change.
- New Automatisierung rules let an admin define a format (e.g.
  "WH-{YYYY}-{SEQ:4}") that auto-generates HoneyBatch.batchNumber and
  BottlingBatch.lotNumber on creation, with an atomic per-rule sequence
  counter and a manual reset. Manual entry still works when the rule is
  inactive.
2026-09-03 13:37:09 +02:00
Nick Szaunig 7b23862a41 feat: expand backup to all data and add automatic scheduled backups
Backend Image / build-and-push (push) Successful in 2m16s
Frontend Image / build-and-push (push) Successful in 1m56s
Backup export/import now covers Role, User, RolePermission,
Notification, AuditLog and Document (with actual file content), not
just business data - matching "wirklich alle Daten". Role/User use
natural keys (role.key, resource+action) so a bundle restores cleanly
into a different database.

Adds BackupSchedulerService (@nestjs/schedule) for cron-based automatic
backups to a local path (BACKUP_STORAGE_PATH), with retention pruning
and a file-mtime guard against duplicate runs across replicas. New
GET /backup/schedule and POST /backup/schedule/run-now endpoints, plus
a status card and manual trigger in the frontend Backup settings page.
2026-09-03 11:33:48 +02:00
Nick SzaunigandClaude Sonnet 5 d998f611e2 feat: Verwaltungsmenü zu 3 Sammelseiten umgebaut - Benutzer & Rollen, Automatisierung, Backup
Backend Image / build-and-push (push) Successful in 2m17s
Frontend Image / build-and-push (push) Successful in 2m18s
Menü unter "Verwaltung" wie gewünscht neu strukturiert:

- "Benutzer & Rollen" (/settings/users-roles): Benutzerverwaltung und
  Rollen & Rechte als zwei Reiter auf einer Seite (vorher zwei
  getrennte Menüpunkte).
- "Automatisierung" (/settings/automation): Webhooks, Wiederkehrende
  Aufgaben, Etikettenvorlagen und Social Media Links als Reiter
  gebündelt - dazu (eigene Ergänzung, da sonst zwei fast gleich
  benannte Menüpunkte "Automatisierung"/"Automatisierungen"
  nebeneinander gestanden hätten) auch die bestehenden
  Automatisierungsregeln als fünfter Reiter "Regeln".
- "Backup" (/settings/backup): neuer voller Daten-Export/Import
  (Commit zuvor) plus der bisherige CSV-Import, beides auf einer Seite.

Aktiver Reiter wird als ?tab=... in der URL gehalten statt nur im
Client-State, damit z.B. "Speichern" im Etiketten-Designer wieder auf
den richtigen Reiter zurückführt.

Technisch: bestehende Seiteninhalte in Reiter-Komponenten extrahiert
(PageHeader entfernt, Rest unverändert übernommen), Dialog-Unterkomponenten
in die neuen Ordner verschoben (git mv, Historie bleibt erhalten). Die
Etiketten-Editor-Routen (/settings/label-templates/new, /[id]) bleiben
unverändert bestehen, nur der Rücksprung nach dem Speichern zeigt jetzt
auf den neuen Reiter. settings/layout.tsx's Berechtigungsliste um
label-templates:read, site-settings:read, backup:export/import ergänzt -
schließt nebenbei eine bestehende Lücke (eine Rolle mit ausschließlich
diesen Rechten wurde vorher faelschlich von der ganzen
Verwaltungssektion ausgesperrt.

Neuer Reiter-Baustein src/components/tab-switcher.tsx (generisch, nach
Vorbild von ActiveHistoryTabs). sidebar.tsx's NavItem.permission
unterstützt jetzt auch ein Array (irgendeine der Berechtigungen reicht),
fuer die neuen gebündelten Menüpunkte.

Lokal verifiziert: typecheck/lint/test/build fehlerfrei, Build zeigt
alle neuen Routen korrekt, Live-Test im Browser (ohne Login) zeigt
sauberen Redirect zu /login ohne Konsolenfehler.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-03 10:37:22 +02:00
Nick SzaunigandClaude Sonnet 5 2f223b6941 feat: Backend-Modul fuer vollstaendiges Daten-Backup (Export/Import)
Neues Modul src/backup/ fuer Backups und Datenuebertragung zwischen
Deployments: GET /backup/export liefert ein JSON-Bundle mit allen
fachlichen Daten (Bienenstaende bis Etikettenvorlagen, ~30 Tabellen),
POST /backup/import spielt so ein Bundle wieder ein.

Bewusst NICHT enthalten: User/Rollen/Rechte (werden ueber die
Benutzerverwaltung gepflegt, Permission-Zeilen werden ohnehin bei
jedem Start aus dem Code neu synchronisiert), Audit-Log (unveraenderliche
Historie), Notifications (fluechtig), Dokumente (nur die DB-Zeile waere
gesichert, nicht die tatsaechliche Datei auf der Festplatte).

Import laeuft in einer Transaktion, Tabellen in Abhaengigkeitsreihenfolge
(z.B. Bienenstand vor Volk, Volk vor Durchsicht). Selbstreferenzen
(Koenigin -> Mutterkoenigin) werden in zwei Durchgaengen aufgeloest,
damit die Reihenfolge im Export keine Rolle spielt. userId-Felder, die
im Zielsystem nicht existieren (z.B. Uebertragung auf ein anderes
Deployment), werden auf den ausfuehrenden Admin umgebogen statt die
Fremdschluessel-Pruefung scheitern zu lassen. AutomationRule wird ueber
triggerType statt id abgeglichen, da diese Zeilen code-fest sind.

Neue Berechtigungen backup:export/backup:import. 8 neue Unit-Tests
fuer die Sonderfaelle (Selbstreferenz, userId-Remapping,
AutomationRule-Abgleich, Audit-Eintrag).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-03 10:22:15 +02:00
Nick SzaunigandClaude Sonnet 5 390ec32f50 feat: Etikettendruck prueft auch gedrehte Anordnung fuer bessere Blattausnutzung
Backend Image / build-and-push (push) Successful in 2m25s
Frontend Image / build-and-push (push) Successful in 2m5s
Im automatischen Modus (Spalten/Zeilen leer) wird jetzt zusaetzlich
berechnet, wie viele Etiketten pro Blatt passen, wenn man sie um 90
Grad dreht (Breite/Hoehe vertauscht). Passt die gedrehte Anordnung mehr
drauf, wird sie verwendet - die Etiketten selbst bleiben inhaltlich
unveraendert (nur auf dem Blatt gedreht), jedes einzelne Etikett bleibt
beim Abziehen normal lesbar.

Bei explizit gesetzten Spalten/Zeilen (z.B. fuer ein vorgedrucktes
Avery-Raster) greift die Drehung nicht - ein reales Blatt hat feste,
nicht drehbare Felder.

Rotation erfolgt via pdfkit-Rotation um die Mitte des Feldes: ein
Etikett, exakt 90 Grad um seinen eigenen Mittelpunkt gedreht, ergibt
immer eine vertauschte Bounding-Box an derselben Stelle - unabhaengig
von der Drehrichtungskonvention. 2 neue Tests belegen ueber die
Seitenzahl, dass die Drehung greift, wenn sie mehr Etiketten
unterbringt, und dass ein explizites Raster unangetastet bleibt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-03 08:14:51 +02:00
Nick SzaunigandClaude Sonnet 5 1e53758753 fix: proxy.ts redirete staendig zurueck zu /login, obwohl eingeloggt
Backend Image / build-and-push (push) Successful in 2m19s
Frontend Image / build-and-push (push) Successful in 2m2s
Der voran gegangene Fix (proxy.ts nach src/ verschoben, damit Next.js
sie ueberhaupt erkennt) hat eine zweite, bislang tote Stelle live
geschaltet: eine Cookie-Pruefung auf "refreshToken", die zu /login
umleitet, wenn das Cookie fehlt - fuer /settings, /apiaries, /colonies,
/queens.

Dieses Cookie wird vom BACKEND gesetzt (eigene Domain,
apicorebackend.* statt apicore.*, Pfad /api/v1/auth) und ist dem
Browser zufolge nie Teil einer Anfrage an das Frontend - proxy.ts lief
auf der Frontend-Domain und konnte dieses Cookie strukturell nie sehen.
Solange die Datei am falschen Ort lag und nie ausgefuehrt wurde, blieb
das folgenlos; sobald sie lief, hat sie jeden Aufruf dieser Seiten
serverseitig zurueck zu /login geschickt, unabhaengig vom tatsaechlichen
Login-Status.

Die Cookie-Pruefung komplett entfernt statt repariert: es gibt keinen
Weg, ein cross-origin gesetztes Cookie hier sichtbar zu machen, ohne
seinen Schutz (Pfad-Scoping) aufzuweichen. Die echte Session lebt
ohnehin nur clientseitig (Access-Token im Speicher, Refresh-Cookie nur
fuer Backend-Aufrufe) - src/app/(app)/layout.tsx prueft das bereits
korrekt und leitet bei Bedarf weiter. proxy.ts setzt jetzt nur noch die
Security-Header.

Lokal verifiziert: /settings/users ohne jedes Cookie liefert jetzt 200
statt eines Redirects, CSP zeigt weiterhin den korrekten
Laufzeit-API_URL.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-03 01:20:51 +02:00
Nick SzaunigandClaude Sonnet 5 c95e1c4b18 fix: Login komplett kaputt seit dem Security-Header-Commit - CSP blockierte jede API-Anfrage
Backend Image / build-and-push (push) Successful in 2m16s
Frontend Image / build-and-push (push) Successful in 2m1s
Root Cause, zweifach:

1. proxy.ts lag im Projekt-Root statt unter src/ (wo app/ tatsaechlich
   liegt). Next.js hat die Datei dadurch nie erkannt - ueberprueft via
   Build-Output ("Proxy (Middleware)" fehlte komplett) und via
   routes-manifest.json. Das betraf nicht nur die neuen
   Security-Header, sondern auch die schon laenger bestehende
   Login-Redirect-Logik, die dadurch vermutlich nie serverseitig
   gegriffen hat - vorher unbemerkt, weil Client-seitige Auth-Checks
   das kaschiert haben.

2. Die CSP (mit dynamischem connect-src aus API_URL) lag in
   next.config.ts's headers() - die wird beim Docker-Build EINMAL
   aufgeloest und ins Standalone-Output eingefroren, nicht bei jedem
   Container-Start neu gelesen, wie im Code-Kommentar faelschlich
   behauptet. Jedes Deployment bekam dadurch permanent
   "connect-src http://localhost:3001" (den Fallback, da API_URL beim
   Build nicht gesetzt ist) - unabhaengig vom tatsaechlich im Pod
   gesetzten API_URL. Jede Anfrage vom Frontend ans Backend (Login,
   SSO-Status, Herkunftsseite) wurde dadurch von jedem Browser
   CSP-blockiert.

Fix: CSP + Security-Header ziehen nach proxy.ts (jetzt korrekt unter
src/) um - Proxy laeuft mit Node.js-Runtime frisch bei jeder Anfrage,
liest process.env.API_URL also korrekt zur Laufzeit. next.config.ts
behaelt nur noch die statische sw.js-Cache-Control-Regel.

Lokal end-to-end verifiziert: Build zeigt jetzt "ƒ Proxy (Middleware)"
(vorher komplett abwesend), CSP-Header zeigt nach Server-Start einen
distinct getesteten API_URL-Wert korrekt an (nicht mehr den
Localhost-Fallback), und ein echter Login-Klick im Browser schickt die
Anfrage jetzt an die richtige, zur Laufzeit gesetzte Backend-Origin.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 21:44:23 +02:00
Nick SzaunigandClaude Sonnet 5 cd24523ea5 fix: PermissionsGuard blockiert seit dem Fail-Closed-Umbau alle @Public-Routen
Backend Image / build-and-push (push) Successful in 3m14s
Frontend Image / build-and-push (push) Successful in 1m54s
Beim Umstellen von PermissionsGuard auf Fail-Closed wurde der
@Public()-Check vergessen. JwtAuthGuard ueberspringt bei @Public()
zwar die Authentifizierung, aber PermissionsGuard laeuft als
eigenstaendiger, separat registrierter APP_GUARD trotzdem mit - und
verweigerte seitdem jede Route ohne @RequirePermission/
@AnyAuthenticatedUser, selbst wenn sie @Public() war. Betroffen: health
und health/ready (die gemeldeten 403), aber ebenso Login, SSO-Flow und
die oeffentliche Herkunftsseite.

PermissionsGuard prueft jetzt @Public() als erstes und laesst diese
Routen durch, genau wie JwtAuthGuard es bereits tut. Regressionstest
ergaenzt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 20:46:24 +02:00
Nick SzaunigandClaude Sonnet 5 351f9ef34c feat: Abfüllungs-Archiv und Social-Media-Links auf der Herkunftsseite
Abfüllungen hatten keine Übersichtsseite - die Navigation ging direkt
auf /bottling/new, sodass eine angelegte Abfüllung danach nirgends
mehr sichtbar war. Neue Seite /bottling listet alle Abfüllungen
(Archiv), Klick führt zur bestehenden Detail-/Bearbeiten-Seite.
Navigationslink zeigt jetzt auf die Liste statt direkt auf "Neu".

Neues Modul site-settings (Singleton-Tabelle SiteSettings) für
YouTube-/Instagram-/TikTok-Links, verwaltbar unter Verwaltung > Website
(neue Berechtigung site-settings:read/update). Die öffentliche
Herkunftsseite (/herkunft/[lot]) zeigt die konfigurierten Links jetzt
zusätzlich zur Chargen-Herkunft an - PublicService liefert sie über
das bereits oeffentliche /public/batches/:lotNumber mit aus, keine
zusaetzliche unauthentifizierte Route noetig.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 19:57:29 +02:00
Nick SzaunigandClaude Sonnet 5 33fa768062 fix: Mindestlaenge fuer JWT_ACCESS_SECRET beim Start erzwungen
Backend Image / build-and-push (push) Successful in 2m13s
Frontend Image / build-and-push (push) Successful in 1m59s
JWT_ACCESS_SECRET war zwar als Pflicht-Env-Var geprueft, aber ohne
Mindestlaenge - ein kurzer/erratbarer Wert haette nicht nur das
Faelschen von Access-/MFA-/SSO-Tokens fuer jeden Nutzer erlaubt,
sondern auch (ueber mfa-crypto.util.ts, das den AES-Schluessel fuer
gespeicherte TOTP-Secrets aus genau diesem Wert ableitet) das
Entschluesseln aller gespeicherten MFA-Secrets.

main.ts bricht den Start jetzt mit klarer Fehlermeldung ab, wenn
JWT_ACCESS_SECRET kuerzer als 32 Zeichen ist. .env.example und das
K8s-Secret-Template zeigen jetzt einen ausreichend langen Platzhalter
mit Erzeugungshinweis (openssl rand -hex 32).

Betrifft nur main.ts's bootstrap() - e2e-Tests bootstrappen ueber
test/utils/create-test-app.ts direkt via configureApp() und durchlaufen
diese Pruefung nicht, daher keine Testanpassung noetig.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 15:17:39 +02:00
Nick SzaunigandClaude Sonnet 5 4bd7e9791a fix: Robustheits-Luecken aus zweitem, unabhaengigem Sicherheitsaudit behoben
Ein zweiter, unabhaengiger Audit-Durchlauf (frischer Blick, keine
Vorkenntnis meiner eigenen Fixes) bestaetigte alle bisherigen Fixes als
korrekt verdrahtet und fand 3 LOW-severity Robustheitsluecken - keine
davon war ausnutzbar, aber alle sind jetzt behoben:

- HarvestsService.create/ExtractionsService.create pruefen jetzt, dass
  referenzierte colonyIds/harvestIds tatsaechlich existieren, bevor die
  Transaktion laeuft - vorher fuehrte eine unbekannte Id zu einem
  ungefangenen 500 mit rohem Prisma-Fehler statt einer sauberen 400.
- Etiketten-Bildelemente durchlaufen jetzt denselben Magic-Byte-Check
  wie Avatar-/Dokument-Uploads, statt pdfkits Bildparser ungepruefte
  Bytes zu uebergeben.
- Webhook-Secret-Redaktion im Audit-Log nutzt jetzt explizites delete
  statt sich auf Prismas JSON-Serialisierung zu verlassen, die
  undefined-Werte verwirft (fragil, falls sich die Serialisierung mal
  aendert).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 15:13:02 +02:00
Nick SzaunigandClaude Sonnet 5 f57cffe6a8 fix: Rechteausweitung ueber Rollen-Rechtezuweisung geschlossen
RolesService.create/update uebernahm eine explizit uebergebene
permissionKeys-Liste ungeprueft. Ein Nutzer mit nur roles:update
konnte damit JEDER Rolle - auch der eigenen, ohne users:assign-role -
beliebige weitere Rechte (bis hin zu allen 92 Katalog-Eintraegen)
hinzufuegen, da das Bearbeiten der eigenen Rollen-Rechte nicht ueber
User.roleId laeuft und daher vom bereits gefixten
users:assign-role-Check nicht erfasst wurde.

Neue Regel (Standard-RBAC-Prinzip "keine Rechte ueber das eigene
Rechte-Set hinaus vergeben"): create/update pruefen jetzt, dass der
aufrufende Nutzer jedes zu vergebende Recht selbst bereits besitzt,
bevor die Rolle angelegt/geaendert wird.

roles.service.ts hatte bislang keine Unit-Tests; neue Spec-Datei deckt
den Fix sowie die uebrigen Methoden (create/update/deactivate/
reactivate) ab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:58:44 +02:00
Nick SzaunigandClaude Sonnet 5 8b8616ce03 fix: Etiketten werden beim Drucken abgeschnitten, wenn Spalten/Zeilen zu gross gewaehlt sind
Eine explizit im Etiketten-Designer eingetragene Spalten-/Zeilenzahl
wurde beim PDF-Rendern ungeprueft uebernommen. Passt sie nicht auf ein
A4-Blatt (z.B. 90mm breite Etiketten x 4 Spalten bei 5mm Rand), ragen
die hinteren Spalten/Zeilen ueber den Blattrand hinaus - der Drucker
schneidet sie dann einfach ab, statt sie zu drucken.

- Backend (label-templates.service.ts): Spalten/Zeilen werden jetzt auf
  das tatsaechlich auf A4 passende Maximum begrenzt, bevor gerendert
  wird - betrifft auch bereits gespeicherte Vorlagen mit zu grossen
  Werten.
- Frontend (label-designer.tsx): zeigt die maximal passende Spalten-/
  Zeilenzahl direkt im Seiten-Layout-Panel an und warnt inline, wenn
  der eingetragene Wert das Blatt sprengen wuerde.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:53:15 +02:00
Nick SzaunigandClaude Sonnet 5 19d5e5d65c fix: PermissionsGuard schliesst jetzt statt bei fehlendem Decorator zu oeffnen
PermissionsGuard erlaubte bisher jede Route ohne @RequirePermission
automatisch fuer jeden eingeloggten Nutzer (fail-open) und verliess sich
allein auf test/authorization-coverage.e2e-spec.ts, um eine vergessene
Decorator-Angabe vor dem Deploy zu erwischen - der Guard selbst hatte
kein Default-Deny.

Neuer Decorator @AnyAuthenticatedUser() macht das bisher stillschweigend
erlaubte Verhalten explizit fuer die 12 Routen, die es tatsaechlich
brauchen (ProfileController komplett, NotificationsController komplett,
AuthController.me - alle bereits vorher auf der Allowlist des e2e-Tests).
PermissionsGuard verweigert jetzt jede Route, die weder
@RequirePermission noch @AnyAuthenticatedUser traegt - eine vergessene
Decorator-Angabe schlaegt jetzt zur Laufzeit fehl (403), nicht mehr nur
im Test. Der e2e-Test prueft jetzt denselben Decorator-Vertrag statt
einer separat gepflegten Allowlist, kann also nicht mehr vom
Guard-Verhalten abweichen.

Neue Unit-Tests fuer PermissionsGuard decken beide Codepfade und das
Fail-Closed-Verhalten ab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:44:28 +02:00
Nick SzaunigandClaude Sonnet 5 b7ff92abd3 fix: SSO-MFA-Umgehung, Datei-Magic-Bytes und oeffentliches Swagger geschlossen
Weitere vom Sicherheitsaudit gemeldete Luecken behoben, die bisher als
niedrige Prioritaet offen gelassen wurden:

- SSO-Login (Entra ID) prüft jetzt lokale MFA genauso wie der
  Passwort-Login: ist mfaEnabled gesetzt, liefert /auth/sso/complete
  denselben mfaRequired-Codepfad wie /auth/login, bevor Tokens
  ausgestellt werden. Frontend (Login-Seite, AuthContext) zeigt in dem
  Fall den bestehenden Code-Eingabe-Schritt statt die Anmeldung
  stillschweigend abzuschliessen.
- Avatar- und Dokument-Uploads pruefen jetzt die tatsaechlichen
  Datei-Bytes (Magic Numbers fuer PNG/JPEG/WEBP/PDF) statt nur dem
  client-angegebenen MIME-Typ/data:-URL-Praefix zu vertrauen.
- Swagger-UI (/api/v1/docs) ist jetzt standardmaessig deaktiviert
  (ENABLE_SWAGGER=true erforderlich), da sie zuvor das komplette
  API-Schema ungeschuetzt oeffentlich preisgab.

npm-audit-Warnungen (deepmerge-ts/mysql2 unter prisma) bewusst nicht
gefixt: kein stabiles Prisma-Release loest sie bislang auf, nur eine
Release-Candidate-Hauptversion - das Risiko eines Downgrades/RC-Upgrades
der Datenbankschicht uebersteigt den Nutzen fuer eine Kette, die zur
Laufzeit ohnehin nicht erreichbar ist (nur Prisma-CLI, nur MySQL-Treiber,
dieses Projekt nutzt Postgres).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 09:09:58 +02:00
Nick SzaunigandClaude Sonnet 5 1b16b49913 fix: Rechteausweitung ueber Rollenzuweisung und weitere Auth-Haertung
Ein Sicherheitsaudit deckte auf, dass jede Rolle mit users:update sich
selbst per PATCH /users/:id auf eine Admin-Rolle heraufstufen konnte,
da roleId ohne eigene Berechtigung und ohne Selbstbezug-Pruefung
akzeptiert wurde. Ausserdem lag ein fest im Code hinterlegtes
Standard-Admin-Passwort vor, das bei fehlender SEED_ADMIN_PASSWORD-Env
greift.

- Neue Berechtigung users:assign-role, getrennt von users:create/update,
  fuer jede Rollenzuweisung erforderlich
- Selbstaenderung der eigenen Rolle serverseitig blockiert (auch fuer
  Admins), ebenso Selbst-Reset der eigenen MFA ueber die Admin-Route
- Seed erzeugt bei fehlender Env ein zufaelliges statt eines fixen
  Passworts
- Wiederverwendung eines bereits rotierten Refresh-Tokens (Diebstahl-
  Indiz) widerruft nun alle Sessions des betroffenen Nutzers
- Rate-Limit auf MFA-Enrollment-Bestaetigung ergaenzt

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 08:57:42 +02:00
Nick Szaunig 7bd2a3dc3a feat: Sicherheits-Header fuer das Frontend ergaenzt
apicore.encry.de lieferte bisher gar keine Security-Header aus (per
curl bestaetigt) - CSP, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, HSTS fehlten komplett, waehrend das Backend das
laengst per helmet() macht. Ergaenzt fuer alle Routen. Bewusst ohne
CSP-Nonce (haette dynamic rendering fuer jede Seite erzwungen und
damit die bestehende statische Optimierung gekostet) - stattdessen
script-src/style-src 'unsafe-inline', aber default-src/connect-src/
img-src/object-src/frame-ancestors/form-action/base-uri strikt
eingeschraenkt. Lokal mit dem echten Production-Build (standalone
server + next.config.ts) verifiziert: Login-Seite rendert/hydratisiert
sauber, keine CSP-Verstoesse in der Konsole, Header per curl bestaetigt.
2026-09-02 08:45:56 +02:00
Nick Szaunig 135738fdfc fix: Profilbild-Upload auf JSON/Base64 umgestellt statt multipart
POST /profile/avatar schlug live mit net::ERR_FAILED fehl (Verbindung
abgebrochen, keine Antwort) - selbst bei einer winzigen Testdatei.
CORS-Preflight lief sauber durch, nur der eigentliche multipart-POST
nicht; passt zum bereits dokumentierten Cloudflare-Verhalten bei
multipart-Uploads (siehe .gitea/workflows/*-image.yml). Umgangen durch
Umstellung auf denselben JSON-Content-Type, der fuer alle anderen
Endpunkte nachweislich zuverlaessig funktioniert: Bild wird
clientseitig als data:-URL kodiert und im JSON-Body gesendet.
Globales JSON-Body-Limit auf 5mb angehoben (deckt 2MB-Bild plus
Base64-Overhead).
2026-09-02 08:32:08 +02:00
Nick Szaunig abe1f6e8cf fix: Etiketten-Import zeigt konkreten Grund statt generischem Fehler
Eine KI-erzeugte JSON ohne Kenntnis des echten Schemas (z.B. eigene
Feldnamen wie xMm/yMm oder erfundene Elementtypen wie rectangle/svg)
scheiterte bisher mit "Datei enthält keine gültige Etikettenvorlage"
ohne Hinweis, was falsch ist. Jetzt wird pro Element benannt, was
fehlt oder welcher Typ nicht unterstützt wird.
2026-09-01 20:13:36 +02:00
Nick Szaunig c6941a797f feat: Etiketten-Seitenlayout auf dem A4-Bogen frei waehlbar
Backend Image / build-and-push (push) Successful in 2m16s
Frontend Image / build-and-push (push) Successful in 2m0s
- LabelTemplate bekommt columns/rows (optional, leer = automatisch
  passend), plus konfigurierbare Raender/Abstaende - damit ein Etikett
  exakt auf das vorgedruckte Raster eines physischen Etikettenbogens
  (z.B. Avery) gelegt werden kann statt nur automatisch berechnet
- Startposition beim Drucken waehlbar, fuer bereits angebrochene Boegen
  (erste Seite startet ab der gewaehlten Position, Folgeseiten wieder
  von vorne)
2026-09-01 19:43:01 +02:00
Nick Szaunig 9659b8d266 feat: Etiketten-Editor erheblich verbessert - Import/Export, Zoom, Duplizieren
- Vorlage als JSON exportieren/importieren, damit man sich ein Etikett
  auch per KI erstellen und danach importieren kann
- Zoom-Regler fuer den Canvas (50-200%), da Etikettgroessen stark variieren
- X/Y-Positionsfelder pro Element fuer praezise Platzierung statt nur Drag
- Element duplizieren-Button
- Entf/Backspace loescht das ausgewaehlte Element
2026-09-01 19:21:24 +02:00
Nick Szaunig 55b6dff31b fix: Kontomenü stürzt beim Öffnen ab (Base UI error #31)
Backend Image / build-and-push (push) Successful in 3m9s
Frontend Image / build-and-push (push) Successful in 2m55s
DropdownMenuLabel (rendert Menu.GroupLabel) stand direkt im
DropdownMenuContent statt in einem DropdownMenuGroup - Base UI wirft
dafür beim Öffnen einen ungefangenen Fehler, der den kompletten
React-Baum crasht. Das war die eigentliche Ursache des seit Sitzungsbeginn
gemeldeten "This page couldn't load" beim Klick auf das Profilbild
oben rechts, nicht der Service Worker (der war schon korrekt).
2026-09-01 17:23:39 +02:00
Nick Szaunig 938239e16e fix: fehlende Dateien fuer Etiketten-Elemente-Umbau nachreichen
Backend Image / build-and-push (push) Successful in 2m19s
Frontend Image / build-and-push (push) Successful in 1m55s
git add -u im letzten Commit hat neue (untracked) Dateien übersprungen:
die Migration für LabelTemplate.elements, die neuen Typ-/DTO-Dateien und
den kompletten Drag-and-Drop-Editor fehlten dadurch im Build.
2026-09-01 16:59:48 +02:00
Nick Szaunig 5f4e1a7ba9 fix: Bug fixed für Rollen
Backend Image / build-and-push (push) Failing after 1m12s
Frontend Image / build-and-push (push) Successful in 1m14s
2026-09-01 16:54:42 +02:00
Nick Szaunig 8432313fa9 feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
Backend Image / build-and-push (push) Successful in 2m28s
Frontend Image / build-and-push (push) Successful in 2m10s
- Profilseite mit MFA-Einrichtung, Passwort/E-Mail/Name aendern, Konto deaktivieren
- Bearbeiten-Buttons fuer Bienenstaende/Voelker/Koeniginnen/Aufgaben-Datensaetze
- Restliche englische Enum-Werte durch deutsche Labels ersetzt (SelectValue-Fix)
- Sidebar-Eintraege fuer Ernten/Schleuderungen ergaenzt
- Automatisierungen von 3 auf 6 Regeln erweitert (Schwarmstimmung, kritischer Gesundheitsstatus, Lagerbestand)
- Responsive Grid-Layout fuer Volk-/Koeniginnen-/Honigchargen-/Bienenstand-Detailseiten
- Etikettenvorlagen mit Platzhaltern inkl. QR-Code (verweist auf Herkunftsseite) und PDF-Druck
- Entra-ID-SSO (Authorization Code + PKCE, kein Auto-Provisioning, Secrets nur ueber Environment-Variablen)
2026-08-31 18:56:08 +02:00
Nick Szaunig 0a52b29c57 fix: Bug fixed für alle Seiten und das Profil und label
Backend Image / build-and-push (push) Successful in 2m44s
Frontend Image / build-and-push (push) Successful in 2m47s
2026-08-27 15:15:55 +02:00
Nick Szaunig 133edcd5ba fix: fehlende Dateien fuer Volk-Ueberarbeitung nachreichen
Backend Image / build-and-push (push) Successful in 1m16s
Frontend Image / build-and-push (push) Successful in 3m7s
labels.ts und die ColonyCondition-Migration waren untracked und fehlten
im letzten Commit, dadurch schlug der Docker-Build mit "Module not
found: @/lib/colonies/labels" fehl.
2026-08-27 10:51:07 +02:00
Nick Szaunig 8520678a48 Volk Überarbeittung und Englisch Varaiblen ersetzt
Backend Image / build-and-push (push) Successful in 3m43s
Frontend Image / build-and-push (push) Failing after 1m10s
2026-08-27 10:38:06 +02:00
Nick Szaunig 9282777ac9 Env Update
Backend Image / build-and-push (push) Successful in 3m8s
Frontend Image / build-and-push (push) Successful in 2m24s
2026-08-27 08:32:47 +02:00
Nick Szaunig f2b9993427 fix: OOM-Crash beim Seed-Schritt durch Vorkompilieren beheben
Backend Image / build-and-push (push) Successful in 3m11s
Frontend Image / build-and-push (push) Successful in 2m46s
ts-node prisma/seed.ts lief bisher live im Container - der
TypeScript-Compiler dafuer braucht deutlich mehr Heap als das
512Mi-Pod-Limit, daher sporadische "JavaScript heap out of memory"-
Crashes beim Start (manchmal lief's, manchmal nicht, reine
GC-Zufaelligkeit an der Speichergrenze).

Fix: seed.ts wird jetzt wie main.ts beim Docker-Build vorkompiliert
(tsconfig.seed.json, rootDir bewusst auf prisma/ statt Projekt-Root
gesetzt, damit der relative Import '../generated/prisma' nach dem
Kompilieren weiterhin auf denselben Pfad zeigt). Laufzeit-CMD startet
nur noch plain `node dist/seed.js` - kein TypeScript-Compiler mehr im
Container. ts-node/typescript zurueck zu devDependencies (nur noch
fuer den Build-Schritt gebraucht, nicht mehr zur Laufzeit).

incremental in tsconfig.seed.json explizit deaktiviert - ein stale
tsconfig.seed.tsbuildinfo liess tsc beim zweiten Testlauf sonst
faelschlich "nichts zu tun" melden (derselbe Fallstrick wie bei
tsconfig.build.tsbuildinfo).

Verifiziert: tsc/eslint clean, 304/304 Tests, Build reproduzierbar
(zweimal hintereinander ohne Cache-Reset getestet), node dist/seed.js
lief erfolgreich gegen die echte lokale DB (erkannte den bestehenden
Admin korrekt und legte ihn nicht erneut an).
2026-08-27 08:02:58 +02:00
Nick Szaunig ce3a0c44ac feat: globales HTTP-Access-Log und Exception-Logging
Backend Image / build-and-push (push) Successful in 2m50s
Frontend Image / build-and-push (push) Successful in 2m22s
Bisher waren 4xx-Fehler (falsches Passwort, Validierungsfehler,
fehlende Berechtigung) im Log komplett unsichtbar - Nest loggt
standardmaessig nur unbehandelte 5xx-Fehler. Ein Login, das aus
Infra-Gruenden fehlschlaegt (siehe TLS/Cloudflare-Vorfall), war dadurch
allein aus den Logs nicht diagnostizierbar.

- AllExceptionsFilter (global): loggt jede Exception mit Methode/Pfad/
  Status (4xx als warn, 5xx als error mit Stacktrace). Client-Antwort
  bleibt unveraendert - HttpExceptions liefern weiter exakt ihren
  bisherigen Body, unbehandelte Fehler weiter nur eine generische
  500-Meldung ohne interne Details.
- requestLoggerMiddleware (global): eine Zeile pro Request (Methode,
  Pfad, Status, Dauer), damit auch erfolgreiche Requests im Log
  auftauchen - vorher gab's dafuer gar keine Spur.
- AuthService.login()/verifyMfaAndLogin(): konkrete Ursache pro
  Fehlschlag (unbekannter User, deaktiviert, falsches Passwort,
  falscher MFA-Code) zusaetzlich zum bestehenden Audit-Log-Eintrag.

Nirgends werden Passwoerter, Tokens oder Request-Bodies geloggt - nur
Methode, Pfad, Status, Dauer und die (bereits client-sichere)
Fehlermeldung.

Verifiziert: tsc/eslint clean, 304/304 Tests (7 neu), Build clean.
2026-08-27 07:29:51 +02:00
Nick Szaunig 05876838b0 feat: Admin-Seed automatisch beim Deploy ausfuehren
Backend Image / build-and-push (push) Successful in 2m35s
Frontend Image / build-and-push (push) Successful in 2m46s
Bisher wurde nirgends automatisch ein erster Admin-User angelegt -
prisma/seed.ts existierte, war aber weder im Dockerfile-CMD noch sonst
irgendwo verdrahtet. Auf einer frischen Datenbank haette also niemand
sich einloggen koennen.

- Dockerfile-CMD: migrate:deploy && seed && node dist/main.js
- ts-node/typescript von devDependencies zu dependencies verschoben
  (seed.ts laeuft als rohes TypeScript zur Laufzeit, kein
  Kompilierschritt), tsconfig.json zusaetzlich ins Runtime-Image kopiert
- seed.ts gegen Unique-Constraint-Race bei mehreren gleichzeitig
  startenden Pod-Replicas gehaertet (P2002 abgefangen statt Absturz)
- Login-Banner im Log nur beim tatsaechlichen Erst-Anlegen (URL aus
  FRONTEND_ORIGIN, Benutzername, Passwort) - bewusst nicht bei jedem
  Neustart, sonst wuerden veraltete/geaenderte Zugangsdaten weiter
  geloggt und Credentials landen unnoetig dauerhaft in den Logs

Seed ist bereits idempotent per E-Mail-Check (auch bei deaktivierten
Usern), UsersService.assertNotLastSystemRoleUser verhindert ohnehin
das Deaktivieren des letzten aktiven Admins - kein Aenderungsbedarf
dort, siehe Konversation.

Verifiziert: tsc/eslint clean, 297/297 Tests, Build clean. Migration
und Schema live gegen die lokale DB bestaetigt (ueber WSL direkt, da
Windows->WSL-Portweiterleitung weiterhin kaputt ist); der eigentliche
Seed-Lauf selbst konnte nicht End-to-End gegen die DB verifiziert
werden (gleiches Verbindungsproblem), Banner-Ausgabe isoliert
smoke-getestet.
2026-08-27 06:38:23 +02:00
Nick Szaunig 62004483cf feat: MFA (TOTP) und Self-Service-Profilseite
Backend Image / build-and-push (push) Successful in 2m57s
Frontend Image / build-and-push (push) Successful in 2m30s
Benutzer koennen jetzt selbst Zwei-Faktor-Authentifizierung einrichten
und ihr Profil verwalten (Name, E-Mail, Passwort, Profilbild).

Backend:
- Neues MfaModule (src/auth/mfa/): AES-256-GCM-verschluesselte
  TOTP-Secrets (otplib), QR-Code-Erzeugung, kurzlebige Setup-/
  Challenge-JWTs fuer den 2-Schritt-Login-Flow
- Neues ProfileModule (src/profile/): PATCH /profile,
  POST /profile/password, POST/DELETE/GET /profile/avatar,
  POST /profile/mfa/enroll(/confirm), POST /profile/mfa/disable -
  alle self-scoped ueber actor.id, ohne @RequirePermission (siehe
  ALLOWLIST in authorization-coverage.e2e-spec.ts)
- AuthService.login() zweistufig: bei aktivem MFA erst
  { mfaRequired, mfaToken } statt Tokens, neuer Endpunkt
  POST /auth/mfa/verify schliesst den Flow ab
- Admin-Recovery: POST /users/:id/mfa/reset (users:update) fuer
  verlorene MFA-Geraete, da es keinen E-Mail-Reset-Flow gibt

Frontend:
- Login-Seite mit zweitem Schritt fuer den TOTP-Code
- Neue Profilseite (/profile, ausserhalb des admin-only
  Settings-Bereichs): Profilbild, Name/E-Mail, Passwort, MFA-Ein-/
  Ausrichtung mit QR-Code-Dialog
- Header zeigt jetzt Avatar mit Dropdown (Profil/Abmelden) statt
  nur E-Mail-Text
- Admin-Users-Tabelle zeigt MFA-Status + Reset-Button

Haertung nach Security-Review (Backend/Frontend-Diff, kein CRITICAL,
mehrere MEDIUM behoben):
- Access-Token wird bei Passwortwechsel sofort ungueltig
  (User.passwordChangedAt + JWT-iat-Vergleich in
  PermissionResolverService.isTokenValid, vorher isUserActive) statt
  erst nach bis zu 15 Minuten
- E-Mail-Aenderung verlangt jetzt aktuelles Passwort
- POST /profile/password und /profile/mfa/disable haben jetzt das
  gleiche strikte Throttling wie Login (SENSITIVE_ACTION_THROTTLE)
- Kein stiller leerer Verschluesselungs-Key-Fallback mehr (wirft
  jetzt explizit)

Bewusst zurueckgestellt (dokumentiert, kein aktiver Exploit-Pfad):
dedizierter Verschluesselungs-Key fuer MFA-Secrets statt
JWT_ACCESS_SECRET-Ableitung, dedizierte users:mfa-reset-Permission
statt users:update, separate JWT-Secrets pro Token-Zweck.

Verifiziert: tsc/eslint clean, 297/297 Backend- + 31/31
Frontend-Unit-Tests, Build clean auf beiden Projekten. e2e weiterhin
durch bekanntes WSL2/Postgres-Problem blockiert. Unabhaengiges
Security- und Code-Quality-Review durchgefuehrt (0 CRITICAL, 0 HIGH).
2026-08-27 00:25:34 +02:00
Nick Szaunig 6776bdfe73 fix: Root und 404 leiten unangemeldete User direkt zu /login
Backend Image / build-and-push (push) Successful in 2m52s
Frontend Image / build-and-push (push) Successful in 2m38s
Bisher gingen "/" und unbekannte URLs immer erst zu /dashboard, das
dann bei fehlendem Login zu /login weiterleitete - unnoetiger Umweg.
Beide pruefen jetzt selbst den Auth-Status (gleiches Muster wie
(app)/layout.tsx: waehrend isLoading nichts tun, danach je nach User
direkt zu /dashboard oder /login).
2026-08-26 23:21:06 +02:00
Nick Szaunig b3157484c4 feat: Unbekannte und nicht erlaubte Seiten leiten zum Dashboard um
Backend Image / build-and-push (push) Successful in 2m55s
Frontend Image / build-and-push (push) Successful in 2m23s
- src/app/not-found.tsx: jede nicht existierende Route landet auf
  /dashboard statt der Next.js-Standard-404-Seite (der bestehende
  Auth-Guard in (app)/layout.tsx schickt nicht angemeldete User von
  dort automatisch weiter zu /login).
- settings/layout.tsx: fehlt die Berechtigung fuer den
  Einstellungen-Bereich, wird jetzt zu /dashboard umgeleitet statt nur
  einen Hinweistext anzuzeigen (gleiches Redirect-Muster wie der
  bestehende Login-Guard).

Login-nach-Anmeldung-Redirect zu /dashboard und der Auth-Gate in
(app)/layout.tsx bestanden bereits.
2026-08-26 23:14:47 +02:00
Nick Szaunig 9de123ab32 fix: Root-Route zeigt Next.js-Scaffold statt der App
Backend Image / build-and-push (push) Successful in 3m23s
Frontend Image / build-and-push (push) Successful in 2m30s
src/app/page.tsx war seit dem Projekt-Scaffold nie ersetzt worden -
die echte App laeuft komplett unter (app)/dashboard, (auth)/login etc.,
aber "/" selbst wurde nie verdrahtet und zeigte weiterhin die
create-next-app-Standardseite. / leitet jetzt zu /dashboard weiter;
(app)/layout.tsx uebernimmt von dort den bestehenden Auth-Redirect zu
/login fuer nicht angemeldete User.

Root Cause via systematic-debugging gefunden: Pod lief mit korrektem,
frisch gepushtem Image (kubectl describe bestaetigt), Ursache lag im
App-Code selbst, nicht in Build/Deploy-Pipeline.
2026-08-26 23:05:08 +02:00
769 changed files with 63963 additions and 5276 deletions

No files matched your search

-2
View File
@@ -63,8 +63,6 @@ jobs:
with:
context: ./ApiCore-Frontend
push: true
build-args: |
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
+52 -11
View File
@@ -26,6 +26,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig.
@@ -59,11 +60,11 @@ Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein di
## 3. Auth-Flow
1. Login (`POST /api/v1/auth/login`) mit E-Mail/Passwort → Access-Token (kurzlebig, ~15 min, JWT) + Refresh-Token (langlebig, ~7 Tage, in DB als Hash gespeichert, rotierend).
2. Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie.
3. Bei 401 automatischer Refresh über `POST /api/v1/auth/refresh`, danach Retry.
4. Jeder geschützte Endpunkt: `JwtAuthGuard` (Authentifizierung) + `RolesGuard` (Autorisierung nach Rolle aus Abschnitt 3).
5. API-Client-Rolle nutzt dieselben JWT-Endpunkte mit client-credentials-artigem Service-Account (kein OAuth2-Server im MVP, siehe Annahmen).
1. Login (`POST /api/v1/auth/login`) mit E-Mail/Passwort → Access-Token (15 min, JWT HS256, `iss=apicore`, `aud=apicore:access`) + Refresh-Token (in DB als Hash, rotierend). Bei aktiver MFA zuerst ein Zwischen-Token (`aud=apicore:mfa-challenge`), das nie als Access-Token gilt.
2. Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie (`Secure`, `SameSite=Lax`, Pfad `/api/v1/auth`).
3. Bei 401 automatischer Refresh über `POST /api/v1/auth/refresh`, danach Retry. Rotation atomar; Wiederverwendung eines alten Tokens (nach 10 s Gnadenfrist für parallele Tabs) beendet alle Sitzungen. Spätestens `SESSION_MAX_AGE` (7 Tage) nach dem Login ist neu anzumelden.
4. Guards in dieser Reihenfolge: `ThrottlerGuard` → `JwtAuthGuard` (Authentifizierung, Gültigkeit frisch aus der DB je Anfrage) → `PermissionsGuard` (Berechtigung, fail-closed).
5. Maschinen-Zugriff (Wabenhain) nur per API-Schlüssel mit Scope-Allowlist und optionaler IP-Allowlist, siehe Annahme 14 und Abschnitt 9.
## 4. Datenfluss / Rückverfolgbarkeit
@@ -96,6 +97,22 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden.
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`).
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
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.
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`.
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
## 7. Kubernetes
@@ -105,12 +122,36 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
## 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` selbst beim Start aus, bevor der Server Verbindungen annimmt (Dockerfile-`CMD`: `npm run migrate:deploy && 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 – eine Instanz wendet die Migrationen an, die anderen warten kurz und 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. 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.
**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. Aktuell **noch nicht umgesetzt** (offener Punkt, siehe TASKS.md) – empfohlene Strategie:
**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.
- Tägliches logisches Backup via `pg_dump -Fc` (custom format, komprimiert, für `pg_restore` geeignet), z. B. als Kubernetes-`CronJob` gegen den `apicore-postgres`-Service, Ablage auf einem separaten PVC oder Objektspeicher außerhalb des Postgres-eigenen Volumes.
- Kontinuierliches WAL-Archiving/PITR (Point-in-Time Recovery), falls die Storage-Class/das Backup-Tooling das unterstützt, für Wiederherstellung auf einen beliebigen Zeitpunkt zwischen den täglichen Dumps.
- Restore-Test: `pg_restore` eines Dumps in eine isolierte Datenbank, danach `npx prisma migrate deploy` zur Kontrolle, dass der Migrationsstand konsistent ist (`prisma migrate status`).
**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:
**Restore-Ablauf (Kurzfassung):** 1) Zielinstanz/-datenbank bereitstellen. 2) `pg_restore` (oder WAL-Replay bis zum Zielzeitpunkt bei PITR). 3) `npx prisma migrate status` prüfen. 4) `npx prisma migrate deploy` falls seit dem Dump neue Migrationen hinzugekommen sind. 5) `GET /health/ready` gegen die wiederhergestellte Instanz aufrufen, um DB-Erreichbarkeit zu bestätigen, bevor Verkehr umgeschaltet wird.
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14) und IdempotencyRecord (Annahme 11); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
- **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
## 9. API-Sicherheit (Stand Oktober 2026)
Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung und Begründung je Punkt in `docs/pruefung-2026-09.md`, Abschnitt „API-Sicherheit“.
| Bereich | Maßnahme |
|---|---|
| Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad |
| Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per `PASSWORD_BREACH_CHECK=false`) |
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
| Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
- Rate-Limits zählen je Pod im Speicher; bei 2 Replikas verdoppelt sich das Limit. Brute-Force-Schutz hängt nicht daran, sondern an der Kontosperre in der DB. Ein gemeinsamer Speicher (Redis) wäre der nächste Schritt, falls Lastspitzen ein Thema werden.
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
+66 -1
View File
@@ -2,13 +2,29 @@
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
JWT_ACCESS_SECRET="change-me-access-secret"
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
# 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"
JWT_ACCESS_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"
# Hard limit from login, however often the session is refreshed.
SESSION_MAX_AGE="7d"
# New passwords are checked against known breaches (Have I Been Pwned,
# k-anonymity: only 5 hash characters leave the server). "false" turns the
# check off for deployments without internet access.
# PASSWORD_BREACH_CHECK=true
PORT=3001
FRONTEND_ORIGIN="http://localhost:3000"
# Reverse-proxy hops in front of the backend whose X-Forwarded-For is
# 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
# Cloudflare edge IP that many clients share.
TRUST_PROXY=1
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
# local HTTP development - every real deployment must leave this unset (or
@@ -19,3 +35,52 @@ COOKIE_SECURE=false
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
DOCUMENTS_STORAGE_PATH="./uploads"
# Automatische Backups (Modul Backup). Alle drei Werte sind auch ueber die
# GUI (Verwaltung > Backup) konfigurierbar - eine hier gesetzte Env-Variable
# hat aber IMMER Vorrang vor dem GUI-Wert, pro Feld einzeln. Ohne Pfad
# (weder Env noch GUI) bleibt der Zeitplan deaktiviert (nur manueller
# Export/Import ueber die UI verfuegbar).
# Bei horizontaler Skalierung (>1 Replica) muss der Pfad auf ein geteiltes
# Volume zeigen (wie DOCUMENTS_STORAGE_PATH), sonst sieht jede Replica nur
# ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift
# nicht.
BACKUP_STORAGE_PATH="./backups"
# Directory a backup path set in the admin UI must stay under (the env
# variable above is not restricted). Default "./backups".
# BACKUP_ROOT="./backups"
# Cron-Ausdruck (Standard: taeglich um 03:00 Uhr).
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.
# BACKUP_ENCRYPTION_KEY=""
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
# OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI
# muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen.
#
# Dieselbe App-Registrierung wird auch fuer die M365-Kalender-Synchronisation
# (Modul Kalender) wiederverwendet - kein zweites Enterprise-App-Objekt noetig.
# Dafuer in der App-Registrierung zusaetzlich die Application-Permission
# "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig); die Tenant-ID wird
# aus OIDC_ISSUER_URL abgeleitet. Eintraege (Aufgaben, Durchsichten,
# Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen) werden
# einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365-Gruppe
# uebertragen. Ohne diese drei Werte bleibt die Synchronisation deaktiviert;
# zusaetzlich muessen die Synchronisation selbst UND die jeweilige Gruppen-ID
# in der GUI (Verwaltung > Automatisierung > Kalender-Sync) gesetzt werden.
OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0"
OIDC_CLIENT_ID="change-me-entra-client-id"
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
# Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
ENABLE_SWAGGER=false
+15 -5
View File
@@ -6,6 +6,12 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
# Qualitaets-Gate: ein Image entsteht nur, wenn Lint, Typecheck und
# Unit-Tests gruen sind. eslint laeuft hier ohne --fix (anders als
# `npm run lint`), damit ein Verstoss den Build abbricht statt still
# korrigiert zu werden. Die e2e-Tests brauchen eine Datenbank und laufen
# deshalb nicht im Image-Build.
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
RUN npm run build
FROM node:22-alpine AS runtime
@@ -17,13 +23,17 @@ 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), bevor der Server Verbindungen annimmt.
# (siehe 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
# braucht dafuer deutlich mehr Heap als das 512Mi-Pod-Limit hergibt und
# 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
USER node
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
# daher unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas:
# eine Instanz wendet die Migrationen an, die anderen warten kurz und finden
# danach nichts mehr zu tun.
CMD ["sh", "-c", "npm run migrate:deploy && node dist/main.js"]
# 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"]
+173
View File
@@ -15,6 +15,7 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^12.0.1",
"@nestjs/swagger": "^11.4.7",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.9.1",
@@ -24,6 +25,8 @@
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
@@ -2475,6 +2478,22 @@
"@nestjs/core": "^11.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.1.tgz",
"integrity": "sha512-v0z/li9L8oxpvuIDAGm5uOLaZ8KzsfZRzgWgppvs5vClneSinZP5IFl3C1F1DCeTCkNLiGN9g/oLUvzg8x++RQ==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0 || ^12.0.0",
"@nestjs/core": "^11.0.0 || ^12.0.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
@@ -2697,6 +2716,74 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@otplib/core": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.5.0.tgz",
"integrity": "sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ==",
"license": "MIT"
},
"node_modules/@otplib/hotp": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.5.0.tgz",
"integrity": "sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.5.0",
"@otplib/uri": "13.5.0"
}
},
"node_modules/@otplib/plugin-base32-scure": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.5.0.tgz",
"integrity": "sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.5.0",
"@scure/base": "^2.2.0"
}
},
"node_modules/@otplib/plugin-crypto-noble": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.5.0.tgz",
"integrity": "sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.2.0",
"@otplib/core": "13.5.0"
}
},
"node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@otplib/totp": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.5.0.tgz",
"integrity": "sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.5.0",
"@otplib/hotp": "13.5.0",
"@otplib/uri": "13.5.0"
}
},
"node_modules/@otplib/uri": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.5.0.tgz",
"integrity": "sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.5.0"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
@@ -3104,6 +3191,15 @@
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@scure/base": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz",
"integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.34.52",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz",
@@ -3504,6 +3600,12 @@
"integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
"license": "MIT"
},
"node_modules/@types/luxon": {
"version": "3.7.5",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -5819,6 +5921,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -8603,6 +8722,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.10",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz",
"integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
@@ -8981,6 +9109,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -9570,6 +9707,15 @@
"node": ">=8"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.7",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz",
"integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -9634,6 +9780,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openid-client": {
"version": "6.8.4",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
"integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
"license": "MIT",
"dependencies": {
"jose": "^6.2.2",
"oauth4webapi": "^3.8.5"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -9676,6 +9835,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/otplib": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.5.0.tgz",
"integrity": "sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.5.0",
"@otplib/hotp": "13.5.0",
"@otplib/plugin-base32-scure": "13.5.0",
"@otplib/plugin-crypto-noble": "13.5.0",
"@otplib/totp": "13.5.0",
"@otplib/uri": "13.5.0"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+10 -3
View File
@@ -6,7 +6,7 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"build": "nest build && tsc -p tsconfig.seed.json",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
@@ -19,7 +19,8 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"migrate:deploy": "prisma migrate deploy"
"migrate:deploy": "prisma migrate deploy",
"seed": "node dist/seed.js"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
@@ -28,19 +29,22 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^12.0.1",
"@nestjs/swagger": "^11.4.7",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1",
"prisma": "^7.9.1",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"prisma": "^7.9.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
@@ -87,6 +91,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
@@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "AuditAction" ADD VALUE 'MFA_ENABLED';
ALTER TYPE "AuditAction" ADD VALUE 'MFA_DISABLED';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "mfaEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "mfaSecret" TEXT,
ADD COLUMN "avatarPath" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "passwordChangedAt" TIMESTAMP(3);
@@ -0,0 +1,8 @@
-- CreateEnum
CREATE TYPE "ColonyCondition" AS ENUM ('EXCELLENT', 'GOOD', 'AVERAGE', 'WEAK', 'CRITICAL');
-- AlterTable
ALTER TABLE "Colony"
ALTER COLUMN "broodStatus" TYPE "ColonyCondition" USING ("broodStatus"::"ColonyCondition"),
ALTER COLUMN "feedStatus" TYPE "ColonyCondition" USING ("feedStatus"::"ColonyCondition"),
ALTER COLUMN "healthStatus" TYPE "ColonyCondition" USING ("healthStatus"::"ColonyCondition");
@@ -0,0 +1,4 @@
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SWARM_MOOD_HIGH';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_HEALTH_CRITICAL';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'STOCK_ITEM_LOW';
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "LabelTemplate" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"content" TEXT NOT NULL,
"widthMm" DOUBLE PRECISION NOT NULL DEFAULT 90,
"heightMm" DOUBLE PRECISION NOT NULL DEFAULT 35,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LabelTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "LabelTemplate_active_idx" ON "LabelTemplate"("active");
@@ -0,0 +1,5 @@
-- LabelTemplate switches from a plain-text "content" field to a structured
-- "elements" JSON array driving the canvas designer + PDF renderer. The
-- feature was never deployed live, so no existing rows need preserving.
ALTER TABLE "LabelTemplate" DROP COLUMN "content";
ALTER TABLE "LabelTemplate" ADD COLUMN "elements" JSONB NOT NULL DEFAULT '[]';
@@ -0,0 +1,9 @@
-- Explicit A4 sheet layout per LabelTemplate: columns/rows (nullable, null =
-- auto-fit) plus configurable page margins and gaps, so a template can be
-- made to match a specific physical label sheet's exact printed grid.
ALTER TABLE "LabelTemplate" ADD COLUMN "columns" INTEGER;
ALTER TABLE "LabelTemplate" ADD COLUMN "rows" INTEGER;
ALTER TABLE "LabelTemplate" ADD COLUMN "marginTopMm" DOUBLE PRECISION NOT NULL DEFAULT 5;
ALTER TABLE "LabelTemplate" ADD COLUMN "marginLeftMm" DOUBLE PRECISION NOT NULL DEFAULT 5;
ALTER TABLE "LabelTemplate" ADD COLUMN "gapXMm" DOUBLE PRECISION NOT NULL DEFAULT 2;
ALTER TABLE "LabelTemplate" ADD COLUMN "gapYMm" DOUBLE PRECISION NOT NULL DEFAULT 2;
@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "SiteSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"youtubeUrl" TEXT,
"instagramUrl" TEXT,
"tiktokUrl" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,9 @@
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_BATCH_NUMBER_FORMAT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'BOTTLING_LOT_NUMBER_FORMAT';
-- AlterTable
ALTER TABLE "AutomationRule" ADD COLUMN "sequenceValue" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "websiteUrl" TEXT;
@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "BackupSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"storagePath" TEXT,
"cronSchedule" TEXT,
"retentionCount" INTEGER,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BackupSettings_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,42 @@
-- CreateEnum
CREATE TYPE "CalendarSyncSourceType" AS ENUM ('TASK', 'INSPECTION', 'TREATMENT', 'FEEDING', 'HARVEST', 'QUEEN_ASSIGNMENT');
-- CreateEnum
CREATE TYPE "CalendarSyncStatus" AS ENUM ('PENDING', 'SYNCED', 'FAILED', 'SKIPPED');
-- CreateTable
CREATE TABLE "CalendarSyncSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"enabled" BOOLEAN NOT NULL DEFAULT false,
"groupId" TEXT,
"syncTask" BOOLEAN NOT NULL DEFAULT true,
"syncInspection" BOOLEAN NOT NULL DEFAULT true,
"syncTreatment" BOOLEAN NOT NULL DEFAULT true,
"syncFeeding" BOOLEAN NOT NULL DEFAULT true,
"syncHarvest" BOOLEAN NOT NULL DEFAULT true,
"syncQueenAssignment" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarSyncSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CalendarSyncOutboxEntry" (
"id" TEXT NOT NULL,
"sourceType" "CalendarSyncSourceType" NOT NULL,
"sourceId" TEXT NOT NULL,
"status" "CalendarSyncStatus" NOT NULL DEFAULT 'PENDING',
"graphEventId" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarSyncOutboxEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CalendarSyncOutboxEntry_status_idx" ON "CalendarSyncOutboxEntry"("status");
-- CreateIndex
CREATE UNIQUE INDEX "CalendarSyncOutboxEntry_sourceType_sourceId_key" ON "CalendarSyncOutboxEntry"("sourceType", "sourceId");
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "HoneyBatchContainerAllocation" (
"id" TEXT NOT NULL,
"honeyBatchId" TEXT NOT NULL,
"storageContainerId" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HoneyBatchContainerAllocation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "HoneyBatchContainerAllocation_storageContainerId_idx" ON "HoneyBatchContainerAllocation"("storageContainerId");
-- CreateIndex
CREATE UNIQUE INDEX "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key" ON "HoneyBatchContainerAllocation"("honeyBatchId", "storageContainerId");
-- AddForeignKey
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_storageContainerId_fkey" FOREIGN KEY ("storageContainerId") REFERENCES "StorageContainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "IdempotencyStatus" AS ENUM ('PENDING', 'COMPLETED');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceAutomationTrigger" "AutomationTriggerType";
-- CreateTable
CREATE TABLE "IdempotencyRecord" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"status" "IdempotencyStatus" NOT NULL DEFAULT 'PENDING',
"statusCode" INTEGER,
"responseBody" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IdempotencyRecord_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IdempotencyRecord_createdAt_idx" ON "IdempotencyRecord"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "IdempotencyRecord_userId_key_key" ON "IdempotencyRecord"("userId", "key");
-- CreateIndex
CREATE INDEX "Task_sourceAutomationTrigger_relatedType_relatedId_status_idx" ON "Task"("sourceAutomationTrigger", "relatedType", "relatedId", "status");
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"scopes" TEXT[],
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
-- CreateIndex
CREATE INDEX "ApiKey_revokedAt_idx" ON "ApiKey"("revokedAt");
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "LabelTemplate" ADD COLUMN "cropMarks" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,11 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AutomationTriggerType" ADD VALUE 'TREATMENT_FOLLOW_UP';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'FEEDING_FOLLOW_UP';
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "holderAddress" TEXT,
ADD COLUMN "holderName" TEXT;
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "apiaryId" TEXT,
ADD COLUMN "prescribingVet" TEXT,
ADD COLUMN "purchaseProof" TEXT,
ADD COLUMN "supplier" TEXT;
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,60 @@
-- CreateEnum
CREATE TYPE "WinterCheckSeason" AS ENUM ('AUTUMN', 'SPRING');
-- CreateEnum
CREATE TYPE "WinterOutcome" AS ENUM ('SURVIVED', 'WEAK', 'LOST');
-- CreateEnum
CREATE TYPE "ColonyLossCause" AS ENUM ('DEAD_OR_EMPTY', 'QUEEN_PROBLEM', 'NATURAL_DISASTER');
-- CreateTable
CREATE TABLE "WinterCheck" (
"id" TEXT NOT NULL,
"colonyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"winterYear" INTEGER NOT NULL,
"season" "WinterCheckSeason" NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"occupiedSeams" INTEGER,
"feedStockKg" DOUBLE PRECISION,
"queenPresent" BOOLEAN,
"outcome" "WinterOutcome",
"lossCause" "ColonyLossCause",
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WinterCheck_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WinterCheck_winterYear_idx" ON "WinterCheck"("winterYear");
-- CreateIndex
CREATE UNIQUE INDEX "WinterCheck_colonyId_winterYear_season_key" ON "WinterCheck"("colonyId", "winterYear", "season");
-- AddForeignKey
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Permissions: the boot-time catalog sync only grants new permissions to
-- system roles (ADMIN). Winter checks are everyday beekeeping records, so the
-- other roles get the same access they have to inspections.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'winter-checks:read', 'winter-checks', 'read', 'Ansehen', 'Überwinterung', 850),
(gen_random_uuid()::text, 'winter-checks:create', 'winter-checks', 'create', 'Anlegen', 'Überwinterung', 851),
(gen_random_uuid()::text, 'winter-checks:update', 'winter-checks', 'update', 'Bearbeiten', 'Überwinterung', 852)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.resource = 'winter-checks'
AND (
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
OR (r.key IN ('READONLY', 'API_CLIENT') AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,15 @@
-- Microsoft sign-in is bound to the IdP identity ("<iss>|<oid>"), set on the
-- first SSO login; the unverified email claim no longer picks the account.
ALTER TABLE "User" ADD COLUMN "ssoSubject" TEXT;
CREATE UNIQUE INDEX "User_ssoSubject_key" ON "User"("ssoSubject");
-- Emails are compared case-insensitively from now on. Store existing ones
-- lower-case, except where that would collide with another account - those
-- stay as they are and are still found by the case-insensitive lookup.
UPDATE "User" AS u
SET "email" = lower(u."email")
WHERE u."email" <> lower(u."email")
AND NOT EXISTS (
SELECT 1 FROM "User" AS o
WHERE o."id" <> u."id" AND lower(o."email") = lower(u."email")
);
@@ -0,0 +1,5 @@
-- TOTP replay protection and per-account lockout after failed attempts.
ALTER TABLE "User" ADD COLUMN "mfaLastTimeStep" INTEGER;
ALTER TABLE "User" ADD COLUMN "failedAuthAttempts" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "User" ADD COLUMN "authLockedUntil" TIMESTAMP(3);
ALTER TYPE "AuditAction" ADD VALUE 'ACCOUNT_LOCKED';
@@ -0,0 +1,3 @@
-- Absolute session lifetime: every rotated refresh token keeps the time
-- the session was first opened. Existing sessions count from now.
ALTER TABLE "RefreshToken" ADD COLUMN "sessionStartedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,3 @@
-- API keys can be limited to source addresses (IP or CIDR).
ALTER TABLE "ApiKey" ADD COLUMN "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "ApiKey" ADD COLUMN "lastUsedIp" TEXT;
@@ -0,0 +1,8 @@
-- One-time helper tokens (SSO completion, MFA challenge) are recorded once
-- used; a second use of the same jti is refused.
CREATE TABLE "ConsumedToken" (
"jti" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConsumedToken_pkey" PRIMARY KEY ("jti")
);
CREATE INDEX "ConsumedToken_expiresAt_idx" ON "ConsumedToken"("expiresAt");
@@ -0,0 +1,162 @@
-- Bestandsabgleich mit dem Webshop (docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md):
-- Bewegungsbuch je Charge, Herstellungschargen fuer Nicht-Honig-Artikel,
-- idempotente Buchungsanfragen des Shops. Rein additiv.
-- CreateEnum
CREATE TYPE "ProductKind" AS ENUM ('BOTTLED_HONEY', 'MANUFACTURED');
-- CreateEnum
CREATE TYPE "ShopCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "BatchMovementType" AS ENUM ('SHOP_SALE', 'SHOP_RETURN', 'WITHDRAWAL', 'CORRECTION');
-- CreateEnum
CREATE TYPE "WithdrawalReason" AS ENUM ('HOFLADEN', 'EIGENBEDARF', 'BRUCH', 'PROBE', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "ShopStockRequestKind" AS ENUM ('SALE', 'RETURN');
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "kind" "ProductKind" NOT NULL DEFAULT 'BOTTLED_HONEY',
ADD COLUMN "shopCategory" "ShopCategory" NOT NULL DEFAULT 'HONIG';
-- CreateTable
CREATE TABLE "ProductionBatch" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"lotNumber" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"quantity" INTEGER NOT NULL,
"bestBeforeDate" TIMESTAMP(3),
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductionBatch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BatchMovement" (
"id" TEXT NOT NULL,
"bottlingBatchId" TEXT,
"productionBatchId" TEXT,
"type" "BatchMovementType" NOT NULL,
"quantity" INTEGER NOT NULL,
"reason" "WithdrawalReason",
"notes" TEXT,
"shopRequestId" TEXT,
"userId" TEXT,
"apiKeyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BatchMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ShopStockRequest" (
"id" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"kind" "ShopStockRequestKind" NOT NULL,
"bottlingBatchId" TEXT,
"productId" TEXT,
"quantity" INTEGER NOT NULL,
"reversesExternalId" TEXT,
"apiKeyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ShopStockRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProductionBatch_lotNumber_key" ON "ProductionBatch"("lotNumber");
-- CreateIndex
CREATE INDEX "ProductionBatch_productId_idx" ON "ProductionBatch"("productId");
-- CreateIndex
CREATE INDEX "BatchMovement_bottlingBatchId_idx" ON "BatchMovement"("bottlingBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_productionBatchId_idx" ON "BatchMovement"("productionBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_shopRequestId_idx" ON "BatchMovement"("shopRequestId");
-- CreateIndex
CREATE UNIQUE INDEX "ShopStockRequest_externalId_key" ON "ShopStockRequest"("externalId");
-- CreateIndex
CREATE INDEX "ShopStockRequest_reversesExternalId_idx" ON "ShopStockRequest"("reversesExternalId");
-- AddForeignKey
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_bottlingBatchId_fkey" FOREIGN KEY ("bottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_shopRequestId_fkey" FOREIGN KEY ("shopRequestId") REFERENCES "ShopStockRequest"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Genau eine Charge je Bewegung, genau ein Ziel je Shop-Anfrage.
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (("bottlingBatchId" IS NULL) <> ("productionBatchId" IS NULL));
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_exactly_one_target"
CHECK (("bottlingBatchId" IS NULL) <> ("productId" IS NULL));
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_quantity_positive"
CHECK ("quantity" > 0);
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_quantity_positive"
CHECK ("quantity" > 0);
-- Permissions: the boot-time catalog sync only grants new permissions to
-- ADMIN; the other built-in roles get them here, matching their existing
-- bottling rights.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'bottling:withdraw', 'bottling', 'withdraw', 'Entnehmen und Inventur', 'Abfüllungen', 1604),
(gen_random_uuid()::text, 'production-batch:read', 'production-batch', 'read', 'Ansehen', 'Herstellungschargen', 1650),
(gen_random_uuid()::text, 'production-batch:create', 'production-batch', 'create', 'Anlegen', 'Herstellungschargen', 1651),
(gen_random_uuid()::text, 'production-batch:update', 'production-batch', 'update', 'Bearbeiten', 'Herstellungschargen', 1652),
(gen_random_uuid()::text, 'production-batch:delete', 'production-batch', 'delete', 'Löschen', 'Herstellungschargen', 1653),
(gen_random_uuid()::text, 'production-batch:withdraw', 'production-batch', 'withdraw', 'Entnehmen und Inventur', 'Herstellungschargen', 1654),
(gen_random_uuid()::text, 'shop-sync:write', 'shop-sync', 'write', 'Verkäufe und Rückbuchungen des Shops buchen', 'Shop-Synchronisation', 3301)
ON CONFLICT (key) DO NOTHING;
-- Wer Abfuellungen bearbeiten darf, darf kuenftig auch entnehmen und
-- Herstellungschargen pflegen; wer Abfuellungen sehen darf, sieht auch
-- Herstellungschargen.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:update'
JOIN "Permission" p ON p.key IN (
'bottling:withdraw', 'production-batch:create', 'production-batch:update',
'production-batch:delete', 'production-batch:withdraw'
)
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:read'
JOIN "Permission" p ON p.key = 'production-batch:read'
ON CONFLICT DO NOTHING;
-- Only ADMIN may hand out shop-sync:write to an API key (a key can only get
-- permissions its creator holds). Inserted here, the row is no longer "new"
-- for the boot-time catalog sync, so the grant has to happen here too.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'ADMIN' AND p.key = 'shop-sync:write'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,106 @@
-- Rohstoffe (Wachs, Propolis) mit eigenen Chargen und Bestand; Herstellungs-
-- chargen verbrauchen sie (ProductionInput + Bewegung CONSUMPTION). Damit
-- reicht die Rueckverfolgung vom Rohstoff ueber die Charge bis zur Kundin.
-- CreateEnum
CREATE TYPE "RawMaterialKind" AS ENUM ('BEESWAX', 'PROPOLIS');
-- AlterEnum
ALTER TYPE "BatchMovementType" ADD VALUE 'CONSUMPTION';
-- AlterTable
ALTER TABLE "BatchMovement" ADD COLUMN "rawMaterialLotId" TEXT;
-- CreateTable
CREATE TABLE "RawMaterialLot" (
"id" TEXT NOT NULL,
"kind" "RawMaterialKind" NOT NULL,
"lotNumber" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"quantity" INTEGER NOT NULL,
"apiaryId" TEXT,
"harvestId" TEXT,
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RawMaterialLot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductionInput" (
"id" TEXT NOT NULL,
"productionBatchId" TEXT NOT NULL,
"rawMaterialLotId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductionInput_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RawMaterialLot_lotNumber_key" ON "RawMaterialLot"("lotNumber");
-- CreateIndex
CREATE INDEX "RawMaterialLot_kind_idx" ON "RawMaterialLot"("kind");
-- CreateIndex
CREATE INDEX "ProductionInput_productionBatchId_idx" ON "ProductionInput"("productionBatchId");
-- CreateIndex
CREATE INDEX "ProductionInput_rawMaterialLotId_idx" ON "ProductionInput"("rawMaterialLotId");
-- CreateIndex
CREATE INDEX "BatchMovement_rawMaterialLotId_idx" ON "BatchMovement"("rawMaterialLotId");
-- AddForeignKey
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "Harvest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Eine Bewegung gehoert jetzt zu genau einer von drei Chargenarten.
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId") = 1);
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_quantity_positive" CHECK ("quantity" > 0);
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_quantity_positive" CHECK ("quantity" > 0);
-- Permissions (catalog sync grants only brand-new rows to ADMIN): roles that
-- may maintain production batches may maintain raw materials, readers read.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'raw-material:read', 'raw-material', 'read', 'Ansehen', 'Rohstoffe', 1630),
(gen_random_uuid()::text, 'raw-material:create', 'raw-material', 'create', 'Anlegen', 'Rohstoffe', 1631),
(gen_random_uuid()::text, 'raw-material:update', 'raw-material', 'update', 'Bearbeiten', 'Rohstoffe', 1632),
(gen_random_uuid()::text, 'raw-material:delete', 'raw-material', 'delete', 'Löschen', 'Rohstoffe', 1633),
(gen_random_uuid()::text, 'raw-material:withdraw', 'raw-material', 'withdraw', 'Entnehmen und Inventur', 'Rohstoffe', 1634)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:create'
JOIN "Permission" p ON p.key IN ('raw-material:create', 'raw-material:update', 'raw-material:delete', 'raw-material:withdraw')
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:read'
JOIN "Permission" p ON p.key = 'raw-material:read'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,4 @@
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOT_NUMBER_FORMAT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PRODUCTION_BATCH_NUMBER_FORMAT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOW';
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "AutomationSequence" (
"triggerType" "AutomationTriggerType" NOT NULL,
"scope" TEXT NOT NULL,
"value" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutomationSequence_pkey" PRIMARY KEY ("triggerType","scope")
);
-- AddForeignKey
ALTER TABLE "AutomationSequence" ADD CONSTRAINT "AutomationSequence_triggerType_fkey" FOREIGN KEY ("triggerType") REFERENCES "AutomationRule"("triggerType") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- Outbox claims: one worker at a time writes an entry to M365, and a
-- revision counter lets it notice an enqueue that arrived during the sync.
-- AlterTable
ALTER TABLE "CalendarSyncOutboxEntry" ADD COLUMN "claimedAt" TIMESTAMP(3),
ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
+555 -3
View File
@@ -76,11 +76,37 @@ model User {
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
// TOTP-Secret AES-256-GCM-verschluesselt gespeichert (siehe
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
mfaEnabled Boolean @default(false)
mfaSecret String?
// Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer)
// wird danach nie wieder akzeptiert (Replay-Schutz).
mfaLastTimeStep Int?
// Fehlversuche (Passwort, MFA-Code, Bestaetigung) seit dem letzten Erfolg;
// ab 5 ist das Konto bis authLockedUntil gesperrt.
failedAuthAttempts Int @default(0)
authLockedUntil DateTime?
avatarPath String?
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
// die vor diesem Zeitpunkt ausgestellt wurden (siehe
// PermissionResolverService.isTokenValid), damit ein gestohlener Token
// nach einer Passwortaenderung nicht bis zu 15 Minuten weiter funktioniert.
passwordChangedAt DateTime?
// Unveraenderliche Identitaet beim Identity Provider ("<iss>|<oid>"),
// gesetzt bei der ersten Microsoft-Anmeldung. Danach entscheidet nur sie,
// nicht mehr der (unverifizierte) E-Mail-Claim, welches Konto sich anmeldet.
ssoSubject String? @unique
refreshTokens RefreshToken[]
inspections Inspection[]
varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[]
treatments Treatment[]
feedings Feeding[]
harvests Harvest[]
@@ -94,15 +120,55 @@ model User {
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[]
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[]
@@index([roleId])
}
// Schlüssel für Maschinen-Zugriffe (z. B. Wabenhain-Shop-Sync) statt eines
// Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash des
// Schlüssels; der Klartext wird genau einmal bei der Erstellung angezeigt.
// Ein Schlüssel ist kein Benutzer: er darf nur die Routen, deren
// Berechtigung in `scopes` steht (siehe api-keys/api-key-scopes.ts).
model ApiKey {
id String @id @default(cuid())
name String
// Anfang des Schlüssels ("ack_ab12cd34") - zum Wiedererkennen in der
// Liste, ohne den Schlüssel selbst zu speichern.
prefix String
keyHash String @unique
scopes String[]
createdByUserId String?
createdByUser User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
lastUsedAt DateTime?
// Absender-IP der letzten Nutzung - hilft beim Einrichten der Allowlist.
lastUsedIp String?
// Nur von diesen Adressen/Netzen (IP oder CIDR) gueltig; leer = ueberall.
allowedIps String[] @default([])
expiresAt DateTime?
revokedAt DateTime?
@@index([revokedAt])
}
// Einmal-Tokens (SSO-Abschluss, MFA-Zwischenschritt), die schon benutzt
// wurden. Ein zweiter Versuch mit derselben jti wird abgelehnt.
model ConsumedToken {
jti String @id
expiresAt DateTime
@@index([expiresAt])
}
model RefreshToken {
id String @id @default(cuid())
userId String
@@ -111,6 +177,9 @@ model RefreshToken {
expiresAt DateTime
createdAt DateTime @default(now())
revokedAt DateTime?
// Anmeldezeitpunkt der Sitzung, an jede rotierte Nachfolge-Token vererbt:
// spaetestens SESSION_MAX_AGE danach ist neu anzumelden.
sessionStartedAt DateTime @default(now())
@@index([userId])
}
@@ -124,6 +193,9 @@ enum AuditAction {
LOGIN_FAILED
LOGOUT
TOKEN_REFRESHED
MFA_ENABLED
MFA_DISABLED
ACCOUNT_LOCKED
}
model AuditLog {
@@ -167,6 +239,8 @@ model Apiary {
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
@@index([status])
}
@@ -195,6 +269,15 @@ enum ColonyStatus {
MERGED
}
// Fuenfstufige Zustandsskala, geteilt von Brut-, Futter- und Gesundheitsstatus.
enum ColonyCondition {
EXCELLENT
GOOD
AVERAGE
WEAK
CRITICAL
}
model Colony {
id String @id @default(cuid())
code String @unique
@@ -205,9 +288,9 @@ model Colony {
colonyType ColonyType
status ColonyStatus @default(ACTIVE)
strength Int?
broodStatus String?
feedStatus String?
healthStatus String?
broodStatus ColonyCondition?
feedStatus ColonyCondition?
healthStatus ColonyCondition?
notes String?
currentQueenId String? @unique
currentQueen Queen? @relation("ColonyCurrentQueen", fields: [currentQueenId], references: [id])
@@ -222,6 +305,7 @@ model Colony {
inspections Inspection[]
varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[]
treatments Treatment[]
feedings Feeding[]
harvests HarvestColony[]
@@ -437,11 +521,17 @@ model Task {
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
sourceTemplateId String?
sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id])
// Set when an automation rule created the task (not a person). Keeps an
// automatic suggestion distinguishable from a planned task, and lets the
// rule see that it already left an open task for the same object instead
// of piling up a duplicate on every further trigger.
sourceAutomationTrigger AutomationTriggerType?
createdAt DateTime @default(now())
completedAt DateTime?
@@index([status, dueDate])
@@index([relatedType, relatedId])
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
}
enum TaskTemplateRecurrenceType {
@@ -541,6 +631,54 @@ model SwarmControlCheck {
@@index([colonyId, date])
}
// ---------------------------------------------------------------------------
// Ein- und Auswinterung, Winterverluste
// ---------------------------------------------------------------------------
enum WinterCheckSeason {
AUTUMN // Einwinterung
SPRING // Auswinterung
}
enum WinterOutcome {
SURVIVED
WEAK
LOST
}
// Verlustkategorien des COLOSS-Monitorings: totes/leeres Volk, unloesbares
// Koeniginnenproblem, Naturereignis. Bewusst keine Diagnose (Varroa, Virus
// ...): die gehoert, wenn ueberhaupt bekannt, in die Notiz.
enum ColonyLossCause {
DEAD_OR_EMPTY
QUEEN_PROBLEM
NATURAL_DISASTER
}
// Eine Einwinterung und eine Auswinterung je Volk und Winter. winterYear ist
// das Jahr, in dem der Winter beginnt (Winter 2026/27 = 2026).
model WinterCheck {
id String @id @default(cuid())
colonyId String
colony Colony @relation(fields: [colonyId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
winterYear Int
season WinterCheckSeason
date DateTime
occupiedSeams Int?
feedStockKg Float?
queenPresent Boolean?
outcome WinterOutcome?
lossCause ColonyLossCause?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([colonyId, winterYear, season])
@@index([winterYear])
}
// ---------------------------------------------------------------------------
// Behandlung / Tierarzneimittel (Abschnitt 4.11)
// ---------------------------------------------------------------------------
@@ -561,6 +699,15 @@ model Treatment {
reason String?
batchNumber String?
notes String?
withdrawalPeriodDays Int?
// Bestandsbuch (Art. 108 VO (EU) 2019/6): where the colony stood on the
// day of the treatment - kept with the treatment because the colony may
// be moved later - and the supplier / purchase proof / prescribing vet.
apiaryId String?
apiary Apiary? @relation(fields: [apiaryId], references: [id])
supplier String?
purchaseProof String?
prescribingVet String?
createdAt DateTime @default(now())
@@index([colonyId, date])
@@ -616,6 +763,7 @@ model Harvest {
colonies HarvestColony[]
extractions ExtractionHarvest[]
rawMaterialLots RawMaterialLot[]
@@index([apiaryId, date])
}
@@ -688,6 +836,7 @@ model HoneyBatch {
measurements HoneyMeasurement[]
bottlingBatches BottlingBatch[]
relocations HoneyBatchRelocation[]
containerAllocations HoneyBatchContainerAllocation[]
@@index([status])
@@index([extractionId])
@@ -736,6 +885,7 @@ model StorageContainer {
honeyBatches HoneyBatch[]
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
batchAllocations HoneyBatchContainerAllocation[]
@@index([status])
}
@@ -758,6 +908,29 @@ model HoneyBatchRelocation {
@@index([honeyBatchId, relocatedAt])
}
// Aufteilung einer Honigcharge auf mehrere Lagerbehälter (z.B. 125 kg auf 5
// Eimer à 25 kg) - additiv zum obigen "primären" storageContainerId/
// Umlagern-Fluss auf HoneyBatch, der weiterhin den einfachen Ein-Behälter-
// Fall abdeckt; diese Tabelle bildet zusätzlich den Fall ab, dass eine
// Charge zu groß für einen einzelnen Behälter ist. @@unique macht das
// Zuweisen einer Menge zu einem Behälter ein Upsert (erneutes Zuweisen
// überschreibt die Menge, statt einen Duplikat-Eintrag anzulegen).
model HoneyBatchContainerAllocation {
id String @id @default(cuid())
honeyBatchId String
honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id])
storageContainerId String
storageContainer StorageContainer @relation(fields: [storageContainerId], references: [id])
amount Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// The name the migration created (an older Prisma shortened it). Mapped
// explicitly so schema and database agree and no rename migration is due.
@@unique([honeyBatchId, storageContainerId], map: "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key")
@@index([storageContainerId])
}
enum StockCategory {
HONEY
CONSUMABLE
@@ -813,6 +986,22 @@ enum ProductStatus {
DISCONTINUED
}
// Wo der Bestand eines Artikels liegt: Honig je Abfuellung (BottlingBatch),
// alles andere (Kerzen, Propolis-Produkte, ...) je Herstellungscharge
// (ProductionBatch). Nicht umstellbar, sobald Chargen existieren.
enum ProductKind {
BOTTLED_HONEY
MANUFACTURED
}
// Vorgabe fuer die Kategorie des Produkts im Webshop (Wabenhain).
enum ShopCategory {
HONIG
KERZEN
PROPOLIS
SONSTIGES
}
model Product {
id String @id @default(cuid())
name String
@@ -822,14 +1011,51 @@ model Product {
weight Float?
salesUnit String?
status ProductStatus @default(ACTIVE)
kind ProductKind @default(BOTTLED_HONEY)
shopCategory ShopCategory @default(HONIG)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
@@index([status])
}
// Etikettenvorlage mit {{Platzhalter}}-Text, wird beim Drucken mit den
// Daten einer Abfuellung befuellt (siehe LabelTemplatesService.renderLabelsPdf).
model LabelTemplate {
id String @id @default(cuid())
name String
// Array of LabelElement objects (text/placeholder/qrCode/image/line), see
// label-templates.service.ts. Drives both the in-browser canvas designer
// and the pdfkit print renderer from the same source of truth.
elements Json @default("[]")
widthMm Float @default(90)
heightMm Float @default(35)
// A4 sheet layout. columns/rows are nullable: null means "auto-fit as many
// as fit" (the original behaviour); a real label sheet product (e.g. a
// specific Avery SKU) publishes an exact columns/rows/margin/gap grid, so
// setting these lets a template's printed positions match the physical,
// pre-die-cut sheet instead of just whatever the naive fit computes.
columns Int?
rows Int?
marginTopMm Float @default(5)
marginLeftMm Float @default(5)
gapXMm Float @default(2)
gapYMm Float @default(2)
// Kleine Schnittmarken im Blattrand, eine an jedem Ende jeder
// Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten
// Etikettenbögen, die nicht geschnitten werden.
cropMarks Boolean @default(true)
isDefault Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([active])
}
model BottlingBatch {
id String @id @default(cuid())
honeyBatchId String
@@ -846,10 +1072,167 @@ model BottlingBatch {
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
movements BatchMovement[]
@@index([honeyBatchId])
@@index([productId])
}
// ---------------------------------------------------------------------------
// Herstellungschargen und Bestandsbewegungen (Bestandsabgleich mit dem Shop,
// docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md)
// ---------------------------------------------------------------------------
// Gegenstueck zur Abfuellung fuer Artikel der Art MANUFACTURED (Kerzen,
// Propolis-Tinkturen, Salben, ...). Spaeter Ziel eines Herstellungslaufs, der
// Rohstoff-Chargen verbraucht.
model ProductionBatch {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id])
lotNumber String @unique
date DateTime
// Hergestellte Stueckzahl - Ausgangsmenge fuer den Rest.
quantity Int
bestBeforeDate DateTime?
notes String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements BatchMovement[]
// Woraus die Charge hergestellt wurde (Rueckverfolgung Rohstoff -> Ware).
inputs ProductionInput[]
@@index([productId])
}
// ---------------------------------------------------------------------------
// Rohstoffe (Wachs, Propolis) und ihr Verbrauch in der Herstellung
// ---------------------------------------------------------------------------
enum RawMaterialKind {
BEESWAX
PROPOLIS
}
// Eine Rohstoff-Charge, z. B. "Wachs 2026-01: 4200 g" aus dem Entdeckeln
// oder "Propolis 2026-02: 180 g" vom Abkratzen der Gitter. Menge in Gramm;
// der Rest folgt wie bei allen Chargen aus dem Bewegungsbuch.
model RawMaterialLot {
id String @id @default(cuid())
kind RawMaterialKind
lotNumber String @unique
// Gewinnungsdatum
date DateTime
// Gewonnene Menge in Gramm - Ausgangsmenge fuer den Rest.
quantity Int
// Herkunft, optional: Stand und/oder Ernte, aus der der Rohstoff stammt.
apiaryId String?
apiary Apiary? @relation(fields: [apiaryId], references: [id])
harvestId String?
harvest Harvest? @relation(fields: [harvestId], references: [id])
notes String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements BatchMovement[]
usedIn ProductionInput[]
@@index([kind])
}
// Verbrauch einer Rohstoff-Charge in einer Herstellungscharge. Die
// Abbuchung selbst steht als CONSUMPTION im Bewegungsbuch des Rohstoffs.
model ProductionInput {
id String @id @default(cuid())
productionBatchId String
productionBatch ProductionBatch @relation(fields: [productionBatchId], references: [id], onDelete: Cascade)
rawMaterialLotId String
rawMaterialLot RawMaterialLot @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
// Verbrauchte Menge in Gramm.
quantity Int
createdAt DateTime @default(now())
@@index([productionBatchId])
@@index([rawMaterialLotId])
}
enum BatchMovementType {
SHOP_SALE
SHOP_RETURN
WITHDRAWAL
CORRECTION
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
CONSUMPTION
}
enum WithdrawalReason {
HOFLADEN
EIGENBEDARF
BRUCH
PROBE
SONSTIGES
}
// Bewegungsbuch: jede Bestandsaenderung einer Charge, nie geloescht (Fehler
// werden gegengebucht). Rest = Ausgangsmenge + Summe aller quantity.
// Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der
// Migration).
model BatchMovement {
id String @id @default(cuid())
bottlingBatchId String?
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
productionBatchId String?
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
rawMaterialLotId String?
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
type BatchMovementType
// Mit Vorzeichen: minus = raus, plus = rein.
quantity Int
reason WithdrawalReason?
notes String?
shopRequestId String?
shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict)
userId String?
user User? @relation(fields: [userId], references: [id])
apiKeyId String?
createdAt DateTime @default(now())
@@index([bottlingBatchId])
@@index([productionBatchId])
@@index([rawMaterialLotId])
@@index([shopRequestId])
}
enum ShopStockRequestKind {
SALE
RETURN
}
// Eine Buchungsanfrage des Shops. externalId (vom Shop vergeben) macht jede
// Anfrage idempotent: dieselbe Anfrage zweimal bucht nur einmal.
model ShopStockRequest {
id String @id @default(cuid())
externalId String @unique
kind ShopStockRequestKind
// Ziel: eine Abfuellung (Honig) ODER ein Artikel (MANUFACTURED, FIFO).
bottlingBatchId String?
productId String?
quantity Int
reversesExternalId String?
apiKeyId String?
createdAt DateTime @default(now())
movements BatchMovement[]
@@index([reversesExternalId])
}
// ---------------------------------------------------------------------------
// Inventar (Abschnitt 4.22) – v1.x Modul 24
// ---------------------------------------------------------------------------
@@ -958,6 +1341,16 @@ enum AutomationTriggerType {
QUEENLESS_NO_EGGS
VARROA_THRESHOLD
FEED_STATUS_CRITICAL
SWARM_MOOD_HIGH
COLONY_HEALTH_CRITICAL
STOCK_ITEM_LOW
HONEY_BATCH_NUMBER_FORMAT
BOTTLING_LOT_NUMBER_FORMAT
TREATMENT_FOLLOW_UP
FEEDING_FOLLOW_UP
RAW_MATERIAL_LOT_NUMBER_FORMAT
PRODUCTION_BATCH_NUMBER_FORMAT
RAW_MATERIAL_LOW
}
model AutomationRule {
@@ -966,6 +1359,165 @@ model AutomationRule {
active Boolean @default(true)
config Json?
actionTemplate String?
// Running counter for the number-format rules (*_NUMBER_FORMAT) - unused
// by the threshold-style rules. Kept as
// a dedicated column (not inside `config`) so it can be incremented with a
// single atomic `UPDATE ... SET sequenceValue = sequenceValue + 1`,
// race-safe under concurrent batch/bottling creation.
sequenceValue Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sequences AutomationSequence[]
}
// Separate counters of one number-format rule, one per scope - e.g. per raw
// material kind when the format contains {ART}, so wax and propolis each
// count WA-2026-0001, WA-2026-0002, ... and PR-2026-0001, ... on their own.
// Without {ART} the rule keeps using AutomationRule.sequenceValue: separate
// counters behind an identical prefix would produce duplicate numbers.
model AutomationSequence {
triggerType AutomationTriggerType
rule AutomationRule @relation(fields: [triggerType], references: [triggerType], onDelete: Cascade)
scope String
value Int @default(0)
updatedAt DateTime @updatedAt
@@id([triggerType, scope])
}
// ---------------------------------------------------------------------------
// Website-Einstellungen – Social-Media-Links auf der öffentlichen Herkunftsseite
// ---------------------------------------------------------------------------
// Singleton row (fixed id) rather than a key/value table: exactly one set of
// site-wide social links exists, and a fixed-id upsert is simpler than
// enforcing "at most one row" any other way.
model SiteSettings {
id String @id @default("singleton")
youtubeUrl String?
instagramUrl String?
tiktokUrl String?
websiteUrl String?
// Name and address of the bee keeper, printed on the Bestandsbuch. Never
// part of any public page.
holderName String?
holderAddress String?
updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
// Backup-Einstellungen – GUI-Override für den automatischen Backup-Zeitplan.
// Jede der drei Umgebungsvariablen (BACKUP_STORAGE_PATH/_CRON_SCHEDULE/
// _RETENTION_COUNT) hat Vorrang vor dem hier gespeicherten Wert - diese Zeile
// greift nur pro Feld, wenn die jeweilige Env-Variable nicht gesetzt ist.
// ---------------------------------------------------------------------------
model BackupSettings {
id String @id @default("singleton")
storagePath String?
cronSchedule String?
retentionCount Int?
updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
// Kalender-Synchronisation – einseitige Übertragung (ApiCore → M365) von
// Kalendereinträgen in den Kalender einer Microsoft-365-Gruppe via Graph API.
// Zugangsdaten (Tenant/Client-ID/-Secret) sind bewusst NICHT Teil dieses
// Modells - wie bei OIDC_CLIENT_SECRET nur über Env-Variablen (Secret), nie
// über die GUI editierbar. groupId ist keine Zugangsdaten-Information und
// daher hier GUI-konfigurierbar.
// ---------------------------------------------------------------------------
enum CalendarSyncSourceType {
TASK
INSPECTION
TREATMENT
FEEDING
HARVEST
QUEEN_ASSIGNMENT
}
enum CalendarSyncStatus {
PENDING
SYNCED
FAILED
SKIPPED
}
model CalendarSyncSettings {
id String @id @default("singleton")
enabled Boolean @default(false)
groupId String?
syncTask Boolean @default(true)
syncInspection Boolean @default(true)
syncTreatment Boolean @default(true)
syncFeeding Boolean @default(true)
syncHarvest Boolean @default(true)
syncQueenAssignment Boolean @default(true)
updatedAt DateTime @updatedAt
}
// Outbox statt synchronem Push direkt im Request-Pfad (anders als das
// Webhooks-Modul): Graph-API-Aufrufe sind zu langsam/fehleranfällig
// (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/
// Aufgaben-Erstellung darauf warten zu lassen. Ein periodischer Worker
// verarbeitet PENDING-Zeilen und wiederholt fehlgeschlagene Versuche.
// @@unique auf [sourceType, sourceId] macht enqueue() ein Upsert: ein
// erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das
// bestehende Graph-Event über die gespeicherte graphEventId, statt ein
// Duplikat anzulegen.
// ---------------------------------------------------------------------------
// Idempotenz – schützt Anlege-Anfragen vor doppelter Ausführung, wenn ein
// Client dieselbe Anfrage wiederholt (Offline-Warteschlange nach verlorener
// Antwort, Netzabbruch, erneutes Absenden). Der Client schickt pro fachlichem
// Vorgang einen eindeutigen `Idempotency-Key`; eine Wiederholung bekommt die
// gespeicherte erste Antwort statt eines zweiten Datensatzes. Siehe
// src/common/idempotency/idempotency.interceptor.ts.
// ---------------------------------------------------------------------------
enum IdempotencyStatus {
PENDING
COMPLETED
}
model IdempotencyRecord {
id String @id @default(cuid())
userId String
key String
// "POST /api/v1/inspections" - a key reused for a different endpoint is
// rejected instead of replaying an unrelated response.
scope String
status IdempotencyStatus @default(PENDING)
statusCode Int?
responseBody Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, key])
@@index([createdAt])
}
model CalendarSyncOutboxEntry {
id String @id @default(cuid())
sourceType CalendarSyncSourceType
sourceId String
status CalendarSyncStatus @default(PENDING)
graphEventId String?
attempts Int @default(0)
lastError String?
// Set while one worker (pod, cron tick, "Jetzt synchronisieren") writes
// this entry to M365, so no second worker creates the same event. A claim
// older than the stale timeout belongs to a crashed worker and is retaken.
claimedAt DateTime?
// Counts every enqueue: a worker only marks the entry SYNCED if nothing
// was enqueued since it claimed it - an edit made during the sync is not
// lost but synced on the next pass.
revision Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sourceType, sourceId])
@@index([status])
}
+205 -4
View File
@@ -1,19 +1,203 @@
import 'dotenv/config';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'node:crypto';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma';
import { Prisma, PrismaClient } from '../generated/prisma';
const BCRYPT_SALT_ROUNDS = 12;
const DEFAULT_ADMIN_EMAIL = '[email protected]';
const DEFAULT_ADMIN_PASSWORD = 'ChangeMe123!';
// No fixed fallback password: a fixed literal shipped in source would be a
// well-known, guessable credential for any deployment that forgets to set
// SEED_ADMIN_PASSWORD. A random one is generated instead and printed once
// below, same as an operator-supplied one would be.
function generateRandomPassword(): string {
return crypto.randomBytes(18).toString('base64url');
}
// Only printed the moment the account is actually created (first deploy on
// an empty database), never on later restarts: by then the real admin may
// have changed the password via /profile, so repeating it would just be
// stale and would leak credentials into logs indefinitely for no reason.
function printLoginBanner(email: string, password: string): void {
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const line = '='.repeat(60);
console.log(line);
console.log('Seed: Admin-Zugang eingerichtet.');
console.log(` URL: ${url}`);
console.log(` Benutzer: ${email}`);
console.log(` Passwort: ${password}`);
console.log(line);
}
// Duplicated from src/task-templates/recurrence.ts rather than imported:
// tsconfig.seed.json scopes this build to rootDir "./prisma" only (its own
// isolated compile, separate from the main Nest build), so a cross-rootDir
// import would fail to compile. Only the YEARLY case is needed here.
function nextYearlyOccurrenceUtc(
month: number,
dayOfMonth: number,
from: Date,
): Date {
const year = from.getUTCFullYear();
const clampedDay = (y: number): number =>
Math.min(dayOfMonth, new Date(Date.UTC(y, month, 0)).getUTCDate());
const today = new Date(
Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()),
);
const candidate = new Date(Date.UTC(year, month - 1, clampedDay(year)));
if (candidate.getTime() >= today.getTime()) {
return candidate;
}
return new Date(Date.UTC(year + 1, month - 1, clampedDay(year + 1)));
}
interface DefaultTaskTemplate {
title: string;
description: string;
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
month: number;
dayOfMonth: number;
}
// The standard German-beekeeping annual cycle, one flagship task per month -
// gives a new deployment a ready-made starter calendar instead of a
// completely empty task-template list. Admins can edit/deactivate any of
// these afterward; this only ever runs once (see the count() guard below).
const DEFAULT_TASK_TEMPLATES: DefaultTaskTemplate[] = [
{
title: 'Wintersitz kontrollieren',
description:
'Ruhige Kontrolle von außen: Flugloch frei, Gewicht per Anheben schätzen, auf Mäuse-/Spechtschäden achten. Beute möglichst nicht öffnen.',
priority: 'NORMAL',
month: 1,
dayOfMonth: 15,
},
{
title: 'Gemülldiagnose',
description:
'Windel auswerten: natürlicher Varroa-Totenfall, Bienensitz und Lage der Wintertraube anhand des Gemülls einschätzen.',
priority: 'NORMAL',
month: 2,
dayOfMonth: 15,
},
{
title: 'Frühjahrsdurchsicht',
description:
'Erste Innendurchsicht bei ca. 15°C und ruhigem Wetter: Brutnest, Futtervorrat, Volksstärke und Weiselrichtigkeit prüfen.',
priority: 'HIGH',
month: 3,
dayOfMonth: 15,
},
{
title: 'Volk erweitern',
description:
'Zargen/Rähmchen nach Volksstärke erweitern, Baurahmen einhängen, Platz für die Frühtracht schaffen.',
priority: 'NORMAL',
month: 4,
dayOfMonth: 1,
},
{
title: 'Schwarmkontrolle beginnen',
description:
'Wöchentlich auf Weiselzellen kontrollieren, bis zum Ende der Schwarmzeit fortführen. Maßnahmen bei Bedarf: Ableger bilden, Zellen brechen.',
priority: 'HIGH',
month: 5,
dayOfMonth: 1,
},
{
title: 'Honigernte vorbereiten',
description:
'Reifegrad der Frühtracht prüfen (Spritzprobe), Schleuderplan und Gerätereinigung vorbereiten.',
priority: 'NORMAL',
month: 6,
dayOfMonth: 15,
},
{
title: 'Varroa-Kontrolle nach der Ernte',
description:
'Milbenbefall messen (z. B. Puderzuckermethode oder natürlicher Totenfall) und Behandlung planen, sobald die Honigräume abgeräumt sind.',
priority: 'HIGH',
month: 7,
dayOfMonth: 15,
},
{
title: 'Einfütterung beginnen',
description:
'Wintervorrat auffüttern (Zielgewicht je nach Beutensystem), Futteraufnahme nach ein bis zwei Wochen kontrollieren.',
priority: 'HIGH',
month: 8,
dayOfMonth: 1,
},
{
title: 'Varroa-Restentmilbung / Winterfestigkeit prüfen',
description:
'Zweite Varroabehandlung durchführen, Volksstärke und Weiselrichtigkeit für den Winter beurteilen.',
priority: 'HIGH',
month: 9,
dayOfMonth: 15,
},
{
title: 'Fütterung abschließen, Mäusegitter anbringen',
description:
'Letzte Futtergabe, Fluglochkeile bzw. Mäusegitter montieren, Bienenstand winterfest machen.',
priority: 'NORMAL',
month: 10,
dayOfMonth: 1,
},
{
title: 'Winterbehandlung (Oxalsäure)',
description:
'Brutfreiheit abwarten und Träufel- oder Sprühbehandlung gegen Varroa durchführen.',
priority: 'HIGH',
month: 11,
dayOfMonth: 15,
},
{
title: 'Jahresrückblick und Gerätewartung',
description:
'Saison auswerten, Zargen und Rähmchen reinigen/reparieren, Material für die kommende Saison planen.',
priority: 'LOW',
month: 12,
dayOfMonth: 15,
},
];
async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
const existingCount = await prisma.taskTemplate.count();
if (existingCount > 0) {
return;
}
const now = new Date();
await prisma.taskTemplate.createMany({
data: DEFAULT_TASK_TEMPLATES.map((template) => ({
title: template.title,
description: template.description,
priority: template.priority,
recurrenceType: 'YEARLY',
recurrenceMonth: template.month,
recurrenceDayOfMonth: template.dayOfMonth,
nextDueDate: nextYearlyOccurrenceUtc(
template.month,
template.dayOfMonth,
now,
),
})),
});
console.log(
`Seed: ${DEFAULT_TASK_TEMPLATES.length} Standard-Aufgabenvorlagen angelegt.`,
);
}
async function main(): Promise<void> {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
await seedDefaultTaskTemplates(prisma);
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
const password = process.env.SEED_ADMIN_PASSWORD ?? DEFAULT_ADMIN_PASSWORD;
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
console.log(`Seed: admin user ${email} already exists, skipping.`);
@@ -25,6 +209,7 @@ async function main(): Promise<void> {
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
});
try {
await prisma.user.create({
data: {
email,
@@ -33,7 +218,23 @@ async function main(): Promise<void> {
roleId: adminRole.id,
},
});
console.log(`Seed: created admin user ${email}.`);
printLoginBanner(email, password);
} catch (error: unknown) {
// P2002 = unique constraint violation on email: another replica's
// startup won this race between the existence check above and this
// create() call. The user now exists either way, so this is a no-op,
// not a failure - container startup must not crash on it.
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
console.log(
`Seed: admin user ${email} was created concurrently, skipping.`,
);
} else {
throw error;
}
}
await prisma.$disconnect();
}
@@ -0,0 +1,24 @@
import { PermissionKey } from '../auth/permissions/permission-catalog';
/**
* Permissions an API key may carry. Deliberately a short allowlist, not the
* whole catalog: a key is a long-lived secret stored in another system, so it
* only ever gets what a machine integration needs. shop-sync:write lets the
* shop book its own sales and returns - nothing else - and every booking is
* idempotent and kept in the ledger, so a leaked key cannot erase history.
* erp-inventory:read lets ERPNext read the inventory mirror (articles,
* batches with remaining stock, stock movements, harvest totals) - read
* only, no people, no notes. Removing an entry here also takes it away from
* existing keys (see ApiKeysService.authenticate).
*/
export const API_KEY_SCOPES = [
'shop-sync:read',
'shop-sync:write',
'erp-inventory:read',
] as const satisfies readonly PermissionKey[];
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
export function isApiKeyScope(value: string): value is ApiKeyScope {
return (API_KEY_SCOPES as readonly string[]).includes(value);
}
@@ -0,0 +1,53 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import {
ApiKeySummary,
ApiKeysService,
CreatedApiKey,
} from './api-keys.service';
@ApiTags('api-keys')
@ApiBearerAuth()
@Controller('api-keys')
export class ApiKeysController {
constructor(private readonly apiKeys: ApiKeysService) {}
@RequirePermission('api-keys:manage')
@Get()
findAll(): Promise<ApiKeySummary[]> {
return this.apiKeys.findAll();
}
@RequirePermission('api-keys:manage')
@Post()
create(
@Body() dto: CreateApiKeyDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<CreatedApiKey> {
return this.apiKeys.create(dto, actor.id);
}
@RequirePermission('api-keys:manage')
@Patch(':id')
updateAllowedIps(
@Param('id') id: string,
@Body() dto: UpdateApiKeyDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ApiKeySummary> {
return this.apiKeys.updateAllowedIps(id, dto, actor.id);
}
@RequirePermission('api-keys:manage')
@Post(':id/revoke')
revoke(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ApiKeySummary> {
return this.apiKeys.revoke(id, actor.id);
}
}
@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
@Global()
@Module({
controllers: [ApiKeysController],
providers: [ApiKeysService],
exports: [ApiKeysService],
})
export class ApiKeysModule {}
@@ -0,0 +1,219 @@
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
API_KEY_FAILURE_LIMIT,
ApiKeysService,
extractApiKeyToken,
} from './api-keys.service';
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`;
function storedKey(overrides: Record<string, unknown> = {}) {
return {
id: 'key-1',
name: 'ERPNext',
prefix: 'ack_abcd1234',
keyHash: createHash('sha256').update(TOKEN).digest('hex'),
scopes: ['erp-inventory:read'],
createdByUserId: 'user-1',
createdAt: new Date('2026-10-01T00:00:00.000Z'),
lastUsedAt: null,
lastUsedIp: null,
allowedIps: [],
expiresAt: null,
revokedAt: null,
...overrides,
};
}
describe('ApiKeysService.authenticate', () => {
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
let service: ApiKeysService;
let warn: jest.SpyInstance;
beforeEach(() => {
prisma = {
apiKey: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({}),
},
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
);
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
});
function loggedText(): string {
return JSON.stringify(warn.mock.calls);
}
it('sucht den Schlüssel nur über seinen Hash', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] });
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') },
});
});
it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => {
await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe(
null,
);
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
});
it('lehnt einen unbekannten Schlüssel ab', async () => {
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
});
it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('revoked');
});
it('lehnt einen abgelaufenen Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('expired');
});
it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date() }),
);
await service.authenticate(TOKEN, '203.0.113.7');
await service.authenticate(OTHER_TOKEN, '203.0.113.7');
const text = loggedText();
expect(text).not.toContain(TOKEN);
expect(text).not.toContain(OTHER_TOKEN);
expect(text).not.toContain('A'.repeat(43));
expect(text).not.toContain('B'.repeat(43));
expect(text).toContain('ack_abcd1234');
});
it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ scopes: ['erp-inventory:read', 'users:create'] }),
);
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal?.scopes).toEqual(['erp-inventory:read']);
});
it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ allowedIps: ['198.51.100.0/24'] }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toBeInstanceOf(ForbiddenException);
});
async function failFrom(ip: string, times: number): Promise<void> {
for (let i = 0; i < times; i++) {
// Unbekannter, aber richtig aufgebauter Schlüssel.
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
}
}
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
await expect(blocked).rejects.toBeInstanceOf(HttpException);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
expect(loggedText()).toContain('203.0.113.7');
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
// Andere Adressen sind nicht betroffen.
await expect(
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
).resolves.toBe(null);
});
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
id: 'key-1',
scopes: ['erp-inventory:read'],
});
});
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
null,
);
}
// Weder gesperrt noch eine Datenbankabfrage.
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).resolves.toBe(null);
});
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await service.authenticate(
OTHER_TOKEN,
`2001:db8:1:2::${i.toString(16)}`,
);
}
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
).rejects.toMatchObject({ status: 429 });
// Ein anderes /64 ist frei.
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
).resolves.toBe(null);
});
});
describe('extractApiKeyToken', () => {
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull();
expect(extractApiKeyToken(undefined)).toBeNull();
});
});
@@ -0,0 +1,310 @@
import {
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomBytes, randomInt } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { ApiKey } from '../../generated/prisma';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
return [
...new Set(
(entries ?? [])
.map(normalizeAllowlistEntry)
.filter((entry): entry is string => entry !== null),
),
];
}
// ack_<8 lowercase chars>_<32 random bytes, base64url>
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
// lastUsedAt is informational; writing it on every request would turn each
// read into a write for no benefit.
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
// "ack_" plus the 8-character prefix: identifies a key in logs without
// revealing its secret part.
const LOGGABLE_PREFIX_LENGTH = 12;
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys for the rest of the window - the same
* budget as the shop's ERP export. It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
maxTrackedSources: 10_000,
} as const;
type RejectReason = 'unknown' | 'revoked' | 'expired';
/** What a request authenticated by an API key may do - it is not a user. */
export interface ApiKeyPrincipal {
id: string;
scopes: ApiKeyScope[];
}
/** An API key as shown in the admin UI - never the hash or the key itself. */
export type ApiKeySummary = Omit<ApiKey, 'keyHash'>;
export interface CreatedApiKey {
apiKey: ApiKeySummary;
/** The only time the plain key exists outside the caller's system. */
token: string;
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function randomPrefix(): string {
let prefix = '';
for (let i = 0; i < 8; i++) {
prefix += PREFIX_ALPHABET[randomInt(PREFIX_ALPHABET.length)];
}
return `ack_${prefix}`;
}
function toSummary(apiKey: ApiKey): ApiKeySummary {
const { keyHash, ...summary } = apiKey;
void keyHash;
return summary;
}
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
async findAll(): Promise<ApiKeySummary[]> {
const keys = await this.prisma.apiKey.findMany({
orderBy: { createdAt: 'desc' },
});
return keys.map(toSummary);
}
async create(
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// Nobody hands a machine more than they may do themselves.
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope));
if (missing.length > 0) {
throw new ForbiddenException(
`Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`,
);
}
const prefix = randomPrefix();
// 32 random bytes: a plain hash of it is safe to store without a slow
// KDF - there is nothing guessable to brute-force.
const token = `${prefix}_${randomBytes(32).toString('base64url')}`;
const expiresAt =
dto.expiresInDays !== undefined
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
: null;
const apiKey = await this.prisma.apiKey.create({
data: {
name: dto.name.trim(),
prefix,
keyHash: hashToken(token),
scopes: [...new Set(dto.scopes)],
allowedIps: normalizeAllowlist(dto.allowedIps),
expiresAt,
createdByUserId: actingUserId,
},
});
const summary = toSummary(apiKey);
await this.auditLog.record({
entityType: 'ApiKey',
entityId: apiKey.id,
action: 'CREATE',
userId: actingUserId,
afterData: summary,
});
return { apiKey: summary, token };
}
/** Changes where a key may be used from, without issuing a new one. */
async updateAllowedIps(
id: string,
dto: UpdateApiKeyDto,
actingUserId: string,
): Promise<ApiKeySummary> {
const before = await this.prisma.apiKey.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('API-Schlüssel nicht gefunden');
}
const updated = await this.prisma.apiKey.update({
where: { id },
data: { allowedIps: normalizeAllowlist(dto.allowedIps) },
});
await this.auditLog.record({
entityType: 'ApiKey',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: toSummary(before),
afterData: toSummary(updated),
});
return toSummary(updated);
}
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
const before = await this.prisma.apiKey.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('API-Schlüssel nicht gefunden');
}
if (before.revokedAt) {
return toSummary(before);
}
const revoked = await this.prisma.apiKey.update({
where: { id },
data: { revokedAt: new Date() },
});
await this.auditLog.record({
entityType: 'ApiKey',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: toSummary(before),
afterData: toSummary(revoked),
});
return toSummary(revoked);
}
/**
* Resolves a presented key to what it may do, or null for anything that
* must not pass: malformed, unknown, revoked or expired.
*
* The key is found by the SHA-256 of the presented token through a unique
* index - never by comparing secrets byte by byte, so response timing says
* nothing about the stored key. Order matters: the lookup comes first, and
* only an invalid key counts against (or is stopped by) the failure limit
* of the client network. A malformed token is not a guess at a key and is
* refused without lookup and without counting.
*/
async authenticate(
token: string,
clientIp?: string,
): Promise<ApiKeyPrincipal | null> {
if (!TOKEN_PATTERN.test(token)) {
return null;
}
const apiKey = await this.prisma.apiKey.findUnique({
where: { keyHash: hashToken(token) },
});
const now = new Date();
const reason = rejectReason(apiKey, now);
if (reason === null && apiKey) {
return this.accept(apiKey, clientIp, now);
}
this.recordRejection(token, reason ?? 'unknown', clientIp);
return null;
}
/** Counts an invalid key; 429 once the client network is over the limit. */
private recordRejection(
token: string,
reason: RejectReason,
clientIp: string | undefined,
): void {
// Counted per client network like the throttler (IPv6 /64), so rotating
// addresses inside one's own prefix does not reset the budget.
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
if (this.failures.isBlocked(subject)) {
// Same body shape as the built-in 4xx exceptions (JSON object, not a
// bare string), so clients can read `message` uniformly.
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
error: 'Too Many Requests',
message:
'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen',
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
// Only the public prefix is ever logged, never the key itself.
this.logger.warn(
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
);
if (this.failures.recordFailure(subject)) {
this.logger.warn(
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
);
}
}
private async accept(
apiKey: ApiKey,
clientIp: string | undefined,
now: Date,
): Promise<ApiKeyPrincipal> {
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
// The key itself is right: say so, so the admin finds the cause - a
// thief learns nothing new, they already hold the key.
this.logger.warn(
`API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`,
);
throw new ForbiddenException(
'API-Schlüssel ist für diese IP-Adresse nicht freigegeben',
);
}
if (
!apiKey.lastUsedAt ||
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS ||
apiKey.lastUsedIp !== (clientIp ?? null)
) {
await this.prisma.apiKey.update({
where: { id: apiKey.id },
data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
});
}
// Intersect with today's allowlist: narrowing it later also narrows
// keys issued before.
return { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) };
}
}
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
if (!apiKey) return 'unknown';
if (apiKey.revokedAt) return 'revoked';
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
return null;
}
/** The key from an `Authorization: Bearer ack_…` header, or null. */
export function extractApiKeyToken(
authorization: string | undefined,
): string | null {
if (!authorization?.startsWith('Bearer ack_')) {
return null;
}
return authorization.slice('Bearer '.length).trim();
}
@@ -0,0 +1,17 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { normalizeAllowlistEntry } from '../ip-allowlist';
@ValidatorConstraint({ name: 'allowedIp' })
export class AllowedIpConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
return typeof value === 'string' && normalizeAllowlistEntry(value) !== null;
}
defaultMessage(args: ValidationArguments): string {
return `${String(args.value)} ist keine IP-Adresse und kein Netz (z. B. 203.0.113.7 oder 203.0.113.0/24)`;
}
}
@@ -0,0 +1,58 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
Validate,
} from 'class-validator';
import { API_KEY_SCOPES } from '../api-key-scopes';
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
import { AllowedIpConstraint } from './allowed-ips.validator';
export class CreateApiKeyDto {
@ApiProperty({
description: 'Wofür der Schlüssel ist, z. B. "Wabenhain-Shop"',
})
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
@ApiProperty({ enum: API_KEY_SCOPES, isArray: true })
@IsArray()
@ArrayMinSize(1)
@ArrayUnique()
@IsIn(API_KEY_SCOPES, { each: true })
scopes!: string[];
@ApiPropertyOptional({
description:
'Gültigkeit in Tagen; ohne Angabe gilt der Schlüssel bis zum Widerruf',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(730)
expiresInDays?: number;
@ApiPropertyOptional({
description:
'Erlaubte Absender (IP oder CIDR), z. B. die Adresse des Wabenhain-Servers',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(MAX_ALLOWED_IPS)
@IsString({ each: true })
@Validate(AllowedIpConstraint, { each: true })
allowedIps?: string[];
}
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, IsArray, IsString, Validate } from 'class-validator';
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
import { AllowedIpConstraint } from './allowed-ips.validator';
export class UpdateApiKeyDto {
@ApiProperty({
description:
'Erlaubte Absender (IP oder CIDR); leere Liste = von überall nutzbar',
type: [String],
})
@IsArray()
@ArrayMaxSize(MAX_ALLOWED_IPS)
@IsString({ each: true })
@Validate(AllowedIpConstraint, { each: true })
allowedIps!: string[];
}
@@ -0,0 +1,112 @@
import { FailedAttemptLimiter } from './failed-attempt-limiter';
describe('FailedAttemptLimiter', () => {
let now: number;
let limiter: FailedAttemptLimiter;
beforeEach(() => {
now = 1_000_000;
limiter = new FailedAttemptLimiter({
maxFailures: 3,
windowMs: 60_000,
maxTrackedSources: 100,
clock: () => now,
});
});
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
});
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
limiter.recordFailure('203.0.113.7');
limiter.recordFailure('203.0.113.7');
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
});
it('trennt Quellen voneinander', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
});
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
now += 60_001;
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
});
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
now += 60_001;
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
small.recordFailure('c');
expect(small.trackedSources()).toBe(1);
expect(small.isBlocked('c')).toBe(true);
});
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
const small = new FailedAttemptLimiter({
maxFailures: 5,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
expect(small.trackedSources()).toBe(2);
});
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 2,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('angreifer');
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
small.recordFailure('neu');
expect(small.trackedSources()).toBe(2);
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
expect(small.isBlocked('angreifer')).toBe(true);
// Verdrängt wurde der nicht gesperrte Eintrag.
expect(small.recordFailure('harmlos')).toBe(false);
});
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
expect(small.trackedSources()).toBe(2);
expect(small.isBlocked('a')).toBe(false);
expect(small.isBlocked('b')).toBe(true);
expect(small.isBlocked('c')).toBe(true);
});
});
@@ -0,0 +1,94 @@
export interface FailedAttemptLimiterOptions {
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
maxFailures: number;
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
windowMs: number;
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
maxTrackedSources: number;
clock?: () => number;
}
interface Window {
failures: number;
startedAt: number;
}
/**
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
*
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
* das ist für eine Bremse gegen Durchprobieren ausreichend.
*/
export class FailedAttemptLimiter {
private readonly windows = new Map<string, Window>();
private readonly clock: () => number;
constructor(private readonly options: FailedAttemptLimiterOptions) {
this.clock = options.clock ?? Date.now;
}
isBlocked(source: string): boolean {
const window = this.activeWindow(source);
return window !== undefined && window.failures >= this.options.maxFailures;
}
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
recordFailure(source: string): boolean {
const now = this.clock();
const current = this.activeWindow(source);
if (!current) {
this.makeRoom(now);
}
const failures = (current?.failures ?? 0) + 1;
this.windows.set(source, {
failures,
startedAt: current?.startedAt ?? now,
});
return failures === this.options.maxFailures;
}
trackedSources(): number {
return this.windows.size;
}
private activeWindow(source: string): Window | undefined {
const window = this.windows.get(source);
if (window && this.clock() - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
return undefined;
}
return window;
}
private makeRoom(now: number): void {
if (this.windows.size < this.options.maxTrackedSources) return;
for (const [source, window] of this.windows) {
if (now - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
}
}
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
// die Einfügereihenfolge).
if (this.windows.size >= this.options.maxTrackedSources) {
const victim =
this.oldestWhere(
(window) => window.failures < this.options.maxFailures,
) ?? this.oldestWhere(() => true);
if (victim !== undefined) this.windows.delete(victim);
}
}
private oldestWhere(
matches: (window: Window) => boolean,
): string | undefined {
for (const [source, window] of this.windows) {
if (matches(window)) return source;
}
return undefined;
}
}
@@ -0,0 +1,34 @@
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
describe('ip-allowlist', () => {
it('normalizes addresses and networks', () => {
expect(normalizeAllowlistEntry(' 203.0.113.7 ')).toBe('203.0.113.7');
expect(normalizeAllowlistEntry('203.0.113.0/24')).toBe('203.0.113.0/24');
expect(normalizeAllowlistEntry('2001:db8::/32')).toBe('2001:db8::/32');
expect(normalizeAllowlistEntry('::ffff:203.0.113.7')).toBe('203.0.113.7');
});
it('rejects anything else', () => {
for (const entry of [
'',
'example.com',
'203.0.113.0/33',
'1.2.3.4/x',
'1.2.3.4/8/1',
]) {
expect(normalizeAllowlistEntry(entry)).toBeNull();
}
});
it('allows every address when the list is empty', () => {
expect(isIpAllowed('198.51.100.1', [])).toBe(true);
});
it('allows only listed addresses and networks', () => {
const list = ['203.0.113.7', '198.51.100.0/24'];
expect(isIpAllowed('203.0.113.7', list)).toBe(true);
expect(isIpAllowed('::ffff:198.51.100.42', list)).toBe(true);
expect(isIpAllowed('203.0.113.8', list)).toBe(false);
expect(isIpAllowed(undefined, list)).toBe(false);
});
});
@@ -0,0 +1,66 @@
import * as net from 'node:net';
/** Max entries per key - an allowlist is a short list of servers. */
export const MAX_ALLOWED_IPS = 20;
function unmapIpv4(ip: string): string {
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
return mapped ? mapped[1] : ip;
}
/**
* "203.0.113.7", "203.0.113.0/24", "2001:db8::/32" in canonical form, or
* null if the entry is neither an address nor a network.
*/
export function normalizeAllowlistEntry(entry: string): string | null {
const trimmed = entry.trim();
const [address, prefix, ...rest] = trimmed.split('/');
if (rest.length > 0 || !address) {
return null;
}
const ip = unmapIpv4(address);
const version = net.isIP(ip);
if (version === 0) {
return null;
}
if (prefix === undefined) {
return ip;
}
if (!/^\d{1,3}$/.test(prefix)) {
return null;
}
const bits = Number(prefix);
if (bits > (version === 4 ? 32 : 128)) {
return null;
}
return `${ip}/${bits}`;
}
/** An empty list allows every address. */
export function isIpAllowed(
clientIp: string | undefined,
allowedIps: readonly string[],
): boolean {
if (allowedIps.length === 0) {
return true;
}
if (!clientIp) {
return false;
}
const ip = unmapIpv4(clientIp);
const version = net.isIP(ip);
if (version === 0) {
return false;
}
const list = new net.BlockList();
for (const entry of allowedIps) {
const [address, prefix] = entry.split('/');
const family = net.isIP(address) === 6 ? 'ipv6' : 'ipv4';
if (prefix === undefined) {
list.addAddress(address, family);
} else {
list.addSubnet(address, Number(prefix), family);
}
}
return list.check(ip, version === 6 ? 'ipv6' : 'ipv4');
}
@@ -12,6 +12,7 @@ describe('ApiariesService', () => {
create: jest.Mock;
update: jest.Mock;
};
colony: { count: jest.Mock };
};
let auditLog: { record: jest.Mock };
@@ -30,6 +31,7 @@ describe('ApiariesService', () => {
create: jest.fn(),
update: jest.fn(),
},
colony: { count: jest.fn().mockResolvedValue(0) },
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService(
@@ -76,6 +78,30 @@ describe('ApiariesService', () => {
);
});
it('deactivate counts every living colony, weak and queenless ones included', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue(existingApiary);
await service.deactivate('apiary-1', 'user-1');
expect(prisma.colony.count).toHaveBeenCalledWith({
where: {
apiaryId: 'apiary-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
});
});
it('deactivate throws ConflictException when active colonies remain', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.colony.count.mockResolvedValue(2);
await expect(
service.deactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.apiary.update).not.toHaveBeenCalled();
});
it('reactivate throws ConflictException when already active', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
await expect(
@@ -5,6 +5,7 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { Apiary, ApiaryStatus } from '../../generated/prisma';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
@@ -76,6 +77,19 @@ export class ApiariesService {
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
// Colony.apiaryId is a plain FK, not enforced against apiary status -
// without this guard a colony can silently keep pointing at an
// inactive apiary (still shows ACTIVE, still countable, but its
// "location" has vanished from the active apiary list). Move the
// colonies elsewhere or dissolve them first instead.
const activeColonyCount = await this.prisma.colony.count({
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
});
if (activeColonyCount > 0) {
throw new ConflictException(
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
);
}
const apiary = await this.prisma.apiary.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
@@ -1,4 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateApiaryDto } from './create-apiary.dto';
export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {}
export class UpdateApiaryDto extends PartialType(CreateApiaryDto, {
skipNullProperties: false,
}) {}
+120
View File
@@ -0,0 +1,120 @@
import { Logger } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import {
configureApp,
describeTrustProxy,
trustProxySetting,
} from './app.config';
describe('trustProxySetting', () => {
const original = process.env.TRUST_PROXY;
afterEach(() => {
if (original === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = original;
});
it('defaults to one hop when unset or blank', () => {
delete process.env.TRUST_PROXY;
expect(trustProxySetting()).toBe(1);
process.env.TRUST_PROXY = ' ';
expect(trustProxySetting()).toBe(1);
});
it('parses a number as hop count and anything else as a list', () => {
process.env.TRUST_PROXY = '2';
expect(trustProxySetting()).toBe(2);
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxySetting()).toBe('loopback, 10.42.0.0/16');
});
});
describe('describeTrustProxy', () => {
it('reports the hop count mode and the default fallback', () => {
expect(describeTrustProxy(1, false)).toBe(
'TRUST_PROXY not set, using default of 1 trusted hop',
);
expect(describeTrustProxy(2, true)).toBe('TRUST_PROXY: 2 trusted hop(s)');
});
it('reports the list mode with entry count but not the entries', () => {
const text = describeTrustProxy(
'loopback, 10.42.0.0/16, 2400:cb00::/32',
true,
);
expect(text).toBe('TRUST_PROXY: list of 3 trusted proxy range(s)');
});
});
describe('configureApp trust proxy logging', () => {
it('logs the active mode once at startup', () => {
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
expect(
log.mock.calls.filter(([m]) => String(m).startsWith('TRUST_PROXY')),
).toHaveLength(1);
log.mockRestore();
});
});
describe('configureApp trust proxy warning', () => {
const originalProxy = process.env.TRUST_PROXY;
const originalEnv = process.env.NODE_ENV;
afterEach(() => {
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = originalProxy;
if (originalEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalEnv;
});
function configure(): jest.SpyInstance {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
return warn;
}
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
const calls = warn.mock.calls as unknown[][];
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
}
afterEach(() => jest.restoreAllMocks());
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
process.env.NODE_ENV = 'production';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(1);
});
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
process.env.NODE_ENV = 'production';
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
it('warnt nicht in der Entwicklung', () => {
process.env.NODE_ENV = 'development';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
});
+88 -4
View File
@@ -1,15 +1,99 @@
import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common';
import {
Logger,
RequestMethod,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware';
import { noStoreMiddleware } from './common/middleware/no-store.middleware';
/**
* Which proxies to trust for X-Forwarded-For. It decides the client IP behind
* every rate limit and API-key IP allowlist: trust too little and all users
* share the proxy's IP, trust too much and a client can forge its own.
*
* 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
* count and stays the default when the variable is unset.
*/
export function trustProxySetting(): number | string {
const raw = process.env.TRUST_PROXY?.trim();
if (!raw) {
return 1;
}
return /^\d+$/.test(raw) ? Number(raw) : raw;
}
/** Startup log line for the active mode; never includes the addresses. */
export function describeTrustProxy(
setting: number | string,
isExplicit: boolean,
): string {
if (typeof setting === 'number') {
return isExplicit
? `TRUST_PROXY: ${setting} trusted hop(s)`
: `TRUST_PROXY not set, using default of ${setting} trusted hop`;
}
const entries = setting.split(',').filter((e) => e.trim()).length;
return `TRUST_PROXY: list of ${entries} trusted proxy range(s)`;
}
export function configureApp(app: NestExpressApplication): void {
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the
// Trust the configured proxies (Traefik, Cloudflare) so req.ip reflects the
// real client IP from X-Forwarded-For — required for per-client throttling
// to work correctly rather than rate-limiting all traffic together.
app.set('trust proxy', 1);
app.use(helmet());
const trustProxy = trustProxySetting();
app.set('trust proxy', trustProxy);
const bootstrapLogger = new Logger('Bootstrap');
const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim();
bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit));
if (!trustProxyExplicit && process.env.NODE_ENV === 'production') {
// Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the
// proxy, so rate limits, API-key failure limits and IP allowlists act
// on a shared address instead of the client.
bootstrapLogger.warn(
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
);
}
// No ETags: they let caches and proxies revalidate (and keep) personal
// responses that are meant to be no-store.
app.set('etag', false);
// Default 100kb JSON body limit is too small for the avatar upload, which
// sends the image as a base64 data: URL in the JSON body (not multipart -
// see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers
// the 2MB decoded size cap plus base64 overhead.
app.useBodyParser('json', { limit: '5mb' });
app.use(
helmet({
// This server only answers JSON and files. A strict policy means even
// a response that is somehow rendered as a page can load nothing and
// cannot be framed. The Swagger UI (opt-in) needs scripts, so it keeps
// helmet's default page policy.
contentSecurityPolicy:
process.env.ENABLE_SWAGGER === 'true'
? undefined
: {
useDefaults: false,
directives: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'none'"],
formAction: ["'none'"],
},
},
crossOriginResourcePolicy: { policy: 'same-site' },
}),
);
app.use(noStoreMiddleware);
app.use(cookieParser());
app.use(requestLoggerMiddleware);
app.useGlobalFilters(new AllExceptionsFilter());
app.enableCors({
origin: process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000',
+34 -3
View File
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule } from '@nestjs/throttler';
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
@@ -26,25 +28,39 @@ import { ExportModule } from './export/export.module';
import { QrModule } from './qr/qr.module';
import { ProductsModule } from './products/products.module';
import { BottlingModule } from './bottling/bottling.module';
import { ProductionBatchesModule } from './production-batches/production-batches.module';
import { RawMaterialsModule } from './raw-materials/raw-materials.module';
import { EquipmentModule } from './equipment/equipment.module';
import { CalendarModule } from './calendar/calendar.module';
import { CalendarSyncModule } from './calendar-sync/calendar-sync.module';
import { NotificationsModule } from './notifications/notifications.module';
import { StatisticsModule } from './statistics/statistics.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { DocumentsModule } from './documents/documents.module';
import { AutomationModule } from './automation/automation.module';
import { LabelTemplatesModule } from './label-templates/label-templates.module';
import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { WinterChecksModule } from './winter-checks/winter-checks.module';
import { WeatherModule } from './weather/weather.module';
import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module';
import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
PrismaModule,
PermissionsModule,
@@ -71,24 +87,39 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
QrModule,
ProductsModule,
BottlingModule,
ProductionBatchesModule,
RawMaterialsModule,
EquipmentModule,
CalendarModule,
CalendarSyncModule,
NotificationsModule,
StatisticsModule,
WebhooksModule,
DocumentsModule,
AutomationModule,
LabelTemplatesModule,
ImportModule,
PublicModule,
TaskTemplatesModule,
SwarmControlModule,
WinterChecksModule,
WeatherModule,
ProfileModule,
SiteSettingsModule,
BackupModule,
ShopSyncModule,
ErpExportModule,
ApiKeysModule,
],
controllers: [],
providers: [
// Throttle first: a request with a wrong token or API key must count
// against the limit too, before any database lookup is spent on it.
{ provide: APP_GUARD, useClass: ClientIpThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
IdempotencyCleanupService,
],
})
export class AppModule {}
+39 -45
View File
@@ -7,75 +7,67 @@ import {
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from './decorators/public.decorator';
import { AnyAuthenticatedUser } from './decorators/any-authenticated-user.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import type { AuthenticatedUser } from './types/authenticated-user';
import { LoginDto } from './dto/login.dto';
import { AccessTokenDto } from './dto/access-token.dto';
import { MfaVerifyDto } from './dto/mfa-verify.dto';
import { AuthService, CurrentUserProfile } from './auth.service';
import { parseDurationToMs } from './util/duration';
import { SENSITIVE_ACTION_THROTTLE } from './util/throttle';
import {
clearRefreshTokenCookie,
extractRefreshToken,
setRefreshTokenCookie,
} from './util/refresh-token-cookie';
const REFRESH_TOKEN_COOKIE = 'refreshToken';
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
// Stricter than the global default: bounds brute-force/credential-stuffing
// attempts against login and refresh-token guessing specifically.
const AUTH_THROTTLE = { default: { limit: 10, ttl: 60000 } };
export interface MfaRequiredResponse {
mfaRequired: true;
mfaToken: string;
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
httpOnly: true,
// Explicit opt-out for local HTTP dev only; every real deployment must
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
// over plaintext HTTP.
secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax',
path: REFRESH_TOKEN_COOKIE_PATH,
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
});
}
private clearRefreshTokenCookie(res: Response): void {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
}
private extractRefreshToken(req: Request): string {
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
REFRESH_TOKEN_COOKIE
];
if (!refreshToken) {
throw new UnauthorizedException('Missing refresh token');
}
return refreshToken;
}
@Public()
@Throttle(AUTH_THROTTLE)
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('login')
@HttpCode(HttpStatus.OK)
async login(
@Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto | MfaRequiredResponse> {
const result = await this.authService.login(dto.email, dto.password);
if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken };
}
setRefreshTokenCookie(res, result.refreshToken);
return { accessToken: result.accessToken };
}
@Public()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('mfa/verify')
@HttpCode(HttpStatus.OK)
async verifyMfa(
@Body() dto: MfaVerifyDto,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
const { accessToken, refreshToken } = await this.authService.login(
dto.email,
dto.password,
);
this.setRefreshTokenCookie(res, refreshToken);
const { accessToken, refreshToken } =
await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
@Public()
@Throttle(AUTH_THROTTLE)
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(
@@ -83,24 +75,26 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
const { accessToken, refreshToken } = await this.authService.refresh(
this.extractRefreshToken(req),
extractRefreshToken(req),
);
this.setRefreshTokenCookie(res, refreshToken);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
@Public()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
await this.authService.logout(this.extractRefreshToken(req));
this.clearRefreshTokenCookie(res);
await this.authService.logout(extractRefreshToken(req));
clearRefreshTokenCookie(res);
}
@ApiBearerAuth()
@AnyAuthenticatedUser()
@Get('me')
me(@CurrentUser() user: AuthenticatedUser): Promise<CurrentUserProfile> {
return this.authService.getCurrentUserProfile(user.id);
+13 -3
View File
@@ -5,10 +5,20 @@ import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { OneTimeTokenService } from './one-time-token.service';
import { MfaModule } from './mfa/mfa.module';
import { SsoModule } from './sso/sso.module';
import { SsoController } from './sso/sso.controller';
@Module({
imports: [PassportModule, JwtModule.register({}), UsersModule],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
imports: [
PassportModule,
JwtModule.register({}),
UsersModule,
MfaModule,
SsoModule,
],
controllers: [AuthController, SsoController],
providers: [AuthService, JwtStrategy, OneTimeTokenService],
})
export class AuthModule {}
+271 -9
View File
@@ -4,6 +4,9 @@ import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
import { OneTimeTokenService } from './one-time-token.service';
describe('AuthService', () => {
let authService: AuthService;
@@ -18,17 +21,35 @@ describe('AuthService', () => {
};
let jwtService: { sign: jest.Mock };
let auditLog: { record: jest.Mock };
let permissionResolver: { getPermissions: jest.Mock };
let permissionResolver: {
getPermissions: jest.Mock;
invalidateUser: jest.Mock;
};
let mfaService: {
signChallengeToken: jest.Mock;
verifyChallengeToken: jest.Mock;
};
let oneTimeTokens: { consume: jest.Mock };
let accountGuard: {
isLocked: jest.Mock;
recordFailure: jest.Mock;
recordSuccess: jest.Mock;
consumeTotp: jest.Mock;
};
const activeUser = {
id: 'user-1',
email: '[email protected]',
isActive: true,
passwordHash: '',
mfaEnabled: false,
mfaSecret: null as string | null,
};
beforeEach(async () => {
activeUser.passwordHash = await bcrypt.hash('correct-password', 4);
activeUser.mfaEnabled = false;
activeUser.mfaSecret = null;
usersService = { findByEmail: jest.fn(), findById: jest.fn() };
prisma = {
refreshToken: {
@@ -42,6 +63,18 @@ describe('AuthService', () => {
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
getPermissions: jest.fn().mockResolvedValue(new Set()),
invalidateUser: jest.fn(),
};
mfaService = {
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
verifyChallengeToken: jest.fn(),
};
oneTimeTokens = { consume: jest.fn().mockResolvedValue(undefined) };
accountGuard = {
isLocked: jest.fn().mockReturnValue(false),
recordFailure: jest.fn().mockResolvedValue(undefined),
recordSuccess: jest.fn().mockResolvedValue(undefined),
consumeTotp: jest.fn(),
};
authService = new AuthService(
@@ -50,6 +83,9 @@ describe('AuthService', () => {
jwtService as never,
auditLog as unknown as AuditLogService,
permissionResolver as never,
mfaService as unknown as MfaService,
accountGuard as unknown as AccountGuardService,
oneTimeTokens as unknown as OneTimeTokenService,
);
});
@@ -87,6 +123,9 @@ describe('AuthService', () => {
'correct-password',
);
if (result.mfaRequired) {
throw new Error('expected mfaRequired to be false');
}
expect(result.accessToken).toBe('signed.jwt.token');
expect(result.refreshToken).toEqual(expect.any(String));
const [[createArg]] = prisma.refreshToken.create.mock.calls as [
@@ -98,6 +137,29 @@ describe('AuthService', () => {
);
});
it('returns an MFA challenge instead of tokens when MFA is enabled', async () => {
usersService.findByEmail.mockResolvedValue({
...activeUser,
mfaEnabled: true,
mfaSecret: 'encrypted-secret',
});
const result = await authService.login(
activeUser.email,
'correct-password',
);
if (!result.mfaRequired) {
throw new Error('expected mfaRequired to be true');
}
expect(result.mfaToken).toBe('mfa-challenge-token');
expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN' }),
);
});
it('records a LOGIN_FAILED audit entry on wrong password', async () => {
usersService.findByEmail.mockResolvedValue(activeUser);
await expect(
@@ -111,18 +173,151 @@ describe('AuthService', () => {
);
});
it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => {
it('writes nothing attacker-chosen to the audit log for an unknown email', async () => {
usersService.findByEmail.mockResolvedValue(null);
await expect(
authService.login('[email protected]', 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(auditLog.record).not.toHaveBeenCalled();
});
it('counts a wrong password against the account', async () => {
usersService.findByEmail.mockResolvedValue(activeUser);
await expect(
authService.login(activeUser.email, 'wrong-password'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(accountGuard.recordFailure).toHaveBeenCalledWith(activeUser.id);
});
it('refuses a locked account even with the right password, with the same message', async () => {
usersService.findByEmail.mockResolvedValue(activeUser);
accountGuard.isLocked.mockReturnValue(true);
const lockedMessage = await authService
.login(activeUser.email, 'correct-password')
.then(() => 'no error')
.catch((error: Error) => error.message);
accountGuard.isLocked.mockReturnValue(false);
const wrongMessage = await authService
.login(activeUser.email, 'wrong-password')
.then(() => 'no error')
.catch((error: Error) => error.message);
expect(lockedMessage).not.toBe('no error');
expect(lockedMessage).toBe(wrongMessage);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
});
});
describe('verifyMfaAndLogin', () => {
const mfaUser = {
...activeUser,
mfaEnabled: true,
mfaSecret: 'encrypted-secret',
};
it('issues tokens and records LOGIN when the code is valid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({
userId: mfaUser.id,
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
usersService.findById.mockResolvedValue(mfaUser);
accountGuard.consumeTotp.mockResolvedValue(true);
prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.verifyMfaAndLogin(
'mfa-challenge-token',
'123456',
);
expect(result.accessToken).toBe('signed.jwt.token');
expect(accountGuard.consumeTotp).toHaveBeenCalledWith(mfaUser, '123456');
expect(accountGuard.recordSuccess).toHaveBeenCalledWith(mfaUser.id);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }),
);
});
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({
userId: mfaUser.id,
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
usersService.findById.mockResolvedValue(mfaUser);
accountGuard.consumeTotp.mockResolvedValue(false);
await expect(
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'LOGIN_FAILED',
entityId: '[email protected]',
userId: undefined,
userId: mfaUser.id,
}),
);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
});
it('propagates an expired/invalid challenge token as UnauthorizedException', async () => {
mfaService.verifyChallengeToken.mockImplementation(() => {
throw new UnauthorizedException('expired');
});
await expect(
authService.verifyMfaAndLogin('bad-token', '123456'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(usersService.findById).not.toHaveBeenCalled();
});
});
describe('issueTokensForSsoUser', () => {
it('issues tokens and records LOGIN for an active user', async () => {
usersService.findById.mockResolvedValue(activeUser);
prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.issueTokensForSsoUser(activeUser.id);
if (result.mfaRequired) {
throw new Error('expected mfaRequired to be false');
}
expect(result.accessToken).toBe('signed.jwt.token');
expect(prisma.refreshToken.create).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }),
);
});
it('returns an MFA challenge instead of tokens when the SSO user has local MFA enabled', async () => {
usersService.findById.mockResolvedValue({
...activeUser,
mfaEnabled: true,
mfaSecret: 'encrypted-secret',
});
const result = await authService.issueTokensForSsoUser(activeUser.id);
if (!result.mfaRequired) {
throw new Error('expected mfaRequired to be true');
}
expect(result.mfaToken).toBe('mfa-challenge-token');
expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN' }),
);
});
it('throws when the user is deactivated', async () => {
usersService.findById.mockResolvedValue({
...activeUser,
isActive: false,
});
await expect(
authService.issueTokensForSsoUser(activeUser.id),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
});
});
@@ -134,16 +329,27 @@ describe('AuthService', () => {
);
});
it('throws when the refresh token is revoked', async () => {
it('throws when the refresh token is revoked and revokes all other sessions for that user (reuse detection)', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: new Date(),
// Rotated out a minute ago - well past the parallel-tab window.
revokedAt: new Date(Date.now() - 60_000),
expiresAt: new Date(Date.now() + 1000),
});
prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 });
await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: activeUser.id, revokedAt: null },
}),
);
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
activeUser.id,
);
});
it('throws when the refresh token is expired', async () => {
@@ -158,22 +364,78 @@ describe('AuthService', () => {
);
});
it('does not revoke everything when a parallel tab just rotated the token', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: new Date(Date.now() - 1000),
expiresAt: new Date(Date.now() + 1000),
});
await expect(authService.refresh('just-rotated')).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(prisma.refreshToken.updateMany).not.toHaveBeenCalled();
});
it('gives only one of two concurrent refreshes a successor', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: null,
expiresAt: new Date(Date.now() + 1000),
sessionStartedAt: new Date(),
});
usersService.findById.mockResolvedValue(activeUser);
prisma.refreshToken.updateMany.mockResolvedValue({ count: 0 });
await expect(authService.refresh('raced-token')).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
});
it('never extends a session past seven days from login', async () => {
const startedSixDaysAgo = new Date(Date.now() - 6 * 24 * 3600 * 1000);
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: null,
expiresAt: new Date(Date.now() + 1000),
sessionStartedAt: startedSixDaysAgo,
});
usersService.findById.mockResolvedValue(activeUser);
prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
prisma.refreshToken.create.mockResolvedValue({});
await authService.refresh('valid-token');
const [[createArg]] = prisma.refreshToken.create.mock.calls as [
[{ data: { expiresAt: Date; sessionStartedAt: Date } }],
];
expect(createArg.data.sessionStartedAt).toEqual(startedSixDaysAgo);
expect(createArg.data.expiresAt.getTime()).toBe(
startedSixDaysAgo.getTime() + 7 * 24 * 3600 * 1000,
);
});
it('rotates the refresh token on success', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: null,
expiresAt: new Date(Date.now() + 1000),
sessionStartedAt: new Date(),
});
usersService.findById.mockResolvedValue(activeUser);
prisma.refreshToken.update.mockResolvedValue({});
prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.refresh('valid-token');
expect(result.accessToken).toBe('signed.jwt.token');
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'rt-1' } }),
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'rt-1', revokedAt: null } }),
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
+198 -29
View File
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'node:crypto';
@@ -6,14 +6,21 @@ import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service';
import { AuthenticatedUser } from './types/authenticated-user';
import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
import { OneTimeTokenService } from './one-time-token.service';
import { parseDurationToMs } from './util/duration';
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export type LoginResult =
| ({ mfaRequired: false } & AuthTokens)
| { mfaRequired: true; mfaToken: string };
export interface CurrentUserProfile {
id: string;
email: string;
@@ -21,18 +28,44 @@ export interface CurrentUserProfile {
roleKey: string;
roleName: string;
permissions: string[];
mfaEnabled: boolean;
hasAvatar: boolean;
}
const REFRESH_TOKEN_BYTES = 40;
// Hard cap from the moment of login, however often the session is refreshed.
const DEFAULT_SESSION_MAX_AGE = '7d';
// Two tabs refreshing with the same token at the same moment is normal;
// only a reuse after this window is treated as theft.
const ROTATION_GRACE_MS = 10_000;
// Same answer for unknown email, wrong password, deactivated or locked
// account - nothing in the response tells which one it was.
const INVALID_CREDENTIALS =
'E-Mail oder Passwort falsch. Nach mehreren Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.';
// Hash of a random value nobody knows, at the same cost as real password
// hashes; compared against when there is no real hash, so every login
// costs the same time.
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
crypto.randomBytes(32).toString('hex'),
12,
);
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly usersService: UsersService,
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
private readonly mfaService: MfaService,
private readonly accountGuard: AccountGuardService,
private readonly oneTimeTokens: OneTimeTokenService,
) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
@@ -48,6 +81,8 @@ export class AuthService {
roleKey: user.role.key,
roleName: user.role.name,
permissions: Array.from(permissions),
mfaEnabled: user.mfaEnabled,
hasAvatar: user.avatarPath !== null,
};
}
@@ -55,53 +90,152 @@ export class AuthService {
return crypto.createHash('sha256').update(token).digest('hex');
}
private signAccessToken(user: AuthenticatedUser): string {
private signAccessToken(user: { id: string; email: string }): string {
const expiresInSeconds = Math.floor(
parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000,
);
return this.jwtService.sign(
{ sub: user.id, email: user.email },
{
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: expiresInSeconds,
},
jwtSignOptions(TOKEN_AUDIENCE.access, expiresInSeconds),
);
}
private async issueRefreshToken(userId: string): Promise<string> {
private async issueRefreshToken(
userId: string,
sessionStartedAt: Date = new Date(),
): Promise<string> {
const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex');
const expiresAt = new Date(
const slidingExpiry =
Date.now() +
parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
);
parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d');
const absoluteExpiry =
sessionStartedAt.getTime() +
parseDurationToMs(process.env.SESSION_MAX_AGE ?? DEFAULT_SESSION_MAX_AGE);
await this.prisma.refreshToken.create({
data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt },
data: {
userId,
tokenHash: this.hashRefreshToken(token),
expiresAt: new Date(Math.min(slidingExpiry, absoluteExpiry)),
sessionStartedAt,
},
});
return token;
}
async login(email: string, password: string): Promise<AuthTokens> {
async login(email: string, password: string): Promise<LoginResult> {
const user = await this.usersService.findByEmail(email);
if (!user || !user.isActive) {
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user?.id ?? email,
action: 'LOGIN_FAILED',
userId: user?.id,
});
throw new UnauthorizedException('Invalid credentials');
// bcrypt runs on every path - unknown, deactivated, locked or real - so
// the response time does not reveal which accounts exist.
const passwordMatches = await bcrypt.compare(
password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
);
if (!user) {
this.logger.warn('Login failed: unknown email');
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) {
if (!user.isActive || this.accountGuard.isLocked(user)) {
this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (!passwordMatches) {
this.logger.warn(`Login failed: wrong password for user ${user.id}`);
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (user.mfaEnabled) {
return {
mfaRequired: true,
mfaToken: this.mfaService.signChallengeToken(user.id),
};
}
const authenticatedUser = {
id: user.id,
email: user.email,
};
const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id);
await this.accountGuard.recordSuccess(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN',
userId: user.id,
});
return { mfaRequired: false, accessToken, refreshToken };
}
/**
* Issues our own access/refresh token pair for a user already
* authenticated via SSO. Local MFA is still enforced here, same as
* password login: SSO establishes identity, not the second factor, so a
* user who has explicitly enabled TOTP must complete it here too rather
* than getting it silently skipped by signing in via Microsoft instead.
*/
async issueTokensForSsoUser(userId: string): Promise<LoginResult> {
const user = await this.usersService.findById(userId);
if (!user.isActive) {
throw new UnauthorizedException('Invalid credentials');
}
const authenticatedUser: AuthenticatedUser = {
if (user.mfaEnabled) {
return {
mfaRequired: true,
mfaToken: this.mfaService.signChallengeToken(user.id),
};
}
const accessToken = this.signAccessToken({
id: user.id,
email: user.email,
});
const refreshToken = await this.issueRefreshToken(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN',
userId: user.id,
});
return { mfaRequired: false, accessToken, refreshToken };
}
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
const { userId, claims } = this.mfaService.verifyChallengeToken(mfaToken);
const user = await this.usersService.findById(userId);
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
throw new UnauthorizedException('MFA nicht verfügbar');
}
if (this.accountGuard.isLocked(user)) {
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
const isValid = await this.accountGuard.consumeTotp(user, code);
if (!isValid) {
this.logger.warn(
`MFA verification failed: wrong or reused code for user ${user.id}`,
);
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException('Code ungültig');
}
// A challenge that already opened a session cannot open another one.
await this.oneTimeTokens.consume(claims);
await this.accountGuard.recordSuccess(user.id);
const authenticatedUser = {
id: user.id,
email: user.email,
};
@@ -121,23 +255,58 @@ export class AuthService {
const stored = await this.prisma.refreshToken.findUnique({
where: { tokenHash },
});
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
if (!stored) {
throw new UnauthorizedException('Invalid refresh token');
}
if (
stored.revokedAt &&
Date.now() - stored.revokedAt.getTime() < ROTATION_GRACE_MS
) {
// Lost a race with a parallel refresh (second tab): no new session,
// but no alarm either - that tab simply uses the other's cookie.
throw new UnauthorizedException('Invalid refresh token');
}
if (stored.revokedAt) {
// Reuse of an already-rotated-out refresh token is a strong signal
// it was stolen (a legitimate client only ever presents the newest
// token) - revoke every other active session for this user rather
// than trusting rotation alone to have contained the leak.
await this.prisma.refreshToken.updateMany({
where: { userId: stored.userId, revokedAt: null },
data: { revokedAt: new Date() },
});
this.permissionResolver.invalidateUser(stored.userId);
this.logger.warn(
`Refresh token reuse detected for user ${stored.userId}; all sessions revoked`,
);
throw new UnauthorizedException('Invalid refresh token');
}
if (stored.expiresAt < new Date()) {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.usersService.findById(stored.userId);
if (!user.isActive) {
throw new UnauthorizedException('Invalid refresh token');
}
await this.prisma.refreshToken.update({
where: { id: stored.id },
// Atomic: of two requests presenting the same token, exactly one wins
// and gets a successor - one stolen token can never fork into two live
// sessions.
const claimed = await this.prisma.refreshToken.updateMany({
where: { id: stored.id, revokedAt: null },
data: { revokedAt: new Date() },
});
const authenticatedUser: AuthenticatedUser = {
if (claimed.count !== 1) {
throw new UnauthorizedException('Invalid refresh token');
}
const authenticatedUser = {
id: user.id,
email: user.email,
};
const accessToken = this.signAccessToken(authenticatedUser);
const newRefreshToken = await this.issueRefreshToken(user.id);
const newRefreshToken = await this.issueRefreshToken(
user.id,
stored.sessionStartedAt,
);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
@@ -0,0 +1,16 @@
import { SetMetadata } from '@nestjs/common';
export const ANY_AUTHENTICATED_USER_KEY = 'any-authenticated-user';
/**
* Explicit opt-out of permission checking for a route that is meant to be
* reachable by any logged-in user regardless of role - it acts only on the
* caller's own data (profile, own notifications) or is the auth bootstrap
* endpoint every authenticated user must reach. Required by
* PermissionsGuard, which fails closed on any route carrying neither this
* nor @RequirePermission: a route is either explicitly public, explicitly
* permission-gated, or explicitly opted into "any authenticated user" -
* there is no fourth, silent option.
*/
export const AnyAuthenticatedUser = () =>
SetMetadata(ANY_AUTHENTICATED_USER_KEY, true);
+6 -1
View File
@@ -1,13 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength } from 'class-validator';
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
import { NormalizeEmail } from '../../common/validation/normalize-email';
export class LoginDto {
@ApiProperty()
@NormalizeEmail()
@IsEmail()
email!: string;
@ApiProperty()
@IsString()
@MinLength(8)
// Long enough for any real password; bounds the bcrypt work an
// anonymous request can cause.
@MaxLength(256)
password!: string;
}
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches, MinLength } from 'class-validator';
export class MfaVerifyDto {
@ApiProperty()
@IsString()
@MinLength(1)
mfaToken!: string;
@ApiProperty()
@IsString()
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
code!: string;
}
@@ -2,14 +2,19 @@ import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { ApiKeysService } from '../../api-keys/api-keys.service';
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let reflector: { getAllAndOverride: jest.Mock };
let permissionResolver: { isUserActive: jest.Mock };
let permissionResolver: { isTokenValid: jest.Mock };
let apiKeys: { authenticate: jest.Mock };
function contextWithUser(user: { id: string } | undefined): ExecutionContext {
const request = { user };
function contextWithUser(
user: { id: string; tokenIssuedAt?: number } | undefined,
authorization?: string,
): ExecutionContext {
const request = { user, headers: { authorization } };
return {
getHandler: () => ({}),
getClass: () => ({}),
@@ -19,10 +24,12 @@ describe('JwtAuthGuard', () => {
beforeEach(() => {
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) };
permissionResolver = { isUserActive: jest.fn() };
permissionResolver = { isTokenValid: jest.fn() };
apiKeys = { authenticate: jest.fn() };
guard = new JwtAuthGuard(
reflector as unknown as Reflector,
permissionResolver as unknown as PermissionResolverService,
apiKeys as unknown as ApiKeysService,
);
});
@@ -32,11 +39,11 @@ describe('JwtAuthGuard', () => {
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
true,
);
expect(permissionResolver.isUserActive).not.toHaveBeenCalled();
expect(permissionResolver.isTokenValid).not.toHaveBeenCalled();
});
it('allows the request when the JWT is valid and the user is active', async () => {
const context = contextWithUser({ id: 'user-1' });
it('allows the request when the JWT is valid and the token is still valid for the user', async () => {
const context = contextWithUser({ id: 'user-1', tokenIssuedAt: 1000 });
jest
.spyOn(
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
@@ -45,14 +52,17 @@ describe('JwtAuthGuard', () => {
'canActivate',
)
.mockReturnValue(true);
permissionResolver.isUserActive.mockResolvedValue(true);
permissionResolver.isTokenValid.mockResolvedValue(true);
await expect(guard.canActivate(context)).resolves.toBe(true);
expect(permissionResolver.isUserActive).toHaveBeenCalledWith('user-1');
expect(permissionResolver.isTokenValid).toHaveBeenCalledWith(
'user-1',
1000,
);
});
it('throws UnauthorizedException when the authenticated user has been deactivated', async () => {
const context = contextWithUser({ id: 'user-1' });
it('throws UnauthorizedException when the user is deactivated or the token predates a password change', async () => {
const context = contextWithUser({ id: 'user-1', tokenIssuedAt: 1000 });
jest
.spyOn(
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
@@ -61,10 +71,33 @@ describe('JwtAuthGuard', () => {
'canActivate',
)
.mockReturnValue(true);
permissionResolver.isUserActive.mockResolvedValue(false);
permissionResolver.isTokenValid.mockResolvedValue(false);
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('authenticates an API key as a machine principal, not as a user', async () => {
const principal = { id: 'key-1', scopes: ['shop-sync:read'] };
apiKeys.authenticate.mockResolvedValue(principal);
const context = contextWithUser(undefined, 'Bearer ack_abcdefgh_secret');
await expect(guard.canActivate(context)).resolves.toBe(true);
const request = context.switchToHttp().getRequest<{
apiKey?: unknown;
user?: unknown;
}>();
expect(request.apiKey).toBe(principal);
expect(request.user).toBeUndefined();
expect(permissionResolver.isTokenValid).not.toHaveBeenCalled();
});
it('rejects an unknown, revoked or expired API key', async () => {
apiKeys.authenticate.mockResolvedValue(null);
await expect(
guard.canActivate(contextWithUser(undefined, 'Bearer ack_abcdefgh_x')),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
@@ -8,25 +8,35 @@ import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user';
import {
ApiKeyPrincipal,
ApiKeysService,
extractApiKeyToken,
} from '../../api-keys/api-keys.service';
interface RequestWithUser {
headers: { authorization?: string };
ip?: string;
user?: AuthenticatedUser;
apiKey?: ApiKeyPrincipal;
}
/**
* Enforces User.isActive on every authenticated request, not just ones
* gated by @RequirePermission. Without this, a deactivated user's
* still-valid access token would keep working on any route that carries no
* permission decorator until the token's own expiry (up to
* JWT_ACCESS_EXPIRES_IN). isUserActive shares PermissionsGuard's cache, so
* this check costs no extra query beyond what permission resolution already
* does.
* Enforces User.isActive and token freshness (issued after the last
* password change) on every authenticated request, not just ones gated by
* @RequirePermission. Without this, a deactivated user's still-valid access
* token - or one stolen before a password change - would keep working on
* any route that carries no permission decorator until the token's own
* expiry (up to JWT_ACCESS_EXPIRES_IN). isTokenValid shares
* PermissionsGuard's cache, so this check costs no extra query beyond what
* permission resolution already does.
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(
private readonly reflector: Reflector,
private readonly permissionResolver: PermissionResolverService,
private readonly apiKeys: ApiKeysService,
) {
super();
}
@@ -39,13 +49,34 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
// An API key ("Bearer ack_…") authenticates a machine, not a user: no
// request.user is set, so it can never pass a user-only route, and
// PermissionsGuard checks its scopes instead of a role.
const apiKeyToken = extractApiKeyToken(request.headers.authorization);
if (apiKeyToken !== null) {
// request.ip honours TRUST_PROXY (see app.config.ts).
const principal = await this.apiKeys.authenticate(
apiKeyToken,
request.ip,
);
if (!principal) {
throw new UnauthorizedException();
}
request.apiKey = principal;
return true;
}
const authenticated = (await super.canActivate(context)) as boolean;
if (!authenticated) {
return false;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user || !(await this.permissionResolver.isUserActive(user.id))) {
if (
!user ||
!(await this.permissionResolver.isTokenValid(user.id, user.tokenIssuedAt))
) {
throw new UnauthorizedException();
}
return true;
@@ -0,0 +1,98 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard } from './permissions.guard';
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
describe('PermissionsGuard', () => {
let guard: PermissionsGuard;
let reflectorValues: Map<string, unknown>;
let reflector: { getAllAndOverride: jest.Mock };
let permissionResolver: { hasAny: jest.Mock };
function contextWithUser(user: { id: string } | undefined): ExecutionContext {
const request = { user };
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
beforeEach(() => {
reflectorValues = new Map();
reflector = {
getAllAndOverride: jest.fn((key: string) => reflectorValues.get(key)),
};
permissionResolver = { hasAny: jest.fn() };
guard = new PermissionsGuard(
reflector as unknown as Reflector,
permissionResolver as unknown as PermissionResolverService,
);
});
it('allows a @Public route through with no user on the request (e.g. health checks, login)', async () => {
reflectorValues.set(IS_PUBLIC_KEY, true);
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
true,
);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('denies a route that carries neither @RequirePermission nor @AnyAuthenticatedUser (fails closed)', async () => {
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(false);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('checks the caller permission set when @RequirePermission is present', async () => {
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
permissionResolver.hasAny.mockResolvedValue(true);
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(true);
expect(permissionResolver.hasAny).toHaveBeenCalledWith('user-1', [
'apiaries:read',
]);
});
it('denies when the caller lacks all required permissions', async () => {
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
permissionResolver.hasAny.mockResolvedValue(false);
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(false);
});
it('denies a @RequirePermission route when there is no authenticated user on the request', async () => {
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
false,
);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('allows an authenticated user through a route marked @AnyAuthenticatedUser', async () => {
reflectorValues.set(ANY_AUTHENTICATED_USER_KEY, true);
await expect(
guard.canActivate(contextWithUser({ id: 'user-1' })),
).resolves.toBe(true);
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
});
it('denies @AnyAuthenticatedUser when there is no user on the request', async () => {
reflectorValues.set(ANY_AUTHENTICATED_USER_KEY, true);
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
false,
);
});
});
@@ -1,23 +1,31 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { PermissionKey } from '../permissions/permission-catalog';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user';
import type { ApiKeyPrincipal } from '../../api-keys/api-keys.service';
interface RequestWithUser {
user?: AuthenticatedUser;
apiKey?: ApiKeyPrincipal;
}
/**
* Fail-open by decorator absence: a route with no @RequirePermission is
* allowed through untouched (any authenticated user can reach it), only
* routes that carry the decorator are enforced against the caller's live
* permission set. There is no default-deny here, so
* test/authorization-coverage.e2e-spec.ts — which fails the build if any
* non-public route lacks a catalog-valid @RequirePermission — is the sole
* safety net for "someone added a route and forgot the decorator", not a
* defense-in-depth nicety.
* Fails closed by decorator absence: a route reaches this guard whether or
* not it's @Public (JwtAuthGuard only skips authentication for those, it
* doesn't remove this guard from the chain - NestJS APP_GUARD entries all
* run, there's no early-exit between them), so @Public is checked here too,
* the same way JwtAuthGuard checks it. A non-public route is denied unless
* it carries either @RequirePermission (checked against the caller's live
* permission set) or the explicit @AnyAuthenticatedUser opt-out for routes
* meant to be reachable by any logged-in user regardless of role. A route
* with none of the three is a bug - forgetting to decorate a new route now
* fails shut at runtime instead of silently granting access to every
* authenticated user and relying solely on
* test/authorization-coverage.e2e-spec.ts to catch it before deploy.
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
@@ -27,17 +35,40 @@ export class PermissionsGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<
PermissionKey[]
>(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
// API keys pass only routes that name a permission the key carries -
// never @AnyAuthenticatedUser routes, which are meant for people.
if (request.apiKey) {
const scopes: readonly string[] = request.apiKey.scopes;
return Boolean(
requiredPermissions?.some((permission) => scopes.includes(permission)),
);
}
if (requiredPermissions && requiredPermissions.length > 0) {
if (!user) {
return false;
}
return this.permissionResolver.hasAny(user.id, requiredPermissions);
}
const isAnyAuthenticatedUser = this.reflector.getAllAndOverride<boolean>(
ANY_AUTHENTICATED_USER_KEY,
[context.getHandler(), context.getClass()],
);
return Boolean(isAnyAuthenticatedUser && user);
}
}
@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditLogService } from '../../audit/audit-log.service';
import { MfaService } from './mfa.service';
/** After this many failed attempts in a row the account is locked ... */
export const MAX_FAILED_AUTH_ATTEMPTS = 5;
/** ... for this long. */
export const AUTH_LOCK_MS = 15 * 60 * 1000;
export interface GuardedUser {
id: string;
email: string;
mfaSecret: string | null;
mfaLastTimeStep: number | null;
authLockedUntil: Date | null;
}
/**
* Account-level brute-force protection, on top of the per-IP throttle:
* an attacker spreading guesses over many addresses still only gets five
* tries per account per 15 minutes - for the password, the MFA code and
* step-up confirmations alike. A TOTP code is accepted once: its time step
* is recorded, and that step or any earlier one is refused afterwards.
*/
@Injectable()
export class AccountGuardService {
private readonly logger = new Logger(AccountGuardService.name);
constructor(
private readonly prisma: PrismaService,
private readonly mfaService: MfaService,
private readonly auditLog: AuditLogService,
) {}
isLocked(user: Pick<GuardedUser, 'authLockedUntil'>): boolean {
return Boolean(user.authLockedUntil && user.authLockedUntil > new Date());
}
async recordFailure(userId: string): Promise<void> {
const updated = await this.prisma.user.update({
where: { id: userId },
data: { failedAuthAttempts: { increment: 1 } },
select: { failedAuthAttempts: true },
});
if (updated.failedAuthAttempts >= MAX_FAILED_AUTH_ATTEMPTS) {
await this.prisma.user.update({
where: { id: userId },
data: {
authLockedUntil: new Date(Date.now() + AUTH_LOCK_MS),
failedAuthAttempts: 0,
},
});
this.logger.warn(`Account ${userId} locked after repeated failures`);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: userId,
action: 'ACCOUNT_LOCKED',
userId,
});
}
}
async recordSuccess(userId: string): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: { failedAuthAttempts: 0, authLockedUntil: null },
});
}
/**
* True only for a valid code whose time step is newer than the last one
* used - and only for one of two concurrent requests with the same code.
*/
async consumeTotp(user: GuardedUser, code: string): Promise<boolean> {
if (!user.mfaSecret) {
return false;
}
const timeStep = await this.mfaService.verifyCodeStep(
user.mfaSecret,
user.email,
code,
user.mfaLastTimeStep ?? undefined,
);
if (timeStep === null) {
return false;
}
const claimed = await this.prisma.user.updateMany({
where: {
id: user.id,
OR: [{ mfaLastTimeStep: null }, { mfaLastTimeStep: { lt: timeStep } }],
},
data: { mfaLastTimeStep: timeStep },
});
return claimed.count === 1;
}
}
@@ -0,0 +1,37 @@
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
describe('mfa-crypto.util', () => {
const originalSecret = process.env.JWT_ACCESS_SECRET;
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
});
afterEach(() => {
process.env.JWT_ACCESS_SECRET = originalSecret;
});
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('produces a different ciphertext every time (random IV)', () => {
const plaintext = 'JBSWY3DPEHPK3PXP';
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(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();
});
it('rejects a malformed ciphertext', () => {
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
'Invalid MFA secret ciphertext',
);
});
});
@@ -0,0 +1,53 @@
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();
}
/**
* 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.
*/
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('.');
}
export function decryptMfaSecret(ciphertext: string): string {
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
if (!ivB64 || !authTagB64 || !dataB64) {
throw new Error('Invalid MFA secret ciphertext');
}
const decipher = createDecipheriv(
ALGORITHM,
getKey(),
Buffer.from(ivB64, 'base64'),
);
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(dataB64, 'base64')),
decipher.final(),
]).toString('utf8');
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { MfaService } from './mfa.service';
import { AccountGuardService } from './account-guard.service';
@Module({
imports: [JwtModule.register({})],
providers: [MfaService, AccountGuardService],
exports: [MfaService, AccountGuardService],
})
export class MfaModule {}
@@ -0,0 +1,185 @@
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { TOTP, NobleCryptoPlugin, ScureBase32Plugin } from 'otplib';
import { MfaService } from './mfa.service';
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import { generateTotpSecret } from './totp.util';
const crypto = new NobleCryptoPlugin();
const base32 = new ScureBase32Plugin();
describe('MfaService', () => {
let mfaService: MfaService;
let jwtService: { sign: jest.Mock; verify: jest.Mock };
const originalSecret = process.env.JWT_ACCESS_SECRET;
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
jwtService = { sign: jest.fn(), verify: jest.fn() };
mfaService = new MfaService(jwtService as unknown as JwtService);
});
afterEach(() => {
process.env.JWT_ACCESS_SECRET = originalSecret;
});
describe('startEnrollment', () => {
it('returns a secret, QR SVG, otpauth URI, and a signed setup token', async () => {
jwtService.sign.mockReturnValue('signed-setup-token');
const enrollment = await mfaService.startEnrollment(
'user-1',
'[email protected]',
);
expect(enrollment.secret).toMatch(/^[A-Z2-7]+$/);
expect(enrollment.qrCodeSvg).toContain('<svg');
expect(enrollment.otpauthUri).toContain('otpauth://totp/');
expect(enrollment.setupToken).toBe('signed-setup-token');
expect(jwtService.sign).toHaveBeenCalledWith(
expect.objectContaining({
sub: 'user-1',
secret: enrollment.secret,
purpose: 'mfa-setup',
}),
expect.objectContaining({ expiresIn: '10m' }),
);
});
});
describe('confirmEnrollment', () => {
async function totpCodeFor(secret: string): Promise<string> {
return new TOTP({ secret, crypto, base32 }).generate();
}
it('encrypts and returns the secret when the code is valid', async () => {
const secret = generateTotpSecret();
jwtService.verify.mockReturnValue({
sub: 'user-1',
secret,
purpose: 'mfa-setup',
});
const code = await totpCodeFor(secret);
const { encryptedSecret } = await mfaService.confirmEnrollment(
'user-1',
'setup-token',
'[email protected]',
code,
);
expect(decryptMfaSecret(encryptedSecret)).toBe(secret);
});
it('throws when the code does not match the secret', async () => {
const secret = generateTotpSecret();
jwtService.verify.mockReturnValue({
sub: 'user-1',
secret,
purpose: 'mfa-setup',
});
await expect(
mfaService.confirmEnrollment(
'user-1',
'setup-token',
'[email protected]',
'000000',
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws when the setup token belongs to a different user', async () => {
jwtService.verify.mockReturnValue({
sub: 'someone-else',
secret: generateTotpSecret(),
purpose: 'mfa-setup',
});
await expect(
mfaService.confirmEnrollment(
'user-1',
'setup-token',
'[email protected]',
'123456',
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws when the token is expired or invalid', async () => {
jwtService.verify.mockImplementation(() => {
throw new Error('jwt expired');
});
await expect(
mfaService.confirmEnrollment(
'user-1',
'expired-token',
'[email protected]',
'123456',
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
describe('challenge tokens', () => {
it('signs a challenge token for login-time MFA verification', () => {
jwtService.sign.mockReturnValue('signed-challenge-token');
const token = mfaService.signChallengeToken('user-1');
expect(token).toBe('signed-challenge-token');
expect(jwtService.sign).toHaveBeenCalledWith(
expect.objectContaining({ sub: 'user-1', purpose: 'mfa-challenge' }),
expect.objectContaining({ expiresIn: '5m' }),
);
});
it('verifies a valid challenge token', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'mfa-challenge',
jti: 'jti-1',
exp: 1_900_000_000,
});
expect(mfaService.verifyChallengeToken('token')).toEqual({
userId: 'user-1',
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
});
it('throws on an expired/invalid challenge token', () => {
jwtService.verify.mockImplementation(() => {
throw new Error('jwt expired');
});
expect(() => mfaService.verifyChallengeToken('bad-token')).toThrow(
UnauthorizedException,
);
});
it('throws when the token purpose does not match', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'mfa-setup',
});
expect(() => mfaService.verifyChallengeToken('token')).toThrow(
UnauthorizedException,
);
});
});
describe('verifyCode', () => {
it('decrypts the stored secret and verifies the code', async () => {
const secret = generateTotpSecret();
const encryptedSecret = encryptMfaSecret(secret);
const code = await new TOTP({ secret, crypto, base32 }).generate();
await expect(
mfaService.verifyCode(encryptedSecret, '[email protected]', code),
).resolves.toBe(true);
});
});
});
+141
View File
@@ -0,0 +1,141 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as QRCode from 'qrcode';
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import {
buildTotpUri,
generateTotpSecret,
verifyTotpCode,
verifyTotpStep,
} from './totp.util';
import {
OneTimeTokenClaims,
TOKEN_AUDIENCE,
jwtSignOptions,
jwtVerifyOptions,
} from '../util/jwt-claims';
const SETUP_TOKEN_TTL = '10m';
const CHALLENGE_TOKEN_TTL = '5m';
const SETUP_TOKEN_PURPOSE = 'mfa-setup';
const CHALLENGE_TOKEN_PURPOSE = 'mfa-challenge';
interface SetupTokenPayload {
sub: string;
secret: string;
purpose: typeof SETUP_TOKEN_PURPOSE;
}
interface ChallengeTokenPayload {
sub: string;
purpose: typeof CHALLENGE_TOKEN_PURPOSE;
}
export interface MfaEnrollment {
secret: string;
qrCodeSvg: string;
otpauthUri: string;
setupToken: string;
}
@Injectable()
export class MfaService {
constructor(private readonly jwtService: JwtService) {}
async startEnrollment(userId: string, email: string): Promise<MfaEnrollment> {
const secret = generateTotpSecret();
const otpauthUri = buildTotpUri(secret, email);
const qrCodeSvg = await QRCode.toString(otpauthUri, {
type: 'svg',
margin: 1,
width: 256,
});
const setupToken = this.jwtService.sign(
{ sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE },
jwtSignOptions(TOKEN_AUDIENCE.mfaSetup, SETUP_TOKEN_TTL),
);
return { secret, qrCodeSvg, otpauthUri, setupToken };
}
/** Returns the plaintext secret from a setup token once the enrollment code checks out. */
async confirmEnrollment(
userId: string,
setupToken: string,
email: string,
code: string,
): Promise<{ encryptedSecret: string }> {
let payload: SetupTokenPayload;
try {
payload = this.jwtService.verify<SetupTokenPayload>(
setupToken,
jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup),
);
} catch {
throw new UnauthorizedException(
'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten',
);
}
if (payload.purpose !== SETUP_TOKEN_PURPOSE || payload.sub !== userId) {
throw new UnauthorizedException('Setup-Token ungültig');
}
const isValid = await verifyTotpCode(payload.secret, email, code);
if (!isValid) {
throw new UnauthorizedException('Code ungültig');
}
return { encryptedSecret: encryptMfaSecret(payload.secret) };
}
signChallengeToken(userId: string): string {
return this.jwtService.sign(
{ sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE },
jwtSignOptions(TOKEN_AUDIENCE.mfaChallenge, CHALLENGE_TOKEN_TTL),
);
}
verifyChallengeToken(challengeToken: string): {
userId: string;
claims: OneTimeTokenClaims;
} {
let payload: ChallengeTokenPayload & OneTimeTokenClaims;
try {
payload = this.jwtService.verify<
ChallengeTokenPayload & OneTimeTokenClaims
>(challengeToken, jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge));
} catch {
throw new UnauthorizedException(
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
);
}
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token');
}
return {
userId: payload.sub,
claims: { jti: payload.jti, exp: payload.exp },
};
}
/** See verifyTotpStep; use AccountGuardService.consumeTotp for logins. */
async verifyCodeStep(
encryptedSecret: string,
email: string,
code: string,
afterTimeStep?: number,
): Promise<number | null> {
return verifyTotpStep(
decryptMfaSecret(encryptedSecret),
email,
code,
afterTimeStep,
);
}
async verifyCode(
encryptedSecret: string,
email: string,
code: string,
): Promise<boolean> {
const secret = decryptMfaSecret(encryptedSecret);
return verifyTotpCode(secret, email, code);
}
}
@@ -0,0 +1,48 @@
import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib';
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util';
const crypto = new NobleCryptoPlugin();
const base32 = new ScureBase32Plugin();
describe('totp.util', () => {
it('generates a base32 secret', () => {
const secret = generateTotpSecret();
expect(secret).toMatch(/^[A-Z2-7]+$/);
});
it('builds an otpauth:// URI carrying the issuer and label', () => {
const uri = buildTotpUri('JBSWY3DPEHPK3PXP', '[email protected]');
expect(uri).toContain('otpauth://totp/');
expect(uri).toContain('ApiCore');
expect(uri).toContain('secret=JBSWY3DPEHPK3PXP');
});
it('verifies a code generated for the same secret', async () => {
const secret = generateTotpSecret();
const totp = new TOTP({ secret, crypto, base32 });
const code = await totp.generate();
await expect(
verifyTotpCode(secret, '[email protected]', code),
).resolves.toBe(true);
});
it('rejects an incorrect code', async () => {
const secret = generateTotpSecret();
const totp = new TOTP({ secret, crypto, base32 });
const validCode = await totp.generate();
const wrongCode = validCode === '000000' ? '111111' : '000000';
await expect(
verifyTotpCode(secret, '[email protected]', wrongCode),
).resolves.toBe(false);
});
it('rejects a code generated for a different secret', async () => {
const secretA = generateTotpSecret();
const secretB = generateTotpSecret();
const totpB = new TOTP({ secret: secretB, crypto, base32 });
const codeFromB = await totpB.generate();
await expect(
verifyTotpCode(secretA, '[email protected]', codeFromB),
).resolves.toBe(false);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib';
const ISSUER = 'ApiCore';
// Allows one 30s step of clock drift in either direction between the
// authenticator app and the server, matching common 2FA implementations.
const EPOCH_TOLERANCE_SECONDS = 30;
const crypto = new NobleCryptoPlugin();
const base32 = new ScureBase32Plugin();
function createTotp(secret: string, label: string): TOTP {
return new TOTP({ secret, label, issuer: ISSUER, crypto, base32 });
}
export function generateTotpSecret(): string {
return new TOTP({ crypto, base32 }).generateSecret();
}
export function buildTotpUri(secret: string, label: string): string {
return createTotp(secret, label).toURI();
}
/**
* The time step a code belongs to, or null if it is wrong - or not newer
* than afterTimeStep (a code that was already used, or an older one).
*/
export async function verifyTotpStep(
secret: string,
label: string,
code: string,
afterTimeStep?: number,
): Promise<number | null> {
const result = await createTotp(secret, label).verify(code, {
epochTolerance: EPOCH_TOLERANCE_SECONDS,
afterTimeStep,
});
return result.valid ? result.timeStep : null;
}
export async function verifyTotpCode(
secret: string,
label: string,
code: string,
): Promise<boolean> {
const result = await createTotp(secret, label).verify(code, {
epochTolerance: EPOCH_TOLERANCE_SECONDS,
});
return result.valid;
}
@@ -0,0 +1,38 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '../../generated/prisma';
import { OneTimeTokenClaims } from './util/jwt-claims';
/**
* Makes a short-lived helper token usable exactly once (SSO completion,
* MFA challenge after a successful code): its id is recorded, and a
* second presentation - a replayed request, a copied cookie - is refused.
* Works across replicas because the record lives in the database.
*/
@Injectable()
export class OneTimeTokenService {
constructor(private readonly prisma: PrismaService) {}
async consume(claims: OneTimeTokenClaims): Promise<void> {
if (!claims.jti || !claims.exp) {
throw new UnauthorizedException(
'Anmeldung abgelaufen, bitte erneut anmelden',
);
}
try {
await this.prisma.consumedToken.create({
data: { jti: claims.jti, expiresAt: new Date(claims.exp * 1000) },
});
} catch (error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new UnauthorizedException(
'Diese Anmeldung wurde bereits abgeschlossen, bitte erneut anmelden',
);
}
throw error;
}
}
}
@@ -0,0 +1,98 @@
import { PermissionCatalogSyncService } from './permission-catalog-sync.service';
import { PrismaService } from '../../prisma/prisma.service';
import { PERMISSION_CATALOG } from './permission-catalog';
describe('PermissionCatalogSyncService', () => {
let service: PermissionCatalogSyncService;
let prisma: {
permission: { findMany: jest.Mock; createMany: jest.Mock };
role: { findMany: jest.Mock };
rolePermission: { createMany: jest.Mock };
};
beforeEach(() => {
prisma = {
permission: {
// 1st call: existing keys (set per test). 2nd call: ids of the
// freshly inserted keys, derived from the `where.key.in` filter.
findMany: jest.fn(),
createMany: jest.fn().mockResolvedValue({ count: 0 }),
},
role: { findMany: jest.fn().mockResolvedValue([]) },
rolePermission: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
service = new PermissionCatalogSyncService(
prisma as unknown as PrismaService,
);
});
it('does nothing when every catalog permission already exists', async () => {
prisma.permission.findMany.mockResolvedValue(
PERMISSION_CATALOG.map((entry) => ({ key: entry.key })),
);
await service.onModuleInit();
expect(prisma.permission.createMany).not.toHaveBeenCalled();
expect(prisma.role.findMany).not.toHaveBeenCalled();
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled();
});
it('inserts missing catalog permissions and grants them to every system role', async () => {
const [first, ...rest] = PERMISSION_CATALOG;
prisma.permission.findMany
.mockResolvedValueOnce(rest.map((entry) => ({ key: entry.key })))
.mockResolvedValueOnce([{ id: `perm-${first.key}` }]);
prisma.role.findMany.mockResolvedValue([
{ id: 'role-admin' },
{ id: 'role-owner' },
]);
await service.onModuleInit();
expect(prisma.permission.createMany).toHaveBeenCalledWith({
data: [first],
skipDuplicates: true,
});
expect(prisma.role.findMany).toHaveBeenCalledWith({
where: { isSystem: true },
select: { id: true },
});
expect(prisma.rolePermission.createMany).toHaveBeenCalledWith({
data: [
{ roleId: 'role-admin', permissionId: `perm-${first.key}` },
{ roleId: 'role-owner', permissionId: `perm-${first.key}` },
],
skipDuplicates: true,
});
});
it('skips the role grant step when no system role exists', async () => {
prisma.permission.findMany.mockResolvedValue([]);
prisma.role.findMany.mockResolvedValue([]);
await service.onModuleInit();
expect(prisma.permission.createMany).toHaveBeenCalledWith({
data: PERMISSION_CATALOG,
skipDuplicates: true,
});
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled();
});
it('tolerates another replica inserting the same permissions concurrently', async () => {
// Both replicas saw the permission as missing; the other one won the
// insert, so createMany skips it instead of throwing a unique error.
const [first, ...rest] = PERMISSION_CATALOG;
prisma.permission.findMany
.mockResolvedValueOnce(rest.map((entry) => ({ key: entry.key })))
.mockResolvedValueOnce([{ id: `perm-${first.key}` }]);
prisma.role.findMany.mockResolvedValue([{ id: 'role-admin' }]);
await expect(service.onModuleInit()).resolves.toBeUndefined();
expect(prisma.rolePermission.createMany).toHaveBeenCalledWith({
data: [{ roleId: 'role-admin', permissionId: `perm-${first.key}` }],
skipDuplicates: true,
});
});
});
@@ -9,6 +9,14 @@ import { PERMISSION_CATALOG } from './permission-catalog';
* catalog entry missing from the database. Never deletes a Permission row
* that fell out of the catalog (only warns) — a role's grants must not
* silently disappear because of a typo or a reordered catalog edit.
*
* Every newly-synced permission is also granted to every `isSystem` role
* (ADMIN's seed row documents itself as "Voller Zugriff auf alle Bereiche" -
* full access to every area). Without this, a role that legitimately means
* "everything" would silently lose that guarantee each time a new feature
* ships a new permission, forcing a manual grant in Rollen & Rechte before
* the feature is usable at all - exactly the gap that made the freshly
* shipped Etiketten feature invisible to the seeded admin account.
*/
@Injectable()
export class PermissionCatalogSyncService implements OnModuleInit {
@@ -25,15 +33,42 @@ export class PermissionCatalogSyncService implements OnModuleInit {
const missing = PERMISSION_CATALOG.filter(
(entry) => !existingKeys.has(entry.key),
);
for (const entry of missing) {
await this.prisma.permission.create({ data: entry });
}
if (missing.length > 0) {
// skipDuplicates: several replicas boot at once against the same
// database and all see the same "missing" set. Plain creates made
// every replica but the first crash on a unique-key error.
await this.prisma.permission.createMany({
data: missing,
skipDuplicates: true,
});
const createdPermissions = await this.prisma.permission.findMany({
where: { key: { in: missing.map((entry) => entry.key) } },
select: { id: true },
});
this.logger.log(
`Synced ${missing.length} new permission(s) from the code catalog: ${missing
.map((entry) => entry.key)
.join(', ')}`,
);
const systemRoles = await this.prisma.role.findMany({
where: { isSystem: true },
select: { id: true },
});
if (systemRoles.length > 0) {
await this.prisma.rolePermission.createMany({
data: systemRoles.flatMap((role) =>
createdPermissions.map((permission) => ({
roleId: role.id,
permissionId: permission.id,
})),
),
skipDuplicates: true,
});
this.logger.log(
`Granted ${missing.length} new permission(s) to ${systemRoles.length} system role(s).`,
);
}
}
const catalogKeys = new Set(PERMISSION_CATALOG.map((entry) => entry.key));
@@ -111,6 +111,18 @@ export const PERMISSION_CATALOG = [
],
800,
),
// Seeded with role grants by the winter_checks migration (everyday records
// like inspections, not an admin-only area).
...group(
'Überwinterung',
'winter-checks',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
850,
),
...group(
'Behandlungen',
'treatments',
@@ -157,6 +169,7 @@ export const PERMISSION_CATALOG = [
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['relocate', 'Umlagern'],
['measure', 'Messwerte erfassen'],
],
@@ -168,6 +181,7 @@ export const PERMISSION_CATALOG = [
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['move', 'Bewegung buchen'],
],
1400,
@@ -189,9 +203,47 @@ export const PERMISSION_CATALOG = [
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
['withdraw', 'Entnehmen und Inventur'],
],
1600,
),
...group(
'Rohstoffe',
'raw-material',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
['withdraw', 'Entnehmen und Inventur'],
],
1630,
),
...group(
'Herstellungschargen',
'production-batch',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
['withdraw', 'Entnehmen und Inventur'],
],
1650,
),
...group(
'Etiketten',
'label-templates',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
['print', 'Drucken'],
],
1650,
),
...group(
'Inventar',
'equipment',
@@ -208,6 +260,7 @@ export const PERMISSION_CATALOG = [
[
['read', 'Ansehen'],
['upload', 'Hochladen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
],
1800,
@@ -218,6 +271,7 @@ export const PERMISSION_CATALOG = [
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
],
1900,
@@ -241,6 +295,7 @@ export const PERMISSION_CATALOG = [
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
['assign-role', 'Rolle zuweisen'],
],
2300,
),
@@ -256,11 +311,65 @@ export const PERMISSION_CATALOG = [
2400,
),
...group('Audit-Log', 'audit', [['read', 'Ansehen']], 2500),
...group('Kalender', 'calendar', [['read', 'Ansehen']], 2600),
...group(
'Kalender',
'calendar',
[
['read', 'Ansehen'],
['configure', 'M365-Synchronisation konfigurieren'],
],
2600,
),
...group('Dashboard', 'dashboard', [['read', 'Ansehen']], 2700),
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
...group('QR-Codes', 'qr', [['read', 'Ansehen']], 3000),
...group(
'Website-Einstellungen',
'site-settings',
[
['read', 'Ansehen'],
['update', 'Bearbeiten'],
],
3100,
),
...group(
'Backup',
'backup',
[
['export', 'Exportieren'],
['import', 'Importieren'],
['configure', 'Zeitplan konfigurieren'],
],
3200,
),
// Deliberately separate from harvests:read etc.: those endpoints return
// internal notes and full records. The shop sync account needs only the
// allowlisted feed, so it gets this one permission and nothing else.
...group(
'Shop-Synchronisation',
'shop-sync',
[
['read', 'Freigegebene Daten für den Shop lesen'],
['write', 'Verkäufe und Rückbuchungen des Shops buchen'],
],
3300,
),
...group(
'API-Schlüssel',
'api-keys',
[['manage', 'Anlegen, ansehen und widerrufen']],
3400,
),
// Like shop-sync: a narrow read-only allowlist for one machine consumer
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
// which return descriptions and full records.
...group(
'ERP-Anbindung',
'erp-inventory',
[['read', 'Inventar-Spiegel für ERPNext lesen']],
3500,
),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -8,6 +8,7 @@ describe('PermissionResolverService', () => {
function activeUserWithPermissions(...keys: string[]) {
return {
isActive: true,
passwordChangedAt: null as Date | null,
role: {
status: 'ACTIVE',
permissions: keys.map((key) => ({ permission: { key } })),
@@ -93,54 +94,105 @@ describe('PermissionResolverService', () => {
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
describe('isUserActive', () => {
it('returns true for an active user with an active role', async () => {
describe('isTokenValid', () => {
const nowSeconds = Math.floor(Date.now() / 1000);
it('returns true for an active user with no recorded password change', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.isUserActive('user-1')).resolves.toBe(true);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
true,
);
});
it('returns false for a deactivated user', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: false,
passwordChangedAt: null,
role: {
status: 'ACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
await expect(resolver.isUserActive('user-1')).resolves.toBe(false);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
false,
);
});
it('returns false when the role is inactive', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: true,
passwordChangedAt: null,
role: {
status: 'INACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
await expect(resolver.isUserActive('user-1')).resolves.toBe(false);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
false,
);
});
it('returns false when the user does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
await expect(resolver.isUserActive('missing')).resolves.toBe(false);
await expect(resolver.isTokenValid('missing', nowSeconds)).resolves.toBe(
false,
);
});
it('shares the cached entry with getPermissions instead of re-querying', async () => {
it('returns false when the token was issued before the last password change', async () => {
const entry = activeUserWithPermissions('colonies:read');
entry.passwordChangedAt = new Date((nowSeconds + 60) * 1000);
prisma.user.findUnique.mockResolvedValue(entry);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
false,
);
});
it('returns true when the token was issued after the last password change', async () => {
const entry = activeUserWithPermissions('colonies:read');
entry.passwordChangedAt = new Date((nowSeconds - 60) * 1000);
prisma.user.findUnique.mockResolvedValue(entry);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
true,
);
});
it('reads fresh every time and refreshes the cache for getPermissions', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
await resolver.isUserActive('user-1');
await resolver.isTokenValid('user-1', nowSeconds);
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
// One read for the first getPermissions, one fresh read for the
// validity check - the second getPermissions uses that fresh entry.
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
it('sees a deactivation made elsewhere on the next request', async () => {
prisma.user.findUnique.mockResolvedValueOnce(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
true,
);
prisma.user.findUnique.mockResolvedValueOnce({
...activeUserWithPermissions('colonies:read'),
isActive: false,
});
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
false,
);
});
});
@@ -4,6 +4,7 @@ import { PermissionKey } from './permission-catalog';
interface CacheEntry {
isActive: boolean;
passwordChangedAt: Date | null;
permissions: ReadonlySet<string>;
expiresAt: number;
}
@@ -16,13 +17,14 @@ const CACHE_TTL_MS = 30_000;
* role's grants takes effect without forcing a re-login), with a short TTL
* cache to keep that from becoming a query-per-request cost. Call
* invalidateUser/invalidateAll after any write to Role, Permission,
* RolePermission, or User.roleId so the change is visible immediately
* instead of waiting out the cache window.
* RolePermission, User.roleId, or User.passwordChangedAt so the change is
* visible immediately instead of waiting out the cache window.
*
* isUserActive shares the same cached entry: JwtAuthGuard calls it on every
* isTokenValid shares the same cached entry: JwtAuthGuard calls it on every
* authenticated request (including routes with no @RequirePermission) so a
* deactivated user's still-valid access token stops working within the
* cache TTL instead of staying usable until the token expires.
* cache TTL instead of staying usable until the token expires, and so does
* a token issued before the user's last password change.
*/
@Injectable()
export class PermissionResolverService {
@@ -35,9 +37,25 @@ export class PermissionResolverService {
return entry.permissions;
}
async isUserActive(userId: string): Promise<boolean> {
const entry = await this.getEntry(userId);
return entry.isActive;
/**
* Always read fresh (one primary-key lookup) and stored as the cache
* entry the PermissionsGuard then uses: a deactivation, role change or
* password change made on ANOTHER replica takes effect on the very next
* request here, not after the cache window.
*/
async isTokenValid(
userId: string,
tokenIssuedAtSeconds: number,
): Promise<boolean> {
const entry = await this.loadEntry(userId);
this.cache.set(userId, entry);
if (!entry.isActive) {
return false;
}
if (!entry.passwordChangedAt) {
return true;
}
return tokenIssuedAtSeconds * 1000 >= entry.passwordChangedAt.getTime();
}
async hasAny(
@@ -74,6 +92,7 @@ export class PermissionResolverService {
where: { id: userId },
select: {
isActive: true,
passwordChangedAt: true,
role: {
select: {
status: true,
@@ -92,6 +111,7 @@ export class PermissionResolverService {
: new Set<string>();
return {
isActive: isEligible,
passwordChangedAt: user?.passwordChangedAt ?? null,
permissions,
expiresAt: Date.now() + CACHE_TTL_MS,
};
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MfaModule } from '../mfa/mfa.module';
import { ReauthService } from './reauth.service';
@Module({
imports: [MfaModule],
providers: [ReauthService],
exports: [ReauthService],
})
export class ReauthModule {}
@@ -0,0 +1,65 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountGuardService } from '../mfa/account-guard.service';
export interface ReauthProof {
password?: string;
mfaCode?: string;
}
/**
* Step-up check for actions that are as powerful as the account itself
* (restoring a backup overwrites every user): a stolen access token alone
* must not be enough, so the caller proves they know the password - and,
* with MFA on, holds the second factor - right now.
*/
@Injectable()
export class ReauthService {
constructor(
private readonly prisma: PrismaService,
private readonly accountGuard: AccountGuardService,
) {}
async verify(userId: string, proof: ReauthProof): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
passwordHash: true,
mfaEnabled: true,
mfaSecret: true,
mfaLastTimeStep: true,
authLockedUntil: true,
isActive: true,
},
});
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
throw new UnauthorizedException(
'Bestätigung derzeit nicht möglich. Nach mehreren Fehlversuchen ist sie für 15 Minuten gesperrt.',
);
}
const passwordMatches =
typeof proof.password === 'string' &&
(await bcrypt.compare(proof.password, user.passwordHash));
if (!passwordMatches) {
await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException(
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
);
}
if (user.mfaEnabled) {
const codeMatches =
typeof proof.mfaCode === 'string' &&
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
if (!codeMatches) {
await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException(
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
);
}
}
await this.accountGuard.recordSuccess(user.id);
}
}
@@ -0,0 +1,167 @@
import {
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from '../decorators/public.decorator';
import { AuthService } from '../auth.service';
import { MfaRequiredResponse } from '../auth.controller';
import { AccessTokenDto } from '../dto/access-token.dto';
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
import { OneTimeTokenService } from '../one-time-token.service';
import { SsoService } from './sso.service';
const PKCE_COOKIE = 'oidcPkceVerifier';
const PKCE_COOKIE_PATH = '/api/v1/auth/sso';
const PKCE_COOKIE_MAX_AGE_MS = 5 * 60 * 1000;
// Scoped to /complete only - the completion endpoint is the only place that
// ever needs to read it, and it's never sent on the wider /auth/sso/* path.
const SSO_CODE_COOKIE = 'oidcSsoCode';
const SSO_CODE_COOKIE_PATH = '/api/v1/auth/sso/complete';
const SSO_CODE_COOKIE_MAX_AGE_MS = 60 * 1000;
interface PkceCookiePayload {
codeVerifier: string;
state: string;
}
@ApiTags('auth')
@Controller('auth/sso')
export class SsoController {
constructor(
private readonly ssoService: SsoService,
private readonly authService: AuthService,
private readonly oneTimeTokens: OneTimeTokenService,
) {}
private frontendLoginUrl(): string {
return `${process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'}/login`;
}
private cookieOptions(path: string, maxAge: number) {
return {
httpOnly: true,
secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax' as const,
path,
maxAge,
};
}
@Public()
@Get('status')
status(): { enabled: boolean } {
return { enabled: this.ssoService.isConfigured() };
}
@Public()
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { redirectUrl, codeVerifier, state } =
await this.ssoService.buildAuthorizationRequest();
const payload: PkceCookiePayload = { codeVerifier, state };
res.cookie(
PKCE_COOKIE,
JSON.stringify(payload),
this.cookieOptions(PKCE_COOKIE_PATH, PKCE_COOKIE_MAX_AGE_MS),
);
res.redirect(redirectUrl);
}
@Public()
@Get('callback')
async callback(@Req() req: Request, @Res() res: Response): Promise<void> {
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
PKCE_COOKIE
];
res.clearCookie(PKCE_COOKIE, this.cookieOptions(PKCE_COOKIE_PATH, 0));
const pkce = this.parsePkceCookie(rawCookie);
if (!pkce) {
res.redirect(`${this.frontendLoginUrl()}?ssoError=session_expired`);
return;
}
// Built from the configured redirect URI, not from the Host header a
// client sends; only the query (code, state, iss) comes from the request.
const callbackUrl = new URL(process.env.OIDC_REDIRECT_URI as string);
callbackUrl.search = new URL(req.originalUrl, 'http://placeholder').search;
const result = await this.ssoService.handleCallback(
callbackUrl,
pkce.codeVerifier,
pkce.state,
);
if (result.status === 'error') {
res.redirect(`${this.frontendLoginUrl()}?ssoError=${result.reason}`);
return;
}
// The one-time completion code never touches the URL (history, server
// logs, Referer headers) - it travels only in an httpOnly cookie scoped
// to the /complete endpoint, which the frontend calls right after.
res.cookie(
SSO_CODE_COOKIE,
result.ssoCode,
this.cookieOptions(SSO_CODE_COOKIE_PATH, SSO_CODE_COOKIE_MAX_AGE_MS),
);
res.redirect(`${this.frontendLoginUrl()}?ssoPending=1`);
}
@Public()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('complete')
@HttpCode(HttpStatus.OK)
async complete(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto | MfaRequiredResponse> {
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
SSO_CODE_COOKIE
];
res.clearCookie(
SSO_CODE_COOKIE,
this.cookieOptions(SSO_CODE_COOKIE_PATH, 0),
);
if (!ssoCode) {
throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
);
}
const { userId, claims } = this.ssoService.verifySsoCode(ssoCode);
await this.oneTimeTokens.consume(claims);
const result = await this.authService.issueTokensForSsoUser(userId);
if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken };
}
setRefreshTokenCookie(res, result.refreshToken);
return { accessToken: result.accessToken };
}
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<PkceCookiePayload>;
if (
typeof parsed.codeVerifier !== 'string' ||
typeof parsed.state !== 'string'
) {
return null;
}
return { codeVerifier: parsed.codeVerifier, state: parsed.state };
} catch {
return null;
}
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { UsersModule } from '../../users/users.module';
import { SsoService } from './sso.service';
// SsoController lives in AuthModule (not here) because it also depends on
// AuthService, which only AuthModule provides - keeping the controller here
// would require a circular import between AuthModule and SsoModule.
@Module({
imports: [JwtModule.register({}), UsersModule],
providers: [SsoService],
exports: [SsoService],
})
export class SsoModule {}
@@ -0,0 +1,294 @@
import { JwtService } from '@nestjs/jwt';
import {
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import * as client from 'openid-client';
import { SsoService } from './sso.service';
import { UsersService } from '../../users/users.service';
jest.mock('openid-client', () => ({
discovery: jest.fn(),
randomPKCECodeVerifier: jest.fn(),
calculatePKCECodeChallenge: jest.fn(),
randomState: jest.fn(),
buildAuthorizationUrl: jest.fn(),
authorizationCodeGrant: jest.fn(),
}));
const mockedClient = client as jest.Mocked<typeof client>;
const IDENTITY = {
iss: 'https://login.microsoftonline.com/tenant/v2.0',
sub: 'pairwise-sub',
oid: 'object-id-1',
};
const SSO_SUBJECT = `${IDENTITY.iss}|${IDENTITY.oid}`;
function grantWithClaims(claims: Record<string, unknown>) {
return {
claims: () => claims,
} as unknown as Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
}
describe('SsoService', () => {
let ssoService: SsoService;
let usersService: {
findByEmail: jest.Mock;
findBySsoSubject: jest.Mock;
linkSsoSubject: jest.Mock;
};
let jwtService: { sign: jest.Mock; verify: jest.Mock };
const originalEnv = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env.OIDC_ISSUER_URL =
'https://login.microsoftonline.com/tenant/v2.0';
process.env.OIDC_CLIENT_ID = 'client-id';
process.env.OIDC_CLIENT_SECRET = 'client-secret';
process.env.OIDC_REDIRECT_URI =
'https://api.example.com/api/v1/auth/sso/callback';
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
usersService = {
findByEmail: jest.fn(),
findBySsoSubject: jest.fn().mockResolvedValue(null),
linkSsoSubject: jest.fn(),
};
jwtService = { sign: jest.fn(), verify: jest.fn() };
ssoService = new SsoService(
usersService as unknown as UsersService,
jwtService as unknown as JwtService,
);
});
afterEach(() => {
process.env = { ...originalEnv };
});
describe('isConfigured', () => {
it('returns true when all four OIDC variables are set', () => {
expect(ssoService.isConfigured()).toBe(true);
});
it('returns false for a multi-tenant issuer', () => {
process.env.OIDC_ISSUER_URL =
'https://login.microsoftonline.com/common/v2.0';
expect(ssoService.isConfigured()).toBe(false);
});
it('returns false when a variable is missing', () => {
delete process.env.OIDC_CLIENT_SECRET;
expect(ssoService.isConfigured()).toBe(false);
});
});
describe('buildAuthorizationRequest', () => {
it('throws ServiceUnavailableException when SSO is not configured', async () => {
delete process.env.OIDC_ISSUER_URL;
await expect(ssoService.buildAuthorizationRequest()).rejects.toThrow(
ServiceUnavailableException,
);
});
it('discovers the issuer once and returns the authorization URL with a PKCE verifier', async () => {
const config = {} as client.Configuration;
mockedClient.discovery.mockResolvedValue(config);
mockedClient.randomPKCECodeVerifier.mockReturnValue('verifier');
mockedClient.calculatePKCECodeChallenge.mockResolvedValue('challenge');
mockedClient.randomState.mockReturnValue('random-state');
mockedClient.buildAuthorizationUrl.mockReturnValue(
new URL(
'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?x=1',
),
);
const result = await ssoService.buildAuthorizationRequest();
await ssoService.buildAuthorizationRequest();
expect(result.codeVerifier).toBe('verifier');
expect(result.state).toBe('random-state');
expect(result.redirectUrl).toContain('authorize');
expect(mockedClient.discovery).toHaveBeenCalledTimes(1);
expect(mockedClient.buildAuthorizationUrl).toHaveBeenCalledWith(
config,
expect.objectContaining({
redirect_uri: process.env.OIDC_REDIRECT_URI,
code_challenge: 'challenge',
code_challenge_method: 'S256',
state: 'random-state',
}),
);
});
});
describe('handleCallback', () => {
beforeEach(() => {
mockedClient.discovery.mockResolvedValue({} as client.Configuration);
});
it('returns exchange_failed when the token exchange throws', async () => {
mockedClient.authorizationCodeGrant.mockRejectedValue(
new Error('bad code'),
);
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'exchange_failed' });
});
it('returns no_email when the ID token has neither email nor preferred_username', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ ...IDENTITY }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'no_email' });
});
it('returns no_account when no active local user matches the email claim', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ ...IDENTITY, email: '[email protected]' }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
usersService.findByEmail.mockResolvedValue(null);
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'no_account' });
});
it('signs a short-lived sso-complete token for an active matching user', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({
...IDENTITY,
preferred_username: '[email protected]',
}),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
usersService.findByEmail.mockResolvedValue({
id: 'user-1',
isActive: true,
});
jwtService.sign.mockReturnValue('signed-sso-code');
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' });
expect(mockedClient.authorizationCodeGrant).toHaveBeenCalledWith(
{},
expect.any(URL),
{ pkceCodeVerifier: 'verifier', expectedState: 'random-state' },
);
expect(jwtService.sign).toHaveBeenCalledWith(
expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }),
expect.objectContaining({ expiresIn: '60s' }),
);
expect(usersService.linkSsoSubject).toHaveBeenCalledWith(
'user-1',
SSO_SUBJECT,
);
});
it('signs in a linked account by its identity, whatever the email claim says', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue(
grantWithClaims({ ...IDENTITY, email: '[email protected]' }),
);
usersService.findBySsoSubject.mockResolvedValue({
id: 'user-1',
isActive: true,
});
jwtService.sign.mockReturnValue('signed-sso-code');
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' });
expect(usersService.findBySsoSubject).toHaveBeenCalledWith(SSO_SUBJECT);
expect(usersService.findByEmail).not.toHaveBeenCalled();
});
it('refuses a matching email when the account is linked to another identity', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue(
grantWithClaims({
...IDENTITY,
oid: 'attacker-object-id',
email: '[email protected]',
}),
);
usersService.findByEmail.mockResolvedValue({
id: 'user-1',
isActive: true,
ssoSubject: SSO_SUBJECT,
});
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'no_account' });
expect(jwtService.sign).not.toHaveBeenCalled();
expect(usersService.linkSsoSubject).not.toHaveBeenCalled();
});
});
describe('verifySsoCode', () => {
it('returns the userId for a valid sso-complete token', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'sso-complete',
jti: 'jti-1',
exp: 1_900_000_000,
});
expect(ssoService.verifySsoCode('token')).toEqual({
userId: 'user-1',
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
});
it('throws UnauthorizedException when verification fails', () => {
jwtService.verify.mockImplementation(() => {
throw new Error('expired');
});
expect(() => ssoService.verifySsoCode('token')).toThrow(
UnauthorizedException,
);
});
it('throws UnauthorizedException when the token purpose does not match', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'mfa-challenge',
});
expect(() => ssoService.verifySsoCode('token')).toThrow(
UnauthorizedException,
);
});
});
});
+204
View File
@@ -0,0 +1,204 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as client from 'openid-client';
import { UsersService } from '../../users/users.service';
import {
OneTimeTokenClaims,
TOKEN_AUDIENCE,
jwtSignOptions,
jwtVerifyOptions,
} from '../util/jwt-claims';
const SSO_COMPLETE_TOKEN_TTL = '60s';
const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete';
interface SsoCompleteTokenPayload {
sub: string;
purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE;
}
// Entra's shared endpoints accept users of ANY tenant - with them, anyone
// able to create an account in some tenant could present a matching email.
const MULTI_TENANT_SEGMENTS = new Set(['common', 'organizations', 'consumers']);
function isSingleTenantIssuer(issuerUrl: string): boolean {
try {
const segments = new URL(issuerUrl).pathname.split('/').filter(Boolean);
return !segments.some((segment) =>
MULTI_TENANT_SEGMENTS.has(segment.toLowerCase()),
);
} catch {
return false;
}
}
export type SsoCallbackResult =
| { status: 'success'; ssoCode: string }
| { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' };
@Injectable()
export class SsoService {
private readonly logger = new Logger(SsoService.name);
private config: client.Configuration | undefined;
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
isConfigured(): boolean {
const issuer = process.env.OIDC_ISSUER_URL;
if (
!issuer ||
!process.env.OIDC_CLIENT_ID ||
!process.env.OIDC_CLIENT_SECRET ||
!process.env.OIDC_REDIRECT_URI
) {
return false;
}
if (!isSingleTenantIssuer(issuer)) {
this.logger.error(
'SSO disabled: OIDC_ISSUER_URL must name one tenant (not common/organizations/consumers)',
);
return false;
}
return true;
}
private async getConfig(): Promise<client.Configuration> {
if (!this.isConfigured()) {
throw new ServiceUnavailableException(
'Microsoft-Anmeldung ist nicht konfiguriert',
);
}
if (!this.config) {
this.config = await client.discovery(
new URL(process.env.OIDC_ISSUER_URL as string),
process.env.OIDC_CLIENT_ID as string,
process.env.OIDC_CLIENT_SECRET,
);
}
return this.config;
}
async buildAuthorizationRequest(): Promise<{
redirectUrl: string;
codeVerifier: string;
state: string;
}> {
const config = await this.getConfig();
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const redirectUrl = client.buildAuthorizationUrl(config, {
redirect_uri: process.env.OIDC_REDIRECT_URI as string,
scope: 'openid email profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
});
return { redirectUrl: redirectUrl.href, codeVerifier, state };
}
async handleCallback(
callbackUrl: URL,
codeVerifier: string,
state: string,
): Promise<SsoCallbackResult> {
const config = await this.getConfig();
let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
try {
tokens = await client.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: codeVerifier,
expectedState: state,
});
} catch (error) {
this.logger.warn(
`SSO callback token exchange failed: ${(error as Error).message}`,
);
return { status: 'error', reason: 'exchange_failed' };
}
const claims = tokens.claims();
// The account is bound to the IdP's immutable identity (issuer + object
// id), not to the email claim: Entra does not verify email or
// preferred_username, so a mutable claim alone must never pick the
// account.
const objectId =
(claims?.oid as string | undefined) ?? claims?.sub ?? undefined;
const ssoSubject =
claims?.iss && objectId ? `${claims.iss}|${objectId}` : null;
if (!ssoSubject) {
this.logger.warn('SSO callback succeeded but ID token had no subject');
return { status: 'error', reason: 'no_account' };
}
const linked = await this.usersService.findBySsoSubject(ssoSubject);
if (linked) {
if (!linked.isActive) {
this.logger.warn(`SSO login denied: user ${linked.id} is deactivated`);
return { status: 'error', reason: 'no_account' };
}
return { status: 'success', ssoCode: this.signSsoCode(linked.id) };
}
const email =
(claims?.email as string | undefined) ??
(claims?.preferred_username as string | undefined);
if (!email) {
this.logger.warn(
'SSO callback succeeded but ID token had no email claim',
);
return { status: 'error', reason: 'no_email' };
}
const user = await this.usersService.findByEmail(email);
if (!user || !user.isActive) {
this.logger.warn('SSO login denied: no active local account for email');
return { status: 'error', reason: 'no_account' };
}
if (user.ssoSubject) {
// Already bound to a different IdP identity: someone else is
// presenting this email.
this.logger.warn(
`SSO login denied: user ${user.id} is linked to another identity`,
);
return { status: 'error', reason: 'no_account' };
}
// First sign-in: bind the account to this identity for good.
await this.usersService.linkSsoSubject(user.id, ssoSubject);
return { status: 'success', ssoCode: this.signSsoCode(user.id) };
}
private signSsoCode(userId: string): string {
return this.jwtService.sign(
{ sub: userId, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
jwtSignOptions(TOKEN_AUDIENCE.ssoComplete, SSO_COMPLETE_TOKEN_TTL),
);
}
verifySsoCode(ssoCode: string): {
userId: string;
claims: OneTimeTokenClaims;
} {
let payload: SsoCompleteTokenPayload & OneTimeTokenClaims;
try {
payload = this.jwtService.verify<
SsoCompleteTokenPayload & OneTimeTokenClaims
>(ssoCode, jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete));
} catch {
throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
);
}
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token');
}
return {
userId: payload.sub,
claims: { jti: payload.jti, exp: payload.exp },
};
}
}
@@ -1,11 +1,18 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthenticatedUser } from '../types/authenticated-user';
import { JWT_ALGORITHM, JWT_ISSUER, TOKEN_AUDIENCE } from '../util/jwt-claims';
interface AccessTokenPayload {
sub: string;
email: string;
// Standard JWT claim, added automatically by jsonwebtoken.sign(); used to
// reject tokens issued before the user's last password change (see
// PermissionResolverService.isTokenValid).
iat: number;
// Set only on the helper tokens (MFA setup/challenge, SSO completion).
purpose?: string;
}
@Injectable()
@@ -15,10 +22,23 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_ACCESS_SECRET as string,
// Only access tokens: the MFA challenge token (issued after the
// password step, before the second factor) shares the secret and must
// never open a session on its own.
algorithms: [JWT_ALGORITHM],
issuer: JWT_ISSUER,
audience: TOKEN_AUDIENCE.access,
});
}
validate(payload: AccessTokenPayload): AuthenticatedUser {
return { id: payload.sub, email: payload.email };
if (payload.purpose !== undefined || typeof payload.sub !== 'string') {
throw new UnauthorizedException();
}
return {
id: payload.sub,
email: payload.email,
tokenIssuedAt: payload.iat,
};
}
}
@@ -1,4 +1,6 @@
export interface AuthenticatedUser {
id: string;
email: string;
/** JWT `iat` claim (seconds since epoch) - see PermissionResolverService.isTokenValid. */
tokenIssuedAt: number;
}
@@ -0,0 +1,56 @@
import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
import { randomUUID } from 'node:crypto';
/**
* Every JWT ApiCore issues is signed with JWT_ACCESS_SECRET, so the token's
* audience - not the secret - is what tells an access token apart from the
* short-lived helper tokens (MFA setup, MFA challenge, SSO completion).
* Without a pinned audience, the MFA challenge token handed out after the
* password step would pass the access-token strategy and skip the second
* factor entirely.
*
* Algorithm and issuer are pinned as well, so a token is only ever accepted
* in the exact shape ApiCore itself produces.
*/
export const JWT_ISSUER = 'apicore';
export const JWT_ALGORITHM = 'HS256';
export const TOKEN_AUDIENCE = {
access: 'apicore:access',
mfaSetup: 'apicore:mfa-setup',
mfaChallenge: 'apicore:mfa-challenge',
ssoComplete: 'apicore:sso-complete',
} as const;
export type TokenAudience =
(typeof TOKEN_AUDIENCE)[keyof typeof TOKEN_AUDIENCE];
export function jwtSignOptions(
audience: TokenAudience,
expiresIn: JwtSignOptions['expiresIn'],
): JwtSignOptions {
return {
secret: process.env.JWT_ACCESS_SECRET,
algorithm: JWT_ALGORITHM,
issuer: JWT_ISSUER,
audience,
expiresIn,
// Unique id, so a one-time token can be marked as used.
jwtid: randomUUID(),
};
}
/** What a verified one-time token carries for OneTimeTokenService. */
export interface OneTimeTokenClaims {
jti?: string;
exp?: number;
}
export function jwtVerifyOptions(audience: TokenAudience): JwtVerifyOptions {
return {
secret: process.env.JWT_ACCESS_SECRET,
algorithms: [JWT_ALGORITHM],
issuer: JWT_ISSUER,
audience,
};
}
@@ -0,0 +1,44 @@
import { UnauthorizedException } from '@nestjs/common';
import type { Request, Response } from 'express';
import { parseDurationToMs } from './duration';
export const REFRESH_TOKEN_COOKIE = 'refreshToken';
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
function cookieOptions() {
return {
httpOnly: true,
// Explicit opt-out for local HTTP dev only; every real deployment must
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
// over plaintext HTTP.
secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax' as const,
path: REFRESH_TOKEN_COOKIE_PATH,
};
}
export function setRefreshTokenCookie(
res: Response,
refreshToken: string,
): void {
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
...cookieOptions(),
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
});
}
// Same attributes as when set - some browsers keep a cookie that is
// cleared with different ones.
export function clearRefreshTokenCookie(res: Response): void {
res.clearCookie(REFRESH_TOKEN_COOKIE, cookieOptions());
}
export function extractRefreshToken(req: Request): string {
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
REFRESH_TOKEN_COOKIE
];
if (!refreshToken) {
throw new UnauthorizedException('Missing refresh token');
}
return refreshToken;
}
@@ -0,0 +1,4 @@
// Stricter than the global default: bounds brute-force/credential-stuffing
// attempts against any endpoint that verifies a password or MFA code
// (login, refresh, MFA verify, change-password, MFA disable).
export const SENSITIVE_ACTION_THROTTLE = { default: { limit: 10, ttl: 60000 } };
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@@ -27,4 +27,13 @@ export class AutomationController {
) {
return this.automationService.update(id, dto, actor.id);
}
@RequirePermission('automation:update')
@Post(':id/reset-sequence')
resetSequence(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.automationService.resetSequence(id, actor.id);
}
}
@@ -1,9 +1,10 @@
import { NotFoundException } from '@nestjs/common';
import { Logger, NotFoundException } from '@nestjs/common';
import { AutomationService } from './automation.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationTriggerType, Prisma } from '../../generated/prisma';
describe('AutomationService', () => {
let service: AutomationService;
@@ -14,7 +15,10 @@ describe('AutomationService', () => {
findUnique: jest.Mock;
update: jest.Mock;
};
task: { create: jest.Mock };
automationSequence: { upsert: jest.Mock };
task: { create: jest.Mock; findFirst: jest.Mock };
$transaction: jest.Mock;
$queryRaw: jest.Mock;
};
let auditLog: { record: jest.Mock };
let notifications: { record: jest.Mock };
@@ -50,7 +54,14 @@ describe('AutomationService', () => {
findUnique: jest.fn(),
update: jest.fn(),
},
task: { create: jest.fn().mockResolvedValue({ id: 'task-1' }) },
automationSequence: { upsert: jest.fn() },
task: {
create: jest.fn().mockResolvedValue({ id: 'task-1' }),
findFirst: jest.fn().mockResolvedValue(null),
},
// Interactive transactions run against the same mocks.
$transaction: jest.fn((work: (tx: unknown) => unknown) => work(prisma)),
$queryRaw: jest.fn().mockResolvedValue([]),
};
auditLog = { record: jest.fn() };
notifications = { record: jest.fn() };
@@ -70,7 +81,7 @@ describe('AutomationService', () => {
);
});
it('findAll seeds the three default rules before returning them', async () => {
it('findAll seeds all default rules before returning them', async () => {
prisma.automationRule.findMany.mockResolvedValue([
queenlessRule,
varroaRule,
@@ -79,10 +90,128 @@ describe('AutomationService', () => {
const result = await service.findAll();
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(3);
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(
Object.values(AutomationTriggerType).length,
);
expect(result).toHaveLength(3);
});
// Rule evaluation runs after the triggering record is committed. A failure
// here must not turn that request into an error - the client would retry
// and store the inspection or treatment a second time.
describe('post-commit evaluation', () => {
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.automationRule.upsert.mockRejectedValue(new Error('db down'));
});
afterEach(() => {
errorSpy.mockRestore();
});
const colony = { id: 'colony-1', healthStatus: 'GOOD' };
const evaluations: [string, string, (s: AutomationService) => unknown][] = [
[
'evaluateInspection',
'Inspection inspection-1',
(s) =>
s.evaluateInspection({
id: 'inspection-1',
colonyId: 'colony-1',
} as never),
],
[
'evaluateVarroaMeasurement',
'VarroaMeasurement measurement-1',
(s) =>
s.evaluateVarroaMeasurement({
id: 'measurement-1',
colonyId: 'colony-1',
} as never),
],
[
'evaluateSwarmControlCheck',
'SwarmControlCheck check-1',
(s) =>
s.evaluateSwarmControlCheck({
id: 'check-1',
colonyId: 'colony-1',
} as never),
],
[
'evaluateColonyHealth',
'Colony colony-1',
(s) =>
s.evaluateColonyHealth(
colony as never,
{ ...colony, healthStatus: 'CRITICAL' } as never,
),
],
[
'evaluateStockItem',
'StockItem item-1',
(s) =>
s.evaluateStockItem({
id: 'item-1',
quantity: 1,
minThreshold: 5,
} as never),
],
[
'evaluateRawMaterialLow',
'RawMaterialKind BEESWAX',
(s) => s.evaluateRawMaterialLow('BEESWAX', 100),
],
[
'evaluateTreatment',
'Treatment treatment-1',
(s) =>
s.evaluateTreatment({
id: 'treatment-1',
colonyId: 'colony-1',
date: new Date('2026-08-01'),
endDate: null,
product: 'Ameisensäure',
} as never),
],
[
'evaluateFeeding',
'Feeding feeding-1',
(s) =>
s.evaluateFeeding({
id: 'feeding-1',
colonyId: 'colony-1',
date: new Date('2026-09-01'),
amount: 5,
unit: 'l',
} as never),
],
];
it.each(evaluations)(
'%s never throws and logs the failure with the record',
async (_name, subject, evaluate) => {
await expect(evaluate(service)).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain(subject);
expect(message).toContain('db down');
},
);
it('still throws from generateNumber, which runs before the write', async () => {
await expect(
service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'),
).rejects.toThrow('db down');
expect(errorSpy).not.toHaveBeenCalled();
});
});
describe('evaluateInspection', () => {
it('creates a task and notification when queen not seen and no eggs present', async () => {
prisma.automationRule.findMany.mockResolvedValue([queenlessRule]);
@@ -154,6 +283,7 @@ describe('AutomationService', () => {
await service.evaluateVarroaMeasurement({
id: 'measurement-1',
colonyId: 'colony-1',
method: 'NATURAL_MITE_FALL',
calculatedValue: 12,
miteCount: null,
} as never);
@@ -167,6 +297,7 @@ describe('AutomationService', () => {
await service.evaluateVarroaMeasurement({
id: 'measurement-2',
colonyId: 'colony-1',
method: 'NATURAL_MITE_FALL',
calculatedValue: 5,
miteCount: null,
} as never);
@@ -174,18 +305,245 @@ describe('AutomationService', () => {
expect(prisma.task.create).not.toHaveBeenCalled();
});
it('falls back to miteCount when calculatedValue is null', async () => {
it('ignores a raw board count without a per-day rate', async () => {
// 15 mites over an unknown number of days says nothing about the
// per-day threshold - comparing them would be a unit error.
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
await service.evaluateVarroaMeasurement({
id: 'measurement-3',
colonyId: 'colony-1',
method: 'NATURAL_MITE_FALL',
calculatedValue: null,
miteCount: 15,
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
});
it('ignores sample-based methods, whose values use another unit', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
await service.evaluateVarroaMeasurement({
id: 'measurement-4',
colonyId: 'colony-1',
method: 'WASHING_METHOD',
calculatedValue: 30,
miteCount: 30,
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
});
it('adds no second task while one for the same colony is still open', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.task.findFirst.mockResolvedValue({ id: 'open-task' });
await service.evaluateVarroaMeasurement({
id: 'measurement-5',
colonyId: 'colony-1',
method: 'NATURAL_MITE_FALL',
calculatedValue: 25,
miteCount: 25,
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
expect(notifications.record).not.toHaveBeenCalled();
expect(prisma.task.findFirst).toHaveBeenCalledWith({
where: {
sourceAutomationTrigger: 'VARROA_THRESHOLD',
relatedType: 'COLONY',
relatedId: 'colony-1',
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
});
it('checks for an open task and creates one under a lock on the rule row', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
await service.evaluateVarroaMeasurement({
id: 'measurement-6',
colonyId: 'colony-1',
method: 'NATURAL_MITE_FALL',
calculatedValue: 25,
miteCount: 25,
} as never);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
const [sql, ...values] = prisma.$queryRaw.mock.calls[0] as [
TemplateStringsArray,
...unknown[],
];
expect(sql.join('?')).toMatch(
/SELECT id FROM "AutomationRule" WHERE id = \? FOR UPDATE/,
);
expect(values).toEqual(['rule-3']);
// Lock first, then check, then create - all in that one transaction.
const [lockOrder] = prisma.$queryRaw.mock.invocationCallOrder;
const [checkOrder] = prisma.task.findFirst.mock.invocationCallOrder;
const [createOrder] = prisma.task.create.mock.invocationCallOrder;
expect(lockOrder).toBeLessThan(checkOrder);
expect(checkOrder).toBeLessThan(createOrder);
// Webhook and warning follow the commit, outside the lock.
const [dispatchOrder] = webhooks.dispatch.mock.invocationCallOrder;
expect(dispatchOrder).toBeGreaterThan(createOrder);
});
});
describe('evaluateSwarmControlCheck', () => {
const swarmRule = {
id: 'rule-4',
triggerType: 'SWARM_MOOD_HIGH',
active: true,
config: {},
actionTemplate: 'Schwarmkontrolle dringend',
};
it('creates a task when swarm mood is HIGH', async () => {
prisma.automationRule.findUnique.mockResolvedValue(swarmRule);
await service.evaluateSwarmControlCheck({
id: 'check-1',
colonyId: 'colony-1',
swarmMood: 'HIGH',
} as never);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
});
it('does not trigger for a low swarm mood', async () => {
prisma.automationRule.findUnique.mockResolvedValue(swarmRule);
await service.evaluateSwarmControlCheck({
id: 'check-2',
colonyId: 'colony-1',
swarmMood: 'LOW',
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
describe('evaluateColonyHealth', () => {
const healthRule = {
id: 'rule-5',
triggerType: 'COLONY_HEALTH_CRITICAL',
active: true,
config: {},
actionTemplate: 'Kontrolle erforderlich',
};
it('creates a task on the transition into CRITICAL', async () => {
prisma.automationRule.findUnique.mockResolvedValue(healthRule);
await service.evaluateColonyHealth(
{ id: 'colony-1', healthStatus: 'AVERAGE' } as never,
{ id: 'colony-1', healthStatus: 'CRITICAL' } as never,
);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
});
it('does not re-trigger when already CRITICAL before the update', async () => {
prisma.automationRule.findUnique.mockResolvedValue(healthRule);
await service.evaluateColonyHealth(
{ id: 'colony-1', healthStatus: 'CRITICAL' } as never,
{ id: 'colony-1', healthStatus: 'CRITICAL' } as never,
);
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
describe('evaluateStockItem', () => {
const stockRule = {
id: 'rule-6',
triggerType: 'STOCK_ITEM_LOW',
active: true,
config: {},
actionTemplate: 'Nachbestellung prüfen',
};
it('creates a task when quantity drops below minThreshold', async () => {
prisma.automationRule.findUnique.mockResolvedValue(stockRule);
await service.evaluateStockItem({
id: 'item-1',
quantity: 2,
minThreshold: 5,
} as never);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
});
it('does not trigger when minThreshold is not set', async () => {
prisma.automationRule.findUnique.mockResolvedValue(stockRule);
await service.evaluateStockItem({
id: 'item-2',
quantity: 2,
minThreshold: null,
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
describe('evaluateRawMaterialLow', () => {
const rawRule = {
id: 'rule-7',
triggerType: 'RAW_MATERIAL_LOW',
active: true,
config: { minGrams: { BEESWAX: 1000 } },
actionTemplate: 'Rohstoff unter Mindestmenge',
};
it('creates one task per kind when the kind drops below its minimum', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawRule);
await service.evaluateRawMaterialLow('BEESWAX', 400);
const [createArgs] = prisma.task.create.mock.calls[0] as [unknown];
expect(createArgs).toMatchObject({
data: {
relatedType: 'GENERAL',
relatedId: 'BEESWAX',
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
description:
'Bienenwachs: noch 400 g auf Lager, Mindestmenge 1000 g.',
},
});
});
it('does not trigger at or above the minimum', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawRule);
await service.evaluateRawMaterialLow('BEESWAX', 1000);
expect(prisma.task.create).not.toHaveBeenCalled();
});
it('does not trigger for a kind without a minimum', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawRule);
await service.evaluateRawMaterialLow('PROPOLIS', 0);
expect(prisma.task.create).not.toHaveBeenCalled();
});
it('does not trigger when the rule is inactive', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
...rawRule,
active: false,
});
await service.evaluateRawMaterialLow('BEESWAX', 0);
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
describe('update', () => {
@@ -206,5 +564,182 @@ describe('AutomationService', () => {
}),
);
});
it('clears the config on null so the rule falls back to its defaults', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue({
...varroaRule,
config: null,
});
await service.update('rule-3', { config: null as never }, 'user-1');
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
// A Json column is cleared with DbNull - a plain null fails the write.
expect(updateArg.data.config).toBe(Prisma.DbNull);
});
it('leaves the config untouched when it is omitted', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue(varroaRule);
await service.update('rule-3', { active: true }, 'user-1');
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
expect(updateArg.data.config).toBeUndefined();
});
});
describe('generateNumber', () => {
it('returns null when the rule is inactive', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-4',
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
active: false,
config: { format: 'WH-{YYYY}-{SEQ:4}' },
sequenceValue: 0,
});
const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT');
expect(result).toBeNull();
expect(prisma.automationRule.update).not.toHaveBeenCalled();
});
it('returns null when no format is configured', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-4',
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
active: true,
config: {},
sequenceValue: 0,
});
const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT');
expect(result).toBeNull();
});
it('atomically increments sequenceValue and formats the number', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-4',
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
active: true,
config: { format: 'WH-{YYYY}-{SEQ:4}' },
sequenceValue: 0,
});
prisma.automationRule.update.mockResolvedValue({ sequenceValue: 7 });
const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT');
expect(prisma.automationRule.update).toHaveBeenCalledWith({
where: { triggerType: 'HONEY_BATCH_NUMBER_FORMAT' },
data: { sequenceValue: { increment: 1 } },
select: { sequenceValue: true },
});
expect(result).toBe(`WH-${new Date().getFullYear()}-0007`);
});
const rawFormatRule = {
id: 'rule-8',
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
active: true,
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
sequenceValue: 0,
};
it('counts per scope when the format tells the scopes apart', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawFormatRule);
prisma.automationSequence.upsert.mockResolvedValue({ value: 3 });
const result = await service.generateNumber(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
{ scope: 'PROPOLIS', vars: { ART: 'PR' } },
);
expect(prisma.automationSequence.upsert).toHaveBeenCalledWith({
where: {
triggerType_scope: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'PROPOLIS',
},
},
create: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'PROPOLIS',
value: 1,
},
update: { value: { increment: 1 } },
select: { value: true },
});
expect(prisma.automationRule.update).not.toHaveBeenCalled();
expect(result).toBe(`PR-${new Date().getFullYear()}-0003`);
});
it('keeps one shared counter when the format has no {ART}', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
...rawFormatRule,
config: { format: 'RS-{YYYY}-{SEQ:4}' },
});
prisma.automationRule.update.mockResolvedValue({ sequenceValue: 5 });
const result = await service.generateNumber(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
{ scope: 'BEESWAX', vars: { ART: 'WA' } },
);
expect(prisma.automationSequence.upsert).not.toHaveBeenCalled();
expect(result).toBe(`RS-${new Date().getFullYear()}-0005`);
});
it('retries once when two first uses of a scope collide', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawFormatRule);
prisma.automationSequence.upsert
.mockRejectedValueOnce({ code: 'P2002' })
.mockResolvedValueOnce({ value: 2 });
const result = await service.generateNumber(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
{ scope: 'BEESWAX', vars: { ART: 'WA' } },
);
expect(prisma.automationSequence.upsert).toHaveBeenCalledTimes(2);
expect(result).toBe(`WA-${new Date().getFullYear()}-0002`);
});
});
describe('resetSequence', () => {
it('sets sequenceValue back to 0 and records an audit log entry', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-4',
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
active: true,
config: { format: 'WH-{YYYY}-{SEQ:4}' },
sequenceValue: 12,
});
prisma.automationRule.update.mockResolvedValue({
id: 'rule-4',
sequenceValue: 0,
});
await service.resetSequence('rule-4', 'user-1');
expect(prisma.automationRule.update).toHaveBeenCalledWith({
where: { id: 'rule-4' },
data: { sequenceValue: 0, sequences: { deleteMany: {} } },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'AutomationRule',
entityId: 'rule-4',
action: 'UPDATE',
userId: 'user-1',
}),
);
});
});
});
@@ -1,20 +1,62 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { runAfterCommit } from '../common/after-commit';
import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import {
AutomationRule,
AutomationTriggerType,
Colony,
Inspection,
Feeding,
Prisma,
RawMaterialKind,
StockItem,
SwarmControlCheck,
TaskRelatedType,
Treatment,
VarroaMeasurement,
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
/** The rules that fill in a batch/lot number instead of creating tasks. */
export type NumberFormatTrigger =
| 'HONEY_BATCH_NUMBER_FORMAT'
| 'BOTTLING_LOT_NUMBER_FORMAT'
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
| 'PRODUCTION_BATCH_NUMBER_FORMAT';
const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
BEESWAX: 'Bienenwachs',
PROPOLIS: 'Propolis',
};
/** What {ART} turns into in the raw material number format. */
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
BEESWAX: 'WA',
PROPOLIS: 'PR',
};
export type AutomationRuleWithSequences = AutomationRule & {
sequences: { scope: string; value: number }[];
};
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'P2002'
);
}
interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
}
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
@@ -31,10 +73,100 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: {},
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
},
SWARM_MOOD_HIGH: {
config: {},
actionTemplate:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
},
COLONY_HEALTH_CRITICAL: {
config: {},
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
},
STOCK_ITEM_LOW: {
config: {},
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
},
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
BOTTLING_LOT_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
PRODUCTION_BATCH_NUMBER_FORMAT: {
config: { format: 'HS-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
// no minimum of their own, so the minimum per kind (config.minGrams) lives
// here - and without one there is nothing to compare, hence off by default.
RAW_MATERIAL_LOW: {
config: {},
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
active: false,
},
// Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
TREATMENT_FOLLOW_UP: {
config: {},
actionTemplate: 'Behandlungserfolg kontrollieren',
active: false,
},
FEEDING_FOLLOW_UP: {
config: {},
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
active: false,
},
};
/**
* Optional per-rule follow-up interval (config.dueInDays). There is no
* built-in default: how soon a queenless colony or a high mite fall needs a
* look depends on season and practice, so the rule stays date-less unless the
* beekeeper configures one.
*/
function formatDay(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
}
function dueDateFromConfig(
dueInDays: unknown,
baseDate: Date = new Date(),
): Date | undefined {
if (
typeof dueInDays !== 'number' ||
!Number.isInteger(dueInDays) ||
dueInDays < 0 ||
dueInDays > 365
) {
return undefined;
}
const due = new Date(baseDate);
due.setUTCHours(0, 0, 0, 0);
due.setUTCDate(due.getUTCDate() + dueInDays);
return due;
}
@Injectable()
export class AutomationService {
private readonly logger = new Logger(AutomationService.name);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
@@ -52,6 +184,7 @@ export class AutomationService {
update: {},
create: {
triggerType,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
@@ -59,10 +192,16 @@ export class AutomationService {
}
}
async findAll(): Promise<AutomationRule[]> {
async findAll(): Promise<AutomationRuleWithSequences[]> {
await this.ensureDefaults();
return this.prisma.automationRule.findMany({
orderBy: { triggerType: 'asc' },
include: {
sequences: {
select: { scope: true, value: true },
orderBy: { scope: 'asc' },
},
},
});
}
@@ -86,7 +225,12 @@ export class AutomationService {
where: { id },
data: {
...dto,
config: dto.config as Prisma.InputJsonValue | undefined,
// null resets the rule to its defaults; a Json column is cleared
// with DbNull, a plain null would fail the write.
config:
dto.config === null
? Prisma.DbNull
: (dto.config as Prisma.InputJsonValue | undefined),
},
});
await this.auditLog.record({
@@ -100,7 +244,25 @@ export class AutomationService {
return rule;
}
/**
* Every evaluate*() runs after the triggering record is committed and is
* called by the domain services without a guard - so it never throws (see
* runAfterCommit). A failed evaluation is logged, the request succeeds.
*/
private evaluateAfterCommit(
entityType: string,
entityId: string,
evaluate: () => Promise<void>,
): Promise<void> {
return runAfterCommit(
this.logger,
{ action: 'Automation rule evaluation', entityType, entityId },
evaluate,
);
}
async evaluateInspection(inspection: Inspection): Promise<void> {
await this.evaluateAfterCommit('Inspection', inspection.id, async () => {
await this.ensureDefaults();
const rules = await this.prisma.automationRule.findMany({
where: {
@@ -118,6 +280,7 @@ export class AutomationService {
) {
await this.triggerAction(
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
@@ -125,13 +288,23 @@ export class AutomationService {
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
await this.triggerAction(feedRule, inspection.colonyId, inspection.id);
await this.triggerAction(
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
});
}
async evaluateVarroaMeasurement(
measurement: VarroaMeasurement,
): Promise<void> {
await this.evaluateAfterCommit(
'VarroaMeasurement',
measurement.id,
async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
@@ -139,41 +312,310 @@ export class AutomationService {
if (!rule || !rule.active) {
return;
}
// The threshold is a natural mite fall rate (mites per day). Only
// that method yields a value in this unit - a mite count from a
// sugar roll or wash is per bee sample and would be compared against
// the wrong scale, as would a raw board count without the number of
// days it covered.
if (measurement.method !== 'NATURAL_MITE_FALL') {
return;
}
const config = rule.config as { thresholdMitesPerDay?: number } | null;
const threshold = config?.thresholdMitesPerDay ?? 10;
const value = measurement.calculatedValue ?? measurement.miteCount;
const value = measurement.calculatedValue;
if (value !== null && value >= threshold) {
await this.triggerAction(rule, measurement.colonyId);
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
}
},
);
}
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerAction(rule, 'COLONY', check.colonyId);
}
});
}
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
await this.evaluateAfterCommit('Colony', after.id, async () => {
await this.ensureDefaults();
if (
after.healthStatus !== 'CRITICAL' ||
before.healthStatus === 'CRITICAL'
) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', after.id);
});
}
async evaluateStockItem(item: StockItem): Promise<void> {
await this.evaluateAfterCommit('StockItem', item.id, async () => {
await this.ensureDefaults();
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'STORAGE', item.id);
});
}
/**
* After raw material left its lots (consumption, withdrawal, inventory):
* compares what is left of the kind across all lots with config.minGrams.
* The task is about the kind, not one lot - a new lot of the same kind is
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
* keeps one open task per kind.
*/
async evaluateRawMaterialLow(
kind: RawMaterialKind,
remainingGrams: number,
): Promise<void> {
await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
return;
}
const config = rule.config as {
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return;
}
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
});
});
}
async evaluateTreatment(treatment: Treatment): Promise<void> {
await this.evaluateAfterCommit('Treatment', treatment.id, () =>
this.evaluateFollowUp(
'TREATMENT_FOLLOW_UP',
treatment.colonyId,
treatment.endDate ?? treatment.date,
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
),
);
}
async evaluateFeeding(feeding: Feeding): Promise<void> {
await this.evaluateAfterCommit('Feeding', feeding.id, () =>
this.evaluateFollowUp(
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
),
);
}
/** A check counted from the measure's own date, not from today. */
private async evaluateFollowUp(
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
colonyId: string,
measureDate: Date,
description: string,
): Promise<void> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', colonyId, undefined, {
baseDate: measureDate,
description,
});
}
// Returns null (never throws) when the rule is inactive or has no format
// configured, so the caller can fall back to requiring manual entry.
//
// `scope` asks for a counter of its own (e.g. per raw material kind). It
// only applies when the format contains a placeholder from `vars` that
// tells the scopes apart - otherwise two scopes would hand out the same
// number, so the rule falls back to its one shared counter.
async generateNumber(
triggerType: NumberFormatTrigger,
options: { scope?: string; vars?: Record<string, string> } = {},
): Promise<string | null> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
return null;
}
const config = rule.config as { format?: string } | null;
const format = config?.format;
if (!format) {
return null;
}
const vars = options.vars ?? {};
const scoped =
options.scope !== undefined &&
Object.keys(vars).some((key) => format.includes(`{${key}}`));
const sequence = scoped
? await this.nextScopedSequence(triggerType, options.scope!)
: await this.nextSharedSequence(triggerType);
return formatGeneratedNumber(format, sequence, new Date(), vars);
}
// Atomic `SET sequenceValue = sequenceValue + 1` - safe under concurrent
// batch/bottling creation, no explicit transaction/lock needed.
private async nextSharedSequence(
triggerType: NumberFormatTrigger,
): Promise<number> {
const updated = await this.prisma.automationRule.update({
where: { triggerType },
data: { sequenceValue: { increment: 1 } },
select: { sequenceValue: true },
});
return updated.sequenceValue;
}
// Same atomic increment on the scope's own row, created on first use. Two
// first uses at once can both try the insert; the loser gets P2002 and
// simply increments the row the winner created.
private async nextScopedSequence(
triggerType: NumberFormatTrigger,
scope: string,
): Promise<number> {
const upsert = () =>
this.prisma.automationSequence.upsert({
where: { triggerType_scope: { triggerType, scope } },
create: { triggerType, scope, value: 1 },
update: { value: { increment: 1 } },
select: { value: true },
});
try {
return (await upsert()).value;
} catch (error) {
if (isUniqueViolation(error)) {
return (await upsert()).value;
}
throw error;
}
}
async resetSequence(
id: string,
actingUserId: string,
): Promise<AutomationRule> {
const before = await this.findById(id);
// Resets the shared counter and every per-scope counter of the rule.
const rule = await this.prisma.automationRule.update({
where: { id },
data: { sequenceValue: 0, sequences: { deleteMany: {} } },
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: rule,
});
return rule;
}
/**
* Leaves a suggestion task (never a record of work done) plus a warning.
*
* At most one open task per rule and object: while the reorder task for a
* stock item or the check task for a colony is still open, further triggers
* (the next removal below minimum, a second high varroa count) add nothing.
* Repeating the same alarm would only bury the one task that matters.
*
* Check and create run in one transaction under a lock on the rule's row:
* two evaluations at the same moment (parallel requests, several pods)
* would otherwise both see "no open task" and both create one. The lock
* is per rule and held for two short statements; webhook, warning and
* audit entry follow after the commit.
*/
private async triggerAction(
rule: AutomationRule,
colonyId: string,
relatedType: TaskRelatedType,
relatedId: string,
sourceInspectionId?: string,
options: { baseDate?: Date; description?: string } = {},
): Promise<void> {
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const task = await this.prisma.task.create({
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
const alreadyOpen = await tx.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return null;
}
return tx.task.create({
data: {
title,
priority: 'HIGH',
relatedType: 'COLONY',
relatedId: colonyId,
relatedType,
relatedId,
sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
},
});
});
if (!task) {
return;
}
await this.webhooks.dispatch('task.created', task);
await this.notifications.record({
priority: 'WARNUNG',
message: title,
relatedType: 'COLONY',
relatedId: colonyId,
relatedType,
relatedId,
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: { taskId: task.id, colonyId, triggerType: rule.triggerType },
afterData: {
taskId: task.id,
relatedType,
relatedId,
triggerType: rule.triggerType,
},
});
}
}
@@ -1,9 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class UpdateAutomationRuleDto {
@ApiPropertyOptional()
@IsOptional()
@IsOptionalNotNull()
@IsBoolean()
active?: boolean;
@@ -0,0 +1,38 @@
import { formatGeneratedNumber } from './number-format';
describe('formatGeneratedNumber', () => {
// Constructed via local-time components (not an ISO string) so the test
// is deterministic regardless of the machine's timezone.
const date = new Date(2026, 2, 7);
it('substitutes year, month, day and a zero-padded sequence', () => {
const result = formatGeneratedNumber('WH-{YYYY}-{SEQ:4}', 7, date);
expect(result).toBe('WH-2026-0007');
});
it('supports {YY}, {MM}, {DD} and an unpadded {SEQ}', () => {
const result = formatGeneratedNumber('{YY}{MM}{DD}-{SEQ}', 3, date);
expect(result).toBe('260307-3');
});
it('does not truncate a sequence wider than the requested padding', () => {
const result = formatGeneratedNumber('L-{SEQ:2}', 12345, date);
expect(result).toBe('L-12345');
});
it('leaves a format with no placeholders unchanged', () => {
const result = formatGeneratedNumber('FIXED-NUMBER', 1, date);
expect(result).toBe('FIXED-NUMBER');
});
it('clamps a pathologically large {SEQ:N} width instead of allocating it', () => {
const result = formatGeneratedNumber('L-{SEQ:999999999}', 7, date);
expect(result).toBe(`L-${'0'.repeat(19)}7`);
expect(result.length).toBe(22);
});
});
@@ -0,0 +1,41 @@
// Far beyond any realistic business use (sequence numbers are a handful of
// digits) but small enough that padStart can never be tricked into
// allocating a pathologically large string - {SEQ:999999999} in a
// carelessly typed format would otherwise try to build a ~1GB string on
// every single batch/bottling creation from then on.
const MAX_SEQUENCE_PAD_WIDTH = 20;
// Placeholders: {YYYY} 4-digit year, {YY} 2-digit year, {MM} month (01-12),
// {DD} day (01-31), {SEQ} running sequence unpadded, {SEQ:N} sequence
// zero-padded to N digits. Example matching the lastenheft's own sample
// ("WH-2026-0001"): format "WH-{YYYY}-{SEQ:4}".
// `vars` fills rule-specific placeholders, e.g. { ART: 'WA' } for {ART} in
// the raw material format; a placeholder without a value stays as typed.
export function formatGeneratedNumber(
format: string,
sequence: number,
date: Date,
vars: Record<string, string> = {},
): string {
const yyyy = String(date.getFullYear());
const yy = yyyy.slice(-2);
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const withVars = Object.entries(vars).reduce(
(result, [key, value]) => result.split(`{${key}}`).join(value),
format,
);
return withVars
.replace(/\{YYYY\}/g, yyyy)
.replace(/\{YY\}/g, yy)
.replace(/\{MM\}/g, mm)
.replace(/\{DD\}/g, dd)
.replace(/\{SEQ:(\d+)\}/g, (_match, width: string) =>
String(sequence).padStart(
Math.min(Number(width), MAX_SEQUENCE_PAD_WIDTH),
'0',
),
)
.replace(/\{SEQ\}/g, String(sequence));
}
@@ -0,0 +1,67 @@
import { BadRequestException } from '@nestjs/common';
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
describe('backup-crypto', () => {
const originalEnv = { ...process.env };
const bundle = {
version: 2,
exportedAt: '2026-10-01T09:00:00.000Z',
data: { user: [{ id: 'u1', passwordHash: '$2a$12$secret' }] },
};
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
delete process.env.BACKUP_ENCRYPTION_KEY;
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('round-trips a bundle and hides its contents', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
expect(isSealedBackup(sealed)).toBe(true);
expect(JSON.stringify(sealed)).not.toContain('passwordHash');
expect(JSON.stringify(sealed)).not.toContain('$2a$12$secret');
expect(openBackup(sealed)).toEqual(bundle);
});
it('rejects a file whose ciphertext was changed', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
const bytes = Buffer.from(sealed.ciphertext, 'base64');
bytes[0] ^= 0xff;
expect(() =>
openBackup({ ...sealed, ciphertext: bytes.toString('base64') }),
).toThrow(BadRequestException);
});
it('rejects a file whose header was changed', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
expect(() =>
openBackup({ ...sealed, exportedAt: '2020-01-01T00:00:00.000Z' }),
).toThrow(BadRequestException);
});
it('rejects a file sealed with another key', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
expect(() => openBackup(sealed)).toThrow(BadRequestException);
});
it('uses BACKUP_ENCRYPTION_KEY independently of the JWT secret', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(openBackup(sealed)).toEqual(bundle);
});
it('refuses a too short BACKUP_ENCRYPTION_KEY', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'short';
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
});
});
+155
View File
@@ -0,0 +1,155 @@
import { BadRequestException } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as zlib from 'node:zlib';
/**
* A backup holds everything - password hashes, encrypted MFA secrets,
* webhook signing secrets - and an import writes straight into the users
* table. So the file is sealed: AES-256-GCM over the gzipped bundle, which
* 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.
*/
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;
export interface SealedBackup {
format: typeof SEALED_BACKUP_FORMAT;
version: typeof SEALED_BACKUP_VERSION;
algorithm: 'A256GCM+gzip';
/** Tells which key a file needs, without revealing the key. */
keyId: string;
exportedAt: string;
iv: string;
tag: string;
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' };
}
function deriveKey(): { key: Buffer; keyId: string } {
const { secret, source } = rootSecret();
const key = Buffer.from(
crypto.hkdfSync(
'sha256',
secret,
'apicore-backup',
`apicore-backup-v${SEALED_BACKUP_VERSION}:${source}`,
32,
),
);
const keyId = crypto
.createHash('sha256')
.update(key)
.digest('hex')
.slice(0, 12);
return { key, keyId };
}
function associatedData(
header: Pick<SealedBackup, 'format' | 'version' | 'keyId' | 'exportedAt'>,
): Buffer {
return Buffer.from(
`${header.format}|${header.version}|${header.keyId}|${header.exportedAt}`,
);
}
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
const { key, keyId } = deriveKey();
const iv = crypto.randomBytes(IV_BYTES);
const header = {
format: SEALED_BACKUP_FORMAT,
version: SEALED_BACKUP_VERSION,
keyId,
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()]);
return {
...header,
algorithm: 'A256GCM+gzip',
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
};
}
export function isSealedBackup(value: unknown): value is SealedBackup {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
candidate.format === SEALED_BACKUP_FORMAT &&
typeof candidate.ciphertext === 'string'
);
}
/** The bundle inside a sealed backup; throws if the file was not made with this key or 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) {
throw new BadRequestException(
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
);
}
let compressed: Buffer;
try {
const decipher = crypto.createDecipheriv(
ALGORITHM,
key,
Buffer.from(sealed.iv, 'base64'),
);
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',
);
}
let json: string;
try {
json = zlib
.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES })
.toString('utf-8');
} catch {
throw new BadRequestException('Backup-Datei ist zu groß oder beschädigt');
}
return JSON.parse(json) as unknown;
}
@@ -0,0 +1,75 @@
import { Logger } from '@nestjs/common';
import {
DEFAULT_BACKUP_MAX_IMPORT_BYTES,
backupMaxImportBytes,
exceedsBackupImportLimit,
} from './backup-import-limit';
describe('backup-import-limit', () => {
const original = process.env.BACKUP_MAX_IMPORT_BYTES;
let warn: jest.SpyInstance;
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
if (original === undefined) {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
} else {
process.env.BACKUP_MAX_IMPORT_BYTES = original;
}
});
it('allows 50 MB when BACKUP_MAX_IMPORT_BYTES is not set', () => {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
expect(DEFAULT_BACKUP_MAX_IMPORT_BYTES).toBe(50 * 1024 * 1024);
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
expect(warn).not.toHaveBeenCalled();
});
it('takes a positive byte count from BACKUP_MAX_IMPORT_BYTES', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '104857600';
expect(backupMaxImportBytes()).toBe(104857600);
expect(warn).not.toHaveBeenCalled();
});
it.each(['abc', '0', '-5', '1.5', '50MB'])(
'falls back to the default with a logged warning for "%s"',
(value) => {
process.env.BACKUP_MAX_IMPORT_BYTES = value;
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('BACKUP_MAX_IMPORT_BYTES'),
);
},
);
it('caps a value beyond 256 MB, since the whole upload is held in memory', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = String(8 * 1024 * 1024 * 1024);
expect(backupMaxImportBytes()).toBe(256 * 1024 * 1024);
expect(warn).toHaveBeenCalledTimes(1);
});
it('warns only once for the same invalid value', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = 'not-a-number';
backupMaxImportBytes();
backupMaxImportBytes();
expect(warn).toHaveBeenCalledTimes(1);
});
it('flags only a file larger than the limit', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
expect(exceedsBackupImportLimit(1000)).toBe(false);
expect(exceedsBackupImportLimit(1001)).toBe(true);
});
});
Loaded 100 of 769 files, more files were not shown because too many files have changed in this diff. Show more