Compare commits

..
51 Commits
Author SHA1 Message Date
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
369 changed files with 21284 additions and 4013 deletions

No files matched your search

+38 -5
View File
@@ -59,11 +59,11 @@ Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein di
## 3. Auth-Flow ## 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). 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. 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. 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. Jeder geschützte Endpunkt: `JwtAuthGuard` (Authentifizierung) + `RolesGuard` (Autorisierung nach Rolle aus Abschnitt 3). 4. Guards in dieser Reihenfolge: `ThrottlerGuard` → `JwtAuthGuard` (Authentifizierung, Gültigkeit frisch aus der DB je Anfrage) → `PermissionsGuard` (Berechtigung, fail-closed).
5. API-Client-Rolle nutzt dieselben JWT-Endpunkte mit client-credentials-artigem Service-Account (kein OAuth2-Server im MVP, siehe Annahmen). 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 ## 4. Datenfluss / Rückverfolgbarkeit
@@ -100,6 +100,17 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
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. 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. 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. 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 – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. 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 – 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.
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.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
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 Benutzer, Rollen, Webhooks und Audit-Log wieder her; 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.
## 7. Kubernetes ## 7. Kubernetes
`deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`). `deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`).
@@ -119,3 +130,25 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. - **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen.
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. Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
## 9. API-Sicherheit (Stand Oktober 2026)
Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung und Begründung je Punkt in `docs/pruefung-2026-09.md`, Abschnitt „API-Sicherheit“.
| Bereich | Maßnahme |
|---|---|
| Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad |
| Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per `PASSWORD_BREACH_CHECK=false`) |
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben |
| 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.
+18
View File
@@ -9,9 +9,19 @@ JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
JWT_REFRESH_SECRET="change-me-refresh-secret" JWT_REFRESH_SECRET="change-me-refresh-secret"
JWT_ACCESS_EXPIRES_IN="15m" JWT_ACCESS_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d" 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 PORT=3001
FRONTEND_ORIGIN="http://localhost:3000" 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.
TRUST_PROXY=1
# Refresh-token cookie requires HTTPS by default. Set to "false" only for # Refresh-token cookie requires HTTPS by default. Set to "false" only for
# local HTTP development - every real deployment must leave this unset (or # local HTTP development - every real deployment must leave this unset (or
@@ -33,11 +43,19 @@ DOCUMENTS_STORAGE_PATH="./uploads"
# ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift # ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift
# nicht. # nicht.
BACKUP_STORAGE_PATH="./backups" 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). # Cron-Ausdruck (Standard: taeglich um 03:00 Uhr).
BACKUP_CRON_SCHEDULE="0 3 * * *" BACKUP_CRON_SCHEDULE="0 3 * * *"
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach # Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
# jedem Lauf geloescht (Standard: 14). # jedem Lauf geloescht (Standard: 14).
BACKUP_RETENTION_COUNT=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 # Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). # (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
@@ -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");
+160
View File
@@ -80,6 +80,14 @@ model User {
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB. // src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
mfaEnabled Boolean @default(false) mfaEnabled Boolean @default(false)
mfaSecret String? 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? avatarPath String?
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens, // Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
@@ -88,11 +96,17 @@ model User {
// nach einer Passwortaenderung nicht bis zu 15 Minuten weiter funktioniert. // nach einer Passwortaenderung nicht bis zu 15 Minuten weiter funktioniert.
passwordChangedAt DateTime? 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[] refreshTokens RefreshToken[]
inspections Inspection[] inspections Inspection[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[] swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[]
treatments Treatment[] treatments Treatment[]
feedings Feeding[] feedings Feeding[]
harvests Harvest[] harvests Harvest[]
@@ -111,10 +125,47 @@ model User {
webhookSubscriptions WebhookSubscription[] webhookSubscriptions WebhookSubscription[]
documents Document[] documents Document[]
honeyBatchRelocations HoneyBatchRelocation[] honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[]
@@index([roleId]) @@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 { model RefreshToken {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
@@ -123,6 +174,9 @@ model RefreshToken {
expiresAt DateTime expiresAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
revokedAt DateTime? 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]) @@index([userId])
} }
@@ -138,6 +192,7 @@ enum AuditAction {
TOKEN_REFRESHED TOKEN_REFRESHED
MFA_ENABLED MFA_ENABLED
MFA_DISABLED MFA_DISABLED
ACCOUNT_LOCKED
} }
model AuditLog { model AuditLog {
@@ -181,6 +236,7 @@ model Apiary {
colonies Colony[] colonies Colony[]
colonyMoves ColonyApiaryHistory[] colonyMoves ColonyApiaryHistory[]
harvests Harvest[] harvests Harvest[]
treatments Treatment[]
@@index([status]) @@index([status])
} }
@@ -245,6 +301,7 @@ model Colony {
inspections Inspection[] inspections Inspection[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[] swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[]
treatments Treatment[] treatments Treatment[]
feedings Feeding[] feedings Feeding[]
harvests HarvestColony[] harvests HarvestColony[]
@@ -460,11 +517,17 @@ model Task {
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id]) sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
sourceTemplateId String? sourceTemplateId String?
sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id]) 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()) createdAt DateTime @default(now())
completedAt DateTime? completedAt DateTime?
@@index([status, dueDate]) @@index([status, dueDate])
@@index([relatedType, relatedId]) @@index([relatedType, relatedId])
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
} }
enum TaskTemplateRecurrenceType { enum TaskTemplateRecurrenceType {
@@ -564,6 +627,54 @@ model SwarmControlCheck {
@@index([colonyId, date]) @@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) // Behandlung / Tierarzneimittel (Abschnitt 4.11)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -585,6 +696,14 @@ model Treatment {
batchNumber String? batchNumber String?
notes String? notes String?
withdrawalPeriodDays Int? 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()) createdAt DateTime @default(now())
@@index([colonyId, date]) @@index([colonyId, date])
@@ -899,6 +1018,10 @@ model LabelTemplate {
marginLeftMm Float @default(5) marginLeftMm Float @default(5)
gapXMm Float @default(2) gapXMm Float @default(2)
gapYMm 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) isDefault Boolean @default(false)
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -1040,6 +1163,8 @@ enum AutomationTriggerType {
STOCK_ITEM_LOW STOCK_ITEM_LOW
HONEY_BATCH_NUMBER_FORMAT HONEY_BATCH_NUMBER_FORMAT
BOTTLING_LOT_NUMBER_FORMAT BOTTLING_LOT_NUMBER_FORMAT
TREATMENT_FOLLOW_UP
FEEDING_FOLLOW_UP
} }
model AutomationRule { model AutomationRule {
@@ -1071,6 +1196,10 @@ model SiteSettings {
instagramUrl String? instagramUrl String?
tiktokUrl String? tiktokUrl String?
websiteUrl 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 updatedAt DateTime @updatedAt
} }
@@ -1136,6 +1265,37 @@ model CalendarSyncSettings {
// erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das // erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das
// bestehende Graph-Event über die gespeicherte graphEventId, statt ein // bestehende Graph-Event über die gespeicherte graphEventId, statt ein
// Duplikat anzulegen. // 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 { model CalendarSyncOutboxEntry {
id String @id @default(cuid()) id String @id @default(cuid())
sourceType CalendarSyncSourceType sourceType CalendarSyncSourceType
@@ -0,0 +1,18 @@
import { PermissionKey } from '../auth/permissions/permission-catalog';
/**
* Permissions an API key may carry. Deliberately a short allowlist of
* read-only permissions, 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. Removing an entry here also takes it away from existing keys (see
* ApiKeysService.authenticate).
*/
export const API_KEY_SCOPES = [
'shop-sync: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,232 @@
import {
ForbiddenException,
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';
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;
/** 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);
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.
*/
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();
if (
!apiKey ||
apiKey.revokedAt ||
(apiKey.expiresAt && apiKey.expiresAt <= now)
) {
return null;
}
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) };
}
}
/** 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,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');
}
+42 -2
View File
@@ -4,18 +4,58 @@ import helmet from 'helmet';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware'; import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware';
import { noStoreMiddleware } from './common/middleware/no-store.middleware';
/**
* How many reverse-proxy hops to trust for X-Forwarded-For. It decides the
* client IP behind every rate limit and API-key IP allowlist: too few and
* all users share the proxy's IP, too many and a client can forge its own.
* A hop count ("1", the single K8s ingress) or Express' list syntax
* ("loopback, 10.0.0.0/8").
*/
export function trustProxySetting(): number | string {
const raw = process.env.TRUST_PROXY?.trim();
if (!raw) {
return 1;
}
return /^\d+$/.test(raw) ? Number(raw) : raw;
}
export function configureApp(app: NestExpressApplication): void { export function configureApp(app: NestExpressApplication): void {
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the // Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the
// real client IP from X-Forwarded-For — required for per-client throttling // real client IP from X-Forwarded-For — required for per-client throttling
// to work correctly rather than rate-limiting all traffic together. // to work correctly rather than rate-limiting all traffic together.
app.set('trust proxy', 1); app.set('trust proxy', trustProxySetting());
// 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 // 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 - // 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 // see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers
// the 2MB decoded size cap plus base64 overhead. // the 2MB decoded size cap plus base64 overhead.
app.useBodyParser('json', { limit: '5mb' }); app.useBodyParser('json', { limit: '5mb' });
app.use(helmet()); 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(cookieParser());
app.use(requestLoggerMiddleware); app.use(requestLoggerMiddleware);
app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalFilters(new AllExceptionsFilter());
+14 -2
View File
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
@@ -40,13 +40,18 @@ import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module'; import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module'; import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { SwarmControlModule } from './swarm-control/swarm-control.module'; import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { WinterChecksModule } from './winter-checks/winter-checks.module';
import { WeatherModule } from './weather/weather.module'; import { WeatherModule } from './weather/weather.module';
import { ProfileModule } from './profile/profile.module'; import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module'; import { SiteSettingsModule } from './site-settings/site-settings.module';
import { BackupModule } from './backup/backup.module'; import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module'; import { PermissionsModule } from './auth/permissions/permissions.module';
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
@Module({ @Module({
imports: [ imports: [
@@ -91,16 +96,23 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
PublicModule, PublicModule,
TaskTemplatesModule, TaskTemplatesModule,
SwarmControlModule, SwarmControlModule,
WinterChecksModule,
WeatherModule, WeatherModule,
ProfileModule, ProfileModule,
SiteSettingsModule, SiteSettingsModule,
BackupModule, BackupModule,
ShopSyncModule,
ApiKeysModule,
], ],
controllers: [], controllers: [],
providers: [ 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: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard }, { provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
IdempotencyCleanupService,
], ],
}) })
export class AppModule {} export class AppModule {}
@@ -82,6 +82,7 @@ export class AuthController {
} }
@Public() @Public()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async logout( async logout(
+2 -1
View File
@@ -5,6 +5,7 @@ import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
import { OneTimeTokenService } from './one-time-token.service';
import { MfaModule } from './mfa/mfa.module'; import { MfaModule } from './mfa/mfa.module';
import { SsoModule } from './sso/sso.module'; import { SsoModule } from './sso/sso.module';
import { SsoController } from './sso/sso.controller'; import { SsoController } from './sso/sso.controller';
@@ -18,6 +19,6 @@ import { SsoController } from './sso/sso.controller';
SsoModule, SsoModule,
], ],
controllers: [AuthController, SsoController], controllers: [AuthController, SsoController],
providers: [AuthService, JwtStrategy], providers: [AuthService, JwtStrategy, OneTimeTokenService],
}) })
export class AuthModule {} export class AuthModule {}
+121 -26
View File
@@ -5,6 +5,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { MfaService } from './mfa/mfa.service'; import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
import { OneTimeTokenService } from './one-time-token.service';
describe('AuthService', () => { describe('AuthService', () => {
let authService: AuthService; let authService: AuthService;
@@ -26,7 +28,13 @@ describe('AuthService', () => {
let mfaService: { let mfaService: {
signChallengeToken: jest.Mock; signChallengeToken: jest.Mock;
verifyChallengeToken: jest.Mock; verifyChallengeToken: jest.Mock;
verifyCode: jest.Mock; };
let oneTimeTokens: { consume: jest.Mock };
let accountGuard: {
isLocked: jest.Mock;
recordFailure: jest.Mock;
recordSuccess: jest.Mock;
consumeTotp: jest.Mock;
}; };
const activeUser = { const activeUser = {
@@ -60,7 +68,13 @@ describe('AuthService', () => {
mfaService = { mfaService = {
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'), signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
verifyChallengeToken: jest.fn(), verifyChallengeToken: jest.fn(),
verifyCode: 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( authService = new AuthService(
@@ -70,6 +84,8 @@ describe('AuthService', () => {
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
permissionResolver as never, permissionResolver as never,
mfaService as unknown as MfaService, mfaService as unknown as MfaService,
accountGuard as unknown as AccountGuardService,
oneTimeTokens as unknown as OneTimeTokenService,
); );
}); });
@@ -157,21 +173,40 @@ 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); usersService.findByEmail.mockResolvedValue(null);
await expect( await expect(
authService.login('[email protected]', 'x'), authService.login('[email protected]', 'x'),
).rejects.toBeInstanceOf(UnauthorizedException); ).rejects.toBeInstanceOf(UnauthorizedException);
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).not.toHaveBeenCalled();
expect.objectContaining({ });
action: 'LOGIN_FAILED',
entityId: '[email protected]', it('counts a wrong password against the account', async () => {
}), usersService.findByEmail.mockResolvedValue(activeUser);
); await expect(
const [[recordArg]] = auditLog.record.mock.calls as [ authService.login(activeUser.email, 'wrong-password'),
[{ userId?: string }], ).rejects.toBeInstanceOf(UnauthorizedException);
]; expect(accountGuard.recordFailure).toHaveBeenCalledWith(activeUser.id);
expect(recordArg).not.toHaveProperty('userId'); });
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();
}); });
}); });
@@ -183,9 +218,12 @@ describe('AuthService', () => {
}; };
it('issues tokens and records LOGIN when the code is valid', async () => { it('issues tokens and records LOGIN when the code is valid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id }); mfaService.verifyChallengeToken.mockReturnValue({
userId: mfaUser.id,
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
usersService.findById.mockResolvedValue(mfaUser); usersService.findById.mockResolvedValue(mfaUser);
mfaService.verifyCode.mockResolvedValue(true); accountGuard.consumeTotp.mockResolvedValue(true);
prisma.refreshToken.create.mockResolvedValue({}); prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.verifyMfaAndLogin( const result = await authService.verifyMfaAndLogin(
@@ -194,20 +232,20 @@ describe('AuthService', () => {
); );
expect(result.accessToken).toBe('signed.jwt.token'); expect(result.accessToken).toBe('signed.jwt.token');
expect(mfaService.verifyCode).toHaveBeenCalledWith( expect(accountGuard.consumeTotp).toHaveBeenCalledWith(mfaUser, '123456');
mfaUser.mfaSecret, expect(accountGuard.recordSuccess).toHaveBeenCalledWith(mfaUser.id);
mfaUser.email,
'123456',
);
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }), expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }),
); );
}); });
it('throws and records LOGIN_FAILED when the code is invalid', async () => { it('throws and records LOGIN_FAILED when the code is invalid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id }); mfaService.verifyChallengeToken.mockReturnValue({
userId: mfaUser.id,
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
usersService.findById.mockResolvedValue(mfaUser); usersService.findById.mockResolvedValue(mfaUser);
mfaService.verifyCode.mockResolvedValue(false); accountGuard.consumeTotp.mockResolvedValue(false);
await expect( await expect(
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'), authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
@@ -295,7 +333,8 @@ describe('AuthService', () => {
prisma.refreshToken.findUnique.mockResolvedValue({ prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1', id: 'rt-1',
userId: activeUser.id, 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), expiresAt: new Date(Date.now() + 1000),
}); });
prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 }); prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 });
@@ -325,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 () => { it('rotates the refresh token on success', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({ prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1', id: 'rt-1',
userId: activeUser.id, userId: activeUser.id,
revokedAt: null, revokedAt: null,
expiresAt: new Date(Date.now() + 1000), expiresAt: new Date(Date.now() + 1000),
sessionStartedAt: new Date(),
}); });
usersService.findById.mockResolvedValue(activeUser); usersService.findById.mockResolvedValue(activeUser);
prisma.refreshToken.update.mockResolvedValue({}); prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
prisma.refreshToken.create.mockResolvedValue({}); prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.refresh('valid-token'); const result = await authService.refresh('valid-token');
expect(result.accessToken).toBe('signed.jwt.token'); expect(result.accessToken).toBe('signed.jwt.token');
expect(prisma.refreshToken.update).toHaveBeenCalledWith( expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'rt-1' } }), expect.objectContaining({ where: { id: 'rt-1', revokedAt: null } }),
); );
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
+84 -31
View File
@@ -7,7 +7,10 @@ import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service'; import { PermissionResolverService } from './permissions/permission-resolver.service';
import { MfaService } from './mfa/mfa.service'; 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 { parseDurationToMs } from './util/duration';
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
interface AuthTokens { interface AuthTokens {
accessToken: string; accessToken: string;
@@ -31,6 +34,25 @@ export interface CurrentUserProfile {
const REFRESH_TOKEN_BYTES = 40; 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() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
@@ -42,6 +64,8 @@ export class AuthService {
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly mfaService: MfaService, private readonly mfaService: MfaService,
private readonly accountGuard: AccountGuardService,
private readonly oneTimeTokens: OneTimeTokenService,
) {} ) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> { async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
@@ -72,56 +96,64 @@ export class AuthService {
); );
return this.jwtService.sign( return this.jwtService.sign(
{ sub: user.id, email: user.email }, { sub: user.id, email: user.email },
{ jwtSignOptions(TOKEN_AUDIENCE.access, expiresInSeconds),
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: 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 token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex');
const expiresAt = new Date( const slidingExpiry =
Date.now() + 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({ 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; return token;
} }
async login(email: string, password: string): Promise<LoginResult> { async login(email: string, password: string): Promise<LoginResult> {
const user = await this.usersService.findByEmail(email); const user = await this.usersService.findByEmail(email);
// 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) { if (!user) {
this.logger.warn(`Login failed: no user with email ${email}`); this.logger.warn('Login failed: unknown email');
await this.auditLog.record({ throw new UnauthorizedException(INVALID_CREDENTIALS);
entityType: 'AuthSession',
entityId: email,
action: 'LOGIN_FAILED',
});
throw new UnauthorizedException('Invalid credentials');
} }
if (!user.isActive) { if (!user.isActive || this.accountGuard.isLocked(user)) {
this.logger.warn(`Login failed: user ${user.id} is deactivated`); this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'AuthSession', entityType: 'AuthSession',
entityId: user.id, entityId: user.id,
action: 'LOGIN_FAILED', action: 'LOGIN_FAILED',
userId: user.id, userId: user.id,
}); });
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException(INVALID_CREDENTIALS);
} }
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) { if (!passwordMatches) {
this.logger.warn(`Login failed: wrong password for user ${user.id}`); this.logger.warn(`Login failed: wrong password for user ${user.id}`);
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'AuthSession', entityType: 'AuthSession',
entityId: user.id, entityId: user.id,
action: 'LOGIN_FAILED', action: 'LOGIN_FAILED',
userId: user.id, userId: user.id,
}); });
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException(INVALID_CREDENTIALS);
} }
if (user.mfaEnabled) { if (user.mfaEnabled) {
return { return {
@@ -135,6 +167,7 @@ export class AuthService {
}; };
const accessToken = this.signAccessToken(authenticatedUser); const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id); const refreshToken = await this.issueRefreshToken(user.id);
await this.accountGuard.recordSuccess(user.id);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'AuthSession', entityType: 'AuthSession',
entityId: user.id, entityId: user.id,
@@ -177,20 +210,20 @@ export class AuthService {
} }
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> { async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
const { userId } = this.mfaService.verifyChallengeToken(mfaToken); const { userId, claims } = this.mfaService.verifyChallengeToken(mfaToken);
const user = await this.usersService.findById(userId); const user = await this.usersService.findById(userId);
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) { if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
throw new UnauthorizedException('MFA nicht verfügbar'); throw new UnauthorizedException('MFA nicht verfügbar');
} }
const isValid = await this.mfaService.verifyCode( if (this.accountGuard.isLocked(user)) {
user.mfaSecret, throw new UnauthorizedException(INVALID_CREDENTIALS);
user.email, }
code, const isValid = await this.accountGuard.consumeTotp(user, code);
);
if (!isValid) { if (!isValid) {
this.logger.warn( this.logger.warn(
`MFA verification failed: wrong code for user ${user.id}`, `MFA verification failed: wrong or reused code for user ${user.id}`,
); );
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'AuthSession', entityType: 'AuthSession',
entityId: user.id, entityId: user.id,
@@ -199,6 +232,9 @@ export class AuthService {
}); });
throw new UnauthorizedException('Code ungültig'); 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 = { const authenticatedUser = {
id: user.id, id: user.id,
email: user.email, email: user.email,
@@ -222,6 +258,14 @@ export class AuthService {
if (!stored) { if (!stored) {
throw new UnauthorizedException('Invalid refresh token'); 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) { if (stored.revokedAt) {
// Reuse of an already-rotated-out refresh token is a strong signal // Reuse of an already-rotated-out refresh token is a strong signal
// it was stolen (a legitimate client only ever presents the newest // it was stolen (a legitimate client only ever presents the newest
@@ -244,16 +288,25 @@ export class AuthService {
if (!user.isActive) { if (!user.isActive) {
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedException('Invalid refresh token');
} }
await this.prisma.refreshToken.update({ // Atomic: of two requests presenting the same token, exactly one wins
where: { id: stored.id }, // 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() }, data: { revokedAt: new Date() },
}); });
if (claimed.count !== 1) {
throw new UnauthorizedException('Invalid refresh token');
}
const authenticatedUser = { const authenticatedUser = {
id: user.id, id: user.id,
email: user.email, email: user.email,
}; };
const accessToken = this.signAccessToken(authenticatedUser); 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({ await this.auditLog.record({
entityType: 'AuthSession', entityType: 'AuthSession',
entityId: user.id, entityId: user.id,
+6 -1
View File
@@ -1,13 +1,18 @@
import { ApiProperty } from '@nestjs/swagger'; 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 { export class LoginDto {
@ApiProperty() @ApiProperty()
@NormalizeEmail()
@IsEmail() @IsEmail()
email!: string; email!: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@MinLength(8) @MinLength(8)
// Long enough for any real password; bounds the bcrypt work an
// anonymous request can cause.
@MaxLength(256)
password!: string; password!: string;
} }
@@ -2,16 +2,19 @@ import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
import { PermissionResolverService } from '../permissions/permission-resolver.service'; import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { ApiKeysService } from '../../api-keys/api-keys.service';
describe('JwtAuthGuard', () => { describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard; let guard: JwtAuthGuard;
let reflector: { getAllAndOverride: jest.Mock }; let reflector: { getAllAndOverride: jest.Mock };
let permissionResolver: { isTokenValid: jest.Mock }; let permissionResolver: { isTokenValid: jest.Mock };
let apiKeys: { authenticate: jest.Mock };
function contextWithUser( function contextWithUser(
user: { id: string; tokenIssuedAt?: number } | undefined, user: { id: string; tokenIssuedAt?: number } | undefined,
authorization?: string,
): ExecutionContext { ): ExecutionContext {
const request = { user }; const request = { user, headers: { authorization } };
return { return {
getHandler: () => ({}), getHandler: () => ({}),
getClass: () => ({}), getClass: () => ({}),
@@ -22,9 +25,11 @@ describe('JwtAuthGuard', () => {
beforeEach(() => { beforeEach(() => {
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) }; reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) };
permissionResolver = { isTokenValid: jest.fn() }; permissionResolver = { isTokenValid: jest.fn() };
apiKeys = { authenticate: jest.fn() };
guard = new JwtAuthGuard( guard = new JwtAuthGuard(
reflector as unknown as Reflector, reflector as unknown as Reflector,
permissionResolver as unknown as PermissionResolverService, permissionResolver as unknown as PermissionResolverService,
apiKeys as unknown as ApiKeysService,
); );
}); });
@@ -72,4 +77,27 @@ describe('JwtAuthGuard', () => {
UnauthorizedException, 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,9 +8,17 @@ import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PermissionResolverService } from '../permissions/permission-resolver.service'; import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user'; import { AuthenticatedUser } from '../types/authenticated-user';
import {
ApiKeyPrincipal,
ApiKeysService,
extractApiKeyToken,
} from '../../api-keys/api-keys.service';
interface RequestWithUser { interface RequestWithUser {
headers: { authorization?: string };
ip?: string;
user?: AuthenticatedUser; user?: AuthenticatedUser;
apiKey?: ApiKeyPrincipal;
} }
/** /**
@@ -28,6 +36,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
constructor( constructor(
private readonly reflector: Reflector, private readonly reflector: Reflector,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly apiKeys: ApiKeysService,
) { ) {
super(); super();
} }
@@ -40,11 +49,29 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
if (isPublic) { if (isPublic) {
return true; 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; const authenticated = (await super.canActivate(context)) as boolean;
if (!authenticated) { if (!authenticated) {
return false; return false;
} }
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user; const user = request.user;
if ( if (
!user || !user ||
@@ -6,9 +6,11 @@ import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorat
import { PermissionKey } from '../permissions/permission-catalog'; import { PermissionKey } from '../permissions/permission-catalog';
import { PermissionResolverService } from '../permissions/permission-resolver.service'; import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user'; import { AuthenticatedUser } from '../types/authenticated-user';
import type { ApiKeyPrincipal } from '../../api-keys/api-keys.service';
interface RequestWithUser { interface RequestWithUser {
user?: AuthenticatedUser; user?: AuthenticatedUser;
apiKey?: ApiKeyPrincipal;
} }
/** /**
@@ -47,6 +49,15 @@ export class PermissionsGuard implements CanActivate {
const request = context.switchToHttp().getRequest<RequestWithUser>(); const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user; 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 (requiredPermissions && requiredPermissions.length > 0) {
if (!user) { if (!user) {
return false; return false;
@@ -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;
}
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { MfaService } from './mfa.service'; import { MfaService } from './mfa.service';
import { AccountGuardService } from './account-guard.service';
@Module({ @Module({
imports: [JwtModule.register({})], imports: [JwtModule.register({})],
providers: [MfaService], providers: [MfaService, AccountGuardService],
exports: [MfaService], exports: [MfaService, AccountGuardService],
}) })
export class MfaModule {} export class MfaModule {}
@@ -139,10 +139,13 @@ describe('MfaService', () => {
jwtService.verify.mockReturnValue({ jwtService.verify.mockReturnValue({
sub: 'user-1', sub: 'user-1',
purpose: 'mfa-challenge', purpose: 'mfa-challenge',
jti: 'jti-1',
exp: 1_900_000_000,
}); });
expect(mfaService.verifyChallengeToken('token')).toEqual({ expect(mfaService.verifyChallengeToken('token')).toEqual({
userId: 'user-1', userId: 'user-1',
claims: { jti: 'jti-1', exp: 1_900_000_000 },
}); });
}); });
+45 -12
View File
@@ -2,7 +2,18 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util'; import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.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 SETUP_TOKEN_TTL = '10m';
const CHALLENGE_TOKEN_TTL = '5m'; const CHALLENGE_TOKEN_TTL = '5m';
@@ -41,7 +52,7 @@ export class MfaService {
}); });
const setupToken = this.jwtService.sign( const setupToken = this.jwtService.sign(
{ sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE }, { sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE },
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: SETUP_TOKEN_TTL }, jwtSignOptions(TOKEN_AUDIENCE.mfaSetup, SETUP_TOKEN_TTL),
); );
return { secret, qrCodeSvg, otpauthUri, setupToken }; return { secret, qrCodeSvg, otpauthUri, setupToken };
} }
@@ -55,9 +66,10 @@ export class MfaService {
): Promise<{ encryptedSecret: string }> { ): Promise<{ encryptedSecret: string }> {
let payload: SetupTokenPayload; let payload: SetupTokenPayload;
try { try {
payload = this.jwtService.verify<SetupTokenPayload>(setupToken, { payload = this.jwtService.verify<SetupTokenPayload>(
secret: process.env.JWT_ACCESS_SECRET, setupToken,
}); jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup),
);
} catch { } catch {
throw new UnauthorizedException( throw new UnauthorizedException(
'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten', 'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten',
@@ -76,16 +88,19 @@ export class MfaService {
signChallengeToken(userId: string): string { signChallengeToken(userId: string): string {
return this.jwtService.sign( return this.jwtService.sign(
{ sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE }, { sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE },
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: CHALLENGE_TOKEN_TTL }, jwtSignOptions(TOKEN_AUDIENCE.mfaChallenge, CHALLENGE_TOKEN_TTL),
); );
} }
verifyChallengeToken(challengeToken: string): { userId: string } { verifyChallengeToken(challengeToken: string): {
let payload: ChallengeTokenPayload; userId: string;
claims: OneTimeTokenClaims;
} {
let payload: ChallengeTokenPayload & OneTimeTokenClaims;
try { try {
payload = this.jwtService.verify<ChallengeTokenPayload>(challengeToken, { payload = this.jwtService.verify<
secret: process.env.JWT_ACCESS_SECRET, ChallengeTokenPayload & OneTimeTokenClaims
}); >(challengeToken, jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge));
} catch { } catch {
throw new UnauthorizedException( throw new UnauthorizedException(
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden', 'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
@@ -94,7 +109,25 @@ export class MfaService {
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) { if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token'); throw new UnauthorizedException('Ungültiger Token');
} }
return { userId: payload.sub }; 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( async verifyCode(
+17
View File
@@ -20,6 +20,23 @@ export function buildTotpUri(secret: string, label: string): string {
return createTotp(secret, label).toURI(); 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( export async function verifyTotpCode(
secret: string, secret: string,
label: string, label: string,
@@ -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;
}
}
}
@@ -5,7 +5,7 @@ import { PERMISSION_CATALOG } from './permission-catalog';
describe('PermissionCatalogSyncService', () => { describe('PermissionCatalogSyncService', () => {
let service: PermissionCatalogSyncService; let service: PermissionCatalogSyncService;
let prisma: { let prisma: {
permission: { findMany: jest.Mock; create: jest.Mock }; permission: { findMany: jest.Mock; createMany: jest.Mock };
role: { findMany: jest.Mock }; role: { findMany: jest.Mock };
rolePermission: { createMany: jest.Mock }; rolePermission: { createMany: jest.Mock };
}; };
@@ -13,10 +13,10 @@ describe('PermissionCatalogSyncService', () => {
beforeEach(() => { beforeEach(() => {
prisma = { prisma = {
permission: { 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(), findMany: jest.fn(),
create: jest.fn((args: { data: { key: string } }) => createMany: jest.fn().mockResolvedValue({ count: 0 }),
Promise.resolve({ id: `perm-${args.data.key}`, ...args.data }),
),
}, },
role: { findMany: jest.fn().mockResolvedValue([]) }, role: { findMany: jest.fn().mockResolvedValue([]) },
rolePermission: { createMany: jest.fn().mockResolvedValue({ count: 0 }) }, rolePermission: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
@@ -33,16 +33,16 @@ describe('PermissionCatalogSyncService', () => {
await service.onModuleInit(); await service.onModuleInit();
expect(prisma.permission.create).not.toHaveBeenCalled(); expect(prisma.permission.createMany).not.toHaveBeenCalled();
expect(prisma.role.findMany).not.toHaveBeenCalled(); expect(prisma.role.findMany).not.toHaveBeenCalled();
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled(); expect(prisma.rolePermission.createMany).not.toHaveBeenCalled();
}); });
it('inserts missing catalog permissions and grants them to every system role', async () => { it('inserts missing catalog permissions and grants them to every system role', async () => {
const [first, ...rest] = PERMISSION_CATALOG; const [first, ...rest] = PERMISSION_CATALOG;
prisma.permission.findMany.mockResolvedValue( prisma.permission.findMany
rest.map((entry) => ({ key: entry.key })), .mockResolvedValueOnce(rest.map((entry) => ({ key: entry.key })))
); .mockResolvedValueOnce([{ id: `perm-${first.key}` }]);
prisma.role.findMany.mockResolvedValue([ prisma.role.findMany.mockResolvedValue([
{ id: 'role-admin' }, { id: 'role-admin' },
{ id: 'role-owner' }, { id: 'role-owner' },
@@ -50,7 +50,10 @@ describe('PermissionCatalogSyncService', () => {
await service.onModuleInit(); await service.onModuleInit();
expect(prisma.permission.create).toHaveBeenCalledWith({ data: first }); expect(prisma.permission.createMany).toHaveBeenCalledWith({
data: [first],
skipDuplicates: true,
});
expect(prisma.role.findMany).toHaveBeenCalledWith({ expect(prisma.role.findMany).toHaveBeenCalledWith({
where: { isSystem: true }, where: { isSystem: true },
select: { id: true }, select: { id: true },
@@ -70,9 +73,26 @@ describe('PermissionCatalogSyncService', () => {
await service.onModuleInit(); await service.onModuleInit();
expect(prisma.permission.create).toHaveBeenCalledTimes( expect(prisma.permission.createMany).toHaveBeenCalledWith({
PERMISSION_CATALOG.length, data: PERMISSION_CATALOG,
); skipDuplicates: true,
});
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled(); 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,
});
});
}); });
@@ -33,10 +33,18 @@ export class PermissionCatalogSyncService implements OnModuleInit {
const missing = PERMISSION_CATALOG.filter( const missing = PERMISSION_CATALOG.filter(
(entry) => !existingKeys.has(entry.key), (entry) => !existingKeys.has(entry.key),
); );
const createdPermissions = await Promise.all(
missing.map((entry) => this.prisma.permission.create({ data: entry })),
);
if (missing.length > 0) { 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( this.logger.log(
`Synced ${missing.length} new permission(s) from the code catalog: ${missing `Synced ${missing.length} new permission(s) from the code catalog: ${missing
.map((entry) => entry.key) .map((entry) => entry.key)
@@ -111,6 +111,18 @@ export const PERMISSION_CATALOG = [
], ],
800, 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( ...group(
'Behandlungen', 'Behandlungen',
'treatments', 'treatments',
@@ -306,6 +318,21 @@ export const PERMISSION_CATALOG = [
], ],
3200, 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']],
3300,
),
...group(
'API-Schlüssel',
'api-keys',
[['manage', 'Anlegen, ansehen und widerrufen']],
3400,
),
] as const satisfies readonly PermissionCatalogEntry[]; ] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -165,15 +165,34 @@ describe('PermissionResolverService', () => {
); );
}); });
it('shares the cached entry with getPermissions instead of re-querying', async () => { it('reads fresh every time and refreshes the cache for getPermissions', async () => {
prisma.user.findUnique.mockResolvedValue( prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'), activeUserWithPermissions('colonies:read'),
); );
await resolver.getPermissions('user-1'); await resolver.getPermissions('user-1');
await resolver.isTokenValid('user-1', nowSeconds); 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,
);
}); });
}); });
@@ -37,11 +37,18 @@ export class PermissionResolverService {
return entry.permissions; return entry.permissions;
} }
/**
* 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( async isTokenValid(
userId: string, userId: string,
tokenIssuedAtSeconds: number, tokenIssuedAtSeconds: number,
): Promise<boolean> { ): Promise<boolean> {
const entry = await this.getEntry(userId); const entry = await this.loadEntry(userId);
this.cache.set(userId, entry);
if (!entry.isActive) { if (!entry.isActive) {
return false; return false;
} }
@@ -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);
}
}
+13 -7
View File
@@ -17,6 +17,7 @@ import { MfaRequiredResponse } from '../auth.controller';
import { AccessTokenDto } from '../dto/access-token.dto'; import { AccessTokenDto } from '../dto/access-token.dto';
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle'; import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
import { setRefreshTokenCookie } from '../util/refresh-token-cookie'; import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
import { OneTimeTokenService } from '../one-time-token.service';
import { SsoService } from './sso.service'; import { SsoService } from './sso.service';
const PKCE_COOKIE = 'oidcPkceVerifier'; const PKCE_COOKIE = 'oidcPkceVerifier';
@@ -40,6 +41,7 @@ export class SsoController {
constructor( constructor(
private readonly ssoService: SsoService, private readonly ssoService: SsoService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly oneTimeTokens: OneTimeTokenService,
) {} ) {}
private frontendLoginUrl(): string { private frontendLoginUrl(): string {
@@ -82,7 +84,7 @@ export class SsoController {
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[ const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
PKCE_COOKIE PKCE_COOKIE
]; ];
res.clearCookie(PKCE_COOKIE, { path: PKCE_COOKIE_PATH }); res.clearCookie(PKCE_COOKIE, this.cookieOptions(PKCE_COOKIE_PATH, 0));
const pkce = this.parsePkceCookie(rawCookie); const pkce = this.parsePkceCookie(rawCookie);
if (!pkce) { if (!pkce) {
@@ -90,10 +92,10 @@ export class SsoController {
return; return;
} }
const callbackUrl = new URL( // Built from the configured redirect URI, not from the Host header a
req.originalUrl, // client sends; only the query (code, state, iss) comes from the request.
`${req.protocol}://${req.get('host')}`, 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( const result = await this.ssoService.handleCallback(
callbackUrl, callbackUrl,
pkce.codeVerifier, pkce.codeVerifier,
@@ -126,13 +128,17 @@ export class SsoController {
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[ const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
SSO_CODE_COOKIE SSO_CODE_COOKIE
]; ];
res.clearCookie(SSO_CODE_COOKIE, { path: SSO_CODE_COOKIE_PATH }); res.clearCookie(
SSO_CODE_COOKIE,
this.cookieOptions(SSO_CODE_COOKIE_PATH, 0),
);
if (!ssoCode) { if (!ssoCode) {
throw new UnauthorizedException( throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen', 'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
); );
} }
const { userId } = this.ssoService.verifySsoCode(ssoCode); const { userId, claims } = this.ssoService.verifySsoCode(ssoCode);
await this.oneTimeTokens.consume(claims);
const result = await this.authService.issueTokensForSsoUser(userId); const result = await this.authService.issueTokensForSsoUser(userId);
if (result.mfaRequired) { if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken }; return { mfaRequired: true, mfaToken: result.mfaToken };
@@ -18,9 +18,26 @@ jest.mock('openid-client', () => ({
const mockedClient = client as jest.Mocked<typeof client>; 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', () => { describe('SsoService', () => {
let ssoService: SsoService; let ssoService: SsoService;
let usersService: { findByEmail: jest.Mock }; let usersService: {
findByEmail: jest.Mock;
findBySsoSubject: jest.Mock;
linkSsoSubject: jest.Mock;
};
let jwtService: { sign: jest.Mock; verify: jest.Mock }; let jwtService: { sign: jest.Mock; verify: jest.Mock };
const originalEnv = { ...process.env }; const originalEnv = { ...process.env };
@@ -33,7 +50,11 @@ describe('SsoService', () => {
process.env.OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI =
'https://api.example.com/api/v1/auth/sso/callback'; 'https://api.example.com/api/v1/auth/sso/callback';
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret'; process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
usersService = { findByEmail: jest.fn() }; usersService = {
findByEmail: jest.fn(),
findBySsoSubject: jest.fn().mockResolvedValue(null),
linkSsoSubject: jest.fn(),
};
jwtService = { sign: jest.fn(), verify: jest.fn() }; jwtService = { sign: jest.fn(), verify: jest.fn() };
ssoService = new SsoService( ssoService = new SsoService(
usersService as unknown as UsersService, usersService as unknown as UsersService,
@@ -50,6 +71,12 @@ describe('SsoService', () => {
expect(ssoService.isConfigured()).toBe(true); 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', () => { it('returns false when a variable is missing', () => {
delete process.env.OIDC_CLIENT_SECRET; delete process.env.OIDC_CLIENT_SECRET;
expect(ssoService.isConfigured()).toBe(false); expect(ssoService.isConfigured()).toBe(false);
@@ -116,7 +143,7 @@ describe('SsoService', () => {
it('returns no_email when the ID token has neither email nor preferred_username', async () => { it('returns no_email when the ID token has neither email nor preferred_username', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({ mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ sub: 'abc' }), claims: () => ({ ...IDENTITY }),
} as unknown as Awaited< } as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant> ReturnType<typeof client.authorizationCodeGrant>
>); >);
@@ -132,7 +159,7 @@ describe('SsoService', () => {
it('returns no_account when no active local user matches the email claim', async () => { it('returns no_account when no active local user matches the email claim', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({ mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ email: '[email protected]' }), claims: () => ({ ...IDENTITY, email: '[email protected]' }),
} as unknown as Awaited< } as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant> ReturnType<typeof client.authorizationCodeGrant>
>); >);
@@ -149,7 +176,10 @@ describe('SsoService', () => {
it('signs a short-lived sso-complete token for an active matching user', async () => { it('signs a short-lived sso-complete token for an active matching user', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({ mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ preferred_username: '[email protected]' }), claims: () => ({
...IDENTITY,
preferred_username: '[email protected]',
}),
} as unknown as Awaited< } as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant> ReturnType<typeof client.authorizationCodeGrant>
>); >);
@@ -175,6 +205,56 @@ describe('SsoService', () => {
expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }), expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }),
expect.objectContaining({ expiresIn: '60s' }), 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();
}); });
}); });
@@ -183,8 +263,13 @@ describe('SsoService', () => {
jwtService.verify.mockReturnValue({ jwtService.verify.mockReturnValue({
sub: 'user-1', sub: 'user-1',
purpose: 'sso-complete', 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 },
}); });
expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' });
}); });
it('throws UnauthorizedException when verification fails', () => { it('throws UnauthorizedException when verification fails', () => {
+86 -20
View File
@@ -7,6 +7,12 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as client from 'openid-client'; import * as client from 'openid-client';
import { UsersService } from '../../users/users.service'; 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_TTL = '60s';
const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete'; const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete';
@@ -16,6 +22,21 @@ interface SsoCompleteTokenPayload {
purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE; 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 = export type SsoCallbackResult =
| { status: 'success'; ssoCode: string } | { status: 'success'; ssoCode: string }
| { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' }; | { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' };
@@ -31,12 +52,22 @@ export class SsoService {
) {} ) {}
isConfigured(): boolean { isConfigured(): boolean {
return Boolean( const issuer = process.env.OIDC_ISSUER_URL;
process.env.OIDC_ISSUER_URL && if (
process.env.OIDC_CLIENT_ID && !issuer ||
process.env.OIDC_CLIENT_SECRET && !process.env.OIDC_CLIENT_ID ||
process.env.OIDC_REDIRECT_URI, !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> { private async getConfig(): Promise<client.Configuration> {
@@ -93,6 +124,27 @@ export class SsoService {
return { status: 'error', reason: 'exchange_failed' }; return { status: 'error', reason: 'exchange_failed' };
} }
const claims = tokens.claims(); 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 = const email =
(claims?.email as string | undefined) ?? (claims?.email as string | undefined) ??
(claims?.preferred_username as string | undefined); (claims?.preferred_username as string | undefined);
@@ -104,27 +156,38 @@ export class SsoService {
} }
const user = await this.usersService.findByEmail(email); const user = await this.usersService.findByEmail(email);
if (!user || !user.isActive) { 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( this.logger.warn(
`SSO login denied: no active local account for ${email}`, `SSO login denied: user ${user.id} is linked to another identity`,
); );
return { status: 'error', reason: 'no_account' }; return { status: 'error', reason: 'no_account' };
} }
const ssoCode = this.jwtService.sign( // First sign-in: bind the account to this identity for good.
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE }, await this.usersService.linkSsoSubject(user.id, ssoSubject);
{ return { status: 'success', ssoCode: this.signSsoCode(user.id) };
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: SSO_COMPLETE_TOKEN_TTL,
},
);
return { status: 'success', ssoCode };
} }
verifySsoCode(ssoCode: string): { userId: string } { private signSsoCode(userId: string): string {
let payload: SsoCompleteTokenPayload; 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 { try {
payload = this.jwtService.verify<SsoCompleteTokenPayload>(ssoCode, { payload = this.jwtService.verify<
secret: process.env.JWT_ACCESS_SECRET, SsoCompleteTokenPayload & OneTimeTokenClaims
}); >(ssoCode, jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete));
} catch { } catch {
throw new UnauthorizedException( throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen', 'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
@@ -133,6 +196,9 @@ export class SsoService {
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) { if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token'); throw new UnauthorizedException('Ungültiger Token');
} }
return { userId: payload.sub }; return {
userId: payload.sub,
claims: { jti: payload.jti, exp: payload.exp },
};
} }
} }
@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthenticatedUser } from '../types/authenticated-user'; import { AuthenticatedUser } from '../types/authenticated-user';
import { JWT_ALGORITHM, JWT_ISSUER, TOKEN_AUDIENCE } from '../util/jwt-claims';
interface AccessTokenPayload { interface AccessTokenPayload {
sub: string; sub: string;
@@ -10,6 +11,8 @@ interface AccessTokenPayload {
// reject tokens issued before the user's last password change (see // reject tokens issued before the user's last password change (see
// PermissionResolverService.isTokenValid). // PermissionResolverService.isTokenValid).
iat: number; iat: number;
// Set only on the helper tokens (MFA setup/challenge, SSO completion).
purpose?: string;
} }
@Injectable() @Injectable()
@@ -19,10 +22,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: process.env.JWT_ACCESS_SECRET as string, 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 { validate(payload: AccessTokenPayload): AuthenticatedUser {
if (payload.purpose !== undefined || typeof payload.sub !== 'string') {
throw new UnauthorizedException();
}
return { return {
id: payload.sub, id: payload.sub,
email: payload.email, email: payload.email,
@@ -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,
};
}
@@ -5,24 +5,32 @@ import { parseDurationToMs } from './duration';
export const REFRESH_TOKEN_COOKIE = 'refreshToken'; export const REFRESH_TOKEN_COOKIE = 'refreshToken';
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth'; export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
export function setRefreshTokenCookie( function cookieOptions() {
res: Response, return {
refreshToken: string,
): void {
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
httpOnly: true, httpOnly: true,
// Explicit opt-out for local HTTP dev only; every real deployment must // Explicit opt-out for local HTTP dev only; every real deployment must
// set COOKIE_SECURE=true so the refresh-token cookie is never sent // set COOKIE_SECURE=true so the refresh-token cookie is never sent
// over plaintext HTTP. // over plaintext HTTP.
secure: process.env.COOKIE_SECURE !== 'false', secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax', sameSite: 'lax' as const,
path: REFRESH_TOKEN_COOKIE_PATH, 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'), 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 { export function clearRefreshTokenCookie(res: Response): void {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH }); res.clearCookie(REFRESH_TOKEN_COOKIE, cookieOptions());
} }
export function extractRefreshToken(req: Request): string { export function extractRefreshToken(req: Request): string {
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationTriggerType } from '../../generated/prisma';
describe('AutomationService', () => { describe('AutomationService', () => {
let service: AutomationService; let service: AutomationService;
@@ -14,7 +15,7 @@ describe('AutomationService', () => {
findUnique: jest.Mock; findUnique: jest.Mock;
update: jest.Mock; update: jest.Mock;
}; };
task: { create: jest.Mock }; task: { create: jest.Mock; findFirst: jest.Mock };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let notifications: { record: jest.Mock }; let notifications: { record: jest.Mock };
@@ -50,7 +51,10 @@ describe('AutomationService', () => {
findUnique: jest.fn(), findUnique: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
task: { create: jest.fn().mockResolvedValue({ id: 'task-1' }) }, task: {
create: jest.fn().mockResolvedValue({ id: 'task-1' }),
findFirst: jest.fn().mockResolvedValue(null),
},
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
notifications = { record: jest.fn() }; notifications = { record: jest.fn() };
@@ -79,7 +83,9 @@ describe('AutomationService', () => {
const result = await service.findAll(); const result = await service.findAll();
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(8); expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(
Object.values(AutomationTriggerType).length,
);
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
}); });
@@ -154,6 +160,7 @@ describe('AutomationService', () => {
await service.evaluateVarroaMeasurement({ await service.evaluateVarroaMeasurement({
id: 'measurement-1', id: 'measurement-1',
colonyId: 'colony-1', colonyId: 'colony-1',
method: 'NATURAL_MITE_FALL',
calculatedValue: 12, calculatedValue: 12,
miteCount: null, miteCount: null,
} as never); } as never);
@@ -167,6 +174,7 @@ describe('AutomationService', () => {
await service.evaluateVarroaMeasurement({ await service.evaluateVarroaMeasurement({
id: 'measurement-2', id: 'measurement-2',
colonyId: 'colony-1', colonyId: 'colony-1',
method: 'NATURAL_MITE_FALL',
calculatedValue: 5, calculatedValue: 5,
miteCount: null, miteCount: null,
} as never); } as never);
@@ -174,17 +182,59 @@ describe('AutomationService', () => {
expect(prisma.task.create).not.toHaveBeenCalled(); 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); prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
await service.evaluateVarroaMeasurement({ await service.evaluateVarroaMeasurement({
id: 'measurement-3', id: 'measurement-3',
colonyId: 'colony-1', colonyId: 'colony-1',
method: 'NATURAL_MITE_FALL',
calculatedValue: null, calculatedValue: null,
miteCount: 15, miteCount: 15,
} as never); } as never);
expect(prisma.task.create).toHaveBeenCalledTimes(1); 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 },
});
}); });
}); });
@@ -8,10 +8,12 @@ import {
AutomationTriggerType, AutomationTriggerType,
Colony, Colony,
Inspection, Inspection,
Feeding,
Prisma, Prisma,
StockItem, StockItem,
SwarmControlCheck, SwarmControlCheck,
TaskRelatedType, TaskRelatedType,
Treatment,
VarroaMeasurement, VarroaMeasurement,
} from '../../generated/prisma'; } from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
@@ -20,6 +22,8 @@ import { formatGeneratedNumber } from './number-format';
interface RuleDefaults { interface RuleDefaults {
config: Record<string, unknown>; config: Record<string, unknown>;
actionTemplate: string; actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
} }
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = { const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
@@ -60,8 +64,55 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: { format: 'WH-{YYYY}-{SEQ:4}' }, config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '', actionTemplate: '',
}, },
// 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() @Injectable()
export class AutomationService { export class AutomationService {
constructor( constructor(
@@ -81,6 +132,7 @@ export class AutomationService {
update: {}, update: {},
create: { create: {
triggerType, triggerType,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue, config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate, actionTemplate: defaults.actionTemplate,
}, },
@@ -174,9 +226,16 @@ export class AutomationService {
if (!rule || !rule.active) { if (!rule || !rule.active) {
return; 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 config = rule.config as { thresholdMitesPerDay?: number } | null;
const threshold = config?.thresholdMitesPerDay ?? 10; const threshold = config?.thresholdMitesPerDay ?? 10;
const value = measurement.calculatedValue ?? measurement.miteCount; const value = measurement.calculatedValue;
if (value !== null && value >= threshold) { if (value !== null && value >= threshold) {
await this.triggerAction(rule, 'COLONY', measurement.colonyId); await this.triggerAction(rule, 'COLONY', measurement.colonyId);
} }
@@ -227,6 +286,44 @@ export class AutomationService {
await this.triggerAction(rule, 'STORAGE', item.id); await this.triggerAction(rule, 'STORAGE', item.id);
} }
async evaluateTreatment(treatment: Treatment): Promise<void> {
await 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.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 // Returns null (never throws) when the rule is inactive or has no format
// configured, so the caller can fall back to requiring manual entry. // configured, so the caller can fall back to requiring manual entry.
async generateNumber( async generateNumber(
@@ -274,13 +371,36 @@ export class AutomationService {
return 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.
*/
private async triggerAction( private async triggerAction(
rule: AutomationRule, rule: AutomationRule,
relatedType: TaskRelatedType, relatedType: TaskRelatedType,
relatedId: string, relatedId: string,
sourceInspectionId?: string, sourceInspectionId?: string,
options: { baseDate?: Date; description?: string } = {},
): Promise<void> { ): Promise<void> {
const alreadyOpen = await this.prisma.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return;
}
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.prisma.task.create({ const task = await this.prisma.task.create({
data: { data: {
title, title,
@@ -288,6 +408,9 @@ export class AutomationService {
relatedType, relatedType,
relatedId, relatedId,
sourceInspectionId, sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
}, },
}); });
await this.webhooks.dispatch('task.created', task); await this.webhooks.dispatch('task.created', task);
@@ -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,50 @@
import { BadRequestException } from '@nestjs/common';
import * as path from 'node:path';
import {
assertBackupCron,
assertGuiBackupPath,
resolveGuiBackupPath,
} from './backup-schedule-rules';
describe('backup-schedule-rules', () => {
const originalRoot = process.env.BACKUP_ROOT;
beforeEach(() => {
process.env.BACKUP_ROOT = path.resolve('/data/backups');
});
afterAll(() => {
process.env.BACKUP_ROOT = originalRoot;
});
it('keeps a GUI path under the backup root', () => {
expect(resolveGuiBackupPath('nightly')).toBe(
path.resolve('/data/backups/nightly'),
);
expect(() => assertGuiBackupPath('nightly')).not.toThrow();
});
it('refuses a GUI path that leaves the backup root', () => {
expect(() => assertGuiBackupPath('../../app/public')).toThrow(
BadRequestException,
);
expect(() => assertGuiBackupPath('/etc')).toThrow(BadRequestException);
expect(() => assertGuiBackupPath('/data/backups-evil')).toThrow(
BadRequestException,
);
});
it('accepts a daily schedule', () => {
expect(() => assertBackupCron('0 3 * * *')).not.toThrow();
});
it('refuses a schedule that runs more than hourly or has seconds', () => {
expect(() => assertBackupCron('* * * * *')).toThrow(BadRequestException);
expect(() => assertBackupCron('*/10 * * * *')).toThrow(BadRequestException);
expect(() => assertBackupCron('* * * * * *')).toThrow(BadRequestException);
});
it('refuses an invalid expression', () => {
expect(() => assertBackupCron('every day')).toThrow(BadRequestException);
});
});
@@ -0,0 +1,55 @@
import { BadRequestException } from '@nestjs/common';
import { CronTime, validateCronExpression } from 'cron';
import * as path from 'node:path';
/**
* Limits on what the admin UI may configure for automatic backups. The env
* variables (set by the operator) stay unrestricted; a GUI value is data a
* logged-in user typed, so it may neither write a full dump anywhere on the
* server's file system nor run an export every second.
*/
const MIN_INTERVAL_MS = 60 * 60 * 1000;
const INTERVAL_SAMPLES = 48;
/** Root every GUI-configured backup directory must stay under. */
export function backupRoot(): string {
return path.resolve(process.env.BACKUP_ROOT ?? './backups');
}
/** The GUI path resolved under backupRoot(), or null if it would leave it. */
export function resolveGuiBackupPath(value: string): string | null {
const root = backupRoot();
const resolved = path.resolve(root, value);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
return null;
}
return resolved;
}
export function assertGuiBackupPath(value: string): void {
if (resolveGuiBackupPath(value) === null) {
throw new BadRequestException(
`Backup-Pfad muss innerhalb von ${backupRoot()} liegen`,
);
}
}
/** A standard five-field cron expression that fires at most once an hour. */
export function assertBackupCron(expression: string): void {
const fields = expression.trim().split(/\s+/);
if (fields.length !== 5 || !validateCronExpression(expression).valid) {
throw new BadRequestException(
'Zeitplan muss ein Cron-Ausdruck mit fünf Feldern sein, z. B. "0 3 * * *"',
);
}
const runs = new CronTime(expression)
.sendAt(INTERVAL_SAMPLES)
.map((run) => run.toMillis());
for (let i = 1; i < runs.length; i++) {
if (runs[i] - runs[i - 1] < MIN_INTERVAL_MS) {
throw new BadRequestException(
'Zeitplan darf höchstens einmal pro Stunde sichern',
);
}
}
}
@@ -5,6 +5,10 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import { BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service'; import { BackupSettingsService } from './backup-settings.service';
import {
assertBackupCron,
resolveGuiBackupPath,
} from './backup-schedule-rules';
const SCHEDULED_JOB_NAME = 'automatic-backup'; const SCHEDULED_JOB_NAME = 'automatic-backup';
const DEFAULT_CRON_SCHEDULE = '0 3 * * *'; const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
@@ -68,7 +72,13 @@ export class BackupSchedulerService implements OnModuleInit {
const gui = await this.backupSettingsService.get(); const gui = await this.backupSettingsService.get();
return { return {
path: hasEnvPath ? (envPath as string) : (gui.storagePath ?? null), // A GUI path counts only inside BACKUP_ROOT (a value stored before
// that rule existed and pointing elsewhere disables the schedule).
path: hasEnvPath
? (envPath as string)
: gui.storagePath
? resolveGuiBackupPath(gui.storagePath)
: null,
cronSchedule: hasEnvCron cronSchedule: hasEnvCron
? (envCron as string) ? (envCron as string)
: (gui.cronSchedule ?? DEFAULT_CRON_SCHEDULE), : (gui.cronSchedule ?? DEFAULT_CRON_SCHEDULE),
@@ -88,11 +98,22 @@ export class BackupSchedulerService implements OnModuleInit {
// admin's schedule/path/retention change takes effect immediately without // admin's schedule/path/retention change takes effect immediately without
// a pod restart. // a pod restart.
async reload(): Promise<void> { async reload(): Promise<void> {
const settings = await this.effectiveSettings();
if (!settings.envOverrides.cronSchedule) {
try {
assertBackupCron(settings.cronSchedule);
} catch {
// Keep whatever is running instead of silently ending backups.
this.logger.error(
`Ungültiger Backup-Zeitplan "${settings.cronSchedule}" - bisheriger Zeitplan bleibt aktiv`,
);
return;
}
}
if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) { if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) {
this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME); this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME);
} }
const settings = await this.effectiveSettings();
if (!settings.path) { if (!settings.path) {
this.logger.log( this.logger.log(
'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert', 'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert',
@@ -144,7 +165,7 @@ export class BackupSchedulerService implements OnModuleInit {
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no // Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
// duplicate-tick guard, since a human explicitly asked for it. // duplicate-tick guard, since a human explicitly asked for it.
async runNow(): Promise<{ filePath: string }> { async runNow(): Promise<{ fileName: string }> {
const settings = await this.effectiveSettings(); const settings = await this.effectiveSettings();
if (!settings.path) { if (!settings.path) {
throw new Error( throw new Error(
@@ -153,7 +174,8 @@ export class BackupSchedulerService implements OnModuleInit {
} }
const filePath = await this.writeBackup(settings.path); const filePath = await this.writeBackup(settings.path);
await this.pruneOldBackups(settings.path, settings.retentionCount); await this.pruneOldBackups(settings.path, settings.retentionCount);
return { filePath }; // Only the name: the server's directory layout stays on the server.
return { fileName: path.basename(filePath) };
} }
private async runScheduled(directory: string): Promise<void> { private async runScheduled(directory: string): Promise<void> {
@@ -176,14 +198,15 @@ export class BackupSchedulerService implements OnModuleInit {
} }
private async writeBackup(directory: string): Promise<string> { private async writeBackup(directory: string): Promise<string> {
const bundle = await this.backupService.exportAll(); const sealed = await this.backupService.exportSealed();
await fs.mkdir(directory, { recursive: true }); await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const timestamp = bundle.exportedAt.replace(/[:.]/g, '-'); const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
const filePath = path.join( const filePath = path.join(
directory, directory,
`${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`, `${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`,
); );
await fs.writeFile(filePath, JSON.stringify(bundle, null, 2)); // Owner-only: the file holds every account's credentials (encrypted).
await fs.writeFile(filePath, JSON.stringify(sealed), { mode: 0o600 });
return filePath; return filePath;
} }
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { BackupSettings } from '../../generated/prisma'; import { BackupSettings } from '../../generated/prisma';
import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto'; import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto';
import { assertBackupCron, assertGuiBackupPath } from './backup-schedule-rules';
const SINGLETON_ID = 'singleton'; const SINGLETON_ID = 'singleton';
@@ -31,6 +32,12 @@ export class BackupSettingsService {
dto: UpdateBackupSettingsDto, dto: UpdateBackupSettingsDto,
actingUserId: string, actingUserId: string,
): Promise<BackupSettings> { ): Promise<BackupSettings> {
if (dto.storagePath) {
assertGuiBackupPath(dto.storagePath);
}
if (dto.cronSchedule) {
assertBackupCron(dto.cronSchedule);
}
const before = await this.get(); const before = await this.get();
const settings = await this.prisma.backupSettings.update({ const settings = await this.prisma.backupSettings.update({
where: { id: SINGLETON_ID }, where: { id: SINGLETON_ID },
@@ -16,7 +16,9 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { BackupSettings } from '../../generated/prisma'; import { BackupSettings } from '../../generated/prisma';
import { BackupBundle, BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { ReauthService } from '../auth/reauth/reauth.service';
import { BackupImportProofDto } from './dto/backup-import-proof.dto';
import { import {
BackupScheduleStatus, BackupScheduleStatus,
BackupSchedulerService, BackupSchedulerService,
@@ -38,6 +40,7 @@ export class BackupController {
private readonly backupService: BackupService, private readonly backupService: BackupService,
private readonly backupSchedulerService: BackupSchedulerService, private readonly backupSchedulerService: BackupSchedulerService,
private readonly backupSettingsService: BackupSettingsService, private readonly backupSettingsService: BackupSettingsService,
private readonly reauth: ReauthService,
) {} ) {}
@RequirePermission('backup:export') @RequirePermission('backup:export')
@@ -48,7 +51,7 @@ export class BackupController {
@RequirePermission('backup:export') @RequirePermission('backup:export')
@Post('schedule/run-now') @Post('schedule/run-now')
runScheduleNow(): Promise<{ filePath: string }> { runScheduleNow(): Promise<{ fileName: string }> {
return this.backupSchedulerService.runNow(); return this.backupSchedulerService.runNow();
} }
@@ -72,11 +75,12 @@ export class BackupController {
@RequirePermission('backup:export') @RequirePermission('backup:export')
@Get('export') @Get('export')
async export(@Res() res: Response): Promise<void> { async export(@Res() res: Response): Promise<void> {
const bundle = await this.backupService.exportAll(); const sealed = await this.backupService.exportSealed();
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`; const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(JSON.stringify(bundle, null, 2)); res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(sealed));
} }
@RequirePermission('backup:import') @RequirePermission('backup:import')
@@ -99,17 +103,20 @@ export class BackupController {
) )
async import( async import(
@UploadedFile() file: Express.Multer.File | undefined, @UploadedFile() file: Express.Multer.File | undefined,
@Body() proof: BackupImportProofDto,
@CurrentUser() actor: AuthenticatedUser, @CurrentUser() actor: AuthenticatedUser,
) { ) {
// Restoring overwrites every account: prove it is really you, now.
await this.reauth.verify(actor.id, proof);
if (!file) { if (!file) {
throw new BadRequestException('No file uploaded'); throw new BadRequestException('No file uploaded');
} }
let bundle: BackupBundle; let parsed: unknown;
try { try {
bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle; parsed = JSON.parse(file.buffer.toString('utf-8'));
} catch { } catch {
throw new BadRequestException('Datei ist kein gültiges JSON'); throw new BadRequestException('Datei ist kein gültiges JSON');
} }
return this.backupService.importAll(bundle, actor.id); return this.backupService.importFile(parsed, actor.id);
} }
} }
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ReauthModule } from '../auth/reauth/reauth.module';
import { BackupController } from './backup.controller'; import { BackupController } from './backup.controller';
import { BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service'; import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service'; import { BackupSettingsService } from './backup-settings.service';
@Module({ @Module({
imports: [ReauthModule],
controllers: [BackupController], controllers: [BackupController],
providers: [BackupService, BackupSchedulerService, BackupSettingsService], providers: [BackupService, BackupSchedulerService, BackupSettingsService],
}) })
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
jest.mock('node:fs/promises', () => ({ jest.mock('node:fs/promises', () => ({
readFile: jest.fn(), readFile: jest.fn(),
@@ -24,6 +25,7 @@ const BUSINESS_ENTITY_KEYS = [
'task', 'task',
'varroaMeasurement', 'varroaMeasurement',
'swarmControlCheck', 'swarmControlCheck',
'winterCheck',
'treatment', 'treatment',
'feeding', 'feeding',
'harvest', 'harvest',
@@ -68,6 +70,8 @@ describe('BackupService', () => {
user: { upsert: jest.Mock; findMany: jest.Mock }; user: { upsert: jest.Mock; findMany: jest.Mock };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let permissionResolver: { invalidateAll: jest.Mock };
const TRUSTED = { trusted: true };
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -91,6 +95,9 @@ describe('BackupService', () => {
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }), findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
}; };
tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]); tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]);
(tx.role as unknown as { findMany: jest.Mock }).findMany = jest
.fn()
.mockResolvedValue([]);
prisma = { prisma = {
user: { user: {
@@ -113,9 +120,11 @@ describe('BackupService', () => {
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = { invalidateAll: jest.fn() };
service = new BackupService( service = new BackupService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
); );
}); });
@@ -197,7 +206,7 @@ describe('BackupService', () => {
describe('importAll', () => { describe('importAll', () => {
it('rejects a bundle with no data object', async () => { it('rejects a bundle with no data object', async () => {
await expect( await expect(
service.importAll({ version: 2 } as never, 'admin-1'), service.importAll({ version: 2 } as never, 'admin-1', TRUSTED),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled();
}); });
@@ -210,6 +219,7 @@ describe('BackupService', () => {
data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] }, data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.role.upsert.mock.calls as [ const [[call]] = tx.role.upsert.mock.calls as [
@@ -232,6 +242,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.user.upsert.mock.calls as [ const [[call]] = tx.user.upsert.mock.calls as [
@@ -251,6 +262,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
), ),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
@@ -274,6 +286,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1); expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1);
@@ -292,6 +305,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
expect(tx.queen.upsert).toHaveBeenCalledTimes(2); expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
@@ -325,6 +339,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.inspection.upsert.mock.calls as [ const [[call]] = tx.inspection.upsert.mock.calls as [
@@ -351,6 +366,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.inspection.upsert.mock.calls as [ const [[call]] = tx.inspection.upsert.mock.calls as [
@@ -371,6 +387,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.stockMovement.upsert.mock.calls as [ const [[call]] = tx.stockMovement.upsert.mock.calls as [
@@ -395,6 +412,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.automationRule.upsert.mock.calls as [ const [[call]] = tx.automationRule.upsert.mock.calls as [
@@ -421,14 +439,14 @@ describe('BackupService', () => {
document: [ document: [
{ {
id: 'doc-1', id: 'doc-1',
entityType: 'Colony', entityType: 'COLONY',
mimeType: 'application/pdf', mimeType: 'application/pdf',
uploadedByUserId: 'known-user', uploadedByUserId: 'known-user',
contentBase64: Buffer.from('hello').toString('base64'), contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'),
}, },
{ {
id: 'doc-2', id: 'doc-2',
entityType: 'Colony', entityType: 'COLONY',
mimeType: 'application/pdf', mimeType: 'application/pdf',
uploadedByUserId: null, uploadedByUserId: null,
contentBase64: null, contentBase64: null,
@@ -437,6 +455,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
expect(fs.writeFile).toHaveBeenCalledTimes(1); expect(fs.writeFile).toHaveBeenCalledTimes(1);
@@ -456,6 +475,7 @@ describe('BackupService', () => {
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] }, data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
@@ -463,9 +483,111 @@ describe('BackupService', () => {
entityType: 'Backup', entityType: 'Backup',
action: 'CREATE', action: 'CREATE',
userId: 'admin-1', userId: 'admin-1',
afterData: expect.objectContaining({ apiary: 1 }) as unknown, afterData: expect.objectContaining({
imported: expect.objectContaining({ apiary: 1 }) as unknown,
}) as unknown,
}), }),
); );
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
});
it('refuses a document whose entityType would leave the storage root', async () => {
await expect(
service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
document: [
{
id: 'doc-evil',
entityType: '../../app/dist',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
},
],
},
},
'admin-1',
TRUSTED,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(fs.mkdir).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('refuses a document whose bytes do not match its declared type', async () => {
await expect(
service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
document: [
{
id: 'doc-html',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('<script>').toString('base64'),
},
],
},
},
'admin-1',
TRUSTED,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('never rewrites audit entries that already exist', async () => {
await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { auditLog: [{ id: 'audit-1', action: 'LOGIN' }] },
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.auditLog.upsert.mock.calls as [
[{ update: Record<string, unknown> }],
];
expect(call.update).toEqual({});
});
it('takes no users, roles, webhooks or audit entries from an unsealed file', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
role: [{ key: 'ADMIN', name: 'Admin' }],
user: [
{
id: 'admin-victim',
email: '[email protected]',
passwordHash: 'attacker-hash',
roleKey: 'ADMIN',
},
],
webhookSubscription: [{ id: 'hook-1', url: 'https://evil' }],
auditLog: [{ id: 'audit-1' }],
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
},
},
'admin-1',
{ trusted: false },
);
expect(tx.role.upsert).not.toHaveBeenCalled();
expect(tx.user.upsert).not.toHaveBeenCalled();
expect(tx.webhookSubscription.upsert).not.toHaveBeenCalled();
expect(tx.auditLog.upsert).not.toHaveBeenCalled();
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
expect(summary.skipped).toEqual(
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
);
}); });
}); });
}); });
+123 -13
View File
@@ -4,7 +4,19 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { ALLOWED_DOCUMENT_MIME_TYPES } from '../documents/document-mime-types'; import {
ALLOWED_DOCUMENT_MIME_TYPES,
isAllowedDocumentMimeType,
} from '../documents/document-mime-types';
import { matchesFileSignature } from '../common/file-signature';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType } from '../../generated/prisma';
import {
SealedBackup,
isSealedBackup,
openBackup,
sealBackup,
} from './backup-crypto';
const BACKUP_VERSION = 2; const BACKUP_VERSION = 2;
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000; const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
@@ -17,8 +29,28 @@ export interface BackupBundle {
export interface BackupImportSummary { export interface BackupImportSummary {
imported: Record<string, number>; imported: Record<string, number>;
/**
* Tables left out because the file was an old, unsealed (v2) backup: its
* contents cannot be proven to come from ApiCore, so nothing that grants
* access (users, roles, webhooks) or rewrites history (audit log) is
* taken from it.
*/
skipped: string[];
} }
// What an unsealed file may never write - see BackupImportSummary.skipped.
const TRUSTED_ONLY_TABLES = [
'role',
'user',
'rolePermission',
'webhookSubscription',
'auditLog',
] as const;
const DOCUMENT_ENTITY_TYPES = new Set<string>(
Object.values(DocumentEntityType),
);
// Excluded on purpose: // Excluded on purpose:
// - Permission: code-derived, re-synced from the catalog on every boot - // - Permission: code-derived, re-synced from the catalog on every boot -
// including it would just fight that sync. RolePermission references // including it would just fight that sync. RolePermission references
@@ -55,8 +87,33 @@ export class BackupService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {} ) {}
/** The export as it leaves the server: encrypted and tamper-evident. */
async exportSealed(): Promise<SealedBackup> {
const bundle = await this.exportAll();
return sealBackup(bundle, bundle.exportedAt);
}
/**
* Restores an uploaded file. A sealed backup is restored completely; an
* old unsealed one only with its business data (see TRUSTED_ONLY_TABLES).
*/
async importFile(
parsed: unknown,
actingUserId: string,
): Promise<BackupImportSummary> {
if (isSealedBackup(parsed)) {
return this.importAll(openBackup(parsed) as BackupBundle, actingUserId, {
trusted: true,
});
}
return this.importAll(parsed as BackupBundle, actingUserId, {
trusted: false,
});
}
async exportAll(): Promise<BackupBundle> { async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {}; const data: Record<string, unknown[]> = {};
@@ -93,6 +150,7 @@ export class BackupService {
data.task = await this.prisma.task.findMany(); data.task = await this.prisma.task.findMany();
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany(); data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany(); data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
data.winterCheck = await this.prisma.winterCheck.findMany();
data.treatment = await this.prisma.treatment.findMany(); data.treatment = await this.prisma.treatment.findMany();
data.feeding = await this.prisma.feeding.findMany(); data.feeding = await this.prisma.feeding.findMany();
data.harvest = await this.prisma.harvest.findMany(); data.harvest = await this.prisma.harvest.findMany();
@@ -144,6 +202,7 @@ export class BackupService {
async importAll( async importAll(
bundle: BackupBundle, bundle: BackupBundle,
actingUserId: string, actingUserId: string,
{ trusted }: { trusted: boolean },
): Promise<BackupImportSummary> { ): Promise<BackupImportSummary> {
if ( if (
typeof bundle !== 'object' || typeof bundle !== 'object' ||
@@ -154,7 +213,18 @@ export class BackupService {
throw new BadRequestException('Invalid backup file'); throw new BadRequestException('Invalid backup file');
} }
const isTrustedOnly = (key: string): boolean =>
(TRUSTED_ONLY_TABLES as readonly string[]).includes(key);
const skipped = trusted
? []
: TRUSTED_ONLY_TABLES.filter((key) => {
const value = bundle.data[key];
return Array.isArray(value) && value.length > 0;
});
const rowsOf = (key: string): Row[] => { const rowsOf = (key: string): Row[] => {
if (!trusted && isTrustedOnly(key)) {
return [];
}
const value = bundle.data[key]; const value = bundle.data[key];
return Array.isArray(value) ? (value as Row[]) : []; return Array.isArray(value) ? (value as Row[]) : [];
}; };
@@ -164,6 +234,13 @@ export class BackupService {
await this.prisma.$transaction( await this.prisma.$transaction(
async (tx) => { async (tx) => {
const roleKeyToId = new Map<string, string>(); const roleKeyToId = new Map<string, string>();
// Roles already in this database resolve too - an unsealed file
// brings none of its own.
for (const role of await tx.role.findMany({
select: { id: true, key: true },
})) {
roleKeyToId.set(role.key, role.id);
}
for (const row of rowsOf('role')) { for (const row of rowsOf('role')) {
const withoutId: Row = { ...row }; const withoutId: Row = { ...row };
delete withoutId.id; delete withoutId.id;
@@ -430,6 +507,21 @@ export class BackupService {
} }
imported.swarmControlCheck = rowsOf('swarmControlCheck').length; imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
for (const row of rowsOf('winterCheck')) {
await tx.winterCheck.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.winterCheck = rowsOf('winterCheck').length;
for (const row of rowsOf('treatment')) { for (const row of rowsOf('treatment')) {
await tx.treatment.upsert({ await tx.treatment.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -677,13 +769,30 @@ export class BackupService {
if (!contentBase64) { if (!contentBase64) {
continue; continue;
} }
const buffer = Buffer.from(contentBase64, 'base64'); const entityType = asString(row, 'entityType');
const extension = const mimeType = asString(row, 'mimeType');
ALLOWED_DOCUMENT_MIME_TYPES[row.mimeType as string] ?? ''; if (
const directory = path.join( !entityType ||
documentsStorageRoot(), !DOCUMENT_ENTITY_TYPES.has(entityType) ||
(row.entityType as string).toLowerCase(), !mimeType ||
!isAllowedDocumentMimeType(mimeType)
) {
throw new BadRequestException(
`Dokument ${String(row.id)} hat einen unzulässigen Typ`,
); );
}
const buffer = Buffer.from(contentBase64, 'base64');
if (!matchesFileSignature(buffer, mimeType)) {
throw new BadRequestException(
`Dokument ${String(row.id)}: Inhalt passt nicht zum Dateityp`,
);
}
const extension = ALLOWED_DOCUMENT_MIME_TYPES[mimeType];
const storageRoot = path.resolve(documentsStorageRoot());
const directory = path.join(storageRoot, entityType.toLowerCase());
if (!directory.startsWith(storageRoot + path.sep)) {
throw new BadRequestException('Unzulässiger Speicherpfad');
}
await fs.mkdir(directory, { recursive: true }); await fs.mkdir(directory, { recursive: true });
const storagePath = path.join( const storagePath = path.join(
directory, directory,
@@ -723,6 +832,7 @@ export class BackupService {
} }
imported.notification = rowsOf('notification').length; imported.notification = rowsOf('notification').length;
// Append-only: entries already here are never rewritten by a file.
for (const row of rowsOf('auditLog')) { for (const row of rowsOf('auditLog')) {
await tx.auditLog.upsert({ await tx.auditLog.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -730,10 +840,7 @@ export class BackupService {
...row, ...row,
userId: resolveOptionalUserId(asString(row, 'userId')), userId: resolveOptionalUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: {},
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
}); });
} }
imported.auditLog = rowsOf('auditLog').length; imported.auditLog = rowsOf('auditLog').length;
@@ -741,14 +848,17 @@ export class BackupService {
{ timeout: IMPORT_TRANSACTION_TIMEOUT_MS }, { timeout: IMPORT_TRANSACTION_TIMEOUT_MS },
); );
// Users, roles and grants may have changed underneath every cache.
this.permissionResolver.invalidateAll();
await this.auditLog.record({ await this.auditLog.record({
entityType: 'Backup', entityType: 'Backup',
entityId: 'import', entityId: 'import',
action: 'CREATE', action: 'CREATE',
userId: actingUserId, userId: actingUserId,
afterData: imported, afterData: { imported, skipped, sealed: trusted },
}); });
return { imported }; return { imported, skipped };
} }
} }
@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
/** Step-up proof sent with a backup upload (multipart fields). */
export class BackupImportProofDto {
@ApiPropertyOptional({ description: 'Aktuelles Passwort' })
@IsOptional()
@IsString()
@MaxLength(200)
password?: string;
@ApiPropertyOptional({ description: 'Aktueller TOTP-Code, falls MFA aktiv' })
@IsOptional()
@Matches(/^\d{6}$/)
mfaCode?: string;
}
@@ -15,6 +15,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('bottling') @ApiTags('bottling')
@ApiBearerAuth() @ApiBearerAuth()
@@ -35,6 +36,7 @@ export class BottlingController {
} }
@RequirePermission('bottling:create') @RequirePermission('bottling:create')
@Idempotent()
@Post() @Post()
create( create(
@Body() dto: CreateBottlingBatchDto, @Body() dto: CreateBottlingBatchDto,
@@ -5,6 +5,18 @@ import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
type AmountChange = number | { increment?: number; decrement?: number };
function applyAmountChange(
current: number | null,
change: AmountChange | undefined,
): number | null {
if (change === undefined) return current;
if (typeof change === 'number') return change;
if (current === null) return null;
return current + (change.increment ?? 0) - (change.decrement ?? 0);
}
describe('BottlingService', () => { describe('BottlingService', () => {
let service: BottlingService; let service: BottlingService;
let prisma: { let prisma: {
@@ -21,18 +33,71 @@ describe('BottlingService', () => {
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let tx: { let tx: {
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock }; bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
honeyBatch: { update: jest.Mock }; honeyBatch: {
findUnique: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
};
}; };
let webhooks: { dispatch: jest.Mock }; let webhooks: { dispatch: jest.Mock };
// Honey batch rows as the transaction sees them - the tests assert the
// resulting amounts instead of the exact sequence of UPDATE statements.
let honeyBatches: Map<string, { amount: number | null; status?: string }>;
beforeEach(() => { beforeEach(() => {
honeyBatches = new Map();
tx = { tx = {
bottlingBatch: { bottlingBatch: {
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
}, },
honeyBatch: { update: jest.fn() }, honeyBatch: {
findUnique: jest.fn(({ where }: { where: { id: string } }) => {
const row = honeyBatches.get(where.id);
return Promise.resolve(row ? { id: where.id, ...row } : null);
}),
update: jest.fn(
({
where,
data,
}: {
where: { id: string };
data: { amount?: AmountChange; status?: string };
}) => {
const row = honeyBatches.get(where.id) ?? { amount: null };
honeyBatches.set(where.id, {
amount: applyAmountChange(row.amount, data.amount),
status: data.status ?? row.status,
});
return Promise.resolve({});
},
),
updateMany: jest.fn(
({
where,
data,
}: {
where: { id: string; amount?: { gte?: number } };
data: { amount: AmountChange; status?: string };
}) => {
const row = honeyBatches.get(where.id);
const minimum = where.amount?.gte;
if (
!row ||
row.amount === null ||
(minimum !== undefined && row.amount < minimum)
) {
return Promise.resolve({ count: 0 });
}
honeyBatches.set(where.id, {
amount: applyAmountChange(row.amount, data.amount),
status: data.status ?? row.status,
});
return Promise.resolve({ count: 1 });
},
),
},
}; };
prisma = { prisma = {
honeyBatch: { findUnique: jest.fn(), update: jest.fn() }, honeyBatch: { findUnique: jest.fn(), update: jest.fn() },
@@ -101,6 +166,7 @@ describe('BottlingService', () => {
id: 'h1', id: 'h1',
amount: 25, amount: 25,
}); });
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({ tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1', id: 'b1',
totalAmount: 25, totalAmount: 25,
@@ -123,10 +189,7 @@ describe('BottlingService', () => {
); );
expect(result).toEqual({ id: 'b1', totalAmount: 25 }); expect(result).toEqual({ id: 'b1', totalAmount: 25 });
expect(tx.honeyBatch.update).toHaveBeenCalledWith({ expect(honeyBatches.get('h1')).toEqual({ amount: 0, status: 'BOTTLED' });
where: { id: 'h1' },
data: { amount: 0, status: 'BOTTLED' },
});
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
@@ -135,6 +198,32 @@ describe('BottlingService', () => {
); );
}); });
it('refuses a bottling when the stored amount shrank after the first check', async () => {
// The pre-check saw 25 kg, but a concurrent bottling already took most
// of it - the conditional booking inside the transaction must catch that.
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 25,
});
honeyBatches.set('h1', { amount: 3 });
await expect(
service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 20,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-9',
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
expect(honeyBatches.get('h1')?.amount).toBe(3);
});
it('update writes an audit log entry with before and after data', async () => { it('update writes an audit log entry with before and after data', async () => {
const before = { const before = {
id: 'b1', id: 'b1',
@@ -209,6 +298,7 @@ describe('BottlingService', () => {
prisma.bottlingBatch.findUnique prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before) .mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 }); .mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
honeyBatches.set('h1', { amount: 0 });
await service.update('b1', { jarCount: 10 }, 'user-1'); await service.update('b1', { jarCount: 10 }, 'user-1');
@@ -217,10 +307,7 @@ describe('BottlingService', () => {
]; ];
expect(updateArgs[0].data.jarCount).toBe(10); expect(updateArgs[0].data.jarCount).toBe(10);
expect(updateArgs[0].data.totalAmount).toBe(5); expect(updateArgs[0].data.totalAmount).toBe(5);
expect(tx.honeyBatch.update).toHaveBeenCalledWith({ expect(honeyBatches.get('h1')?.amount).toBe(5);
where: { id: 'h1' },
data: { amount: 5 },
});
}); });
it('rejects a corrected amount that exceeds the honey batch capacity', async () => { it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
@@ -251,17 +338,13 @@ describe('BottlingService', () => {
.mockResolvedValueOnce(before) .mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' }); .mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 }); prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1'); await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(tx.honeyBatch.update).toHaveBeenCalledWith({ expect(honeyBatches.get('h1')?.amount).toBe(7);
where: { id: 'h1' }, expect(honeyBatches.get('h2')).toEqual({ amount: 3, status: 'BOTTLED' });
data: { amount: 7 },
});
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h2' },
data: { amount: 3, status: 'BOTTLED' },
});
}); });
it('remove gives the amount back and records a DELETE audit entry', async () => { it('remove gives the amount back and records a DELETE audit entry', async () => {
@@ -272,6 +355,7 @@ describe('BottlingService', () => {
totalAmount: 5, totalAmount: 5,
}; };
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before); prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
honeyBatches.set('h1', { amount: 2 });
const result = await service.remove('b1', 'user-1'); const result = await service.remove('b1', 'user-1');
@@ -279,10 +363,7 @@ describe('BottlingService', () => {
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({ expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
where: { id: 'b1' }, where: { id: 'b1' },
}); });
expect(tx.honeyBatch.update).toHaveBeenCalledWith({ expect(honeyBatches.get('h1')?.amount).toBe(7);
where: { id: 'h1' },
data: { amount: 7 },
});
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
@@ -7,7 +7,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { BottlingBatch } from '../../generated/prisma'; import { BottlingBatch, Prisma } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -29,6 +29,74 @@ function roundKg(amount: number): number {
return Math.round(amount * 1000) / 1000; return Math.round(amount * 1000) / 1000;
} }
const INSUFFICIENT_AMOUNT_MESSAGE =
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
type Tx = Prisma.TransactionClient;
/**
* Deducts `amount` kg from a honey batch in one conditional UPDATE, so the
* database - not a value read earlier - decides whether enough is left.
* A batch without a recorded amount (null) is not tracked and stays null.
*/
async function takeFromHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
const current = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (!current) {
throw new NotFoundException('Honey batch not found');
}
if (current.amount === null) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
return;
}
// Tolerance of half a gram absorbs float noise from earlier writes.
const booked = await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } },
data: { amount: { decrement: amount }, status: 'BOTTLED' },
});
if (booked.count === 0) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
await normalizeAmount(tx, honeyBatchId);
}
async function giveBackToHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { not: null } },
data: { amount: { increment: amount } },
});
await normalizeAmount(tx, honeyBatchId);
}
/** Keeps gram precision and clamps float noise below zero to exactly 0. */
async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
const row = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (row?.amount == null) return;
const normalized = Math.max(0, roundKg(row.amount));
if (normalized !== row.amount) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: normalized },
});
}
}
@Injectable() @Injectable()
export class BottlingService { export class BottlingService {
constructor( constructor(
@@ -73,11 +141,9 @@ export class BottlingService {
} }
} }
const totalAmount = (dto.jarSize * dto.jarCount) / 1000; const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) { if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
throw new BadRequestException( throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
);
} }
const lotNumber = const lotNumber =
@@ -91,7 +157,12 @@ export class BottlingService {
const bottlingBatch: BottlingBatch = await this.prisma.$transaction( const bottlingBatch: BottlingBatch = await this.prisma.$transaction(
async (tx) => { async (tx) => {
const created = await tx.bottlingBatch.create({ // Booked against the stored amount inside the transaction, never
// against the value read above: two bottlings submitted at the same
// time (double tap, two devices) must not both pass the check and
// overwrite each other's deduction.
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
return tx.bottlingBatch.create({
data: { data: {
honeyBatchId: dto.honeyBatchId, honeyBatchId: dto.honeyBatchId,
productId: dto.productId, productId: dto.productId,
@@ -104,17 +175,6 @@ export class BottlingService {
userId: actingUserId, userId: actingUserId,
}, },
}); });
await tx.honeyBatch.update({
where: { id: dto.honeyBatchId },
data: {
amount:
honeyBatch.amount !== null
? honeyBatch.amount - totalAmount
: null,
status: 'BOTTLED',
},
});
return created;
}, },
); );
@@ -163,8 +223,8 @@ export class BottlingService {
throw new NotFoundException('Honey batch not found'); throw new NotFoundException('Honey batch not found');
} }
// Capacity on the target batch, with this bottling batch's current amount // Early, friendly check against the amounts read above. The booking
// released first when it already sits on that same batch. // itself happens atomically inside the transaction below.
const availableOnTarget = const availableOnTarget =
targetHoneyBatch.amount === null targetHoneyBatch.amount === null
? null ? null
@@ -173,14 +233,26 @@ export class BottlingService {
(movedToOtherHoneyBatch ? 0 : before.totalAmount), (movedToOtherHoneyBatch ? 0 : before.totalAmount),
); );
if (availableOnTarget !== null && totalAmount > availableOnTarget) { if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException( throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
);
} }
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a // A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
// 409 with a field-specific message by AllExceptionsFilter. // 409 with a field-specific message by AllExceptionsFilter.
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
const amountChanged =
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
if (amountChanged) {
// Give the previous amount back first, then take the corrected one -
// a correction never double-charges the source batch.
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await takeFromHoneyBatch(tx, honeyBatchId, totalAmount);
}
if (movedToOtherHoneyBatch) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
}
await tx.bottlingBatch.update({ await tx.bottlingBatch.update({
where: { id }, where: { id },
data: { data: {
@@ -196,38 +268,6 @@ export class BottlingService {
lotNumber: dto.lotNumber, lotNumber: dto.lotNumber,
}, },
}); });
if (movedToOtherHoneyBatch) {
await tx.honeyBatch.update({
where: { id: before.honeyBatchId },
data: {
amount:
before.honeyBatch.amount === null
? null
: roundKg(before.honeyBatch.amount + before.totalAmount),
},
});
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: {
amount:
availableOnTarget === null
? null
: roundKg(availableOnTarget - totalAmount),
status: 'BOTTLED',
},
});
} else if (totalAmount !== before.totalAmount) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: {
amount:
availableOnTarget === null
? null
: roundKg(availableOnTarget - totalAmount),
},
});
}
}); });
const bottlingBatch = await this.findById(id); const bottlingBatch = await this.findById(id);
@@ -257,14 +297,7 @@ export class BottlingService {
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.bottlingBatch.delete({ where: { id } }); await tx.bottlingBatch.delete({ where: { id } });
if (before.honeyBatch.amount !== null) { await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await tx.honeyBatch.update({
where: { id: before.honeyBatchId },
data: {
amount: roundKg(before.honeyBatch.amount + before.totalAmount),
},
});
}
}); });
await this.auditLog.record({ await this.auditLog.record({
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -13,6 +14,7 @@ import { UpdateColonyDto } from './dto/update-colony.dto';
import { MoveColonyDto } from './dto/move-colony.dto'; import { MoveColonyDto } from './dto/move-colony.dto';
import { SplitColonyDto } from './dto/split-colony.dto'; import { SplitColonyDto } from './dto/split-colony.dto';
import { MergeColonyDto } from './dto/merge-colony.dto'; import { MergeColonyDto } from './dto/merge-colony.dto';
import { isLivingColonyStatus } from './colony-status';
export interface ColonyFilter { export interface ColonyFilter {
status?: ColonyStatus; status?: ColonyStatus;
@@ -43,10 +45,13 @@ export class ColoniesService {
return colony; return colony;
} }
// Only a colony that no longer exists (dissolved, sold, dead, merged) is
// locked. Weak, conspicuous or queenless colonies are still worked with -
// uniting a queenless colony with another is the textbook case for merge.
private assertActive(colony: Colony): void { private assertActive(colony: Colony): void {
if (colony.status !== 'ACTIVE') { if (!isLivingColonyStatus(colony.status)) {
throw new ConflictException( throw new ConflictException(
`Das Volk ist nicht aktiv (Status: ${colony.status})`, `Das Volk besteht nicht mehr (Status: ${colony.status})`,
); );
} }
} }
@@ -206,6 +211,16 @@ export class ColoniesService {
): Promise<Colony> { ): Promise<Colony> {
const survivor = await this.findById(id); const survivor = await this.findById(id);
this.assertActive(survivor); this.assertActive(survivor);
if (dto.mergedColonyIds.includes(survivor.id)) {
throw new BadRequestException(
'Ein Volk kann nicht mit sich selbst vereinigt werden',
);
}
if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) {
throw new BadRequestException(
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
);
}
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
for (const mergedColonyId of dto.mergedColonyIds) { for (const mergedColonyId of dto.mergedColonyIds) {
const merged = await tx.colony.findUnique({ const merged = await tx.colony.findUnique({
@@ -265,6 +280,9 @@ export class ColoniesService {
async dissolve(id: string, actingUserId: string): Promise<Colony> { async dissolve(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id); const before = await this.findById(id);
// Re-dissolving would overwrite deactivatedAt and the terminal status
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
this.assertActive(before);
const colony = await this.prisma.$transaction(async (tx) => { const colony = await this.prisma.$transaction(async (tx) => {
const updated = await tx.colony.update({ const updated = await tx.colony.update({
where: { id }, where: { id },
@@ -0,0 +1,18 @@
import { ColonyStatus } from '../../generated/prisma';
/**
* Statuses after which a colony no longer exists as a living unit. The other
* statuses (ACTIVE, WEAK, CONSPICUOUS, QUEENLESS) describe a colony that is
* still in the apiary and can be worked with - a weak colony still gets moved,
* and a queenless one is exactly the colony a beekeeper unites with another.
*/
export const TERMINAL_COLONY_STATUSES: readonly ColonyStatus[] = [
'DISSOLVED',
'SOLD',
'DECEASED',
'MERGED',
];
export function isLivingColonyStatus(status: ColonyStatus): boolean {
return !TERMINAL_COLONY_STATUSES.includes(status);
}
@@ -49,6 +49,30 @@ const FOREIGN_KEY_MESSAGE =
* status and message they deserve. Anything not listed here stays a 500 with * status and message they deserve. Anything not listed here stays a 500 with
* a generic body, so no internal detail leaks. * a generic body, so no internal detail leaks.
*/ */
/**
* Errors raised by Express middleware before Nest sees the request (body
* too large, malformed JSON, unsupported encoding) carry a 4xx `status`.
* They are client mistakes, not outages: answer with that status.
*/
function middlewareErrorResponse(
exception: unknown,
): { status: number; message: string } | null {
if (typeof exception !== 'object' || exception === null) {
return null;
}
const candidate = exception as { status?: unknown; statusCode?: unknown };
const status = candidate.status ?? candidate.statusCode;
if (typeof status !== 'number' || status < 400 || status >= 500) {
return null;
}
const messages: Record<number, string> = {
400: 'Ungültige Anfrage',
413: 'Anfrage ist zu groß',
415: 'Format der Anfrage wird nicht unterstützt',
};
return { status, message: messages[status] ?? 'Ungültige Anfrage' };
}
function prismaErrorResponse( function prismaErrorResponse(
exception: unknown, exception: unknown,
): { status: number; message: string } | null { ): { status: number; message: string } | null {
@@ -105,7 +129,8 @@ export class AllExceptionsFilter implements ExceptionFilter {
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
const isHttpException = exception instanceof HttpException; const isHttpException = exception instanceof HttpException;
const prismaResponse = prismaErrorResponse(exception); const prismaResponse =
prismaErrorResponse(exception) ?? middlewareErrorResponse(exception);
const status = isHttpException const status = isHttpException
? exception.getStatus() ? exception.getStatus()
: (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR); : (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR);
@@ -117,7 +142,8 @@ export class AllExceptionsFilter implements ExceptionFilter {
}; };
const message = const message =
exception instanceof Error ? exception.message : 'Unknown error'; exception instanceof Error ? exception.message : 'Unknown error';
const context = `${request.method} ${request.originalUrl} -> ${status}`; // Path only - query strings can carry codes and personal data.
const context = `${request.method} ${request.baseUrl}${request.path} -> ${status}`;
// Compared against the literal 500, not HttpStatus.INTERNAL_SERVER_ERROR: // Compared against the literal 500, not HttpStatus.INTERNAL_SERVER_ERROR:
// eslint's no-unsafe-enum-comparison and TS's no-unnecessary-type-assertion // eslint's no-unsafe-enum-comparison and TS's no-unnecessary-type-assertion
// fight over any cast here, so a plain number sidesteps both cleanly. // fight over any cast here, so a plain number sidesteps both cleanly.
@@ -0,0 +1,33 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
// Longer than any realistic offline stretch at the apiary: an entry queued
// on the phone must still find its key when it finally syncs, otherwise the
// repeat would create a duplicate after all.
export const IDEMPOTENCY_RETENTION_DAYS = 90;
@Injectable()
export class IdempotencyCleanupService {
private readonly logger = new Logger(IdempotencyCleanupService.name);
constructor(private readonly prisma: PrismaService) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async removeExpired(now: Date = new Date()): Promise<number> {
const cutoff = new Date(
now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
);
const { count } = await this.prisma.idempotencyRecord.deleteMany({
where: { createdAt: { lt: cutoff } },
});
if (count > 0) {
this.logger.log(`Removed ${count} expired idempotency record(s)`);
}
// Used one-time tokens only matter until the token itself expires.
await this.prisma.consumedToken.deleteMany({
where: { expiresAt: { lt: now } },
});
return count;
}
}
@@ -0,0 +1,188 @@
import {
BadRequestException,
CallHandler,
ConflictException,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request, Response } from 'express';
import { Observable, from, of, throwError } from 'rxjs';
import { catchError, mergeMap } from 'rxjs/operators';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma } from '../../../generated/prisma';
import type { AuthenticatedUser } from '../../auth/types/authenticated-user';
import { IDEMPOTENT_KEY } from './idempotent.decorator';
export const IDEMPOTENCY_HEADER = 'idempotency-key';
// UUIDs and similar opaque tokens - long enough to be unguessable per user,
// short enough to keep the unique index small. Anything else is a client bug.
const KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
const ABANDONED_PENDING_MS = 5 * 60 * 1000;
type RequestWithUser = Request & { user?: AuthenticatedUser };
/**
* Makes a POST safe to repeat. A client that cannot tell whether its request
* arrived - the offline queue after a dropped connection, a retry after a
* timeout - sends the same `Idempotency-Key` again and gets the stored first
* response back instead of a second inspection, harvest or task.
*
* Flow per (user, key):
* - first request claims the key (PENDING row), runs the handler and stores
* status + body (COMPLETED);
* - a repeat after completion replays the stored response;
* - a repeat while the first one still runs gets 409, the client retries later;
* - a failed handler releases the key, so a corrected retry is possible.
*
* Only handlers marked @Idempotent() take part (response bodies are stored,
* so endpoints returning secrets must never opt in). Requests without the
* header, or to other handlers, behave exactly as before.
*/
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(
private readonly prisma: PrismaService,
private readonly reflector: Reflector,
) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<unknown>> {
const http = context.switchToHttp();
const request = http.getRequest<RequestWithUser>();
const rawKey = request.headers[IDEMPOTENCY_HEADER];
const userId = request.user?.id;
const optedIn = this.reflector.get<boolean | undefined>(
IDEMPOTENT_KEY,
context.getHandler(),
);
if (
!optedIn ||
request.method !== 'POST' ||
rawKey === undefined ||
!userId
) {
return next.handle();
}
const key = Array.isArray(rawKey) ? rawKey[0] : rawKey;
if (!KEY_PATTERN.test(key)) {
throw new BadRequestException(
'Ungültiger Idempotency-Key (16–128 Zeichen, nur Buchstaben, Ziffern, "-" und "_")',
);
}
const scope = `${request.method} ${request.baseUrl}${request.path}`;
let claimed = await this.claim(userId, key, scope);
if (!claimed && (await this.releaseIfAbandoned(userId, key))) {
claimed = await this.claim(userId, key, scope);
}
if (!claimed) {
const existing = await this.prisma.idempotencyRecord.findUnique({
where: { userId_key: { userId, key } },
});
if (!existing) {
// Released between our failed insert and this read - the first
// attempt failed. Let the client retry instead of guessing.
throw new ConflictException(
'Diese Anfrage wurde gerade abgebrochen - bitte erneut senden',
);
}
if (existing.scope !== scope) {
throw new ConflictException(
'Dieser Idempotency-Key wurde bereits für eine andere Aktion verwendet',
);
}
if (existing.status !== 'COMPLETED') {
throw new ConflictException(
'Diese Anfrage wird bereits verarbeitet - bitte kurz warten',
);
}
http.getResponse<Response>().status(existing.statusCode ?? 201);
return of(existing.responseBody);
}
return next.handle().pipe(
mergeMap((body: unknown) =>
from(this.complete(userId, key, http.getResponse<Response>(), body)),
),
catchError((error: unknown) =>
from(this.release(userId, key)).pipe(
mergeMap(() => throwError(() => error)),
),
),
);
}
private async claim(
userId: string,
key: string,
scope: string,
): Promise<boolean> {
try {
await this.prisma.idempotencyRecord.create({
data: { userId, key, scope },
});
return true;
} catch (error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
return false;
}
throw error;
}
}
private async complete(
userId: string,
key: string,
response: Response,
body: unknown,
): Promise<unknown> {
await this.prisma.idempotencyRecord.update({
where: { userId_key: { userId, key } },
data: {
status: 'COMPLETED',
statusCode: response.statusCode,
// Round-trip through JSON so Dates etc. are stored exactly as the
// client received them the first time.
responseBody:
body === undefined
? Prisma.JsonNull
: (JSON.parse(JSON.stringify(body)) as Prisma.InputJsonValue),
},
});
return body;
}
private async release(userId: string, key: string): Promise<void> {
await this.prisma.idempotencyRecord.deleteMany({ where: { userId, key } });
}
/**
* A PENDING row older than any real request means the process died
* mid-request (pod restart) and never completed or released the key.
* Without this, the queued entry holding that key would get "already
* processing" forever and never sync.
*/
private async releaseIfAbandoned(
userId: string,
key: string,
): Promise<boolean> {
const released = await this.prisma.idempotencyRecord.deleteMany({
where: {
userId,
key,
status: 'PENDING',
createdAt: { lt: new Date(Date.now() - ABANDONED_PENDING_MS) },
},
});
return released.count > 0;
}
}
@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const IDEMPOTENT_KEY = 'idempotent';
/**
* Opts a POST handler into Idempotency-Key handling (see
* IdempotencyInterceptor). Opt-in rather than global on purpose: the
* interceptor stores the response body, and some POST responses carry
* secrets (MFA setup, tokens) that must never be persisted. Only plain
* record-creating endpoints - the ones the offline queue and quick-entry
* forms retry - carry this decorator.
*/
export const Idempotent = () => SetMetadata(IDEMPOTENT_KEY, true);
@@ -0,0 +1,22 @@
import type { NextFunction, Request, Response } from 'express';
// The public origin pages of honey lots are the only answers meant for
// everyone; they may be cached briefly.
const CACHEABLE_PREFIX = '/api/v1/public/';
/**
* Personal data, tokens and exports must not end up in a browser's
* back/forward cache or a shared proxy. Every API answer is no-store unless
* it is one of the public, anonymous ones.
*/
export function noStoreMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!req.path.startsWith(CACHEABLE_PREFIX)) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
}
next();
}
@@ -8,7 +8,12 @@ describe('requestLoggerMiddleware', () => {
}); });
function fakeRequest(): Request { function fakeRequest(): Request {
return { method: 'GET', originalUrl: '/api/v1/dashboard' } as Request; return {
method: 'GET',
baseUrl: '',
path: '/api/v1/dashboard',
originalUrl: '/api/v1/dashboard?q=secret',
} as Request;
} }
function fakeResponse(statusCode: number): { function fakeResponse(statusCode: number): {
@@ -18,7 +18,9 @@ export function requestLoggerMiddleware(
const start = Date.now(); const start = Date.now();
res.on('finish', () => { res.on('finish', () => {
const duration = Date.now() - start; const duration = Date.now() - start;
const line = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`; // Path only: query strings can carry secrets (the SSO callback's
// authorization code and state, search terms with personal data).
const line = `${req.method} ${req.baseUrl}${req.path} ${res.statusCode} ${duration}ms`;
if (res.statusCode >= 500) { if (res.statusCode >= 500) {
logger.error(line); logger.error(line);
} else if (res.statusCode >= 400) { } else if (res.statusCode >= 400) {
@@ -0,0 +1,76 @@
import { BadRequestException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import * as crypto from 'node:crypto';
import { IsNewPassword, assertPasswordNotBreached } from './password-policy';
class Dto {
@IsNewPassword()
password!: string;
}
function errorsFor(password: string): number {
return validateSync(plainToInstance(Dto, { password })).length;
}
describe('password policy', () => {
it('requires 12 characters and at most 72 bytes', () => {
expect(errorsFor('kurz123!')).toBe(1);
expect(errorsFor('lang-genug-12')).toBe(0);
expect(errorsFor('a'.repeat(72))).toBe(0);
expect(errorsFor('a'.repeat(73))).toBe(1);
expect(errorsFor('ä'.repeat(37))).toBe(1); // 74 bytes
});
describe('assertPasswordNotBreached', () => {
const originalEnv = process.env.NODE_ENV;
let fetchMock: jest.SpiedFunction<typeof fetch>;
beforeEach(() => {
process.env.NODE_ENV = 'production';
fetchMock = jest.spyOn(global, 'fetch');
});
afterEach(() => {
process.env.NODE_ENV = originalEnv;
fetchMock.mockRestore();
});
function rangeFor(password: string, count: number): string {
const sha1 = crypto
.createHash('sha1')
.update(password)
.digest('hex')
.toUpperCase();
return `0000000000000000000000000000000000A:0\r\n${sha1.slice(5)}:${count}\r\n`;
}
it('sends only the first five hash characters', async () => {
fetchMock.mockResolvedValue(new Response(rangeFor('other', 3)));
await assertPasswordNotBreached('Pferdebatterie-Heftklammer');
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toMatch(/\/range\/[0-9A-F]{5}$/);
});
it('refuses a breached password', async () => {
fetchMock.mockResolvedValue(new Response(rangeFor('Passwort1234', 4242)));
await expect(
assertPasswordNotBreached('Passwort1234'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('ignores padding entries with a count of 0', async () => {
fetchMock.mockResolvedValue(new Response(rangeFor('Padding-Only-1', 0)));
await expect(
assertPasswordNotBreached('Padding-Only-1'),
).resolves.toBeUndefined();
});
it('accepts the password when the service is unreachable', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
await expect(
assertPasswordNotBreached('Irgendwas-123'),
).resolves.toBeUndefined();
});
});
});
@@ -0,0 +1,95 @@
import { BadRequestException, Logger } from '@nestjs/common';
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength, ValidateBy, buildMessage } from 'class-validator';
import * as crypto from 'node:crypto';
/** OWASP ASVS 2.1.1: at least 12 characters for a new password. */
export const MIN_PASSWORD_LENGTH = 12;
/** bcrypt ignores everything after 72 bytes - refuse instead of truncating. */
export const MAX_PASSWORD_BYTES = 72;
const PWNED_RANGE_URL = 'https://api.pwnedpasswords.com/range/';
const BREACH_CHECK_TIMEOUT_MS = 3_000;
const logger = new Logger('PasswordPolicy');
/** Validation for every NEW password (not for login, where old ones still count). */
export function IsNewPassword(): PropertyDecorator {
return applyDecorators(
ApiProperty({
minLength: MIN_PASSWORD_LENGTH,
description: `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen, höchstens ${MAX_PASSWORD_BYTES} Byte`,
}),
IsString(),
MinLength(MIN_PASSWORD_LENGTH, {
message: `Passwort muss mindestens ${MIN_PASSWORD_LENGTH} Zeichen lang sein`,
}),
ValidateBy({
name: 'maxPasswordBytes',
validator: {
validate: (value: unknown) =>
typeof value === 'string' &&
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES,
defaultMessage: buildMessage(
() =>
`Passwort darf höchstens ${MAX_PASSWORD_BYTES} Byte lang sein (bei Umlauten weniger Zeichen)`,
),
},
}),
);
}
function breachCheckEnabled(): boolean {
return (
process.env.PASSWORD_BREACH_CHECK !== 'false' &&
process.env.NODE_ENV !== 'test'
);
}
/**
* Refuses a password that appears in known data breaches (Have I Been
* Pwned, k-anonymity: only the first 5 hex characters of its SHA-1 leave
* the server, padded responses). If the service cannot be reached the
* password is accepted and a warning logged - a third-party outage must not
* lock everyone out of changing passwords. PASSWORD_BREACH_CHECK=false turns
* it off for offline deployments.
*/
export async function assertPasswordNotBreached(
password: string,
): Promise<void> {
if (!breachCheckEnabled()) {
return;
}
const sha1 = crypto
.createHash('sha1')
.update(password)
.digest('hex')
.toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
let body: string;
try {
const response = await fetch(`${PWNED_RANGE_URL}${prefix}`, {
headers: { 'Add-Padding': 'true', 'User-Agent': 'ApiCore' },
signal: AbortSignal.timeout(BREACH_CHECK_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
body = await response.text();
} catch (error: unknown) {
logger.warn(
`Breach check unavailable, password accepted: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
const breached = body.split('\n').some((line) => {
const [hashSuffix, count] = line.trim().split(':');
return hashSuffix === suffix && Number(count) > 0;
});
if (breached) {
throw new BadRequestException(
'Dieses Passwort ist aus Datenlecks bekannt und daher unsicher. Bitte ein anderes wählen.',
);
}
}
@@ -0,0 +1,12 @@
import { Transform } from 'class-transformer';
/**
* Emails are compared case-insensitively (see UsersService.findByEmail);
* storing them trimmed and lower-case keeps "[email protected]" and "[email protected]"
* from becoming two accounts.
*/
export function NormalizeEmail(): PropertyDecorator {
return Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
);
}
@@ -0,0 +1,431 @@
import { NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import {
PDF_MARGIN,
drawSection,
drawTable,
fillWidth,
formatDay,
orDash,
} from './pdf-table';
/**
* Stockkarte: the complete written record of one colony - master data, queen,
* every inspection as a table row, treatments, feedings, varroa counts and
* harvests. For the folder at the apiary or to hand over with a sold colony.
* Whole history by default, or one calendar year.
*/
const COLONY_TYPE_LABELS: Record<string, string> = {
PRODUCTION_COLONY: 'Wirtschaftsvolk',
NUCLEUS_COLONY: 'Ableger',
YOUNG_COLONY: 'Jungvolk',
CARETAKER_COLONY: 'Pflegevolk',
MATING_NUCLEUS: 'Begattungseinheit',
RESERVE_COLONY: 'Reservevolk',
};
const COLONY_STATUS_LABELS: Record<string, string> = {
ACTIVE: 'Aktiv',
WEAK: 'Schwach',
CONSPICUOUS: 'Auffällig',
QUEENLESS: 'Weisellos',
DISSOLVED: 'Aufgelöst',
SOLD: 'Verkauft',
DECEASED: 'Eingegangen',
MERGED: 'Vereinigt',
};
const FEED_TYPE_LABELS: Record<string, string> = {
SUGAR_WATER: 'Zuckerwasser',
FEED_SYRUP: 'Futtersirup',
FEED_PASTE: 'Futterteig',
OWN_HONEY: 'Eigener Honig',
OTHER: 'Sonstiges',
};
const VARROA_METHOD_LABELS: Record<string, string> = {
NATURAL_MITE_FALL: 'Natürlicher Milbenfall',
POWDERED_SUGAR: 'Puderzucker',
WASHING_METHOD: 'Auswaschen',
DRONE_BROOD_CONTROL: 'Drohnenbrut',
};
export interface ColonyRecordCard {
generatedAt: Date;
year: number | null;
colony: {
code: string;
colonyType: string;
status: string;
hiveType: string | null;
originText: string | null;
createdAt: Date;
apiaryName: string;
apiaryLocation: string | null;
};
currentQueen: {
code: string;
hatchYear: number | null;
markingColor: string | null;
breed: string | null;
origin: string | null;
} | null;
queenHistory: { code: string; startDate: Date; endDate: Date | null }[];
inspections: {
date: Date;
colonyStrength: number | null;
openBroodPresent: boolean | null;
cappedBroodPresent: boolean | null;
eggsPresent: boolean | null;
queenSeen: boolean | null;
queenCellsPresent: boolean | null;
gentleness: number | null;
swarmMood: number | null;
feedStatus: string | null;
measures: string | null;
notes: string | null;
}[];
treatments: {
date: Date;
endDate: Date | null;
product: string;
activeIngredient: string | null;
amount: string | null;
batchNumber: string | null;
withdrawalPeriodDays: number | null;
}[];
feedings: {
date: Date;
feedType: string;
product: string | null;
amount: number;
unit: string;
}[];
varroaMeasurements: {
date: Date;
method: string;
miteCount: number | null;
calculatedValue: number | null;
}[];
harvests: {
date: Date;
honeyType: string | null;
netWeight: number | null;
}[];
}
function dateFilter(year: number | null) {
return year === null
? undefined
: {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
};
}
export async function buildColonyRecordCard(
prisma: PrismaService,
colonyId: string,
year: number | null,
): Promise<ColonyRecordCard> {
const colony = await prisma.colony.findUnique({
where: { id: colonyId },
include: { apiary: true, currentQueen: true },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
const date = dateFilter(year);
const [
inspections,
treatments,
feedings,
varroa,
harvestLinks,
queenHistory,
] = await Promise.all([
prisma.inspection.findMany({
where: { colonyId, date },
orderBy: { date: 'asc' },
}),
prisma.treatment.findMany({
where: { colonyId, date },
orderBy: { date: 'asc' },
}),
prisma.feeding.findMany({
where: { colonyId, date },
orderBy: { date: 'asc' },
}),
prisma.varroaMeasurement.findMany({
where: { colonyId, date },
orderBy: { date: 'asc' },
}),
prisma.harvestColony.findMany({
where: { colonyId, harvest: { date } },
include: { harvest: true },
orderBy: { harvest: { date: 'asc' } },
}),
prisma.queenColonyHistory.findMany({
where: { colonyId },
include: { queen: true },
orderBy: { startDate: 'asc' },
}),
]);
const queen = colony.currentQueen;
return {
generatedAt: new Date(),
year,
colony: {
code: colony.code,
colonyType: colony.colonyType,
status: colony.status,
hiveType: colony.hiveType,
originText: colony.originText,
createdAt: colony.createdAt,
apiaryName: colony.apiary.name,
apiaryLocation: colony.apiary.location,
},
currentQueen: queen
? {
code: queen.code,
hatchYear: queen.hatchYear,
markingColor: queen.markingColor,
breed: queen.breed,
origin: queen.origin,
}
: null,
queenHistory: queenHistory.map((entry) => ({
code: entry.queen.code,
startDate: entry.startDate,
endDate: entry.endDate,
})),
inspections: inspections.map((i) => ({
date: i.date,
colonyStrength: i.colonyStrength,
openBroodPresent: i.openBroodPresent,
cappedBroodPresent: i.cappedBroodPresent,
eggsPresent: i.eggsPresent,
queenSeen: i.queenSeen,
queenCellsPresent: i.queenCellsPresent,
gentleness: i.gentleness,
swarmMood: i.swarmMood,
feedStatus: i.feedStatus,
measures: i.measures,
notes: i.notes,
})),
treatments: treatments.map((t) => ({
date: t.date,
endDate: t.endDate,
product: t.product,
activeIngredient: t.activeIngredient,
amount: t.amount,
batchNumber: t.batchNumber,
withdrawalPeriodDays: t.withdrawalPeriodDays,
})),
feedings: feedings.map((f) => ({
date: f.date,
feedType: f.feedType,
product: f.product,
amount: f.amount,
unit: f.unit,
})),
varroaMeasurements: varroa.map((v) => ({
date: v.date,
method: v.method,
miteCount: v.miteCount,
calculatedValue: v.calculatedValue,
})),
harvests: harvestLinks.map((link) => ({
date: link.harvest.date,
honeyType: link.harvest.honeyType,
netWeight: link.harvest.netWeight,
})),
};
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
const MARGIN = PDF_MARGIN;
const day = formatDay;
const section = drawSection;
function yesNo(value: boolean | null): string {
return value === null ? '–' : value ? 'ja' : 'nein';
}
export function renderColonyRecordCardPdf(
card: ColonyRecordCard,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
// Landscape: the inspection table needs the width.
const doc = new PDFDocument({
size: 'A4',
layout: 'landscape',
margin: MARGIN,
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const { colony } = card;
doc
.font('Helvetica-Bold')
.fontSize(16)
.text(`Stockkarte ${colony.code}`, MARGIN, MARGIN);
doc
.font('Helvetica')
.fontSize(9)
.text(
`${card.year === null ? 'Gesamte Historie' : `Jahr ${card.year}`} · erstellt am ${day(card.generatedAt)}`,
);
doc.moveDown(0.5);
doc
.fontSize(9)
.text(
[
`Stand: ${colony.apiaryName}${colony.apiaryLocation ? ` (${colony.apiaryLocation})` : ''}`,
`Art: ${COLONY_TYPE_LABELS[colony.colonyType] ?? colony.colonyType}`,
`Status: ${COLONY_STATUS_LABELS[colony.status] ?? colony.status}`,
`Beute: ${orDash(colony.hiveType)}`,
`Herkunft: ${orDash(colony.originText)}`,
`Erfasst seit: ${day(colony.createdAt)}`,
].join(' · '),
);
section(doc, 'Königin');
const queen = card.currentQueen;
doc
.font('Helvetica')
.fontSize(9)
.text(
queen
? `${queen.code} · Schlupfjahr ${orDash(queen.hatchYear)} · Zeichen ${orDash(queen.markingColor)} · Rasse ${orDash(queen.breed)} · Herkunft ${orDash(queen.origin)}`
: 'Keine Königin zugeordnet.',
MARGIN,
);
if (card.queenHistory.length > 0) {
doc.text(
`Verlauf: ${card.queenHistory
.map(
(q) =>
`${q.code} (${day(q.startDate)} – ${q.endDate ? day(q.endDate) : 'heute'})`,
)
.join('; ')}`,
);
}
section(doc, 'Durchsichten');
drawTable(
doc,
fillWidth(doc, [
{ header: 'Datum', width: 58 },
{ header: 'Stärke (Waben)', width: 52 },
{ header: 'Stifte', width: 36 },
{ header: 'Offene Brut', width: 42 },
{ header: 'Verd. Brut', width: 42 },
{ header: 'Kö gesehen', width: 44 },
{ header: 'Weiselzellen', width: 60 },
{ header: 'Sanftmut 1–5', width: 44 },
{ header: 'Schwarmtrieb 1–5', width: 66 },
{ header: 'Futter', width: 56 },
{ header: 'Maßnahmen', width: 126 },
{ header: 'Notizen', width: 0 },
]),
card.inspections.map((i) => [
day(i.date),
orDash(i.colonyStrength),
yesNo(i.eggsPresent),
yesNo(i.openBroodPresent),
yesNo(i.cappedBroodPresent),
yesNo(i.queenSeen),
yesNo(i.queenCellsPresent),
orDash(i.gentleness),
orDash(i.swarmMood),
orDash(i.feedStatus),
orDash(i.measures),
orDash(i.notes),
]),
);
section(doc, 'Behandlungen');
drawTable(
doc,
[
{ header: 'Datum', width: 70 },
{ header: 'Ende', width: 70 },
{ header: 'Mittel', width: 150 },
{ header: 'Wirkstoff', width: 120 },
{ header: 'Menge', width: 100 },
{ header: 'Chargen-Nr.', width: 100 },
{ header: 'Wartezeit (Tage)', width: 80 },
],
card.treatments.map((t) => [
day(t.date),
t.endDate ? day(t.endDate) : '–',
t.product,
orDash(t.activeIngredient),
orDash(t.amount),
orDash(t.batchNumber),
orDash(t.withdrawalPeriodDays),
]),
);
section(doc, 'Fütterungen');
drawTable(
doc,
[
{ header: 'Datum', width: 70 },
{ header: 'Futterart', width: 120 },
{ header: 'Produkt', width: 200 },
{ header: 'Menge', width: 100 },
],
card.feedings.map((f) => [
day(f.date),
FEED_TYPE_LABELS[f.feedType] ?? f.feedType,
orDash(f.product),
`${f.amount} ${f.unit}`,
]),
);
section(doc, 'Varroa');
drawTable(
doc,
[
{ header: 'Datum', width: 70 },
{ header: 'Methode', width: 150 },
{ header: 'Milben gezählt', width: 90 },
{ header: 'Berechneter Wert', width: 100 },
],
card.varroaMeasurements.map((v) => [
day(v.date),
VARROA_METHOD_LABELS[v.method] ?? v.method,
orDash(v.miteCount),
v.calculatedValue === null ? '–' : v.calculatedValue.toFixed(1),
]),
);
section(doc, 'Ernten');
drawTable(
doc,
[
{ header: 'Datum', width: 70 },
{ header: 'Sorte', width: 200 },
{ header: 'Netto (kg, gesamte Ernte)', width: 140 },
],
card.harvests.map((h) => [
day(h.date),
orDash(h.honeyType),
orDash(h.netWeight),
]),
);
doc.end();
});
}
@@ -0,0 +1,21 @@
import { toCsvField } from './export.service';
describe('toCsvField', () => {
it('neutralizes cells that a spreadsheet would run as a formula', () => {
expect(toCsvField('=HYPERLINK("http://evil","x")')).toBe(
`"'=HYPERLINK(""http://evil"",""x"")"`,
);
expect(toCsvField('+1+1')).toBe("'+1+1");
expect(toCsvField('-2')).toBe("'-2");
expect(toCsvField('@SUM(A1)')).toBe("'@SUM(A1)");
});
it('keeps numbers, including negative ones, as numbers', () => {
expect(toCsvField(-2.5)).toBe('-2.5');
expect(toCsvField(0)).toBe('0');
});
it('quotes carriage returns too', () => {
expect(toCsvField('a\rb')).toBe('"a\rb"');
});
});
+40 -3
View File
@@ -1,5 +1,10 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, BadRequestException } from '@nestjs/common';
import PDFDocument from 'pdfkit'; import PDFDocument from 'pdfkit';
import {
buildColonyRecordCard,
renderColonyRecordCardPdf,
} from './colony-record-card';
import { buildTreatmentLog, renderTreatmentLogPdf } from './treatment-log';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service'; import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
import { import {
@@ -22,6 +27,8 @@ const PDF_REPORTS = [
'honey-batch-traceability', 'honey-batch-traceability',
'colony-annual-report', 'colony-annual-report',
'apiary-annual-report', 'apiary-annual-report',
'colony-record-card',
'treatment-log',
] as const; ] as const;
type PdfReport = (typeof PDF_REPORTS)[number]; type PdfReport = (typeof PDF_REPORTS)[number];
@@ -64,12 +71,20 @@ function stringifyCsvValue(value: unknown): string {
return JSON.stringify(value); return JSON.stringify(value);
} }
function toCsvField(value: unknown): string { // A text cell starting with one of these is run as a formula by Excel and
// LibreOffice (=HYPERLINK(...), DDE). Typed-in values such as a colony code
// are prefixed with ' so they show as text; real numbers are not strings
// and keep their sign.
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
export function toCsvField(value: unknown): string {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return ''; return '';
} }
const text = stringifyCsvValue(value); const raw = stringifyCsvValue(value);
if (/[",\n]/.test(text)) { const text =
typeof value === 'string' && FORMULA_TRIGGER.test(raw) ? `'${raw}` : raw;
if (/[",\r\n]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`; return `"${text.replace(/"/g, '""')}"`;
} }
return text; return text;
@@ -188,6 +203,28 @@ export class ExportService {
); );
return renderApiaryAnnualReportPdf(apiaryReport); return renderApiaryAnnualReportPdf(apiaryReport);
} }
case 'colony-record-card': {
if (!options.colonyId) {
throw new BadRequestException(
'Query parameter colonyId is required for this report',
);
}
// Without a year the record card covers the colony's whole history.
const card = await buildColonyRecordCard(
this.prisma,
options.colonyId,
options.year ? resolveYear(options.year) : null,
);
return renderColonyRecordCardPdf(card);
}
case 'treatment-log': {
const log = await buildTreatmentLog(
this.prisma,
resolveYear(options.year),
options.apiaryId,
);
return renderTreatmentLogPdf(log);
}
} }
} }
+107
View File
@@ -0,0 +1,107 @@
/**
* Small pdfkit helpers shared by the printable records (Stockkarte,
* Bestandsbuch): dates, empty values and a table that repeats its header on
* every page.
*/
export const PDF_MARGIN = 36;
export interface PdfColumn {
header: string;
width: number;
}
export function formatDay(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
}
export function orDash(value: string | number | null | undefined): string {
return value === null || value === undefined || value === ''
? '–'
: String(value);
}
/** Widens the last column so the table fills the page width. */
export function fillWidth(
doc: PDFKit.PDFDocument,
columns: PdfColumn[],
): PdfColumn[] {
const fixed = columns.slice(0, -1).reduce((sum, c) => sum + c.width, 0);
const last = columns[columns.length - 1];
return [
...columns.slice(0, -1),
{ ...last, width: doc.page.width - 2 * PDF_MARGIN - fixed },
];
}
export function drawSection(doc: PDFKit.PDFDocument, title: string): void {
if (doc.y > doc.page.height - PDF_MARGIN - 80) doc.addPage();
doc.moveDown(0.6);
doc
.font('Helvetica-Bold')
.fontSize(11)
.fillColor('#000000')
.text(title, PDF_MARGIN);
doc.moveDown(0.2);
}
/** Draws a simple table, repeating the header on every new page. */
export function drawTable(
doc: PDFKit.PDFDocument,
columns: PdfColumn[],
rows: string[][],
): void {
const left = PDF_MARGIN;
const bottomLimit = doc.page.height - PDF_MARGIN - 20;
const drawRow = (cells: string[], bold: boolean) => {
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
const heights = cells.map((cell, index) =>
doc.heightOfString(cell, { width: columns[index].width - 4 }),
);
const rowHeight = Math.max(...heights, 10) + 4;
if (doc.y + rowHeight > bottomLimit) {
doc.addPage();
if (!bold) {
drawRow(
columns.map((c) => c.header),
true,
);
doc.font('Helvetica').fontSize(8);
}
}
const top = doc.y;
let x = left;
cells.forEach((cell, index) => {
doc.text(cell, x + 2, top + 2, { width: columns[index].width - 4 });
x += columns[index].width;
});
doc
.moveTo(left, top + rowHeight)
.lineTo(x, top + rowHeight)
.lineWidth(0.3)
.strokeColor('#999999')
.stroke();
doc.y = top + rowHeight;
};
drawRow(
columns.map((c) => c.header),
true,
);
if (rows.length === 0) {
doc
.font('Helvetica-Oblique')
.fontSize(8)
.text('Keine Einträge.', left + 2, doc.y + 2);
doc.moveDown(0.5);
return;
}
for (const row of rows) drawRow(row, false);
doc.moveDown(0.5);
}
@@ -0,0 +1,126 @@
import {
apiaryOnDate,
groupTreatmentLog,
TreatmentLogSource,
} from './treatment-log';
const d = (value: string) => new Date(`${value}T00:00:00.000Z`);
function source(overrides: Partial<TreatmentLogSource>): TreatmentLogSource {
return {
id: 't',
colonyId: 'c1',
colonyCode: 'V-01',
colonyApiaryId: 'today',
apiaryId: null,
date: d('2026-08-01'),
startDate: null,
endDate: null,
product: 'Oxalsäure',
amount: '30 ml',
batchNumber: 'B1',
supplier: 'Imkereibedarf X',
purchaseProof: 'Rechnung 1',
prescribingVet: null,
withdrawalPeriodDays: 0,
administeredBy: 'Imker',
...overrides,
};
}
describe('apiaryOnDate', () => {
it('uses the apiary stored with the treatment', () => {
expect(apiaryOnDate(source({ apiaryId: 'stored' }), [])).toEqual({
apiaryId: 'stored',
uncertain: false,
});
});
it('uses the last move on or before the treatment day', () => {
const moves = [
{ colonyId: 'c1', apiaryId: 'summer', movedAt: d('2026-05-01') },
{ colonyId: 'c1', apiaryId: 'winter', movedAt: d('2026-10-01') },
];
expect(apiaryOnDate(source({}), moves)).toEqual({
apiaryId: 'summer',
uncertain: false,
});
});
it('flags the location when the colony was only moved after the treatment', () => {
const moves = [
{ colonyId: 'c1', apiaryId: 'today', movedAt: d('2026-09-01') },
];
expect(apiaryOnDate(source({}), moves)).toEqual({
apiaryId: 'today',
uncertain: true,
});
});
it('trusts today when the colony was never moved', () => {
expect(apiaryOnDate(source({}), [])).toEqual({
apiaryId: 'today',
uncertain: false,
});
});
});
describe('groupTreatmentLog', () => {
it('puts identical treatments of several colonies into one row, per apiary', () => {
const sheets = groupTreatmentLog(
[
source({ id: '1', colonyId: 'c1', colonyCode: 'V-02', apiaryId: 'a' }),
source({ id: '2', colonyId: 'c2', colonyCode: 'V-01', apiaryId: 'a' }),
source({ id: '3', colonyId: 'c3', colonyCode: 'V-09', apiaryId: 'b' }),
source({
id: '4',
colonyId: 'c4',
colonyCode: 'V-03',
apiaryId: 'a',
amount: '50 ml',
}),
],
[],
);
const sheetA = sheets.find((sheet) => sheet.apiaryId === 'a');
expect(sheetA?.rows).toHaveLength(2);
expect(sheetA?.rows[0].colonyCodes).toEqual(['V-01', 'V-02']);
expect(sheets.find((sheet) => sheet.apiaryId === 'b')?.rows).toHaveLength(
1,
);
});
it('computes the treatment duration in days, inclusive', () => {
const [sheet] = groupTreatmentLog(
[
source({
apiaryId: 'a',
startDate: d('2026-08-01'),
endDate: d('2026-08-14'),
}),
],
[],
);
expect(sheet.rows[0].durationDays).toBe(14);
});
it('keeps missing mandatory fields empty so the PDF can flag them', () => {
const [sheet] = groupTreatmentLog(
[
source({
apiaryId: 'a',
supplier: null,
purchaseProof: null,
withdrawalPeriodDays: null,
}),
],
[],
);
expect(sheet.rows[0]).toMatchObject({
supplier: null,
purchaseProof: null,
withdrawalPeriodDays: null,
});
});
});
+353
View File
@@ -0,0 +1,353 @@
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import {
PDF_MARGIN,
drawTable,
fillWidth,
formatDay,
orDash,
} from './pdf-table';
/**
* Bestandsbuch: record of every veterinary medicine applied to bees, as
* required for food-producing animals by Art. 108 Regulation (EU) 2019/6 -
* for every product, including over-the-counter formic or oxalic acid. The
* columns follow the model sheet of the German veterinary authorities
* (Veterinäramt Kassel, "Buchführung über die Anwendung von TAM bei
* Bienen", March 2026): one sheet per apiary; colonies, product, supplier
* and purchase proof, date and duration, amount, withdrawal period (also
* when 0), administering person. Keep 5 years, as well as purchase proofs.
*
* Missing mandatory information is printed as "FEHLT" so it can be added
* before an inspection, instead of silently leaving a gap.
*/
export const MISSING = 'FEHLT';
export interface TreatmentLogSource {
id: string;
colonyId: string;
colonyCode: string;
colonyApiaryId: string;
/** Apiary recorded with the treatment (set since this report exists). */
apiaryId: string | null;
date: Date;
startDate: Date | null;
endDate: Date | null;
product: string;
amount: string | null;
batchNumber: string | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
withdrawalPeriodDays: number | null;
administeredBy: string;
}
export interface ColonyMove {
colonyId: string;
apiaryId: string;
movedAt: Date;
}
export interface TreatmentLogRow {
colonyCodes: string[];
product: string;
batchNumber: string | null;
supplier: string | null;
prescribingVet: string | null;
purchaseProof: string | null;
date: Date;
endDate: Date | null;
durationDays: number;
amount: string | null;
withdrawalPeriodDays: number | null;
administeredBy: string;
/** Apiary derived from today's assignment, not known for that date. */
locationUncertain: boolean;
}
export interface TreatmentLogSheet {
apiaryId: string;
rows: TreatmentLogRow[];
}
/**
* Where the colony stood on the day of the treatment: the apiary stored with
* the treatment, else the last recorded move on or before that day. Before
* the first recorded move the original apiary is not known - then today's
* apiary is used and the row is flagged.
*/
export function apiaryOnDate(
source: TreatmentLogSource,
moves: ColonyMove[],
): { apiaryId: string; uncertain: boolean } {
if (source.apiaryId) return { apiaryId: source.apiaryId, uncertain: false };
const colonyMoves = moves
.filter((move) => move.colonyId === source.colonyId)
.sort((a, b) => a.movedAt.getTime() - b.movedAt.getTime());
const before = colonyMoves.filter(
(move) => move.movedAt.getTime() <= source.date.getTime(),
);
if (before.length > 0) {
return { apiaryId: before[before.length - 1].apiaryId, uncertain: false };
}
return { apiaryId: source.colonyApiaryId, uncertain: colonyMoves.length > 0 };
}
function durationDays(start: Date, end: Date | null): number {
if (!end) return 1;
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
return Math.max(days, 1);
}
/**
* One sheet per apiary; treatments that are identical except for the colony
* (same day, product, batch, amount, supplier, person) become one row listing
* all colonies - as in the paper form.
*/
export function groupTreatmentLog(
sources: TreatmentLogSource[],
moves: ColonyMove[],
): TreatmentLogSheet[] {
const sheets = new Map<string, Map<string, TreatmentLogRow>>();
const sorted = [...sources].sort(
(a, b) =>
a.date.getTime() - b.date.getTime() ||
a.colonyCode.localeCompare(b.colonyCode, 'de', { numeric: true }),
);
for (const source of sorted) {
const { apiaryId, uncertain } = apiaryOnDate(source, moves);
const start = source.startDate ?? source.date;
const key = [
source.date.toISOString(),
source.endDate?.toISOString() ?? '',
source.product,
source.batchNumber ?? '',
source.amount ?? '',
source.supplier ?? '',
source.purchaseProof ?? '',
source.prescribingVet ?? '',
source.withdrawalPeriodDays ?? '',
source.administeredBy,
uncertain,
].join('|');
const rows = sheets.get(apiaryId) ?? new Map<string, TreatmentLogRow>();
const existing = rows.get(key);
if (existing) {
existing.colonyCodes.push(source.colonyCode);
} else {
rows.set(key, {
colonyCodes: [source.colonyCode],
product: source.product,
batchNumber: source.batchNumber,
supplier: source.supplier,
prescribingVet: source.prescribingVet,
purchaseProof: source.purchaseProof,
date: source.date,
endDate: source.endDate,
durationDays: durationDays(start, source.endDate),
amount: source.amount,
withdrawalPeriodDays: source.withdrawalPeriodDays,
administeredBy: source.administeredBy,
locationUncertain: uncertain,
});
}
sheets.set(apiaryId, rows);
}
return [...sheets.entries()].map(([apiaryId, rows]) => ({
apiaryId,
rows: [...rows.values()],
}));
}
// ---------------------------------------------------------------------------
// Loading and rendering
// ---------------------------------------------------------------------------
export interface TreatmentLog {
year: number;
generatedAt: Date;
holderName: string | null;
holderAddress: string | null;
sheets: (TreatmentLogSheet & {
apiaryName: string;
apiaryLocation: string | null;
})[];
}
export async function buildTreatmentLog(
prisma: PrismaService,
year: number,
apiaryId?: string,
): Promise<TreatmentLog> {
const treatments = await prisma.treatment.findMany({
where: {
date: {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
},
},
include: {
colony: { select: { code: true, apiaryId: true } },
user: { select: { name: true } },
},
});
const colonyIds = [...new Set(treatments.map((t) => t.colonyId))];
const [moves, settings, apiaries] = await Promise.all([
prisma.colonyApiaryHistory.findMany({
where: { colonyId: { in: colonyIds } },
select: { colonyId: true, apiaryId: true, movedAt: true },
}),
prisma.siteSettings.findUnique({ where: { id: 'singleton' } }),
prisma.apiary.findMany({
select: { id: true, name: true, location: true, address: true },
}),
]);
const sheets = groupTreatmentLog(
treatments.map((t) => ({
id: t.id,
colonyId: t.colonyId,
colonyCode: t.colony.code,
colonyApiaryId: t.colony.apiaryId,
apiaryId: t.apiaryId,
date: t.date,
startDate: t.startDate,
endDate: t.endDate,
product: t.product,
amount: t.amount,
batchNumber: t.batchNumber,
supplier: t.supplier,
purchaseProof: t.purchaseProof,
prescribingVet: t.prescribingVet,
withdrawalPeriodDays: t.withdrawalPeriodDays,
administeredBy: t.user.name,
})),
moves,
).filter((sheet) => !apiaryId || sheet.apiaryId === apiaryId);
const apiaryById = new Map(apiaries.map((a) => [a.id, a]));
return {
year,
generatedAt: new Date(),
holderName: settings?.holderName ?? null,
holderAddress: settings?.holderAddress ?? null,
sheets: sheets
.map((sheet) => {
const apiary = apiaryById.get(sheet.apiaryId);
return {
...sheet,
apiaryName: apiary?.name ?? 'Unbekannter Stand',
apiaryLocation: apiary?.address ?? apiary?.location ?? null,
};
})
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de')),
};
}
function orMissing(value: string | number | null | undefined): string {
return value === null || value === undefined || value === ''
? MISSING
: String(value);
}
function renderSheet(
doc: PDFKit.PDFDocument,
log: TreatmentLog,
sheet: TreatmentLog['sheets'][number] | null,
): void {
doc
.font('Helvetica-Bold')
.fontSize(14)
.text(
'Buchführung (Bestandsbuch) über die Anwendung von Tierarzneimitteln bei Bienen',
PDF_MARGIN,
PDF_MARGIN,
);
doc.font('Helvetica').fontSize(9);
doc.text(
`Jahr ${log.year} · erstellt am ${formatDay(log.generatedAt)} · Art. 108 VO (EU) 2019/6`,
);
doc.moveDown(0.4);
doc.text(
`Name und Anschrift des Bienenhalters: ${
log.holderName || log.holderAddress
? [log.holderName, log.holderAddress].filter(Boolean).join(', ')
: `${MISSING} (in ApiCore unter Verwaltung → Automatisierung → Stammdaten & Web eintragen)`
}`,
);
if (sheet) {
doc.text(
`Standort der Bienen: ${sheet.apiaryName}${sheet.apiaryLocation ? `, ${sheet.apiaryLocation}` : ''}`,
);
}
doc.moveDown(0.5);
if (!sheet) {
doc.text(`Im Jahr ${log.year} wurden keine Behandlungen erfasst.`);
return;
}
const uncertain = sheet.rows.some((row) => row.locationUncertain);
drawTable(
doc,
fillWidth(doc, [
{ header: 'Anzahl und Nummern der Völker', width: 110 },
{ header: 'Arzneimittel (Handelsname), Ch.-B.', width: 120 },
{ header: 'a) Lieferant (Name, Anschrift), ggf. Tierarzt', width: 150 },
{ header: 'b) Beleg für den Erwerb', width: 110 },
{ header: 'Datum der Verabreichung, Dauer', width: 90 },
{ header: 'Verabreichte Menge', width: 70 },
{ header: 'Wartezeit (Tage)', width: 50 },
{ header: 'Anwendende Person', width: 0 },
]),
sheet.rows.map((row) => [
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
`${orMissing(row.supplier)}${row.prescribingVet ? `; Tierarzt: ${row.prescribingVet}` : ''}`,
orMissing(row.purchaseProof),
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
orMissing(row.amount),
orMissing(row.withdrawalPeriodDays),
orDash(row.administeredBy),
]),
);
doc.font('Helvetica').fontSize(7);
if (uncertain) {
doc.text(
'* Standort aus der heutigen Zuordnung ermittelt: das Volk wurde später umgesetzt, der Stand am Behandlungstag ist nicht erfasst. Bitte prüfen.',
PDF_MARGIN,
);
}
doc.text(
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung nachtragen. Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
PDF_MARGIN,
);
}
export function renderTreatmentLogPdf(log: TreatmentLog): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({
size: 'A4',
layout: 'landscape',
margin: PDF_MARGIN,
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
if (log.sheets.length === 0) {
renderSheet(doc, log, null);
}
log.sheets.forEach((sheet, index) => {
if (index > 0) doc.addPage();
renderSheet(doc, log, sheet);
});
doc.end();
});
}
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
ArrayMinSize, ArrayMinSize,
ArrayUnique,
IsArray, IsArray,
IsDateString, IsDateString,
IsNumber, IsNumber,
@@ -18,6 +19,7 @@ export class CreateExtractionDto {
@ApiProperty({ type: [String] }) @ApiProperty({ type: [String] })
@IsArray() @IsArray()
@ArrayMinSize(1) @ArrayMinSize(1)
@ArrayUnique()
@IsString({ each: true }) @IsString({ each: true })
harvestIds!: string[]; harvestIds!: string[];
@@ -6,6 +6,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateFeedingDto } from './dto/create-feeding.dto'; import { CreateFeedingDto } from './dto/create-feeding.dto';
import { UpdateFeedingDto } from './dto/update-feeding.dto'; import { UpdateFeedingDto } from './dto/update-feeding.dto';
import { FeedingsService } from './feedings.service'; import { FeedingsService } from './feedings.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('feedings') @ApiTags('feedings')
@ApiBearerAuth() @ApiBearerAuth()
@@ -26,6 +27,7 @@ export class FeedingsController {
} }
@RequirePermission('feedings:create') @RequirePermission('feedings:create')
@Idempotent()
@Post() @Post()
create( create(
@Body() dto: CreateFeedingDto, @Body() dto: CreateFeedingDto,
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { FeedingsController } from './feedings.controller'; import { FeedingsController } from './feedings.controller';
import { ColonyFeedingsController } from './colony-feedings.controller'; import { ColonyFeedingsController } from './colony-feedings.controller';
import { FeedingsService } from './feedings.service'; import { FeedingsService } from './feedings.service';
@Module({ @Module({
imports: [AutomationModule],
controllers: [FeedingsController, ColonyFeedingsController], controllers: [FeedingsController, ColonyFeedingsController],
providers: [FeedingsService], providers: [FeedingsService],
exports: [FeedingsService], exports: [FeedingsService],
@@ -1,4 +1,5 @@
import { NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service';
import { FeedingsService } from './feedings.service'; import { FeedingsService } from './feedings.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
@@ -35,6 +36,7 @@ describe('FeedingsService', () => {
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
calendarSync as unknown as CalendarSyncService, calendarSync as unknown as CalendarSyncService,
{ evaluateFeeding: jest.fn() } as unknown as AutomationService,
); );
}); });
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
@@ -17,6 +18,7 @@ export class FeedingsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly calendarSync: CalendarSyncService, private readonly calendarSync: CalendarSyncService,
private readonly automation: AutomationService,
) {} ) {}
async findAll(): Promise<Feeding[]> { async findAll(): Promise<Feeding[]> {
@@ -62,6 +64,7 @@ export class FeedingsService {
afterData: feeding, afterData: feeding,
}); });
await this.calendarSync.enqueue('FEEDING', feeding.id); await this.calendarSync.enqueue('FEEDING', feeding.id);
await this.automation.evaluateFeeding(feeding);
return feeding; return feeding;
} }
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
ArrayMinSize, ArrayMinSize,
ArrayUnique,
IsArray, IsArray,
IsDateString, IsDateString,
IsInt, IsInt,
@@ -22,6 +23,7 @@ export class CreateHarvestDto {
@ApiProperty({ type: [String] }) @ApiProperty({ type: [String] })
@IsArray() @IsArray()
@ArrayMinSize(1) @ArrayMinSize(1)
@ArrayUnique()
@IsString({ each: true }) @IsString({ each: true })
colonyIds!: string[]; colonyIds!: string[];
@@ -6,6 +6,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHarvestDto } from './dto/create-harvest.dto'; import { CreateHarvestDto } from './dto/create-harvest.dto';
import { UpdateHarvestDto } from './dto/update-harvest.dto'; import { UpdateHarvestDto } from './dto/update-harvest.dto';
import { HarvestsService } from './harvests.service'; import { HarvestsService } from './harvests.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('harvests') @ApiTags('harvests')
@ApiBearerAuth() @ApiBearerAuth()
@@ -26,6 +27,7 @@ export class HarvestsController {
} }
@RequirePermission('harvests:create') @RequirePermission('harvests:create')
@Idempotent()
@Post() @Post()
create( create(
@Body() dto: CreateHarvestDto, @Body() dto: CreateHarvestDto,
@@ -55,7 +55,20 @@ export class HarvestsService {
} }
} }
// Net weight is gross weight minus frames and boxes - it cannot be larger.
private assertWeightsPlausible(
grossWeight: number | null | undefined,
netWeight: number | null | undefined,
): void {
if (grossWeight != null && netWeight != null && netWeight > grossWeight) {
throw new BadRequestException(
'Das Nettogewicht kann nicht größer als das Bruttogewicht sein',
);
}
}
async create(dto: CreateHarvestDto, actingUserId: string) { async create(dto: CreateHarvestDto, actingUserId: string) {
this.assertWeightsPlausible(dto.grossWeight, dto.netWeight);
await this.assertColoniesExist(dto.colonyIds); await this.assertColoniesExist(dto.colonyIds);
const harvest = await this.prisma.$transaction(async (tx) => { const harvest = await this.prisma.$transaction(async (tx) => {
const created = await tx.harvest.create({ const created = await tx.harvest.create({
@@ -93,6 +106,10 @@ export class HarvestsService {
async update(id: string, dto: UpdateHarvestDto, actingUserId: string) { async update(id: string, dto: UpdateHarvestDto, actingUserId: string) {
const before = await this.findById(id); const before = await this.findById(id);
this.assertWeightsPlausible(
dto.grossWeight !== undefined ? dto.grossWeight : before.grossWeight,
dto.netWeight !== undefined ? dto.netWeight : before.netWeight,
);
const { colonyIds, ...scalarFields } = dto; const { colonyIds, ...scalarFields } = dto;
if (colonyIds) { if (colonyIds) {
await this.assertColoniesExist(colonyIds); await this.assertColoniesExist(colonyIds);
@@ -6,6 +6,8 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Max,
Min,
} from 'class-validator'; } from 'class-validator';
export class CreateInspectionDto { export class CreateInspectionDto {
@@ -30,6 +32,7 @@ export class CreateInspectionDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0)
colonyStrength?: number; colonyStrength?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -40,6 +43,8 @@ export class CreateInspectionDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Max(5)
broodPattern?: number; broodPattern?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -70,16 +75,22 @@ export class CreateInspectionDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Max(5)
swarmMood?: number; swarmMood?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Max(5)
gentleness?: number; gentleness?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1)
@Max(5)
combBehavior?: number; combBehavior?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -7,6 +7,7 @@ import { CreateInspectionDto } from './dto/create-inspection.dto';
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
import { UpdateInspectionDto } from './dto/update-inspection.dto'; import { UpdateInspectionDto } from './dto/update-inspection.dto';
import { InspectionsService } from './inspections.service'; import { InspectionsService } from './inspections.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('inspections') @ApiTags('inspections')
@ApiBearerAuth() @ApiBearerAuth()
@@ -27,6 +28,7 @@ export class InspectionsController {
} }
@RequirePermission('inspections:create') @RequirePermission('inspections:create')
@Idempotent()
@Post() @Post()
create( create(
@Body() dto: CreateInspectionDto, @Body() dto: CreateInspectionDto,
@@ -80,7 +80,7 @@ export class InspectionsService {
actingUserId: string, actingUserId: string,
): Promise<Task> { ): Promise<Task> {
const inspection = await this.findById(id); const inspection = await this.findById(id);
return this.prisma.task.create({ const task = await this.prisma.task.create({
data: { data: {
title: dto.title, title: dto.title,
description: dto.description, description: dto.description,
@@ -93,5 +93,19 @@ export class InspectionsService {
sourceInspectionId: inspection.id, sourceInspectionId: inspection.id,
}, },
}); });
// Same trail as a task created on the task page: audit entry, webhook
// and calendar entry must not depend on where the task was started.
await this.auditLog.record({
entityType: 'Task',
entityId: task.id,
action: 'CREATE',
userId: actingUserId,
afterData: task,
});
await this.webhooks.dispatch('task.created', task);
if (task.dueDate) {
await this.calendarSync.enqueue('TASK', task.id);
}
return task;
} }
} }
@@ -0,0 +1,78 @@
import { computeCropMarks, CropMarkGrid, CropMarkLine } from './crop-marks';
// 2 x 2 labels of 90 x 35 mm, 10 mm margin, 2 mm gaps on A4.
const GRID: CropMarkGrid = {
pageWidthMm: 210,
pageHeightMm: 297,
columns: 2,
rows: 2,
slotWidthMm: 90,
slotHeightMm: 35,
marginLeftMm: 10,
marginTopMm: 10,
gapXMm: 2,
gapYMm: 2,
};
const vertical = (lines: CropMarkLine[]) =>
lines.filter((line) => line.x1 === line.x2);
const horizontal = (lines: CropMarkLine[]) =>
lines.filter((line) => line.y1 === line.y2);
describe('computeCropMarks', () => {
it('marks both edges of every column and row, at both ends of each cut', () => {
const lines = computeCropMarks(GRID);
// Columns: 10|100 and 102|192 -> 4 cuts, marked at top and bottom.
const xs = [...new Set(vertical(lines).map((line) => line.x1))];
expect(xs).toEqual([10, 100, 102, 192]);
expect(vertical(lines)).toHaveLength(8);
// Rows: 10|45 and 47|82 -> 4 cuts, marked left and right.
const ys = [...new Set(horizontal(lines).map((line) => line.y1))];
expect(ys).toEqual([10, 45, 47, 82]);
expect(horizontal(lines)).toHaveLength(8);
});
it('keeps every mark in the margin, never on a label', () => {
const lines = computeCropMarks(GRID);
const gridRight = 192;
const gridBottom = 82;
for (const line of vertical(lines)) {
const inTopMargin = line.y2 <= GRID.marginTopMm - 1;
const inBottomMargin = line.y1 >= gridBottom + 1;
expect(inTopMargin || inBottomMargin).toBe(true);
}
for (const line of horizontal(lines)) {
const inLeftMargin = line.x2 <= GRID.marginLeftMm - 1;
const inRightMargin = line.x1 >= gridRight + 1;
expect(inLeftMargin || inRightMargin).toBe(true);
}
});
it('stays clear of the unprintable paper edge', () => {
const lines = computeCropMarks({
...GRID,
marginLeftMm: 5,
marginTopMm: 5,
});
for (const line of lines) {
expect(
Math.min(line.x1, line.x2, line.y1, line.y2),
).toBeGreaterThanOrEqual(2);
}
});
it('draws one cut, not two, where labels touch without a gap', () => {
const lines = computeCropMarks({ ...GRID, gapXMm: 0 });
const xs = [...new Set(vertical(lines).map((line) => line.x1))];
expect(xs).toEqual([10, 100, 190]);
});
it('leaves out marks when the margin is too narrow to print them', () => {
const lines = computeCropMarks({ ...GRID, marginTopMm: 2 });
// No room above the grid: top marks disappear, bottom marks remain.
expect(vertical(lines).every((line) => line.y1 > 10)).toBe(true);
expect(vertical(lines)).toHaveLength(4);
});
});
@@ -0,0 +1,123 @@
/**
* Cut marks for trimming a printed label sheet with a guillotine / lever
* paper cutter: short hairlines in the sheet margin, outside every label,
* one at each end of every cut line. Laying both ends of a mark against the
* cutter's ruler lines the blade up with the label edge.
*
* All values in millimetres on an A4 page (0,0 = top left).
*/
/** How far a mark stays away from the label edge it points at. */
export const CROP_MARK_GAP_MM = 1;
/** Mark length. Short on purpose - it lives in the margin only. */
export const CROP_MARK_LENGTH_MM = 4;
/** Most printers cannot print closer to the paper edge than this. */
export const CROP_MARK_PAGE_INSET_MM = 2;
/** A mark shorter than this is not worth printing (too little margin). */
const MIN_MARK_LENGTH_MM = 1.5;
export interface CropMarkGrid {
pageWidthMm: number;
pageHeightMm: number;
columns: number;
rows: number;
slotWidthMm: number;
slotHeightMm: number;
marginLeftMm: number;
marginTopMm: number;
gapXMm: number;
gapYMm: number;
}
export interface CropMarkLine {
x1: number;
y1: number;
x2: number;
y2: number;
}
/** Cut positions along one axis; edges closer than 0.05 mm are one cut. */
function cutPositions(
start: number,
count: number,
size: number,
gap: number,
): number[] {
const positions: number[] = [];
for (let i = 0; i < count; i++) {
const from = start + i * (size + gap);
positions.push(from, from + size);
}
return positions
.sort((a, b) => a - b)
.filter(
(value, index, all) => index === 0 || value - all[index - 1] > 0.05,
);
}
/**
* The segment of a mark in the margin between the page edge and the grid,
* or null when the margin is too narrow to print one.
*/
function marginSegment(
gridEdge: number,
pageEdge: number,
towardsPageStart: boolean,
): [number, number] | null {
if (towardsPageStart) {
const end = gridEdge - CROP_MARK_GAP_MM;
const start = Math.max(end - CROP_MARK_LENGTH_MM, CROP_MARK_PAGE_INSET_MM);
return end - start >= MIN_MARK_LENGTH_MM ? [start, end] : null;
}
const start = gridEdge + CROP_MARK_GAP_MM;
const end = Math.min(
start + CROP_MARK_LENGTH_MM,
pageEdge - CROP_MARK_PAGE_INSET_MM,
);
return end - start >= MIN_MARK_LENGTH_MM ? [start, end] : null;
}
export function computeCropMarks(grid: CropMarkGrid): CropMarkLine[] {
if (grid.columns < 1 || grid.rows < 1) {
return [];
}
const gridLeft = grid.marginLeftMm;
const gridTop = grid.marginTopMm;
const gridRight =
gridLeft +
grid.columns * grid.slotWidthMm +
(grid.columns - 1) * grid.gapXMm;
const gridBottom =
gridTop + grid.rows * grid.slotHeightMm + (grid.rows - 1) * grid.gapYMm;
const verticalCuts = cutPositions(
gridLeft,
grid.columns,
grid.slotWidthMm,
grid.gapXMm,
);
const horizontalCuts = cutPositions(
gridTop,
grid.rows,
grid.slotHeightMm,
grid.gapYMm,
);
const top = marginSegment(gridTop, grid.pageHeightMm, true);
const bottom = marginSegment(gridBottom, grid.pageHeightMm, false);
const left = marginSegment(gridLeft, grid.pageWidthMm, true);
const right = marginSegment(gridRight, grid.pageWidthMm, false);
const lines: CropMarkLine[] = [];
for (const x of verticalCuts) {
for (const segment of [top, bottom]) {
if (segment) lines.push({ x1: x, y1: segment[0], x2: x, y2: segment[1] });
}
}
for (const y of horizontalCuts) {
for (const segment of [left, right]) {
if (segment) lines.push({ x1: segment[0], y1: y, x2: segment[1], y2: y });
}
}
return lines;
}
@@ -78,6 +78,14 @@ export class CreateLabelTemplateDto {
@Min(0) @Min(0)
gapYMm?: number; gapYMm?: number;
@ApiPropertyOptional({
description:
'Schnittmarken im Blattrand drucken (zum Schneiden mit dem Papierschneider)',
})
@IsOptional()
@IsBoolean()
cropMarks?: boolean;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
Loaded 100 of 369 files, more files were not shown because too many files have changed in this diff. Show more