From c01a38835aab03d004d87a602294276a9227821b Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 13 Oct 2026 17:57:54 +0200 Subject: [PATCH] feat: platform backbone and field capture, first part (checkpoint) Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitea/workflows/backend-image.yml | 1 - .gitea/workflows/frontend-image.yml | 1 - .gitea/workflows/manifests.yml | 74 + ARCHITECTURE.md | 121 +- ApiCore-Backend/.dockerignore | 3 + ApiCore-Backend/.env.example | 38 +- ApiCore-Backend/Dockerfile | 34 +- ApiCore-Backend/package-lock.json | 32 +- ApiCore-Backend/package.json | 4 + .../migration.sql | 139 ++ .../migration.sql | 1 + ApiCore-Backend/prisma/schema.prisma | 181 +- ApiCore-Backend/src/all-in-one.module.ts | 14 + .../src/api-keys/api-keys.service.spec.ts | 58 + .../src/api-keys/api-keys.service.ts | 11 + .../src/apiaries/apiaries.controller.ts | 24 +- .../src/apiaries/apiaries.module.ts | 3 +- .../src/apiaries/apiary-field.service.spec.ts | 206 ++ .../src/apiaries/apiary-field.service.ts | 230 ++ .../src/apiaries/apiary-field.types.ts | 91 + .../src/apiaries/dto/create-apiary.dto.ts | 17 +- ApiCore-Backend/src/app.config.ts | 2 +- ApiCore-Backend/src/app.module.ts | 13 +- .../src/audit/audit-log.service.spec.ts | 12 + .../src/audit/audit-log.service.ts | 16 +- .../src/auth/mfa/mfa-crypto.util.spec.ts | 107 +- .../src/auth/mfa/mfa-crypto.util.ts | 64 +- .../src/automation/varroa-threshold.spec.ts | 41 + .../src/automation/varroa-threshold.ts | 30 + .../src/backup/backup-crypto.spec.ts | 129 +- ApiCore-Backend/src/backup/backup-crypto.ts | 121 +- .../src/backup/backup.service.spec.ts | 21 + ApiCore-Backend/src/backup/backup.service.ts | 8 + .../calendar-sync/calendar-sync.controller.ts | 29 +- .../calendar-sync.service.spec.ts | 204 +- .../calendar-sync/calendar-sync.service.ts | 89 +- .../src/common/crypto/key-ring.spec.ts | 370 +++ ApiCore-Backend/src/common/crypto/key-ring.ts | 286 +++ .../middleware/no-store.middleware.spec.ts | 26 + .../common/middleware/no-store.middleware.ts | 17 +- .../src/common/outbox/outbox-claims.ts | 204 ++ .../src/common/retry-policy.spec.ts | 29 + ApiCore-Backend/src/common/retry-policy.ts | 23 + .../prisma-throttler.storage.spec.ts | 108 + .../throttler/prisma-throttler.storage.ts | 88 + .../throttler/throttler-storage.module.ts | 15 + .../update-dto-null-handling.spec.ts | 10 + .../src/common/with-timeout.spec.ts | 37 + ApiCore-Backend/src/common/with-timeout.ts | 16 + ApiCore-Backend/src/common/worker-wakeup.ts | 24 + .../src/documents/documents.controller.ts | 2 + .../src/domain-events/domain-event-context.ts | 10 + .../domain-event-dispatcher.spec.ts | 589 +++++ .../domain-events/domain-event-dispatcher.ts | 269 +++ .../domain-event-registry.spec.ts | 456 ++++ .../domain-events/domain-event-registry.ts | 174 ++ .../src/domain-events/domain-event-types.ts | 173 ++ .../src/domain-events/domain-events.module.ts | 14 + .../domain-events.service.spec.ts | 265 +++ .../domain-events/domain-events.service.ts | 66 + .../domain-events/handler-contract.spec.ts | 209 ++ .../on-domain-event.decorator.ts | 50 + .../src/health/health.controller.ts | 22 +- .../inspections/dto/create-inspection.dto.ts | 6 + ApiCore-Backend/src/main.ts | 57 +- .../notifications.service.spec.ts | 97 + .../notifications/notifications.service.ts | 46 +- ApiCore-Backend/src/prisma/db-client.spec.ts | 25 + ApiCore-Backend/src/prisma/db-client.ts | 23 + ApiCore-Backend/src/prisma/db-session.spec.ts | 130 ++ ApiCore-Backend/src/prisma/db-session.ts | 34 + ApiCore-Backend/src/prisma/prisma.service.ts | 4 +- .../src/public/public.controller.ts | 18 +- .../src/public/public.service.spec.ts | 92 +- ApiCore-Backend/src/public/public.service.ts | 27 +- ApiCore-Backend/src/runtime/role.spec.ts | 58 + ApiCore-Backend/src/runtime/role.ts | 57 + .../src/runtime/root-module.spec.ts | 109 + ApiCore-Backend/src/runtime/root-module.ts | 20 + .../src/runtime/startup-checks.spec.ts | 324 +++ ApiCore-Backend/src/runtime/startup-checks.ts | 145 ++ .../dto/update-site-settings.dto.ts | 14 + .../site-settings/site-settings.service.ts | 1 + .../dto/last-by-product-query.dto.ts | 24 + .../treatments/treatment-prefill.service.ts | 52 + .../src/treatments/treatments.controller.ts | 22 +- .../src/treatments/treatments.module.ts | 3 +- .../src/varroa/varroa-threshold.service.ts | 21 + .../src/varroa/varroa.controller.ts | 13 +- ApiCore-Backend/src/varroa/varroa.module.ts | 3 +- .../src/weather/weather.service.spec.ts | 61 + .../src/weather/weather.service.ts | 8 +- .../src/webhooks/webhook-secret.spec.ts | 91 + .../src/webhooks/webhook-secret.ts | 37 +- .../winter-checks.service.spec.ts | 86 + .../winter-checks/winter-checks.service.ts | 7 +- ApiCore-Backend/src/worker.spec.ts | 66 + ApiCore-Backend/src/worker.ts | 28 + .../src/worker/pg-listener.spec.ts | 254 +++ ApiCore-Backend/src/worker/pg-listener.ts | 149 ++ .../src/worker/polling-loop.spec.ts | 215 ++ ApiCore-Backend/src/worker/polling-loop.ts | 81 + .../worker/worker-health-server.service.ts | 44 + .../src/worker/worker-health.spec.ts | 135 ++ ApiCore-Backend/src/worker/worker-health.ts | 94 + .../src/worker/worker-runtime.module.ts | 14 + .../src/worker/worker-runtime.service.spec.ts | 330 +++ .../src/worker/worker-runtime.service.ts | 175 ++ ApiCore-Backend/src/worker/worker.module.ts | 36 + ApiCore-Backend/test/api-keys.e2e-spec.ts | 8 + ApiCore-Backend/test/apiaries.e2e-spec.ts | 176 ++ .../test/calendar-sync.e2e-spec.ts | 97 +- ApiCore-Backend/test/db-session.e2e-spec.ts | 64 + ApiCore-Backend/test/documents.e2e-spec.ts | 31 + .../test/domain-event-dispatcher.e2e-spec.ts | 538 +++++ ApiCore-Backend/test/health.e2e-spec.ts | 31 + ApiCore-Backend/test/inspections.e2e-spec.ts | 29 + .../test/platform-schema.e2e-spec.ts | 55 + ApiCore-Backend/test/public.e2e-spec.ts | 39 +- ApiCore-Backend/test/stock.e2e-spec.ts | 2 +- ApiCore-Backend/test/throttler.e2e-spec.ts | 174 ++ ApiCore-Backend/test/treatments.e2e-spec.ts | 96 + ApiCore-Backend/test/utils/create-test-app.ts | 20 +- ApiCore-Backend/test/utils/domain-events.ts | 27 + ApiCore-Backend/test/varroa.e2e-spec.ts | 56 +- .../test/worker-runtime.e2e-spec.ts | 219 ++ ApiCore-Backend/tsconfig.build.json | 5 +- ApiCore-Frontend/Dockerfile | 5 +- ApiCore-Frontend/package-lock.json | 11 + ApiCore-Frontend/package.json | 1 + .../[id]/apiary-colonies-card.spec.tsx | 109 + .../apiaries/[id]/apiary-colonies-card.tsx | 107 + .../apiaries/[id]/apiary-edit-card.spec.tsx | 4 +- .../(app)/apiaries/[id]/apiary-edit-card.tsx | 18 + .../src/app/(app)/apiaries/[id]/page.tsx | 20 +- .../apiaries/new/apiary-form-values.spec.ts | 18 + .../(app)/apiaries/new/apiary-form-values.ts | 7 + .../src/app/(app)/apiaries/new/page.spec.tsx | 1 + .../src/app/(app)/apiaries/new/page.tsx | 21 + .../src/app/(app)/apiaries/page.spec.tsx | 1 + .../src/app/(app)/bottling/new/page.tsx | 7 + ApiCore-Frontend/src/app/(app)/bottom-nav.tsx | 28 +- .../colonies/[id]/colony-inspections.spec.tsx | 52 + .../colonies/[id]/colony-inspections.tsx | 1 + .../colonies/[id]/colony-varroa.spec.tsx | 93 + .../app/(app)/colonies/[id]/colony-varroa.tsx | 132 +- .../[id]/inspection-edit-dialog.spec.tsx | 92 +- .../colonies/[id]/inspection-edit-dialog.tsx | 65 +- .../colonies/[id]/varroa-edit-dialog.spec.tsx | 27 +- .../colonies/[id]/varroa-edit-dialog.tsx | 41 +- .../src/app/(app)/colonies/new/page.tsx | 7 + .../src/app/(app)/equipment/new/page.tsx | 7 + .../src/app/(app)/extractions/new/page.tsx | 7 + .../src/app/(app)/feedings/new/page.tsx | 68 +- .../src/app/(app)/harvests/new/page.tsx | 66 +- .../new/inspection-form-values.spec.ts | 27 + .../inspections/new/inspection-form-values.ts | 19 + .../app/(app)/inspections/new/page.spec.tsx | 583 ++++- .../src/app/(app)/inspections/new/page.tsx | 596 ++++- .../src/app/(app)/layout.spec.tsx | 62 +- ApiCore-Frontend/src/app/(app)/layout.tsx | 28 +- .../src/app/(app)/more-sheet.spec.tsx | 29 + ApiCore-Frontend/src/app/(app)/more-sheet.tsx | 81 + .../src/app/(app)/nav-config.spec.ts | 53 + ApiCore-Frontend/src/app/(app)/nav-config.ts | 193 ++ .../production-batches/new/page.spec.tsx | 47 + .../app/(app)/production-batches/new/page.tsx | 23 +- .../src/app/(app)/products/new/page.tsx | 7 + .../src/app/(app)/queens/new/page.tsx | 7 + .../src/app/(app)/quick-actions.ts | 63 + .../src/app/(app)/quick-add-menu.spec.tsx | 38 - .../src/app/(app)/quick-add-menu.tsx | 62 - .../src/app/(app)/quick-add-sheet.spec.tsx | 82 + .../src/app/(app)/quick-add-sheet.tsx | 92 + .../app/(app)/raw-materials/new/page.spec.tsx | 53 + .../src/app/(app)/raw-materials/new/page.tsx | 22 +- .../settings/automation/api-keys-tab.spec.tsx | 90 + .../settings/automation/api-keys-tab.tsx | 41 +- .../automation/social-media-tab.spec.tsx | 70 + .../settings/automation/social-media-tab.tsx | 29 + .../settings/label-templates/new/page.tsx | 6 + ApiCore-Frontend/src/app/(app)/sidebar.tsx | 162 +- .../src/app/(app)/swarm-control/new/page.tsx | 68 +- .../src/app/(app)/tasks/new/page.tsx | 7 + .../app/(app)/treatments/new/page.spec.tsx | 216 +- .../src/app/(app)/treatments/new/page.tsx | 189 +- .../src/app/(app)/varroa/new/page.spec.tsx | 89 +- .../src/app/(app)/varroa/new/page.tsx | 134 +- .../(app)/varroa/new/varroa-form-values.ts | 2 +- .../src/app/(auth)/login/page.spec.tsx | 25 + .../src/app/(auth)/login/page.tsx | 16 +- ApiCore-Frontend/src/app/globals.css | 6 +- .../src/app/herkunft/[lot]/page.tsx | 13 +- ApiCore-Frontend/src/app/providers.spec.tsx | 40 + ApiCore-Frontend/src/app/providers.tsx | 17 +- .../colonies/status-light-badge.tsx | 22 + .../src/components/confirm-dialog.tsx | 7 + .../components/document-upload-card.spec.tsx | 131 ++ .../src/components/document-upload-card.tsx | 52 +- .../src/components/form/choice-group.tsx | 3 + .../components/form/form-back-link.spec.tsx | 58 + .../src/components/form/form-back-link.tsx | 83 + .../components/form/number-stepper.spec.tsx | 111 + .../src/components/form/number-stepper.tsx | 123 + .../form/previous-value-hint.spec.tsx | 22 + .../components/form/previous-value-hint.tsx | 40 + .../src/components/form/rating-scale.spec.tsx | 94 + .../src/components/form/rating-scale.tsx | 42 +- .../components/form/tri-state-field.spec.tsx | 27 + .../src/components/form/tri-state-field.tsx | 12 +- .../src/components/nav/nav-sheet.tsx | 70 + .../src/components/offline-sync-badge.tsx | 78 - .../components/offline/offline-band.spec.tsx | 263 +++ .../src/components/offline/offline-band.tsx | 171 ++ .../offline/pending-queue-sheet.spec.tsx | 226 ++ .../offline/pending-queue-sheet.tsx | 256 +++ .../photos/photo-capture-field.spec.tsx | 144 ++ .../components/photos/photo-capture-field.tsx | 229 ++ .../components/rounds/round-next-panel.tsx | 53 + .../components/treatments/prefill-note.tsx | 18 + .../components/varroa/varroa-period-field.tsx | 107 + .../components/varroa/varroa-rate-light.tsx | 55 + .../src/lib/api-keys/scopes.spec.ts | 13 + ApiCore-Frontend/src/lib/api-keys/scopes.ts | 16 + ApiCore-Frontend/src/lib/api-keys/types.ts | 6 +- .../src/lib/apiaries/field-api.ts | 11 + .../src/lib/apiaries/field-types.ts | 83 + ApiCore-Frontend/src/lib/apiaries/types.ts | 4 + .../src/lib/apiaries/use-colony-overview.ts | 13 + .../src/lib/auth/api-client.spec.ts | 104 + ApiCore-Frontend/src/lib/auth/api-client.ts | 86 +- ApiCore-Frontend/src/lib/auth/api.spec.ts | 44 + ApiCore-Frontend/src/lib/auth/api.ts | 32 +- ApiCore-Frontend/src/lib/calendar-day.spec.ts | 9 + .../src/lib/colonies/status-light.spec.ts | 60 + .../src/lib/colonies/status-light.ts | 86 + .../src/lib/config/runtime-config.spec.ts | 20 + .../src/lib/config/runtime-config.ts | 3 +- ApiCore-Frontend/src/lib/dates.ts | 7 + ApiCore-Frontend/src/lib/documents/api.ts | 24 +- ApiCore-Frontend/src/lib/documents/limits.ts | 5 + .../src/lib/forms/back-target.spec.ts | 24 + ApiCore-Frontend/src/lib/forms/back-target.ts | 25 + .../src/lib/forms/rating-contrast.spec.ts | 56 + ApiCore-Frontend/src/lib/forms/rating.spec.ts | 32 + ApiCore-Frontend/src/lib/forms/rating.ts | 35 + .../src/lib/forms/use-form-draft.spec.ts | 41 + .../src/lib/forms/use-form-draft.ts | 35 +- .../src/lib/http/deadline-test-helper.ts | 27 + .../src/lib/http/fetch-with-timeout.spec.ts | 108 + .../src/lib/http/fetch-with-timeout.ts | 99 + .../src/lib/inspections/takeover.spec.ts | 170 ++ .../src/lib/inspections/takeover.ts | 144 ++ ApiCore-Frontend/src/lib/inspections/types.ts | 3 + .../lib/inspections/use-last-inspection.ts | 32 + .../src/lib/offline/connectivity.spec.ts | 48 + .../src/lib/offline/connectivity.ts | 42 + ApiCore-Frontend/src/lib/offline/db.spec.ts | 229 ++ ApiCore-Frontend/src/lib/offline/db.ts | 499 +++- .../src/lib/offline/null-roundtrip.spec.ts | 25 +- .../src/lib/offline/queue-texts.spec.ts | 26 + .../src/lib/offline/queue-texts.ts | 26 + ApiCore-Frontend/src/lib/offline/sync.spec.ts | 462 +++- ApiCore-Frontend/src/lib/offline/sync.ts | 230 +- .../lib/offline/use-capture-mutation.spec.tsx | 154 ++ .../src/lib/offline/use-capture-mutation.ts | 79 + .../src/lib/photos/reencode.spec.ts | 121 + ApiCore-Frontend/src/lib/photos/reencode.ts | 118 + ApiCore-Frontend/src/lib/photos/store.spec.ts | 327 +++ ApiCore-Frontend/src/lib/photos/store.ts | 297 +++ .../src/lib/public-batch/origin-text.spec.ts | 20 + .../src/lib/public-batch/origin-text.ts | 6 + .../src/lib/public-batch/types.ts | 9 +- .../src/lib/rounds/round-state.spec.ts | 96 + .../src/lib/rounds/round-state.ts | 139 ++ .../src/lib/rounds/use-stand-round.ts | 68 + .../src/lib/site-settings/types.ts | 2 + .../src/lib/treatments/api.spec.ts | 21 +- ApiCore-Frontend/src/lib/treatments/api.ts | 10 + .../src/lib/treatments/prefill.spec.ts | 66 + .../src/lib/treatments/prefill.ts | 44 + ApiCore-Frontend/src/lib/treatments/types.ts | 12 + .../lib/treatments/use-prefilled-fields.ts | 84 + .../lib/treatments/use-treatment-prefill.ts | 54 + ApiCore-Frontend/src/lib/varroa/api.ts | 4 + ApiCore-Frontend/src/lib/varroa/format.ts | 15 + .../src/lib/varroa/period.spec.ts | 22 + ApiCore-Frontend/src/lib/varroa/period.ts | 38 + ApiCore-Frontend/src/lib/varroa/rate.spec.ts | 19 + ApiCore-Frontend/src/lib/varroa/rate.ts | 24 + .../src/lib/varroa/use-varroa-threshold.ts | 14 + .../src/lib/weather/describe.spec.ts | 79 + ApiCore-Frontend/src/lib/weather/describe.ts | 55 + ApiCore-Frontend/src/lib/weather/types.ts | 2 + CLAUDE.md | 2 +- .../{ => base}/backend/configmap.example.yaml | 0 deploy/k8s/{ => base}/backend/data-pvc.yaml | 0 deploy/k8s/{ => base}/backend/deployment.yaml | 27 +- .../{ => base}/backend/secret.example.yaml | 23 +- deploy/k8s/{ => base}/backend/service.yaml | 0 .../frontend/configmap.example.yaml | 0 .../k8s/{ => base}/frontend/deployment.yaml | 6 +- deploy/k8s/{ => base}/frontend/service.yaml | 0 deploy/k8s/base/kustomization.yaml | 16 + .../{ => base}/postgres/backup-cronjob.yaml | 0 deploy/k8s/base/postgres/data-pvc.yaml | 18 + .../{ => base}/postgres/secret.example.yaml | 0 deploy/k8s/base/postgres/service.yaml | 14 + deploy/k8s/base/postgres/statefulset.yaml | 132 ++ deploy/k8s/components/api-hpa/hpa.yaml | 18 + .../k8s/components/api-hpa/kustomization.yaml | 16 + .../api-hpa/patch-api-replicas.yaml | 2 + .../components/backend-roles/cronjobs.yaml | 163 ++ .../backend-roles/kustomization.yaml | 71 + .../components/backend-roles/patch-api.yaml | 91 + .../backend-roles/worker-deployment.yaml | 127 ++ .../network-policies/acme-http01-solver.yaml | 22 + .../network-policies/allow-dns.yaml | 19 + .../k8s/components/network-policies/api.yaml | 42 + .../network-policies/default-deny.yaml | 8 + .../components/network-policies/frontend.yaml | 28 + .../k8s/components/network-policies/jobs.yaml | 18 + .../network-policies/kustomization.yaml | 19 + .../network-policies/patch-api-db-client.yaml | 14 + .../components/network-policies/postgres.yaml | 20 + .../components/network-policies/worker.yaml | 27 + .../pod-hardening/kustomization.yaml | 7 + .../pod-hardening/patch-frontend.yaml | 31 + .../k8s/overlays/encry/configmap-backend.yaml | 21 + .../overlays/encry/configmap-frontend.yaml | 8 + .../encry/ingress-backend.yaml} | 0 .../encry/ingress-frontend.yaml} | 0 deploy/k8s/overlays/encry/kustomization.yaml | 51 + deploy/k8s/postgres/service.yaml | 11 - deploy/k8s/postgres/statefulset.yaml | 57 - deploy/k8s/tests/manifest-policy.test.mjs | 459 ++++ deploy/k8s/tests/netpol-model.mjs | 161 ++ deploy/k8s/tests/render-stages.mjs | 83 + .../plans/2026-10-13-plattform-rueckgrat.md | 2010 +++++++++++++++++ .../plans/2026-10-13-t3-erfassung-am-stand.md | 994 ++++++++ 340 files changed, 25695 insertions(+), 1808 deletions(-) create mode 100644 .gitea/workflows/manifests.yml create mode 100644 ApiCore-Backend/prisma/migrations/20261013100000_platform_backbone/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261013120000_inspection_brood_frames/migration.sql create mode 100644 ApiCore-Backend/src/all-in-one.module.ts create mode 100644 ApiCore-Backend/src/api-keys/api-keys.service.spec.ts create mode 100644 ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts create mode 100644 ApiCore-Backend/src/apiaries/apiary-field.service.ts create mode 100644 ApiCore-Backend/src/apiaries/apiary-field.types.ts create mode 100644 ApiCore-Backend/src/automation/varroa-threshold.spec.ts create mode 100644 ApiCore-Backend/src/automation/varroa-threshold.ts create mode 100644 ApiCore-Backend/src/common/crypto/key-ring.spec.ts create mode 100644 ApiCore-Backend/src/common/crypto/key-ring.ts create mode 100644 ApiCore-Backend/src/common/middleware/no-store.middleware.spec.ts create mode 100644 ApiCore-Backend/src/common/outbox/outbox-claims.ts create mode 100644 ApiCore-Backend/src/common/retry-policy.spec.ts create mode 100644 ApiCore-Backend/src/common/retry-policy.ts create mode 100644 ApiCore-Backend/src/common/throttler/prisma-throttler.storage.spec.ts create mode 100644 ApiCore-Backend/src/common/throttler/prisma-throttler.storage.ts create mode 100644 ApiCore-Backend/src/common/throttler/throttler-storage.module.ts create mode 100644 ApiCore-Backend/src/common/with-timeout.spec.ts create mode 100644 ApiCore-Backend/src/common/with-timeout.ts create mode 100644 ApiCore-Backend/src/common/worker-wakeup.ts create mode 100644 ApiCore-Backend/src/domain-events/domain-event-context.ts create mode 100644 ApiCore-Backend/src/domain-events/domain-event-dispatcher.spec.ts create mode 100644 ApiCore-Backend/src/domain-events/domain-event-dispatcher.ts create mode 100644 ApiCore-Backend/src/domain-events/domain-event-registry.spec.ts create mode 100644 ApiCore-Backend/src/domain-events/domain-event-registry.ts create mode 100644 ApiCore-Backend/src/domain-events/domain-event-types.ts create mode 100644 ApiCore-Backend/src/domain-events/domain-events.module.ts create mode 100644 ApiCore-Backend/src/domain-events/domain-events.service.spec.ts create mode 100644 ApiCore-Backend/src/domain-events/domain-events.service.ts create mode 100644 ApiCore-Backend/src/domain-events/handler-contract.spec.ts create mode 100644 ApiCore-Backend/src/domain-events/on-domain-event.decorator.ts create mode 100644 ApiCore-Backend/src/prisma/db-client.spec.ts create mode 100644 ApiCore-Backend/src/prisma/db-client.ts create mode 100644 ApiCore-Backend/src/prisma/db-session.spec.ts create mode 100644 ApiCore-Backend/src/prisma/db-session.ts create mode 100644 ApiCore-Backend/src/runtime/role.spec.ts create mode 100644 ApiCore-Backend/src/runtime/role.ts create mode 100644 ApiCore-Backend/src/runtime/root-module.spec.ts create mode 100644 ApiCore-Backend/src/runtime/root-module.ts create mode 100644 ApiCore-Backend/src/runtime/startup-checks.spec.ts create mode 100644 ApiCore-Backend/src/runtime/startup-checks.ts create mode 100644 ApiCore-Backend/src/treatments/dto/last-by-product-query.dto.ts create mode 100644 ApiCore-Backend/src/treatments/treatment-prefill.service.ts create mode 100644 ApiCore-Backend/src/varroa/varroa-threshold.service.ts create mode 100644 ApiCore-Backend/src/webhooks/webhook-secret.spec.ts create mode 100644 ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts create mode 100644 ApiCore-Backend/src/worker.spec.ts create mode 100644 ApiCore-Backend/src/worker.ts create mode 100644 ApiCore-Backend/src/worker/pg-listener.spec.ts create mode 100644 ApiCore-Backend/src/worker/pg-listener.ts create mode 100644 ApiCore-Backend/src/worker/polling-loop.spec.ts create mode 100644 ApiCore-Backend/src/worker/polling-loop.ts create mode 100644 ApiCore-Backend/src/worker/worker-health-server.service.ts create mode 100644 ApiCore-Backend/src/worker/worker-health.spec.ts create mode 100644 ApiCore-Backend/src/worker/worker-health.ts create mode 100644 ApiCore-Backend/src/worker/worker-runtime.module.ts create mode 100644 ApiCore-Backend/src/worker/worker-runtime.service.spec.ts create mode 100644 ApiCore-Backend/src/worker/worker-runtime.service.ts create mode 100644 ApiCore-Backend/src/worker/worker.module.ts create mode 100644 ApiCore-Backend/test/db-session.e2e-spec.ts create mode 100644 ApiCore-Backend/test/domain-event-dispatcher.e2e-spec.ts create mode 100644 ApiCore-Backend/test/platform-schema.e2e-spec.ts create mode 100644 ApiCore-Backend/test/throttler.e2e-spec.ts create mode 100644 ApiCore-Backend/test/utils/domain-events.ts create mode 100644 ApiCore-Backend/test/worker-runtime.e2e-spec.ts create mode 100644 ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.spec.ts create mode 100644 ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-inspections.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/more-sheet.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/more-sheet.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/nav-config.spec.ts create mode 100644 ApiCore-Frontend/src/app/(app)/nav-config.ts create mode 100644 ApiCore-Frontend/src/app/(app)/quick-actions.ts delete mode 100644 ApiCore-Frontend/src/app/(app)/quick-add-menu.spec.tsx delete mode 100644 ApiCore-Frontend/src/app/(app)/quick-add-menu.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/quick-add-sheet.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/quick-add-sheet.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/raw-materials/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.spec.tsx create mode 100644 ApiCore-Frontend/src/app/providers.spec.tsx create mode 100644 ApiCore-Frontend/src/components/colonies/status-light-badge.tsx create mode 100644 ApiCore-Frontend/src/components/document-upload-card.spec.tsx create mode 100644 ApiCore-Frontend/src/components/form/form-back-link.spec.tsx create mode 100644 ApiCore-Frontend/src/components/form/form-back-link.tsx create mode 100644 ApiCore-Frontend/src/components/form/number-stepper.spec.tsx create mode 100644 ApiCore-Frontend/src/components/form/number-stepper.tsx create mode 100644 ApiCore-Frontend/src/components/form/previous-value-hint.spec.tsx create mode 100644 ApiCore-Frontend/src/components/form/previous-value-hint.tsx create mode 100644 ApiCore-Frontend/src/components/form/rating-scale.spec.tsx create mode 100644 ApiCore-Frontend/src/components/nav/nav-sheet.tsx delete mode 100644 ApiCore-Frontend/src/components/offline-sync-badge.tsx create mode 100644 ApiCore-Frontend/src/components/offline/offline-band.spec.tsx create mode 100644 ApiCore-Frontend/src/components/offline/offline-band.tsx create mode 100644 ApiCore-Frontend/src/components/offline/pending-queue-sheet.spec.tsx create mode 100644 ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx create mode 100644 ApiCore-Frontend/src/components/photos/photo-capture-field.spec.tsx create mode 100644 ApiCore-Frontend/src/components/photos/photo-capture-field.tsx create mode 100644 ApiCore-Frontend/src/components/rounds/round-next-panel.tsx create mode 100644 ApiCore-Frontend/src/components/treatments/prefill-note.tsx create mode 100644 ApiCore-Frontend/src/components/varroa/varroa-period-field.tsx create mode 100644 ApiCore-Frontend/src/components/varroa/varroa-rate-light.tsx create mode 100644 ApiCore-Frontend/src/lib/api-keys/scopes.spec.ts create mode 100644 ApiCore-Frontend/src/lib/api-keys/scopes.ts create mode 100644 ApiCore-Frontend/src/lib/apiaries/field-api.ts create mode 100644 ApiCore-Frontend/src/lib/apiaries/field-types.ts create mode 100644 ApiCore-Frontend/src/lib/apiaries/use-colony-overview.ts create mode 100644 ApiCore-Frontend/src/lib/auth/api-client.spec.ts create mode 100644 ApiCore-Frontend/src/lib/auth/api.spec.ts create mode 100644 ApiCore-Frontend/src/lib/calendar-day.spec.ts create mode 100644 ApiCore-Frontend/src/lib/colonies/status-light.spec.ts create mode 100644 ApiCore-Frontend/src/lib/colonies/status-light.ts create mode 100644 ApiCore-Frontend/src/lib/config/runtime-config.spec.ts create mode 100644 ApiCore-Frontend/src/lib/documents/limits.ts create mode 100644 ApiCore-Frontend/src/lib/forms/back-target.spec.ts create mode 100644 ApiCore-Frontend/src/lib/forms/back-target.ts create mode 100644 ApiCore-Frontend/src/lib/forms/rating-contrast.spec.ts create mode 100644 ApiCore-Frontend/src/lib/forms/rating.spec.ts create mode 100644 ApiCore-Frontend/src/lib/forms/rating.ts create mode 100644 ApiCore-Frontend/src/lib/forms/use-form-draft.spec.ts create mode 100644 ApiCore-Frontend/src/lib/http/deadline-test-helper.ts create mode 100644 ApiCore-Frontend/src/lib/http/fetch-with-timeout.spec.ts create mode 100644 ApiCore-Frontend/src/lib/http/fetch-with-timeout.ts create mode 100644 ApiCore-Frontend/src/lib/inspections/takeover.spec.ts create mode 100644 ApiCore-Frontend/src/lib/inspections/takeover.ts create mode 100644 ApiCore-Frontend/src/lib/inspections/use-last-inspection.ts create mode 100644 ApiCore-Frontend/src/lib/offline/connectivity.spec.ts create mode 100644 ApiCore-Frontend/src/lib/offline/connectivity.ts create mode 100644 ApiCore-Frontend/src/lib/offline/db.spec.ts create mode 100644 ApiCore-Frontend/src/lib/offline/queue-texts.spec.ts create mode 100644 ApiCore-Frontend/src/lib/offline/queue-texts.ts create mode 100644 ApiCore-Frontend/src/lib/offline/use-capture-mutation.spec.tsx create mode 100644 ApiCore-Frontend/src/lib/offline/use-capture-mutation.ts create mode 100644 ApiCore-Frontend/src/lib/photos/reencode.spec.ts create mode 100644 ApiCore-Frontend/src/lib/photos/reencode.ts create mode 100644 ApiCore-Frontend/src/lib/photos/store.spec.ts create mode 100644 ApiCore-Frontend/src/lib/photos/store.ts create mode 100644 ApiCore-Frontend/src/lib/public-batch/origin-text.spec.ts create mode 100644 ApiCore-Frontend/src/lib/public-batch/origin-text.ts create mode 100644 ApiCore-Frontend/src/lib/rounds/round-state.spec.ts create mode 100644 ApiCore-Frontend/src/lib/rounds/round-state.ts create mode 100644 ApiCore-Frontend/src/lib/rounds/use-stand-round.ts create mode 100644 ApiCore-Frontend/src/lib/treatments/prefill.spec.ts create mode 100644 ApiCore-Frontend/src/lib/treatments/prefill.ts create mode 100644 ApiCore-Frontend/src/lib/treatments/use-prefilled-fields.ts create mode 100644 ApiCore-Frontend/src/lib/treatments/use-treatment-prefill.ts create mode 100644 ApiCore-Frontend/src/lib/varroa/format.ts create mode 100644 ApiCore-Frontend/src/lib/varroa/period.spec.ts create mode 100644 ApiCore-Frontend/src/lib/varroa/period.ts create mode 100644 ApiCore-Frontend/src/lib/varroa/rate.spec.ts create mode 100644 ApiCore-Frontend/src/lib/varroa/rate.ts create mode 100644 ApiCore-Frontend/src/lib/varroa/use-varroa-threshold.ts create mode 100644 ApiCore-Frontend/src/lib/weather/describe.spec.ts create mode 100644 ApiCore-Frontend/src/lib/weather/describe.ts rename deploy/k8s/{ => base}/backend/configmap.example.yaml (100%) rename deploy/k8s/{ => base}/backend/data-pvc.yaml (100%) rename deploy/k8s/{ => base}/backend/deployment.yaml (69%) rename deploy/k8s/{ => base}/backend/secret.example.yaml (50%) rename deploy/k8s/{ => base}/backend/service.yaml (100%) rename deploy/k8s/{ => base}/frontend/configmap.example.yaml (100%) rename deploy/k8s/{ => base}/frontend/deployment.yaml (92%) rename deploy/k8s/{ => base}/frontend/service.yaml (100%) create mode 100644 deploy/k8s/base/kustomization.yaml rename deploy/k8s/{ => base}/postgres/backup-cronjob.yaml (100%) create mode 100644 deploy/k8s/base/postgres/data-pvc.yaml rename deploy/k8s/{ => base}/postgres/secret.example.yaml (100%) create mode 100644 deploy/k8s/base/postgres/service.yaml create mode 100644 deploy/k8s/base/postgres/statefulset.yaml create mode 100644 deploy/k8s/components/api-hpa/hpa.yaml create mode 100644 deploy/k8s/components/api-hpa/kustomization.yaml create mode 100644 deploy/k8s/components/api-hpa/patch-api-replicas.yaml create mode 100644 deploy/k8s/components/backend-roles/cronjobs.yaml create mode 100644 deploy/k8s/components/backend-roles/kustomization.yaml create mode 100644 deploy/k8s/components/backend-roles/patch-api.yaml create mode 100644 deploy/k8s/components/backend-roles/worker-deployment.yaml create mode 100644 deploy/k8s/components/network-policies/acme-http01-solver.yaml create mode 100644 deploy/k8s/components/network-policies/allow-dns.yaml create mode 100644 deploy/k8s/components/network-policies/api.yaml create mode 100644 deploy/k8s/components/network-policies/default-deny.yaml create mode 100644 deploy/k8s/components/network-policies/frontend.yaml create mode 100644 deploy/k8s/components/network-policies/jobs.yaml create mode 100644 deploy/k8s/components/network-policies/kustomization.yaml create mode 100644 deploy/k8s/components/network-policies/patch-api-db-client.yaml create mode 100644 deploy/k8s/components/network-policies/postgres.yaml create mode 100644 deploy/k8s/components/network-policies/worker.yaml create mode 100644 deploy/k8s/components/pod-hardening/kustomization.yaml create mode 100644 deploy/k8s/components/pod-hardening/patch-frontend.yaml create mode 100644 deploy/k8s/overlays/encry/configmap-backend.yaml create mode 100644 deploy/k8s/overlays/encry/configmap-frontend.yaml rename deploy/k8s/{backend/ingress.yaml => overlays/encry/ingress-backend.yaml} (100%) rename deploy/k8s/{frontend/ingress.yaml => overlays/encry/ingress-frontend.yaml} (100%) create mode 100644 deploy/k8s/overlays/encry/kustomization.yaml delete mode 100644 deploy/k8s/postgres/service.yaml delete mode 100644 deploy/k8s/postgres/statefulset.yaml create mode 100644 deploy/k8s/tests/manifest-policy.test.mjs create mode 100644 deploy/k8s/tests/netpol-model.mjs create mode 100644 deploy/k8s/tests/render-stages.mjs create mode 100644 docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md create mode 100644 docs/superpowers/plans/2026-10-13-t3-erfassung-am-stand.md diff --git a/.gitea/workflows/backend-image.yml b/.gitea/workflows/backend-image.yml index 8ea8727..40b9194 100644 --- a/.gitea/workflows/backend-image.yml +++ b/.gitea/workflows/backend-image.yml @@ -54,7 +54,6 @@ jobs: with: images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }} tags: | - type=raw,value=latest,enable={{is_default_branch}} type=sha,format=short type=semver,pattern={{version}} diff --git a/.gitea/workflows/frontend-image.yml b/.gitea/workflows/frontend-image.yml index 28c7d85..474d350 100644 --- a/.gitea/workflows/frontend-image.yml +++ b/.gitea/workflows/frontend-image.yml @@ -54,7 +54,6 @@ jobs: with: images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }} tags: | - type=raw,value=latest,enable={{is_default_branch}} type=sha,format=short type=semver,pattern={{version}} diff --git a/.gitea/workflows/manifests.yml b/.gitea/workflows/manifests.yml new file mode 100644 index 0000000..35bf789 --- /dev/null +++ b/.gitea/workflows/manifests.yml @@ -0,0 +1,74 @@ +name: Manifests + +# Prueft deploy/k8s ohne Cluster-Zugriff: Overlay und jeden Rollout-Schritt +# rendern, gegen die Kubernetes-Schemas validieren und die Regeln testen +# (NetworkPolicies, Pod-Haertung, Ressourcen, CronJobs, Digests, Live-Selektoren). +# +# Bewusst kein pull_request-Trigger: der Runner laeuft im Cluster, und ein +# Fork-PR braechte eigenen Code (auch eine geaenderte Fassung dieser Datei, +# also ohne jeden if-Waechter) darauf zur Ausfuehrung. push setzt +# Schreibrechte am Repo voraus; jeder Branch-Push wird geprueft, damit auch +# Aenderungen vor dem Merge nach main durch die CI gehen. +on: + push: + paths: ['deploy/k8s/**', '.gitea/workflows/manifests.yml'] + workflow_dispatch: + +jobs: + validate: + runs-on: docker-builder + env: + # Werkzeuge mit festen Versionen; jede Datei wird gegen ihre SHA-256 von + # der offiziellen Release-Seite geprueft, bevor sie laeuft: + # kubectl: https://dl.k8s.io/release//bin/linux/amd64/kubectl.sha256 + # kubeconform: CHECKSUMS im GitHub-Release yannh/kubeconform + # yq: checksums (Spalte SHA-256) im GitHub-Release mikefarah/yq + # Ein Update aendert Version und Pruefsumme zusammen. + KUBECTL_VERSION: v1.36.5 + KUBECTL_SHA256: 33bc88a24c3b09cf55bfd59e6ca977881b61fc3f25ab65bc80c65b430248648e + KUBECONFORM_VERSION: v0.8.0 + KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883 + YQ_VERSION: v4.53.6 + YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Node einrichten + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: kubectl, kubeconform und yq laden + run: | + mkdir -p "$RUNNER_TEMP/bin" + cd "$RUNNER_TEMP" + curl -fsSLo kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + echo "${KUBECTL_SHA256} kubectl" | sha256sum -c - + curl -fsSLo kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" + echo "${KUBECONFORM_SHA256} kubeconform.tar.gz" | sha256sum -c - + tar -xzf kubeconform.tar.gz kubeconform + curl -fsSLo yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" + echo "${YQ_SHA256} yq" | sha256sum -c - + install -m 0755 kubectl kubeconform yq "$RUNNER_TEMP/bin/" + + - name: Overlay rendern + run: | + "$RUNNER_TEMP/bin/kubectl" kustomize deploy/k8s/overlays/encry > "$RUNNER_TEMP/rendered.yaml" + "$RUNNER_TEMP/bin/yq" ea -o=json '[.]' "$RUNNER_TEMP/rendered.yaml" > "$RUNNER_TEMP/rendered.json" + + - name: Rollout-Schritte rendern + # Runbook Schritte 1, 2, 3 und 5: Overlay mit den bis dahin + # freigegebenen Komponenten (siehe render-stages.mjs). + run: | + export KUBECTL="$RUNNER_TEMP/bin/kubectl" YQ="$RUNNER_TEMP/bin/yq" + node deploy/k8s/tests/render-stages.mjs "$RUNNER_TEMP/stages" + + - name: Gegen Kubernetes-Schemas pruefen + run: | + "$RUNNER_TEMP/bin/kubeconform" -strict -summary "$RUNNER_TEMP/rendered.yaml" "$RUNNER_TEMP"/stages/*.yaml + + - name: Regeln testen + run: | + export RENDERED_MANIFESTS="$RUNNER_TEMP/rendered.json" RENDERED_STAGES="$RUNNER_TEMP/stages/stages.json" + node --test deploy/k8s/tests/manifest-policy.test.mjs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d1af101..13bc76b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,32 +102,109 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`). 12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. -14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. +14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`): Ein Schlüssel mit beiden Rechten wird beim Anlegen mit 400 abgelehnt; bereits ausgestellte kombinierte Schlüssel bleiben gültig, die Oberfläche markiert sie und empfiehlt zwei getrennte Schlüssel (neue anlegen, in Wabenhain eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar. 15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). 16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. 17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung. 18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"|"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen. -19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel `BACKUP_ENCRYPTION_KEY` (sonst per HKDF aus `JWT_ACCESS_SECRET`). Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). Webhook-Secrets liegen verschlüsselt in der DB (Schlüssel aus `JWT_ACCESS_SECRET`) – für eine Wiederherstellung in einem anderen Deployment mit anderem `JWT_ACCESS_SECRET` müssen Webhook-Secrets und MFA neu eingerichtet werden. +19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden. 20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. 21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist. +22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:..` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore--v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. +23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden. +**Rotation über `*_PREVIOUS`:** alten Wert nach `_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `` setzen, Job `rekey` laufen lassen, dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen. +**Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar). + +## Concerns + +1. **Rollout order (blocking for production).** The live `apicore-backend-secret` must contain `MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY` and `BACKUP_ENCRYPTION_KEY` *before* this image runs with `NODE_ENV=production`. Without them, api, worker and job all refuse to start (by design). + - The three keys must be distinct and must not equal the JWT secrets. + - The pods get them via `envFrom: secretRef`, so no manifest change is needed. + - Per the owner's rule, the user creates the secrets. +2. **JWT rotation after Task 21 will strand old backup files.** Backups sealed before `BACKUP_ENCRYPTION_KEY` was set (JWT-derived keyId) become unrestorable once `JWT_ACCESS_SECRET` rotates. + - Remedy: take a fresh export after setting the key, before rotating. This is in the Annahme 22 text. + - Task 21 or the runbook should state this ordering explicitly. +3. **Dev-only edge.** Values sealed with the dev fallback (no env key set) become unreadable if a developer later sets an explicit key: the fallback cannot be named as `*_PREVIOUS`. This does not affect production, which never uses the fallback. +4. **Not checked in production:** `*_PREVIOUS` values are not checked for equality with the JWT or other keys; only their length is enforced. This matches the brief, which limits that rule to the three main variables. +5. **Not run:** whole-project `npx eslint "{src,test}/**/*.ts"` (I linted only my files). Typecheck was project-wide and clean. ## 7. Kubernetes -`deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`). +`deploy/k8s/` ist Kustomize und die Quelle der Wahrheit für den Cluster (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md` §6): -`deploy/k8s/postgres/` enthält das PostgreSQL-`StatefulSet` (ein Replica, `volumeClaimTemplates` für persistente Daten, `postgres:18-alpine`), den zugehörigen headless `Service` (`apicore-postgres`, DNS-Name fürs Backend) sowie `secret.example.yaml` für `POSTGRES_USER`/`POSTGRES_PASSWORD`/`POSTGRES_DB`. `deploy/k8s/backend/secret.example.yaml`s `DATABASE_URL` zeigt auf diesen Service. +- `base/`: clusterneutral, ohne Namespace, Hosts und Digests. `backend/` (Deployment `apicore-backend`, Service, PVC `apicore-data`), `frontend/` (Deployment, Service), `postgres/` (StatefulSet, headless Service `apicore-postgres`, CronJob `apicore-db-backup` mit `pg_dump`). Die Vorlagen `configmap.example.yaml` und `secret.example.yaml` stehen nicht unter `resources`; Secrets bleiben außerhalb des Repos. +- Selektoren und Service-Ports entsprechen dem Cluster (ein Test hält sie fest): Backend und Frontend `app: apicore-backend|apicore-frontend`, Postgres `app.kubernetes.io/name: apicore-postgres` (Pod zusätzlich `encry.de/tier: database`, Service-Port `postgres`). Bis 2026-10-13 wich das Repo bei Postgres ab (`app: apicore-postgres`, ohne Härtung, `volumeClaimTemplates`). Seit 2026-10-13 ist `base/postgres` der Live-Stand: StatefulSet mit Härtung (uid/gid 70, nur lesbares Root-Dateisystem, begrenzte `emptyDir`s für Socket, `/tmp` und `/dev/shm`, kein Service-Account-Token, `enableServiceLinks: false`), Probes, Ressourcen und Affinität wie live, Service und das statisch gebundene PVC `apicore-postgres-data` (PV `pv-apicore-postgres`). Das PV und sein SMB-Secret gehören zur Cluster-Infrastruktur und liegen nicht im Repo. Ein Test verhindert, dass das Repo die Härtung abschwächt; Selektor und Volumes sind unveränderlich, darum Postgres-Änderungen immer zuerst per `kubectl diff` prüfen. +- `components/`, je Rollout-Schritt einschaltbar. Das Auskommentieren im Overlay ist nur lokal für `kubectl diff`/`apply` gedacht und wird nicht committet. + - `network-policies`: Default-Deny und die Freigaben aus der Tabelle unten. Setzt selbst das Label `db-client: "true"` an der api, damit Schritt 2 sie nicht von Postgres abschneidet. + - `pod-hardening`: Frontend mit `readOnlyRootFilesystem`, `emptyDir` auf `/tmp` und `/app/.next/cache`, ohne Service-Account-Token, Ressourcen laut Spec §7. + - `backend-roles`: `APICORE_ROLE=api` samt initContainern `migrate` und `seed` im Deployment `apicore-backend`, Deployment `apicore-worker`, CronJobs `apicore-job-`. Das neue Backend-Image (CMD nur `node dist/main.js`) geht nur zusammen mit dieser Komponente live. Ohne `APICORE_ROLE` bricht es den Start in Produktion ab, und ohne die initContainer liefe keine Migration. + - `api-hpa`: HPA `apicore-backend` (`autoscaling/v2`, 1–2 Pods bei 70 % CPU); entfernt `replicas` aus dem Deployment. +- `overlays/encry/`: Namespace `apicore`, ConfigMaps, Ingresses (Traefik, Middleware `apicore-cloudflare-only@kubernetescrd`, cert-manager `letsencrypt-prod` mit HTTP-01), Image-Digests. Hosts, ConfigMap-Werte und Digests sind bis zum Rollout Platzhalter (`example.com`, `sha256:000…`). +- `tests/`: `manifest-policy.test.mjs` prüft das gerenderte Overlay (NetworkPolicies nach Kubernetes-Semantik, Pod-Härtung, Ressourcen, CronJobs, Migration, Digests, Live-Selektoren) und jeden Rollout-Schritt (DNS, Ingress, api → Postgres und alle DB-Clients bleiben erreichbar). `render-stages.mjs` rendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (`.gitea/workflows/manifests.yml`, nur `push` und `workflow_dispatch`, kein `pull_request`, weil der Runner im Cluster läuft) rendert mit `kubectl kustomize`, validiert mit `kubeconform -strict` und führt den Test aus; kubectl, kubeconform und yq mit festen Versionen und SHA-256. + +**Rollen-Deployments** (ein Image, Rolle per `APICORE_ROLE`): + +| Objekt | Rolle | Start | Ports | Pods | CPU Request/Limit | RAM Request/Limit | +|---|---|---|---|---|---|---| +| Deployment `apicore-backend` | `api` | Image-CMD `node dist/main.js` | 3001 (Service, Ingress) | 1, mit HPA 1–2 | 50m/500m | 192Mi/512Mi | +| Deployment `apicore-worker` | `worker` | `node dist/worker.js` | 3002 nur `/health` für die Probes; kein Service, kein Ingress | 1 | 20m/200m | 128Mi/256Mi | +| CronJobs `apicore-job-` | `job` | `node dist/cli.js ` | keine | je Lauf ein Pod | 50m/500m | 192Mi/512Mi | +| Deployment `apicore-frontend` | – | `node server.js` | 3000 | 1 | 30m/300m | 128Mi/256Mi | + +initContainer haben die Ressourcen der CronJob-Pods. Alle App-Pods laufen ohne Service-Account-Token, mit nur lesbarem Root-Dateisystem und `emptyDir` auf `/tmp` (`HOME=/tmp`), als uid 1000, mit `seccompProfile: RuntimeDefault` und ohne Capabilities. api und worker rollen mit `maxSurge: 1`, `maxUnavailable: 0` aus. + +**CronJobs:** `timeZone: Europe/Berlin`, `concurrencyPolicy: Forbid`, `startingDeadlineSeconds: 600`, je 3 erfolgreiche und fehlgeschlagene Jobs, `ttlSecondsAfterFinished: 86400`, `restartPolicy: Never`. + +| CronJob | Zeitplan | backoffLimit | activeDeadlineSeconds | +|---|---|---|---| +| `apicore-job-cleanup` | `0 4 * * *` | 1 | 1800 | +| `apicore-job-daily-evaluation` | `30 4 * * *` | 1 | 1800 | +| `apicore-job-task-templates` | `0 5 * * *` | 1 | 1800 | +| `apicore-job-app-backup` | `0 * * * *`, prüft selbst, ob fällig; `apicore-data` auf `/data` | 0 | 3600 | +| `apicore-job-rekey`, `apicore-job-rekey-dry-run` | `suspend: true`, nur von Hand (`kubectl create job --from=cronjob/…`) | 0 | 1800 | + +**NetworkPolicies:** Default-Deny für Ingress und Egress im Namespace. Kubelet-Probes erlaubt Kubernetes immer. + +| Pod (Selektor) | Ingress | Egress | +|---|---|---| +| alle | – | DNS: UDP und TCP 53 zu `k8s-app: kube-dns` in `kube-system` | +| frontend (`app: apicore-frontend`) | Traefik (`app.kubernetes.io/name: rke2-traefik` in `kube-system`) auf 3000 | `app: apicore-backend` auf 3001 | +| api (`app: apicore-backend`) | Traefik und frontend auf 3001 | Postgres 5432; TCP 443 zu öffentlichen Adressen | +| worker (`app: apicore-worker`) | keiner | Postgres 5432; TCP 443 zu öffentlichen Adressen | +| Jobs (`app in (apicore-job, apicore-db-backup)`) | keiner | Postgres 5432 | +| postgres (`app.kubernetes.io/name: apicore-postgres`) | nur Pods mit `db-client: "true"` auf 5432 | keiner außer DNS | +| cert-manager-Solver (`acme.cert-manager.io/http01-solver: "true"`) | Traefik auf 8089 (HTTP-01-Erneuerung) | keiner außer DNS | + +„Öffentlich“ heißt `0.0.0.0/0` ohne `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10`, `169.254.0.0/16`, `127.0.0.0/8` und `::/0` ohne `fc00::/7`, `fe80::/10`, `::1/128`. Canal kennt keine FQDN-Regeln; den SSRF-Schutz für Webhook-Ziele behält die App (`webhook-url-guard.ts`). `db-client: "true"` tragen nur api, worker, die App-Jobs und `apicore-db-backup`. + +**Übergang:** Im Namespace liegen außerdem drei Policies aus dem Repo `rke2-cluster` (Label `encry.de/managed-by: cluster-hardening`): `default-deny-ingress`, `allow-from-traefik` und `allow-same-namespace`. NetworkPolicies addieren sich; bis diese drei in Runbook Schritt 2 dort entfernt werden, gelten sie neben den neuen. +- `allow-same-namespace` öffnet die Ingress-Seite jedes Pods für alle Pods im Namespace. Postgres nimmt dann auf der Ingress-Seite Verbindungen von jedem Pod an, nicht nur von `db-client`. Weiter sperrt nur die Egress-Seite: Jeder Pod im Namespace unterliegt dem neuen Default-Deny-Egress, das Frontend kommt also trotzdem nicht an Postgres. Die Absicherung hat dann eine statt zwei Schichten. +- `allow-from-traefik` lässt Traefik jeden Pod auf jedem Port erreichen, auch Postgres 5432 und den Worker auf 3002. Die Regel „Traefik nur auf 3000 und 3001“ gilt erst danach; ausnutzbar ist das nur über eine Ingress-Route. +- `default-deny-ingress` ist neben dem neuen `default-deny` überflüssig, schwächt aber nichts. + +**Images:** Das Base-Image `node:22-alpine` ist in beiden Dockerfiles per Digest gepinnt; ein Update ist ein bewusster Commit mit neuem Digest. Die CI taggt nur noch `sha-` und Versionen, kein `latest`. Das Overlay setzt die Digests der gebauten Images. Bis Schritt 3 bleibt beim Backend der Digest des laufenden Images. + +**Rollout:** nur nach dem Runbook im Plan `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md`, jeder Schritt mit Freigabe des Inhabers: 0 Digests, 1 Overlay auf den Ist-Stand (Diff leer), 2 NetworkPolicies, 3 Rollen und Pod-Härtung, 4 Schlüssel trennen, 5 HPA. ## 8. Backup & Restore -**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. Der Backend-Container führt `migrate deploy` und danach `npm run seed` selbst beim Start aus, bevor der Server Verbindungen annimmt (Dockerfile-`CMD`: `npm run migrate:deploy && npm run seed && node dist/main.js`) – kein separater Kubernetes-`Job`. Bei `replicas: 2` starten mehrere Pods potenziell gleichzeitig; das ist unproblematisch, da Prisma für `migrate deploy` einen datenbankseitigen Advisory-Lock nutzt und `prisma/seed.ts` per Unique-Constraint auf `User.email` idempotent ist (verlierender Pod bekommt einen `P2002`-Fehler, den `seed.ts` abfängt und als No-op behandelt statt den Container abstürzen zu lassen) – eine Instanz wendet die Migrationen/den Seed an, die anderen finden danach nichts mehr zu tun. `deploy/k8s/backend/deployment.yaml` hat dafür eine `startupProbe` mit großzügigem `failureThreshold`, damit liveness/readiness den Container nicht neu starten, während die (beim allerersten Deploy ggf. länger laufende) Migration noch durchläuft. +**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. + +Die Migration läuft nicht mehr im Container-`CMD` (das ist nur noch `node dist/main.js`). Sie läuft als initContainer `migrate` (`node node_modules/prisma/build/index.js migrate deploy`, `HOME=/tmp`, `CHECKPOINT_DISABLE=1`) in den Pods von `api` und `worker` (`deploy/k8s/components/backend-roles`). + +Starten mehrere dieser initContainer gleichzeitig, serialisiert sie der datenbankseitige Advisory-Lock von Prisma. Ein Lauf, der den Lock nicht binnen 10 s bekommt, bricht ab. Kubelet startet den initContainer dann neu, und der nächste Lauf findet nichts mehr zu tun. Das ist harmlos. + +Der Seed (`node dist/seed.js`) läuft als zweiter initContainer `seed` nur im `api`-Pod. Er ist per Unique-Constraint auf `User.email` idempotent: Ein verlierender Pod bekommt `P2002`, `seed.ts` behandelt das als No-op. + +Scheitert die Migration, scheitert der initContainer und der neue Pod startet nicht. Wegen `maxSurge: 1` und `maxUnavailable: 0` läuft der alte Pod weiter. Die Hauptprozesse starten ohne Prisma-CLI. Die `startupProbe` der api (bis 5 Minuten) bleibt für das alte Image, das die Migration noch im `CMD` ausführt. **Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `admin@apicore.local`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren. **Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie: -- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14) und IdempotencyRecord (Annahme 11); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`. +- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`. - **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer. - **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe. @@ -144,13 +221,37 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un | Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt | | Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) | | Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten | -| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben | +| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`, `shop-sync:write`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Lesen und Buchen sind getrennte Schlüssel: `ApiKeysService.create` lehnt einen Schlüssel mit beiden Shop-Rechten mit 400 ab; bereits ausgestellte kombinierte Schlüssel bleiben gültig und werden in der Oberfläche mit Hinweis markiert | | Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body | -| Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies | -| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` | +| Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies | +| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf | | Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings | **Bekannte Restpunkte (bewusst nicht umgesetzt):** -- Rate-Limits zählen je Pod im Speicher; bei 2 Replikas verdoppelt sich das Limit. Brute-Force-Schutz hängt nicht daran, sondern an der Kontosperre in der DB. Ein gemeinsamer Speicher (Redis) wäre der nächste Schritt, falls Lastspitzen ein Thema werden. - Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren. + +## 10. Ereignis-Rückgrat und Laufrollen + +### Ereignisse + +**Outbox.** Fachliche Folgen werden nicht direkt nach dem Commit ausgeführt. Sie werden als `DomainEvent` geschrieben, in derselben Prisma-Transaktion wie die fachliche Änderung und der Audit-Eintrag (`DomainEventsService.record/recordAll`, nur mit Transaktions-Client). Kein Commit ohne Ereignis, kein Ereignis ohne Commit. Nach dem Commit weckt `pg_notify('domain_event', '')` den Worker. Ein verlorenes Wecken verzögert nur bis zum nächsten Takt (5 s). + +**Claim.** `DomainEventDispatcher.runOnce()` übernimmt höchstens 50 fällige Ereignisse (`status = 'PENDING'`, `availableAt <= now()`), älteste zuerst (`occurredAt`). Das geschieht per `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING *` → `PROCESSING`, `claimedAt = now()`. Mehrere Worker übernehmen so nebeneinander disjunkte Mengen, ohne aufeinander zu warten. `drain()` wiederholt `runOnce()`, bis eine Runde nichts übernimmt, höchstens 20 Runden je Takt. Folgeereignisse der Handler kommen so in der nächsten Runde dran. Claim, Abschluss und Freigabe liegen für alle Outboxen in `src/common/outbox/outbox-claims.ts`, die Wiederholregel in `src/common/retry-policy.ts`. Alle Zeitvergleiche laufen in SQL gegen `now()` (DB-Uhr). + +**Delivery-Sperre.** Je Ereignis laufen die Handler nacheinander in fester Reihenfolge (Handler-ID nach Codepunkt), jeder in einer eigenen Transaktion mit 10 s Timeout: +1. `DomainEventDelivery` (eventId, handler) anlegen, falls es sie noch nicht gibt (`skipDuplicates`). +2. Die Zeile `FOR UPDATE` sperren. Ist sie `DONE`, wird der Handler übersprungen. +3. Den Handler mit dem Transaktions-Client aufrufen; die Korrelations-ID steht im `domainEventContext`. +4. Die Delivery auf `DONE` setzen, `attempts + 1`. + +Die Wirkung des Handlers und `DONE` werden gemeinsam committet. Die Exklusivität je (Ereignis, Handler) kommt allein von dieser `FOR UPDATE`-Sperre der Delivery-Zeile, nicht vom Claim: Ein Ereignis kann nach einer Stale-Freigabe von einem zweiten Worker übernommen werden, während der erste noch läuft. Der zweite Worker wartet dann auf die Sperre und findet danach `DONE`. So entsteht jede Wirkung höchstens einmal je (Ereignis, Handler), auch bei einem Retry oder einem wieder freigegebenen Ereignis. Scheitert ein Handler, rollt seine Transaktion zurück. Außerhalb davon zählt die Delivery `attempts + 1` und speichert `lastError`; die übrigen Handler laufen trotzdem. + +**Backoff und `FAILED`.** Sind alle Deliveries `DONE` (oder hat der Typ keinen Handler), wird das Ereignis `DONE` (`processedAt = now()`). Sonst gilt `attempts + 1`, `lastError = ": "` (erster gescheiterter Handler) und `availableAt = now() +` Wartezeit: 30 s, 2 min, 10 min, 1 h, ab dem 5. Fehlversuch 6 h. Beim 8. Fehlversuch wird das Ereignis `FAILED`, ebenso seine offenen Deliveries. Erledigte Handler laufen beim Wiederholen nicht erneut. Der Abschluss ist ein Compare-and-Set auf `id` + `claimedAt`: Wer seinen Claim verloren hat, ändert nichts. + +**Liegengebliebene Claims.** `releaseStaleClaims()` läuft in jedem Worker-Takt vor `drain()`. Es setzt Ereignisse, deren `claimedAt` älter als 5 min ist, sofort wieder auf `PENDING`, mit `attempts + 1` und `lastError = 'claim expired (worker restarted?)'`; ab 8 Versuchen werden sie `FAILED`, und ihre offenen Deliveries ebenfalls. So kann ein Ereignis, das seinen Worker abstürzen lässt, nicht endlos kreisen. + +**Handler-Vertrag.** `@OnDomainEvent(types, '.')` steht nur auf Singleton-Providern. Ein Handler schreibt ausschließlich über den übergebenen `tx`; lesen darf er auch über `PrismaService`. Er macht keine HTTP- oder Graph-Aufrufe: Netzwerkarbeit kommt in eine eigene Outbox (`CalendarSyncOutboxEntry`, `WebhookDelivery`), die eine Worker-Schleife abarbeitet. Der Wächter `src/domain-events/handler-contract.spec.ts` prüft, dass `*.handlers.ts` keine Netzwerk-Clients importieren. Fehlertexte gehen nur über `loggableError()` in `lastError` und Logs, nie Payload oder Secrets. + +**Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören. +``` diff --git a/ApiCore-Backend/.dockerignore b/ApiCore-Backend/.dockerignore index b8d7906..f24a6f3 100644 --- a/ApiCore-Backend/.dockerignore +++ b/ApiCore-Backend/.dockerignore @@ -1,5 +1,8 @@ node_modules dist +# A stale build info makes `nest build` emit only changed files into the +# freshly deleted dist (tsconfig.json: incremental). +*.tsbuildinfo generated coverage .devdb diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index e709316..5e60a50 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -2,8 +2,10 @@ DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public" -# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert -# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets. +# Mindestens 32 Zeichen (server-seitig erzwungen, siehe +# src/runtime/startup-checks.ts) - signiert jedes Access-/MFA-/SSO-Token. +# TOTP- und Webhook-Secrets aus der Zeit vor den eigenen Schluesseln (unten) +# sind noch damit verschluesselt: erst nach dem rekey-Job rotieren. # Erzeugen z.B. mit: openssl rand -hex 32 JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long" JWT_REFRESH_SECRET="change-me-refresh-secret" @@ -22,7 +24,7 @@ FRONTEND_ORIGIN="http://localhost:3000" # trusted (client IP for rate limits and API-key IP allowlists). "1" for the # single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works. # Behind Cloudflare + Traefik use "loopback, , " -# (see deploy/k8s/backend/configmap.example.yaml); "1" would yield the +# (see deploy/k8s/base/backend/configmap.example.yaml); "1" would yield the # Cloudflare edge IP that many clients share. TRUST_PROXY=1 @@ -54,11 +56,25 @@ BACKUP_CRON_SCHEDULE="0 3 * * *" # Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach # jedem Lauf geloescht (Standard: 14). BACKUP_RETENTION_COUNT=14 -# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a -# key derived from JWT_ACCESS_SECRET and restore only into this deployment. -# Set the same value on another deployment to restore there. At least 32 -# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage. + +# Encryption keys, one per purpose (AES-256-GCM, key derived per purpose with +# HKDF): TOTP secrets, webhook signing secrets, backup files. Required in +# production: at least 32 characters each, all three different from each +# other and from JWT_ACCESS_SECRET/JWT_REFRESH_SECRET, e.g. +# "openssl rand -hex 32". Outside production a missing key falls back to one +# derived from JWT_ACCESS_SECRET. +# Rotation: move the old value to _PREVIOUS (still opens), set a new +# , run the rekey job, then drop _PREVIOUS. Backup files are not +# resealed: keep BACKUP_ENCRYPTION_KEY_PREVIOUS while older files must restore. +# Set the same BACKUP_ENCRYPTION_KEY on another deployment to restore its +# backups there (MFA_/WEBHOOK_ENCRYPTION_KEY too, to keep MFA and webhook +# secrets). Keep the backup key outside the backup storage. +# MFA_ENCRYPTION_KEY="" +# WEBHOOK_ENCRYPTION_KEY="" # BACKUP_ENCRYPTION_KEY="" +# MFA_ENCRYPTION_KEY_PREVIOUS="" +# WEBHOOK_ENCRYPTION_KEY_PREVIOUS="" +# BACKUP_ENCRYPTION_KEY_PREVIOUS="" # Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert # (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). @@ -84,3 +100,11 @@ OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback" # gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde. # Nur fuer lokale Entwicklung/Debugging explizit aktivieren. ENABLE_SWAGGER=false + +# Laufrolle des Prozesses: api (nur HTTP), worker, job (CLI) oder all (API + +# Worker in einem Prozess, nur fuer lokale Entwicklung und Tests). Ohne Wert +# gilt der Standard des Einstiegspunkts (main: all, worker: worker, cli: job). +# In Produktion Pflicht und "all" ist dort verboten. +# APICORE_ROLE=api +# Port des Health-Endpunkts des Worker-Prozesses. +# WORKER_HEALTH_PORT=3002 diff --git a/ApiCore-Backend/Dockerfile b/ApiCore-Backend/Dockerfile index 2227ca9..72fe081 100644 --- a/ApiCore-Backend/Dockerfile +++ b/ApiCore-Backend/Dockerfile @@ -1,6 +1,10 @@ # syntax=docker/dockerfile:1 -FROM node:22-alpine AS builder +# Base-Image per Digest gepinnt (node:22-alpine, Multi-Arch-Index vom +# 2026-09-23): ein Rebuild zieht nie ungeprueft ein neues Image. Ein Update +# ist ein bewusster Commit mit neuem Digest, ermittelt per +# docker buildx imagetools inspect node:22-alpine +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci @@ -14,16 +18,16 @@ RUN npx prisma generate RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci RUN npm run build -FROM node:22-alpine AS runtime +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime ENV NODE_ENV=production WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist COPY --from=builder /app/generated ./generated -# schema.prisma, migrations/ und prisma.config.ts: noetig fuer -# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt -# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt - +# schema.prisma, migrations/ und prisma.config.ts: noetig fuer den +# initContainer `migrate` (siehe Kommentar ueber CMD unten). +# seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt - # `npm run build` kompiliert es vorab zu dist/seed.js (siehe # tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief # `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler @@ -31,9 +35,19 @@ COPY --from=builder /app/generated ./generated # fuehrte zu sporadischen OOM-Crashes beim Start. COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts -EXPOSE 3001 +# 3001: HTTP der api-Rolle. 3002: /health der worker-Rolle. +EXPOSE 3001 3002 USER node -# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock, -# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) - -# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas. -CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"] +# Migration und Seed laufen nicht mehr hier, sondern als initContainer im +# Pod (deploy/k8s/components/backend-roles): `migrate` in api und worker +# (`node node_modules/prisma/build/index.js migrate deploy`, idempotent mit +# DB-seitigem Advisory-Lock), `seed` nur in der api (`node dist/seed.js`, +# idempotent per Unique-Constraint auf User.email). Scheitert die Migration, +# startet der neue Pod nicht und der alte laeuft weiter. Der Hauptprozess +# startet ohne Prisma-CLI; worker und Jobs ueberschreiben das Kommando +# (dist/worker.js, dist/cli.js ), die Rolle waehlt APICORE_ROLE. +# Dieses Image darf darum nur zusammen mit components/backend-roles live +# gehen (Runbook Schritt 3). Fehlt APICORE_ROLE, bricht der Start in +# Produktion absichtlich ab. APICORE_ROLE nicht von Hand in ein Deployment +# ohne die initContainer setzen: dann liefe keine Migration. +CMD ["node", "dist/main.js"] diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index 620b322..85e5c23 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -30,6 +30,7 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pdfkit": "^0.19.1", + "pg": "^8.23.1", "prisma": "^7.9.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", @@ -49,6 +50,7 @@ "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/pdfkit": "^0.17.6", + "@types/pg": "^8.23.1", "@types/qrcode": "^1.5.6", "@types/supertest": "^7.0.0", "eslint": "^9.18.0", @@ -10087,14 +10089,14 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", - "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.14.0", + "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.0", + "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -10102,7 +10104,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.4.0" + "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -10114,16 +10116,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", "license": "MIT" }, "node_modules/pg-int8": { @@ -10145,9 +10147,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", "license": "MIT" }, "node_modules/pg-types": { diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index 80b5655..39ea567 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -12,6 +12,8 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", + "worker": "node dist/worker.js", + "job": "node dist/cli.js", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "typecheck": "tsc --noEmit", "test": "jest", @@ -44,6 +46,7 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pdfkit": "^0.19.1", + "pg": "^8.23.1", "prisma": "^7.9.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", @@ -63,6 +66,7 @@ "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/pdfkit": "^0.17.6", + "@types/pg": "^8.23.1", "@types/qrcode": "^1.5.6", "@types/supertest": "^7.0.0", "eslint": "^9.18.0", diff --git a/ApiCore-Backend/prisma/migrations/20261013100000_platform_backbone/migration.sql b/ApiCore-Backend/prisma/migrations/20261013100000_platform_backbone/migration.sql new file mode 100644 index 0000000..a4489b9 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261013100000_platform_backbone/migration.sql @@ -0,0 +1,139 @@ +-- Platform backbone: domain-event outbox (DomainEvent + per-handler +-- DomainEventDelivery), webhook delivery queue, cron job run history and +-- throttler counters, plus two small columns (Apiary.publicLocationLabel, +-- SiteSettings.originCountry). +-- ThrottlerHit is UNLOGGED on purpose: no WAL, emptied after a crash - only +-- rate-limit counters get lost. DomainEvent.dedupeKey is a nullable UNIQUE +-- (not a partial index) so the schema and the database cannot drift apart. +-- CreateEnum +CREATE TYPE "DomainEventStatus" AS ENUM ('PENDING', 'PROCESSING', 'DONE', 'FAILED'); + +-- CreateEnum +CREATE TYPE "DomainEventDeliveryStatus" AS ENUM ('PENDING', 'DONE', 'FAILED'); + +-- CreateEnum +CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "JobRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED'); + +-- AlterTable +ALTER TABLE "Apiary" ADD COLUMN "publicLocationLabel" TEXT; + +-- AlterTable +ALTER TABLE "SiteSettings" ADD COLUMN "originCountry" TEXT NOT NULL DEFAULT 'Deutschland'; + +-- CreateTable +CREATE TABLE "DomainEvent" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "aggregateType" TEXT NOT NULL, + "aggregateId" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "actorUserId" TEXT, + "correlationId" TEXT NOT NULL, + "dedupeKey" TEXT, + "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "DomainEventStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "claimedAt" TIMESTAMP(3), + "processedAt" TIMESTAMP(3), + "lastError" TEXT, + + CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DomainEventDelivery" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "handler" TEXT NOT NULL, + "status" "DomainEventDeliveryStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DomainEventDelivery_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WebhookDelivery" ( + "id" TEXT NOT NULL, + "subscriptionId" TEXT NOT NULL, + "event" TEXT NOT NULL, + "body" TEXT NOT NULL, + "status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "claimedAt" TIMESTAMP(3), + "lastStatusCode" INTEGER, + "lastError" TEXT, + "deliveredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobRun" ( + "id" TEXT NOT NULL, + "job" TEXT NOT NULL, + "status" "JobRunStatus" NOT NULL DEFAULT 'RUNNING', + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finishedAt" TIMESTAMP(3), + "result" JSONB, + "error" TEXT, + + CONSTRAINT "JobRun_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE UNLOGGED TABLE "ThrottlerHit" ( + "key" TEXT NOT NULL, + "throttlerName" TEXT NOT NULL, + "hits" INTEGER NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "blockedUntil" TIMESTAMP(3), + + CONSTRAINT "ThrottlerHit_pkey" PRIMARY KEY ("key","throttlerName") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DomainEvent_dedupeKey_key" ON "DomainEvent"("dedupeKey"); + +-- CreateIndex +CREATE INDEX "DomainEvent_status_availableAt_idx" ON "DomainEvent"("status", "availableAt"); + +-- CreateIndex +CREATE INDEX "DomainEvent_correlationId_idx" ON "DomainEvent"("correlationId"); + +-- CreateIndex +CREATE INDEX "DomainEvent_aggregateType_aggregateId_idx" ON "DomainEvent"("aggregateType", "aggregateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DomainEventDelivery_eventId_handler_key" ON "DomainEventDelivery"("eventId", "handler"); + +-- CreateIndex +CREATE INDEX "WebhookDelivery_status_availableAt_idx" ON "WebhookDelivery"("status", "availableAt"); + +-- CreateIndex +CREATE INDEX "WebhookDelivery_subscriptionId_createdAt_idx" ON "WebhookDelivery"("subscriptionId", "createdAt"); + +-- CreateIndex +CREATE INDEX "JobRun_job_startedAt_idx" ON "JobRun"("job", "startedAt"); + +-- CreateIndex +CREATE INDEX "JobRun_status_idx" ON "JobRun"("status"); + +-- CreateIndex +CREATE INDEX "ThrottlerHit_expiresAt_idx" ON "ThrottlerHit"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "DomainEventDelivery" ADD CONSTRAINT "DomainEventDelivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "DomainEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/ApiCore-Backend/prisma/migrations/20261013120000_inspection_brood_frames/migration.sql b/ApiCore-Backend/prisma/migrations/20261013120000_inspection_brood_frames/migration.sql new file mode 100644 index 0000000..030590a --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261013120000_inspection_brood_frames/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Inspection" ADD COLUMN "broodFrames" INTEGER; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 1e4eb5e..7d59c51 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -78,8 +78,8 @@ model User { // TOTP-Secret AES-256-GCM-verschluesselt gespeichert (siehe // src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB. - mfaEnabled Boolean @default(false) - mfaSecret String? + mfaEnabled Boolean @default(false) + mfaSecret String? // Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer) // wird danach nie wieder akzeptiert (Replay-Schutz). mfaLastTimeStep Int? @@ -88,7 +88,7 @@ model User { // ab 5 ist das Konto bis authLockedUntil gesperrt. failedAuthAttempts Int @default(0) authLockedUntil DateTime? - avatarPath String? + avatarPath String? // Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens, // die vor diesem Zeitpunkt ausgestellt wurden (siehe @@ -170,16 +170,16 @@ model ConsumedToken { } model RefreshToken { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id]) - tokenHash String @unique - expiresAt DateTime - createdAt DateTime @default(now()) - revokedAt DateTime? + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) + tokenHash String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + revokedAt DateTime? // Anmeldezeitpunkt der Sitzung, an jede rotierte Nachfolge-Token vererbt: // spaetestens SESSION_MAX_AGE danach ist neu anzumelden. - sessionStartedAt DateTime @default(now()) + sessionStartedAt DateTime @default(now()) @@index([userId]) } @@ -223,23 +223,25 @@ enum ApiaryStatus { } model Apiary { - id String @id @default(cuid()) - name String - description String? - location String? - latitude Float? - longitude Float? - address String? - status ApiaryStatus @default(ACTIVE) - notes String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deactivatedAt DateTime? + id String @id @default(cuid()) + name String + description String? + location String? + latitude Float? + longitude Float? + address String? + // Coarse, public-safe place name for storefront/shop output (never the exact address). + publicLocationLabel String? + status ApiaryStatus @default(ACTIVE) + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deactivatedAt DateTime? - colonies Colony[] - colonyMoves ColonyApiaryHistory[] - harvests Harvest[] - treatments Treatment[] + colonies Colony[] + colonyMoves ColonyApiaryHistory[] + harvests Harvest[] + treatments Treatment[] rawMaterialLots RawMaterialLot[] @@index([status]) @@ -454,6 +456,7 @@ model Inspection { weather String? temperature Float? colonyStrength Int? + broodFrames Int? broodAmount String? broodPattern Int? openBroodPresent Boolean? @@ -761,8 +764,8 @@ model Harvest { notes String? createdAt DateTime @default(now()) - colonies HarvestColony[] - extractions ExtractionHarvest[] + colonies HarvestColony[] + extractions ExtractionHarvest[] rawMaterialLots RawMaterialLot[] @@index([apiaryId, date]) @@ -1300,6 +1303,8 @@ model WebhookSubscription { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + deliveries WebhookDelivery[] + @@index([active]) } @@ -1403,6 +1408,8 @@ model SiteSettings { // part of any public page. holderName String? holderAddress String? + // Country of origin printed on labels and shop output. + originCountry String @default("Deutschland") updatedAt DateTime @updatedAt } @@ -1521,3 +1528,119 @@ model CalendarSyncOutboxEntry { @@unique([sourceType, sourceId]) @@index([status]) } + +// --------------------------------------------------------------------------- +// Plattform-Rückgrat: Domain-Events (Outbox), Webhook-Zustellung, Job-Läufe, +// Throttler-Zähler. Alle fünf Modelle sind vom Backup ausgenommen. +// --------------------------------------------------------------------------- + +enum DomainEventStatus { + PENDING + PROCESSING + DONE + FAILED +} + +model DomainEvent { + id String @id @default(cuid()) + type String + aggregateType String + aggregateId String + payload Json + actorUserId String? + correlationId String + // Nullable @unique instead of a partial index: avoids the migration drift + // described in the backbone spec (section 3.5). + dedupeKey String? @unique + occurredAt DateTime @default(now()) + status DomainEventStatus @default(PENDING) + attempts Int @default(0) + availableAt DateTime @default(now()) + claimedAt DateTime? + processedAt DateTime? + lastError String? + + deliveries DomainEventDelivery[] + + @@index([status, availableAt]) + @@index([correlationId]) + @@index([aggregateType, aggregateId]) +} + +enum DomainEventDeliveryStatus { + PENDING + DONE + FAILED +} + +model DomainEventDelivery { + id String @id @default(cuid()) + eventId String + event DomainEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) + handler String + status DomainEventDeliveryStatus @default(PENDING) + attempts Int @default(0) + lastError String? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([eventId, handler]) +} + +enum WebhookDeliveryStatus { + PENDING + PROCESSING + DELIVERED + FAILED +} + +model WebhookDelivery { + id String @id @default(cuid()) + subscriptionId String + subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + event String + body String + status WebhookDeliveryStatus @default(PENDING) + attempts Int @default(0) + availableAt DateTime @default(now()) + claimedAt DateTime? + lastStatusCode Int? + lastError String? + deliveredAt DateTime? + createdAt DateTime @default(now()) + + @@index([status, availableAt]) + @@index([subscriptionId, createdAt]) +} + +enum JobRunStatus { + RUNNING + SUCCEEDED + FAILED +} + +model JobRun { + id String @id @default(cuid()) + job String + status JobRunStatus @default(RUNNING) + startedAt DateTime @default(now()) + finishedAt DateTime? + result Json? + error String? + + @@index([job, startedAt]) + @@index([status]) +} + +// UNLOGGED in the migration: no WAL, emptied after a crash - only counters get lost. +model ThrottlerHit { + key String + throttlerName String + hits Int + expiresAt DateTime + blockedUntil DateTime? + + @@id([key, throttlerName]) + @@index([expiresAt]) +} diff --git a/ApiCore-Backend/src/all-in-one.module.ts b/ApiCore-Backend/src/all-in-one.module.ts new file mode 100644 index 0000000..db603b5 --- /dev/null +++ b/ApiCore-Backend/src/all-in-one.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AppModule } from './app.module'; +import { WorkerRuntimeModule } from './worker/worker-runtime.module'; + +/** + * Role "all": the API plus the worker loops in one process, for local + * development and tests. Production refuses it (runtime/role.ts) and runs + * api and worker as separate pods. No worker health server here - the + * API's /health endpoints serve the process. + */ +@Module({ + imports: [AppModule, WorkerRuntimeModule], +}) +export class AllInOneModule {} diff --git a/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts b/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts new file mode 100644 index 0000000..d866e14 --- /dev/null +++ b/ApiCore-Backend/src/api-keys/api-keys.service.spec.ts @@ -0,0 +1,58 @@ +import { BadRequestException } from '@nestjs/common'; +import { ApiKeysService } from './api-keys.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; + +describe('ApiKeysService.create', () => { + let service: ApiKeysService; + let prisma: { apiKey: { create: jest.Mock } }; + + beforeEach(() => { + prisma = { + apiKey: { + create: jest.fn((args: { data: Record }) => + Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }), + ), + }, + }; + const permissionResolver = { + getPermissions: jest + .fn() + .mockResolvedValue(new Set(['shop-sync:read', 'shop-sync:write'])), + }; + service = new ApiKeysService( + prisma as unknown as PrismaService, + { record: jest.fn() } as unknown as AuditLogService, + permissionResolver as unknown as PermissionResolverService, + ); + }); + + it('refuses a new key with both shop scopes', async () => { + await expect( + service.create( + { + name: 'Shop', + scopes: ['shop-sync:read', 'shop-sync:write'], + }, + 'user-1', + ), + ).rejects.toThrow( + new BadRequestException( + 'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen', + ), + ); + expect(prisma.apiKey.create).not.toHaveBeenCalled(); + }); + + it.each(['shop-sync:read', 'shop-sync:write'])( + 'still creates a key with only %s', + async (scope) => { + const created = await service.create( + { name: 'Shop', scopes: [scope] }, + 'user-1', + ); + expect(created.apiKey.scopes).toEqual([scope]); + }, + ); +}); diff --git a/ApiCore-Backend/src/api-keys/api-keys.service.ts b/ApiCore-Backend/src/api-keys/api-keys.service.ts index 0da0256..bdd538c 100644 --- a/ApiCore-Backend/src/api-keys/api-keys.service.ts +++ b/ApiCore-Backend/src/api-keys/api-keys.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ForbiddenException, Injectable, Logger, @@ -85,6 +86,16 @@ export class ApiKeysService { dto: CreateApiKeyDto, actingUserId: string, ): Promise { + // Reading and booking are separate keys: a leaked read key cannot book, + // a leaked booking key cannot read. Existing combined keys stay valid. + if ( + dto.scopes.includes('shop-sync:read') && + dto.scopes.includes('shop-sync:write') + ) { + throw new BadRequestException( + 'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen', + ); + } // Nobody hands a machine more than they may do themselves. const callerPermissions = await this.permissionResolver.getPermissions(actingUserId); diff --git a/ApiCore-Backend/src/apiaries/apiaries.controller.ts b/ApiCore-Backend/src/apiaries/apiaries.controller.ts index b28044f..906aca3 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.controller.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.controller.ts @@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateApiaryDto } from './dto/create-apiary.dto'; import { UpdateApiaryDto } from './dto/update-apiary.dto'; import { ApiariesService } from './apiaries.service'; +import { ApiaryFieldService } from './apiary-field.service'; @ApiTags('apiaries') @ApiBearerAuth() @Controller('apiaries') export class ApiariesController { - constructor(private readonly apiariesService: ApiariesService) {} + constructor( + private readonly apiariesService: ApiariesService, + private readonly apiaryFieldService: ApiaryFieldService, + ) {} @RequirePermission('apiaries:read') @Get() @@ -31,6 +35,24 @@ export class ApiariesController { return this.apiariesService.findAll({ status, location }); } + @RequirePermission('colonies:read') + @Get(':id/colony-overview') + colonyOverview( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.apiaryFieldService.colonyOverview(id, actor.id); + } + + @RequirePermission('colonies:read') + @Get(':id/offline-pack') + offlinePack( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.apiaryFieldService.offlinePack(id, actor.id); + } + @RequirePermission('apiaries:read') @Get(':id') findOne(@Param('id') id: string) { diff --git a/ApiCore-Backend/src/apiaries/apiaries.module.ts b/ApiCore-Backend/src/apiaries/apiaries.module.ts index 6ab3ba7..094107e 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.module.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { ApiariesController } from './apiaries.controller'; import { ApiariesService } from './apiaries.service'; +import { ApiaryFieldService } from './apiary-field.service'; @Module({ controllers: [ApiariesController], - providers: [ApiariesService], + providers: [ApiariesService, ApiaryFieldService], exports: [ApiariesService], }) export class ApiariesModule {} diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts new file mode 100644 index 0000000..3d1ce66 --- /dev/null +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts @@ -0,0 +1,206 @@ +import { NotFoundException } from '@nestjs/common'; +import { ApiaryFieldService } from './apiary-field.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; + +describe('ApiaryFieldService', () => { + let service: ApiaryFieldService; + let prisma: { + apiary: { findUnique: jest.Mock }; + colony: { findMany: jest.Mock }; + inspection: { groupBy: jest.Mock; findMany: jest.Mock }; + notification: { groupBy: jest.Mock }; + queen: { findMany: jest.Mock }; + task: { findMany: jest.Mock }; + }; + let permissionResolver: { getPermissions: jest.Mock }; + + const colonyRow = (id: string, code: string, queenId: string | null) => ({ + id, + code, + apiaryId: 'apiary-1', + status: 'ACTIVE', + colonyType: 'PRODUCTION_COLONY', + hiveType: null, + strength: 5, + broodStatus: null, + feedStatus: null, + healthStatus: null, + currentQueenId: queenId, + notes: null, + }); + + const grant = (...keys: string[]) => + permissionResolver.getPermissions.mockResolvedValue(new Set(keys)); + + beforeEach(() => { + prisma = { + apiary: { + findUnique: jest.fn().mockResolvedValue({ + id: 'apiary-1', + name: 'Heimstand', + location: 'Garten', + status: 'ACTIVE', + }), + }, + colony: { + findMany: jest + .fn() + .mockResolvedValue([ + colonyRow('c10', 'V-10', 'q1'), + colonyRow('c2', 'V-2', null), + ]), + }, + inspection: { + groupBy: jest.fn().mockResolvedValue([]), + findMany: jest.fn().mockResolvedValue([]), + }, + notification: { groupBy: jest.fn().mockResolvedValue([]) }, + queen: { findMany: jest.fn().mockResolvedValue([]) }, + task: { findMany: jest.fn().mockResolvedValue([]) }, + }; + permissionResolver = { getPermissions: jest.fn() }; + service = new ApiaryFieldService( + prisma as unknown as PrismaService, + permissionResolver as unknown as PermissionResolverService, + ); + }); + + it('throws NotFoundException for an unknown apiary', async () => { + grant('colonies:read'); + prisma.apiary.findUnique.mockResolvedValue(null); + await expect(service.colonyOverview('x', 'u1')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('sorts living colonies by code with numeric collation', async () => { + grant('colonies:read'); + const overview = await service.colonyOverview('apiary-1', 'u1'); + expect(overview.rows.map((row) => row.code)).toEqual(['V-2', 'V-10']); + expect(prisma.colony.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ apiaryId: 'apiary-1' }) as unknown, + }), + ); + }); + + it('leaves out inspections, queens and tasks without their read permission', async () => { + grant('colonies:read'); + const pack = await service.offlinePack('apiary-1', 'u1'); + expect(pack.inspections).toBeNull(); + expect(pack.queens).toBeNull(); + expect(pack.tasks).toBeNull(); + expect(pack.overview.inspectionsVisible).toBe(false); + expect(pack.overview.rows.every((r) => r.lastInspectionDate === null)).toBe( + true, + ); + expect(prisma.inspection.groupBy).not.toHaveBeenCalled(); + expect(prisma.inspection.findMany).not.toHaveBeenCalled(); + expect(prisma.queen.findMany).not.toHaveBeenCalled(); + expect(prisma.task.findMany).not.toHaveBeenCalled(); + }); + + it('fills the permitted sections and the last inspection date', async () => { + grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read'); + const date = new Date('2026-05-01T10:00:00.000Z'); + prisma.inspection.groupBy.mockResolvedValue([ + { colonyId: 'c2', _max: { date } }, + ]); + prisma.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-1' }]); + prisma.task.findMany.mockResolvedValue([{ id: 't1', title: 'Futter' }]); + + const pack = await service.offlinePack('apiary-1', 'u1'); + + expect(pack.overview.inspectionsVisible).toBe(true); + expect(pack.overview.rows[0].lastInspectionDate).toBe(date.toISOString()); + expect(pack.overview.rows[1].lastInspectionDate).toBeNull(); + expect(pack.queens).toEqual([{ id: 'q1', code: 'K-1' }]); + expect(prisma.queen.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: { in: ['q1'] } } }), + ); + expect(pack.tasks).toEqual([{ id: 't1', title: 'Futter' }]); + expect(prisma.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + status: { in: ['OPEN', 'IN_PROGRESS'] }, + OR: [ + { relatedType: 'APIARY', relatedId: 'apiary-1' }, + { relatedType: 'COLONY', relatedId: { in: ['c2', 'c10'] } }, + ], + }, + }), + ); + }); + + it('keeps at most three inspections per colony, newest first', async () => { + grant('colonies:read', 'inspections:read'); + prisma.inspection.findMany.mockResolvedValue([{ id: 'i1' }]); + + const pack = await service.offlinePack('apiary-1', 'u1'); + + expect(prisma.inspection.findMany).toHaveBeenCalledTimes(2); + expect(prisma.inspection.findMany).toHaveBeenCalledWith({ + where: { colonyId: 'c2' }, + take: 3, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }], + omit: { userId: true }, + }); + expect(pack.inspections).toHaveLength(2); + }); + + it('never returns coordinates, address, apiary notes or inspection userId', async () => { + grant('colonies:read', 'inspections:read'); + const pack = await service.offlinePack('apiary-1', 'u1'); + + expect(Object.keys(pack.apiary).sort()).toEqual([ + 'id', + 'location', + 'name', + 'status', + ]); + const apiarySelect = ( + prisma.apiary.findUnique.mock.calls[0] as [{ select: object }] + )[0].select; + expect(apiarySelect).not.toHaveProperty('latitude'); + expect(apiarySelect).not.toHaveProperty('longitude'); + expect(apiarySelect).not.toHaveProperty('address'); + expect(apiarySelect).not.toHaveProperty('notes'); + const inspectionArgs = ( + prisma.inspection.findMany.mock.calls[0] as [{ omit: object }] + )[0]; + expect(inspectionArgs.omit).toEqual({ userId: true }); + }); + + it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => { + grant('colonies:read'); + prisma.notification.groupBy.mockResolvedValue([ + { relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } }, + { relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } }, + ]); + + const overview = await service.colonyOverview('apiary-1', 'u1'); + + expect(prisma.notification.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + by: ['relatedId', 'priority'], + where: { + relatedType: 'COLONY', + relatedId: { in: ['c2', 'c10'] }, + read: false, + priority: { in: ['WARNUNG', 'KRITISCH'] }, + OR: [{ userId: 'u1' }, { userId: null }], + }, + }), + ); + expect(overview.rows[0]).toMatchObject({ + code: 'V-2', + openWarnings: 2, + openCritical: 1, + }); + expect(overview.rows[1]).toMatchObject({ + openWarnings: 0, + openCritical: 0, + }); + }); +}); diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.ts new file mode 100644 index 0000000..0318a29 --- /dev/null +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.ts @@ -0,0 +1,230 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; +import { PrismaService } from '../prisma/prisma.service'; +import type { + ColonyOverview, + ColonyOverviewRow, + OfflinePackV1, +} from './apiary-field.types'; + +const INSPECTIONS_PER_COLONY = 3; + +type PackColony = OfflinePackV1['colonies'][number]; + +interface FieldData { + apiary: OfflinePackV1['apiary']; + colonies: PackColony[]; + permissions: ReadonlySet; + overview: ColonyOverview; +} + +/** + * Read models for work at the apiary: the colony traffic-light overview and + * the offline pack the PWA caches before the beekeeper loses reception. + * Sections beyond the colonies are only included for callers who may read them. + */ +@Injectable() +export class ApiaryFieldService { + constructor( + private readonly prisma: PrismaService, + private readonly permissionResolver: PermissionResolverService, + ) {} + + async colonyOverview( + apiaryId: string, + userId: string, + ): Promise { + return (await this.load(apiaryId, userId)).overview; + } + + async offlinePack(apiaryId: string, userId: string): Promise { + const { apiary, colonies, permissions, overview } = await this.load( + apiaryId, + userId, + ); + const [queens, inspections, tasks] = await Promise.all([ + permissions.has('queens:read') ? this.loadQueens(colonies) : null, + permissions.has('inspections:read') + ? this.loadInspections(colonies) + : null, + permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null, + ]); + return { + version: 1, + generatedAt: new Date().toISOString(), + apiary, + overview, + colonies, + queens, + inspections, + tasks, + }; + } + + private async load(apiaryId: string, userId: string): Promise { + // Explicit select: coordinates, address and notes of an apiary are + // theft-sensitive and must never reach the field payloads. + const apiary = await this.prisma.apiary.findUnique({ + where: { id: apiaryId }, + select: { id: true, name: true, location: true, status: true }, + }); + if (!apiary) { + throw new NotFoundException('Apiary not found'); + } + const permissions = await this.permissionResolver.getPermissions(userId); + const found = await this.prisma.colony.findMany({ + where: { ...LIVING_COLONY_WHERE, apiaryId }, + select: { + id: true, + code: true, + apiaryId: true, + status: true, + colonyType: true, + hiveType: true, + strength: true, + broodStatus: true, + feedStatus: true, + healthStatus: true, + currentQueenId: true, + notes: true, + }, + }); + const colonies = [...found].sort((a, b) => + a.code.localeCompare(b.code, 'de', { numeric: true }), + ); + const inspectionsVisible = permissions.has('inspections:read'); + const [lastInspections, warnings] = await Promise.all([ + inspectionsVisible ? this.lastInspectionDates(colonies) : null, + this.warningCounts(colonies, userId), + ]); + const rows = colonies.map((colony): ColonyOverviewRow => ({ + id: colony.id, + code: colony.code, + status: colony.status, + strength: colony.strength, + broodStatus: colony.broodStatus, + feedStatus: colony.feedStatus, + healthStatus: colony.healthStatus, + lastInspectionDate: lastInspections?.get(colony.id) ?? null, + openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0, + openCritical: warnings.get(colony.id)?.KRITISCH ?? 0, + })); + return { + apiary, + colonies, + permissions, + overview: { apiaryId, inspectionsVisible, rows }, + }; + } + + private async lastInspectionDates( + colonies: PackColony[], + ): Promise> { + const groups = await this.prisma.inspection.groupBy({ + by: ['colonyId'], + where: { colonyId: { in: colonies.map((colony) => colony.id) } }, + _max: { date: true }, + }); + const dates = new Map(); + for (const group of groups) { + if (group._max.date) { + dates.set(group.colonyId, group._max.date.toISOString()); + } + } + return dates; + } + + private async warningCounts( + colonies: PackColony[], + userId: string, + ): Promise> { + const groups = await this.prisma.notification.groupBy({ + by: ['relatedId', 'priority'], + where: { + relatedType: 'COLONY', + relatedId: { in: colonies.map((colony) => colony.id) }, + read: false, + priority: { in: ['WARNUNG', 'KRITISCH'] }, + OR: [{ userId }, { userId: null }], + }, + _count: { _all: true }, + }); + const counts = new Map(); + for (const group of groups) { + if ( + group.relatedId && + (group.priority === 'WARNUNG' || group.priority === 'KRITISCH') + ) { + counts.set(group.relatedId, { + ...counts.get(group.relatedId), + [group.priority]: group._count._all, + }); + } + } + return counts; + } + + private loadQueens(colonies: PackColony[]): Promise { + const queenIds = colonies.flatMap((colony) => + colony.currentQueenId ? [colony.currentQueenId] : [], + ); + return this.prisma.queen.findMany({ + where: { id: { in: queenIds } }, + select: { + id: true, + code: true, + hatchYear: true, + breed: true, + markingColor: true, + markingNumber: true, + status: true, + }, + }); + } + + private async loadInspections( + colonies: PackColony[], + ): Promise { + const perColony = await Promise.all( + colonies.map((colony) => + this.prisma.inspection.findMany({ + where: { colonyId: colony.id }, + take: INSPECTIONS_PER_COLONY, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }], + omit: { userId: true }, + }), + ), + ); + return perColony.flat(); + } + + private loadTasks( + apiaryId: string, + colonies: PackColony[], + ): Promise { + return this.prisma.task.findMany({ + where: { + status: { in: ['OPEN', 'IN_PROGRESS'] }, + OR: [ + { relatedType: 'APIARY', relatedId: apiaryId }, + { + relatedType: 'COLONY', + relatedId: { in: colonies.map((colony) => colony.id) }, + }, + ], + }, + select: { + id: true, + title: true, + description: true, + dueDate: true, + priority: true, + status: true, + relatedType: true, + relatedId: true, + }, + orderBy: [{ dueDate: 'asc' }, { createdAt: 'asc' }], + }); + } +} diff --git a/ApiCore-Backend/src/apiaries/apiary-field.types.ts b/ApiCore-Backend/src/apiaries/apiary-field.types.ts new file mode 100644 index 0000000..12aa33c --- /dev/null +++ b/ApiCore-Backend/src/apiaries/apiary-field.types.ts @@ -0,0 +1,91 @@ +import type { + ApiaryStatus, + Colony, + ColonyCondition, + ColonyStatus, + Inspection, + Queen, + Task, +} from '../../generated/prisma'; + +/** One living colony on the apiary page, reduced to what the traffic light needs. */ +export interface ColonyOverviewRow { + id: string; + code: string; + status: ColonyStatus; + strength: number | null; + broodStatus: ColonyCondition | null; + feedStatus: ColonyCondition | null; + healthStatus: ColonyCondition | null; + /** ISO timestamp; always null when the caller may not read inspections. */ + lastInspectionDate: string | null; + /** Unread WARNUNG notifications visible to the caller. */ + openWarnings: number; + /** Unread KRITISCH notifications visible to the caller. */ + openCritical: number; +} + +export interface ColonyOverview { + apiaryId: string; + inspectionsVisible: boolean; + rows: ColonyOverviewRow[]; +} + +/** + * Everything the offline apiary mode needs. It deliberately carries no + * coordinates, address or apiary notes (apiary locations are theft-sensitive) + * and no inspection userId. Sections the caller has no read permission for + * are null. + */ +export interface OfflinePackV1 { + version: 1; + generatedAt: string; + apiary: { + id: string; + name: string; + location: string | null; + status: ApiaryStatus; + }; + overview: ColonyOverview; + colonies: Pick< + Colony, + | 'id' + | 'code' + | 'apiaryId' + | 'status' + | 'colonyType' + | 'hiveType' + | 'strength' + | 'broodStatus' + | 'feedStatus' + | 'healthStatus' + | 'currentQueenId' + | 'notes' + >[]; + queens: + | Pick< + Queen, + | 'id' + | 'code' + | 'hatchYear' + | 'breed' + | 'markingColor' + | 'markingNumber' + | 'status' + >[] + | null; + inspections: Omit[] | null; + tasks: + | Pick< + Task, + | 'id' + | 'title' + | 'description' + | 'dueDate' + | 'priority' + | 'status' + | 'relatedType' + | 'relatedId' + >[] + | null; +} diff --git a/ApiCore-Backend/src/apiaries/dto/create-apiary.dto.ts b/ApiCore-Backend/src/apiaries/dto/create-apiary.dto.ts index 135edbb..7a43ace 100644 --- a/ApiCore-Backend/src/apiaries/dto/create-apiary.dto.ts +++ b/ApiCore-Backend/src/apiaries/dto/create-apiary.dto.ts @@ -1,5 +1,11 @@ import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; -import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator'; +import { + IsLatitude, + IsLongitude, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; export class CreateApiaryDto { @ApiProperty() @@ -35,4 +41,13 @@ export class CreateApiaryDto { @IsOptional() @IsString() notes?: string; + + @ApiPropertyOptional({ + description: + 'Ort für die öffentliche Herkunftsseite, z. B. "Nordhessen". Nie die genaue Adresse.', + }) + @IsOptional() + @IsString() + @MaxLength(100) + publicLocationLabel?: string; } diff --git a/ApiCore-Backend/src/app.config.ts b/ApiCore-Backend/src/app.config.ts index f04f2b8..847c552 100644 --- a/ApiCore-Backend/src/app.config.ts +++ b/ApiCore-Backend/src/app.config.ts @@ -19,7 +19,7 @@ import { noStoreMiddleware } from './common/middleware/no-store.middleware'; * Production runs client -> Cloudflare -> Traefik -> backend, so the hop * count is not stable; TRUST_PROXY is an Express list there ("loopback, * 10.42.0.0/16, ", see - * deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop + * deploy/k8s/base/backend/configmap.example.yaml). A plain number ("1") is a hop * count and stays the default when the variable is unset. */ export function trustProxySetting(): number | string { diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 7dd32b7..c04b74d 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -3,10 +3,13 @@ import { ConfigModule } from '@nestjs/config'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ThrottlerModule } from '@nestjs/throttler'; import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard'; +import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage'; +import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module'; import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './health/health.module'; import { AuditModule } from './audit/audit.module'; +import { DomainEventsModule } from './domain-events/domain-events.module'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; import { RolesModule } from './roles/roles.module'; @@ -60,10 +63,18 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea imports: [ ConfigModule.forRoot({ isGlobal: true }), ScheduleModule.forRoot(), - ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]), + ThrottlerModule.forRootAsync({ + imports: [ThrottlerStorageModule], + inject: [PrismaThrottlerStorage], + useFactory: (storage: PrismaThrottlerStorage) => ({ + throttlers: [{ name: 'default', ttl: 60000, limit: 100 }], + storage, + }), + }), PrismaModule, PermissionsModule, AuditModule, + DomainEventsModule, HealthModule, UsersModule, AuthModule, diff --git a/ApiCore-Backend/src/audit/audit-log.service.spec.ts b/ApiCore-Backend/src/audit/audit-log.service.spec.ts index d0e4404..4541ce0 100644 --- a/ApiCore-Backend/src/audit/audit-log.service.spec.ts +++ b/ApiCore-Backend/src/audit/audit-log.service.spec.ts @@ -43,6 +43,18 @@ describe('AuditLogService', () => { expect(createArg.data.userId).toBe('user-1'); }); + it('writes through the transaction client when one is given', async () => { + const tx = { auditLog: { create: jest.fn() } }; + + await service.record( + { entityType: 'Inspection', entityId: 'i1', action: 'CREATE' }, + tx as never, + ); + + expect(tx.auditLog.create).toHaveBeenCalledTimes(1); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + it('findAll filters by entity type and id', async () => { await service.findAll({ entityType: 'Colony', entityId: 'colony-1' }); diff --git a/ApiCore-Backend/src/audit/audit-log.service.ts b/ApiCore-Backend/src/audit/audit-log.service.ts index a838ce2..25c1e6e 100644 --- a/ApiCore-Backend/src/audit/audit-log.service.ts +++ b/ApiCore-Backend/src/audit/audit-log.service.ts @@ -1,8 +1,9 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { DbClient } from '../prisma/db-client'; import { AuditAction, AuditLog, Prisma } from '../../generated/prisma'; -interface RecordAuditLogInput { +export interface RecordAuditLogInput { entityType: string; entityId: string; action: AuditAction; @@ -20,8 +21,17 @@ export interface AuditLogFilter { export class AuditLogService { constructor(private readonly prisma: PrismaService) {} - async record(input: RecordAuditLogInput): Promise { - await this.prisma.auditLog.create({ + /** + * With `tx` the entry commits or rolls back together with the change it + * describes - no audited change without its entry, and no entry for a + * change that never happened. + */ + async record( + input: RecordAuditLogInput, + tx?: Prisma.TransactionClient, + ): Promise { + const db: DbClient = tx ?? this.prisma; + await db.auditLog.create({ data: { entityType: input.entityType, entityId: input.entityId, diff --git a/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.spec.ts b/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.spec.ts index ec87a7b..c57344b 100644 --- a/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.spec.ts +++ b/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.spec.ts @@ -1,37 +1,114 @@ -import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util'; +import { Logger } from '@nestjs/common'; +import { KEY_ID_PATTERN } from '../../common/crypto/key-ring'; +import { + decryptMfaSecret, + encryptMfaSecret, + mfaSecretNeedsRekey, +} from './mfa-crypto.util'; + +// Written by the code before the key ring (AES key sha256(JWT_ACCESS_SECRET), +// no key id) with JWT_ACCESS_SECRET = 'x' * 40. +const LEGACY_SECRET = + 'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ=='; +const PLAINTEXT = 'JBSWY3DPEHPK3PXP'; describe('mfa-crypto.util', () => { - const originalSecret = process.env.JWT_ACCESS_SECRET; + const originalEnv = { ...process.env }; beforeEach(() => { - process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret'; + process.env.JWT_ACCESS_SECRET = 'x'.repeat(40); + process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40); + delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS; + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); }); afterEach(() => { - process.env.JWT_ACCESS_SECRET = originalSecret; + process.env = { ...originalEnv }; + jest.restoreAllMocks(); }); - it('round-trips a TOTP secret through encrypt/decrypt', () => { - const plaintext = 'JBSWY3DPEHPK3PXP'; - const ciphertext = encryptMfaSecret(plaintext); - expect(ciphertext).not.toContain(plaintext); - expect(decryptMfaSecret(ciphertext)).toBe(plaintext); + it('round-trips a TOTP secret and prefixes the key id', () => { + const ciphertext = encryptMfaSecret(PLAINTEXT); + + expect(ciphertext).toMatch(KEY_ID_PATTERN); + expect(ciphertext).not.toContain(PLAINTEXT); + expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT); }); it('produces a different ciphertext every time (random IV)', () => { - const plaintext = 'JBSWY3DPEHPK3PXP'; - expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext)); + expect(encryptMfaSecret(PLAINTEXT)).not.toBe(encryptMfaSecret(PLAINTEXT)); + }); + + it('no longer depends on JWT_ACCESS_SECRET', () => { + const ciphertext = encryptMfaSecret(PLAINTEXT); + process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); + + expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT); }); it('fails to decrypt with the wrong key', () => { - const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP'); - process.env.JWT_ACCESS_SECRET = 'a-different-secret'; - expect(() => decryptMfaSecret(ciphertext)).toThrow(); + const ciphertext = encryptMfaSecret(PLAINTEXT); + process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40); + + expect(() => decryptMfaSecret(ciphertext)).toThrow('unknown key id'); + }); + + it('opens a secret sealed with MFA_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => { + const ciphertext = encryptMfaSecret(PLAINTEXT); + process.env.MFA_ENCRYPTION_KEY_PREVIOUS = process.env.MFA_ENCRYPTION_KEY; + process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40); + + expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT); + expect(mfaSecretNeedsRekey(ciphertext)).toBe(true); + }); + + it('does not flag a secret sealed with the current key', () => { + expect(mfaSecretNeedsRekey(encryptMfaSecret(PLAINTEXT))).toBe(false); + }); + + it('still opens a legacy MFA secret encrypted with the JWT-derived key', () => { + expect(decryptMfaSecret(LEGACY_SECRET)).toBe(PLAINTEXT); + expect(mfaSecretNeedsRekey(LEGACY_SECRET)).toBe(true); + }); + + it('fails closed on a legacy secret once JWT_ACCESS_SECRET changed', () => { + process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); + + expect(() => decryptMfaSecret(LEGACY_SECRET)).toThrow( + 'ciphertext failed authentication', + ); + }); + + it('never retries a k…: value that fails authentication with the legacy key', () => { + const warn = jest.spyOn(Logger.prototype, 'warn'); + const currentKeyId = encryptMfaSecret(PLAINTEXT).split(':')[0]; + // A valid legacy ciphertext behind the current key id: only a retry with + // sha256(JWT_ACCESS_SECRET) could open it. + const disguised = `${currentKeyId}:${LEGACY_SECRET}`; + + expect(() => decryptMfaSecret(disguised)).toThrow( + 'ciphertext failed authentication', + ); + expect(() => decryptMfaSecret(`k00000000:${LEGACY_SECRET}`)).toThrow( + 'unknown key id', + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it('fails on a tampered ciphertext', () => { + const [keyId, body] = encryptMfaSecret(PLAINTEXT).split(':'); + const [iv, tag, data] = body.split('.'); + const bytes = Buffer.from(data, 'base64'); + bytes[0] ^= 0xff; + + expect(() => + decryptMfaSecret(`${keyId}:${iv}.${tag}.${bytes.toString('base64')}`), + ).toThrow('ciphertext failed authentication'); }); it('rejects a malformed ciphertext', () => { expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow( - 'Invalid MFA secret ciphertext', + 'malformed ciphertext', ); }); }); diff --git a/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.ts b/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.ts index 001d523..8dff46c 100644 --- a/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.ts +++ b/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.ts @@ -1,53 +1,31 @@ import { - createCipheriv, - createDecipheriv, - createHash, - randomBytes, -} from 'node:crypto'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_BYTES = 12; - -function getKey(): Buffer { - const secret = process.env.JWT_ACCESS_SECRET; - if (!secret) { - throw new Error('JWT_ACCESS_SECRET is not set'); - } - return createHash('sha256').update(secret).digest(); -} + KEY_ID_PATTERN, + isSealedWithCurrentKey, + loadKeyRing, + openLegacyCiphertext, + openWithRing, + sealWithRing, +} from '../../common/crypto/key-ring'; /** * TOTP secrets must be reversible (unlike passwords/refresh tokens, which * are one-way hashed) so the server can compute the expected code on every - * login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret - * out of the DB in plaintext without introducing a new secret to manage. + * login. They are sealed with the MFA key ring (MFA_ENCRYPTION_KEY, see + * common/crypto/key-ring.ts). Secrets stored before the key ring have no key + * id and were encrypted with sha256(JWT_ACCESS_SECRET); they stay readable + * until the rekey job reseals them. */ export function encryptMfaSecret(plaintext: string): string { - const iv = randomBytes(IV_BYTES); - const cipher = createCipheriv(ALGORITHM, getKey(), iv); - const encrypted = Buffer.concat([ - cipher.update(plaintext, 'utf8'), - cipher.final(), - ]); - const authTag = cipher.getAuthTag(); - return [iv, authTag, encrypted] - .map((buffer) => buffer.toString('base64')) - .join('.'); + return sealWithRing(loadKeyRing('mfa'), plaintext); } -export function decryptMfaSecret(ciphertext: string): string { - const [ivB64, authTagB64, dataB64] = ciphertext.split('.'); - if (!ivB64 || !authTagB64 || !dataB64) { - throw new Error('Invalid MFA secret ciphertext'); - } - const decipher = createDecipheriv( - ALGORITHM, - getKey(), - Buffer.from(ivB64, 'base64'), - ); - decipher.setAuthTag(Buffer.from(authTagB64, 'base64')); - return Buffer.concat([ - decipher.update(Buffer.from(dataB64, 'base64')), - decipher.final(), - ]).toString('utf8'); +export function decryptMfaSecret(stored: string): string { + return KEY_ID_PATTERN.test(stored) + ? openWithRing(loadKeyRing('mfa'), stored).plaintext + : openLegacyCiphertext('mfa', stored); +} + +/** True for secrets the rekey job should reseal with the current key. */ +export function mfaSecretNeedsRekey(stored: string): boolean { + return !isSealedWithCurrentKey(loadKeyRing('mfa'), stored); } diff --git a/ApiCore-Backend/src/automation/varroa-threshold.spec.ts b/ApiCore-Backend/src/automation/varroa-threshold.spec.ts new file mode 100644 index 0000000..b8db7eb --- /dev/null +++ b/ApiCore-Backend/src/automation/varroa-threshold.spec.ts @@ -0,0 +1,41 @@ +import { + DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY, + varroaThresholdOf, +} from './varroa-threshold'; + +describe('varroaThresholdOf', () => { + it('uses the default when no rule exists yet (default rule is active)', () => { + expect(varroaThresholdOf(null)).toBe( + DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY, + ); + expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10); + }); + + it('returns null for an inactive rule', () => { + expect( + varroaThresholdOf({ + active: false, + config: { thresholdMitesPerDay: 7 }, + }), + ).toBeNull(); + }); + + it('returns the configured threshold of an active rule', () => { + expect( + varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }), + ).toBe(7); + }); + + it.each([ + ['a string', { thresholdMitesPerDay: 'x' }], + ['zero', { thresholdMitesPerDay: 0 }], + ['a negative number', { thresholdMitesPerDay: -1 }], + ['NaN', { thresholdMitesPerDay: Number.NaN }], + ['a missing key', {}], + ['a null config', null], + ])('falls back to the default for %s', (_label, config) => { + expect(varroaThresholdOf({ active: true, config })).toBe( + DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY, + ); + }); +}); diff --git a/ApiCore-Backend/src/automation/varroa-threshold.ts b/ApiCore-Backend/src/automation/varroa-threshold.ts new file mode 100644 index 0000000..2b5bb2f --- /dev/null +++ b/ApiCore-Backend/src/automation/varroa-threshold.ts @@ -0,0 +1,30 @@ +import type { AutomationRule } from '../../generated/prisma'; + +/** Default natural mite fall (mites per day) that triggers the varroa rule. */ +export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10; + +/** + * Effective varroa threshold of the VARROA_THRESHOLD rule. + * - no rule row yet: the default rule applies (ensureDefaults creates it active) + * - inactive rule: null (no threshold in force) + * - active rule: the configured value, or the default if it is not a + * positive finite number (a hand-edited config must never disable the check). + */ +export function varroaThresholdOf( + rule: Pick | null, +): number | null { + if (rule === null) { + return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY; + } + if (!rule.active) { + return null; + } + const config = rule.config; + if (typeof config !== 'object' || config === null || Array.isArray(config)) { + return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY; + } + const value = (config as Record).thresholdMitesPerDay; + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY; +} diff --git a/ApiCore-Backend/src/backup/backup-crypto.spec.ts b/ApiCore-Backend/src/backup/backup-crypto.spec.ts index 6ebb65f..9667fd4 100644 --- a/ApiCore-Backend/src/backup/backup-crypto.spec.ts +++ b/ApiCore-Backend/src/backup/backup-crypto.spec.ts @@ -1,5 +1,41 @@ -import { BadRequestException } from '@nestjs/common'; -import { isSealedBackup, openBackup, sealBackup } from './backup-crypto'; +import { BadRequestException, Logger } from '@nestjs/common'; +import { + isSealedBackup, + openBackup, + sealBackup, + type SealedBackup, +} from './backup-crypto'; + +// Sealed by the code before the key ring, JWT_ACCESS_SECRET = 'x' * 40: +// LEGACY_JWT_BACKUP without BACKUP_ENCRYPTION_KEY (key derived from the JWT +// secret), KEY_BACKUP with BACKUP_ENCRYPTION_KEY = 'k' * 40. +const FIXTURE_BUNDLE = { + version: 3, + exportedAt: '2026-10-01T09:00:00.000Z', + data: { user: [{ id: 'u1' }] }, +}; +const LEGACY_JWT_BACKUP: SealedBackup = { + format: 'apicore-backup', + version: 3, + keyId: '6ac8404ff5ae', + exportedAt: '2026-10-01T09:00:00.000Z', + algorithm: 'A256GCM+gzip', + iv: 'vD+47m3e5IFJAexz', + tag: 'LOgrmURu9vAMalQmlduDpw==', + ciphertext: + 'yvuAGhVALAxXkqZM8FpeiPnGHD9llp/SVtdQ6S58oS4ezussfP7Lqj8ZDuNbxabwjUP4QBJvVdEyhKCFZ9w5YRbCRjDozizJyE6YwjqiCsAXWPyVZ2lZ6Fo8/G+/0HsoQg==', +}; +const KEY_BACKUP: SealedBackup = { + format: 'apicore-backup', + version: 3, + keyId: '14b11a29dc33', + exportedAt: '2026-10-01T09:00:00.000Z', + algorithm: 'A256GCM+gzip', + iv: 'NKe1zNJfRPyBqeYL', + tag: 'zU/oVHyV4bj5+wwF0lvGiA==', + ciphertext: + 'ArjmmWq18F3OkPQYfx6x9XcjWdqP1SZC9zarUCRfV7bo6KRYx3pi5sNMRro6mg0eqlr4+C4jRKn3y+110kaWEFrY6J/ashF76pWSl1gzfiewkpxdqoyjkXRxrd4AnPrnfw==', +}; describe('backup-crypto', () => { const originalEnv = { ...process.env }; @@ -12,10 +48,13 @@ describe('backup-crypto', () => { beforeEach(() => { process.env.JWT_ACCESS_SECRET = 'x'.repeat(40); delete process.env.BACKUP_ENCRYPTION_KEY; + delete process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS; + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); }); afterEach(() => { process.env = { ...originalEnv }; + jest.restoreAllMocks(); }); it('round-trips a bundle and hides its contents', () => { @@ -45,7 +84,17 @@ describe('backup-crypto', () => { ).toThrow(BadRequestException); }); + it('rejects a file with a truncated auth tag', () => { + const sealed = sealBackup(bundle, bundle.exportedAt); + const tag = Buffer.from(sealed.tag, 'base64').subarray(0, 4); + + expect(() => + openBackup({ ...sealed, tag: tag.toString('base64') }), + ).toThrow(BadRequestException); + }); + it('rejects a file sealed with another key', () => { + process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40); const sealed = sealBackup(bundle, bundle.exportedAt); process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40); @@ -64,4 +113,80 @@ describe('backup-crypto', () => { process.env.BACKUP_ENCRYPTION_KEY = 'short'; expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow(); }); + + it('opens a backup sealed with BACKUP_ENCRYPTION_KEY_PREVIOUS', () => { + process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40); + const sealed = sealBackup(bundle, bundle.exportedAt); + process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40); + process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS = 'k'.repeat(40); + + expect(openBackup(sealed)).toEqual(bundle); + expect(sealBackup(bundle, bundle.exportedAt).keyId).not.toBe(sealed.keyId); + }); + + it('still opens files sealed before the key ring (file format v3 unchanged)', () => { + process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40); + + expect(openBackup(KEY_BACKUP)).toEqual(FIXTURE_BUNDLE); + expect(openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE); + }); + + it('only reads with the JWT-derived key, never seals with it once BACKUP_ENCRYPTION_KEY is set', () => { + process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40); + + expect(sealBackup(bundle, bundle.exportedAt).keyId).toBe(KEY_BACKUP.keyId); + }); + + it('fails closed on a JWT-derived backup once JWT_ACCESS_SECRET changed', () => { + process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40); + process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); + + expect(() => openBackup(LEGACY_JWT_BACKUP)).toThrow(BadRequestException); + }); + + it('refuses to seal in production without BACKUP_ENCRYPTION_KEY', () => { + process.env.NODE_ENV = 'production'; + + expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow( + 'BACKUP_ENCRYPTION_KEY is not set', + ); + }); + + it('warns once that a development key stands in for BACKUP_ENCRYPTION_KEY', () => { + jest.isolateModules(() => { + // A fresh module copy, so earlier tests cannot have used up the warning. + const nest = + jest.requireActual('@nestjs/common'); + const fresh = + jest.requireActual('./backup-crypto'); + const warn = jest + .spyOn(nest.Logger.prototype, 'warn') + .mockImplementation(() => undefined); + + const sealed = fresh.sealBackup(bundle, bundle.exportedAt); + fresh.sealBackup(bundle, bundle.exportedAt); + fresh.openBackup(sealed); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + 'BACKUP_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production', + ); + }); + }); + + it('does not warn when only reading a legacy file next to a configured key', () => { + jest.isolateModules(() => { + const nest = + jest.requireActual('@nestjs/common'); + const fresh = + jest.requireActual('./backup-crypto'); + const warn = jest + .spyOn(nest.Logger.prototype, 'warn') + .mockImplementation(() => undefined); + process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40); + + expect(fresh.openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE); + expect(warn).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/backup/backup-crypto.ts b/ApiCore-Backend/src/backup/backup-crypto.ts index f09f617..7b1219b 100644 --- a/ApiCore-Backend/src/backup/backup-crypto.ts +++ b/ApiCore-Backend/src/backup/backup-crypto.ts @@ -1,6 +1,17 @@ import { BadRequestException } from '@nestjs/common'; import * as crypto from 'node:crypto'; import * as zlib from 'node:zlib'; +import { + ENCRYPTION_KEY_VARIABLES, + devFallbackSecret, + findRingKey, + gcmOpen, + gcmSeal, + readRingSecrets, + requireJwtAccessSecret, + type KeyRing, + type RingKey, +} from '../common/crypto/key-ring'; /** * A backup holds everything - password hashes, encrypted MFA secrets, @@ -9,17 +20,17 @@ import * as zlib from 'node:zlib'; * keeps it unreadable outside ApiCore AND makes any change to it (or a * hand-made file) fail authentication before a single row is touched. * - * Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another - * deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment - * can always restore its own backups. Either way the AES key comes from - * HKDF with a backup-specific label, never the raw secret. + * Keys follow the key ring (common/crypto/key-ring.ts): BACKUP_ENCRYPTION_KEY + * seals, BACKUP_ENCRYPTION_KEY_PREVIOUS still opens, and files sealed before + * the key was mandatory - with a key derived from JWT_ACCESS_SECRET - are + * still read (never written, except outside production without + * BACKUP_ENCRYPTION_KEY). The file names its key in `keyId`. Either way the + * AES key comes from HKDF with a backup-specific label, never the raw + * secret; derivation and file format v3 are unchanged, so older files open. */ export const SEALED_BACKUP_FORMAT = 'apicore-backup'; export const SEALED_BACKUP_VERSION = 3; -const ALGORITHM = 'aes-256-gcm'; -const IV_BYTES = 12; -const MIN_KEY_LENGTH = 32; // Decompressed size cap: a small file must not unpack into gigabytes. const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024; @@ -35,25 +46,9 @@ export interface SealedBackup { ciphertext: string; } -function rootSecret(): { secret: string; source: 'backup' | 'jwt' } { - const backupKey = process.env.BACKUP_ENCRYPTION_KEY; - if (backupKey) { - if (backupKey.length < MIN_KEY_LENGTH) { - throw new Error( - `BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`, - ); - } - return { secret: backupKey, source: 'backup' }; - } - const jwtSecret = process.env.JWT_ACCESS_SECRET; - if (!jwtSecret) { - throw new Error('JWT_ACCESS_SECRET is not set'); - } - return { secret: jwtSecret, source: 'jwt' }; -} +type KeySource = 'backup' | 'jwt'; -function deriveKey(): { key: Buffer; keyId: string } { - const { secret, source } = rootSecret(); +function deriveBackupKey(secret: string, source: KeySource): RingKey { const key = Buffer.from( crypto.hkdfSync( 'sha256', @@ -63,12 +58,35 @@ function deriveKey(): { key: Buffer; keyId: string } { 32, ), ); - const keyId = crypto - .createHash('sha256') - .update(key) - .digest('hex') - .slice(0, 12); - return { key, keyId }; + const id = crypto.createHash('sha256').update(key).digest('hex').slice(0, 12); + return { id, key }; +} + +function legacyBackupKey(): RingKey { + return deriveBackupKey(requireJwtAccessSecret(), 'jwt'); +} + +function loadBackupRing(): KeyRing { + const variable = ENCRYPTION_KEY_VARIABLES.backup; + const secrets = readRingSecrets(variable); + return { + current: secrets.current + ? deriveBackupKey(secrets.current, 'backup') + : deriveBackupKey(devFallbackSecret(variable), 'jwt'), + previous: secrets.previous + ? deriveBackupKey(secrets.previous, 'backup') + : null, + }; +} + +/** The ring's keys first; the JWT-derived key of older files only as a last resort for reading. */ +function keyForReading(keyId: string): RingKey | null { + const ringKey = findRingKey(loadBackupRing(), keyId); + if (ringKey) { + return ringKey; + } + const legacy = legacyBackupKey(); + return legacy.id === keyId ? legacy : null; } function associatedData( @@ -80,24 +98,21 @@ function associatedData( } export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup { - const { key, keyId } = deriveKey(); - const iv = crypto.randomBytes(IV_BYTES); + const { current } = loadBackupRing(); const header = { format: SEALED_BACKUP_FORMAT, version: SEALED_BACKUP_VERSION, - keyId, + keyId: current.id, exportedAt, } as const; - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - cipher.setAAD(associatedData(header)); const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle))); - const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]); + const sealed = gcmSeal(current.key, compressed, associatedData(header)); return { ...header, algorithm: 'A256GCM+gzip', - iv: iv.toString('base64'), - tag: cipher.getAuthTag().toString('base64'), - ciphertext: ciphertext.toString('base64'), + iv: sealed.iv.toString('base64'), + tag: sealed.tag.toString('base64'), + ciphertext: sealed.ciphertext.toString('base64'), }; } @@ -112,32 +127,30 @@ export function isSealedBackup(value: unknown): value is SealedBackup { ); } -/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */ +/** The bundle inside a sealed backup; throws if no known key made the file or it was changed. */ export function openBackup(sealed: SealedBackup): unknown { if (sealed.version !== SEALED_BACKUP_VERSION) { throw new BadRequestException( `Backup-Version ${String(sealed.version)} wird nicht unterstützt`, ); } - const { key, keyId } = deriveKey(); - if (sealed.keyId !== keyId) { + const ringKey = keyForReading(sealed.keyId); + if (!ringKey) { throw new BadRequestException( - 'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)', + 'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY bzw. BACKUP_ENCRYPTION_KEY_PREVIOUS prüfen)', ); } let compressed: Buffer; try { - const decipher = crypto.createDecipheriv( - ALGORITHM, - key, - Buffer.from(sealed.iv, 'base64'), + compressed = gcmOpen( + ringKey.key, + { + iv: Buffer.from(sealed.iv, 'base64'), + tag: Buffer.from(sealed.tag, 'base64'), + ciphertext: Buffer.from(sealed.ciphertext, 'base64'), + }, + associatedData(sealed), ); - decipher.setAAD(associatedData(sealed)); - decipher.setAuthTag(Buffer.from(sealed.tag, 'base64')); - compressed = Buffer.concat([ - decipher.update(Buffer.from(sealed.ciphertext, 'base64')), - decipher.final(), - ]); } catch { throw new BadRequestException( 'Backup-Datei ist beschädigt oder wurde verändert', diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index 8591fda..214b7fd 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -216,6 +216,27 @@ describe('BackupService', () => { ); expect(documents.find((d) => d.id === 'doc-2')?.contentBase64).toBeNull(); }); + + it('exports and restores Inspection.broodFrames', async () => { + const row = { + id: 'inspection-1', + colonyId: 'colony-1', + userId: 'known-user', + broodFrames: 5, + }; + prisma.inspection.findMany.mockResolvedValue([row]); + + const bundle = await service.exportAll(); + const exported = bundle.data.inspection as { broodFrames: number }[]; + expect(exported[0]?.broodFrames).toBe(5); + + await service.importAll(bundle, 'admin-1', TRUSTED); + + const [[call]] = tx.inspection.upsert.mock.calls as [ + [{ create: { broodFrames: number } }], + ]; + expect(call.create.broodFrames).toBe(5); + }); }); describe('importAll', () => { diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index 6d7dd23..b1e724c 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -127,6 +127,14 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly< 'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.', IdempotencyRecord: 'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.', + DomainEvent: + 'Operational outbox - side effects of restored data must never be replayed after a restore.', + DomainEventDelivery: + 'Per-handler progress of DomainEvent rows, excluded with them.', + WebhookDelivery: + 'Delivery log and retry queue - restoring it would resend old events to receivers.', + JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.", + ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.', }; const DOCUMENT_ENTITY_TYPES = new Set( diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts index 513e672..d393778 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts @@ -12,7 +12,10 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; -import { CalendarSyncService } from './calendar-sync.service'; +import { + CalendarSyncService, + CalendarSyncStatusReport, +} from './calendar-sync.service'; import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settings.dto'; @ApiTags('calendar-sync') @@ -45,13 +48,27 @@ export class CalendarSyncController { return this.calendarSyncService.getStatus(); } - // Starts a pass and answers at once (202) - the pass itself can take - // minutes. The returned status says running: true; GET status follows it. + // Queues a pass and answers at once (202): FAILED entries are reset and + // the worker is woken - the API itself never calls Graph. GET status + // reports running from the worker's live claims; until the worker has + // claimed its first entry, a due pass counts as running, so the client + // starts following it. @RequirePermission('calendar:configure') @Post('run-now') @HttpCode(HttpStatus.ACCEPTED) - runNow() { - this.calendarSyncService.startRun(); - return this.calendarSyncService.getStatus(); + async runNow(): Promise { + await this.calendarSyncService.requestRun(); + const status = await this.calendarSyncService.getStatus(); + return { ...status, running: status.running || isPassDue(status) }; } } + +/** The woken worker syncs every PENDING entry - if syncing is set up. */ +function isPassDue(status: CalendarSyncStatusReport): boolean { + return ( + status.configured && + status.enabled && + Boolean(status.groupId) && + status.pendingCount > 0 + ); +} diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts index 3800172..0e85d1f 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts @@ -22,6 +22,7 @@ describe('CalendarSyncService', () => { feeding: { findUnique: jest.Mock }; harvest: { findUnique: jest.Mock }; queenColonyHistory: { findUnique: jest.Mock }; + $executeRaw: jest.Mock; }; let settingsService: { get: jest.Mock }; let graphClient: { @@ -93,6 +94,7 @@ describe('CalendarSyncService', () => { feeding: { findUnique: jest.fn() }, harvest: { findUnique: jest.fn() }, queenColonyHistory: { findUnique: jest.fn() }, + $executeRaw: jest.fn().mockResolvedValue(1), }; settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) }; graphClient = { @@ -284,6 +286,44 @@ describe('CalendarSyncService', () => { ); }); + // Worker shutdown: the entry in progress is finished and settled (a cut + // off one is only taken over after the stale-claim window), but no + // further entry is claimed. + it('stops between entries once the signal aborts', async () => { + const stop = new AbortController(); + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + pendingEntry(), + pendingEntry({ id: 'entry-2', sourceId: 'task-2' }), + ]); + prisma.task.findUnique.mockResolvedValue(dueTask); + graphClient.createEvent.mockImplementation(() => { + stop.abort(); + return Promise.resolve('graph-event-1'); + }); + + await service.processOutbox(stop.signal); + + expect(graphClient.createEvent).toHaveBeenCalledTimes(1); + expectSettled({ + status: 'SYNCED', + graphEventId: 'graph-event-1', + lastError: null, + }); + expect(updateManyCalls().map((call) => call.where.id)).not.toContain( + 'entry-2', + ); + }); + + it('reads nothing once the signal has aborted', async () => { + const stop = new AbortController(); + stop.abort(); + + await service.processOutbox(stop.signal); + + expect(settingsService.get).not.toHaveBeenCalled(); + expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled(); + }); + it('claims a row with a conditional update before working on it', async () => { prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ pendingEntry(), @@ -626,22 +666,42 @@ describe('CalendarSyncService', () => { }); }); - describe('runNow', () => { - it('gives FAILED entries a fresh set of attempts before processing', async () => { - await service.runNow(); + // "Jetzt synchronisieren" only queues the pass: the worker runs it, so the + // API request neither talks to Graph nor waits for up to 20 entries. + describe('requestRun', () => { + it('requestRun resets FAILED entries and wakes the worker without calling Graph', async () => { + await service.requestRun(); expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({ where: { status: 'FAILED' }, data: { status: 'PENDING', attempts: 0, lastError: null }, }); - const [resetOrder] = - prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder; - const [readOrder] = - prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder; - expect(resetOrder).toBeLessThan(readOrder); + expect(prisma.$executeRaw).toHaveBeenCalled(); // pg_notify('calendar_sync') + expect(graphClient.createEvent).not.toHaveBeenCalled(); + expect(graphClient.updateEvent).not.toHaveBeenCalled(); + expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled(); }); - it('leaves FAILED entries alone on a regular cron tick', async () => { + it("wakes the calendar outbox with pg_notify('calendar_sync', '') - never data in the channel", async () => { + await service.requestRun(); + + const [[strings, ...values]] = prisma.$executeRaw.mock.calls as [ + [TemplateStringsArray, ...unknown[]], + ]; + expect(strings.join('$')).toBe("SELECT pg_notify($, '')"); + expect(values).toEqual(['calendar_sync']); + }); + + it('resets before waking, so the woken worker finds the entries PENDING', async () => { + await service.requestRun(); + + const [resetOrder] = + prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder; + const [wakeOrder] = prisma.$executeRaw.mock.invocationCallOrder; + expect(resetOrder).toBeLessThan(wakeOrder); + }); + + it('leaves FAILED entries alone on a regular worker tick', async () => { await service.processOutbox(); expect( @@ -649,127 +709,7 @@ describe('CalendarSyncService', () => { ).not.toHaveBeenCalledWith( expect.objectContaining({ where: { status: 'FAILED' } }), ); - }); - }); - - // "Jetzt synchronisieren" must not hold the HTTP request for a whole pass - // (up to 20 entries with several Graph calls each): it only starts it. - describe('startRun', () => { - const flush = async () => { - for (let i = 0; i < 5; i++) { - await new Promise((resolve) => setImmediate(resolve)); - } - }; - /** Lets the pass hang on reading the outbox until released. */ - const holdOutbox = () => { - let release: () => void = () => undefined; - prisma.calendarSyncOutboxEntry.findMany.mockReturnValue( - new Promise((resolve) => { - release = () => resolve([]); - }), - ); - return () => release(); - }; - - it('returns while the pass is still running and reports it as running', async () => { - const release = holdOutbox(); - - service.startRun(); - await flush(); - - expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({ - where: { status: 'FAILED' }, - data: { status: 'PENDING', attempts: 0, lastError: null }, - }); - expect((await service.getStatus()).running).toBe(true); - - release(); - await flush(); - - expect((await service.getStatus()).running).toBe(false); - }); - - it('starts only one pass per pod at a time', async () => { - const release = holdOutbox(); - - service.startRun(); - service.startRun(); - await flush(); - - expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1); - release(); - await flush(); - }); - - it('logs a failed pass without query values and clears the indicator', async () => { - const errorSpy = jest - .spyOn(Logger.prototype, 'error') - .mockImplementation(() => undefined); - prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue( - new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', { - code: 'P2010', - clientVersion: '7.9.1', - meta: { modelName: 'CalendarSyncOutboxEntry' }, - }), - ); - - service.startRun(); - await flush(); - - expect(errorSpy).toHaveBeenCalledTimes(1); - const [message, stack] = errorSpy.mock.calls[0] as [string, unknown]; - expect(message).toContain('P2010'); - expect(message).not.toContain('Mustermann'); - expect(stack).toBeUndefined(); - expect((await service.getStatus()).running).toBe(false); - errorSpy.mockRestore(); - }); - - it('lets shutdown wait for a running pass', async () => { - const release = holdOutbox(); - service.startRun(); - let destroyed = false; - - const shutdown = service.onModuleDestroy().then(() => { - destroyed = true; - }); - await flush(); - expect(destroyed).toBe(false); - - release(); - await shutdown; - expect(destroyed).toBe(true); - }); - }); - - // The cron tick's own errors would otherwise reach the scheduler's logger - // with the full Prisma message and stack. - describe('scheduledRun (cron tick)', () => { - it('runs the regular pass', async () => { - await service.scheduledRun(); - - expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1); - }); - - it('never throws and logs a failed tick without the Prisma message', async () => { - const errorSpy = jest - .spyOn(Logger.prototype, 'error') - .mockImplementation(() => undefined); - prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue( - new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', { - code: 'P2010', - clientVersion: '7.9.1', - meta: { modelName: 'CalendarSyncOutboxEntry' }, - }), - ); - - await expect(service.scheduledRun()).resolves.toBeUndefined(); - - const [message, stack] = errorSpy.mock.calls[0] as [string, unknown]; - expect(message).toContain('P2010'); - expect(message).not.toContain('Mustermann'); - expect(stack).toBeUndefined(); - errorSpy.mockRestore(); + expect(prisma.$executeRaw).not.toHaveBeenCalled(); }); }); @@ -794,8 +734,8 @@ describe('CalendarSyncService', () => { }); }); - // Another pod (its cron tick or its "Jetzt synchronisieren") may be the - // one working - its live claims show that across replicas. + // The pass runs in a worker process, never in the API that answers - + // only the worker's live claims can show it, across replicas. it('reports running while any worker holds an entry', async () => { prisma.calendarSyncOutboxEntry.count .mockResolvedValueOnce(0) diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts index 2c11cc2..f0d91f3 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts @@ -1,9 +1,9 @@ import { createHash } from 'node:crypto'; -import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; +import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { runAfterCommit } from '../common/after-commit'; import { loggableError } from '../common/loggable-error'; +import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { GraphClientService, @@ -17,8 +17,8 @@ import { Prisma, } from '../../generated/prisma'; -// Bounded per tick so a large backlog can't turn one cron run into a -// long-running request that overlaps the next tick. +// Bounded per tick so a large backlog can't turn one worker tick into a +// long-running pass that delays the other wake-ups. const BATCH_SIZE = 20; // A row that keeps failing this many times stops retrying automatically // (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue @@ -73,7 +73,7 @@ export interface CalendarSyncStatusReport { pendingCount: number; failedCount: number; syncedCount: number; - /** A pass is under way - on this pod, or any pod holding an entry. */ + /** A pass is under way: some worker holds an entry (a live claim). */ running: boolean; } @@ -82,10 +82,8 @@ function getErrorMessage(error: unknown): string { } @Injectable() -export class CalendarSyncService implements OnModuleDestroy { +export class CalendarSyncService { private readonly logger = new Logger(CalendarSyncService.name); - // The pass started by "Jetzt synchronisieren" on this pod, if one runs. - private activeRun: Promise | null = null; constructor( private readonly prisma: PrismaService, @@ -209,20 +207,15 @@ export class CalendarSyncService implements OnModuleDestroy { } } - // The cron tick. An error is logged here, Prisma errors by name/code/model - // only (see loggableError) - left uncaught, the scheduler would log the - // full Prisma message and stack. - @Cron(CronExpression.EVERY_MINUTE) - async scheduledRun(): Promise { - try { - await this.processOutbox(); - } catch (error: unknown) { - const { message, stack } = loggableError(error); - this.logger.error(`Calendar sync tick failed: ${message}`, stack); + // One pass, run by the worker's calendar-outbox loop (worker/ + // worker-runtime.service.ts): every 60 s and on a calendar_sync wake-up. + // The loop logs a failed pass via loggableError. Once `signal` aborts + // (worker shutdown), no further entry is claimed; the entry in progress + // is finished, since a cut-off one waits for the stale-claim window. + async processOutbox(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return; } - } - - async processOutbox(): Promise { const settings = await this.settingsService.get(); if ( !settings.enabled || @@ -232,15 +225,18 @@ export class CalendarSyncService implements OnModuleDestroy { return; } - // Several workers may run at once - one cron tick per pod, plus "Jetzt - // synchronisieren". Each entry is claimed right before it is written, - // so exactly one of them talks to Graph about it. + // Several workers may run at once - one loop per worker pod. Each entry + // is claimed right before it is written, so exactly one of them talks + // to Graph about it. const candidates = await this.prisma.calendarSyncOutboxEntry.findMany({ where: { status: 'PENDING', ...claimableNow() }, orderBy: { updatedAt: 'asc' }, take: BATCH_SIZE, }); for (const candidate of candidates) { + if (signal?.aborted) { + return; + } const claimed = await this.claim(candidate); if (claimed) { await this.processEntry(claimed, settings.groupId); @@ -248,41 +244,19 @@ export class CalendarSyncService implements OnModuleDestroy { } } - // "Jetzt synchronisieren" also gives FAILED entries a fresh set of - // attempts - besides editing the source record the documented way out of - // FAILED. A FAILED row is never claimed (settle() clears the claim with - // it), so this cannot pull a row from under a running worker. The regular - // cron tick leaves FAILED alone. - async runNow(): Promise { + // "Jetzt synchronisieren": gives FAILED entries a fresh set of attempts - + // besides editing the source record the documented way out of FAILED - + // and wakes the worker, which runs the pass. The API never calls Graph + // for it, so the request returns at once. A FAILED row is never claimed + // (settle() clears the claim with it), so this cannot pull a row from + // under a running worker. The regular tick leaves FAILED alone. Reset + // first: the woken worker must find the entries PENDING. + async requestRun(): Promise { await this.prisma.calendarSyncOutboxEntry.updateMany({ where: { status: 'FAILED' }, data: { status: 'PENDING', attempts: 0, lastError: null }, }); - await this.processOutbox(); - } - - // What the API's "Jetzt synchronisieren" calls: starts runNow() in the - // background and returns at once - a pass over up to BATCH_SIZE entries - // with several Graph calls each must not hold the HTTP request. One such - // pass per pod at a time; passes on other pods (and the cron tick) stay - // safe through the per-entry claims. getStatus().running follows it. - startRun(): void { - if (!this.activeRun) { - this.activeRun = this.runNow() - .catch((error: unknown) => { - const { message, stack } = loggableError(error); - this.logger.error(`Calendar sync run failed: ${message}`, stack); - }) - .finally(() => { - this.activeRun = null; - }); - } - } - - // A pass cut off mid-entry would only be retaken after STALE_CLAIM_MS - - // shutdown lets it finish (each Graph request is bounded by its timeout). - async onModuleDestroy(): Promise { - await this.activeRun; + await notifyWorker(this.prisma, WAKE_CHANNELS.calendarSync); } // Compare-and-set on the row as it was read: the update only matches while @@ -534,7 +508,8 @@ export class CalendarSyncService implements OnModuleDestroy { this.prisma.calendarSyncOutboxEntry.count({ where: { status: 'SYNCED' }, }), - // Live claims: some worker is writing an entry right now. + // Live claims: some worker is writing an entry right now. The pass + // runs in a worker process, so nothing local can tell. this.prisma.calendarSyncOutboxEntry.count({ where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } }, }), @@ -546,7 +521,7 @@ export class CalendarSyncService implements OnModuleDestroy { pendingCount, failedCount, syncedCount, - running: this.activeRun !== null || heldCount > 0, + running: heldCount > 0, }; } } diff --git a/ApiCore-Backend/src/common/crypto/key-ring.spec.ts b/ApiCore-Backend/src/common/crypto/key-ring.spec.ts new file mode 100644 index 0000000..2e16f10 --- /dev/null +++ b/ApiCore-Backend/src/common/crypto/key-ring.spec.ts @@ -0,0 +1,370 @@ +import { Logger } from '@nestjs/common'; +import { createHash, hkdfSync } from 'node:crypto'; +import { + KEY_ID_PATTERN, + isSealedWithCurrentKey, + legacyJwtKey, + loadKeyRing, + openLegacyCiphertext, + openWithRing, + readRingSecrets, + sealWithRing, +} from './key-ring'; + +const JWT_SECRET = 'j'.repeat(40); +const KEY_A = 'a'.repeat(40); +const KEY_B = 'b'.repeat(40); + +// Written by the code before the key ring: AES-256-GCM with +// sha256(JWT_ACCESS_SECRET) as key, no key id. JWT_ACCESS_SECRET = 'x' * 40. +const LEGACY_JWT_SECRET = 'x'.repeat(40); +const LEGACY_CIPHERTEXT = + 'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ=='; +const LEGACY_PLAINTEXT = 'JBSWY3DPEHPK3PXP'; + +function expectedKeyId(secret: string, purpose: string): string { + const key = Buffer.from( + hkdfSync('sha256', secret, 'apicore-key-ring', `apicore-${purpose}-v1`, 32), + ); + return `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`; +} + +/** Applies `change` to one base64 part of `:..`. */ +function tamper( + stored: string, + part: 0 | 1 | 2, + change: (bytes: Buffer) => Buffer, +): string { + const [keyId, body] = stored.split(':'); + const parts = body.split('.'); + parts[part] = change(Buffer.from(parts[part], 'base64')).toString('base64'); + return `${keyId}:${parts.join('.')}`; +} + +function flipFirstByte(bytes: Buffer): Buffer { + const copy = Buffer.from(bytes); + copy[0] ^= 0xff; + return copy; +} + +describe('key-ring', () => { + beforeEach(() => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('loadKeyRing', () => { + it('derives the key per purpose with HKDF and names it by its hash', () => { + const ring = loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A }); + + expect(ring.current.id).toBe(expectedKeyId(KEY_A, 'mfa')); + expect(ring.current.key).toHaveLength(32); + expect(ring.previous).toBeNull(); + }); + + it('derives different keys for mfa and webhook from the same dev fallback', () => { + const env = { JWT_ACCESS_SECRET: JWT_SECRET }; + const mfa = loadKeyRing('mfa', env); + const webhook = loadKeyRing('webhook', env); + + expect(mfa.current.id).toBe(expectedKeyId(JWT_SECRET, 'mfa')); + expect(webhook.current.id).toBe(expectedKeyId(JWT_SECRET, 'webhook')); + expect(mfa.current.key.equals(webhook.current.key)).toBe(false); + expect(mfa.current.key.equals(legacyJwtKey(env))).toBe(false); + }); + + it('loads the previous key from *_PREVIOUS', () => { + const ring = loadKeyRing('webhook', { + WEBHOOK_ENCRYPTION_KEY: KEY_B, + WEBHOOK_ENCRYPTION_KEY_PREVIOUS: KEY_A, + }); + + expect(ring.current.id).toBe(expectedKeyId(KEY_B, 'webhook')); + expect(ring.previous?.id).toBe(expectedKeyId(KEY_A, 'webhook')); + }); + + it('requires the key in production instead of falling back', () => { + expect(() => + loadKeyRing('webhook', { + NODE_ENV: 'production', + JWT_ACCESS_SECRET: JWT_SECRET, + }), + ).toThrow('WEBHOOK_ENCRYPTION_KEY is not set'); + }); + + it('refuses keys shorter than 32 characters, the previous one too', () => { + expect(() => loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: 'short' })).toThrow( + 'MFA_ENCRYPTION_KEY must be at least 32 characters', + ); + expect(() => + loadKeyRing('mfa', { + MFA_ENCRYPTION_KEY: KEY_A, + MFA_ENCRYPTION_KEY_PREVIOUS: 'short', + }), + ).toThrow('MFA_ENCRYPTION_KEY_PREVIOUS must be at least 32 characters'); + }); + + it('needs JWT_ACCESS_SECRET for the dev fallback', () => { + expect(() => loadKeyRing('mfa', {})).toThrow( + 'JWT_ACCESS_SECRET is not set', + ); + }); + }); + + describe('readRingSecrets', () => { + it('returns the configured secrets of one variable', () => { + expect( + readRingSecrets('BACKUP_ENCRYPTION_KEY', { + BACKUP_ENCRYPTION_KEY: KEY_A, + BACKUP_ENCRYPTION_KEY_PREVIOUS: KEY_B, + }), + ).toEqual({ current: KEY_A, previous: KEY_B }); + expect(readRingSecrets('BACKUP_ENCRYPTION_KEY', {})).toEqual({ + current: null, + previous: null, + }); + }); + + it('never puts the key into its errors', () => { + const shortKey = 'too-short-but-secret'; + let message = ''; + try { + readRingSecrets('BACKUP_ENCRYPTION_KEY', { + BACKUP_ENCRYPTION_KEY: shortKey, + }); + } catch (error) { + message = (error as Error).message; + } + + expect(message).toContain('BACKUP_ENCRYPTION_KEY'); + expect(message).not.toContain(shortKey); + }); + }); + + describe('sealWithRing / openWithRing', () => { + const ring = loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A }); + + it('seals with the current key and prefixes its id', () => { + const stored = sealWithRing(ring, 's'); + + expect(stored).toMatch(KEY_ID_PATTERN); + expect(stored.startsWith(`${ring.current.id}:`)).toBe(true); + }); + + it('round-trips a value and reports the key it was sealed with', () => { + const stored = sealWithRing(ring, LEGACY_PLAINTEXT); + + expect(stored).not.toContain(LEGACY_PLAINTEXT); + expect(openWithRing(ring, stored)).toEqual({ + plaintext: LEGACY_PLAINTEXT, + keyId: ring.current.id, + }); + }); + + it('uses a fresh IV for every value', () => { + expect(sealWithRing(ring, 's')).not.toBe(sealWithRing(ring, 's')); + }); + + it('opens a value sealed with the previous key', () => { + const stored = sealWithRing(ring, 'secret'); + const rotated = loadKeyRing('mfa', { + MFA_ENCRYPTION_KEY: KEY_B, + MFA_ENCRYPTION_KEY_PREVIOUS: KEY_A, + }); + + expect(openWithRing(rotated, stored)).toEqual({ + plaintext: 'secret', + keyId: ring.current.id, + }); + }); + + it('tells whether a value is sealed with the current key', () => { + const rotated = loadKeyRing('mfa', { + MFA_ENCRYPTION_KEY: KEY_B, + MFA_ENCRYPTION_KEY_PREVIOUS: KEY_A, + }); + + expect(isSealedWithCurrentKey(rotated, sealWithRing(ring, 's'))).toBe( + false, + ); + expect(isSealedWithCurrentKey(rotated, sealWithRing(rotated, 's'))).toBe( + true, + ); + expect(isSealedWithCurrentKey(rotated, LEGACY_CIPHERTEXT)).toBe(false); + }); + + it('refuses an unknown key id', () => { + expect(() => openWithRing(ring, 'k00000000:a.b.c')).toThrow( + 'unknown key id', + ); + }); + + it('refuses a value sealed for another purpose', () => { + const webhookRing = loadKeyRing('webhook', { + WEBHOOK_ENCRYPTION_KEY: KEY_A, + }); + + expect(() => + openWithRing(webhookRing, sealWithRing(ring, 'secret')), + ).toThrow('unknown key id'); + }); + + it('fails on a tampered ciphertext (GCM)', () => { + const stored = sealWithRing(ring, 'secret'); + + for (const part of [0, 1, 2] as const) { + expect(() => + openWithRing(ring, tamper(stored, part, flipFirstByte)), + ).toThrow('ciphertext failed authentication'); + } + }); + + it('refuses a truncated auth tag', () => { + const stored = sealWithRing(ring, 'secret'); + const truncated = tamper(stored, 1, (tag) => tag.subarray(0, 4)); + + expect(() => openWithRing(ring, truncated)).toThrow( + 'malformed ciphertext', + ); + }); + + it('fails with the right key id but a different key', () => { + const stored = sealWithRing(ring, 'secret'); + const forged = { + current: { id: ring.current.id, key: Buffer.alloc(32, 1) }, + previous: null, + }; + + expect(() => openWithRing(forged, stored)).toThrow( + 'ciphertext failed authentication', + ); + }); + + it('refuses values without key id or with a malformed body', () => { + expect(() => openWithRing(ring, LEGACY_CIPHERTEXT)).toThrow( + 'missing key id', + ); + expect(() => openWithRing(ring, `${ring.current.id}:abc`)).toThrow( + 'malformed ciphertext', + ); + }); + + it('never puts key or plaintext into its errors', () => { + const stored = sealWithRing(ring, 'plaintext-value'); + const tampered = tamper(stored, 2, flipFirstByte); + let message = ''; + try { + openWithRing(ring, tampered); + } catch (error) { + message = (error as Error).message; + } + + expect(message).not.toBe(''); + expect(message).not.toContain('plaintext-value'); + expect(message).not.toContain(KEY_A); + expect(message).not.toContain(ring.current.key.toString('base64')); + }); + }); + + describe('development fallback', () => { + it('warns once per purpose that a development key stands in, without values', () => { + jest.isolateModules(() => { + // A fresh module copy, so earlier tests cannot have used up the warning. + const nest = + jest.requireActual('@nestjs/common'); + const fresh = + jest.requireActual('./key-ring'); + const warn = jest + .spyOn(nest.Logger.prototype, 'warn') + .mockImplementation(() => undefined); + const env = { JWT_ACCESS_SECRET: JWT_SECRET }; + + fresh.loadKeyRing('mfa', env); + fresh.loadKeyRing('mfa', env); + fresh.loadKeyRing('webhook', env); + fresh.loadKeyRing('webhook', { ...env, WEBHOOK_ENCRYPTION_KEY: KEY_A }); + + expect(warn).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenNthCalledWith( + 1, + 'MFA_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production', + ); + expect(warn).toHaveBeenNthCalledWith( + 2, + 'WEBHOOK_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production', + ); + }); + }); + + it('stays silent when the key is configured', () => { + jest.isolateModules(() => { + const nest = + jest.requireActual('@nestjs/common'); + const fresh = + jest.requireActual('./key-ring'); + const warn = jest + .spyOn(nest.Logger.prototype, 'warn') + .mockImplementation(() => undefined); + + fresh.loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A }); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + }); + + describe('legacy ciphertexts', () => { + const legacyEnv = { JWT_ACCESS_SECRET: LEGACY_JWT_SECRET }; + + it('takes sha256(JWT_ACCESS_SECRET) as the legacy key', () => { + expect(legacyJwtKey(legacyEnv)).toEqual( + createHash('sha256').update(LEGACY_JWT_SECRET).digest(), + ); + }); + + it('still opens a value sealed before the key ring', () => { + expect(openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv)).toBe( + LEGACY_PLAINTEXT, + ); + }); + + it('fails closed once JWT_ACCESS_SECRET changed', () => { + expect(() => + openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, { + JWT_ACCESS_SECRET: JWT_SECRET, + }), + ).toThrow('ciphertext failed authentication'); + }); + + it('warns once per process and purpose, without values', () => { + jest.isolateModules(() => { + // A fresh module copy, so earlier tests cannot have used up the warning. + const nest = + jest.requireActual('@nestjs/common'); + const fresh = + jest.requireActual('./key-ring'); + const warn = jest + .spyOn(nest.Logger.prototype, 'warn') + .mockImplementation(() => undefined); + + fresh.openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv); + fresh.openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv); + fresh.warnLegacyRead('webhook'); + fresh.warnLegacyRead('webhook'); + + expect(warn).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenNthCalledWith( + 1, + 'legacy ciphertext read – run the rekey job (mfa)', + ); + expect(warn).toHaveBeenNthCalledWith( + 2, + 'legacy ciphertext read – run the rekey job (webhook)', + ); + }); + }); + }); +}); diff --git a/ApiCore-Backend/src/common/crypto/key-ring.ts b/ApiCore-Backend/src/common/crypto/key-ring.ts new file mode 100644 index 0000000..1ba5235 --- /dev/null +++ b/ApiCore-Backend/src/common/crypto/key-ring.ts @@ -0,0 +1,286 @@ +import { Logger } from '@nestjs/common'; +import { + createCipheriv, + createDecipheriv, + createHash, + hkdfSync, + randomBytes, +} from 'node:crypto'; + +/** + * Secrets the server must read back (TOTP secrets, webhook signing secrets, + * backup files) are encrypted with a key of their own per purpose, never + * with a JWT secret. Each purpose has a key ring: the current key from + * `` seals, the previous one from `_PREVIOUS` still + * opens, so a key can be rotated without losing data. A sealed value names + * its key: `k<8 hex>:..` (base64 parts, AES-256-GCM). + * + * The AES key comes from HKDF-SHA256 with a per-purpose label, so even the + * same secret never yields the same key for two purposes. Outside + * production a missing variable falls back to JWT_ACCESS_SECRET (still per + * purpose, logged once); production refuses to start without the variables + * (runtime/startup-checks.ts). + * + * Values from before the key ring carry no key id and were encrypted with + * sha256(JWT_ACCESS_SECRET). They stay readable (never written) until the + * rekey job reseals them - and only as long as JWT_ACCESS_SECRET is unchanged. + */ +export const MIN_ENCRYPTION_KEY_LENGTH = 32; +export const KEY_ID_PATTERN = /^k[0-9a-f]{8}:/; +export const PREVIOUS_KEY_SUFFIX = '_PREVIOUS'; + +export const ENCRYPTION_KEY_VARIABLES = { + mfa: 'MFA_ENCRYPTION_KEY', + webhook: 'WEBHOOK_ENCRYPTION_KEY', + backup: 'BACKUP_ENCRYPTION_KEY', +} as const; + +export type EncryptionKeyVariable = + (typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES]; + +/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */ +export type SecretPurpose = 'mfa' | 'webhook'; + +export interface RingKey { + id: string; + key: Buffer; +} + +export interface KeyRing { + current: RingKey; + previous: RingKey | null; +} + +/** The configured secrets behind one ring, before key derivation. */ +export interface RingSecrets { + current: string | null; + previous: string | null; +} + +/** AES-256-GCM output, in parts, so each format chooses its own encoding. */ +export interface GcmSealed { + iv: Buffer; + tag: Buffer; + ciphertext: Buffer; +} + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; +const AUTH_TAG_BYTES = 16; +const KEY_BYTES = 32; +const HKDF_SALT = 'apicore-key-ring'; +const LEGACY_READ_WARNING = 'legacy ciphertext read – run the rekey job'; +const DEV_FALLBACK_WARNING = + 'not set – using development key derived from JWT_ACCESS_SECRET; never in production'; + +const logger = new Logger('KeyRing'); +const issuedWarnings = new Set(); + +/** Each warning once per process: they describe configuration, not events. */ +function warnOnce(message: string): void { + if (issuedWarnings.has(message)) { + return; + } + issuedWarnings.add(message); + logger.warn(message); +} + +function readKey(name: string, env: NodeJS.ProcessEnv): string | null { + const value = env[name]; + if (!value) { + return null; + } + if (value.length < MIN_ENCRYPTION_KEY_LENGTH) { + throw new Error( + `${name} must be at least ${MIN_ENCRYPTION_KEY_LENGTH} characters - generate one with e.g. "openssl rand -hex 32"`, + ); + } + return value; +} + +/** + * `` and `_PREVIOUS`, each at least 32 characters when + * set. The current one is mandatory in production. Errors name the + * variable, never its value. + */ +export function readRingSecrets( + variable: EncryptionKeyVariable, + env: NodeJS.ProcessEnv = process.env, +): RingSecrets { + const current = readKey(variable, env); + const previous = readKey(`${variable}${PREVIOUS_KEY_SUFFIX}`, env); + if (!current && env.NODE_ENV === 'production') { + throw new Error(`${variable} is not set`); + } + return { current, previous }; +} + +export function requireJwtAccessSecret( + env: NodeJS.ProcessEnv = process.env, +): string { + const secret = env.JWT_ACCESS_SECRET; + if (!secret) { + throw new Error('JWT_ACCESS_SECRET is not set'); + } + return secret; +} + +/** + * The stand-in secret for an unset `variable` outside production (in + * production readRingSecrets() has already refused). Logged once per + * variable, without values. + */ +export function devFallbackSecret( + variable: EncryptionKeyVariable, + env: NodeJS.ProcessEnv = process.env, +): string { + const secret = requireJwtAccessSecret(env); + warnOnce(`${variable} ${DEV_FALLBACK_WARNING}`); + return secret; +} + +function deriveRingKey(purpose: SecretPurpose, secret: string): RingKey { + const key = Buffer.from( + hkdfSync('sha256', secret, HKDF_SALT, `apicore-${purpose}-v1`, KEY_BYTES), + ); + const id = `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`; + return { id, key }; +} + +/** The ring of one purpose; outside production a missing key falls back to JWT_ACCESS_SECRET. */ +export function loadKeyRing( + purpose: SecretPurpose, + env: NodeJS.ProcessEnv = process.env, +): KeyRing { + const variable = ENCRYPTION_KEY_VARIABLES[purpose]; + const secrets = readRingSecrets(variable, env); + return { + current: deriveRingKey( + purpose, + secrets.current ?? devFallbackSecret(variable, env), + ), + previous: secrets.previous + ? deriveRingKey(purpose, secrets.previous) + : null, + }; +} + +export function findRingKey(ring: KeyRing, keyId: string): RingKey | null { + if (ring.current.id === keyId) { + return ring.current; + } + return ring.previous?.id === keyId ? ring.previous : null; +} + +export function gcmSeal( + key: Buffer, + plaintext: Buffer, + associatedData?: Buffer, +): GcmSealed { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, key, iv, { + authTagLength: AUTH_TAG_BYTES, + }); + if (associatedData) { + cipher.setAAD(associatedData); + } + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return { iv, tag: cipher.getAuthTag(), ciphertext }; +} + +/** + * Fails closed: a short tag (which GCM would otherwise accept), a wrong key + * or any changed byte throws, and the error carries no input. + */ +export function gcmOpen( + key: Buffer, + sealed: GcmSealed, + associatedData?: Buffer, +): Buffer { + if (sealed.iv.length !== IV_BYTES || sealed.tag.length !== AUTH_TAG_BYTES) { + throw new Error('malformed ciphertext'); + } + try { + const decipher = createDecipheriv(ALGORITHM, key, sealed.iv, { + authTagLength: AUTH_TAG_BYTES, + }); + if (associatedData) { + decipher.setAAD(associatedData); + } + decipher.setAuthTag(sealed.tag); + return Buffer.concat([ + decipher.update(sealed.ciphertext), + decipher.final(), + ]); + } catch { + throw new Error('ciphertext failed authentication'); + } +} + +function sealPayload(key: Buffer, plaintext: string): string { + const sealed = gcmSeal(key, Buffer.from(plaintext, 'utf8')); + return [sealed.iv, sealed.tag, sealed.ciphertext] + .map((part) => part.toString('base64')) + .join('.'); +} + +function openPayload(key: Buffer, payload: string): string { + const parts = payload.split('.'); + if (parts.length !== 3) { + throw new Error('malformed ciphertext'); + } + const [iv, tag, ciphertext] = parts.map((part) => + Buffer.from(part, 'base64'), + ); + return gcmOpen(key, { iv, tag, ciphertext }).toString('utf8'); +} + +/** `${id}:${iv}.${tag}.${data}`, always with the current key. */ +export function sealWithRing(ring: KeyRing, plaintext: string): string { + return `${ring.current.id}:${sealPayload(ring.current.key, plaintext)}`; +} + +export function openWithRing( + ring: KeyRing, + stored: string, +): { plaintext: string; keyId: string } { + if (!KEY_ID_PATTERN.test(stored)) { + throw new Error('missing key id'); + } + const separator = stored.indexOf(':'); + const keyId = stored.slice(0, separator); + const ringKey = findRingKey(ring, keyId); + if (!ringKey) { + throw new Error('unknown key id'); + } + return { + plaintext: openPayload(ringKey.key, stored.slice(separator + 1)), + keyId, + }; +} + +/** False for anything the rekey job should reseal: previous key, legacy or plain values. */ +export function isSealedWithCurrentKey(ring: KeyRing, stored: string): boolean { + return stored.startsWith(`${ring.current.id}:`); +} + +/** sha256(JWT_ACCESS_SECRET), the key of values from before the key ring - only ever used to read. */ +export function legacyJwtKey(env: NodeJS.ProcessEnv = process.env): Buffer { + return createHash('sha256').update(requireJwtAccessSecret(env)).digest(); +} + +/** Logs once per process and purpose that a pre-key-ring value was read, without the value. */ +export function warnLegacyRead(purpose: SecretPurpose): void { + warnOnce(`${LEGACY_READ_WARNING} (${purpose})`); +} + +/** Opens a pre-key-ring `..` value sealed with legacyJwtKey(). */ +export function openLegacyCiphertext( + purpose: SecretPurpose, + payload: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const plaintext = openPayload(legacyJwtKey(env), payload); + warnLegacyRead(purpose); + return plaintext; +} diff --git a/ApiCore-Backend/src/common/middleware/no-store.middleware.spec.ts b/ApiCore-Backend/src/common/middleware/no-store.middleware.spec.ts new file mode 100644 index 0000000..02609a2 --- /dev/null +++ b/ApiCore-Backend/src/common/middleware/no-store.middleware.spec.ts @@ -0,0 +1,26 @@ +import type { NextFunction, Request, Response } from 'express'; +import { noStoreMiddleware } from './no-store.middleware'; + +function run(path: string) { + const setHeader = jest.fn(); + const next = jest.fn() as unknown as NextFunction; + noStoreMiddleware( + { path } as Request, + { setHeader } as unknown as Response, + next, + ); + return { setHeader, next }; +} + +describe('noStoreMiddleware', () => { + it.each(['/api/v1/apiaries', '/api/v1/public/batches/WH-1'])( + 'marks %s no-store', + (path) => { + const { setHeader, next } = run(path); + + expect(setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(setHeader).toHaveBeenCalledWith('Pragma', 'no-cache'); + expect(next).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/ApiCore-Backend/src/common/middleware/no-store.middleware.ts b/ApiCore-Backend/src/common/middleware/no-store.middleware.ts index 9e7b474..75e2276 100644 --- a/ApiCore-Backend/src/common/middleware/no-store.middleware.ts +++ b/ApiCore-Backend/src/common/middleware/no-store.middleware.ts @@ -1,22 +1,17 @@ import type { NextFunction, Request, Response } from 'express'; -// The public origin pages of honey lots are the only answers meant for -// everyone; they may be cached briefly. -const CACHEABLE_PREFIX = '/api/v1/public/'; - /** * Personal data, tokens and exports must not end up in a browser's - * back/forward cache or a shared proxy. Every API answer is no-store unless - * it is one of the public, anonymous ones. + * back/forward cache or a shared proxy. Every API answer starts out no-store; + * the one anonymous origin page overrides this on its 200 only (see + * PublicController). */ export function noStoreMiddleware( - req: Request, + _req: Request, res: Response, next: NextFunction, ): void { - if (!req.path.startsWith(CACHEABLE_PREFIX)) { - res.setHeader('Cache-Control', 'no-store'); - res.setHeader('Pragma', 'no-cache'); - } + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Pragma', 'no-cache'); next(); } diff --git a/ApiCore-Backend/src/common/outbox/outbox-claims.ts b/ApiCore-Backend/src/common/outbox/outbox-claims.ts new file mode 100644 index 0000000..2153c85 --- /dev/null +++ b/ApiCore-Backend/src/common/outbox/outbox-claims.ts @@ -0,0 +1,204 @@ +import { Prisma } from '../../../generated/prisma'; +import { DbClient } from '../../prisma/db-client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy'; + +/** + * Claim, settle and stale release for every outbox table. One worker owns + * an entry from the claim (status PROCESSING, claimedAt = now()) until it + * settles it; settling is a compare-and-set on id + claimedAt, so a worker + * whose claim was released in the meantime changes nothing. Every time + * comparison and calculation runs in SQL against now() - the database + * clock - never against a JS Date. + */ +export type OutboxTable = 'DomainEvent' | 'WebhookDelivery'; + +export type OutboxOrderColumn = 'occurredAt' | 'createdAt'; + +export interface ClaimOptions { + limit: number; + /** Extra condition, ANDed to the claim (e.g. a correlation id filter). */ + filter?: Prisma.Sql; + orderBy: OutboxOrderColumn; +} + +interface OutboxTableSql { + table: Prisma.Sql; + statusType: Prisma.Sql; + settledAtColumn: Prisma.Sql; + successStatus: 'DONE' | 'DELIVERED'; +} + +/** + * Table, enum and column names cannot be bound as parameters. They come + * only from these closed maps - never from caller input - which is what + * makes Prisma.raw safe here. + */ +const OUTBOX_TABLES: Readonly> = { + DomainEvent: { + table: Prisma.raw('"DomainEvent"'), + statusType: Prisma.raw('"DomainEventStatus"'), + settledAtColumn: Prisma.raw('"processedAt"'), + successStatus: 'DONE', + }, + WebhookDelivery: { + table: Prisma.raw('"WebhookDelivery"'), + statusType: Prisma.raw('"WebhookDeliveryStatus"'), + settledAtColumn: Prisma.raw('"deliveredAt"'), + successStatus: 'DELIVERED', + }, +}; + +const ORDER_COLUMNS: Readonly> = { + occurredAt: Prisma.raw('"occurredAt"'), + createdAt: Prisma.raw('"createdAt"'), +}; + +const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)'; + +function outboxTable(table: OutboxTable): OutboxTableSql { + if (!Object.hasOwn(OUTBOX_TABLES, table)) { + throw new Error(`Unknown outbox table "${String(table)}"`); + } + return OUTBOX_TABLES[table]; +} + +function orderColumn(column: OutboxOrderColumn): Prisma.Sql { + if (!Object.hasOwn(ORDER_COLUMNS, column)) { + throw new Error(`Unknown outbox order column "${String(column)}"`); + } + return ORDER_COLUMNS[column]; +} + +/** + * Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets + * several workers claim side by side without waiting for or overlapping + * each other. Returns the claimed rows (all columns) in claim order. + * Uses the root client: a claim must commit on its own. + */ +export async function claimBatch( + db: PrismaService, + table: OutboxTable, + options: ClaimOptions, +): Promise { + if (!Number.isInteger(options.limit) || options.limit < 1) { + throw new RangeError( + `Claim limit must be a positive integer, got ${options.limit}`, + ); + } + const { table: name } = outboxTable(table); + const orderBy = orderColumn(options.orderBy); + const filter = options.filter + ? Prisma.sql`AND ${options.filter}` + : Prisma.empty; + return db.$queryRaw(Prisma.sql` + WITH claimed AS ( + UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now() + WHERE "id" IN ( + SELECT "id" FROM ${name} + WHERE "status" = 'PENDING' AND "availableAt" <= now() ${filter} + ORDER BY ${orderBy} + LIMIT ${options.limit} + FOR UPDATE SKIP LOCKED + ) + RETURNING * + ) + SELECT * FROM claimed ORDER BY ${orderBy}`); +} + +/** An entry a stale release put back (PENDING) or gave up on (FAILED). */ +export interface ReleasedClaim { + id: string; + status: 'PENDING' | 'FAILED'; +} + +/** + * Puts entries whose claim is older than STALE_CLAIM_MS (their worker died) + * back to PENDING right away, with attempts + 1 - or to FAILED once that + * reaches MAX_ATTEMPTS, so an entry that kills its worker cannot loop + * forever. Returns the released entries, for follow-up work on FAILED ones. + */ +export async function releaseStaleClaimRows( + db: PrismaService, + table: OutboxTable, +): Promise { + const { table: name, statusType } = outboxTable(table); + return db.$queryRaw(Prisma.sql` + UPDATE ${name} + SET "status" = CAST(CASE WHEN "attempts" + 1 >= ${MAX_ATTEMPTS} THEN 'FAILED' ELSE 'PENDING' END AS ${statusType}), + "attempts" = "attempts" + 1, + "claimedAt" = NULL, + "availableAt" = now(), + "lastError" = ${STALE_CLAIM_ERROR} + WHERE "status" = 'PROCESSING' + AND "claimedAt" < now() - (${STALE_CLAIM_MS} * interval '1 millisecond') + RETURNING "id", "status"`); +} + +/** releaseStaleClaimRows for callers that only need the count. */ +export async function releaseStaleClaims( + db: PrismaService, + table: OutboxTable, +): Promise { + return (await releaseStaleClaimRows(db, table)).length; +} + +/** + * Settles a claimed entry as done. Returns false if the claim was lost + * (released as stale, maybe claimed again) - the current owner settles it. + * lastError stays as history of earlier failures. + */ +export async function settleSuccess( + db: DbClient, + table: OutboxTable, + id: string, + claimedAt: Date, + status: 'DONE' | 'DELIVERED', +): Promise { + const sql = outboxTable(table); + if (status !== sql.successStatus) { + throw new Error(`${table} settles as ${sql.successStatus}, not ${status}`); + } + const count = await db.$executeRaw(Prisma.sql` + UPDATE ${sql.table} + SET "status" = CAST(${status} AS ${sql.statusType}), + "claimedAt" = NULL, + ${sql.settledAtColumn} = now() + WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`); + return count === 1; +} + +/** + * Settles a claimed entry after a failed attempt: back to PENDING after + * retryDelayMs(attempts) by the database clock, or FAILED once attempts + * reaches MAX_ATTEMPTS. `lastError` must come from loggableError(). + * + * The outcome also holds if the claim was lost: only a stale release + * changes a claimed row, and it applies the same attempts + 1 >= + * MAX_ATTEMPTS rule to the same attempt count. + */ +export async function settleFailure( + db: DbClient, + table: OutboxTable, + id: string, + claimedAt: Date, + attemptsBefore: number, + lastError: string, +): Promise<'retry' | 'failed'> { + const { table: name, statusType } = outboxTable(table); + const attempts = attemptsBefore + 1; + const outcome = attempts >= MAX_ATTEMPTS ? 'failed' : 'retry'; + const nextAttempt = + outcome === 'retry' + ? Prisma.sql`, "availableAt" = now() + (${retryDelayMs(attempts)} * interval '1 millisecond')` + : Prisma.empty; + await db.$executeRaw(Prisma.sql` + UPDATE ${name} + SET "status" = CAST(${outcome === 'retry' ? 'PENDING' : 'FAILED'} AS ${statusType}), + "attempts" = "attempts" + 1, + "claimedAt" = NULL, + "lastError" = ${lastError} + ${nextAttempt} + WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`); + return outcome; +} diff --git a/ApiCore-Backend/src/common/retry-policy.spec.ts b/ApiCore-Backend/src/common/retry-policy.spec.ts new file mode 100644 index 0000000..b1bf9ad --- /dev/null +++ b/ApiCore-Backend/src/common/retry-policy.spec.ts @@ -0,0 +1,29 @@ +import { + MAX_ATTEMPTS, + RETRY_DELAYS_MS, + retryDelayMs, + STALE_CLAIM_MS, +} from './retry-policy'; + +describe('retry policy', () => { + it('waits 30 s, 2 min, 10 min, 1 h and from the 5th failure on 6 h', () => { + expect([1, 2, 3, 4, 5, 6, 7].map(retryDelayMs)).toEqual([ + 30_000, 120_000, 600_000, 3_600_000, 21_600_000, 21_600_000, 21_600_000, + ]); + }); + + it('gives up after 8 attempts and treats a claim older than 5 min as stale', () => { + expect(MAX_ATTEMPTS).toBe(8); + expect(STALE_CLAIM_MS).toBe(300_000); + expect(RETRY_DELAYS_MS).toEqual([ + 30_000, 120_000, 600_000, 3_600_000, 21_600_000, + ]); + }); + + it.each([0, -1, 1.5, Number.NaN])( + 'refuses %p failed attempts - a delay exists only after a failure', + (attempts) => { + expect(() => retryDelayMs(attempts)).toThrow(RangeError); + }, + ); +}); diff --git a/ApiCore-Backend/src/common/retry-policy.ts b/ApiCore-Backend/src/common/retry-policy.ts new file mode 100644 index 0000000..6b6d866 --- /dev/null +++ b/ApiCore-Backend/src/common/retry-policy.ts @@ -0,0 +1,23 @@ +/** + * The one retry rule for every outbox (domain events, webhook deliveries): + * after the n-th failed attempt the entry waits RETRY_DELAYS_MS[n - 1], + * from the 5th failure on 6 h each; the MAX_ATTEMPTS-th failure ends in + * FAILED. A claim older than STALE_CLAIM_MS belongs to a worker that died. + * The delays are applied in SQL against now() (database clock), see + * outbox-claims.ts. + */ +export const RETRY_DELAYS_MS = [ + 30_000, 120_000, 600_000, 3_600_000, 21_600_000, +] as const; +export const MAX_ATTEMPTS = 8; +export const STALE_CLAIM_MS = 300_000; + +/** Delay after `attempts` failed attempts (1 = the first failure). */ +export function retryDelayMs(attempts: number): number { + if (!Number.isInteger(attempts) || attempts < 1) { + throw new RangeError( + `Retry delay needs a positive number of failed attempts, got ${attempts}`, + ); + } + return RETRY_DELAYS_MS[Math.min(attempts, RETRY_DELAYS_MS.length) - 1]; +} diff --git a/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.spec.ts b/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.spec.ts new file mode 100644 index 0000000..b846f76 --- /dev/null +++ b/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.spec.ts @@ -0,0 +1,108 @@ +import { PrismaService } from '../../prisma/prisma.service'; +import { PrismaThrottlerStorage } from './prisma-throttler.storage'; + +describe('PrismaThrottlerStorage', () => { + const queryRaw = jest.fn< + Promise, + [readonly string[], ...unknown[]] + >(); + const prisma = { $queryRaw: queryRaw } as unknown as PrismaService; + + const row = { + totalHits: 3, + timeToExpire: 42, + isBlocked: false, + timeToBlockExpire: 0, + }; + + const sqlText = (): string => queryRaw.mock.calls[0][0].join('?'); + const sqlValues = (): unknown[] => queryRaw.mock.calls[0].slice(1); + + beforeEach(() => { + queryRaw.mockReset(); + queryRaw.mockResolvedValue([row]); + }); + + it('uses only the database clock (no JS dates in the statement)', async () => { + await new PrismaThrottlerStorage(prisma, '').increment( + 'k', + 60000, + 10, + 60000, + 'default', + ); + + expect(sqlValues().some((v) => v instanceof Date)).toBe(false); + expect(sqlText()).toContain('now()'); + }); + + it('blocks on the very first hit when the limit is below 1', async () => { + await new PrismaThrottlerStorage(prisma, '').increment( + 'k', + 60000, + 0, + 60000, + 'default', + ); + + expect(sqlText()).toMatch(/VALUES[\s\S]*?CASE\s+WHEN 1 > /); + }); + + it('runs exactly one statement as an upsert on the composite key', async () => { + await new PrismaThrottlerStorage(prisma, '').increment( + 'k', + 60000, + 10, + 60000, + 'default', + ); + + expect(queryRaw).toHaveBeenCalledTimes(1); + expect(sqlText()).toContain('INSERT INTO "ThrottlerHit"'); + expect(sqlText()).toContain('ON CONFLICT ("key", "throttlerName")'); + expect(sqlText()).toContain('RETURNING'); + }); + + it('prefixes the stored key with the namespace and passes the limits as parameters', async () => { + await new PrismaThrottlerStorage(prisma, 'ns-1').increment( + 'abc', + 60000, + 10, + 120000, + 'default', + ); + + const values = sqlValues(); + expect(values).toContain('ns-1:abc'); + expect(values).toContain('default'); + expect(values).toContain(60000); + expect(values).toContain(10); + expect(values).toContain(120000); + }); + + it('returns the record computed by the database', async () => { + const result = await new PrismaThrottlerStorage(prisma, '').increment( + 'k', + 60000, + 10, + 60000, + 'default', + ); + + expect(result).toEqual(row); + }); + + it('propagates database errors so the request fails closed', async () => { + queryRaw.mockRejectedValue(new Error('db down')); + + await expect( + new PrismaThrottlerStorage(prisma, '').increment( + 'k', + 60000, + 10, + 60000, + 'default', + ), + ).rejects.toThrow('db down'); + }); +}); diff --git a/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.ts b/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.ts new file mode 100644 index 0000000..9e71968 --- /dev/null +++ b/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.ts @@ -0,0 +1,88 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ThrottlerStorage } from '@nestjs/throttler'; +import { PrismaService } from '../../prisma/prisma.service'; + +// The package does not export the record type from its root. +type ThrottlerStorageRecord = Awaited< + ReturnType +>; + +/** + * Key prefix that separates counter sets inside the shared table. Production + * uses '' (all pods share one set); tests give every app its own value. + */ +export const THROTTLER_NAMESPACE = Symbol('THROTTLER_NAMESPACE'); + +/** + * Rate-limit counters in the UNLOGGED table "ThrottlerHit", so the limits hold + * across all api pods. One upsert statement does the whole bookkeeping, and + * every time comparison uses the database clock (now()), never a JS date. + * A database error is not caught here: the guard fails and the request is + * rejected (fail closed). + */ +@Injectable() +export class PrismaThrottlerStorage implements ThrottlerStorage { + constructor( + private readonly prisma: PrismaService, + @Inject(THROTTLER_NAMESPACE) private readonly namespace: string, + ) {} + + async increment( + key: string, + ttl: number, + limit: number, + blockDuration: number, + throttlerName: string, + ): Promise { + const storedKey = `${this.namespace}:${key}`; + // ttl and blockDuration are milliseconds. In ON CONFLICT DO UPDATE every + // expression reads the OLD row: "active block" keeps the hits, an expired + // window or block starts again at 1, everything else counts up. + const rows = await this.prisma.$queryRaw` + INSERT INTO "ThrottlerHit" AS t ("key", "throttlerName", "hits", "expiresAt", "blockedUntil") + VALUES ( + ${storedKey}, ${throttlerName}, 1, + now() + (${ttl}::double precision * interval '1 millisecond'), + CASE + WHEN 1 > ${limit}::int + THEN now() + (${blockDuration}::double precision * interval '1 millisecond') + ELSE NULL + END + ) + ON CONFLICT ("key", "throttlerName") DO UPDATE SET + "hits" = CASE + WHEN t."blockedUntil" > now() THEN t."hits" + WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN 1 + ELSE t."hits" + 1 + END, + "expiresAt" = CASE + WHEN t."blockedUntil" > now() THEN t."expiresAt" + WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL + THEN now() + (${ttl}::double precision * interval '1 millisecond') + ELSE t."expiresAt" + END, + "blockedUntil" = CASE + WHEN t."blockedUntil" > now() THEN t."blockedUntil" + WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN + CASE + WHEN 1 > ${limit}::int + THEN now() + (${blockDuration}::double precision * interval '1 millisecond') + ELSE NULL + END + WHEN t."hits" + 1 > ${limit}::int + THEN now() + (${blockDuration}::double precision * interval '1 millisecond') + ELSE NULL + END + RETURNING + "hits"::int AS "totalHits", + GREATEST(ceil(EXTRACT(EPOCH FROM ("expiresAt" - now()))), 0)::int AS "timeToExpire", + COALESCE("blockedUntil" > now(), false) AS "isBlocked", + CASE + WHEN "blockedUntil" > now() + THEN GREATEST(ceil(EXTRACT(EPOCH FROM ("blockedUntil" - now()))), 0)::int + ELSE 0 + END AS "timeToBlockExpire" + `; + return rows[0]; + } +} diff --git a/ApiCore-Backend/src/common/throttler/throttler-storage.module.ts b/ApiCore-Backend/src/common/throttler/throttler-storage.module.ts new file mode 100644 index 0000000..07b3ac1 --- /dev/null +++ b/ApiCore-Backend/src/common/throttler/throttler-storage.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { + PrismaThrottlerStorage, + THROTTLER_NAMESPACE, +} from './prisma-throttler.storage'; + +@Module({ + providers: [ + // Production: one shared counter set for all pods. + { provide: THROTTLER_NAMESPACE, useValue: '' }, + PrismaThrottlerStorage, + ], + exports: [PrismaThrottlerStorage], +}) +export class ThrottlerStorageModule {} diff --git a/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts b/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts index 8b42ce7..06680f5 100644 --- a/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts +++ b/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts @@ -22,6 +22,7 @@ import { UpdateQueenEvaluationDto } from '../../queens/dto/update-queen-evaluati import { UpdateQueenDto } from '../../queens/dto/update-queen.dto'; import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto'; import { UpdateRoleDto } from '../../roles/dto/update-role.dto'; +import { UpdateSiteSettingsDto } from '../../site-settings/dto/update-site-settings.dto'; import { UpdateStockItemDto } from '../../storage/dto/update-stock-item.dto'; import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto'; import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto'; @@ -53,6 +54,7 @@ const contracts: Record = { 'longitude', 'address', 'notes', + 'publicLocationLabel', ], }, UpdateAutomationRuleDto: { @@ -152,6 +154,7 @@ const contracts: Record = { 'weather', 'temperature', 'colonyStrength', + 'broodFrames', 'broodAmount', 'broodPattern', 'openBroodPresent', @@ -243,6 +246,13 @@ const contracts: Record = { notNull: ['key', 'name', 'permissionKeys'], clearable: ['description'], }, + // The link fields clear with "" (see the DTO), not null; originCountry is + // NOT NULL with a default. + UpdateSiteSettingsDto: { + dto: UpdateSiteSettingsDto, + notNull: ['originCountry'], + clearable: [], + }, UpdateStockItemDto: { dto: UpdateStockItemDto, notNull: ['name', 'category', 'unit', 'quantity'], diff --git a/ApiCore-Backend/src/common/with-timeout.spec.ts b/ApiCore-Backend/src/common/with-timeout.spec.ts new file mode 100644 index 0000000..b3c73d3 --- /dev/null +++ b/ApiCore-Backend/src/common/with-timeout.spec.ts @@ -0,0 +1,37 @@ +import { withTimeout } from './with-timeout'; + +describe('withTimeout', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('resolves with the value when the promise settles in time', async () => { + await expect(withTimeout(Promise.resolve(42), 1000, 'late')).resolves.toBe( + 42, + ); + }); + + it('rejects with the original error when the promise rejects in time', async () => { + await expect( + withTimeout(Promise.reject(new Error('boom')), 1000, 'late'), + ).rejects.toThrow('boom'); + }); + + it('rejects with the given message when the promise takes too long', async () => { + jest.useFakeTimers(); + const pending = withTimeout( + new Promise(() => undefined), + 500, + 'too slow', + ); + const assertion = expect(pending).rejects.toThrow('too slow'); + await jest.advanceTimersByTimeAsync(500); + await assertion; + }); + + it('clears its timer once the promise settled', async () => { + jest.useFakeTimers(); + await withTimeout(Promise.resolve(1), 500, 'late'); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/ApiCore-Backend/src/common/with-timeout.ts b/ApiCore-Backend/src/common/with-timeout.ts new file mode 100644 index 0000000..0a7fe38 --- /dev/null +++ b/ApiCore-Backend/src/common/with-timeout.ts @@ -0,0 +1,16 @@ +/** Rejects with `message` if `promise` has not settled within `ms`. */ +export async function withTimeout( + promise: Promise, + ms: number, + message: string, +): Promise { + let timer: NodeJS.Timeout; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timer!); + } +} diff --git a/ApiCore-Backend/src/common/worker-wakeup.ts b/ApiCore-Backend/src/common/worker-wakeup.ts new file mode 100644 index 0000000..cebb7b7 --- /dev/null +++ b/ApiCore-Backend/src/common/worker-wakeup.ts @@ -0,0 +1,24 @@ +import { DbClient } from '../prisma/db-client'; + +/** LISTEN/NOTIFY channels the worker loops wait on, one per outbox. */ +export const WAKE_CHANNELS = { + domainEvents: 'domain_event', + calendarSync: 'calendar_sync', + webhookDeliveries: 'webhook_delivery', +} as const; + +export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS]; + +/** + * Wakes the worker loop behind `channel` before its next poll. The payload + * is always empty: the loop reads its work from the table, so nothing can + * leak through the channel or be slipped in as work. On a transaction + * client Postgres sends the notification only on commit - a rolled-back + * write wakes nobody. A lost notification only delays work until the poll. + */ +export async function notifyWorker( + db: DbClient, + channel: WakeChannel, +): Promise { + await db.$executeRaw`SELECT pg_notify(${channel}, '')`; +} diff --git a/ApiCore-Backend/src/documents/documents.controller.ts b/ApiCore-Backend/src/documents/documents.controller.ts index 8df2598..9603f85 100644 --- a/ApiCore-Backend/src/documents/documents.controller.ts +++ b/ApiCore-Backend/src/documents/documents.controller.ts @@ -24,6 +24,7 @@ import { isAllowedDocumentMimeType } from './document-mime-types'; import { CreateDocumentDto } from './dto/create-document.dto'; import { UpdateDocumentDto } from './dto/update-document.dto'; import { DocumentsService } from './documents.service'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; @@ -60,6 +61,7 @@ export class DocumentsController { } @RequirePermission('documents:upload') + @Idempotent() @Post() @ApiConsumes('multipart/form-data') @UseInterceptors( diff --git a/ApiCore-Backend/src/domain-events/domain-event-context.ts b/ApiCore-Backend/src/domain-events/domain-event-context.ts new file mode 100644 index 0000000..d09b569 --- /dev/null +++ b/ApiCore-Backend/src/domain-events/domain-event-context.ts @@ -0,0 +1,10 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** + * Set by the dispatcher while a handler runs: events the handler records + * inherit the correlation id of the event being handled, so one business + * action and all its follow-up events can be traced together. + */ +export const domainEventContext = new AsyncLocalStorage<{ + correlationId: string; +}>(); diff --git a/ApiCore-Backend/src/domain-events/domain-event-dispatcher.spec.ts b/ApiCore-Backend/src/domain-events/domain-event-dispatcher.spec.ts new file mode 100644 index 0000000..b6d9f30 --- /dev/null +++ b/ApiCore-Backend/src/domain-events/domain-event-dispatcher.spec.ts @@ -0,0 +1,589 @@ +import { Logger } from '@nestjs/common'; +import { Prisma } from '../../generated/prisma'; +import { PrismaService } from '../prisma/prisma.service'; +import { domainEventContext } from './domain-event-context'; +import { + DOMAIN_EVENT_BATCH_SIZE, + DomainEventDispatcher, + DRAIN_MAX_ROUNDS, + HANDLER_TX_TIMEOUT_MS, +} from './domain-event-dispatcher'; +import type { + DomainEventRegistry, + RegisteredDomainEventHandler, +} from './domain-event-registry'; + +interface ClaimedRow { + id: string; + type: string; + aggregateType: string; + aggregateId: string; + payload: unknown; + actorUserId: string | null; + correlationId: string; + occurredAt: Date; + attempts: number; + claimedAt: Date; +} + +type DeliveryStatus = 'PENDING' | 'DONE' | 'FAILED'; + +interface UpsertArgs { + where: unknown; + create: { lastError: string }; + update: { lastError: string }; +} + +const CLAIMED_AT = new Date('2026-10-13T08:00:01.234Z'); + +function row(overrides: Partial = {}): ClaimedRow { + return { + id: 'e1', + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: 't1', + payload: { dueOn: '2026-10-13' }, + actorUserId: null, + correlationId: 'corr-1', + occurredAt: new Date('2026-10-13T08:00:00.000Z'), + attempts: 0, + claimedAt: CLAIMED_AT, + ...overrides, + }; +} + +function handler( + id: string, + invoke: RegisteredDomainEventHandler['invoke'] = () => Promise.resolve(), +): RegisteredDomainEventHandler & { invoke: jest.Mock } { + return { id, invoke: jest.fn(invoke) }; +} + +function text(query: Prisma.Sql): string { + return query.sql.replace(/\s+/g, ' ').trim(); +} + +/** + * Stands in for Postgres: a queue of claim results, the delivery rows the + * lock query finds, and every statement the dispatcher sent. + */ +class FakeDb { + readonly claims: ClaimedRow[][] = []; + /** Rows the stale release returns, one list per call. */ + readonly releases: { id: string; status: 'PENDING' | 'FAILED' }[][] = []; + readonly deliveries = new Map(); + + readonly tx = { + domainEventDelivery: { + createMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + $queryRaw: jest.fn((query: Prisma.Sql) => { + const handlerId = query.values[1] as string; + return Promise.resolve([ + { + id: `d-${handlerId}`, + status: this.deliveries.get(handlerId) ?? 'PENDING', + }, + ]); + }), + $executeRaw: jest.fn().mockResolvedValue(1), + }; + + readonly prisma = { + $queryRaw: jest.fn((query: Prisma.Sql) => + Promise.resolve( + (query.sql.includes('SKIP LOCKED') + ? this.claims.shift() + : this.releases.shift()) ?? [], + ), + ), + $executeRaw: jest.fn().mockResolvedValue(1), + // Called as (fn, { timeout }); the options only matter to Prisma. + $transaction: jest.fn((fn: (tx: unknown) => Promise) => + fn(this.tx), + ), + domainEventDelivery: { + upsert: jest.fn().mockResolvedValue({}), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + + claimQuery(call = 0): Prisma.Sql { + return (this.prisma.$queryRaw.mock.calls as unknown as [Prisma.Sql][])[ + call + ][0]; + } + + /** Statements on the root client: settle and stale release. */ + rootStatements(): Prisma.Sql[] { + return (this.prisma.$executeRaw.mock.calls as [Prisma.Sql][]).map( + ([query]) => query, + ); + } + + /** Statements inside handler transactions after the lock: delivery DONE. */ + deliveryDoneStatements(): Prisma.Sql[] { + return (this.tx.$executeRaw.mock.calls as [Prisma.Sql][]).map( + ([query]) => query, + ); + } + + upsertArgs(call = 0): UpsertArgs { + return ( + this.prisma.domainEventDelivery.upsert.mock.calls as [UpsertArgs][] + )[call][0]; + } +} + +describe('DomainEventDispatcher', () => { + let db: FakeDb; + let handlers: RegisteredDomainEventHandler[]; + let dispatcher: DomainEventDispatcher; + let logWarn: jest.SpyInstance; + + beforeEach(() => { + db = new FakeDb(); + handlers = []; + const registry = { + handlersFor: jest.fn(() => handlers), + } as unknown as DomainEventRegistry; + dispatcher = new DomainEventDispatcher( + db.prisma as unknown as PrismaService, + registry, + ); + logWarn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('pins batch size, handler transaction timeout and drain rounds', () => { + expect(DOMAIN_EVENT_BATCH_SIZE).toBe(50); + expect(HANDLER_TX_TIMEOUT_MS).toBe(10_000); + expect(DRAIN_MAX_ROUNDS).toBe(20); + }); + + it('claims at most 50 pending events, oldest first, skipping locked rows, by the database clock', async () => { + await dispatcher.runOnce(); + + const claim = db.claimQuery(); + expect(text(claim)).toContain( + `UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`, + ); + expect(text(claim)).toContain( + `WHERE "status" = 'PENDING' AND "availableAt" <= now()`, + ); + expect(text(claim)).toContain( + 'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED', + ); + expect(claim.values).toEqual([50]); + }); + + it('restricts the claim to the given correlation ids', async () => { + await dispatcher.runOnce({ correlationIds: ['c1', 'c2'] }); + + const claim = db.claimQuery(); + expect(text(claim)).toContain( + `"availableAt" <= now() AND "correlationId" = ANY(?) ORDER BY`, + ); + expect(claim.values).toEqual([['c1', 'c2'], 50]); + }); + + it('marks an event DONE when every handler succeeded', async () => { + handlers = [handler('a.first'), handler('b.second')]; + db.claims.push([row()]); + + await expect(dispatcher.runOnce()).resolves.toBe(1); + + expect(handlers[0].invoke).toHaveBeenCalledTimes(1); + expect(handlers[1].invoke).toHaveBeenCalledTimes(1); + expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([ + ['d-a.first'], + ['d-b.second'], + ]); + const [settle] = db.rootStatements(); + expect(text(settle)).toContain('"processedAt" = now()'); + expect(text(settle)).toContain( + `WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`, + ); + expect(settle.values).toEqual(['DONE', 'e1', CLAIMED_AT]); + }); + + it('marks an event without handlers DONE', async () => { + db.claims.push([row({ type: 'VarroaMeasured' })]); + + await dispatcher.runOnce(); + + expect(db.prisma.$transaction).not.toHaveBeenCalled(); + expect(db.rootStatements()[0].values).toEqual(['DONE', 'e1', CLAIMED_AT]); + }); + + it('marks the delivery DONE with attempts + 1 in the handler transaction, by the database clock', async () => { + handlers = [handler('a.first')]; + db.claims.push([row()]); + + await dispatcher.runOnce(); + + const [done] = db.deliveryDoneStatements(); + expect(text(done)).toBe( + `UPDATE "DomainEventDelivery" SET "status" = 'DONE', "attempts" = "attempts" + 1, "completedAt" = now(), "updatedAt" = now() WHERE "id" = ?`, + ); + }); + + it('retries only the failed handler and schedules the event 30 s ahead after the first failure', async () => { + const ok = handler('a.ok'); + const flaky = handler('b.flaky'); + flaky.invoke.mockRejectedValueOnce(new Error('boom')); + handlers = [ok, flaky]; + db.claims.push([row({ attempts: 0 })]); + + await dispatcher.runOnce(); + + expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([ + ['d-a.ok'], + ]); + expect(db.prisma.domainEventDelivery.upsert).toHaveBeenCalledWith({ + where: { eventId_handler: { eventId: 'e1', handler: 'b.flaky' } }, + create: { + eventId: 'e1', + handler: 'b.flaky', + attempts: 1, + lastError: 'boom', + }, + update: { attempts: { increment: 1 }, lastError: 'boom' }, + }); + const [settle] = db.rootStatements(); + expect(text(settle)).toContain( + `"availableAt" = now() + (? * interval '1 millisecond')`, + ); + expect(text(settle)).toContain('"attempts" = "attempts" + 1'); + expect(settle.values).toEqual([ + 'PENDING', + 'b.flaky: boom', + 30_000, + 'e1', + CLAIMED_AT, + ]); + expect(db.prisma.domainEventDelivery.updateMany).not.toHaveBeenCalled(); + + // The next run: a.ok committed its delivery, so only b.flaky runs again. + db.deliveries.set('a.ok', 'DONE'); + db.claims.push([row({ attempts: 1 })]); + await dispatcher.runOnce(); + + expect(ok.invoke).toHaveBeenCalledTimes(1); + expect(flaky.invoke).toHaveBeenCalledTimes(2); + expect(db.rootStatements()[1].values).toEqual(['DONE', 'e1', CLAIMED_AT]); + }); + + it('marks the event and its open deliveries FAILED after the 8th failed attempt', async () => { + handlers = [handler('a.broken', () => Promise.reject(new Error('boom')))]; + db.claims.push([row({ attempts: 7 })]); + + await dispatcher.runOnce(); + + const [settle] = db.rootStatements(); + expect(settle.values).toEqual([ + 'FAILED', + 'a.broken: boom', + 'e1', + CLAIMED_AT, + ]); + expect(text(settle)).not.toContain('interval'); + expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledWith({ + where: { eventId: { in: ['e1'] }, status: 'PENDING' }, + data: { status: 'FAILED' }, + }); + }); + + it('keeps the first failure as lastError when several handlers fail', async () => { + handlers = [ + handler('a.first', () => Promise.reject(new Error('one'))), + handler('b.second', () => Promise.reject(new Error('two'))), + ]; + db.claims.push([row()]); + + await dispatcher.runOnce(); + + expect(db.prisma.domainEventDelivery.upsert).toHaveBeenCalledTimes(2); + expect(db.rootStatements()[0].values).toContain('a.first: one'); + }); + + it('skips a handler whose delivery is already DONE', async () => { + handlers = [handler('a.done'), handler('b.open')]; + db.deliveries.set('a.done', 'DONE'); + db.claims.push([row()]); + + await dispatcher.runOnce(); + + expect(handlers[0].invoke).not.toHaveBeenCalled(); + expect(handlers[1].invoke).toHaveBeenCalledTimes(1); + expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([ + ['d-b.open'], + ]); + expect(db.rootStatements()[0].values).toEqual(['DONE', 'e1', CLAIMED_AT]); + }); + + it('runs the handler again when its delivery FAILED before (manual retry)', async () => { + handlers = [handler('a.failed-before')]; + db.deliveries.set('a.failed-before', 'FAILED'); + db.claims.push([row()]); + + await dispatcher.runOnce(); + + expect(handlers[0].invoke).toHaveBeenCalledTimes(1); + }); + + it('runs the handler inside the delivery transaction with the event correlation id', async () => { + const seen: unknown[] = []; + handlers = [ + handler('a.first', (event, tx) => { + seen.push(event, tx, domainEventContext.getStore()); + return Promise.resolve(); + }), + ]; + db.claims.push([row({ correlationId: 'corr-42' })]); + + await dispatcher.runOnce(); + + expect(db.prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { + timeout: HANDLER_TX_TIMEOUT_MS, + }); + expect(seen[1]).toBe(db.tx); + expect(seen[2]).toEqual({ correlationId: 'corr-42' }); + expect(seen[0]).toEqual({ + id: 'e1', + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: 't1', + payload: { dueOn: '2026-10-13' }, + actorUserId: null, + correlationId: 'corr-42', + occurredAt: new Date('2026-10-13T08:00:00.000Z'), + attempts: 0, + }); + expect(domainEventContext.getStore()).toBeUndefined(); + + // Delivery row first (idempotent), then the row lock, then the handler. + expect(db.tx.domainEventDelivery.createMany).toHaveBeenCalledWith({ + data: [{ eventId: 'e1', handler: 'a.first' }], + skipDuplicates: true, + }); + const [lock] = db.tx.$queryRaw.mock.calls[0]; + expect(text(lock)).toBe( + 'SELECT "id", "status" FROM "DomainEventDelivery" WHERE "eventId" = ? AND "handler" = ? FOR UPDATE', + ); + expect(lock.values).toEqual(['e1', 'a.first']); + expect( + db.tx.domainEventDelivery.createMany.mock.invocationCallOrder[0], + ).toBeLessThan(db.tx.$queryRaw.mock.invocationCallOrder[0]); + expect(db.tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + (handlers[0].invoke as jest.Mock).mock.invocationCallOrder[0], + ); + }); + + it('advances lastTickAt after every dispatched event, so a long batch never looks stale', async () => { + const start = new Date('2026-10-13T08:00:00.000Z'); + jest.useFakeTimers({ + now: start, + doNotFake: [ + 'hrtime', + 'nextTick', + 'performance', + 'queueMicrotask', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + ], + }); + const seen: (Date | null)[] = []; + // Every event takes 100 s, e.g. waiting on locks. + handlers = [ + handler('a.slow', () => { + seen.push(dispatcher.lastTickAt); + jest.setSystemTime(Date.now() + 100_000); + return Promise.resolve(); + }), + ]; + db.claims.push([row({ id: 'e1' }), row({ id: 'e2' }), row({ id: 'e3' })]); + + try { + await dispatcher.runOnce(); + } finally { + jest.useRealTimers(); + } + + expect(seen.map((tick) => tick?.toISOString())).toEqual([ + '2026-10-13T08:00:00.000Z', + '2026-10-13T08:01:40.000Z', + '2026-10-13T08:03:20.000Z', + ]); + expect(dispatcher.lastTickAt?.toISOString()).toBe( + '2026-10-13T08:05:00.000Z', + ); + }); + + it('updates lastTickAt even when nothing was claimed', async () => { + expect(dispatcher.lastTickAt).toBeNull(); + + await expect(dispatcher.runOnce()).resolves.toBe(0); + + expect(dispatcher.lastTickAt).toBeInstanceOf(Date); + }); + + it('stores lastError without the Prisma message', async () => { + handlers = [ + handler('a.db', () => + Promise.reject( + new Prisma.PrismaClientKnownRequestError( + "Can't reach database server at secret-host:5432", + { code: 'P1001', clientVersion: '7.9.1' }, + ), + ), + ), + ]; + db.claims.push([row()]); + + await dispatcher.runOnce(); + + const upsert = db.upsertArgs(); + expect(upsert.create.lastError).toBe('PrismaClientKnownRequestError P1001'); + expect(upsert.update.lastError).toBe('PrismaClientKnownRequestError P1001'); + const settleValues = db.rootStatements()[0].values; + expect(settleValues).toContain('a.db: PrismaClientKnownRequestError P1001'); + expect(JSON.stringify(settleValues)).not.toContain('secret-host'); + }); + + it('keeps dispatching the batch when one event cannot be settled', async () => { + db.claims.push([row({ id: 'e1' }), row({ id: 'e2' })]); + db.prisma.$executeRaw.mockRejectedValueOnce(new Error('connection lost')); + + await expect(dispatcher.runOnce()).resolves.toBe(2); + + expect(db.rootStatements().map((query) => query.values[1])).toEqual([ + 'e1', + 'e2', + ]); + }); + + it('only logs when the claim was lost before settling (stale release)', async () => { + db.claims.push([row()]); + db.prisma.$executeRaw.mockResolvedValueOnce(0); + + await expect(dispatcher.runOnce()).resolves.toBe(1); + + expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('e1')); + }); + + describe('drain', () => { + it('repeats rounds until a round claims nothing and returns the total', async () => { + db.claims.push( + [row({ id: 'e1' }), row({ id: 'e2' })], + [row({ id: 'e3' })], + ); + + await expect(dispatcher.drain({ correlationIds: ['c1'] })).resolves.toBe( + 3, + ); + + expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(3); + expect(db.claimQuery(2).values).toEqual([['c1'], 50]); + }); + + it('stops after maxRounds even if work remains', async () => { + for (let i = 0; i < 5; i++) { + db.claims.push([row({ id: `e${i}` })]); + } + + await expect(dispatcher.drain(undefined, 2)).resolves.toBe(2); + + expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(2); + }); + + it('makes at most 20 rounds by default', async () => { + for (let i = 0; i < 25; i++) { + db.claims.push([row({ id: `e${i}` })]); + } + + await expect(dispatcher.drain()).resolves.toBe(DRAIN_MAX_ROUNDS); + }); + + // Worker shutdown: the claimed batch is finished (its events would + // otherwise sit PROCESSING until the stale-claim release), but no + // further batch is claimed. + it('finishes the claimed batch but claims no further one once the signal aborts', async () => { + const stop = new AbortController(); + handlers = [ + handler('a.stopper', () => { + stop.abort(); + return Promise.resolve(); + }), + ]; + db.claims.push( + [row({ id: 'e1' }), row({ id: 'e2' })], + [row({ id: 'e3' })], + ); + + await expect( + dispatcher.drain(undefined, DRAIN_MAX_ROUNDS, stop.signal), + ).resolves.toBe(2); + + expect(handlers[0].invoke).toHaveBeenCalledTimes(2); + expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(1); + }); + + it('claims nothing once the signal has aborted', async () => { + const stop = new AbortController(); + stop.abort(); + db.claims.push([row()]); + + await expect( + dispatcher.drain(undefined, DRAIN_MAX_ROUNDS, stop.signal), + ).resolves.toBe(0); + + expect(db.prisma.$queryRaw).not.toHaveBeenCalled(); + }); + }); + + it('releases stale claims with attempts + 1, FAILED from the 8th attempt on', async () => { + db.releases.push([ + { id: 'e1', status: 'PENDING' }, + { id: 'e2', status: 'PENDING' }, + { id: 'e3', status: 'PENDING' }, + ]); + + await expect(dispatcher.releaseStaleClaims()).resolves.toBe(3); + + const [release] = db.prisma.$queryRaw.mock.calls[0]; + expect(text(release)).toBe( + `UPDATE "DomainEvent" SET "status" = CAST(CASE WHEN "attempts" + 1 >= ? THEN 'FAILED' ELSE 'PENDING' END AS "DomainEventStatus"), "attempts" = "attempts" + 1, "claimedAt" = NULL, "availableAt" = now(), "lastError" = ? WHERE "status" = 'PROCESSING' AND "claimedAt" < now() - (? * interval '1 millisecond') RETURNING "id", "status"`, + ); + expect(release.values).toEqual([ + 8, + 'claim expired (worker restarted?)', + 300_000, + ]); + expect(db.prisma.domainEventDelivery.updateMany).not.toHaveBeenCalled(); + }); + + it('marks the open deliveries FAILED when a stale release ends an event in FAILED', async () => { + db.releases.push([ + { id: 'e1', status: 'FAILED' }, + { id: 'e2', status: 'PENDING' }, + { id: 'e3', status: 'FAILED' }, + ]); + + await expect(dispatcher.releaseStaleClaims()).resolves.toBe(3); + + expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledTimes(1); + expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledWith({ + where: { eventId: { in: ['e1', 'e3'] }, status: 'PENDING' }, + data: { status: 'FAILED' }, + }); + }); +}); diff --git a/ApiCore-Backend/src/domain-events/domain-event-dispatcher.ts b/ApiCore-Backend/src/domain-events/domain-event-dispatcher.ts new file mode 100644 index 0000000..c6ce832 --- /dev/null +++ b/ApiCore-Backend/src/domain-events/domain-event-dispatcher.ts @@ -0,0 +1,269 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DomainEventDeliveryStatus, Prisma } from '../../generated/prisma'; +import { loggableError } from '../common/loggable-error'; +import { + claimBatch, + releaseStaleClaimRows, + settleFailure, + settleSuccess, +} from '../common/outbox/outbox-claims'; +import { PrismaService } from '../prisma/prisma.service'; +import { domainEventContext } from './domain-event-context'; +import { + DomainEventRegistry, + RegisteredDomainEventHandler, +} from './domain-event-registry'; +import { AnyDomainEventEnvelope, DomainEventType } from './domain-event-types'; + +export const DOMAIN_EVENT_BATCH_SIZE = 50; +export const HANDLER_TX_TIMEOUT_MS = 10_000; +export const DRAIN_MAX_ROUNDS = 20; + +export interface DispatchFilter { + /** Only events of these correlations (e2e tests: no foreign events). */ + correlationIds?: readonly string[]; +} + +/** A claimed DomainEvent row, as RETURNING * yields it. */ +interface ClaimedDomainEvent { + id: string; + type: string; + aggregateType: string; + aggregateId: string; + payload: Prisma.JsonValue; + actorUserId: string | null; + correlationId: string; + occurredAt: Date; + attempts: number; + claimedAt: Date; +} + +/** + * Delivers claimed domain events to their registered handlers, exactly once + * per (event, handler): each handler runs in its own transaction that first + * locks its DomainEventDelivery row and marks it DONE on commit. A handler + * whose delivery is DONE is skipped, so a retry, a second worker or a + * stale-released event never repeats an effect that was committed. + */ +@Injectable() +export class DomainEventDispatcher { + private readonly logger = new Logger(DomainEventDispatcher.name); + private tickAt: Date | null = null; + + constructor( + private readonly prisma: PrismaService, + private readonly registry: DomainEventRegistry, + ) {} + + /** + * When the dispatcher last made progress - a claim (empty or not) or a + * dispatched event - for the worker health. Advancing per event keeps a + * long batch (lock waits, slow handlers) from looking stale. + */ + get lastTickAt(): Date | null { + return this.tickAt; + } + + /** One batch: claims up to 50 events and returns how many it claimed. */ + async runOnce(filter?: DispatchFilter): Promise { + const events = await claimBatch( + this.prisma, + 'DomainEvent', + { + limit: DOMAIN_EVENT_BATCH_SIZE, + filter: claimFilter(filter), + orderBy: 'occurredAt', + }, + ); + this.tickAt = new Date(); + for (const event of events) { + await this.dispatchSafely(event); + this.tickAt = new Date(); + } + return events.length; + } + + /** + * Runs batches until one claims nothing - follow-up events that handlers + * record are picked up in the next round - but at most `maxRounds`, so + * one tick never blocks the worker for long. Returns the claimed total. + * Once `signal` aborts (worker shutdown), no further batch is claimed; + * the batch already claimed is finished, since its events would + * otherwise wait for the stale-claim release. + */ + async drain( + filter?: DispatchFilter, + maxRounds = DRAIN_MAX_ROUNDS, + signal?: AbortSignal, + ): Promise { + let total = 0; + for (let round = 0; round < maxRounds && !signal?.aborted; round++) { + const claimed = await this.runOnce(filter); + if (claimed === 0) { + break; + } + total += claimed; + } + return total; + } + + /** + * Releases stale claims; an event that thereby reaches FAILED gets its + * open deliveries FAILED as well, like a failed settle. + */ + async releaseStaleClaims(): Promise { + const released = await releaseStaleClaimRows(this.prisma, 'DomainEvent'); + const failed = released + .filter((claim) => claim.status === 'FAILED') + .map((claim) => claim.id); + if (failed.length > 0) { + await this.failOpenDeliveries(failed); + this.logger.error( + `Domain events FAILED, an expired claim used up their attempts: ${failed.join(', ')}`, + ); + } + return released.length; + } + + private async failOpenDeliveries(eventIds: readonly string[]): Promise { + await this.prisma.domainEventDelivery.updateMany({ + where: { eventId: { in: [...eventIds] }, status: 'PENDING' }, + data: { status: 'FAILED' }, + }); + } + + /** One event must not stop the batch; an unsettled event goes stale. */ + private async dispatchSafely(event: ClaimedDomainEvent): Promise { + try { + await this.dispatch(event); + } catch (error) { + this.logger.error( + `Domain event ${event.id} (${event.type}) not settled: ${loggableError(error).message}`, + ); + } + } + + private async dispatch(event: ClaimedDomainEvent): Promise { + const envelope = toEnvelope(event); + const failures: string[] = []; + for (const handler of this.registry.handlersFor( + event.type as DomainEventType, + )) { + const failure = await this.deliver(envelope, handler); + if (failure !== null) { + failures.push(`${handler.id}: ${failure}`); + } + } + + if (failures.length === 0) { + const settled = await settleSuccess( + this.prisma, + 'DomainEvent', + event.id, + event.claimedAt, + 'DONE', + ); + if (!settled) { + this.logger.warn( + `Domain event ${event.id} (${event.type}): claim lost before settling, left to its current owner`, + ); + } + return; + } + + const outcome = await settleFailure( + this.prisma, + 'DomainEvent', + event.id, + event.claimedAt, + event.attempts, + failures[0], + ); + if (outcome === 'failed') { + await this.failOpenDeliveries([event.id]); + this.logger.error( + `Domain event ${event.id} (${event.type}) FAILED after ${event.attempts + 1} attempts: ${failures.join('; ')}`, + ); + return; + } + this.logger.warn( + `Domain event ${event.id} (${event.type}) attempt ${event.attempts + 1} failed, retrying: ${failures.join('; ')}`, + ); + } + + /** + * Runs one handler in its delivery transaction. Returns null on success + * or skip, else the loggable error message (already recorded on the + * delivery row, outside the rolled-back transaction). + */ + private async deliver( + event: AnyDomainEventEnvelope, + handler: RegisteredDomainEventHandler, + ): Promise { + try { + await this.prisma.$transaction( + async (tx) => { + await tx.domainEventDelivery.createMany({ + data: [{ eventId: event.id, handler: handler.id }], + skipDuplicates: true, + }); + const [delivery] = await tx.$queryRaw< + { id: string; status: DomainEventDeliveryStatus }[] + >( + Prisma.sql`SELECT "id", "status" FROM "DomainEventDelivery" WHERE "eventId" = ${event.id} AND "handler" = ${handler.id} FOR UPDATE`, + ); + if (delivery.status === 'DONE') { + return; + } + await domainEventContext.run( + { correlationId: event.correlationId }, + () => handler.invoke(event, tx), + ); + await tx.$executeRaw( + Prisma.sql`UPDATE "DomainEventDelivery" SET "status" = 'DONE', "attempts" = "attempts" + 1, "completedAt" = now(), "updatedAt" = now() WHERE "id" = ${delivery.id}`, + ); + }, + { timeout: HANDLER_TX_TIMEOUT_MS }, + ); + return null; + } catch (error) { + const message = loggableError(error).message; + await this.prisma.domainEventDelivery.upsert({ + where: { eventId_handler: { eventId: event.id, handler: handler.id } }, + create: { + eventId: event.id, + handler: handler.id, + attempts: 1, + lastError: message, + }, + update: { attempts: { increment: 1 }, lastError: message }, + }); + return message; + } + } +} + +function claimFilter(filter?: DispatchFilter): Prisma.Sql | undefined { + if (filter?.correlationIds === undefined) { + return undefined; + } + return Prisma.sql`"correlationId" = ANY(${[...filter.correlationIds]})`; +} + +/** + * The row as the handler sees it. The producer paired type and payload + * (DomainEventsService only accepts AnyNewDomainEvent), so the cast holds. + */ +function toEnvelope(event: ClaimedDomainEvent): AnyDomainEventEnvelope { + return { + id: event.id, + type: event.type, + aggregateType: event.aggregateType, + aggregateId: event.aggregateId, + payload: event.payload, + actorUserId: event.actorUserId, + correlationId: event.correlationId, + occurredAt: event.occurredAt, + attempts: event.attempts, + } as AnyDomainEventEnvelope; +} diff --git a/ApiCore-Backend/src/domain-events/domain-event-registry.spec.ts b/ApiCore-Backend/src/domain-events/domain-event-registry.spec.ts new file mode 100644 index 0000000..612ff1b --- /dev/null +++ b/ApiCore-Backend/src/domain-events/domain-event-registry.spec.ts @@ -0,0 +1,456 @@ +import { Injectable, Provider, Scope } from '@nestjs/common'; +import { DiscoveryService, MetadataScanner } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Prisma } from '../../generated/prisma'; +import { DomainEventRegistry } from './domain-event-registry'; +import { DomainEventsModule } from './domain-events.module'; +import type { + AnyDomainEventEnvelope, + DomainEventEnvelope, + DomainEventHandlerFn, + DomainEventType, +} from './domain-event-types'; +import { HANDLER_ID_PATTERN, OnDomainEvent } from './on-domain-event.decorator'; + +interface FakeWrapper { + instance: unknown; + isAlias?: boolean; + isStatic?: boolean; + isTransient?: boolean; +} + +function registryWith(...wrappers: FakeWrapper[]): DomainEventRegistry { + const discovery = { + getProviders: () => + wrappers.map( + ({ + instance, + isAlias = false, + isStatic = true, + isTransient = false, + }) => ({ + instance, + isAlias, + isTransient, + isDependencyTreeStatic: () => isStatic, + }), + ), + }; + return new DomainEventRegistry( + discovery as unknown as DiscoveryService, + new MetadataScanner(), + ); +} + +function registryFor(...instances: unknown[]): DomainEventRegistry { + return registryWith(...instances.map((instance) => ({ instance }))); +} + +function envelope( + type: T, + payload: DomainEventEnvelope['payload'], +): DomainEventEnvelope { + return { + id: 'e1', + type, + aggregateType: 'Inspection', + aggregateId: 'i1', + payload, + actorUserId: null, + correlationId: 'corr-1', + occurredAt: new Date('2026-10-13T08:00:00Z'), + attempts: 0, + }; +} + +class CalendarHandlers { + readonly seen: string[] = []; + + @OnDomainEvent(['InspectionRecorded', 'TreatmentRecorded'], 'calendar.sync') + sync(event: DomainEventEnvelope): Promise { + this.seen.push(event.type); + return Promise.resolve(); + } + + notAHandler(): void {} +} + +class AutomationHandlers { + @OnDomainEvent('InspectionRecorded', 'automation.queenless') + @OnDomainEvent('InspectionCorrected', 'automation.queenless-corrected') + evaluate(): Promise { + return Promise.resolve(); + } +} + +describe('DomainEventRegistry', () => { + it('finds handlers by type, also when one method serves several types', async () => { + const calendar = new CalendarHandlers(); + const registry = registryFor(calendar, new AutomationHandlers()); + registry.onModuleInit(); + + expect( + registry.handlersFor('InspectionRecorded').map((handler) => handler.id), + ).toEqual(['automation.queenless', 'calendar.sync']); + expect( + registry.handlersFor('TreatmentRecorded').map((handler) => handler.id), + ).toEqual(['calendar.sync']); + expect(registry.handlersFor('VarroaMeasured')).toEqual([]); + + const [handler] = registry.handlersFor('TreatmentRecorded'); + await handler.invoke( + envelope('TreatmentRecorded', { colonyId: 'c1' }), + {} as Prisma.TransactionClient, + ); + expect(calendar.seen).toEqual(['TreatmentRecorded']); + }); + + it('registers stacked decorators on one method separately', () => { + const registry = registryFor(new AutomationHandlers()); + registry.onModuleInit(); + + expect( + registry.handlersFor('InspectionCorrected').map((handler) => handler.id), + ).toEqual(['automation.queenless-corrected']); + expect(registry.handlerIds()).toEqual([ + 'automation.queenless', + 'automation.queenless-corrected', + ]); + }); + + it('passes event and transaction client to the handler bound to its provider', async () => { + const received: unknown[] = []; + class Recorder { + readonly name = 'recorder'; + @OnDomainEvent('TaskDue', 'tasks.reminder') + handle(event: DomainEventEnvelope, tx: Prisma.TransactionClient) { + received.push(this.name, event.id, tx); + return Promise.resolve(); + } + } + const registry = registryFor(new Recorder()); + registry.onModuleInit(); + const tx = { marker: true } as unknown as Prisma.TransactionClient; + + await registry + .handlersFor('TaskDue')[0] + .invoke(envelope('TaskDue', { dueOn: '2026-10-13' }), tx); + + expect(received).toEqual(['recorder', 'e1', tx]); + }); + + it('lists every handler id once', () => { + const registry = registryFor( + new CalendarHandlers(), + new AutomationHandlers(), + ); + registry.onModuleInit(); + + expect(registry.handlerIds()).toEqual([ + 'automation.queenless', + 'automation.queenless-corrected', + 'calendar.sync', + ]); + }); + + it('ignores providers without an instance or without handlers', () => { + const registry = registryFor(undefined, null, 'value', { plain: true }); + registry.onModuleInit(); + + expect(registry.handlerIds()).toEqual([]); + }); + + it('refuses two handlers with the same id for one type', () => { + class OtherCalendarHandlers { + @OnDomainEvent('InspectionRecorded', 'calendar.sync') + again(): Promise { + return Promise.resolve(); + } + } + const registry = registryFor( + new CalendarHandlers(), + new OtherCalendarHandlers(), + ); + + expect(() => registry.onModuleInit()).toThrow( + 'Duplicate domain event handler', + ); + }); + + it('refuses the same provider registered twice', () => { + const registry = registryFor( + new CalendarHandlers(), + new CalendarHandlers(), + ); + + expect(() => registry.onModuleInit()).toThrow( + 'Duplicate domain event handler', + ); + }); + + it.each([ + 'calendar', + 'Calendar.sync', + 'calendar.sync.extra', + 'calendar.', + 'calendar sync', + '.sync', + ])( + 'rejects a handler id that is not ".": %p', + (handlerId) => { + class BadId { + @OnDomainEvent('TaskDue', handlerId) + handle(): Promise { + return Promise.resolve(); + } + } + const registry = registryFor(new BadId()); + + expect(HANDLER_ID_PATTERN.test(handlerId)).toBe(false); + expect(() => registry.onModuleInit()).toThrow( + 'Invalid domain event handler id', + ); + }, + ); + + it('rejects an event type outside the catalog', () => { + class UnknownType { + @OnDomainEvent('ColonyCreated' as DomainEventType, 'colonies.welcome') + handle(): Promise { + return Promise.resolve(); + } + } + + expect(() => registryFor(new UnknownType()).onModuleInit()).toThrow( + 'Unknown domain event type', + ); + }); + + it('rejects a handler without any event type', () => { + class NoType { + @OnDomainEvent([], 'colonies.nothing') + handle(): Promise { + return Promise.resolve(); + } + } + + expect(() => registryFor(new NoType()).onModuleInit()).toThrow( + 'serves no domain event type', + ); + }); + + it('skips alias providers - the aliased provider is registered once', () => { + const calendar = new CalendarHandlers(); + const registry = registryWith( + { instance: calendar }, + { instance: calendar, isAlias: true }, + ); + registry.onModuleInit(); + + expect( + registry.handlersFor('InspectionRecorded').map((handler) => handler.id), + ).toEqual(['calendar.sync']); + }); + + it('refuses a handler on a request-scoped provider (dependency tree not static)', () => { + const registry = registryWith({ + instance: new CalendarHandlers(), + isStatic: false, + }); + + expect(() => registry.onModuleInit()).toThrow( + 'must be on a static (singleton) provider', + ); + }); + + it('refuses a handler on a transient provider even with a static dependency tree', () => { + // Nest reports isDependencyTreeStatic() === true here, but the + // discovered instance is an unconstructed Object.create(prototype). + const registry = registryWith({ + instance: new CalendarHandlers(), + isStatic: true, + isTransient: true, + }); + + expect(() => registry.onModuleInit()).toThrow( + 'must be on a static (singleton) provider', + ); + }); + + it('ignores a transient provider without handlers', () => { + class TransientService { + run(): void {} + } + const registry = registryWith({ + instance: new TransientService(), + isTransient: true, + }); + + expect(() => registry.onModuleInit()).not.toThrow(); + }); + + it('ignores a non-static provider without handlers', () => { + class RequestScopedService { + run(): void {} + } + const registry = registryWith({ + instance: new RequestScopedService(), + isStatic: false, + }); + + expect(() => registry.onModuleInit()).not.toThrow(); + expect(registry.handlerIds()).toEqual([]); + }); + + describe('order', () => { + class ManyHandlers { + @OnDomainEvent('TaskDue', 'calendar1.a') + @OnDomainEvent('TaskDue', 'calendar.sync') + @OnDomainEvent('TaskDue', 'calendar-sync.a') + @OnDomainEvent('TaskDue', 'calendar.a') + handle(): Promise { + return Promise.resolve(); + } + } + const CODE_POINT_ORDER = [ + 'calendar-sync.a', + 'calendar.a', + 'calendar.sync', + 'calendar1.a', + ]; + + afterEach(() => jest.restoreAllMocks()); + + it('sorts by code point in handlersFor and handlerIds, whatever the locale collation says', () => { + // A collation that reverses everything: the order must not change. + jest + .spyOn(String.prototype, 'localeCompare') + .mockImplementation(function (this: string, other: string) { + if (this > other) return -1; + return this < other ? 1 : 0; + }); + const registry = registryFor(new ManyHandlers()); + registry.onModuleInit(); + + expect( + registry.handlersFor('TaskDue').map((handler) => handler.id), + ).toEqual(CODE_POINT_ORDER); + expect(registry.handlerIds()).toEqual(CODE_POINT_ORDER); + }); + }); +}); + +describe('handler envelope typing', () => { + const harvest = envelope('HarvestRecorded', { + apiaryId: 'a1', + colonyIds: ['c1', 'c2'], + }); + const inspection = envelope('InspectionRecorded', { colonyId: 'c3' }); + + it('ties every event type to its payload, so a multi-type handler narrows by type', async () => { + const seen: string[] = []; + // Compiles only because the envelope is a distributive union. + const handler: DomainEventHandlerFn< + 'InspectionRecorded' | 'HarvestRecorded' + > = (event) => { + seen.push( + ...(event.type === 'HarvestRecorded' + ? event.payload.colonyIds + : [event.payload.colonyId]), + ); + return Promise.resolve(); + }; + + await handler(harvest, {} as Prisma.TransactionClient); + await handler(inspection, {} as Prisma.TransactionClient); + + expect(seen).toEqual(['c1', 'c2', 'c3']); + }); + + it('refuses an envelope whose payload belongs to another type', () => { + const misfit = { ...harvest, type: 'TaskDue' } as const; + // @ts-expect-error - TaskDue carries { dueOn }, not a harvest payload + const wrong: AnyDomainEventEnvelope = misfit; + + expect(wrong.type).toBe('TaskDue'); + }); +}); + +describe('DomainEventRegistry with Nest DI', () => { + @Injectable() + class Clock { + today(): string { + return '2026-10-13'; + } + } + + @Injectable() + class SingletonHandlers { + readonly seen: string[] = []; + constructor(private readonly clock: Clock) {} + + @OnDomainEvent('TaskDue', 'di.singleton') + handle(): Promise { + this.seen.push(this.clock.today()); + return Promise.resolve(); + } + } + + @Injectable({ scope: Scope.TRANSIENT }) + class TransientHandlers { + constructor(readonly clock: Clock) {} + + @OnDomainEvent('TaskDue', 'di.transient') + handle(): Promise { + return Promise.resolve(); + } + } + + @Injectable({ scope: Scope.REQUEST }) + class RequestHandlers { + @OnDomainEvent('TaskDue', 'di.request') + handle(): Promise { + return Promise.resolve(); + } + } + + async function compile(...providers: Provider[]): Promise { + return Test.createTestingModule({ + imports: [DomainEventsModule], + providers: [Clock, ...providers], + }).compile(); + } + + it('registers a singleton handler once, also behind an alias, bound to the DI instance', async () => { + const moduleRef = await compile(SingletonHandlers, { + provide: 'HANDLERS_ALIAS', + useExisting: SingletonHandlers, + }); + await moduleRef.init(); + const registry = moduleRef.get(DomainEventRegistry); + + expect( + registry.handlersFor('TaskDue').map((handler) => handler.id), + ).toEqual(['di.singleton']); + await registry + .handlersFor('TaskDue')[0] + .invoke( + envelope('TaskDue', { dueOn: '2026-10-13' }), + {} as Prisma.TransactionClient, + ); + expect(moduleRef.get(SingletonHandlers).seen).toEqual(['2026-10-13']); + await moduleRef.close(); + }); + + it.each([ + ['transient', TransientHandlers], + ['request-scoped', RequestHandlers], + ])('stops the boot for a handler on a %s provider', async (_scope, type) => { + const moduleRef = await compile(type); + + // No close(): it re-throws the failed init, and nothing holds resources. + await expect(moduleRef.init()).rejects.toThrow( + 'must be on a static (singleton) provider', + ); + }); +}); diff --git a/ApiCore-Backend/src/domain-events/domain-event-registry.ts b/ApiCore-Backend/src/domain-events/domain-event-registry.ts new file mode 100644 index 0000000..9147403 --- /dev/null +++ b/ApiCore-Backend/src/domain-events/domain-event-registry.ts @@ -0,0 +1,174 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { DiscoveryService, MetadataScanner } from '@nestjs/core'; +import { + DOMAIN_EVENT_TYPES, + DomainEventHandlerFn, + DomainEventType, +} from './domain-event-types'; +import { + domainEventHandlerMetadata, + DomainEventHandlerMetadata, + HANDLER_ID_PATTERN, +} from './on-domain-event.decorator'; + +export interface RegisteredDomainEventHandler { + id: string; + invoke: DomainEventHandlerFn; +} + +interface DiscoveredHandler extends RegisteredDomainEventHandler { + types: readonly DomainEventType[]; + /** "." for boot error messages. */ + source: string; +} + +const NO_HANDLERS: readonly RegisteredDomainEventHandler[] = Object.freeze([]); + +/** + * Collects every @OnDomainEvent method of every provider at boot. A wrong + * declaration - invalid id, unknown type, the same id twice for one type - + * stops the boot: a handler that silently never runs, or runs under + * another handler's delivery record, would lose or swallow effects. + */ +@Injectable() +export class DomainEventRegistry implements OnModuleInit { + private handlers = new Map< + DomainEventType, + readonly RegisteredDomainEventHandler[] + >(); + + constructor( + private readonly discovery: DiscoveryService, + private readonly scanner: MetadataScanner, + ) {} + + onModuleInit(): void { + this.handlers = groupByType(this.discover()); + } + + /** Sorted by handler id (compareIds), so handlers run in a stable order. */ + handlersFor(type: DomainEventType): readonly RegisteredDomainEventHandler[] { + return this.handlers.get(type) ?? NO_HANDLERS; + } + + handlerIds(): string[] { + const ids = new Set(); + for (const handlers of this.handlers.values()) { + handlers.forEach((handler) => ids.add(handler.id)); + } + return [...ids].sort(compareIds); + } + + /** + * Aliases are skipped - they point at a provider that is scanned anyway. + * A handler must sit on a static (singleton) provider: the worker calls + * it outside any request. For a request-scoped provider (dependency tree + * not static) and for a transient one (Nest reports its tree as static) + * the discovered instance is only an unconstructed Object.create of the + * prototype, without dependencies - the handler would fail at dispatch. + */ + private discover(): DiscoveredHandler[] { + return this.discovery.getProviders().flatMap((wrapper) => { + const instance: unknown = wrapper.instance; + if (wrapper.isAlias || !instance || typeof instance !== 'object') { + return []; + } + const handlers = this.handlersOf(instance); + const isSingleton = + !wrapper.isTransient && wrapper.isDependencyTreeStatic(); + if (handlers.length > 0 && !isSingleton) { + const [first] = handlers; + throw new Error( + `Domain event handler "${first.id}" on ${first.source} must be on a static (singleton) provider: the worker calls handlers outside any request`, + ); + } + return handlers; + }); + } + + private handlersOf(instance: object): DiscoveredHandler[] { + const prototype = Object.getPrototypeOf(instance) as object | null; + return this.scanner.getAllMethodNames(prototype).flatMap((methodName) => { + const method: unknown = (prototype as Record)[ + methodName + ]; + if (typeof method !== 'function') { + return []; + } + const source = `${instance.constructor.name}.${methodName}`; + return domainEventHandlerMetadata(method).map((entry) => { + assertValid(entry, source); + return { + id: entry.handlerId, + types: entry.types, + source, + invoke: (method as DomainEventHandlerFn).bind( + instance, + ) as DomainEventHandlerFn, + }; + }); + }); + } +} + +function assertValid(entry: DomainEventHandlerMetadata, source: string): void { + if (!HANDLER_ID_PATTERN.test(entry.handlerId)) { + throw new Error( + `Invalid domain event handler id "${entry.handlerId}" on ${source}: expected "." in lowercase letters, digits and hyphens`, + ); + } + if (entry.types.length === 0) { + throw new Error( + `Domain event handler "${entry.handlerId}" on ${source} serves no domain event type`, + ); + } + const unknown = entry.types.find( + (type) => !DOMAIN_EVENT_TYPES.includes(type), + ); + if (unknown !== undefined) { + throw new Error( + `Unknown domain event type "${unknown}" for handler "${entry.handlerId}" on ${source}`, + ); + } +} + +function groupByType( + handlers: readonly DiscoveredHandler[], +): Map { + const byType = new Map(); + for (const handler of handlers) { + for (const type of handler.types) { + const existing = byType.get(type) ?? []; + const duplicate = existing.find((other) => other.id === handler.id); + if (duplicate) { + throw new Error( + `Duplicate domain event handler "${handler.id}" for ${type} (${duplicate.source} and ${handler.source})`, + ); + } + byType.set(type, [...existing, handler]); + } + } + return new Map( + [...byType].map(([type, list]) => [ + type, + Object.freeze( + [...list] + .sort((a, b) => compareIds(a.id, b.id)) + .map(({ id, invoke }) => ({ id, invoke })), + ), + ]), + ); +} + +/** + * Ordinal comparison by UTF-16 code unit - for handler ids, which + * HANDLER_ID_PATTERN restricts to ASCII, that is code-point order. Unlike + * localeCompare it does not depend on the runtime's locale or ICU data, so + * every pod runs handlers in the same order. + */ +function compareIds(a: string, b: string): number { + if (a < b) { + return -1; + } + return a > b ? 1 : 0; +} diff --git a/ApiCore-Backend/src/domain-events/domain-event-types.ts b/ApiCore-Backend/src/domain-events/domain-event-types.ts new file mode 100644 index 0000000..09c04d6 --- /dev/null +++ b/ApiCore-Backend/src/domain-events/domain-event-types.ts @@ -0,0 +1,173 @@ +import { + ColonyCondition, + ColonyStatus, + Prisma, + RawMaterialKind, + TaskRelatedType, + TaskStatus, +} from '../../generated/prisma'; + +export interface ColonyStateSnapshot { + status: ColonyStatus; + healthStatus: ColonyCondition | null; +} + +/** + * The complete event catalog. A payload carries only ids and the values a + * handler needs - never personal data or secrets; dates are 'YYYY-MM-DD' + * strings. Handlers read anything else from the database. + */ +export interface DomainEventPayloads { + InspectionRecorded: { colonyId: string }; + InspectionCorrected: { colonyId: string }; + VarroaMeasured: { colonyId: string }; + VarroaCorrected: { colonyId: string }; + TreatmentRecorded: { colonyId: string }; + TreatmentCorrected: { colonyId: string }; + FeedingRecorded: { colonyId: string }; + FeedingCorrected: { colonyId: string }; + HarvestRecorded: { apiaryId: string; colonyIds: string[] }; + HarvestCorrected: { apiaryId: string; colonyIds: string[] }; + ColonyStatusChanged: { from: ColonyStateSnapshot; to: ColonyStateSnapshot }; + ColonyEnded: { status: ColonyStatus }; + BatchBottled: { honeyBatchId: string }; + BottlingCorrected: { honeyBatchId: string; removed: boolean }; + StockChanged: { + bottlingBatchIds: string[]; + productIds: string[]; + rawMaterialKinds: RawMaterialKind[]; + }; + TaskCreated: { + relatedType: TaskRelatedType; + relatedId: string | null; + dueDate: string | null; + }; + TaskUpdated: { status: TaskStatus; dueDate: string | null }; + TaskCompleted: { relatedType: TaskRelatedType; relatedId: string | null }; + WithdrawalEnded: { colonyId: string; endedOn: string }; + TaskDue: { dueOn: string }; + QueenAged: { hatchYear: number; colonyId: string | null }; + VarroaMeasurementDue: { dueOn: string; lastMeasuredOn: string | null }; + InspectionOverdue: { dueOn: string; lastInspectedOn: string | null }; +} + +export type DomainEventType = keyof DomainEventPayloads; + +// A Record over the union: the compiler refuses a missing or unknown type. +const CATALOG: Record = { + InspectionRecorded: true, + InspectionCorrected: true, + VarroaMeasured: true, + VarroaCorrected: true, + TreatmentRecorded: true, + TreatmentCorrected: true, + FeedingRecorded: true, + FeedingCorrected: true, + HarvestRecorded: true, + HarvestCorrected: true, + ColonyStatusChanged: true, + ColonyEnded: true, + BatchBottled: true, + BottlingCorrected: true, + StockChanged: true, + TaskCreated: true, + TaskUpdated: true, + TaskCompleted: true, + WithdrawalEnded: true, + TaskDue: true, + QueenAged: true, + VarroaMeasurementDue: true, + InspectionOverdue: true, +}; + +export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze( + Object.keys(CATALOG) as DomainEventType[], +); + +export type AggregateType = + | 'Inspection' + | 'VarroaMeasurement' + | 'Treatment' + | 'Feeding' + | 'Harvest' + | 'Colony' + | 'BottlingBatch' + | 'ProductionBatch' + | 'BatchMovement' + | 'ShopStockRequest' + | 'Task' + | 'Queen'; + +export interface NewDomainEvent { + type: T; + aggregateType: AggregateType; + aggregateId: string; + payload: DomainEventPayloads[T]; + /** The acting user; null or absent for the system or a job. */ + actorUserId?: string | null; + /** Time-based events only, see dailyDedupeKey. */ + dedupeKey?: string; +} + +/** + * One NewDomainEvent per type in T, so `type` and `payload` stay paired: a + * TaskDue with an InspectionRecorded payload does not compile. + * NewDomainEvent alone would accept either payload with either type. + */ +export type AnyNewDomainEvent = { + [K in T]: NewDomainEvent; +}[T]; + +/** What a handler receives: the stored event. */ +export interface DomainEventEnvelope< + T extends DomainEventType = DomainEventType, +> { + id: string; + type: T; + aggregateType: AggregateType; + aggregateId: string; + payload: DomainEventPayloads[T]; + actorUserId: string | null; + correlationId: string; + occurredAt: Date; + attempts: number; +} + +/** + * One DomainEventEnvelope per type in T, paired like AnyNewDomainEvent: a + * handler for several types narrows the payload by checking `event.type`. + */ +export type AnyDomainEventEnvelope< + T extends DomainEventType = DomainEventType, +> = { + [K in T]: DomainEventEnvelope; +}[T]; + +/** + * A handler writes only through `tx` (its delivery transaction) and makes + * no network calls; reading through `tx` or PrismaService is fine. + */ +export type DomainEventHandlerFn = + ( + event: AnyDomainEventEnvelope, + tx: Prisma.TransactionClient, + ) => Promise; + +const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Unique key of a time-based event: at most one per type, aggregate and + * (UTC) day of the business event, however often the job runs. A full + * timestamp instead of the day would create a new event on every run, so + * it is refused. + */ +export function dailyDedupeKey( + type: DomainEventType, + aggregateId: string, + day: string, +): string { + if (!DAY_PATTERN.test(day)) { + throw new Error('Daily dedupe key needs a YYYY-MM-DD day'); + } + return `${type}:${aggregateId}:${day}`; +} diff --git a/ApiCore-Backend/src/domain-events/domain-events.module.ts b/ApiCore-Backend/src/domain-events/domain-events.module.ts new file mode 100644 index 0000000..8a21495 --- /dev/null +++ b/ApiCore-Backend/src/domain-events/domain-events.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { DiscoveryModule } from '@nestjs/core'; +import { PrismaModule } from '../prisma/prisma.module'; +import { DomainEventDispatcher } from './domain-event-dispatcher'; +import { DomainEventRegistry } from './domain-event-registry'; +import { DomainEventsService } from './domain-events.service'; + +@Global() +@Module({ + imports: [DiscoveryModule, PrismaModule], + providers: [DomainEventsService, DomainEventRegistry, DomainEventDispatcher], + exports: [DomainEventsService, DomainEventRegistry, DomainEventDispatcher], +}) +export class DomainEventsModule {} diff --git a/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts new file mode 100644 index 0000000..255f8fd --- /dev/null +++ b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts @@ -0,0 +1,265 @@ +import { DomainEventsService } from './domain-events.service'; +import { domainEventContext } from './domain-event-context'; +import { + AnyNewDomainEvent, + DOMAIN_EVENT_TYPES, + dailyDedupeKey, + NewDomainEvent, +} from './domain-event-types'; + +type CreateManyArg = { + data: { + type: string; + aggregateId: string; + actorUserId: string | null; + correlationId: string; + dedupeKey: string | null; + payload: unknown; + }[]; + skipDuplicates: boolean; +}; + +describe('DomainEventsService', () => { + let service: DomainEventsService; + let tx: { + domainEvent: { createMany: jest.Mock }; + $executeRaw: jest.Mock; + }; + + const event: NewDomainEvent<'InspectionRecorded'> = { + type: 'InspectionRecorded', + aggregateType: 'Inspection', + aggregateId: 'i1', + payload: { colonyId: 'c1' }, + }; + const daily: NewDomainEvent<'TaskDue'> = { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: 't1', + payload: { dueOn: '2026-10-13' }, + dedupeKey: dailyDedupeKey('TaskDue', 't1', '2026-10-13'), + }; + + function createManyArg(call = 0): CreateManyArg { + const calls = tx.domainEvent.createMany.mock.calls as [CreateManyArg][]; + return calls[call][0]; + } + + beforeEach(() => { + tx = { + domainEvent: { + createMany: jest + .fn() + .mockImplementation(({ data }: CreateManyArg) => + Promise.resolve({ count: data.length }), + ), + }, + $executeRaw: jest.fn().mockResolvedValue(1), + }; + service = new DomainEventsService(); + }); + + it('records the event and the wake-up on the same transaction client', async () => { + await service.record(tx as never, { + type: 'InspectionRecorded', + aggregateType: 'Inspection', + aggregateId: 'i1', + payload: { colonyId: 'c1' }, + actorUserId: 'u1', + }); + + expect(tx.domainEvent.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + type: 'InspectionRecorded', + aggregateId: 'i1', + actorUserId: 'u1', + correlationId: expect.any(String) as unknown, + }), + ], + skipDuplicates: true, + }); + expect(tx.$executeRaw).toHaveBeenCalledTimes(1); + }); + + it("wakes the dispatcher with pg_notify('domain_event', '') - never data in the channel", async () => { + await service.record(tx as never, event); + + const [[strings, ...values]] = tx.$executeRaw.mock.calls as [ + [TemplateStringsArray, ...unknown[]], + ]; + expect(strings.join('$')).toBe("SELECT pg_notify($, '')"); + expect(values).toEqual(['domain_event']); + }); + + it('refuses the root PrismaService (must run inside the business transaction)', async () => { + const prismaServiceMock = { + ...tx, + $connect: jest.fn(), + $transaction: jest.fn(), + }; + + await expect( + service.record(prismaServiceMock as never, event), + ).rejects.toThrow( + 'DomainEventsService.record needs the transaction client', + ); + expect(tx.domainEvent.createMany).not.toHaveBeenCalled(); + }); + + it('names recordAll when recordAll gets the root client', async () => { + const prismaServiceMock = { ...tx, $connect: jest.fn() }; + + await expect( + service.recordAll(prismaServiceMock as never, [event]), + ).rejects.toThrow( + 'DomainEventsService.recordAll needs the transaction client', + ); + expect(tx.domainEvent.createMany).not.toHaveBeenCalled(); + }); + + it('records through a transaction client that can open nested transactions (Prisma 7)', async () => { + const nestingTx = { ...tx, $transaction: jest.fn() }; + + await service.record(nestingTx as never, event); + + expect(tx.domainEvent.createMany).toHaveBeenCalledTimes(1); + }); + + it('inherits the correlation id of the event being handled', async () => { + await domainEventContext.run({ correlationId: 'corr-1' }, () => + service.record(tx as never, event), + ); + + expect(createManyArg().data[0].correlationId).toBe('corr-1'); + }); + + it('starts a new correlation id per call outside a handler, shared by all events of the call', async () => { + await service.recordAll(tx as never, [event, daily]); + await service.record(tx as never, event); + + const [first, second] = createManyArg(0).data; + expect(first.correlationId).toBe(second.correlationId); + expect(createManyArg(1).data[0].correlationId).not.toBe( + first.correlationId, + ); + }); + + it('stores missing actor and dedupe key as null and keeps the payload as given', async () => { + await service.recordAll(tx as never, [event, daily]); + + const [plain, withKey] = createManyArg().data; + expect(plain.actorUserId).toBeNull(); + expect(plain.dedupeKey).toBeNull(); + expect(plain.payload).toEqual({ colonyId: 'c1' }); + expect(withKey.dedupeKey).toBe('TaskDue:t1:2026-10-13'); + }); + + it('wakes the worker only when something was inserted', async () => { + tx.domainEvent.createMany.mockResolvedValue({ count: 0 }); + + await expect(service.recordAll(tx as never, [daily])).resolves.toBe(0); + + expect(tx.$executeRaw).not.toHaveBeenCalled(); + }); + + it('returns the number of inserted events', async () => { + tx.domainEvent.createMany.mockResolvedValue({ count: 1 }); + + await expect(service.recordAll(tx as never, [event, daily])).resolves.toBe( + 1, + ); + expect(tx.$executeRaw).toHaveBeenCalledTimes(1); + }); + + it('does nothing for an empty list', async () => { + await expect(service.recordAll(tx as never, [])).resolves.toBe(0); + + expect(tx.domainEvent.createMany).not.toHaveBeenCalled(); + expect(tx.$executeRaw).not.toHaveBeenCalled(); + }); +}); + +describe('domain event catalog', () => { + it('lists every event type exactly once', () => { + expect(DOMAIN_EVENT_TYPES).toHaveLength(23); + expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length); + expect(DOMAIN_EVENT_TYPES).toEqual( + expect.arrayContaining([ + 'InspectionRecorded', + 'StockChanged', + 'TaskDue', + 'InspectionOverdue', + ]), + ); + }); + + it('builds the daily dedupe key as "::"', () => { + expect(dailyDedupeKey('QueenAged', 'q1', '2026-10-13')).toBe( + 'QueenAged:q1:2026-10-13', + ); + }); + + it.each(['2026-10-13T00:00:00.000Z', '13.10.2026', '2026-1-3', ''])( + 'refuses %p as day - a timestamp would make every run a new event', + (day) => { + expect(() => dailyDedupeKey('TaskDue', 't1', day)).toThrow( + 'Daily dedupe key needs a YYYY-MM-DD day', + ); + }, + ); +}); + +// The @ts-expect-error lines are checked by `npm run typecheck`: if the +// type stopped refusing the mismatch, the unused directive fails it. +describe('domain event typing', () => { + const tx = { + domainEvent: { createMany: jest.fn().mockResolvedValue({ count: 0 }) }, + }; + const service = new DomainEventsService(); + + it('ties every event type to its payload in recordAll', async () => { + const recorded = service.recordAll(tx as never, [ + { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: 't1', + payload: { dueOn: '2026-10-13' }, + }, + { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: 't1', + // @ts-expect-error - TaskDue carries { dueOn }, not { colonyId } + payload: { colonyId: 'c1' }, + }, + ]); + + await expect(recorded).resolves.toBe(0); + }); + + it('refuses a stored event value whose payload belongs to another type', () => { + const misfit = { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: 't1', + payload: { colonyId: 'c1' }, + } as const; + // @ts-expect-error - TaskDue carries { dueOn }, not { colonyId } + const wrong: AnyNewDomainEvent = misfit; + + expect(wrong.type).toBe('TaskDue'); + }); + + it('keeps record checking the payload against the literal type', async () => { + await service.record(tx as never, { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: 't1', + // @ts-expect-error - TaskDue carries { dueOn }, not { colonyId } + payload: { colonyId: 'c1' }, + }); + + expect(tx.domainEvent.createMany).toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/domain-events/domain-events.service.ts b/ApiCore-Backend/src/domain-events/domain-events.service.ts new file mode 100644 index 0000000..f9bf476 --- /dev/null +++ b/ApiCore-Backend/src/domain-events/domain-events.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { Prisma } from '../../generated/prisma'; +import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup'; +import { assertTransactionClient } from '../prisma/db-client'; +import { domainEventContext } from './domain-event-context'; +import { + AnyNewDomainEvent, + DomainEventType, + NewDomainEvent, +} from './domain-event-types'; + +/** + * Writes domain events into the outbox. Always inside the business + * transaction: no commit, no event - and no commit without its event. + */ +@Injectable() +export class DomainEventsService { + async record( + tx: Prisma.TransactionClient, + event: NewDomainEvent, + ): Promise { + assertTransactionClient(tx, 'DomainEventsService.record'); + await this.insert(tx, [event]); + } + + /** + * Returns how many events were inserted - an event whose dedupeKey + * already exists is skipped. The worker is woken only if something new + * was written; Postgres delivers the wake-up on commit. + */ + async recordAll( + tx: Prisma.TransactionClient, + events: readonly AnyNewDomainEvent[], + ): Promise { + assertTransactionClient(tx, 'DomainEventsService.recordAll'); + return this.insert(tx, events); + } + + private async insert( + tx: Prisma.TransactionClient, + events: readonly NewDomainEvent[], + ): Promise { + if (events.length === 0) { + return 0; + } + const correlationId = + domainEventContext.getStore()?.correlationId ?? randomUUID(); + const { count } = await tx.domainEvent.createMany({ + data: events.map((event) => ({ + type: event.type, + aggregateType: event.aggregateType, + aggregateId: event.aggregateId, + payload: event.payload as Prisma.InputJsonValue, + actorUserId: event.actorUserId ?? null, + correlationId, + dedupeKey: event.dedupeKey ?? null, + })), + skipDuplicates: true, + }); + if (count > 0) { + await notifyWorker(tx, WAKE_CHANNELS.domainEvents); + } + return count; + } +} diff --git a/ApiCore-Backend/src/domain-events/handler-contract.spec.ts b/ApiCore-Backend/src/domain-events/handler-contract.spec.ts new file mode 100644 index 0000000..22583a7 --- /dev/null +++ b/ApiCore-Backend/src/domain-events/handler-contract.spec.ts @@ -0,0 +1,209 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve, sep } from 'node:path'; + +/** + * Guard for the handler contract (Review Focus 2): a domain event handler + * runs inside its delivery transaction and may be repeated after a crash, + * so it only writes to the database. Network work belongs to an outbox of + * its own (calendar sync, WebhooksService.enqueueDeliveries) that a worker + * loop drains. A static source scan: + * - every @OnDomainEvent handler lives in a `*.handlers.ts` file, and + * - a handler file imports no network client: no http/https/fetch library, + * no `fetch(` call, nothing from graph-client or webhook-delivery, and no + * src module that itself makes network calls (a fetch wrapper). + */ +const SRC = join(__dirname, '..'); +const DECORATOR_FILE = join( + SRC, + 'domain-events', + 'on-domain-event.decorator.ts', +); + +interface SourceFile { + path: string; + source: string; +} + +const NETWORK_PACKAGES = new Set([ + 'http', + 'https', + 'http2', + 'net', + 'tls', + 'dgram', + 'undici', + 'axios', + 'node-fetch', + 'cross-fetch', + 'got', + 'ky', + 'superagent', +]); +const NETWORK_MODULE_NAME = /graph-client|webhook-delivery/; +const FETCH_CALL = /\bfetch\s*\(/; +const IMPORT_SPECIFIER = + /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g; + +function importsOf(source: string): string[] { + return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]); +} + +function isNetworkPackage(specifier: string): boolean { + return NETWORK_PACKAGES.has(specifier.replace(/^node:/, '')); +} + +/** A module that talks to the network itself. */ +function makesNetworkCalls(file: SourceFile): boolean { + return ( + FETCH_CALL.test(file.source) || + importsOf(file.source).some(isNetworkPackage) + ); +} + +function resolveImport(from: string, specifier: string): string | undefined { + if (!specifier.startsWith('.')) { + return undefined; + } + const base = resolve(dirname(from), specifier); + return [`${base}.ts`, join(base, 'index.ts')].find(existsSync) ?? base; +} + +/** Files declaring @OnDomainEvent handlers outside a `*.handlers.ts` file. */ +function misplacedHandlerFiles(files: readonly SourceFile[]): string[] { + return files + .filter( + (file) => + file.path !== DECORATOR_FILE && + !file.path.endsWith('.spec.ts') && + file.source.includes('@OnDomainEvent(') && + !file.path.endsWith('.handlers.ts'), + ) + .map((file) => file.path); +} + +/** What a handler file must not use; empty when it keeps the contract. */ +function networkViolations( + handlerFile: SourceFile, + networkModules: ReadonlySet, +): string[] { + const violations = importsOf(handlerFile.source).filter((specifier) => { + const resolved = resolveImport(handlerFile.path, specifier); + return ( + isNetworkPackage(specifier) || + NETWORK_MODULE_NAME.test(specifier) || + (resolved !== undefined && networkModules.has(resolved)) + ); + }); + return FETCH_CALL.test(handlerFile.source) + ? [...violations, 'fetch('] + : violations; +} + +function sourceFiles(): SourceFile[] { + return readdirSync(SRC, { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.ts')) + .map((file) => join(SRC, file)) + .map((path) => ({ path, source: readFileSync(path, 'utf8') })); +} + +function networkModulesOf(files: readonly SourceFile[]): Set { + return new Set( + files + .filter((file) => !file.path.endsWith('.spec.ts')) + .filter(makesNetworkCalls) + .map((file) => file.path), + ); +} + +describe('domain event handler contract', () => { + const files = sourceFiles(); + const networkModules = networkModulesOf(files); + + it('every @OnDomainEvent handler lives in a *.handlers.ts file', () => { + expect(misplacedHandlerFiles(files)).toEqual([]); + }); + + it('handler files never import network clients', () => { + for (const file of files.filter((f) => f.path.endsWith('.handlers.ts'))) { + expect({ + file: file.path, + violations: networkViolations(file, networkModules), + }).toEqual({ + file: file.path, + violations: [], + }); + } + }); + + it('knows the network modules of the code base (fetch wrappers included)', () => { + const relative = [...networkModules].map((path) => + path + .slice(SRC.length + 1) + .split(sep) + .join('/'), + ); + expect(relative).toEqual( + expect.arrayContaining([ + 'calendar-sync/graph-client.service.ts', + 'webhooks/webhook-delivery.ts', + 'weather/weather.service.ts', + 'common/password/password-policy.ts', + ]), + ); + expect(relative).not.toContain('webhooks/webhooks.service.ts'); + }); + + describe('with planted violations', () => { + const handlerPath = join(SRC, 'colonies', 'colonies.handlers.ts'); + + it('flags a handler declared outside a *.handlers.ts file', () => { + const planted = { + path: join(SRC, 'colonies', 'colonies.service.ts'), + source: "@OnDomainEvent('TaskDue', 'colonies.due')\n", + }; + + expect(misplacedHandlerFiles([planted])).toEqual([planted.path]); + expect( + misplacedHandlerFiles([ + { path: handlerPath, source: planted.source }, + { + path: `${planted.path.slice(0, -3)}.spec.ts`, + source: planted.source, + }, + { path: DECORATOR_FILE, source: planted.source }, + ]), + ).toEqual([]); + }); + + it.each([ + "import https from 'https';", + "import { request } from 'http';", + "import { get } from 'node:https';", + "const http = require('node:http');", + "import { fetch as undiciFetch } from 'undici';", + "import axios from 'axios';", + 'const response = await fetch(url);', + "import { GraphClientService } from '../calendar-sync/graph-client.service';", + "import { postWebhook } from '../webhooks/webhook-delivery';", + "import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';", + "import { WeatherService } from '../weather/weather.service';", + "const { checkPassword } = await import('../common/password/password-policy');", + ])('flags %s', (line) => { + expect( + networkViolations({ path: handlerPath, source: line }, networkModules), + ).not.toEqual([]); + }); + + it.each([ + "import { WebhooksService } from '../webhooks/webhooks.service';", + "import { Prisma } from '../../generated/prisma';", + "import type { DomainEventEnvelope } from '../domain-events/domain-event-types';", + 'const prefetched = await tx.task.findMany();', + "const scheme = 'https';", + ])('lets a database-only line pass: %s', (line) => { + expect( + networkViolations({ path: handlerPath, source: line }, networkModules), + ).toEqual([]); + }); + }); +}); diff --git a/ApiCore-Backend/src/domain-events/on-domain-event.decorator.ts b/ApiCore-Backend/src/domain-events/on-domain-event.decorator.ts new file mode 100644 index 0000000..5734d2b --- /dev/null +++ b/ApiCore-Backend/src/domain-events/on-domain-event.decorator.ts @@ -0,0 +1,50 @@ +import { DomainEventType } from './domain-event-types'; + +/** + * ".", e.g. "calendar.sync-source". The id is stored per + * delivery, so it must stay stable once events were delivered to it. + */ +export const HANDLER_ID_PATTERN = /^[a-z0-9-]+\.[a-z0-9-]+$/; + +const DOMAIN_EVENT_HANDLER_METADATA = 'domain-event-handler'; + +export interface DomainEventHandlerMetadata { + types: readonly DomainEventType[]; + handlerId: string; +} + +/** + * Marks a provider method as handler for one or more event types; the + * DomainEventRegistry finds it at boot and validates the id. Decorators + * stack - each adds its own entry. + */ +export function OnDomainEvent( + types: DomainEventType | readonly DomainEventType[], + handlerId: string, +): MethodDecorator { + const entry: DomainEventHandlerMetadata = { + types: typeof types === 'string' ? [types] : [...types], + handlerId, + }; + return (_target, _propertyKey, descriptor) => { + const method = descriptor.value as object; + Reflect.defineMetadata( + DOMAIN_EVENT_HANDLER_METADATA, + [...domainEventHandlerMetadata(method), entry], + method, + ); + return descriptor; + }; +} + +export function domainEventHandlerMetadata( + method: object, +): readonly DomainEventHandlerMetadata[] { + const entries: unknown = Reflect.getMetadata( + DOMAIN_EVENT_HANDLER_METADATA, + method, + ); + return Array.isArray(entries) + ? (entries as DomainEventHandlerMetadata[]) + : []; +} diff --git a/ApiCore-Backend/src/health/health.controller.ts b/ApiCore-Backend/src/health/health.controller.ts index b15aa93..d8f5a5d 100644 --- a/ApiCore-Backend/src/health/health.controller.ts +++ b/ApiCore-Backend/src/health/health.controller.ts @@ -6,27 +6,14 @@ import { VERSION_NEUTRAL, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; import { PrismaService } from '../prisma/prisma.service'; import { Public } from '../auth/decorators/public.decorator'; import { loggableError } from '../common/loggable-error'; +import { withTimeout } from '../common/with-timeout'; const READINESS_QUERY_TIMEOUT_MS = 5000; -async function withTimeout(promise: Promise, ms: number): Promise { - let timer: NodeJS.Timeout; - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error('Readiness query timed out')), - ms, - ); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - clearTimeout(timer!); - } -} - @ApiTags('health') @Public() @Controller({ path: 'health', version: VERSION_NEUTRAL }) @@ -35,6 +22,10 @@ export class HealthController { constructor(private readonly prisma: PrismaService) {} + // Liveness must not touch the throttler: it needs the database, and a database + // outage must not turn this probe red (kubelet would restart all pods). + // Readiness stays throttled and may fail with the database. + @SkipThrottle() @Get() liveness(): { status: 'ok' } { return { status: 'ok' }; @@ -46,6 +37,7 @@ export class HealthController { await withTimeout( this.prisma.$queryRaw`SELECT 1`, READINESS_QUERY_TIMEOUT_MS, + 'Readiness query timed out', ); return { status: 'ok' }; } catch (error: unknown) { diff --git a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts index b0edede..4bc6298 100644 --- a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts +++ b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts @@ -35,6 +35,12 @@ export class CreateInspectionDto { @Min(0) colonyStrength?: number; + @ApiPropertyOptional({ description: 'Anzahl Brutwaben' }) + @IsOptional() + @IsInt() + @Min(0) + broodFrames?: number; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/ApiCore-Backend/src/main.ts b/ApiCore-Backend/src/main.ts index 6fdc3a5..190f089 100644 --- a/ApiCore-Backend/src/main.ts +++ b/ApiCore-Backend/src/main.ts @@ -1,55 +1,22 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { AppModule } from './app.module'; import { configureApp } from './app.config'; - -const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const; -// JWT_ACCESS_SECRET is the root of trust for both authentication (every -// access/MFA-challenge/SSO-completion token is signed with it) and MFA -// secret confidentiality (mfa-crypto.util.ts derives the AES key that -// encrypts stored TOTP secrets from it) - a short or guessable value here -// would let an attacker forge tokens for any user AND decrypt everyone's -// stored TOTP secret, not just guess a password. 32 chars matches the -// output of "openssl rand -hex 32" / "-base64 32", the standard way to -// generate one. -const MIN_JWT_SECRET_LENGTH = 32; - -function assertProductionSettings(): void { - if (process.env.NODE_ENV !== 'production') { - return; - } - // Without it CORS and SSO redirects silently fall back to localhost. - if (!process.env.FRONTEND_ORIGIN?.startsWith('https://')) { - throw new Error( - 'FRONTEND_ORIGIN must be set to an https:// origin in production', - ); - } - if (process.env.COOKIE_SECURE === 'false') { - throw new Error('COOKIE_SECURE=false is for local HTTP development only'); - } -} - -function assertRequiredEnvVars(): void { - const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]); - if (missing.length > 0) { - throw new Error( - `Missing required environment variable(s): ${missing.join(', ')}`, - ); - } - const jwtSecret = process.env.JWT_ACCESS_SECRET as string; - if (jwtSecret.length < MIN_JWT_SECRET_LENGTH) { - throw new Error( - `JWT_ACCESS_SECRET must be at least ${MIN_JWT_SECRET_LENGTH} characters (got ${jwtSecret.length}) - generate one with e.g. "openssl rand -hex 32"`, - ); - } -} +import { resolveRole } from './runtime/role'; +import { isHttpRole, rootModuleFor } from './runtime/root-module'; +import { assertStartupEnvironment } from './runtime/startup-checks'; async function bootstrap() { - assertRequiredEnvVars(); - assertProductionSettings(); + const role = resolveRole('main'); + // resolveRole('main') admits no other role; this only narrows the type. + if (!isHttpRole(role)) { + throw new Error(`APICORE_ROLE=${role} cannot start dist/main.js`); + } + assertStartupEnvironment(role); - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create( + rootModuleFor(role), + ); app.enableShutdownHooks(); configureApp(app); diff --git a/ApiCore-Backend/src/notifications/notifications.service.spec.ts b/ApiCore-Backend/src/notifications/notifications.service.spec.ts index 60906ed..4a5449a 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.spec.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.spec.ts @@ -2,25 +2,35 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { NotificationsService } from './notifications.service'; import { PrismaService } from '../prisma/prisma.service'; +type CreateManyArg = { data: { userId: string; message: string }[] }; + describe('NotificationsService', () => { let service: NotificationsService; let prisma: { notification: { create: jest.Mock; + createMany: jest.Mock; findMany: jest.Mock; findUnique: jest.Mock; update: jest.Mock; }; + user: { findMany: jest.Mock }; }; beforeEach(() => { prisma = { notification: { create: jest.fn(), + createMany: jest + .fn() + .mockImplementation(({ data }: CreateManyArg) => + Promise.resolve({ count: data.length }), + ), findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), }, + user: { findMany: jest.fn().mockResolvedValue([]) }, }; service = new NotificationsService(prisma as unknown as PrismaService); }); @@ -44,6 +54,93 @@ describe('NotificationsService', () => { }); }); + it('records through the transaction client when one is given', async () => { + const tx = { notification: { create: jest.fn() } }; + + await service.record( + { priority: 'HINWEIS', message: 'Aufgabe fällig' }, + tx as never, + ); + + expect(tx.notification.create).toHaveBeenCalledTimes(1); + expect(prisma.notification.create).not.toHaveBeenCalled(); + }); + + describe('recordForPermission', () => { + it('notifies every active user whose active role holds the permission', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]); + + await expect( + service.recordForPermission('backup:configure', { + priority: 'WARNUNG', + message: 'Job „App-Backup“ fehlgeschlagen', + }), + ).resolves.toBe(2); + + const [[createArg]] = prisma.notification.createMany.mock.calls as [ + [CreateManyArg], + ]; + expect(createArg.data.map((n) => n.userId)).toEqual(['u1', 'u2']); + expect(createArg.data[0]).toEqual({ + userId: 'u1', + priority: 'WARNUNG', + message: 'Job „App-Backup“ fehlgeschlagen', + }); + }); + + it('selects recipients like the permission guard: active user, active role, granted key', async () => { + await service.recordForPermission('backup:configure', { + priority: 'WARNUNG', + message: 'x', + }); + + expect(prisma.user.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + role: { + status: 'ACTIVE', + permissions: { + some: { permission: { key: 'backup:configure' } }, + }, + }, + }, + select: { id: true }, + }); + }); + + it('writes nothing and returns 0 when nobody holds the permission', async () => { + await expect( + service.recordForPermission('backup:configure', { + priority: 'WARNUNG', + message: 'x', + }), + ).resolves.toBe(0); + + expect(prisma.notification.createMany).not.toHaveBeenCalled(); + }); + + it('reads and writes through the transaction client when one is given', async () => { + const tx = { + user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, + notification: { + createMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + + await expect( + service.recordForPermission( + 'backup:configure', + { priority: 'WARNUNG', message: 'x' }, + tx as never, + ), + ).resolves.toBe(1); + + expect(tx.notification.createMany).toHaveBeenCalledTimes(1); + expect(prisma.user.findMany).not.toHaveBeenCalled(); + expect(prisma.notification.createMany).not.toHaveBeenCalled(); + }); + }); + it('lists notifications for a user including broadcast notifications', async () => { await service.findAll({ userId: 'user-1' }); diff --git a/ApiCore-Backend/src/notifications/notifications.service.ts b/ApiCore-Backend/src/notifications/notifications.service.ts index edb06b7..2a44ed3 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.ts @@ -4,7 +4,13 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { Notification, NotificationPriority } from '../../generated/prisma'; +import { DbClient } from '../prisma/db-client'; +import { PermissionKey } from '../auth/permissions/permission-catalog'; +import { + Notification, + NotificationPriority, + Prisma, +} from '../../generated/prisma'; export interface CreateNotificationInput { userId?: string; @@ -23,8 +29,42 @@ export interface NotificationFilter { export class NotificationsService { constructor(private readonly prisma: PrismaService) {} - async record(input: CreateNotificationInput): Promise { - return this.prisma.notification.create({ data: { ...input } }); + async record( + input: CreateNotificationInput, + tx?: Prisma.TransactionClient, + ): Promise { + const db: DbClient = tx ?? this.prisma; + return db.notification.create({ data: { ...input } }); + } + + /** + * One notification per user who may act on it: active account, active + * role, permission granted - the same rule the PermissionsGuard applies. + * Returns how many were written. + */ + async recordForPermission( + permission: PermissionKey, + input: Omit, + tx?: Prisma.TransactionClient, + ): Promise { + const db: DbClient = tx ?? this.prisma; + const recipients = await db.user.findMany({ + where: { + isActive: true, + role: { + status: 'ACTIVE', + permissions: { some: { permission: { key: permission } } }, + }, + }, + select: { id: true }, + }); + if (recipients.length === 0) { + return 0; + } + const { count } = await db.notification.createMany({ + data: recipients.map((recipient) => ({ ...input, userId: recipient.id })), + }); + return count; } async findAll(filter: NotificationFilter = {}): Promise { diff --git a/ApiCore-Backend/src/prisma/db-client.spec.ts b/ApiCore-Backend/src/prisma/db-client.spec.ts new file mode 100644 index 0000000..68e6281 --- /dev/null +++ b/ApiCore-Backend/src/prisma/db-client.spec.ts @@ -0,0 +1,25 @@ +import { assertTransactionClient } from './db-client'; + +describe('assertTransactionClient', () => { + it('refuses the root client and names the caller', () => { + const root = { $connect: jest.fn(), $transaction: jest.fn() }; + + expect(() => + assertTransactionClient(root as never, 'DomainEventsService.recordAll'), + ).toThrow('DomainEventsService.recordAll needs the transaction client'); + }); + + it('accepts a transaction client that can open nested transactions (Prisma 7)', () => { + const nestingTx = { $transaction: jest.fn(), $executeRaw: jest.fn() }; + + expect(() => + assertTransactionClient(nestingTx as never, 'caller'), + ).not.toThrow(); + }); + + it('accepts a plain transaction client', () => { + expect(() => + assertTransactionClient({ $executeRaw: jest.fn() } as never, 'caller'), + ).not.toThrow(); + }); +}); diff --git a/ApiCore-Backend/src/prisma/db-client.ts b/ApiCore-Backend/src/prisma/db-client.ts new file mode 100644 index 0000000..0a7e553 --- /dev/null +++ b/ApiCore-Backend/src/prisma/db-client.ts @@ -0,0 +1,23 @@ +import { Prisma } from '../../generated/prisma'; +import { PrismaService } from './prisma.service'; + +/** + * The root client or the client of an interactive transaction, for helpers + * that work on either (e.g. notifyWorker). A helper that must run inside the + * caller's business transaction takes Prisma.TransactionClient instead and + * checks it with assertTransactionClient. + */ +export type DbClient = PrismaService | Prisma.TransactionClient; + +/** + * Throws if `db` is the root client: a write through it would commit on its + * own, even if the caller's business transaction rolls back. `$connect` is + * withheld from transaction clients; `$transaction` does not tell the two + * apart, because a Prisma 7 transaction client can open nested transactions. + * `caller` names the method in the error, e.g. "DomainEventsService.recordAll". + */ +export function assertTransactionClient(db: DbClient, caller: string): void { + if ('$connect' in db) { + throw new Error(`${caller} needs the transaction client`); + } +} diff --git a/ApiCore-Backend/src/prisma/db-session.spec.ts b/ApiCore-Backend/src/prisma/db-session.spec.ts new file mode 100644 index 0000000..f70d548 --- /dev/null +++ b/ApiCore-Backend/src/prisma/db-session.spec.ts @@ -0,0 +1,130 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + DB_SESSION_OPTIONS, + DB_SESSION_TIME_ZONE, + dbSessionConfig, +} from './db-session'; + +describe('dbSessionConfig', () => { + const url = 'postgresql://apicore:s3cret@db:5432/apicore?schema=public'; + + it('pins every session to UTC through the startup options', () => { + expect(DB_SESSION_TIME_ZONE).toBe('UTC'); + expect(DB_SESSION_OPTIONS).toBe('-c TimeZone=UTC'); + expect(dbSessionConfig(url)).toEqual({ + connectionString: url, + options: '-c TimeZone=UTC', + }); + }); + + it('keeps a missing connection string for pg to report', () => { + expect(dbSessionConfig(undefined)).toEqual({ + connectionString: undefined, + options: '-c TimeZone=UTC', + }); + }); + + it.each([ + 'postgresql://apicore:s3cret@db/apicore?options=-c%20TimeZone%3DEurope%2FBerlin', + 'postgresql://apicore:s3cret@db/apicore?schema=public&OPTIONS=-c%20x%3Dy', + 'postgresql://apicore:s3cret@db/apicore?options', + 'postgresql://apicore:s3cret@db/apicore?options&schema=public', + ])( + 'refuses a URL with its own options - pg would let them override the pin: %s', + (withOptions) => { + expect(() => dbSessionConfig(withOptions)).toThrow( + 'DATABASE_URL must not set "options"', + ); + }, + ); + + it.each([ + 'postgresql://apicore:s3cret@db/apicore?schema=public&optionsx=1', + 'postgresql://apicore:s3cret@db/options?schema=public', + ])('accepts a URL that only looks similar: %s', (similar) => { + expect(dbSessionConfig(similar).options).toBe('-c TimeZone=UTC'); + }); + + it('never echoes the connection string (password) in the error', () => { + let message = ''; + try { + dbSessionConfig('postgresql://apicore:s3cret@db/apicore?options=x'); + } catch (error) { + message = (error as Error).message; + } + + expect(message).toContain('DATABASE_URL must not set "options"'); + expect(message).not.toContain('s3cret'); + }); +}); + +/** + * Guard: every Postgres client in src gets its settings from + * dbSessionConfig, so no session escapes the UTC pin. prisma/seed.ts is the + * documented exception: it lives outside src (its build cannot import from + * src) and writes only through Prisma, whose timestamps do not depend on + * the session time zone. + */ +describe('database clients in src', () => { + const SRC = join(__dirname, '..'); + const CLIENT_CONSTRUCTION = + /new\s+(PrismaPg|Pool|Client)\s*\(([\s\S]{0,120})/g; + const FROM_SESSION_CONFIG = /^\s*(?:\{\s*\.\.\.)?\s*dbSessionConfig\(/; + const IMPORTS_PG = /from\s+['"]pg['"]/; + + /** `new X(` calls that do not start from dbSessionConfig(...). */ + function clientsWithoutSessionConfig(source: string): string[] { + const usesPg = IMPORTS_PG.test(source); + return [...source.matchAll(CLIENT_CONSTRUCTION)] + .filter(([, type]) => type === 'PrismaPg' || usesPg) + .filter(([, , args]) => !FROM_SESSION_CONFIG.test(args)) + .map(([, type]) => type); + } + + it('builds every PrismaPg, pg Pool and pg Client from dbSessionConfig', () => { + const offenders = readdirSync(SRC, { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.ts') && !file.endsWith('.spec.ts')) + .flatMap((file) => + clientsWithoutSessionConfig(readFileSync(join(SRC, file), 'utf8')).map( + (client) => `${file}: new ${client}`, + ), + ); + + expect(offenders).toEqual([]); + }); + + it.each([ + [ + 'a pg Pool', + "import { Pool } from 'pg';\nconst pool = new Pool({ connectionString: url });", + ], + [ + 'a pg Client', + "import { Client } from 'pg';\nconst client = new Client({ connectionString, keepAlive: true });", + ], + [ + 'a PrismaPg adapter', + 'const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });', + ], + ])('flags %s built without the session config', (_name, source) => { + expect(clientsWithoutSessionConfig(source)).toHaveLength(1); + }); + + it.each([ + [ + 'the Prisma adapter', + 'const adapter = new PrismaPg(dbSessionConfig(process.env.DATABASE_URL));', + ], + [ + 'the LISTEN client', + "import { Client } from 'pg';\nreturn new Client({\n ...dbSessionConfig(connectionString),\n keepAlive: true,\n});", + ], + [ + 'a Client that is not pg', + "import { Client } from 'openid-client';\nconst oidc = new Client(metadata);", + ], + ])('accepts %s', (_name, source) => { + expect(clientsWithoutSessionConfig(source)).toEqual([]); + }); +}); diff --git a/ApiCore-Backend/src/prisma/db-session.ts b/ApiCore-Backend/src/prisma/db-session.ts new file mode 100644 index 0000000..5e9a108 --- /dev/null +++ b/ApiCore-Backend/src/prisma/db-session.ts @@ -0,0 +1,34 @@ +/** + * Every database session runs in UTC. Columns are `timestamp without time + * zone`: Prisma fills @default(now()) and writes Dates as UTC wall clock, + * while SQL now() (claims, backoff, throttler) is stored in the session's + * time zone. Only a UTC session keeps both on one clock, whatever the + * server or database default is. + */ +export const DB_SESSION_TIME_ZONE = 'UTC'; +export const DB_SESSION_OPTIONS = `-c TimeZone=${DB_SESSION_TIME_ZONE}`; + +export interface DbSessionConfig { + connectionString: string | undefined; + /** Startup options sent with every new connection. */ + options: string; +} + +/** + * Connection settings for every Postgres client of the app (Prisma pool, + * LISTEN client). pg lets an `options` parameter in the URL override the + * config, which would silently drop the UTC pin - such a URL is refused. + */ +export function dbSessionConfig( + connectionString: string | undefined, +): DbSessionConfig { + if ( + connectionString !== undefined && + /[?&]options(?:=|&|$)/i.test(connectionString) + ) { + throw new Error( + 'DATABASE_URL must not set "options": the session time zone is pinned to UTC in db-session.ts', + ); + } + return { connectionString, options: DB_SESSION_OPTIONS }; +} diff --git a/ApiCore-Backend/src/prisma/prisma.service.ts b/ApiCore-Backend/src/prisma/prisma.service.ts index ec6d742..d19f3a7 100644 --- a/ApiCore-Backend/src/prisma/prisma.service.ts +++ b/ApiCore-Backend/src/prisma/prisma.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../../generated/prisma'; +import { dbSessionConfig } from './db-session'; @Injectable() export class PrismaService @@ -9,7 +10,8 @@ export class PrismaService { constructor() { super({ - adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), + // Every pooled connection runs in UTC, see db-session.ts. + adapter: new PrismaPg(dbSessionConfig(process.env.DATABASE_URL)), }); } diff --git a/ApiCore-Backend/src/public/public.controller.ts b/ApiCore-Backend/src/public/public.controller.ts index 4b7bc20..2710317 100644 --- a/ApiCore-Backend/src/public/public.controller.ts +++ b/ApiCore-Backend/src/public/public.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; +import { Controller, Get, NotFoundException, Param, Res } from '@nestjs/common'; +import type { Response } from 'express'; import { Throttle } from '@nestjs/throttler'; import { ApiTags } from '@nestjs/swagger'; import { Public } from '../auth/decorators/public.decorator'; @@ -7,6 +8,11 @@ import { PublicBatch, PublicService } from './public.service'; // What a printed lot number can look like - anything else is not a lot. const LOT_NUMBER_PATTERN = /^[A-Za-z0-9._/-]{1,64}$/; +// Nothing on this page is personal or stand-specific (see PublicOrigin), so +// a successful answer may sit in the browser and at the Cloudflare edge. Only +// the 200 gets this header - errors keep the global no-store. +export const PUBLIC_BATCH_CACHE_CONTROL = 'public, max-age=300, s-maxage=3600'; + @ApiTags('public') @Public() @Controller('public/batches') @@ -17,10 +23,16 @@ export class PublicController { // so it gets a tighter per-IP limit than logged-in traffic. @Throttle({ default: { limit: 30, ttl: 60000 } }) @Get(':lotNumber') - findByLot(@Param('lotNumber') lotNumber: string): Promise { + async findByLot( + @Param('lotNumber') lotNumber: string, + @Res({ passthrough: true }) res: Response, + ): Promise { if (!LOT_NUMBER_PATTERN.test(lotNumber)) { throw new NotFoundException(); } - return this.publicService.findBatchByLot(lotNumber); + const batch = await this.publicService.findBatchByLot(lotNumber); + res.setHeader('Cache-Control', PUBLIC_BATCH_CACHE_CONTROL); + res.removeHeader('Pragma'); + return batch; } } diff --git a/ApiCore-Backend/src/public/public.service.spec.ts b/ApiCore-Backend/src/public/public.service.spec.ts index 2351779..db9b034 100644 --- a/ApiCore-Backend/src/public/public.service.spec.ts +++ b/ApiCore-Backend/src/public/public.service.spec.ts @@ -3,6 +3,26 @@ import { PublicService } from './public.service'; import { PrismaService } from '../prisma/prisma.service'; import { SiteSettingsService } from '../site-settings/site-settings.service'; +function lotWithApiaries(apiaries: Record[]) { + return { + lotNumber: 'WH-1', + date: new Date('2026-08-20'), + bestBeforeDate: new Date('2028-08-20'), + jarSize: 500, + product: null, + honeyBatch: { + honeyType: null, + waterContent: null, + extraction: { + date: null, + harvests: apiaries.map((apiary) => ({ + harvest: { date: new Date('2026-08-12'), apiary }, + })), + }, + }, + }; +} + describe('PublicService', () => { let service: PublicService; let prisma: { bottlingBatch: { findUnique: jest.Mock } }; @@ -15,6 +35,7 @@ describe('PublicService', () => { youtubeUrl: null, instagramUrl: null, tiktokUrl: null, + originCountry: 'Deutschland', }), }; service = new PublicService( @@ -30,7 +51,7 @@ describe('PublicService', () => { ); }); - it('shapes the traceability chain into public-safe fields, deduplicating apiaries', async () => { + it('shapes the traceability chain into public-safe fields, deduplicating regions', async () => { const harvestDate1 = new Date('2026-08-05'); const harvestDate2 = new Date('2026-08-12'); prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ @@ -51,7 +72,11 @@ describe('PublicService', () => { apiary: { id: 'apiary-1', name: 'Stand Wald', - location: 'Wald', + location: 'Waldweg 3', + address: 'Waldweg 3, 12345 Ort', + latitude: 51.2, + longitude: 9.4, + publicLocationLabel: ' Nordhessen ', }, }, }, @@ -61,7 +86,11 @@ describe('PublicService', () => { apiary: { id: 'apiary-1', name: 'Stand Wald', - location: 'Wald', + location: 'Waldweg 3', + address: 'Waldweg 3, 12345 Ort', + latitude: 51.2, + longitude: 9.4, + publicLocationLabel: ' Nordhessen ', }, }, }, @@ -75,7 +104,10 @@ describe('PublicService', () => { expect(result.lotNumber).toBe('WH-2026-001'); expect(result.productName).toBe('Sommerhonig 500g'); expect(result.waterContent).toBe(16.8); - expect(result.apiaries).toEqual([{ name: 'Stand Wald', location: 'Wald' }]); + expect(result.origin).toEqual({ + regions: ['Nordhessen'], + country: 'Deutschland', + }); expect(result.harvestDates).toEqual([harvestDate1, harvestDate2]); }); @@ -135,4 +167,56 @@ describe('PublicService', () => { expect(serialized).not.toContain('Nick Muster'); expect(serialized).not.toContain('Musterweg'); }); + + it('never returns apiary name, location, address or coordinates', async () => { + prisma.bottlingBatch.findUnique.mockResolvedValueOnce( + lotWithApiaries([ + { + id: 'a1', + name: 'Stand Wald', + location: 'Waldweg 3', + address: 'Waldweg 3, 12345 Ort', + latitude: 51.2, + longitude: 9.4, + publicLocationLabel: 'Nordhessen', + }, + ]), + ); + + const json = JSON.stringify(await service.findBatchByLot('WH-1')); + + for (const secret of ['Stand Wald', 'Waldweg 3', '51.2', '9.4', 'Ort']) { + expect(json).not.toContain(secret); + } + expect(json).not.toContain('apiaries'); + }); + + it('returns distinct sorted labels plus the country', async () => { + prisma.bottlingBatch.findUnique.mockResolvedValueOnce( + lotWithApiaries([ + { id: 'a1', publicLocationLabel: 'Südhessen' }, + { id: 'a2', publicLocationLabel: ' Nordhessen' }, + { id: 'a3', publicLocationLabel: 'Südhessen ' }, + { id: 'a4', publicLocationLabel: ' ' }, + { id: 'a5', publicLocationLabel: null }, + ]), + ); + + const result = await service.findBatchByLot('WH-1'); + + expect(result.origin).toEqual({ + regions: ['Nordhessen', 'Südhessen'], + country: 'Deutschland', + }); + }); + + it('returns only the country when no label is set', async () => { + prisma.bottlingBatch.findUnique.mockResolvedValueOnce( + lotWithApiaries([{ id: 'a1', publicLocationLabel: null }]), + ); + + const result = await service.findBatchByLot('WH-1'); + + expect(result.origin).toEqual({ regions: [], country: 'Deutschland' }); + }); }); diff --git a/ApiCore-Backend/src/public/public.service.ts b/ApiCore-Backend/src/public/public.service.ts index bb7a826..12126dc 100644 --- a/ApiCore-Backend/src/public/public.service.ts +++ b/ApiCore-Backend/src/public/public.service.ts @@ -2,9 +2,12 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { SiteSettingsService } from '../site-settings/site-settings.service'; -export interface PublicApiaryOrigin { - name: string; - location: string | null; +// Honey apiaries are a theft target: the public page names a region +// (publicLocationLabel) and a country - never a stand's name, address or +// coordinates. +export interface PublicOrigin { + regions: string[]; + country: string; } export interface PublicSocialLinks { @@ -25,7 +28,7 @@ export interface PublicBatch { waterContent: number | null; extractedDate: Date | null; harvestDates: Date[]; - apiaries: PublicApiaryOrigin[]; + origin: PublicOrigin; socialLinks: PublicSocialLinks; } @@ -61,13 +64,13 @@ export class PublicService { const harvests = batch.honeyBatch.extraction.harvests.map( (entry) => entry.harvest, ); - const apiaryByName = new Map(); - for (const harvest of harvests) { - apiaryByName.set(harvest.apiary.id, { - name: harvest.apiary.name, - location: harvest.apiary.location, - }); - } + const regions = [ + ...new Set( + harvests + .map((harvest) => harvest.apiary.publicLocationLabel?.trim() ?? '') + .filter((label) => label !== ''), + ), + ].sort((a, b) => a.localeCompare(b, 'de')); const harvestDates = [...new Set(harvests.map((h) => h.date.getTime()))] .sort((a, b) => a - b) .map((time) => new Date(time)); @@ -85,7 +88,7 @@ export class PublicService { waterContent: batch.honeyBatch.waterContent, extractedDate: batch.honeyBatch.extraction.date, harvestDates, - apiaries: [...apiaryByName.values()], + origin: { regions, country: settings.originCountry }, socialLinks: { youtube: settings.youtubeUrl, instagram: settings.instagramUrl, diff --git a/ApiCore-Backend/src/runtime/role.spec.ts b/ApiCore-Backend/src/runtime/role.spec.ts new file mode 100644 index 0000000..11987bf --- /dev/null +++ b/ApiCore-Backend/src/runtime/role.spec.ts @@ -0,0 +1,58 @@ +import { resolveRole } from './role'; + +describe('resolveRole', () => { + it('defaults per entry point outside production', () => { + expect(resolveRole('main', {})).toBe('all'); + expect(resolveRole('worker', {})).toBe('worker'); + expect(resolveRole('cli', {})).toBe('job'); + }); + + it('treats a blank APICORE_ROLE as unset', () => { + expect(resolveRole('main', { APICORE_ROLE: ' ' })).toBe('all'); + }); + + it('requires APICORE_ROLE in production', () => { + expect(() => resolveRole('main', { NODE_ENV: 'production' })).toThrow( + 'APICORE_ROLE is required in production', + ); + }); + + it('rejects all in production', () => { + expect(() => + resolveRole('main', { NODE_ENV: 'production', APICORE_ROLE: 'all' }), + ).toThrow('APICORE_ROLE=all is for local development and tests only'); + }); + + it('rejects an unknown role', () => { + expect(() => resolveRole('main', { APICORE_ROLE: 'admin' })).toThrow( + 'APICORE_ROLE must be one of api, worker, job, all', + ); + }); + + it('rejects a role that does not belong to the entry point', () => { + expect(() => resolveRole('main', { APICORE_ROLE: 'worker' })).toThrow( + 'APICORE_ROLE=worker cannot start dist/main.js', + ); + expect(() => resolveRole('worker', { APICORE_ROLE: 'api' })).toThrow( + 'APICORE_ROLE=api cannot start dist/worker.js', + ); + expect(() => resolveRole('cli', { APICORE_ROLE: 'all' })).toThrow( + 'APICORE_ROLE=all cannot start dist/cli.js', + ); + }); + + it('accepts api for main in production', () => { + expect( + resolveRole('main', { NODE_ENV: 'production', APICORE_ROLE: 'api' }), + ).toBe('api'); + }); + + it('accepts worker and job for their entry points in production', () => { + expect( + resolveRole('worker', { NODE_ENV: 'production', APICORE_ROLE: 'worker' }), + ).toBe('worker'); + expect( + resolveRole('cli', { NODE_ENV: 'production', APICORE_ROLE: 'job' }), + ).toBe('job'); + }); +}); diff --git a/ApiCore-Backend/src/runtime/role.ts b/ApiCore-Backend/src/runtime/role.ts new file mode 100644 index 0000000..c6667e8 --- /dev/null +++ b/ApiCore-Backend/src/runtime/role.ts @@ -0,0 +1,57 @@ +export const APICORE_ROLES = ['api', 'worker', 'job', 'all'] as const; +export type ApicoreRole = (typeof APICORE_ROLES)[number]; +export type EntryPoint = 'main' | 'worker' | 'cli'; + +interface EntryPointRules { + readonly file: string; + readonly defaultRole: ApicoreRole; + readonly allowed: readonly ApicoreRole[]; +} + +const ENTRY_POINTS: Readonly> = { + main: { file: 'dist/main.js', defaultRole: 'all', allowed: ['api', 'all'] }, + worker: { + file: 'dist/worker.js', + defaultRole: 'worker', + allowed: ['worker'], + }, + cli: { file: 'dist/cli.js', defaultRole: 'job', allowed: ['job'] }, +}; + +function isApicoreRole(value: string): value is ApicoreRole { + return (APICORE_ROLES as readonly string[]).includes(value); +} + +/** + * Resolves the role this process runs as from APICORE_ROLE and the entry + * point it was started from. Outside production the entry point's default + * applies when the variable is unset; production deployments must name the + * role explicitly (and never "all", which bundles API and worker in one + * process) so a misconfigured pod fails loudly instead of running the wrong + * workload. + */ +export function resolveRole( + entry: EntryPoint, + env: NodeJS.ProcessEnv = process.env, +): ApicoreRole { + const rules = ENTRY_POINTS[entry]; + const isProduction = env.NODE_ENV === 'production'; + const raw = env.APICORE_ROLE?.trim(); + + if (!raw) { + if (isProduction) { + throw new Error('APICORE_ROLE is required in production'); + } + return rules.defaultRole; + } + if (!isApicoreRole(raw)) { + throw new Error(`APICORE_ROLE must be one of ${APICORE_ROLES.join(', ')}`); + } + if (isProduction && raw === 'all') { + throw new Error('APICORE_ROLE=all is for local development and tests only'); + } + if (!rules.allowed.includes(raw)) { + throw new Error(`APICORE_ROLE=${raw} cannot start ${rules.file}`); + } + return raw; +} diff --git a/ApiCore-Backend/src/runtime/root-module.spec.ts b/ApiCore-Backend/src/runtime/root-module.spec.ts new file mode 100644 index 0000000..6471944 --- /dev/null +++ b/ApiCore-Backend/src/runtime/root-module.spec.ts @@ -0,0 +1,109 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { DiscoveryModule } from '@nestjs/core'; +import { AllInOneModule } from '../all-in-one.module'; +import { AppModule } from '../app.module'; +import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module'; +import { WorkerModule } from '../worker/worker.module'; +import { WorkerRuntimeModule } from '../worker/worker-runtime.module'; +import { isHttpRole, rootModuleFor } from './root-module'; + +const importsOf = (module: object): unknown[] => + (Reflect.getMetadata('imports', module) as unknown[] | undefined) ?? []; + +/** + * Every module class reachable from `root` through imports - including + * the imports of dynamic modules (also async ones like + * ConfigModule.forRoot) and forwardRef()s. + */ +async function reachableModules(root: unknown): Promise> { + const seen = new Set(); + async function visit(entry: unknown): Promise { + const resolved: unknown = await entry; + // A circular file import can leave an undefined slot behind. + if (typeof resolved !== 'object' && typeof resolved !== 'function') { + return; + } + if (resolved === null) { + return; + } + if ('forwardRef' in resolved) { + return visit((resolved as { forwardRef: () => unknown }).forwardRef()); + } + if ('module' in resolved) { + const dynamic = resolved as { module: unknown; imports?: unknown[] }; + await visit(dynamic.module); + for (const imported of dynamic.imports ?? []) { + await visit(imported); + } + return; + } + if (seen.has(resolved)) { + return; + } + seen.add(resolved); + for (const imported of importsOf(resolved)) { + await visit(imported); + } + } + await visit(root); + return seen; +} + +// The API role never starts background loops: only the worker and "all" +// (local development and tests) load the worker runtime. +describe('rootModuleFor', () => { + it('the api module never imports the worker runtime, not even transitively', async () => { + const reachable = await reachableModules(AppModule); + + expect(reachable).not.toContain(WorkerRuntimeModule); + // The walk really goes deep: into feature modules and dynamic modules. + expect(reachable).toContain(DiscoveryModule); + expect(reachable).toContain(ThrottlerStorageModule); + }); + + it('api starts the API module alone', () => { + expect(rootModuleFor('api')).toBe(AppModule); + }); + + it('all adds the worker runtime to the API', () => { + expect(rootModuleFor('all')).toBe(AllInOneModule); + expect(importsOf(AllInOneModule)).toEqual([AppModule, WorkerRuntimeModule]); + }); + + it('the worker runs the runtime without the HTTP API', async () => { + const reachable = await reachableModules(WorkerModule); + + expect(reachable).toContain(WorkerRuntimeModule); + expect(reachable).not.toContain(AppModule); + }); +}); + +describe('reachableModules', () => { + @Module({ imports: [WorkerRuntimeModule] }) + class InnerModule {} + + @Module({ imports: [forwardRef(() => InnerModule)] }) + class ViaForwardRefModule {} + + @Module({ + imports: [Promise.resolve({ module: class {}, imports: [InnerModule] })], + }) + class ViaAsyncDynamicModule {} + + it.each([ + ['a plain import chain', InnerModule], + ['a forwardRef', ViaForwardRefModule], + ['the imports of an async dynamic module', ViaAsyncDynamicModule], + ])('finds the worker runtime behind %s', async (_case, root) => { + expect(await reachableModules(root)).toContain(WorkerRuntimeModule); + }); +}); + +describe('isHttpRole', () => { + it('admits exactly the roles dist/main.js starts', () => { + expect(isHttpRole('api')).toBe(true); + expect(isHttpRole('all')).toBe(true); + expect(isHttpRole('worker')).toBe(false); + expect(isHttpRole('job')).toBe(false); + }); +}); diff --git a/ApiCore-Backend/src/runtime/root-module.ts b/ApiCore-Backend/src/runtime/root-module.ts new file mode 100644 index 0000000..ce59689 --- /dev/null +++ b/ApiCore-Backend/src/runtime/root-module.ts @@ -0,0 +1,20 @@ +import type { Type } from '@nestjs/common'; +import { AllInOneModule } from '../all-in-one.module'; +import { AppModule } from '../app.module'; +import type { ApicoreRole } from './role'; + +/** The roles dist/main.js starts; resolveRole('main') admits no other. */ +export type HttpRole = Extract; + +export function isHttpRole(role: ApicoreRole): role is HttpRole { + return role === 'api' || role === 'all'; +} + +/** + * The root module of dist/main.js. "api" is the HTTP API alone and never + * starts background loops, so API pods scale freely; "all" adds the worker + * runtime for local development and tests. + */ +export function rootModuleFor(role: HttpRole): Type { + return role === 'all' ? AllInOneModule : AppModule; +} diff --git a/ApiCore-Backend/src/runtime/startup-checks.spec.ts b/ApiCore-Backend/src/runtime/startup-checks.spec.ts new file mode 100644 index 0000000..84bb7e1 --- /dev/null +++ b/ApiCore-Backend/src/runtime/startup-checks.spec.ts @@ -0,0 +1,324 @@ +import { assertStartupEnvironment } from './startup-checks'; + +const SECRET = 'x'.repeat(32); +const BASE_ENV = { DATABASE_URL: 'postgres://db', JWT_ACCESS_SECRET: SECRET }; +const KEYS = { + MFA_ENCRYPTION_KEY: 'm'.repeat(32), + WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32), + BACKUP_ENCRYPTION_KEY: 'b'.repeat(32), +}; +const PROD_ENV = { + ...BASE_ENV, + ...KEYS, + JWT_REFRESH_SECRET: 'r'.repeat(32), + NODE_ENV: 'production', + FRONTEND_ORIGIN: 'https://app.example.com', +}; + +describe('assertStartupEnvironment', () => { + it('accepts a complete development environment', () => { + expect(() => assertStartupEnvironment('api', BASE_ENV)).not.toThrow(); + }); + + it('lists missing DATABASE_URL', () => { + expect(() => + assertStartupEnvironment('api', { JWT_ACCESS_SECRET: SECRET }), + ).toThrow('Missing required environment variable(s): DATABASE_URL'); + }); + + it('lists every missing variable', () => { + expect(() => assertStartupEnvironment('worker', {})).toThrow( + 'Missing required environment variable(s): DATABASE_URL, JWT_ACCESS_SECRET', + ); + }); + + it('rejects a JWT secret shorter than 32 characters', () => { + expect(() => + assertStartupEnvironment('api', { + ...BASE_ENV, + JWT_ACCESS_SECRET: 'short', + }), + ).toThrow('JWT_ACCESS_SECRET must be at least 32 characters (got 5)'); + }); + + it('accepts a complete production environment', () => { + expect(() => assertStartupEnvironment('api', PROD_ENV)).not.toThrow(); + }); + + it('requires an https FRONTEND_ORIGIN in production', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + FRONTEND_ORIGIN: 'http://app.example.com', + }), + ).toThrow( + 'FRONTEND_ORIGIN must be set to an https:// origin in production', + ); + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + FRONTEND_ORIGIN: undefined, + }), + ).toThrow( + 'FRONTEND_ORIGIN must be set to an https:// origin in production', + ); + }); + + it('rejects COOKIE_SECURE=false in production', () => { + expect(() => + assertStartupEnvironment('api', { ...PROD_ENV, COOKIE_SECURE: 'false' }), + ).toThrow('COOKIE_SECURE=false is for local HTTP development only'); + }); + + it('allows an http FRONTEND_ORIGIN and COOKIE_SECURE=false outside production', () => { + expect(() => + assertStartupEnvironment('api', { + ...BASE_ENV, + FRONTEND_ORIGIN: 'http://localhost:3000', + COOKIE_SECURE: 'false', + }), + ).not.toThrow(); + }); + + describe('encryption keys', () => { + it('production refuses to start without MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY', () => { + expect(() => + assertStartupEnvironment('worker', { + ...PROD_ENV, + MFA_ENCRYPTION_KEY: undefined, + WEBHOOK_ENCRYPTION_KEY: undefined, + BACKUP_ENCRYPTION_KEY: undefined, + }), + ).toThrow( + 'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY', + ); + expect(() => + assertStartupEnvironment('job', { + ...PROD_ENV, + WEBHOOK_ENCRYPTION_KEY: '', + }), + ).toThrow( + 'Missing required environment variable(s): WEBHOOK_ENCRYPTION_KEY', + ); + }); + + it('refuses keys shorter than 32 characters, *_PREVIOUS included', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + MFA_ENCRYPTION_KEY: 'short', + }), + ).toThrow('MFA_ENCRYPTION_KEY must be at least 32 characters'); + expect(() => + assertStartupEnvironment('api', { + ...BASE_ENV, + BACKUP_ENCRYPTION_KEY_PREVIOUS: 'short', + }), + ).toThrow( + 'BACKUP_ENCRYPTION_KEY_PREVIOUS must be at least 32 characters', + ); + }); + + it('refuses two purposes sharing one key or a key equal to JWT_ACCESS_SECRET', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY, + }), + ).toThrow( + 'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY and BACKUP_ENCRYPTION_KEY must be pairwise different', + ); + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + BACKUP_ENCRYPTION_KEY: SECRET, + }), + ).toThrow( + 'BACKUP_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET', + ); + }); + + it('refuses a key equal to JWT_REFRESH_SECRET', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + MFA_ENCRYPTION_KEY: PROD_ENV.JWT_REFRESH_SECRET, + }), + ).toThrow( + 'MFA_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET', + ); + }); + + it('refuses a *_PREVIOUS key equal to a JWT secret, like a current key', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + WEBHOOK_ENCRYPTION_KEY_PREVIOUS: SECRET, + BACKUP_ENCRYPTION_KEY_PREVIOUS: PROD_ENV.JWT_REFRESH_SECRET, + }), + ).toThrow( + 'WEBHOOK_ENCRYPTION_KEY_PREVIOUS, BACKUP_ENCRYPTION_KEY_PREVIOUS must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET', + ); + }); + + it('accepts *_PREVIOUS keys of their own', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + MFA_ENCRYPTION_KEY_PREVIOUS: 'p'.repeat(32), + }), + ).not.toThrow(); + }); + + it('never puts a key into its errors', () => { + const shared = 'shared-secret-value-0123456789abcdef'; + let message = ''; + try { + assertStartupEnvironment('api', { + ...PROD_ENV, + MFA_ENCRYPTION_KEY: shared, + WEBHOOK_ENCRYPTION_KEY: shared, + }); + } catch (error) { + message = (error as Error).message; + } + + expect(message).not.toBe(''); + expect(message).not.toContain(shared); + }); + }); + + describe('example placeholders', () => { + const PLACEHOLDER = + 'change-me-to-a-random-value-at-least-32-characters-long'; + + it.each([ + 'JWT_ACCESS_SECRET', + 'JWT_REFRESH_SECRET', + 'MFA_ENCRYPTION_KEY', + 'WEBHOOK_ENCRYPTION_KEY', + 'BACKUP_ENCRYPTION_KEY', + 'MFA_ENCRYPTION_KEY_PREVIOUS', + 'WEBHOOK_ENCRYPTION_KEY_PREVIOUS', + 'BACKUP_ENCRYPTION_KEY_PREVIOUS', + ])('production refuses a change-me placeholder in %s', (name) => { + expect(() => + assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }), + ).toThrow( + `Secret(s) still set to an example placeholder: ${name} - generate real values, e.g. "openssl rand -hex 32"`, + ); + }); + + it('matches the placeholder case-insensitively and lists every hit', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + JWT_REFRESH_SECRET: 'Change-Me-refresh-secret', + MFA_ENCRYPTION_KEY: 'CHANGE-ME-to-a-third-random-value-at-least-32', + }), + ).toThrow( + 'Secret(s) still set to an example placeholder: JWT_REFRESH_SECRET, MFA_ENCRYPTION_KEY', + ); + }); + + it('names the variable, never the value', () => { + let message = ''; + try { + assertStartupEnvironment('api', { + ...PROD_ENV, + BACKUP_ENCRYPTION_KEY: PLACEHOLDER, + }); + } catch (error) { + message = (error as Error).message; + } + + expect(message).toContain('BACKUP_ENCRYPTION_KEY'); + expect(message).not.toContain(PLACEHOLDER); + }); + + it('production refuses a change-me OIDC_CLIENT_SECRET when SSO is configured', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + OIDC_CLIENT_SECRET: 'change-me-entra-client-secret', + }), + ).toThrow( + 'Secret(s) still set to an example placeholder: OIDC_CLIENT_SECRET', + ); + }); + + it('accepts production without SSO (OIDC_CLIENT_SECRET unset)', () => { + expect(PROD_ENV).not.toHaveProperty('OIDC_CLIENT_SECRET'); + expect(() => assertStartupEnvironment('api', PROD_ENV)).not.toThrow(); + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + OIDC_CLIENT_SECRET: 'a-real-entra-client-secret-value', + }), + ).not.toThrow(); + }); + + it('production refuses a change-me password inside DATABASE_URL, without echoing the URL', () => { + const url = + 'postgresql://apicore:Change-Me-postgres-password@apicore-postgres:5432/apicore?schema=public'; + let message = ''; + try { + assertStartupEnvironment('api', { ...PROD_ENV, DATABASE_URL: url }); + } catch (error) { + message = (error as Error).message; + } + + expect(message).toBe( + 'Secret(s) still set to an example placeholder: DATABASE_URL - generate real values, e.g. "openssl rand -hex 32"', + ); + expect(message).not.toContain('postgres-password'); + expect(message).not.toContain('apicore-postgres'); + }); + + it('finds a percent-encoded change-me password in DATABASE_URL', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + DATABASE_URL: 'postgresql://u:%63hange-me-pw@db:5432/apicore', + }), + ).toThrow('Secret(s) still set to an example placeholder: DATABASE_URL'); + }); + + it('accepts a DATABASE_URL with a real password, none, or one it cannot parse', () => { + for (const DATABASE_URL of [ + 'postgresql://apicore:s3cr3t-generated-value@db:5432/apicore', + 'postgresql://change-me-user@db:5432/apicore', + 'postgresql://db/apicore', + 'not a url', + ]) { + expect(() => + assertStartupEnvironment('api', { ...PROD_ENV, DATABASE_URL }), + ).not.toThrow(); + } + }); + + it('lists DATABASE_URL together with other placeholder secrets', () => { + expect(() => + assertStartupEnvironment('api', { + ...PROD_ENV, + DATABASE_URL: 'postgresql://u:change-me@db:5432/apicore', + OIDC_CLIENT_SECRET: 'change-me-entra-client-secret', + }), + ).toThrow( + 'Secret(s) still set to an example placeholder: DATABASE_URL, OIDC_CLIENT_SECRET', + ); + }); + + it('leaves placeholders alone outside production', () => { + expect(() => + assertStartupEnvironment('api', { + ...BASE_ENV, + JWT_ACCESS_SECRET: PLACEHOLDER, + MFA_ENCRYPTION_KEY: `${PLACEHOLDER}-mfa`, + OIDC_CLIENT_SECRET: 'change-me-entra-client-secret', + DATABASE_URL: 'postgresql://u:change-me@db:5432/apicore', + }), + ).not.toThrow(); + }); + }); +}); diff --git a/ApiCore-Backend/src/runtime/startup-checks.ts b/ApiCore-Backend/src/runtime/startup-checks.ts new file mode 100644 index 0000000..547b191 --- /dev/null +++ b/ApiCore-Backend/src/runtime/startup-checks.ts @@ -0,0 +1,145 @@ +import { + ENCRYPTION_KEY_VARIABLES, + PREVIOUS_KEY_SUFFIX, + readRingSecrets, +} from '../common/crypto/key-ring'; +import type { ApicoreRole } from './role'; + +const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const; +// Production must not fall back to keys derived from JWT_ACCESS_SECRET: +// rotating the JWT secret would then make stored secrets unreadable, and a +// leaked JWT secret would expose them too (common/crypto/key-ring.ts). +const ENCRYPTION_KEYS = Object.values(ENCRYPTION_KEY_VARIABLES); +const PREVIOUS_ENCRYPTION_KEYS = ENCRYPTION_KEYS.map( + (name) => `${name}${PREVIOUS_KEY_SUFFIX}`, +); +// The example files (.env.example, secret.example.yaml) ship "change-me…" +// values long enough to pass every length rule; production must not run on +// them. OIDC_CLIENT_SECRET only counts when set (SSO is optional). +const PLACEHOLDER_PATTERN = /^change-me/i; +const SECRET_VARIABLES = [ + 'JWT_ACCESS_SECRET', + 'JWT_REFRESH_SECRET', + ...ENCRYPTION_KEYS, + ...PREVIOUS_ENCRYPTION_KEYS, + 'OIDC_CLIENT_SECRET', +]; +// JWT_ACCESS_SECRET signs every access/MFA-challenge/SSO-completion token - +// a short or guessable value would let an attacker forge tokens for any +// user. It also still opens MFA and webhook secrets stored before the key +// ring (until the rekey job reseals them). 32 chars matches the output of +// "openssl rand -hex 32" / "-base64 32", the standard way to generate one. +const MIN_JWT_SECRET_LENGTH = 32; + +function requiredEnvVars(env: NodeJS.ProcessEnv): readonly string[] { + return env.NODE_ENV === 'production' + ? [...REQUIRED_ENV_VARS, ...ENCRYPTION_KEYS] + : REQUIRED_ENV_VARS; +} + +function assertProductionSettings(env: NodeJS.ProcessEnv): void { + if (env.NODE_ENV !== 'production') { + return; + } + // Without it CORS and SSO redirects silently fall back to localhost. + if (!env.FRONTEND_ORIGIN?.startsWith('https://')) { + throw new Error( + 'FRONTEND_ORIGIN must be set to an https:// origin in production', + ); + } + if (env.COOKIE_SECURE === 'false') { + throw new Error('COOKIE_SECURE=false is for local HTTP development only'); + } +} + +/** + * The password inside DATABASE_URL. An unparsable URL counts as "no + * password" here - Prisma reports it - and nothing of the URL is echoed. + */ +function databasePassword(env: NodeJS.ProcessEnv): string { + try { + return decodeURIComponent(new URL(env.DATABASE_URL ?? '').password); + } catch { + return ''; + } +} + +function assertNoPlaceholderSecrets(env: NodeJS.ProcessEnv): void { + if (env.NODE_ENV !== 'production') { + return; + } + const placeholders = [ + ...(PLACEHOLDER_PATTERN.test(databasePassword(env)) + ? ['DATABASE_URL'] + : []), + ...SECRET_VARIABLES.filter((name) => + PLACEHOLDER_PATTERN.test(env[name] ?? ''), + ), + ]; + if (placeholders.length > 0) { + throw new Error( + `Secret(s) still set to an example placeholder: ${placeholders.join(', ')} - generate real values, e.g. "openssl rand -hex 32"`, + ); + } +} + +function assertRequiredEnvVars(env: NodeJS.ProcessEnv): void { + const missing = requiredEnvVars(env).filter((name) => !env[name]); + if (missing.length > 0) { + throw new Error( + `Missing required environment variable(s): ${missing.join(', ')}`, + ); + } + const jwtSecret = env.JWT_ACCESS_SECRET as string; + if (jwtSecret.length < MIN_JWT_SECRET_LENGTH) { + throw new Error( + `JWT_ACCESS_SECRET must be at least ${MIN_JWT_SECRET_LENGTH} characters (got ${jwtSecret.length}) - generate one with e.g. "openssl rand -hex 32"`, + ); + } +} + +/** + * Lengths are checked like at runtime (incl. *_PREVIOUS), only earlier. In + * production every purpose needs a key of its own, so one leaked or rotated + * secret never touches another purpose. Errors name variables, never values. + */ +function assertEncryptionKeys(env: NodeJS.ProcessEnv): void { + for (const variable of ENCRYPTION_KEYS) { + readRingSecrets(variable, env); + } + if (env.NODE_ENV !== 'production') { + return; + } + const keys = ENCRYPTION_KEYS.map((name) => env[name]); + if (new Set(keys).size !== keys.length) { + throw new Error( + `${ENCRYPTION_KEYS.slice(0, -1).join(', ')} and ${ENCRYPTION_KEYS.at(-1)} must be pairwise different`, + ); + } + const jwtSecrets = [env.JWT_ACCESS_SECRET, env.JWT_REFRESH_SECRET]; + // *_PREVIOUS keys still open data, so the same rule applies to them. + const reused = [...ENCRYPTION_KEYS, ...PREVIOUS_ENCRYPTION_KEYS].filter( + (name) => env[name] && jwtSecrets.includes(env[name]), + ); + if (reused.length > 0) { + throw new Error( + `${reused.join(', ')} must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET`, + ); + } +} + +/** + * Startup checks shared by every entry point (API, worker, CLI job). All + * roles load the same modules and secrets today, so the checks do not differ + * per role yet; the role is part of the signature so role-specific checks can + * be added without touching the callers. + */ +export function assertStartupEnvironment( + _role: ApicoreRole, + env: NodeJS.ProcessEnv = process.env, +): void { + assertRequiredEnvVars(env); + assertProductionSettings(env); + assertNoPlaceholderSecrets(env); + assertEncryptionKeys(env); +} diff --git a/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts b/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts index 41af257..16eb2a0 100644 --- a/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts +++ b/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts @@ -3,9 +3,12 @@ import { IsOptional, IsString, IsUrl, + Matches, MaxLength, + MinLength, ValidateIf, } from 'class-validator'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; // An empty string clears the link (stored as null); anything else must be a // real URL - ValidateIf skips the @IsUrl check only for the empty-string @@ -52,4 +55,15 @@ export class UpdateSiteSettingsDto { @IsString() @MaxLength(300) holderAddress?: string; + + @ApiPropertyOptional({ + description: 'Herkunftsland auf der öffentlichen Herkunftsseite', + }) + @IsOptionalNotNull() + @IsString() + @MinLength(2) + @MaxLength(80) + // At least two visible characters - the value is trimmed before saving. + @Matches(/^\s*\S.*\S\s*$/s) + originCountry?: string; } diff --git a/ApiCore-Backend/src/site-settings/site-settings.service.ts b/ApiCore-Backend/src/site-settings/site-settings.service.ts index 985b40d..ceb8834 100644 --- a/ApiCore-Backend/src/site-settings/site-settings.service.ts +++ b/ApiCore-Backend/src/site-settings/site-settings.service.ts @@ -60,6 +60,7 @@ export class SiteSettingsService { dto.holderAddress !== undefined ? emptyToNull(dto.holderAddress.trim()) : undefined, + originCountry: dto.originCountry?.trim(), }, }); await this.auditLog.record({ diff --git a/ApiCore-Backend/src/treatments/dto/last-by-product-query.dto.ts b/ApiCore-Backend/src/treatments/dto/last-by-product-query.dto.ts new file mode 100644 index 0000000..3a95d4c --- /dev/null +++ b/ApiCore-Backend/src/treatments/dto/last-by-product-query.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export const MAX_PRODUCT_LENGTH = 200; + +function trimString({ value }: TransformFnParams): unknown { + return typeof value === 'string' ? value.trim() : (value as unknown); +} + +export class LastByProductQueryDto { + @ApiProperty({ + description: 'Präparatename (ohne Beachtung der Groß-/Kleinschreibung)', + example: 'Oxuvar', + maxLength: MAX_PRODUCT_LENGTH, + }) + @Transform(trimString) + @IsString({ message: 'product muss ein Text sein' }) + @IsNotEmpty({ message: 'product darf nicht leer sein' }) + @MaxLength(MAX_PRODUCT_LENGTH, { + message: `product darf höchstens ${MAX_PRODUCT_LENGTH} Zeichen lang sein`, + }) + product!: string; +} diff --git a/ApiCore-Backend/src/treatments/treatment-prefill.service.ts b/ApiCore-Backend/src/treatments/treatment-prefill.service.ts new file mode 100644 index 0000000..4075440 --- /dev/null +++ b/ApiCore-Backend/src/treatments/treatment-prefill.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +/** Fields of a previous treatment that a new one of the same product reuses. */ +export interface TreatmentPrefill { + sourceTreatmentId: string; + sourceDate: string; + product: string; + activeIngredient: string | null; + withdrawalPeriodDays: number | null; + supplier: string | null; + purchaseProof: string | null; + prescribingVet: string | null; +} + +@Injectable() +export class TreatmentPrefillService { + constructor(private readonly prisma: PrismaService) {} + + /** + * The caller's own newest treatment with this product (case-insensitive). + * Strictly scoped to `userId`: supplier, proof and vet are the caller's + * own purchase data and must never leak across users. + */ + async lastByProduct( + userId: string, + product: string, + ): Promise { + const treatment = await this.prisma.treatment.findFirst({ + where: { + userId, + product: { equals: product.trim(), mode: 'insensitive' }, + }, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }], + select: { + id: true, + date: true, + product: true, + activeIngredient: true, + withdrawalPeriodDays: true, + supplier: true, + purchaseProof: true, + prescribingVet: true, + }, + }); + if (!treatment) { + return null; + } + const { id, date, ...rest } = treatment; + return { sourceTreatmentId: id, sourceDate: date.toISOString(), ...rest }; + } +} diff --git a/ApiCore-Backend/src/treatments/treatments.controller.ts b/ApiCore-Backend/src/treatments/treatments.controller.ts index c4c9be0..62c335e 100644 --- a/ApiCore-Backend/src/treatments/treatments.controller.ts +++ b/ApiCore-Backend/src/treatments/treatments.controller.ts @@ -14,6 +14,8 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto'; import { WithdrawalStatusQueryDto } from './dto/withdrawal-status-query.dto'; +import { LastByProductQueryDto } from './dto/last-by-product-query.dto'; +import { TreatmentPrefillService } from './treatment-prefill.service'; import { TreatmentsService } from './treatments.service'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; @@ -21,7 +23,10 @@ import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiBearerAuth() @Controller('treatments') export class TreatmentsController { - constructor(private readonly treatmentsService: TreatmentsService) {} + constructor( + private readonly treatmentsService: TreatmentsService, + private readonly treatmentPrefillService: TreatmentPrefillService, + ) {} @RequirePermission('treatments:read') @Get() @@ -39,6 +44,21 @@ export class TreatmentsController { ); } + /** The caller's newest treatment with this product, to prefill a new one. */ + @RequirePermission('treatments:create') + @Get('last-by-product') + async getLastByProduct( + @Query() query: LastByProductQueryDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return { + treatment: await this.treatmentPrefillService.lastByProduct( + actor.id, + query.product, + ), + }; + } + @RequirePermission('treatments:read') @Get(':id') findOne(@Param('id') id: string) { diff --git a/ApiCore-Backend/src/treatments/treatments.module.ts b/ApiCore-Backend/src/treatments/treatments.module.ts index cbb3c6b..15b7c2f 100644 --- a/ApiCore-Backend/src/treatments/treatments.module.ts +++ b/ApiCore-Backend/src/treatments/treatments.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { AutomationModule } from '../automation/automation.module'; import { TreatmentsController } from './treatments.controller'; import { ColonyTreatmentsController } from './colony-treatments.controller'; +import { TreatmentPrefillService } from './treatment-prefill.service'; import { TreatmentsService } from './treatments.service'; @Module({ imports: [AutomationModule], controllers: [TreatmentsController, ColonyTreatmentsController], - providers: [TreatmentsService], + providers: [TreatmentsService, TreatmentPrefillService], exports: [TreatmentsService], }) export class TreatmentsModule {} diff --git a/ApiCore-Backend/src/varroa/varroa-threshold.service.ts b/ApiCore-Backend/src/varroa/varroa-threshold.service.ts new file mode 100644 index 0000000..d52710a --- /dev/null +++ b/ApiCore-Backend/src/varroa/varroa-threshold.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { varroaThresholdOf } from '../automation/varroa-threshold'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface VarroaThreshold { + // null when the VARROA_THRESHOLD rule is switched off. + thresholdMitesPerDay: number | null; +} + +@Injectable() +export class VarroaThresholdService { + constructor(private readonly prisma: PrismaService) {} + + async current(): Promise { + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'VARROA_THRESHOLD' }, + select: { active: true, config: true }, + }); + return { thresholdMitesPerDay: varroaThresholdOf(rule) }; + } +} diff --git a/ApiCore-Backend/src/varroa/varroa.controller.ts b/ApiCore-Backend/src/varroa/varroa.controller.ts index 0332f93..452e10c 100644 --- a/ApiCore-Backend/src/varroa/varroa.controller.ts +++ b/ApiCore-Backend/src/varroa/varroa.controller.ts @@ -6,13 +6,17 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; import { VarroaService } from './varroa.service'; +import { VarroaThresholdService } from './varroa-threshold.service'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('varroa-measurements') @ApiBearerAuth() @Controller('varroa-measurements') export class VarroaController { - constructor(private readonly varroaService: VarroaService) {} + constructor( + private readonly varroaService: VarroaService, + private readonly varroaThresholdService: VarroaThresholdService, + ) {} @RequirePermission('varroa:read') @Get() @@ -20,6 +24,13 @@ export class VarroaController { return this.varroaService.findAll(); } + /** Effective alarm threshold; declared before :id so it is not shadowed. */ + @RequirePermission('varroa:read', 'varroa:create') + @Get('threshold') + getThreshold() { + return this.varroaThresholdService.current(); + } + @RequirePermission('varroa:read') @Get(':id') findOne(@Param('id') id: string) { diff --git a/ApiCore-Backend/src/varroa/varroa.module.ts b/ApiCore-Backend/src/varroa/varroa.module.ts index 5fc185c..8354d63 100644 --- a/ApiCore-Backend/src/varroa/varroa.module.ts +++ b/ApiCore-Backend/src/varroa/varroa.module.ts @@ -3,11 +3,12 @@ import { AutomationModule } from '../automation/automation.module'; import { VarroaController } from './varroa.controller'; import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller'; import { VarroaService } from './varroa.service'; +import { VarroaThresholdService } from './varroa-threshold.service'; @Module({ imports: [AutomationModule], controllers: [VarroaController, ColonyVarroaMeasurementsController], - providers: [VarroaService], + providers: [VarroaService, VarroaThresholdService], exports: [VarroaService], }) export class VarroaModule {} diff --git a/ApiCore-Backend/src/weather/weather.service.spec.ts b/ApiCore-Backend/src/weather/weather.service.spec.ts index ac3c984..832ba96 100644 --- a/ApiCore-Backend/src/weather/weather.service.spec.ts +++ b/ApiCore-Backend/src/weather/weather.service.spec.ts @@ -9,10 +9,15 @@ function openMeteoResponse( tempMin: number | null; precipitation: number | null; wind?: number | null; + weatherCode?: number | null; }[], ): unknown { + const withCodes = days.some((day) => day.weatherCode !== undefined); return { daily: { + ...(withCodes + ? { weather_code: days.map((day) => day.weatherCode ?? null) } + : {}), time: days.map((day) => day.date), temperature_2m_max: days.map((day) => day.tempMax), temperature_2m_min: days.map((day) => day.tempMin), @@ -91,6 +96,7 @@ describe('WeatherService', () => { tempMinC: 12, precipitationMm: 0, windSpeedMaxKmh: 10, + weatherCode: null, inspectionSuitable: true, }, ]); @@ -99,6 +105,61 @@ describe('WeatherService', () => { expect(url.toString()).toContain('longitude=13.4'); }); + it('maps daily weather_code to weatherCode', async () => { + prisma.apiary.findUnique.mockResolvedValue({ + latitude: 52.5, + longitude: 13.4, + }); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify( + openMeteoResponse([ + { + date: '2026-06-01', + tempMax: 22, + tempMin: 12, + precipitation: 0, + weatherCode: 61, + }, + ]), + ), + { status: 200 }, + ), + ); + + const result = await weatherService.getWeather('apiary-1'); + + expect(result.daily[0].weatherCode).toBe(61); + const [url] = fetchMock.mock.calls[0] as [URL]; + expect(url.searchParams.get('daily')).toContain('weather_code'); + }); + + it('weatherCode is null when Open-Meteo omits it', async () => { + prisma.apiary.findUnique.mockResolvedValue({ + latitude: 52.5, + longitude: 13.4, + }); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify( + openMeteoResponse([ + { + date: '2026-06-01', + tempMax: 22, + tempMin: 12, + precipitation: 0, + }, + ]), + ), + { status: 200 }, + ), + ); + + const result = await weatherService.getWeather('apiary-1'); + + expect(result.daily[0].weatherCode).toBeNull(); + }); + it.each([ ['too cold', { tempMax: 10, tempMin: 4, precipitation: 0, wind: 5 }], ['raining', { tempMax: 20, tempMin: 12, precipitation: 3, wind: 5 }], diff --git a/ApiCore-Backend/src/weather/weather.service.ts b/ApiCore-Backend/src/weather/weather.service.ts index 5aabf22..e106d14 100644 --- a/ApiCore-Backend/src/weather/weather.service.ts +++ b/ApiCore-Backend/src/weather/weather.service.ts @@ -30,6 +30,9 @@ export interface DailyWeather { tempMinC: number | null; precipitationMm: number | null; windSpeedMaxKmh: number | null; + // WMO weather interpretation code (Open-Meteo `weather_code`); null when + // the provider omits it. + weatherCode: number | null; // null when a required value is missing (rather than a misleading guess). inspectionSuitable: boolean | null; } @@ -54,6 +57,7 @@ interface OpenMeteoDaily { temperature_2m_min: (number | null)[]; precipitation_sum: (number | null)[]; wind_speed_10m_max: (number | null)[]; + weather_code?: (number | null)[]; } interface OpenMeteoResponse { @@ -108,7 +112,7 @@ export class WeatherService { url.searchParams.set('longitude', String(longitude)); url.searchParams.set( 'daily', - 'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max', + 'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,weather_code', ); url.searchParams.set('wind_speed_unit', 'kmh'); url.searchParams.set('past_days', String(PAST_DAYS)); @@ -136,6 +140,7 @@ export class WeatherService { temperature_2m_min, precipitation_sum, wind_speed_10m_max, + weather_code, } = body.daily; return time.map((date, index) => { const tempMaxC = temperature_2m_max[index] ?? null; @@ -147,6 +152,7 @@ export class WeatherService { tempMinC: temperature_2m_min[index] ?? null, precipitationMm, windSpeedMaxKmh, + weatherCode: weather_code?.[index] ?? null, inspectionSuitable: tempMaxC === null || precipitationMm === null || diff --git a/ApiCore-Backend/src/webhooks/webhook-secret.spec.ts b/ApiCore-Backend/src/webhooks/webhook-secret.spec.ts new file mode 100644 index 0000000..b240e39 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhook-secret.spec.ts @@ -0,0 +1,91 @@ +import { Logger } from '@nestjs/common'; +import { encryptMfaSecret } from '../auth/mfa/mfa-crypto.util'; +import { KEY_ID_PATTERN } from '../common/crypto/key-ring'; +import { + openWebhookSecret, + sealWebhookSecret, + webhookSecretNeedsRekey, +} from './webhook-secret'; + +// Written by the code before the key ring: "enc:v1:" + AES-256-GCM with +// sha256(JWT_ACCESS_SECRET), JWT_ACCESS_SECRET = 'x' * 40. +const LEGACY_SEALED = + 'enc:v1:MlOPzAuy67RTMMb9.YslrBRO2dTYumby+P28INg==.KFmilRVOsJtQ'; +// Rows from before webhook secrets were encrypted at all. +const LEGACY_PLAIN = '3f9c2a7d41e8b6c05a1d9e7f3b2c8a64'; + +describe('webhook-secret', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = 'x'.repeat(40); + process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40); + delete process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS; + delete process.env.MFA_ENCRYPTION_KEY; + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + jest.restoreAllMocks(); + }); + + it('seals with the webhook key and a key id', () => { + const stored = sealWebhookSecret('topsecret'); + + expect(stored).toMatch(KEY_ID_PATTERN); + expect(stored).not.toContain('topsecret'); + expect(openWebhookSecret(stored)).toBe('topsecret'); + expect(webhookSecretNeedsRekey(stored)).toBe(false); + }); + + it('opens legacy enc:v1 and plaintext webhook secrets and flags them for rekey', () => { + expect(openWebhookSecret(LEGACY_SEALED)).toBe('topsecret'); + expect(openWebhookSecret(LEGACY_PLAIN)).toBe(LEGACY_PLAIN); + expect(webhookSecretNeedsRekey(LEGACY_SEALED)).toBe(true); + expect(webhookSecretNeedsRekey(LEGACY_PLAIN)).toBe(true); + }); + + it('opens a secret sealed with WEBHOOK_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => { + const stored = sealWebhookSecret('topsecret'); + process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = + process.env.WEBHOOK_ENCRYPTION_KEY; + process.env.WEBHOOK_ENCRYPTION_KEY = 'v'.repeat(40); + + expect(openWebhookSecret(stored)).toBe('topsecret'); + expect(webhookSecretNeedsRekey(stored)).toBe(true); + }); + + it('fails closed on a legacy enc:v1 secret once JWT_ACCESS_SECRET changed', () => { + process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); + + expect(() => openWebhookSecret(LEGACY_SEALED)).toThrow( + 'ciphertext failed authentication', + ); + }); + + it('never retries a k…: value that fails authentication as legacy or plain secret', () => { + const warn = jest.spyOn(Logger.prototype, 'warn'); + const currentKeyId = sealWebhookSecret('topsecret').split(':')[0]; + // A valid legacy ciphertext behind the current key id: only a retry with + // sha256(JWT_ACCESS_SECRET) could open it, and only the plaintext + // fallback would hand the stored string back. + const legacyPayload = LEGACY_SEALED.slice('enc:v1:'.length); + + expect(() => openWebhookSecret(`${currentKeyId}:${legacyPayload}`)).toThrow( + 'ciphertext failed authentication', + ); + expect(() => openWebhookSecret(`k00000000:${legacyPayload}`)).toThrow( + 'unknown key id', + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it('keeps MFA and webhook keys apart, even on the shared dev fallback', () => { + delete process.env.WEBHOOK_ENCRYPTION_KEY; + + expect(() => openWebhookSecret(encryptMfaSecret('topsecret'))).toThrow( + 'unknown key id', + ); + }); +}); diff --git a/ApiCore-Backend/src/webhooks/webhook-secret.ts b/ApiCore-Backend/src/webhooks/webhook-secret.ts index 9e3c2ec..f12381e 100644 --- a/ApiCore-Backend/src/webhooks/webhook-secret.ts +++ b/ApiCore-Backend/src/webhooks/webhook-secret.ts @@ -1,19 +1,36 @@ import { - decryptMfaSecret, - encryptMfaSecret, -} from '../auth/mfa/mfa-crypto.util'; + KEY_ID_PATTERN, + isSealedWithCurrentKey, + loadKeyRing, + openLegacyCiphertext, + openWithRing, + sealWithRing, + warnLegacyRead, +} from '../common/crypto/key-ring'; // Webhook signing secrets must stay reversible (the server signs with them), -// so they are stored AES-256-GCM-encrypted like TOTP secrets. Rows written -// before this carry the plain secret and no prefix - still readable. -const PREFIX = 'enc:v1:'; +// so they are sealed with the webhook key ring (WEBHOOK_ENCRYPTION_KEY, see +// common/crypto/key-ring.ts). Two older forms stay readable until the rekey +// job reseals them: "enc:v1:" + a ciphertext under sha256(JWT_ACCESS_SECRET), +// and, from before encryption, the plain secret without any prefix. +const LEGACY_PREFIX = 'enc:v1:'; export function sealWebhookSecret(secret: string): string { - return `${PREFIX}${encryptMfaSecret(secret)}`; + return sealWithRing(loadKeyRing('webhook'), secret); } export function openWebhookSecret(stored: string): string { - return stored.startsWith(PREFIX) - ? decryptMfaSecret(stored.slice(PREFIX.length)) - : stored; + if (KEY_ID_PATTERN.test(stored)) { + return openWithRing(loadKeyRing('webhook'), stored).plaintext; + } + if (stored.startsWith(LEGACY_PREFIX)) { + return openLegacyCiphertext('webhook', stored.slice(LEGACY_PREFIX.length)); + } + warnLegacyRead('webhook'); + return stored; +} + +/** True for secrets the rekey job should reseal with the current key. */ +export function webhookSecretNeedsRekey(stored: string): boolean { + return !isSealedWithCurrentKey(loadKeyRing('webhook'), stored); } diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts b/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts new file mode 100644 index 0000000..6cddbd0 --- /dev/null +++ b/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts @@ -0,0 +1,86 @@ +import { WinterChecksService } from './winter-checks.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; + +describe('WinterChecksService', () => { + const livingColony = { id: 'c1', status: 'ACTIVE', apiaryId: 'a1' }; + const lostCheck: CreateWinterCheckDto = { + colonyId: 'c1', + season: 'SPRING', + date: '2027-03-20', + outcome: 'LOST', + lossCause: 'DEAD_OR_EMPTY', + markColonyDeceased: true, + }; + + let service: WinterChecksService; + let tx: { + winterCheck: { create: jest.Mock }; + colony: { update: jest.Mock }; + auditLog: { create: jest.Mock }; + }; + let prisma: { + colony: { findUnique: jest.Mock }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + + beforeEach(() => { + tx = { + winterCheck: { + create: jest + .fn() + .mockImplementation(({ data }: { data: object }) => + Promise.resolve({ id: 'w1', ...data }), + ), + }, + colony: { + update: jest + .fn() + .mockResolvedValue({ ...livingColony, status: 'DECEASED' }), + }, + auditLog: { create: jest.fn() }, + }; + prisma = { + colony: { findUnique: jest.fn().mockResolvedValue(livingColony) }, + $transaction: jest + .fn() + .mockImplementation((work: (client: typeof tx) => Promise) => + work(tx), + ), + }; + auditLog = { record: jest.fn() }; + service = new WinterChecksService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('audits a colony marked deceased through the audit service inside the transaction', async () => { + await service.create(lostCheck, 'u1'); + + expect(auditLog.record).toHaveBeenCalledWith( + { + entityType: 'Colony', + entityId: 'c1', + action: 'UPDATE', + userId: 'u1', + beforeData: livingColony, + afterData: { ...livingColony, status: 'DECEASED' }, + }, + tx, + ); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + }); + + it('writes no colony audit when the colony is not to be marked deceased', async () => { + await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); + + expect(tx.colony.update).not.toHaveBeenCalled(); + const auditedTypes = ( + auditLog.record.mock.calls as [{ entityType: string }][] + ).map(([input]) => input.entityType); + expect(auditedTypes).toEqual(['WinterCheck']); + }); +}); diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.service.ts b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts index 02f4c28..155ecb7 100644 --- a/ApiCore-Backend/src/winter-checks/winter-checks.service.ts +++ b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts @@ -226,8 +226,8 @@ export class WinterChecksService { where: { id: colony.id }, data: { status: 'DECEASED', deactivatedAt: check.date }, }); - await tx.auditLog.create({ - data: { + await this.auditLog.record( + { entityType: 'Colony', entityId: colony.id, action: 'UPDATE', @@ -235,7 +235,8 @@ export class WinterChecksService { beforeData: colony, afterData: after, }, - }); + tx, + ); } private async withDuplicateMessage(run: () => Promise): Promise { diff --git a/ApiCore-Backend/src/worker.spec.ts b/ApiCore-Backend/src/worker.spec.ts new file mode 100644 index 0000000..13ee033 --- /dev/null +++ b/ApiCore-Backend/src/worker.spec.ts @@ -0,0 +1,66 @@ +import { INestApplicationContext } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { bootstrapWorker } from './worker'; +import { WorkerModule } from './worker/worker.module'; + +const VALID_ENV: NodeJS.ProcessEnv = { + DATABASE_URL: 'postgresql://worker:secret@db/app', + JWT_ACCESS_SECRET: 'a'.repeat(32), +}; + +describe('bootstrapWorker', () => { + let createContext: jest.SpyInstance; + let app: { enableShutdownHooks: jest.Mock }; + + beforeEach(() => { + app = { enableShutdownHooks: jest.fn() }; + createContext = jest + .spyOn(NestFactory, 'createApplicationContext') + .mockResolvedValue(app as unknown as INestApplicationContext); + }); + + afterEach(() => jest.restoreAllMocks()); + + // A misconfigured pod must fail before any module connects anywhere. + it.each([ + [ + 'a role of another entry point', + { ...VALID_ENV, APICORE_ROLE: 'api' }, + 'APICORE_ROLE=api cannot start dist/worker.js', + ], + [ + '"all" in production', + { ...VALID_ENV, NODE_ENV: 'production', APICORE_ROLE: 'all' }, + 'APICORE_ROLE=all is for local development and tests only', + ], + [ + 'no role in production', + { ...VALID_ENV, NODE_ENV: 'production' }, + 'APICORE_ROLE is required in production', + ], + [ + 'a missing DATABASE_URL', + { JWT_ACCESS_SECRET: VALID_ENV.JWT_ACCESS_SECRET }, + 'Missing required environment variable(s): DATABASE_URL', + ], + [ + 'a short JWT secret', + { ...VALID_ENV, JWT_ACCESS_SECRET: 'too-short' }, + 'JWT_ACCESS_SECRET must be at least 32 characters', + ], + ])( + 'refuses %s before creating the context', + async (_case, env: NodeJS.ProcessEnv, message: string) => { + await expect(bootstrapWorker(env)).rejects.toThrow(message); + + expect(createContext).not.toHaveBeenCalled(); + }, + ); + + it('starts WorkerModule as an application context with shutdown hooks', async () => { + await expect(bootstrapWorker(VALID_ENV)).resolves.toBe(app); + + expect(createContext).toHaveBeenCalledWith(WorkerModule); + expect(app.enableShutdownHooks).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ApiCore-Backend/src/worker.ts b/ApiCore-Backend/src/worker.ts new file mode 100644 index 0000000..2c7aa83 --- /dev/null +++ b/ApiCore-Backend/src/worker.ts @@ -0,0 +1,28 @@ +import { INestApplicationContext } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { resolveRole } from './runtime/role'; +import { assertStartupEnvironment } from './runtime/startup-checks'; +import { WorkerModule } from './worker/worker.module'; + +/** + * Entry point of the worker role: domain events, calendar outbox and their + * wake-ups, plus GET /health on WORKER_HEALTH_PORT - no HTTP API. Role and + * environment are checked before any module is created, so a misconfigured + * pod fails before it connects anywhere. `env` is for tests; the modules + * themselves read process.env. + */ +export async function bootstrapWorker( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const role = resolveRole('worker', env); + assertStartupEnvironment(role, env); + + const app = await NestFactory.createApplicationContext(WorkerModule); + app.enableShutdownHooks(); + return app; +} + +// Started as `node dist/worker.js`; importing this file starts nothing. +if (require.main === module) { + void bootstrapWorker(); +} diff --git a/ApiCore-Backend/src/worker/pg-listener.spec.ts b/ApiCore-Backend/src/worker/pg-listener.spec.ts new file mode 100644 index 0000000..b08024a --- /dev/null +++ b/ApiCore-Backend/src/worker/pg-listener.spec.ts @@ -0,0 +1,254 @@ +import { Logger } from '@nestjs/common'; +import { Client } from 'pg'; +import { ListenClient, PgListener } from './pg-listener'; +import type { WakeChannel } from '../common/worker-wakeup'; + +jest.mock('pg', () => ({ Client: jest.fn() })); + +const CONNECTION_STRING = 'postgresql://user:secret@db/app'; +const CHANNELS: readonly WakeChannel[] = [ + 'domain_event', + 'calendar_sync', + 'webhook_delivery', +]; + +class FakeClient implements ListenClient { + connect = jest.fn, []>().mockResolvedValue(undefined); + query = jest.fn, [string]>().mockResolvedValue(undefined); + end = jest.fn, []>().mockResolvedValue(undefined); + private readonly handlers = new Map void>(); + + on( + event: 'notification' | 'error' | 'end', + cb: (arg: unknown) => void, + ): void { + this.handlers.set(event, cb); + } + + emit(event: 'notification' | 'error' | 'end', arg?: unknown): void { + this.handlers.get(event)?.(arg); + } +} + +describe('PgListener', () => { + let clients: FakeClient[]; + let connectFails: boolean; + let logger: { + error: jest.Mock; + warn: jest.Mock; + log: jest.Mock; + }; + let onNotify: jest.Mock; + let listener: PgListener; + + function createListener( + clientFactory: (connectionString: string) => ListenClient, + ): PgListener { + return new PgListener({ + connectionString: CONNECTION_STRING, + channels: CHANNELS, + onNotify, + logger: logger as unknown as Logger, + clientFactory, + }); + } + + beforeEach(() => { + jest.useFakeTimers(); + clients = []; + connectFails = false; + logger = { + error: jest.fn(), + warn: jest.fn(), + log: jest.fn(), + }; + onNotify = jest.fn(); + listener = createListener(() => { + const client = new FakeClient(); + if (connectFails) { + client.connect.mockRejectedValue(new Error('ECONNREFUSED')); + } + clients.push(client); + return client; + }); + }); + + afterEach(async () => { + await listener.stop(); + jest.useRealTimers(); + }); + + it('LISTENs on every channel after connecting', async () => { + await listener.start(); + + expect(clients).toHaveLength(1); + expect(clients[0].connect).toHaveBeenCalledTimes(1); + expect(clients[0].query.mock.calls.map((c) => c[0])).toEqual([ + 'LISTEN "domain_event"', + 'LISTEN "calendar_sync"', + 'LISTEN "webhook_delivery"', + ]); + }); + + it('passes the connection string to the client factory', async () => { + const factory = jest.fn(() => new FakeClient()); + const other = createListener(factory); + await other.start(); + expect(factory).toHaveBeenCalledWith(CONNECTION_STRING); + await other.stop(); + }); + + it('forwards notifications of known channels and ignores others', async () => { + await listener.start(); + + clients[0].emit('notification', { channel: 'calendar_sync', payload: '' }); + clients[0].emit('notification', { channel: 'something_else' }); + clients[0].emit('notification', 'garbage'); + + expect(onNotify).toHaveBeenCalledTimes(1); + expect(onNotify).toHaveBeenCalledWith('calendar_sync'); + }); + + it('reconnects after an error with 1 s, 2 s, 4 s … at most 30 s', async () => { + await listener.start(); + connectFails = true; + clients[0].emit('error', new Error('connection lost')); + + for (const delay of [1000, 2000, 4000, 8000, 16000, 30000, 30000]) { + const before = clients.length; + await jest.advanceTimersByTimeAsync(delay - 1); + expect(clients).toHaveLength(before); + await jest.advanceTimersByTimeAsync(1); + expect(clients).toHaveLength(before + 1); + } + }); + + it('resets the backoff after a successful reconnect', async () => { + await listener.start(); + clients[0].emit('error', new Error('lost')); + await jest.advanceTimersByTimeAsync(1000); + expect(clients).toHaveLength(2); + + clients[1].emit('end'); + await jest.advanceTimersByTimeAsync(999); + expect(clients).toHaveLength(2); + await jest.advanceTimersByTimeAsync(1); + expect(clients).toHaveLength(3); + }); + + it('reconnects only once when error and end both fire', async () => { + await listener.start(); + clients[0].emit('error', new Error('lost')); + clients[0].emit('end'); + await jest.advanceTimersByTimeAsync(1000); + expect(clients).toHaveLength(2); + }); + + it('retries when the initial connect fails and logs no credentials', async () => { + connectFails = true; + await listener.start(); + expect(clients).toHaveLength(1); + + await jest.advanceTimersByTimeAsync(1000); + expect(clients).toHaveLength(2); + + const logged = JSON.stringify([ + ...logger.error.mock.calls, + ...logger.warn.mock.calls, + ]); + expect(logged).not.toContain('secret'); + }); + + it('stop() ends the client and does not reconnect', async () => { + await listener.start(); + await listener.stop(); + + expect(clients[0].end).toHaveBeenCalledTimes(1); + clients[0].emit('end'); + await jest.advanceTimersByTimeAsync(60_000); + expect(clients).toHaveLength(1); + }); + + it('flushes a backlog by notifying every channel after a reconnect only', async () => { + await listener.start(); + expect(onNotify).not.toHaveBeenCalled(); + + clients[0].emit('error', new Error('lost')); + await jest.advanceTimersByTimeAsync(1000); + + expect( + (onNotify.mock.calls as [WakeChannel][]).map(([channel]) => channel), + ).toEqual([...CHANNELS]); + }); + + it('stop() does not hang on a client whose end() never settles', async () => { + await listener.start(); + clients[0].end.mockReturnValue(new Promise(() => undefined)); + + const stopping = listener.stop(); + await jest.advanceTimersByTimeAsync(5000); + await expect(stopping).resolves.toBeUndefined(); + }); + + it('does not recycle the connection when onNotify throws', async () => { + onNotify.mockImplementation(() => { + throw new Error('consumer failed'); + }); + await listener.start(); + + clients[0].emit('notification', { channel: 'domain_event' }); + await jest.advanceTimersByTimeAsync(60_000); + expect(clients).toHaveLength(1); + expect(clients[0].end).not.toHaveBeenCalled(); + + clients[0].emit('error', new Error('lost')); + await jest.advanceTimersByTimeAsync(1000); + expect(clients).toHaveLength(2); + // Flush calls all threw, yet the new connection stays. + await jest.advanceTimersByTimeAsync(60_000); + expect(onNotify).toHaveBeenCalledTimes(1 + CHANNELS.length); + expect(clients).toHaveLength(2); + expect(clients[1].end).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('creates the default pg client with keep-alive settings', async () => { + const created = new FakeClient(); + (Client as unknown as jest.Mock).mockReturnValue(created); + const defaulted = new PgListener({ + connectionString: CONNECTION_STRING, + channels: CHANNELS, + onNotify, + logger: logger as unknown as Logger, + }); + await defaulted.start(); + + expect(Client).toHaveBeenCalledWith( + expect.objectContaining({ + connectionString: CONNECTION_STRING, + keepAlive: true, + keepAliveInitialDelayMillis: 10_000, + }), + ); + await defaulted.stop(); + }); + + it('pins the LISTEN session to UTC like every Prisma connection', async () => { + (Client as unknown as jest.Mock).mockReturnValue(new FakeClient()); + const defaulted = new PgListener({ + connectionString: CONNECTION_STRING, + channels: CHANNELS, + onNotify, + logger: logger as unknown as Logger, + }); + await defaulted.start(); + + expect(Client).toHaveBeenCalledWith( + expect.objectContaining({ + connectionString: CONNECTION_STRING, + options: '-c TimeZone=UTC', + }), + ); + await defaulted.stop(); + }); +}); diff --git a/ApiCore-Backend/src/worker/pg-listener.ts b/ApiCore-Backend/src/worker/pg-listener.ts new file mode 100644 index 0000000..a31831e --- /dev/null +++ b/ApiCore-Backend/src/worker/pg-listener.ts @@ -0,0 +1,149 @@ +import { Logger } from '@nestjs/common'; +import { Client } from 'pg'; +import { loggableError } from '../common/loggable-error'; +import { withTimeout } from '../common/with-timeout'; +import type { WakeChannel } from '../common/worker-wakeup'; +import { dbSessionConfig } from '../prisma/db-session'; + +const RECONNECT_INITIAL_DELAY_MS = 1000; +const RECONNECT_MAX_DELAY_MS = 30_000; +const CONNECTION_TIMEOUT_MS = 10_000; +const KEEP_ALIVE_INITIAL_DELAY_MS = 10_000; +const END_TIMEOUT_MS = 5000; + +export interface ListenClient { + connect(): Promise; + query(sql: string): Promise; + on(event: 'notification' | 'error' | 'end', cb: (arg: unknown) => void): void; + end(): Promise; +} + +export interface PgListenerOptions { + connectionString: string; + channels: readonly WakeChannel[]; + onNotify: (channel: WakeChannel) => void; + logger: Logger; + clientFactory?: (connectionString: string) => ListenClient; +} + +/** + * Same connection string and session settings (UTC) as Prisma, so host, + * TLS and time zone match. + */ +function createPgClient(connectionString: string): ListenClient { + return new Client({ + ...dbSessionConfig(connectionString), + keepAlive: true, + keepAliveInitialDelayMillis: KEEP_ALIVE_INITIAL_DELAY_MS, + connectionTimeoutMillis: CONNECTION_TIMEOUT_MS, + }) as unknown as ListenClient; +} + +function notificationChannel(arg: unknown): string | undefined { + if (typeof arg !== 'object' || arg === null) return undefined; + const { channel } = arg as { channel?: unknown }; + return typeof channel === 'string' ? channel : undefined; +} + +/** + * Dedicated Postgres connection that LISTENs on the wake-up channels and + * reconnects with exponential backoff (1 s, doubled, at most 30 s). A missed + * notification is harmless: the polling loops catch up on their next tick. + */ +export class PgListener { + private readonly createClient: (connectionString: string) => ListenClient; + private client: ListenClient | undefined; + private reconnectTimer: NodeJS.Timeout | undefined; + private reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS; + private stopped = true; + + constructor(private readonly options: PgListenerOptions) { + this.createClient = options.clientFactory ?? createPgClient; + } + + async start(): Promise { + if (!this.stopped) return; + this.stopped = false; + await this.connect(false); + } + + async stop(): Promise { + this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + const client = this.client; + this.client = undefined; + if (client) await this.endQuietly(client); + } + + private async connect(isReconnect: boolean): Promise { + const client = this.createClient(this.options.connectionString); + this.client = client; + client.on('notification', (arg) => this.handleNotification(arg)); + client.on('error', (error) => this.handleLoss(client, error)); + client.on('end', () => this.handleLoss(client)); + try { + await client.connect(); + for (const channel of this.options.channels) { + await client.query(`LISTEN "${channel}"`); + } + this.reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS; + // Notifications sent while disconnected are gone; wake everything. + if (isReconnect) { + this.options.channels.forEach((channel) => this.notify(channel)); + } + } catch (error: unknown) { + this.handleLoss(client, error); + } + } + + /** A failing consumer must never be mistaken for a lost connection. */ + private notify(channel: WakeChannel): void { + try { + this.options.onNotify(channel); + } catch (error: unknown) { + const { message } = loggableError(error); + this.options.logger.error(`onNotify failed for ${channel}: ${message}`); + } + } + + private handleNotification(arg: unknown): void { + const channel = notificationChannel(arg); + const known = this.options.channels.find((c) => c === channel); + if (known) this.notify(known); + } + + private handleLoss(client: ListenClient, error?: unknown): void { + // error and end fire for the same loss; only the current client counts. + if (this.client !== client) return; + this.client = undefined; + if (error !== undefined) { + const { message } = loggableError(error); + this.options.logger.warn(`LISTEN connection lost: ${message}`); + } + void this.endQuietly(client); + if (this.stopped) return; + const delay = this.reconnectDelayMs; + this.reconnectDelayMs = Math.min(delay * 2, RECONNECT_MAX_DELAY_MS); + this.options.logger.log(`Reconnecting LISTEN connection in ${delay} ms`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + void this.connect(true); + }, delay); + } + + private async endQuietly(client: ListenClient): Promise { + try { + await withTimeout( + client.end(), + END_TIMEOUT_MS, + 'LISTEN client end timed out', + ); + } catch (error: unknown) { + const { message } = loggableError(error); + this.options.logger.warn(`Closing LISTEN connection failed: ${message}`); + } + } +} diff --git a/ApiCore-Backend/src/worker/polling-loop.spec.ts b/ApiCore-Backend/src/worker/polling-loop.spec.ts new file mode 100644 index 0000000..0e43ea2 --- /dev/null +++ b/ApiCore-Backend/src/worker/polling-loop.spec.ts @@ -0,0 +1,215 @@ +import { Logger } from '@nestjs/common'; +import { PollingLoop } from './polling-loop'; + +const INTERVAL_MS = 5000; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe('PollingLoop', () => { + let logger: { error: jest.Mock }; + + function createLoop(tick: () => Promise): PollingLoop { + return new PollingLoop({ + name: 'test-loop', + intervalMs: INTERVAL_MS, + tick, + logger: logger as unknown as Logger, + }); + } + + beforeEach(() => { + jest.useFakeTimers(); + logger = { error: jest.fn() }; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('ticks every intervalMs', async () => { + const tick = jest.fn().mockResolvedValue(undefined); + const loop = createLoop(tick); + loop.start(); + + await jest.advanceTimersByTimeAsync(0); + expect(tick).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(INTERVAL_MS - 1); + expect(tick).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(1); + expect(tick).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(INTERVAL_MS); + expect(tick).toHaveBeenCalledTimes(3); + + await loop.stop(); + }); + + it('never runs two ticks at once', async () => { + const gate = deferred(); + const tick = jest.fn().mockReturnValue(gate.promise); + const loop = createLoop(tick); + loop.start(); + + await jest.advanceTimersByTimeAsync(INTERVAL_MS * 3); + expect(tick).toHaveBeenCalledTimes(1); + + gate.resolve(); + await loop.stop(); + }); + + it('wake() during a tick runs exactly one more tick right after', async () => { + const gate = deferred(); + const tick = jest + .fn() + .mockReturnValueOnce(gate.promise) + .mockResolvedValue(undefined); + const loop = createLoop(tick); + loop.start(); + await jest.advanceTimersByTimeAsync(0); + + loop.wake(); + loop.wake(); + loop.wake(); + expect(tick).toHaveBeenCalledTimes(1); + + gate.resolve(); + await jest.advanceTimersByTimeAsync(0); + expect(tick).toHaveBeenCalledTimes(2); + + await loop.stop(); + }); + + it('wake() while waiting runs the next tick immediately', async () => { + const tick = jest.fn().mockResolvedValue(undefined); + const loop = createLoop(tick); + loop.start(); + await jest.advanceTimersByTimeAsync(0); + expect(tick).toHaveBeenCalledTimes(1); + + loop.wake(); + await jest.advanceTimersByTimeAsync(0); + expect(tick).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(INTERVAL_MS - 1); + expect(tick).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(1); + expect(tick).toHaveBeenCalledTimes(3); + + await loop.stop(); + }); + + it('logs a throwing tick via loggableError and keeps going', async () => { + const tick = jest + .fn() + .mockRejectedValueOnce(new Error('tick exploded')) + .mockResolvedValue(undefined); + const loop = createLoop(tick); + loop.start(); + + await jest.advanceTimersByTimeAsync(0); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('tick exploded'), + expect.any(String), + ); + + await jest.advanceTimersByTimeAsync(INTERVAL_MS); + expect(tick).toHaveBeenCalledTimes(2); + + await loop.stop(); + }); + + it('stop() waits for the running tick and schedules nothing more', async () => { + const gate = deferred(); + const tick = jest.fn().mockReturnValue(gate.promise); + const loop = createLoop(tick); + loop.start(); + await jest.advanceTimersByTimeAsync(0); + + let stopped = false; + const stopping = loop.stop().then(() => { + stopped = true; + }); + await jest.advanceTimersByTimeAsync(0); + expect(stopped).toBe(false); + + gate.resolve(); + await stopping; + expect(stopped).toBe(true); + + await jest.advanceTimersByTimeAsync(INTERVAL_MS * 3); + expect(tick).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); + }); + + it('wake() after stop() does nothing', async () => { + const tick = jest.fn().mockResolvedValue(undefined); + const loop = createLoop(tick); + loop.start(); + await jest.advanceTimersByTimeAsync(0); + await loop.stop(); + + loop.wake(); + await jest.advanceTimersByTimeAsync(INTERVAL_MS); + expect(tick).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); + }); + + it('does not lose a wake() fired right at the end of a tick', async () => { + const tick = jest + .fn() + .mockImplementationOnce(async () => { + // Fires after the tick body, before the loop bookkeeping finished. + await Promise.resolve(); + queueMicrotask(() => loop.wake()); + }) + .mockResolvedValue(undefined); + const loop = createLoop(tick); + loop.start(); + + await jest.advanceTimersByTimeAsync(1); + expect(tick).toHaveBeenCalledTimes(2); + + await loop.stop(); + }); + + it('never runs two ticks at once across stop() and start()', async () => { + const gate = deferred(); + let active = 0; + let maxActive = 0; + const tick = jest + .fn() + .mockImplementationOnce(async () => { + active++; + maxActive = Math.max(maxActive, active); + await gate.promise; + active--; + }) + .mockImplementation(() => { + active++; + maxActive = Math.max(maxActive, active); + active--; + return Promise.resolve(); + }); + const loop = createLoop(tick); + loop.start(); + await jest.advanceTimersByTimeAsync(0); + + void loop.stop(); + loop.start(); + await jest.advanceTimersByTimeAsync(INTERVAL_MS); + expect(tick).toHaveBeenCalledTimes(1); + + gate.resolve(); + await jest.advanceTimersByTimeAsync(1); + expect(tick).toHaveBeenCalledTimes(2); + expect(maxActive).toBe(1); + + await loop.stop(); + }); +}); diff --git a/ApiCore-Backend/src/worker/polling-loop.ts b/ApiCore-Backend/src/worker/polling-loop.ts new file mode 100644 index 0000000..4528768 --- /dev/null +++ b/ApiCore-Backend/src/worker/polling-loop.ts @@ -0,0 +1,81 @@ +import { Logger } from '@nestjs/common'; +import { loggableError } from '../common/loggable-error'; + +export interface PollingLoopOptions { + name: string; + intervalMs: number; + tick: () => Promise; + logger: Logger; +} + +/** + * Single-flight polling loop. The next tick is scheduled with setTimeout + * only after the previous one finished (no setInterval), so ticks never + * overlap. `wake()` skips the wait; a wake during a running tick leads to + * exactly one more tick right after it. + */ +export class PollingLoop { + private timer: NodeJS.Timeout | undefined; + private running: Promise | undefined; + private rerunRequested = false; + private stopped = true; + + constructor(private readonly options: PollingLoopOptions) {} + + start(): void { + if (!this.stopped) return; + this.stopped = false; + // A tick of the previous run may still be finishing; its bookkeeping + // schedules the next one, so ticks never overlap across stop()/start(). + // The first tick after start() runs immediately, also in that case. + if (this.running) this.rerunRequested = true; + else this.schedule(0); + } + + wake(): void { + if (this.stopped) return; + if (this.running) { + this.rerunRequested = true; + return; + } + this.schedule(0); + } + + async stop(): Promise { + this.stopped = true; + this.rerunRequested = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + await this.running; + } + + private schedule(delayMs: number): void { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = undefined; + this.running = this.runTick().finally(() => { + this.running = undefined; + // Decided after `running` is cleared: a wake() before this point + // set rerunRequested, one after it schedules itself. + if (!this.stopped) { + this.schedule(this.rerunRequested ? 0 : this.options.intervalMs); + } + }); + }, delayMs); + } + + private async runTick(): Promise { + this.rerunRequested = false; + try { + await this.options.tick(); + } catch (error: unknown) { + const { message, stack } = loggableError(error); + this.options.logger.error( + `${this.options.name} tick failed: ${message}`, + stack, + ); + } + } +} diff --git a/ApiCore-Backend/src/worker/worker-health-server.service.ts b/ApiCore-Backend/src/worker/worker-health-server.service.ts new file mode 100644 index 0000000..4de91c6 --- /dev/null +++ b/ApiCore-Backend/src/worker/worker-health-server.service.ts @@ -0,0 +1,44 @@ +import type { Server } from 'node:http'; +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnApplicationShutdown, +} from '@nestjs/common'; +import { startWorkerHealthServer, workerHealthPort } from './worker-health'; +import { WorkerRuntime } from './worker-runtime.service'; + +/** + * GET /health of the worker process on WORKER_HEALTH_PORT (default 3002), + * for the kubelet only - no Service, no Ingress. Only WorkerModule provides + * it; in "all" the API's own /health endpoints apply. + */ +@Injectable() +export class WorkerHealthServer + implements OnApplicationBootstrap, OnApplicationShutdown +{ + private readonly logger = new Logger(WorkerHealthServer.name); + private server: Server | undefined; + + constructor(private readonly runtime: WorkerRuntime) {} + + async onApplicationBootstrap(): Promise { + const port = workerHealthPort(); + this.server = await startWorkerHealthServer({ + port, + check: () => this.runtime.health(), + }); + this.logger.log(`Worker health endpoint listening on port ${port}`); + } + + async onApplicationShutdown(): Promise { + const server = this.server; + this.server = undefined; + if (!server) { + return; + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} diff --git a/ApiCore-Backend/src/worker/worker-health.spec.ts b/ApiCore-Backend/src/worker/worker-health.spec.ts new file mode 100644 index 0000000..06a58fe --- /dev/null +++ b/ApiCore-Backend/src/worker/worker-health.spec.ts @@ -0,0 +1,135 @@ +import * as http from 'node:http'; +import { AddressInfo } from 'node:net'; +import { Logger } from '@nestjs/common'; +import { + DEFAULT_WORKER_HEALTH_PORT, + HEALTH_STALE_MS, + startWorkerHealthServer, + WorkerHealth, + workerHealthPort, +} from './worker-health'; + +interface JsonResponse { + status: number; + body: unknown; +} + +describe('worker health', () => { + let server: http.Server | undefined; + + afterEach(async () => { + jest.restoreAllMocks(); + const running = server; + server = undefined; + if (running) { + await new Promise((resolve) => running.close(resolve)); + } + }); + + async function start(check: () => Promise): Promise { + server = await startWorkerHealthServer({ port: 0, check }); + return (server.address() as AddressInfo).port; + } + + function request( + port: number, + path: string, + method = 'GET', + ): Promise { + return new Promise((resolve, reject) => { + const req = http.request({ port, path, method }, (res) => { + let raw = ''; + res.on('data', (chunk: Buffer) => (raw += chunk.toString())); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + body: raw ? (JSON.parse(raw) as unknown) : undefined, + }), + ); + }); + req.on('error', reject); + req.end(); + }); + } + + it('exposes the documented constants', () => { + expect(HEALTH_STALE_MS).toBe(120_000); + expect(DEFAULT_WORKER_HEALTH_PORT).toBe(3002); + }); + + it('answers 200 {"status":"ok"} on GET /health when healthy', async () => { + const port = await start(() => Promise.resolve({ ok: true })); + expect(await request(port, '/health')).toEqual({ + status: 200, + body: { status: 'ok' }, + }); + }); + + it('answers 503 with only status and reason when unhealthy', async () => { + const port = await start(() => + Promise.resolve({ ok: false, reason: 'dispatcher-stale' }), + ); + expect(await request(port, '/health')).toEqual({ + status: 503, + body: { status: 'unhealthy', reason: 'dispatcher-stale' }, + }); + }); + + it('answers 404 for any other path or method', async () => { + const port = await start(() => Promise.resolve({ ok: true })); + expect((await request(port, '/')).status).toBe(404); + expect((await request(port, '/health/ready')).status).toBe(404); + expect((await request(port, '/health', 'POST')).status).toBe(404); + }); + + it('sends no JSON content type with a 404', async () => { + const port = await start(() => Promise.resolve({ ok: true })); + const headers = await new Promise((resolve) => { + http.get({ port, path: '/nope' }, (res) => { + res.resume(); + resolve(res.headers); + }); + }); + expect(headers['content-type']).toBeUndefined(); + }); + + it('treats a throwing check as 503 database', async () => { + const error = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const port = await start(() => Promise.reject(new Error('pw=hunter2'))); + expect(await request(port, '/health')).toEqual({ + status: 503, + body: { status: 'unhealthy', reason: 'database' }, + }); + expect(error).toHaveBeenCalled(); + }); + + describe('workerHealthPort', () => { + it('defaults to 3002', () => { + expect(workerHealthPort({})).toBe(3002); + }); + + it('reads WORKER_HEALTH_PORT', () => { + expect(workerHealthPort({ WORKER_HEALTH_PORT: '4100' })).toBe(4100); + }); + + it.each([ + 'abc', + '0', + '-1', + '70000', + '30.5', + '', + '0x10', + '1e3', + ' 3002', + '3002 ', + '+3002', + ])('rejects invalid value %p', (value) => { + expect(() => workerHealthPort({ WORKER_HEALTH_PORT: value })).toThrow( + 'WORKER_HEALTH_PORT', + ); + }); + }); +}); diff --git a/ApiCore-Backend/src/worker/worker-health.ts b/ApiCore-Backend/src/worker/worker-health.ts new file mode 100644 index 0000000..356995d --- /dev/null +++ b/ApiCore-Backend/src/worker/worker-health.ts @@ -0,0 +1,94 @@ +import * as http from 'node:http'; +import { Logger } from '@nestjs/common'; +import { loggableError } from '../common/loggable-error'; + +/** The dispatcher must have completed a run within this window. */ +export const HEALTH_STALE_MS = 120_000; +export const DEFAULT_WORKER_HEALTH_PORT = 3002; + +const MAX_PORT = 65_535; +const HEALTH_PATH = '/health'; + +export interface WorkerHealth { + ok: boolean; + reason?: 'database' | 'dispatcher-stale'; +} + +export interface WorkerHealthServerOptions { + port: number; + check: () => Promise; +} + +const logger = new Logger('WorkerHealth'); + +export function workerHealthPort(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.WORKER_HEALTH_PORT; + if (raw === undefined) return DEFAULT_WORKER_HEALTH_PORT; + const port = /^\d+$/.test(raw) ? Number(raw) : 0; + if (port < 1 || port > MAX_PORT) { + throw new Error( + `WORKER_HEALTH_PORT must be an integer from 1 to ${MAX_PORT}`, + ); + } + return port; +} + +async function evaluate( + check: () => Promise, +): Promise { + try { + return await check(); + } catch (error: unknown) { + const { message, stack } = loggableError(error); + logger.error(`Worker health check failed: ${message}`, stack); + return { ok: false, reason: 'database' }; + } +} + +function respond( + res: http.ServerResponse, + status: number, + body?: Record, +): void { + const headers: http.OutgoingHttpHeaders = { 'Cache-Control': 'no-store' }; + if (body) headers['Content-Type'] = 'application/json'; + res.writeHead(status, headers); + res.end(body ? JSON.stringify(body) : undefined); +} + +/** Minimal probe endpoint (node:http, no Express): GET /health only. */ +export async function startWorkerHealthServer( + options: WorkerHealthServerOptions, +): Promise { + const server = http.createServer((req, res) => { + const path = (req.url ?? '').split('?')[0]; + if (req.method !== 'GET' || path !== HEALTH_PATH) { + respond(res, 404); + return; + } + evaluate(options.check) + .then((health) => { + if (health.ok) { + respond(res, 200, { status: 'ok' }); + } else { + respond(res, 503, { + status: 'unhealthy', + reason: health.reason ?? 'database', + }); + } + }) + .catch((error: unknown) => { + const { message, stack } = loggableError(error); + logger.error(`Worker health response failed: ${message}`, stack); + res.destroy(); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(options.port, () => { + server.off('error', reject); + resolve(); + }); + }); + return server; +} diff --git a/ApiCore-Backend/src/worker/worker-runtime.module.ts b/ApiCore-Backend/src/worker/worker-runtime.module.ts new file mode 100644 index 0000000..aa118c2 --- /dev/null +++ b/ApiCore-Backend/src/worker/worker-runtime.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { WorkerRuntime } from './worker-runtime.service'; + +/** + * The worker loops. Imported by WorkerModule (dist/worker.js) and + * AllInOneModule ("all"), never by AppModule: API pods run no background + * loops. Its dependencies come from the global Prisma, DomainEvents and + * CalendarSync modules of the importing root module. + */ +@Module({ + providers: [WorkerRuntime], + exports: [WorkerRuntime], +}) +export class WorkerRuntimeModule {} diff --git a/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts b/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts new file mode 100644 index 0000000..4139a49 --- /dev/null +++ b/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts @@ -0,0 +1,330 @@ +import { Logger } from '@nestjs/common'; +import { Prisma } from '../../generated/prisma'; +import type { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import type { WakeChannel } from '../common/worker-wakeup'; +import { + DomainEventDispatcher, + DRAIN_MAX_ROUNDS, +} from '../domain-events/domain-event-dispatcher'; +import type { PrismaService } from '../prisma/prisma.service'; +import { PgListener, PgListenerOptions } from './pg-listener'; +import { HEALTH_STALE_MS } from './worker-health'; +import { + CALENDAR_OUTBOX_POLL_MS, + DOMAIN_EVENT_POLL_MS, + HEALTH_QUERY_TIMEOUT_MS, + WorkerRuntime, +} from './worker-runtime.service'; + +interface FakeListener { + start: jest.Mock, []>; + stop: jest.Mock, []>; +} + +jest.mock('./pg-listener', () => ({ PgListener: jest.fn() })); + +const DATABASE_URL = 'postgresql://worker:secret@db/app'; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +/** Advances lastTickAt like the real dispatcher: a new stamp per run. */ +class FakeDispatcher { + lastTickAt: Date | null = null; + releaseStaleClaims = jest.fn, []>().mockResolvedValue(0); + drain = jest.fn, unknown[]>(() => { + this.tick(); + return Promise.resolve(0); + }); + + tick(): void { + this.lastTickAt = new Date(); + } +} + +describe('WorkerRuntime', () => { + const ListenerMock = PgListener as unknown as jest.Mock; + const originalDatabaseUrl = process.env.DATABASE_URL; + let listener: FakeListener; + let prisma: { $queryRaw: jest.Mock }; + let dispatcher: FakeDispatcher; + let calendarSync: { + processOutbox: jest.Mock, [AbortSignal?]>; + }; + let runtime: WorkerRuntime; + let logError: jest.SpyInstance; + + const listenerOptions = (): PgListenerOptions => + (ListenerMock.mock.calls[0] as [PgListenerOptions])[0]; + const notify = (channel: WakeChannel) => listenerOptions().onNotify(channel); + + /** Bootstraps the runtime and lets the first ticks run. */ + async function bootstrap(): Promise { + await runtime.onApplicationBootstrap(); + await jest.advanceTimersByTimeAsync(0); + } + + beforeEach(() => { + jest.useFakeTimers(); + process.env.DATABASE_URL = DATABASE_URL; + listener = { + start: jest.fn, []>().mockResolvedValue(undefined), + stop: jest.fn, []>().mockResolvedValue(undefined), + }; + ListenerMock.mockReset().mockImplementation(() => listener); + prisma = { $queryRaw: jest.fn().mockResolvedValue([{ ok: 1 }]) }; + dispatcher = new FakeDispatcher(); + calendarSync = { + processOutbox: jest + .fn, [AbortSignal?]>() + .mockResolvedValue(undefined), + }; + logError = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + runtime = new WorkerRuntime( + prisma as unknown as PrismaService, + dispatcher as unknown as DomainEventDispatcher, + calendarSync as unknown as CalendarSyncService, + ); + }); + + afterEach(async () => { + await runtime.onModuleDestroy(); + jest.restoreAllMocks(); + jest.useRealTimers(); + // Assigning undefined would store the string "undefined". + if (originalDatabaseUrl === undefined) { + delete process.env.DATABASE_URL; + } else { + process.env.DATABASE_URL = originalDatabaseUrl; + } + }); + + it('exposes the documented poll intervals', () => { + expect(DOMAIN_EVENT_POLL_MS).toBe(5_000); + expect(CALENDAR_OUTBOX_POLL_MS).toBe(60_000); + expect(HEALTH_QUERY_TIMEOUT_MS).toBe(5_000); + }); + + it('starts the listener and every loop at bootstrap', async () => { + await runtime.onApplicationBootstrap(); + + expect(ListenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + connectionString: DATABASE_URL, + channels: ['domain_event', 'calendar_sync'], + }), + ); + expect(listener.start).toHaveBeenCalledTimes(1); + // LISTEN is up before the first tick: nothing committed in between + // can be missed until the next poll. + expect(dispatcher.drain).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(0); + + expect(dispatcher.releaseStaleClaims).toHaveBeenCalledTimes(1); + expect(dispatcher.drain).toHaveBeenCalledTimes(1); + // Every event of the database (no filter), stoppable by shutdown. + expect(dispatcher.drain).toHaveBeenCalledWith( + undefined, + DRAIN_MAX_ROUNDS, + expect.any(AbortSignal), + ); + const [releaseOrder] = + dispatcher.releaseStaleClaims.mock.invocationCallOrder; + const [drainOrder] = dispatcher.drain.mock.invocationCallOrder; + expect(releaseOrder).toBeLessThan(drainOrder); + expect(calendarSync.processOutbox).toHaveBeenCalledWith( + expect.any(AbortSignal), + ); + }); + + it('polls domain events every 5 s and the calendar outbox every 60 s', async () => { + await bootstrap(); + + await jest.advanceTimersByTimeAsync(DOMAIN_EVENT_POLL_MS); + expect(dispatcher.drain).toHaveBeenCalledTimes(2); + expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync( + CALENDAR_OUTBOX_POLL_MS - DOMAIN_EVENT_POLL_MS, + ); + expect(dispatcher.drain).toHaveBeenCalledTimes( + CALENDAR_OUTBOX_POLL_MS / DOMAIN_EVENT_POLL_MS + 1, + ); + expect(calendarSync.processOutbox).toHaveBeenCalledTimes(2); + }); + + it('wakes the domain-events loop on a domain_event notification', async () => { + await bootstrap(); + + notify('domain_event'); + await jest.advanceTimersByTimeAsync(0); + + expect(dispatcher.drain).toHaveBeenCalledTimes(2); + expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1); + }); + + it('wakes the calendar-outbox loop on a calendar_sync notification', async () => { + await bootstrap(); + + notify('calendar_sync'); + await jest.advanceTimersByTimeAsync(0); + + expect(calendarSync.processOutbox).toHaveBeenCalledTimes(2); + expect(dispatcher.drain).toHaveBeenCalledTimes(1); + }); + + it('is healthy after a recent tick and dispatcher-stale after 120 s without one', async () => { + await bootstrap(); + expect(await runtime.health()).toEqual({ ok: true }); + + // From the next tick on the dispatcher hangs and stamps nothing more. + const stuck = deferred(); + dispatcher.drain.mockReturnValue(stuck.promise.then(() => 0)); + + await jest.advanceTimersByTimeAsync(HEALTH_STALE_MS); + expect(await runtime.health()).toEqual({ ok: true }); + await jest.advanceTimersByTimeAsync(1); + expect(await runtime.health()).toEqual({ + ok: false, + reason: 'dispatcher-stale', + }); + + stuck.resolve(); + }); + + // lastTickAt advances per dispatched event: a long batch is progress. + it('stays healthy while a long batch keeps dispatching events', async () => { + await bootstrap(); + const batch = deferred(); + dispatcher.drain.mockReturnValue(batch.promise.then(() => 0)); + await jest.advanceTimersByTimeAsync(DOMAIN_EVENT_POLL_MS); + + await jest.advanceTimersByTimeAsync(HEALTH_STALE_MS - 1_000); + dispatcher.tick(); // one more event of the running batch dispatched + expect(await runtime.health()).toEqual({ ok: true }); + + await jest.advanceTimersByTimeAsync(HEALTH_STALE_MS); + expect(await runtime.health()).toEqual({ ok: true }); + await jest.advanceTimersByTimeAsync(1); + expect(await runtime.health()).toEqual({ + ok: false, + reason: 'dispatcher-stale', + }); + + batch.resolve(); + }); + + // A wall-clock step must neither fake nor hide a stall. + it('measures the dispatcher age on the monotonic clock', async () => { + await bootstrap(); + const stuck = deferred(); + dispatcher.drain.mockReturnValue(stuck.promise.then(() => 0)); + + jest.setSystemTime(Date.now() + 10 * HEALTH_STALE_MS); + expect(await runtime.health()).toEqual({ ok: true }); + + stuck.resolve(); + }); + + it('counts the time since bootstrap while the dispatcher never ran', async () => { + dispatcher.drain.mockRejectedValue(new Error('database down')); + await bootstrap(); + + await jest.advanceTimersByTimeAsync(HEALTH_STALE_MS); + expect(await runtime.health()).toEqual({ ok: true }); + await jest.advanceTimersByTimeAsync(1); + expect(await runtime.health()).toEqual({ + ok: false, + reason: 'dispatcher-stale', + }); + }); + + it('reports database when SELECT 1 fails or exceeds 5 s', async () => { + await bootstrap(); + + prisma.$queryRaw.mockRejectedValueOnce(new Error('connection refused')); + expect(await runtime.health()).toEqual({ ok: false, reason: 'database' }); + + prisma.$queryRaw.mockReturnValueOnce(new Promise(() => undefined)); + const slow = runtime.health(); + await jest.advanceTimersByTimeAsync(HEALTH_QUERY_TIMEOUT_MS); + await expect(slow).resolves.toEqual({ ok: false, reason: 'database' }); + }); + + it('logs a failed health query without the Prisma message', async () => { + await bootstrap(); + prisma.$queryRaw.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError('password "hunter2"', { + code: 'P1001', + clientVersion: '7.9.1', + }), + ); + + await runtime.health(); + + const [message, stack] = logError.mock.calls[0] as [string, unknown]; + expect(message).toContain('P1001'); + expect(message).not.toContain('hunter2'); + expect(stack).toBeUndefined(); + }); + + // A running tick must not claim up to 19 more batches of 50 events or + // 19 more calendar entries while the pod is being stopped. + it('hands the running work a stop signal that shutdown aborts', async () => { + await bootstrap(); + const [, , drainSignal] = dispatcher.drain.mock.calls[0] as [ + unknown, + unknown, + AbortSignal, + ]; + const [outboxSignal] = calendarSync.processOutbox.mock.calls[0]; + expect(drainSignal.aborted).toBe(false); + expect(outboxSignal).toBe(drainSignal); + + await runtime.onModuleDestroy(); + + expect(drainSignal.aborted).toBe(true); + }); + + it('stops loops and listener on shutdown', async () => { + await bootstrap(); + + await runtime.onModuleDestroy(); + + expect(listener.stop).toHaveBeenCalledTimes(1); + notify('domain_event'); + await jest.advanceTimersByTimeAsync(CALENDAR_OUTBOX_POLL_MS * 2); + expect(dispatcher.drain).toHaveBeenCalledTimes(1); + expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1); + }); + + // A pass cut off mid-entry would only be taken over after the 5 min + // stale-claim window - and Prisma disconnects right after this hook. + it('lets shutdown wait for a running tick', async () => { + const pass = deferred(); + calendarSync.processOutbox.mockReturnValueOnce(pass.promise); + await bootstrap(); + let stopped = false; + + const shutdown = runtime.onModuleDestroy().then(() => { + stopped = true; + }); + await jest.advanceTimersByTimeAsync(0); + expect(stopped).toBe(false); + // The running pass has been told to stop after its current entry. + expect(calendarSync.processOutbox.mock.calls[0][0]?.aborted).toBe(true); + + pass.resolve(); + await shutdown; + expect(stopped).toBe(true); + }); +}); diff --git a/ApiCore-Backend/src/worker/worker-runtime.service.ts b/ApiCore-Backend/src/worker/worker-runtime.service.ts new file mode 100644 index 0000000..5fa3748 --- /dev/null +++ b/ApiCore-Backend/src/worker/worker-runtime.service.ts @@ -0,0 +1,175 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnModuleDestroy, +} from '@nestjs/common'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { loggableError } from '../common/loggable-error'; +import { withTimeout } from '../common/with-timeout'; +import { WAKE_CHANNELS, WakeChannel } from '../common/worker-wakeup'; +import { + DomainEventDispatcher, + DRAIN_MAX_ROUNDS, +} from '../domain-events/domain-event-dispatcher'; +import { PrismaService } from '../prisma/prisma.service'; +import { PgListener } from './pg-listener'; +import { PollingLoop } from './polling-loop'; +import { HEALTH_STALE_MS, WorkerHealth } from './worker-health'; + +export const DOMAIN_EVENT_POLL_MS = 5_000; +export const CALENDAR_OUTBOX_POLL_MS = 60_000; +/** The health check's SELECT 1 must answer within this. */ +export const HEALTH_QUERY_TIMEOUT_MS = 5_000; + +/** One background loop: its poll interval and the channel that wakes it. */ +interface WorkerLoop { + name: string; + intervalMs: number; + channel: WakeChannel; + tick: () => Promise; +} + +function databaseUrl(): string { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error('DATABASE_URL is required for the LISTEN connection'); + } + return url; +} + +/** + * The background work of the worker role (dist/worker.js; also "all" for + * local development and tests): one single-flight polling loop per outbox, + * woken early through LISTEN/NOTIFY. Every loop claims its rows, so more + * worker pods are safe. The API role never loads this module + * (runtime/root-module.ts). + */ +@Injectable() +export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy { + private readonly logger = new Logger(WorkerRuntime.name); + private readonly loops: ReadonlyMap; + private listener: PgListener | undefined; + /** Aborted at shutdown: running ticks claim no further work. */ + private readonly stopping = new AbortController(); + /** Monotonic time (performance.now) of the last dispatcher progress. */ + private progressAt = performance.now(); + /** The dispatcher's lastTickAt as last seen; a new value is progress. */ + private seenTickAt: Date | null = null; + + constructor( + private readonly prisma: PrismaService, + private readonly dispatcher: DomainEventDispatcher, + private readonly calendarSync: CalendarSyncService, + ) { + this.loops = new Map( + this.workerLoops().map(({ channel, name, intervalMs, tick }) => [ + channel, + new PollingLoop({ name, intervalMs, tick, logger: this.logger }), + ]), + ); + } + + /** One row per outbox the worker serves. */ + private workerLoops(): readonly WorkerLoop[] { + return [ + { + name: 'domain-events', + intervalMs: DOMAIN_EVENT_POLL_MS, + channel: WAKE_CHANNELS.domainEvents, + tick: () => this.dispatchDomainEvents(), + }, + { + name: 'calendar-outbox', + intervalMs: CALENDAR_OUTBOX_POLL_MS, + channel: WAKE_CHANNELS.calendarSync, + tick: () => this.calendarSync.processOutbox(this.stopping.signal), + }, + ]; + } + + async onApplicationBootstrap(): Promise { + this.progressAt = performance.now(); + this.listener = new PgListener({ + connectionString: databaseUrl(), + channels: [...this.loops.keys()], + onNotify: (channel) => this.loops.get(channel)?.wake(), + logger: this.logger, + }); + // LISTEN first: work committed from now on wakes a loop, and work + // committed before is found by the first ticks, which run right away. + await this.listener.start(); + this.loops.forEach((loop) => loop.start()); + } + + // Not onApplicationShutdown: Nest runs every onModuleDestroy first, and + // PrismaService disconnects there. A running tick finishes the entry or + // batch it holds but claims nothing more; stopping waits for it - a tick + // cut off mid-entry leaves a claim that is only taken over after the + // 5 min stale-claim window. + async onModuleDestroy(): Promise { + this.stopping.abort(); + const listener = this.listener; + this.listener = undefined; + await listener?.stop(); + await Promise.all([...this.loops.values()].map((loop) => loop.stop())); + } + + /** + * Unhealthy if the database does not answer SELECT 1 within 5 s, or if + * the dispatcher has made no progress for longer than HEALTH_STALE_MS. + */ + async health(): Promise { + try { + await withTimeout( + this.prisma.$queryRaw`SELECT 1`, + HEALTH_QUERY_TIMEOUT_MS, + 'Worker health query timed out', + ); + } catch (error: unknown) { + // Prisma errors by name/code/model only (see loggableError). + const { message, stack } = loggableError(error); + this.logger.error(`Worker health query failed: ${message}`, stack); + return { ok: false, reason: 'database' }; + } + const idleMs = performance.now() - this.noteDispatcherProgress(); + if (idleMs > HEALTH_STALE_MS) { + this.logger.warn( + `Domain event dispatcher made no progress for ${Math.round(idleMs)} ms`, + ); + return { ok: false, reason: 'dispatcher-stale' }; + } + return { ok: true }; + } + + /** + * The dispatcher stamps lastTickAt with the wall clock on every claim and + * every dispatched event. Its age is taken on the monotonic clock instead, + * so a clock step can neither fake nor hide a stall: a new stamp counts + * from the moment it is first seen - after every tick, and on every + * health check while a long batch is still running. Before the first + * stamp, the time since bootstrap counts. + */ + private noteDispatcherProgress(): number { + const tickAt = this.dispatcher.lastTickAt; + if (tickAt !== this.seenTickAt) { + this.seenTickAt = tickAt; + this.progressAt = performance.now(); + } + return this.progressAt; + } + + private async dispatchDomainEvents(): Promise { + try { + await this.dispatcher.releaseStaleClaims(); + // No filter: every event of the database. + await this.dispatcher.drain( + undefined, + DRAIN_MAX_ROUNDS, + this.stopping.signal, + ); + } finally { + this.noteDispatcherProgress(); + } + } +} diff --git a/ApiCore-Backend/src/worker/worker.module.ts b/ApiCore-Backend/src/worker/worker.module.ts new file mode 100644 index 0000000..eb3d6fa --- /dev/null +++ b/ApiCore-Backend/src/worker/worker.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AuditModule } from '../audit/audit.module'; +import { AutomationModule } from '../automation/automation.module'; +import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module'; +import { DomainEventsModule } from '../domain-events/domain-events.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { TasksModule } from '../tasks/tasks.module'; +import { WebhooksModule } from '../webhooks/webhooks.module'; +import { WorkerHealthServer } from './worker-health-server.service'; +import { WorkerRuntimeModule } from './worker-runtime.module'; + +/** + * Root module of dist/worker.js, started with + * NestFactory.createApplicationContext: the modules the worker loops and + * the domain event handlers need, without the HTTP layer (no guards, + * interceptors, throttler or routes - controllers of the imported modules + * are never served). The only port is WorkerHealthServer's GET /health. + */ +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + AuditModule, + NotificationsModule, + DomainEventsModule, + WebhooksModule, + CalendarSyncModule, + TasksModule, + AutomationModule, + WorkerRuntimeModule, + ], + providers: [WorkerHealthServer], +}) +export class WorkerModule {} diff --git a/ApiCore-Backend/test/api-keys.e2e-spec.ts b/ApiCore-Backend/test/api-keys.e2e-spec.ts index cc14046..96d85cc 100644 --- a/ApiCore-Backend/test/api-keys.e2e-spec.ts +++ b/ApiCore-Backend/test/api-keys.e2e-spec.ts @@ -95,6 +95,14 @@ describe('API keys (e2e)', () => { expect(raw).not.toContain('keyHash'); }); + it('refuses a new key with both shop scopes', async () => { + const response = await createKey({ + name: `Combined ${suffix}`, + scopes: ['shop-sync:read', 'shop-sync:write'], + }).expect(400); + expect(JSON.stringify(response.body)).toContain('getrennte Schlüssel'); + }); + it('lets a key read exactly what its scopes allow', async () => { const { token } = ( await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] }) diff --git a/ApiCore-Backend/test/apiaries.e2e-spec.ts b/ApiCore-Backend/test/apiaries.e2e-spec.ts index 06d6f14..dcf405c 100644 --- a/ApiCore-Backend/test/apiaries.e2e-spec.ts +++ b/ApiCore-Backend/test/apiaries.e2e-spec.ts @@ -10,6 +10,7 @@ interface ApiaryBody { id: string; status: string; notes: string | null; + publicLocationLabel: string | null; } describe('Apiaries (e2e)', () => { @@ -23,6 +24,11 @@ describe('Apiaries (e2e)', () => { let beekeeperToken: string; let readonlyToken: string; let apiaryId: string; + const noColonyEmail = `apiaries-e2e-nocolony-${Date.now()}@example.com`; + let noColonyRoleId: string; + let noColonyUserId: string; + let noColonyToken: string; + let fieldApiaryId: string; beforeAll(async () => { app = await createTestApp(); @@ -50,9 +56,45 @@ describe('Apiaries (e2e)', () => { beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; readonlyToken = (await login(app, readonlyEmail, password)).accessToken; + + const apiariesRead = await prisma.permission.findMany({ + where: { key: 'apiaries:read' }, + }); + const noColonyRole = await prisma.role.create({ + data: { + key: `APIARIES_ONLY_${Date.now()}`, + name: 'Nur Stände', + permissions: { + create: apiariesRead.map((permission) => ({ + permissionId: permission.id, + })), + }, + }, + }); + noColonyRoleId = noColonyRole.id; + const noColonyUser = await prisma.user.create({ + data: { + email: noColonyEmail, + passwordHash, + name: 'No colonies', + roleId: noColonyRoleId, + }, + }); + noColonyUserId = noColonyUser.id; + noColonyToken = (await login(app, noColonyEmail, password)).accessToken; }); afterAll(async () => { + if (fieldApiaryId) { + await prisma.task.deleteMany({ + where: { title: { startsWith: 'field-e2e-' } }, + }); + await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } }); + await prisma.apiary.delete({ where: { id: fieldApiaryId } }); + } + await prisma.refreshToken.deleteMany({ where: { userId: noColonyUserId } }); + await prisma.user.delete({ where: { id: noColonyUserId } }); + await prisma.role.delete({ where: { id: noColonyRoleId } }); if (apiaryId) { await prisma.apiary.delete({ where: { id: apiaryId } }); } @@ -128,6 +170,24 @@ describe('Apiaries (e2e)', () => { .expect(409); }); + it('lets publicLocationLabel be set, changed and cleared with null', async () => { + const patch = (body: object) => + request(app.getHttpServer()) + .patch(`/api/v1/apiaries/${apiaryId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send(body); + + const set = await patch({ publicLocationLabel: 'Nordhessen' }).expect(200); + expect((set.body as ApiaryBody).publicLocationLabel).toBe('Nordhessen'); + const changed = await patch({ publicLocationLabel: 'Südhessen' }).expect( + 200, + ); + expect((changed.body as ApiaryBody).publicLocationLabel).toBe('Südhessen'); + const cleared = await patch({ publicLocationLabel: null }).expect(200); + expect((cleared.body as ApiaryBody).publicLocationLabel).toBeNull(); + await patch({ publicLocationLabel: 'x'.repeat(101) }).expect(400); + }); + it('refuses to deactivate an apiary that still holds a weak colony', async () => { const weakColony = await prisma.colony.create({ data: { @@ -146,4 +206,120 @@ describe('Apiaries (e2e)', () => { await prisma.colony.delete({ where: { id: weakColony.id } }); } }); + + describe('apiary field endpoints', () => { + interface OverviewBody { + apiaryId: string; + rows: { code: string }[]; + } + interface PackBody { + version: number; + apiary: Record; + tasks: { title: string }[] | null; + } + const codeSuffix = /-\d+$/; + + beforeAll(async () => { + const apiary = await prisma.apiary.create({ + data: { + name: 'Feldstand', + location: 'Wiese', + latitude: 48.1, + longitude: 11.5, + address: 'Geheimweg 1', + notes: 'geheim', + }, + }); + fieldApiaryId = apiary.id; + const suffix = Date.now(); + const colonies = await Promise.all( + ( + [ + ['V-10', 'ACTIVE'], + ['V-2', 'WEAK'], + ['V-3', 'DISSOLVED'], + ] as const + ).map(([code, status]) => + prisma.colony.create({ + data: { + code: `${code}-${suffix}`, + apiaryId: apiary.id, + colonyType: 'PRODUCTION_COLONY', + status, + }, + }), + ), + ); + await prisma.task.createMany({ + data: [ + { + title: 'field-e2e-apiary', + relatedType: 'APIARY', + relatedId: apiary.id, + }, + { + title: 'field-e2e-colony', + relatedType: 'COLONY', + relatedId: colonies[0].id, + }, + { + title: 'field-e2e-done', + relatedType: 'COLONY', + relatedId: colonies[0].id, + status: 'DONE', + }, + ], + }); + }); + + it('colony-overview lists living colonies only, sorted by code', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/apiaries/${fieldApiaryId}/colony-overview`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as OverviewBody; + expect(body.apiaryId).toBe(fieldApiaryId); + expect(body.rows.map((row) => row.code.replace(codeSuffix, ''))).toEqual([ + 'V-2', + 'V-10', + ]); + }); + + it('offline-pack contains the open tasks of the apiary and its colonies', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as PackBody; + expect(body.version).toBe(1); + expect(body.tasks?.map((task) => task.title).sort()).toEqual([ + 'field-e2e-apiary', + 'field-e2e-colony', + ]); + expect(Object.keys(body.apiary).sort()).toEqual([ + 'id', + 'location', + 'name', + 'status', + ]); + }); + + it('rejects both endpoints without colonies:read', async () => { + for (const path of ['colony-overview', 'offline-pack']) { + await request(app.getHttpServer()) + .get(`/api/v1/apiaries/${fieldApiaryId}/${path}`) + .set('Authorization', `Bearer ${noColonyToken}`) + .expect(403); + } + }); + + it('answers 404 for an unknown apiary', async () => { + for (const path of ['colony-overview', 'offline-pack']) { + await request(app.getHttpServer()) + .get(`/api/v1/apiaries/does-not-exist/${path}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(404); + } + }); + }); }); diff --git a/ApiCore-Backend/test/calendar-sync.e2e-spec.ts b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts index cbc4862..6c22cee 100644 --- a/ApiCore-Backend/test/calendar-sync.e2e-spec.ts +++ b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts @@ -1,4 +1,4 @@ -import { INestApplication } from '@nestjs/common'; +import { INestApplication, Logger } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; @@ -6,14 +6,18 @@ import { PrismaService } from '../src/prisma/prisma.service'; import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service'; import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service'; import { GraphClientService } from '../src/calendar-sync/graph-client.service'; +import { withTimeout } from '../src/common/with-timeout'; +import { WAKE_CHANNELS } from '../src/common/worker-wakeup'; +import { PgListener } from '../src/worker/pg-listener'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; /** * The M365 outbox against the real database, with Microsoft Graph replaced - * by a recorder: several workers (pods, the cron and "Jetzt - * synchronisieren") must never write the same entry twice, and an edit - * that arrives while an entry is being synced must not get lost. + * by a recorder: several workers (worker pods, each ticking and woken by + * "Jetzt synchronisieren") must never write the same entry twice, and an + * edit that arrives while an entry is being synced must not get lost. The + * API itself only queues a pass; it has no worker loop. */ describe('Calendar sync outbox (e2e)', () => { let app: INestApplication; @@ -134,11 +138,11 @@ describe('Calendar sync outbox (e2e)', () => { tasks.push(await queueTask(title)); } - // Two pods' cron ticks plus a click on "Jetzt synchronisieren". + // Three worker pods, ticking or woken by "Jetzt synchronisieren". await Promise.all([ calendarSync.processOutbox(), calendarSync.processOutbox(), - calendarSync.runNow(), + calendarSync.processOutbox(), ]); for (const title of titles) { @@ -211,7 +215,7 @@ describe('Calendar sync outbox (e2e)', () => { }); }); - it('retries a FAILED entry on "Jetzt synchronisieren", not on the cron tick', async () => { + it('retries a FAILED entry after "Jetzt synchronisieren", not on a regular tick', async () => { const task = await queueTask('Failed before'); await prisma.calendarSyncOutboxEntry.update({ where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id } }, @@ -222,7 +226,9 @@ describe('Calendar sync outbox (e2e)', () => { expect(createsFor('Failed before')).toHaveLength(0); expect((await entryFor(task.id)).status).toBe('FAILED'); - await calendarSync.runNow(); + // The click, then the tick of the worker it woke. + await calendarSync.requestRun(); + await calendarSync.processOutbox(); expect(createsFor('Failed before')).toHaveLength(1); const entry = await entryFor(task.id); @@ -234,9 +240,51 @@ describe('Calendar sync outbox (e2e)', () => { describe('API', () => { const auth = () => ({ Authorization: `Bearer ${token}` }); - it('answers "Jetzt synchronisieren" with 202 while the pass still runs', async () => { - const task = await queueTask('Started via API'); - const title = `Started via API ${suffix}`; + it('answers "Jetzt synchronisieren" with 202, resets FAILED and wakes the worker - no Graph call from the API', async () => { + const task = await queueTask('Requested via API'); + await prisma.calendarSyncOutboxEntry.update({ + where: { + sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id }, + }, + data: { status: 'FAILED', attempts: 5, lastError: 'Graph down' }, + }); + let wake: () => void = () => undefined; + const woken = new Promise((resolve) => { + wake = resolve; + }); + const worker = new PgListener({ + connectionString: process.env.DATABASE_URL ?? '', + channels: [WAKE_CHANNELS.calendarSync], + onNotify: () => wake(), + logger: new Logger('CalendarSyncE2E'), + }); + await worker.start(); + + try { + const response = await request(app.getHttpServer()) + .post('/api/v1/calendar/sync/run-now') + .set(auth()) + .expect(202); + + // A pass is due: the client follows it through GET status. + expect((response.body as { running: boolean }).running).toBe(true); + await withTimeout(woken, 5_000, 'no calendar_sync wake-up'); + } finally { + await worker.stop(); + } + expect(await entryFor(task.id)).toMatchObject({ + status: 'PENDING', + attempts: 0, + lastError: null, + claimedAt: null, + }); + expect(createdEvents).toEqual([]); + expect(updateEvent).not.toHaveBeenCalled(); + }, 15_000); + + it('reports running in GET status while a worker holds an entry', async () => { + const task = await queueTask('Held by a worker'); + const title = `Held by a worker ${suffix}`; let releaseCreate: () => void = () => undefined; jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => { createdEvents.push(input.title); @@ -248,24 +296,23 @@ describe('Calendar sync outbox (e2e)', () => { eventCounter += 1; return `event-${suffix}-${eventCounter}`; }); + const status = async () => + ( + await request(app.getHttpServer()) + .get('/api/v1/calendar/sync/status') + .set(auth()) + .expect(200) + ).body as { running: boolean }; - const response = await request(app.getHttpServer()) - .post('/api/v1/calendar/sync/run-now') - .set(auth()) - .expect(202); - - expect((response.body as { running: boolean }).running).toBe(true); - await eventually(() => createsFor('Started via API').length === 1); - expect((await entryFor(task.id)).status).toBe('PENDING'); - const whileRunning = await request(app.getHttpServer()) - .get('/api/v1/calendar/sync/status') - .set(auth()) - .expect(200); - expect((whileRunning.body as { running: boolean }).running).toBe(true); + // A worker's tick, held inside the Graph call for this entry. + const pass = calendarSync.processOutbox(); + await eventually(() => createsFor('Held by a worker').length === 1); + expect((await status()).running).toBe(true); releaseCreate(); - await eventually(async () => !(await calendarSync.getStatus()).running); + await pass; + expect((await status()).running).toBe(false); expect((await entryFor(task.id)).status).toBe('SYNCED'); }, 15_000); diff --git a/ApiCore-Backend/test/db-session.e2e-spec.ts b/ApiCore-Backend/test/db-session.e2e-spec.ts new file mode 100644 index 0000000..513c111 --- /dev/null +++ b/ApiCore-Backend/test/db-session.e2e-spec.ts @@ -0,0 +1,64 @@ +import { Client } from 'pg'; +import { dbSessionConfig } from '../src/prisma/db-session'; +import { PrismaService } from '../src/prisma/prisma.service'; + +/** + * Prisma fills @default(now()) on the client as UTC wall clock, SQL now() + * stores the session's wall clock - both in `timestamp without time zone` + * columns. They only agree if every session runs in UTC, whatever the + * server or database default is (the local test DB runs Europe/Berlin). + */ +describe('Database session time zone (e2e)', () => { + let prisma: PrismaService; + const aggregateId = `tz-${Date.now()}-${Math.round(Math.random() * 1e6)}`; + + beforeAll(() => { + prisma = new PrismaService(); + }); + + afterAll(async () => { + await prisma.domainEvent.deleteMany({ where: { aggregateId } }); + await prisma.$disconnect(); + }); + + it('runs a fresh Prisma connection in UTC', async () => { + const [{ TimeZone }] = await prisma.$queryRaw< + { TimeZone: string }[] + >`SHOW TIME ZONE`; + + expect(TimeZone).toBe('UTC'); + }); + + it('writes a Prisma default timestamp and SQL now() on the same clock', async () => { + const seconds = await prisma.$transaction(async (tx) => { + const event = await tx.domainEvent.create({ + data: { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId, + payload: { dueOn: '2026-10-13' }, + correlationId: aggregateId, + }, + }); + const [{ s }] = await tx.$queryRaw< + { s: number }[] + >`SELECT EXTRACT(EPOCH FROM now() - "occurredAt")::float AS s FROM "DomainEvent" WHERE "id" = ${event.id}`; + return s; + }); + + expect(Math.abs(seconds)).toBeLessThan(1); + }); + + it('runs a raw pg connection with the shared session options in UTC (LISTEN client)', async () => { + const client = new Client(dbSessionConfig(process.env.DATABASE_URL)); + await client.connect(); + try { + const { rows } = await client.query<{ TimeZone: string }>( + 'SHOW TIME ZONE', + ); + expect(rows[0].TimeZone).toBe('UTC'); + } finally { + await client.end(); + } + }); +}); diff --git a/ApiCore-Backend/test/documents.e2e-spec.ts b/ApiCore-Backend/test/documents.e2e-spec.ts index 97cca9a..315ec9e 100644 --- a/ApiCore-Backend/test/documents.e2e-spec.ts +++ b/ApiCore-Backend/test/documents.e2e-spec.ts @@ -189,4 +189,35 @@ describe('Documents (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .expect(204); }); + + it('replays an upload with the same Idempotency-Key', async () => { + const key = `idem-documents-${Date.now()}-upload-0001`; + const upload = () => + request(app.getHttpServer()) + .post('/api/v1/documents') + .set('Authorization', `Bearer ${beekeeperToken}`) + .set('Idempotency-Key', key) + .field('entityType', 'COLONY') + .field('entityId', colonyId) + .attach('file', PNG_BYTES, { + filename: 'idempotent.png', + contentType: 'image/png', + }) + .expect(201); + const before = await prisma.document.count({ + where: { entityType: 'COLONY', entityId: colonyId }, + }); + + const first = await upload(); + const second = await upload(); + + expect((second.body as DocumentBody).id).toBe( + (first.body as DocumentBody).id, + ); + expect( + await prisma.document.count({ + where: { entityType: 'COLONY', entityId: colonyId }, + }), + ).toBe(before + 1); + }); }); diff --git a/ApiCore-Backend/test/domain-event-dispatcher.e2e-spec.ts b/ApiCore-Backend/test/domain-event-dispatcher.e2e-spec.ts new file mode 100644 index 0000000..93de102 --- /dev/null +++ b/ApiCore-Backend/test/domain-event-dispatcher.e2e-spec.ts @@ -0,0 +1,538 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Prisma } from '../generated/prisma'; +import * as outboxClaims from '../src/common/outbox/outbox-claims'; +import { domainEventContext } from '../src/domain-events/domain-event-context'; +import { + DispatchFilter, + DomainEventDispatcher, +} from '../src/domain-events/domain-event-dispatcher'; +import type { DomainEventEnvelope } from '../src/domain-events/domain-event-types'; +import { DomainEventsModule } from '../src/domain-events/domain-events.module'; +import { DomainEventsService } from '../src/domain-events/domain-events.service'; +import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator'; +import { PrismaModule } from '../src/prisma/prisma.module'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { drainDomainEvents } from './utils/domain-events'; + +const ENTITY_TYPE = 'DispatcherE2E'; + +/** `entered` resolves when a handler reaches the gate; `open()` lets it on. */ +class Gate { + private signalEntered!: () => void; + private release!: () => void; + readonly entered = new Promise((resolve) => { + this.signalEntered = resolve; + }); + private readonly opened = new Promise((resolve) => { + this.release = resolve; + }); + + pass(): Promise { + this.signalEntered(); + return this.opened; + } + + open(): void { + this.release(); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** True if `promise` has not settled within `ms`. */ +function isPending(promise: Promise, ms = 300): Promise { + return Promise.race([promise.then(() => false), sleep(ms).then(() => true)]); +} + +/** Waits until a run reaches `gate`; fails if the run ends without it. */ +async function enteredBeforeDone( + gate: Gate, + run: Promise, +): Promise { + await Promise.race([ + gate.entered, + run.then((claimed) => { + throw new Error(`Run ended (${claimed} claimed) before the gate`); + }), + ]); +} + +/** + * TaskDue, QueenAged and InspectionOverdue have no real handlers in the + * plan, so these test handlers are the only ones that react - and only to + * this file's events, because every dispatch below is filtered by + * correlation id. + */ +@Injectable() +class DispatcherTestHandlers { + readonly seen: { event: DomainEventEnvelope; correlationId?: string }[] = []; + + @OnDomainEvent('TaskDue', 'test.counter') + async count( + event: DomainEventEnvelope, + tx: Prisma.TransactionClient, + ): Promise { + this.seen.push({ + event, + correlationId: domainEventContext.getStore()?.correlationId, + }); + await tx.auditLog.create({ + data: { + entityType: ENTITY_TYPE, + entityId: event.aggregateId, + action: 'CREATE', + }, + }); + } + + @OnDomainEvent('QueenAged', 'test.failing') + fail(): Promise { + return Promise.reject(new Error('boom')); + } + + /** Aggregates this instance ran 'test.gated' for, in order. */ + readonly gatedRuns: string[] = []; + private readonly gates = new Map(); + + /** Holds 'test.gated' for one aggregate inside its delivery transaction. */ + hold(aggregateId: string): Gate { + const gate = new Gate(); + this.gates.set(aggregateId, gate); + return gate; + } + + @OnDomainEvent('InspectionOverdue', 'test.gated') + async gated( + event: DomainEventEnvelope, + tx: Prisma.TransactionClient, + ): Promise { + this.gatedRuns.push(event.aggregateId); + await this.gates.get(event.aggregateId)?.pass(); + await tx.auditLog.create({ + data: { + entityType: ENTITY_TYPE, + entityId: event.aggregateId, + action: 'CREATE', + }, + }); + } +} + +async function createDispatcherContext(): Promise { + const moduleRef = await Test.createTestingModule({ + imports: [PrismaModule, DomainEventsModule], + providers: [DispatcherTestHandlers], + }).compile(); + await moduleRef.init(); + return moduleRef; +} + +describe('DomainEventDispatcher (e2e)', () => { + const suffix = `dispatch-${Date.now()}-${Math.round(Math.random() * 1e6)}`; + let appA: TestingModule; + let appB: TestingModule; + let prisma: PrismaService; + + /** Records n TaskDue events in one transaction; returns their filter. */ + async function recordTaskDue( + n: number, + group: string, + ): Promise { + const events = appA.get(DomainEventsService); + await prisma.$transaction((tx) => + events.recordAll( + tx, + Array.from({ length: n }, (_, i) => ({ + type: 'TaskDue' as const, + aggregateType: 'Task' as const, + aggregateId: `${suffix}-${group}-${i}`, + payload: { dueOn: '2026-10-13' }, + })), + ), + ); + return filterFor(group); + } + + async function filterFor(group: string): Promise { + const rows = await prisma.domainEvent.findMany({ + where: { aggregateId: { startsWith: `${suffix}-${group}-` } }, + select: { correlationId: true }, + distinct: ['correlationId'], + }); + return { correlationIds: rows.map((r) => r.correlationId) }; + } + + async function waitForStatus(id: string, status: string): Promise { + for (let i = 0; i < 250; i++) { + const event = await prisma.domainEvent.findUnique({ + where: { id }, + select: { status: true }, + }); + if (event?.status === status) { + return; + } + await sleep(20); + } + throw new Error(`Domain event ${id} never reached ${status}`); + } + + function effects(prefix: string): Promise { + return prisma.auditLog.count({ + where: { entityType: ENTITY_TYPE, entityId: { startsWith: prefix } }, + }); + } + + beforeAll(async () => { + [appA, appB] = await Promise.all([ + createDispatcherContext(), + createDispatcherContext(), + ]); + prisma = appA.get(PrismaService); + }); + + afterEach(() => jest.restoreAllMocks()); + + afterAll(async () => { + await prisma.auditLog.deleteMany({ + where: { entityType: ENTITY_TYPE, entityId: { startsWith: suffix } }, + }); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { startsWith: suffix } }, + }); + await Promise.all([appA.close(), appB.close()]); + }); + + it('two dispatchers in parallel produce exactly one effect per event and handler', async () => { + const filter = await recordTaskDue(20, 'parallel'); + + const [a, b] = await Promise.all([ + appA.get(DomainEventDispatcher).drain(filter), + appB.get(DomainEventDispatcher).drain(filter), + ]); + + expect(a + b).toBe(20); + expect(await effects(`${suffix}-parallel-`)).toBe(20); + const events = await prisma.domainEvent.findMany({ + where: { aggregateId: { startsWith: `${suffix}-parallel-` } }, + include: { deliveries: true }, + }); + expect(events).toHaveLength(20); + for (const event of events) { + expect(event).toMatchObject({ + status: 'DONE', + attempts: 0, + claimedAt: null, + lastError: null, + }); + expect(event.processedAt).toBeInstanceOf(Date); + expect(event.deliveries).toEqual([ + expect.objectContaining({ + handler: 'test.counter', + status: 'DONE', + attempts: 1, + }), + ]); + } + }); + + it('hands the handler the stored event and its correlation id', async () => { + const filter = await recordTaskDue(1, 'envelope'); + const handlers = appA.get(DispatcherTestHandlers); + + await appA.get(DomainEventDispatcher).drain(filter); + + const seen = handlers.seen.find( + (entry) => entry.event.aggregateId === `${suffix}-envelope-0`, + ); + expect(seen?.correlationId).toBe(filter.correlationIds?.[0]); + expect(seen?.event).toMatchObject({ + type: 'TaskDue', + aggregateType: 'Task', + payload: { dueOn: '2026-10-13' }, + actorUserId: null, + correlationId: filter.correlationIds?.[0], + attempts: 0, + }); + expect(seen?.event.occurredAt).toBeInstanceOf(Date); + }); + + it('skips an event another worker holds locked instead of waiting for it', async () => { + const filter = await recordTaskDue(2, 'locked'); + const { id } = await prisma.domainEvent.findFirstOrThrow({ + where: { aggregateId: `${suffix}-locked-0` }, + }); + const lock = new Gate(); + // Another worker in the middle of its claim statement. + const holder = appB.get(PrismaService).$transaction( + async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "DomainEvent" WHERE "id" = ${id} FOR UPDATE`; + await lock.pass(); + }, + { timeout: 15_000 }, + ); + await lock.entered; + const dispatcher = appA.get(DomainEventDispatcher); + + try { + const run = dispatcher.runOnce(filter); + // Generous window against slow CI; without SKIP LOCKED the claim + // blocks until the lock opens, so it would still be pending here. + expect(await isPending(run, 4_000)).toBe(false); + await expect(run).resolves.toBe(1); + expect(await effects(`${suffix}-locked-1`)).toBe(1); + expect(await effects(`${suffix}-locked-0`)).toBe(0); + } finally { + lock.open(); + await holder; + } + await expect(dispatcher.runOnce(filter)).resolves.toBe(1); + expect(await effects(`${suffix}-locked-0`)).toBe(1); + }, 20_000); + + it('a stale release that ends in FAILED also fails the open deliveries', async () => { + const filter = await recordTaskDue(1, 'stale-failed'); + const { id } = await prisma.domainEvent.findFirstOrThrow({ + where: { aggregateId: `${suffix}-stale-failed-0` }, + }); + // Seven attempts used up, the worker of the eighth died mid-event. + await prisma.$executeRaw`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "attempts" = 7, "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${id}`; + await prisma.domainEventDelivery.create({ + data: { eventId: id, handler: 'test.counter', attempts: 7 }, + }); + + await expect( + appA.get(DomainEventDispatcher).releaseStaleClaims(), + ).resolves.toBeGreaterThanOrEqual(1); + + expect( + await prisma.domainEvent.findUnique({ + where: { id }, + include: { deliveries: true }, + }), + ).toMatchObject({ + status: 'FAILED', + attempts: 8, + claimedAt: null, + lastError: 'claim expired (worker restarted?)', + deliveries: [{ handler: 'test.counter', status: 'FAILED', attempts: 7 }], + }); + await expect(appA.get(DomainEventDispatcher).drain(filter)).resolves.toBe( + 0, + ); + }); + + it('drains a backlog of 120 events in batches of at most 50', async () => { + const filter = await recordTaskDue(120, 'backlog'); + const dispatcher = appA.get(DomainEventDispatcher); + + const rounds: number[] = []; + for (let i = 0; i < 4; i++) { + rounds.push(await dispatcher.runOnce(filter)); + } + + expect(rounds).toEqual([50, 50, 20, 0]); + expect(await effects(`${suffix}-backlog-`)).toBe(120); + }); + + it('drainDomainEvents processes only the events of the given root aggregates', async () => { + await recordTaskDue(2, 'root'); + await recordTaskDue(1, 'other'); + + await drainDomainEvents(appA, [`${suffix}-root-0`]); + + expect(await effects(`${suffix}-root-`)).toBe(2); + expect(await effects(`${suffix}-other-`)).toBe(0); + await drainDomainEvents(appA, [`${suffix}-other-0`]); + expect(await effects(`${suffix}-other-`)).toBe(1); + }); + + it('a stale-released event does not repeat a handler that already committed', async () => { + const filter = await recordTaskDue(1, 'stale'); + const dispatcher = appA.get(DomainEventDispatcher); + // The worker dies after the handler committed, before settling the event. + jest + .spyOn(outboxClaims, 'settleSuccess') + .mockRejectedValueOnce(new Error('worker killed')); + const logError = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + + await dispatcher.runOnce(filter); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining('not settled: worker killed'), + ); + + const id = ( + await prisma.domainEvent.findFirstOrThrow({ + where: { aggregateId: `${suffix}-stale-0` }, + }) + ).id; + expect( + await prisma.domainEvent.findUnique({ where: { id } }), + ).toMatchObject({ status: 'PROCESSING' }); + expect(await effects(`${suffix}-stale-`)).toBe(1); + + await prisma.$executeRaw`UPDATE "DomainEvent" SET "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${id}`; + await expect( + dispatcher.releaseStaleClaims(), + ).resolves.toBeGreaterThanOrEqual(1); + expect( + await prisma.domainEvent.findUnique({ where: { id } }), + ).toMatchObject({ + status: 'PENDING', + attempts: 1, + claimedAt: null, + lastError: 'claim expired (worker restarted?)', + }); + + await appB.get(DomainEventDispatcher).drain(filter); + + expect(await effects(`${suffix}-stale-`)).toBe(1); + expect( + await prisma.domainEvent.findUnique({ + where: { id }, + include: { deliveries: true }, + }), + ).toMatchObject({ + status: 'DONE', + attempts: 1, + deliveries: [{ handler: 'test.counter', status: 'DONE', attempts: 1 }], + }); + }); + + it('a worker whose claim expired waits for the delivery lock and skips the handler the new owner committed', async () => { + const events = appA.get(DomainEventsService); + for (const i of [0, 1]) { + await prisma.$transaction((tx) => + events.record(tx, { + type: 'InspectionOverdue', + aggregateType: 'Colony', + aggregateId: `${suffix}-gated-${i}`, + payload: { dueOn: '2026-10-13', lastInspectedOn: null }, + }), + ); + } + const filter = await filterFor('gated'); + const [e0, e1] = await Promise.all( + [0, 1].map((i) => + prisma.domainEvent.findFirstOrThrow({ + where: { aggregateId: `${suffix}-gated-${i}` }, + }), + ), + ); + // Worker A handles e0 before e1. + await prisma.$executeRaw`UPDATE "DomainEvent" SET "occurredAt" = "occurredAt" - interval '1 second' WHERE "id" = ${e0.id}`; + // e1's handler failed once before, so its delivery row exists: only + // the row lock (SELECT ... FOR UPDATE), not the unique insert, can + // make a second worker wait. + await prisma.domainEventDelivery.create({ + data: { + eventId: e1.id, + handler: 'test.gated', + attempts: 1, + lastError: 'earlier failure', + }, + }); + const handlersA = appA.get(DispatcherTestHandlers); + const handlersB = appB.get(DispatcherTestHandlers); + const gateA = handlersA.hold(e0.aggregateId); + const gateB = handlersB.hold(e1.aggregateId); + const logWarn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + + const runs: Promise[] = []; + try { + // A claims both events and is held inside the handler for e0 ... + const runA = appA.get(DomainEventDispatcher).runOnce(filter); + runs.push(runA); + await enteredBeforeDone(gateA, runA); + // ... while its claim on e1 expires: B claims e1 and is held inside + // e1's delivery transaction, the delivery row locked. + await prisma.$executeRaw`UPDATE "DomainEvent" SET "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${e1.id}`; + await appB.get(DomainEventDispatcher).releaseStaleClaims(); + const runB = appB.get(DomainEventDispatcher).runOnce(filter); + runs.push(runB); + await enteredBeforeDone(gateB, runB); + + // A finishes e0, moves on to e1 and has to wait for B's delivery row. + gateA.open(); + await waitForStatus(e0.id, 'DONE'); + expect(await isPending(runA)).toBe(true); + + // B commits; A then finds e1's delivery DONE and skips the handler. + gateB.open(); + await expect(runB).resolves.toBe(1); + await expect(runA).resolves.toBe(2); + } finally { + gateA.open(); + gateB.open(); + await Promise.allSettled(runs); + } + + expect(handlersA.gatedRuns).toEqual([e0.aggregateId]); + expect(handlersB.gatedRuns).toEqual([e1.aggregateId]); + expect(await effects(`${suffix}-gated-`)).toBe(2); + const delivered = (attempts: number) => ({ + deliveries: [{ handler: 'test.gated', status: 'DONE', attempts }], + }); + expect( + await prisma.domainEvent.findMany({ + where: { id: { in: [e0.id, e1.id] } }, + include: { deliveries: true }, + orderBy: { aggregateId: 'asc' }, + }), + ).toMatchObject([ + { id: e0.id, status: 'DONE', attempts: 0, ...delivered(1) }, + { id: e1.id, status: 'DONE', attempts: 1, ...delivered(2) }, + ]); + // A's claim on e1 was gone, so B - the owner - settled it. + expect(logWarn).toHaveBeenCalledWith( + expect.stringContaining(`${e1.id} (InspectionOverdue): claim lost`), + ); + }); + + it('schedules a failed event about 30 s ahead by the database clock', async () => { + const events = appA.get(DomainEventsService); + await prisma.$transaction((tx) => + events.record(tx, { + type: 'QueenAged', + aggregateType: 'Queen', + aggregateId: `${suffix}-failing-0`, + payload: { hatchYear: 2024, colonyId: null }, + }), + ); + const filter = await filterFor('failing'); + + await expect(appA.get(DomainEventDispatcher).drain(filter)).resolves.toBe( + 1, + ); + + const event = await prisma.domainEvent.findFirstOrThrow({ + where: { aggregateId: `${suffix}-failing-0` }, + include: { deliveries: true }, + }); + expect(event).toMatchObject({ + status: 'PENDING', + attempts: 1, + claimedAt: null, + lastError: 'test.failing: boom', + deliveries: [ + { + handler: 'test.failing', + status: 'PENDING', + attempts: 1, + lastError: 'boom', + }, + ], + }); + const [{ s }] = await prisma.$queryRaw< + { s: number }[] + >`SELECT EXTRACT(EPOCH FROM "availableAt" - now())::float AS s FROM "DomainEvent" WHERE id = ${event.id}`; + expect(s).toBeGreaterThan(25); + expect(s).toBeLessThanOrEqual(30.5); + }); +}); diff --git a/ApiCore-Backend/test/health.e2e-spec.ts b/ApiCore-Backend/test/health.e2e-spec.ts index c8e07b3..b95212f 100644 --- a/ApiCore-Backend/test/health.e2e-spec.ts +++ b/ApiCore-Backend/test/health.e2e-spec.ts @@ -1,6 +1,7 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; +import { PrismaThrottlerStorage } from '../src/common/throttler/prisma-throttler.storage'; import { createTestApp } from './utils/create-test-app'; describe('Health (e2e)', () => { @@ -27,4 +28,34 @@ describe('Health (e2e)', () => { .expect(200) .expect({ status: 'ok' }); }); + + describe('when the throttler storage is down', () => { + afterEach(() => jest.restoreAllMocks()); + + it('GET /health stays 200 (liveness must not depend on the database)', async () => { + jest + .spyOn(app.get(PrismaThrottlerStorage), 'increment') + .mockRejectedValue(new Error('db down')); + + await request(app.getHttpServer()).get('/health').expect(200); + }); + + it('GET /health/ready stays throttled (fails with the storage)', async () => { + jest + .spyOn(app.get(PrismaThrottlerStorage), 'increment') + .mockRejectedValue(new Error('db down')); + + await request(app.getHttpServer()).get('/health/ready').expect(500); + }); + + it('other routes fail closed instead of being allowed', async () => { + jest + .spyOn(app.get(PrismaThrottlerStorage), 'increment') + .mockRejectedValue(new Error('db down')); + + await request(app.getHttpServer()) + .get('/api/v1/public/batches/NONE-x') + .expect(500); + }); + }); }); diff --git a/ApiCore-Backend/test/inspections.e2e-spec.ts b/ApiCore-Backend/test/inspections.e2e-spec.ts index 6b76eb1..1717593 100644 --- a/ApiCore-Backend/test/inspections.e2e-spec.ts +++ b/ApiCore-Backend/test/inspections.e2e-spec.ts @@ -9,6 +9,7 @@ import { login } from './utils/api'; interface InspectionBody { id: string; colonyId: string; + broodFrames?: number | null; } interface TaskBody { @@ -142,4 +143,32 @@ describe('Inspections (e2e)', () => { taskId = task.id; expect(task.sourceInspectionId).toBe(inspectionId); }); + + it('stores, clears and validates broodFrames', async () => { + const created = await request(app.getHttpServer()) + .post('/api/v1/inspections') + .set('Authorization', `Bearer ${staffToken}`) + .send({ colonyId, date: new Date().toISOString(), broodFrames: 4 }) + .expect(201); + const id = (created.body as InspectionBody).id; + expect((created.body as InspectionBody).broodFrames).toBe(4); + + const cleared = await request(app.getHttpServer()) + .patch(`/api/v1/inspections/${id}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ broodFrames: null }) + .expect(200); + expect((cleared.body as InspectionBody).broodFrames).toBeNull(); + + await request(app.getHttpServer()) + .patch(`/api/v1/inspections/${id}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ broodFrames: -1 }) + .expect(400); + await request(app.getHttpServer()) + .patch(`/api/v1/inspections/${id}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ broodFrames: 'x' }) + .expect(400); + }); }); diff --git a/ApiCore-Backend/test/platform-schema.e2e-spec.ts b/ApiCore-Backend/test/platform-schema.e2e-spec.ts new file mode 100644 index 0000000..1defbc2 --- /dev/null +++ b/ApiCore-Backend/test/platform-schema.e2e-spec.ts @@ -0,0 +1,55 @@ +import { PrismaService } from '../src/prisma/prisma.service'; + +/** + * Schema-level guarantees of the platform backbone migration that the Prisma + * schema alone cannot express (UNLOGGED table, nullable unique dedupe key). + */ +describe('Platform backbone schema (e2e)', () => { + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + + beforeAll(() => { + prisma = new PrismaService(); + }); + + afterAll(async () => { + await prisma.domainEvent.deleteMany({ + where: { aggregateId: `t-${suffix}` }, + }); + await prisma.$disconnect(); + }); + + it('creates ThrottlerHit as an UNLOGGED table', async () => { + const [row] = await prisma.$queryRaw< + { relpersistence: string }[] + >`SELECT relpersistence::text AS relpersistence FROM pg_class WHERE relname = 'ThrottlerHit'`; + expect(row.relpersistence).toBe('u'); + }); + + it('keeps at most one event per dedupe key', async () => { + const data = { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: `t-${suffix}`, + payload: {}, + correlationId: 'c', + dedupeKey: `TaskDue:t-${suffix}:2026-10-13`, + }; + expect( + ( + await prisma.domainEvent.createMany({ + data: [data], + skipDuplicates: true, + }) + ).count, + ).toBe(1); + expect( + ( + await prisma.domainEvent.createMany({ + data: [data], + skipDuplicates: true, + }) + ).count, + ).toBe(0); + }); +}); diff --git a/ApiCore-Backend/test/public.e2e-spec.ts b/ApiCore-Backend/test/public.e2e-spec.ts index e5b5b4c..a6e1254 100644 --- a/ApiCore-Backend/test/public.e2e-spec.ts +++ b/ApiCore-Backend/test/public.e2e-spec.ts @@ -11,7 +11,7 @@ interface PublicBatchBody { productName: string | null; honeyType: string | null; waterContent: number | null; - apiaries: { name: string; location: string | null }[]; + origin: { regions: string[]; country: string }; harvestDates: string[]; } @@ -49,7 +49,14 @@ describe('Public batch origin page (e2e)', () => { beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; const apiary = await prisma.apiary.create({ - data: { name: apiaryName, location: 'Wald' }, + data: { + name: apiaryName, + location: 'Waldweg 3', + address: 'Waldweg 3, 12345 Ort', + latitude: 51.2, + longitude: 9.4, + publicLocationLabel: 'Nordhessen', + }, }); apiaryId = apiary.id; const colony = await prisma.colony.create({ @@ -127,14 +134,32 @@ describe('Public batch origin page (e2e)', () => { expect(body.productName).toBe('Sommerhonig 500g'); expect(body.honeyType).toBe('Sommerhonig'); expect(body.waterContent).toBe(16.8); - expect(body.apiaries).toEqual([{ name: apiaryName, location: 'Wald' }]); + expect(body.origin.regions).toEqual(['Nordhessen']); + expect(body.origin.country).toEqual(expect.any(String)); expect(body.harvestDates).toHaveLength(1); }); - it('returns 404 for an unknown lot', () => { - return request(app.getHttpServer()) - .get('/api/v1/public/batches/does-not-exist') - .expect(404); + it('never leaks the apiary name, location, address or coordinates', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/public/batches/${lotNumber}`) + .expect(200); + const text = JSON.stringify(response.body); + for (const secret of [apiaryName, 'Waldweg 3', '51.2', '9.4', 'Ort']) { + expect(text).not.toContain(secret); + } + expect(response.body).not.toHaveProperty('apiaries'); + }); + + it('sends the public cache header on 200 and no-store on 404', async () => { + const ok = await request(app.getHttpServer()) + .get(`/api/v1/public/batches/${lotNumber}`) + .expect(200) + .expect('Cache-Control', 'public, max-age=300, s-maxage=3600'); + expect(ok.headers['pragma']).toBeUndefined(); + await request(app.getHttpServer()) + .get('/api/v1/public/batches/NONE') + .expect(404) + .expect('Cache-Control', 'no-store'); }); it('generates a QR code for the bottling batch that deep-links to the public page', async () => { diff --git a/ApiCore-Backend/test/stock.e2e-spec.ts b/ApiCore-Backend/test/stock.e2e-spec.ts index f1cc433..d560588 100644 --- a/ApiCore-Backend/test/stock.e2e-spec.ts +++ b/ApiCore-Backend/test/stock.e2e-spec.ts @@ -91,7 +91,7 @@ describe('Stock ledger (e2e)', () => { .expect(201); return (response.body as { token: string }).token; }; - writeKey = await createKey(['shop-sync:read', 'shop-sync:write']); + writeKey = await createKey(['shop-sync:write']); readKey = await createKey(['shop-sync:read']); // Honey chain: apiary -> harvest -> extraction -> honey batch -> bottling. diff --git a/ApiCore-Backend/test/throttler.e2e-spec.ts b/ApiCore-Backend/test/throttler.e2e-spec.ts new file mode 100644 index 0000000..2e7a423 --- /dev/null +++ b/ApiCore-Backend/test/throttler.e2e-spec.ts @@ -0,0 +1,174 @@ +import { randomUUID } from 'node:crypto'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaThrottlerStorage } from '../src/common/throttler/prisma-throttler.storage'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; + +// Public batches allow 30 requests per minute and client network. +const PUBLIC_BATCH_LIMIT = 30; +const PARALLEL_REQUESTS = 40; + +describe('Database-backed throttler (e2e)', () => { + const suffix = randomUUID(); + const path = `/api/v1/public/batches/NONE-${suffix}`; + const namespaces: string[] = []; + const apps: INestApplication[] = []; + let prisma: PrismaService; + + const newApp = async ( + throttlerNamespace: string, + ): Promise> => { + namespaces.push(throttlerNamespace); + const app = await createTestApp({ throttlerNamespace }); + apps.push(app); + prisma = app.get(PrismaService); + return app; + }; + + const status = async (app: INestApplication): Promise => + (await request(app.getHttpServer()).get(path)).status; + + afterAll(async () => { + for (const ns of namespaces) { + await prisma.throttlerHit.deleteMany({ + where: { key: { startsWith: `${ns}:` } }, + }); + } + for (const app of apps) await app.close(); + }); + + it('counts across two app instances sharing one namespace', async () => { + const ns = randomUUID(); + const [a, b] = [await newApp(ns), await newApp(ns)]; + + for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) { + await request((i % 2 ? a : b).getHttpServer()) + .get(path) + .expect(404); + } + + await request(a.getHttpServer()).get(path).expect(429); + await request(b.getHttpServer()).get(path).expect(429); + }); + + it('40 parallel requests over two instances yield exactly 10 × 429', async () => { + const ns = randomUUID(); + const [a, b] = [await newApp(ns), await newApp(ns)]; + + const statuses = await Promise.all( + Array.from({ length: PARALLEL_REQUESTS }, (_, i) => + status(i % 2 ? a : b), + ), + ); + + expect(statuses.filter((s) => s === 429)).toHaveLength( + PARALLEL_REQUESTS - PUBLIC_BATCH_LIMIT, + ); + expect(statuses.filter((s) => s === 404)).toHaveLength(PUBLIC_BATCH_LIMIT); + }); + + it('separate namespaces never share counters', async () => { + const [a, b] = [await newApp(randomUUID()), await newApp(randomUUID())]; + + for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) { + await request(a.getHttpServer()).get(path).expect(404); + } + await request(a.getHttpServer()).get(path).expect(429); + + await request(b.getHttpServer()).get(path).expect(404); + }); + + it('an expired window starts again at 1', async () => { + const ns = randomUUID(); + await newApp(ns); + const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); + + for (let i = 0; i < 3; i += 1) { + await storage.increment('window', 60000, 10, 60000, 'default'); + } + // The window ends in the past, decided by the database clock. + await prisma.$executeRaw` + UPDATE "ThrottlerHit" + SET "expiresAt" = now() - interval '1 second' + WHERE "key" = ${`${ns}:window`}`; + + const record = await storage.increment( + 'window', + 60000, + 10, + 60000, + 'default', + ); + + expect(record.totalHits).toBe(1); + expect(record.isBlocked).toBe(false); + expect(record.timeToExpire).toBeGreaterThan(0); + expect(record.timeToExpire).toBeLessThanOrEqual(60); + }); + + it('keeps hits while blocked and releases the block after it expired', async () => { + const ns = randomUUID(); + await newApp(ns); + const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); + + await storage.increment('block', 60000, 1, 60000, 'default'); + const blocked = await storage.increment( + 'block', + 60000, + 1, + 60000, + 'default', + ); + expect(blocked.isBlocked).toBe(true); + expect(blocked.totalHits).toBe(2); + expect(blocked.timeToBlockExpire).toBeGreaterThan(0); + + const stillBlocked = await storage.increment( + 'block', + 60000, + 1, + 60000, + 'default', + ); + expect(stillBlocked.isBlocked).toBe(true); + expect(stillBlocked.totalHits).toBe(2); + + await prisma.$executeRaw` + UPDATE "ThrottlerHit" + SET "blockedUntil" = now() - interval '1 second' + WHERE "key" = ${`${ns}:block`}`; + + const released = await storage.increment( + 'block', + 60000, + 1, + 60000, + 'default', + ); + expect(released.isBlocked).toBe(false); + expect(released.totalHits).toBe(1); + expect(released.timeToBlockExpire).toBe(0); + }); + + it('blocks the first hit when the limit is 0', async () => { + const ns = randomUUID(); + await newApp(ns); + const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); + + const first = await storage.increment('zero', 60000, 0, 60000, 'default'); + expect(first.isBlocked).toBe(true); + expect(first.totalHits).toBe(1); + + // Same after a window or block has expired. + await prisma.$executeRaw` + UPDATE "ThrottlerHit" + SET "expiresAt" = now() - interval '1 second', + "blockedUntil" = now() - interval '1 second' + WHERE "key" = ${`${ns}:zero`}`; + const again = await storage.increment('zero', 60000, 0, 60000, 'default'); + expect(again.isBlocked).toBe(true); + expect(again.totalHits).toBe(1); + }); +}); diff --git a/ApiCore-Backend/test/treatments.e2e-spec.ts b/ApiCore-Backend/test/treatments.e2e-spec.ts index 2b53074..eaf7e12 100644 --- a/ApiCore-Backend/test/treatments.e2e-spec.ts +++ b/ApiCore-Backend/test/treatments.e2e-spec.ts @@ -254,4 +254,100 @@ describe('Treatments (e2e)', () => { .expect(400), ); }); + + describe('GET /treatments/last-by-product', () => { + const productName = `Oxuvar-${Date.now()}`; + + const lastByProduct = (token: string, product?: string) => + request(app.getHttpServer()) + .get('/api/v1/treatments/last-by-product') + .query(product === undefined ? {} : { product }) + .set('Authorization', `Bearer ${token}`); + + beforeAll(async () => { + await prisma.treatment.createMany({ + data: [ + { + colonyId, + userId: beekeeperId, + date: new Date('2026-05-01T00:00:00.000Z'), + product: productName, + supplier: 'Alt GmbH', + }, + { + colonyId, + userId: beekeeperId, + date: new Date('2026-06-01T00:00:00.000Z'), + product: productName, + activeIngredient: 'Oxalsäure', + withdrawalPeriodDays: 0, + supplier: 'Neu GmbH', + purchaseProof: 'RE-2026-17', + prescribingVet: 'Dr. Imker', + }, + { + colonyId, + userId: staffId, + date: new Date('2026-07-01T00:00:00.000Z'), + product: productName, + supplier: 'Fremd GmbH', + }, + ], + }); + }); + + it("returns the caller's newest treatment with that product, case-insensitive", async () => { + const response = await lastByProduct( + beekeeperToken, + ` ${productName.toLowerCase()} `, + ).expect(200); + const newest = await prisma.treatment.findFirstOrThrow({ + where: { + userId: beekeeperId, + product: productName, + supplier: 'Neu GmbH', + }, + select: { id: true }, + }); + + expect(response.body).toEqual({ + treatment: { + sourceTreatmentId: newest.id, + sourceDate: '2026-06-01T00:00:00.000Z', + product: productName, + activeIngredient: 'Oxalsäure', + withdrawalPeriodDays: 0, + supplier: 'Neu GmbH', + purchaseProof: 'RE-2026-17', + prescribingVet: 'Dr. Imker', + }, + }); + }); + + it("never returns another user's treatment", async () => { + const response = await lastByProduct(beekeeperToken, productName).expect( + 200, + ); + + expect(JSON.stringify(response.body)).not.toContain('Fremd GmbH'); + }); + + it('returns { treatment: null } without a match', async () => { + const response = await lastByProduct( + beekeeperToken, + `Unbekannt-${Date.now()}`, + ).expect(200); + + expect(response.body).toEqual({ treatment: null }); + }); + + it('rejects a request without product', () => + lastByProduct(beekeeperToken).expect(400)); + + it('rejects an empty product', () => + lastByProduct(beekeeperToken, ' ').expect(400)); + + it('rejects a role without treatments:create', () => + lastByProduct(staffToken, productName).expect(403)); + }); }); diff --git a/ApiCore-Backend/test/utils/create-test-app.ts b/ApiCore-Backend/test/utils/create-test-app.ts index 1183fe9..32afa55 100644 --- a/ApiCore-Backend/test/utils/create-test-app.ts +++ b/ApiCore-Backend/test/utils/create-test-app.ts @@ -1,14 +1,30 @@ +import { randomUUID } from 'node:crypto'; import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { NestExpressApplication } from '@nestjs/platform-express'; import { App } from 'supertest/types'; import { AppModule } from '../../src/app.module'; import { configureApp } from '../../src/app.config'; +import { THROTTLER_NAMESPACE } from '../../src/common/throttler/prisma-throttler.storage'; -export async function createTestApp(): Promise> { +export interface CreateTestAppOptions { + /** + * Rate-limit counter namespace. Defaults to a fresh random one so parallel + * e2e files never throttle each other; pass the same value to two apps to + * simulate two pods sharing one counter set. + */ + throttlerNamespace?: string; +} + +export async function createTestApp( + options: CreateTestAppOptions = {}, +): Promise> { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(THROTTLER_NAMESPACE) + .useValue(options.throttlerNamespace ?? randomUUID()) + .compile(); const app = moduleFixture.createNestApplication(); configureApp(app); diff --git a/ApiCore-Backend/test/utils/domain-events.ts b/ApiCore-Backend/test/utils/domain-events.ts new file mode 100644 index 0000000..51d3a04 --- /dev/null +++ b/ApiCore-Backend/test/utils/domain-events.ts @@ -0,0 +1,27 @@ +import { INestApplicationContext } from '@nestjs/common'; +import { DomainEventDispatcher } from '../../src/domain-events/domain-event-dispatcher'; +import { PrismaService } from '../../src/prisma/prisma.service'; + +/** + * Runs the dispatcher for the events of the given root aggregates and every + * follow-up event their handlers record (same correlation id) - and for + * nothing else, so e2e files running in parallel never process each + * other's events. Without a worker, e2e tests call this after the request + * whose side effects they want to assert. + */ +export async function drainDomainEvents( + app: INestApplicationContext, + rootAggregateIds: readonly string[], +): Promise { + const roots = await app.get(PrismaService).domainEvent.findMany({ + where: { aggregateId: { in: [...rootAggregateIds] } }, + select: { correlationId: true }, + distinct: ['correlationId'], + }); + if (roots.length === 0) { + return; + } + await app.get(DomainEventDispatcher).drain({ + correlationIds: roots.map((root) => root.correlationId), + }); +} diff --git a/ApiCore-Backend/test/varroa.e2e-spec.ts b/ApiCore-Backend/test/varroa.e2e-spec.ts index 76f6dad..4fbe599 100644 --- a/ApiCore-Backend/test/varroa.e2e-spec.ts +++ b/ApiCore-Backend/test/varroa.e2e-spec.ts @@ -25,6 +25,9 @@ describe('Varroa measurements (e2e)', () => { let apiaryId: string; let colonyId: string; let measurementId: string; + let noVarroaRoleId: string; + let noVarroaUserId: string; + let noVarroaToken: string; beforeAll(async () => { app = await createTestApp(); @@ -64,6 +67,28 @@ describe('Varroa measurements (e2e)', () => { }, }); colonyId = colony.id; + + const noVarroaRole = await prisma.role.create({ + data: { + key: `NO_VARROA_${Date.now()}`, + name: 'Ohne Varroa', + permissions: { + create: [{ permission: { connect: { key: 'apiaries:read' } } }], + }, + }, + }); + noVarroaRoleId = noVarroaRole.id; + const noVarroaEmail = `varroa-e2e-novarroa-${Date.now()}@example.com`; + const noVarroaUser = await prisma.user.create({ + data: { + email: noVarroaEmail, + passwordHash, + name: 'Ohne Varroa', + role: { connect: { id: noVarroaRoleId } }, + }, + }); + noVarroaUserId = noVarroaUser.id; + noVarroaToken = (await login(app, noVarroaEmail, password)).accessToken; }); afterAll(async () => { @@ -71,14 +96,15 @@ describe('Varroa measurements (e2e)', () => { await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ - where: { userId: { in: [beekeeperId, staffId] } }, + where: { userId: { in: [beekeeperId, staffId, noVarroaUserId] } }, }); await prisma.auditLog.deleteMany({ - where: { userId: { in: [beekeeperId, staffId] } }, + where: { userId: { in: [beekeeperId, staffId, noVarroaUserId] } }, }); await prisma.user.deleteMany({ - where: { id: { in: [beekeeperId, staffId] } }, + where: { id: { in: [beekeeperId, staffId, noVarroaUserId] } }, }); + await prisma.role.deleteMany({ where: { id: noVarroaRoleId } }); await app.close(); }); @@ -137,4 +163,28 @@ describe('Varroa measurements (e2e)', () => { const measurements = response.body as VarroaMeasurementBody[]; expect(measurements.some((entry) => entry.id === measurementId)).toBe(true); }); + + describe('GET /varroa-measurements/threshold', () => { + const thresholdBody = (token: string) => + request(app.getHttpServer()) + .get('/api/v1/varroa-measurements/threshold') + .set('Authorization', `Bearer ${token}`); + + it('returns the threshold shape and is not shadowed by :id', async () => { + const response = await thresholdBody(staffToken).expect(200); + + const body = response.body as { thresholdMitesPerDay: number | null }; + expect(Object.keys(body)).toEqual(['thresholdMitesPerDay']); + expect( + body.thresholdMitesPerDay === null || + typeof body.thresholdMitesPerDay === 'number', + ).toBe(true); + }); + + // The shared rule is never written here (parallel e2e workers); the + // inactive / custom value cases are covered by varroa-threshold.spec.ts. + + it('rejects a role without any varroa permission', () => + thresholdBody(noVarroaToken).expect(403)); + }); }); diff --git a/ApiCore-Backend/test/worker-runtime.e2e-spec.ts b/ApiCore-Backend/test/worker-runtime.e2e-spec.ts new file mode 100644 index 0000000..22e7f32 --- /dev/null +++ b/ApiCore-Backend/test/worker-runtime.e2e-spec.ts @@ -0,0 +1,219 @@ +import { randomUUID } from 'node:crypto'; +import * as http from 'node:http'; +import { AddressInfo } from 'node:net'; +import { Injectable } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Prisma } from '../generated/prisma'; +import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service'; +import { notifyWorker, WAKE_CHANNELS } from '../src/common/worker-wakeup'; +import { domainEventContext } from '../src/domain-events/domain-event-context'; +import { + DispatchFilter, + DomainEventDispatcher, +} from '../src/domain-events/domain-event-dispatcher'; +import type { DomainEventEnvelope } from '../src/domain-events/domain-event-types'; +import { DomainEventsService } from '../src/domain-events/domain-events.service'; +import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { WorkerModule } from '../src/worker/worker.module'; +import { DOMAIN_EVENT_POLL_MS } from '../src/worker/worker-runtime.service'; + +const ENTITY_TYPE = 'WorkerRuntimeE2E'; +/** Every event of this file carries it; the worker below sees no other. */ +const CORRELATION_ID = randomUUID(); +/** Well below the 5 s poll: only a NOTIFY wake-up can be this fast. */ +const WAKE_UP_WITHIN_MS = 1_500; + +@Injectable() +class CountingHandler { + @OnDomainEvent('TaskDue', 'test.worker-counter') + async count( + event: DomainEventEnvelope, + tx: Prisma.TransactionClient, + ): Promise { + await tx.auditLog.create({ + data: { + entityType: ENTITY_TYPE, + entityId: event.aggregateId, + action: 'CREATE', + }, + }); + } +} + +/** + * The real dispatcher, limited to this file's correlation: e2e files run in + * parallel against one database, and a worker draining all of it would + * take over (and release the stale claims of) their events. + */ +@Injectable() +class IsolatedDispatcher extends DomainEventDispatcher { + override drain( + _filter?: DispatchFilter, + maxRounds?: number, + signal?: AbortSignal, + ): Promise { + return super.drain({ correlationIds: [CORRELATION_ID] }, maxRounds, signal); + } + + override releaseStaleClaims(): Promise { + return Promise.resolve(0); + } +} + +/** Assigning undefined would store the string "undefined". */ +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function freePort(): Promise { + const server = http.createServer(); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + await new Promise((resolve) => server.close(resolve)); + return port; +} + +function getJson( + port: number, + path: string, +): Promise<{ status: number; body: unknown }> { + return new Promise((resolve, reject) => { + http + .get({ port, path }, (res) => { + let raw = ''; + res.on('data', (chunk: Buffer) => (raw += chunk.toString())); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + body: raw ? (JSON.parse(raw) as unknown) : undefined, + }), + ); + }) + .on('error', reject); + }); +} + +/** Polls `read` until it returns `expected`, for at most `ms`. */ +async function expectWithin( + ms: number, + read: () => Promise | T, + expected: T, +): Promise { + const deadline = Date.now() + ms; + let actual = await read(); + while (actual !== expected && Date.now() < deadline) { + await sleep(25); + actual = await read(); + } + expect(actual).toBe(expected); +} + +/** + * The worker process as dist/worker.js starts it (WorkerModule, LISTEN, + * loops, health endpoint) against the test database. + */ +describe('Worker runtime (e2e)', () => { + const suffix = `worker-${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const originalHealthPort = process.env.WORKER_HEALTH_PORT; + let worker: TestingModule; + let prisma: PrismaService; + let processOutbox: jest.SpyInstance; + let healthPort: number; + + async function recordTaskDueInTx(): Promise { + const events = worker.get(DomainEventsService); + await domainEventContext.run({ correlationId: CORRELATION_ID }, () => + prisma.$transaction((tx) => + events.record(tx, { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: `${suffix}-0`, + payload: { dueOn: '2026-10-13' }, + }), + ), + ); + } + + const countAuditRows = () => + prisma.auditLog.count({ + where: { entityType: ENTITY_TYPE, entityId: { startsWith: suffix } }, + }); + + beforeAll(async () => { + healthPort = await freePort(); + process.env.WORKER_HEALTH_PORT = String(healthPort); + worker = await Test.createTestingModule({ + imports: [WorkerModule], + providers: [CountingHandler], + }) + .overrideProvider(DomainEventDispatcher) + .useClass(IsolatedDispatcher) + .compile(); + prisma = worker.get(PrismaService); + // The outbox itself is covered by calendar-sync.e2e-spec.ts; here only + // whether the loop runs and wakes up counts. + processOutbox = jest + .spyOn(worker.get(CalendarSyncService), 'processOutbox') + .mockResolvedValue(undefined); + await worker.init(); + // The first ticks run right after bootstrap; wait for the dispatcher's. + const dispatcher = worker.get(DomainEventDispatcher); + await expectWithin(5_000, () => dispatcher.lastTickAt !== null, true); + }, 15_000); + + afterAll(async () => { + await prisma.auditLog.deleteMany({ + where: { entityType: ENTITY_TYPE, entityId: { startsWith: suffix } }, + }); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { startsWith: suffix } }, + }); + await worker.close(); + restoreEnv('WORKER_HEALTH_PORT', originalHealthPort); + }); + + it('wakes up on NOTIFY and processes a new event well before the 5 s poll', async () => { + expect(WAKE_UP_WITHIN_MS).toBeLessThan(DOMAIN_EVENT_POLL_MS); + + await recordTaskDueInTx(); + + await expectWithin(WAKE_UP_WITHIN_MS, countAuditRows, 1); + const event = await prisma.domainEvent.findFirstOrThrow({ + where: { aggregateId: `${suffix}-0` }, + include: { deliveries: true }, + }); + expect(event).toMatchObject({ + status: 'DONE', + deliveries: [{ handler: 'test.worker-counter', status: 'DONE' }], + }); + }); + + it('wakes the calendar outbox loop on a calendar_sync notification', async () => { + expect(processOutbox).toHaveBeenCalled(); + const before = processOutbox.mock.calls.length; + + await notifyWorker(prisma, WAKE_CHANNELS.calendarSync); + + await expectWithin( + WAKE_UP_WITHIN_MS, + () => processOutbox.mock.calls.length > before, + true, + ); + }); + + it('answers GET /health with 200 {"status":"ok"}', async () => { + await expect(getJson(healthPort, '/health')).resolves.toEqual({ + status: 200, + body: { status: 'ok' }, + }); + }); +}); diff --git a/ApiCore-Backend/tsconfig.build.json b/ApiCore-Backend/tsconfig.build.json index eba2f6b..ababca0 100644 --- a/ApiCore-Backend/tsconfig.build.json +++ b/ApiCore-Backend/tsconfig.build.json @@ -1,7 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", + // nest-cli deleteOutDir wipes dist on every build; a build info kept + // outside it would make the next build skip "unchanged" files. + "incremental": false }, "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma.config.ts", "prisma/seed.ts"] } diff --git a/ApiCore-Frontend/Dockerfile b/ApiCore-Frontend/Dockerfile index a709446..a1058ff 100644 --- a/ApiCore-Frontend/Dockerfile +++ b/ApiCore-Frontend/Dockerfile @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1 -FROM node:22-alpine AS builder +# Base-Image per Digest gepinnt, derselbe wie im Backend (siehe dort). +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci @@ -11,7 +12,7 @@ RUN npx next typegen RUN npm run lint && npx tsc --noEmit && npm test RUN npm run build -FROM node:22-alpine AS runtime +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime ENV NODE_ENV=production WORKDIR /app COPY --from=builder /app/public ./public diff --git a/ApiCore-Frontend/package-lock.json b/ApiCore-Frontend/package-lock.json index 995628b..7d39243 100644 --- a/ApiCore-Frontend/package-lock.json +++ b/ApiCore-Frontend/package-lock.json @@ -37,6 +37,7 @@ "@vitejs/plugin-react": "^4.7.0", "eslint": "^9", "eslint-config-next": "16.3.1", + "fake-indexeddb": "^6.2.5", "jsdom": "^30.0.1", "tailwindcss": "^4", "typescript": "^5", @@ -6850,6 +6851,16 @@ "express": ">= 4.11" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/ApiCore-Frontend/package.json b/ApiCore-Frontend/package.json index a6f4451..76cb609 100644 --- a/ApiCore-Frontend/package.json +++ b/ApiCore-Frontend/package.json @@ -41,6 +41,7 @@ "@vitejs/plugin-react": "^4.7.0", "eslint": "^9", "eslint-config-next": "16.3.1", + "fake-indexeddb": "^6.2.5", "jsdom": "^30.0.1", "tailwindcss": "^4", "typescript": "^5", diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx new file mode 100644 index 0000000..2e802e9 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card"; +import { fetchColonyOverview } from "@/lib/apiaries/field-api"; +import { readRound } from "@/lib/rounds/round-state"; +import type { ColonyOverview, ColonyOverviewRow } from "@/lib/apiaries/field-types"; + +vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchColonyOverview: vi.fn(), +})); +vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" })); + +const apiary = { id: "a-1", name: "Obstwiese" }; + +function row(overrides: Partial): ColonyOverviewRow { + return { + id: "c-1", + code: "A1", + status: "ACTIVE", + strength: 6, + broodStatus: "GOOD", + feedStatus: "GOOD", + healthStatus: "GOOD", + lastInspectionDate: new Date().toISOString(), + openWarnings: 0, + openCritical: 0, + ...overrides, + }; +} + +function overview(rows: ColonyOverviewRow[]): ColonyOverview { + return { apiaryId: "a-1", inspectionsVisible: true, rows }; +} + +function renderCard() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + , + ); +} + +describe("ApiaryColoniesCard", () => { + beforeEach(() => { + window.sessionStorage.clear(); + vi.mocked(fetchColonyOverview).mockReset(); + }); + + it("lists living colonies with light and reasons", async () => { + vi.mocked(fetchColonyOverview).mockResolvedValue( + overview([ + row({ id: "c-1", code: "A1" }), + row({ id: "c-2", code: "A2", status: "QUEENLESS", openWarnings: 2 }), + ]), + ); + renderCard(); + + expect(await screen.findByRole("heading", { name: "Völker (2)" })).toBeInTheDocument(); + const first = screen.getByRole("link", { name: /A1/ }); + expect(first).toHaveAttribute("href", "/colonies/c-1"); + expect(within(first).getByText("Gut")).toBeInTheDocument(); + const second = screen.getByRole("link", { name: /A2/ }); + expect(within(second).getByText("Kritisch")).toBeInTheDocument(); + expect(within(second).getByText("Weisellos · 2 Warnungen")).toBeInTheDocument(); + expect(within(first).getByText("Aktiv")).toBeInTheDocument(); + }); + + it("„Runde starten\" opens the first colony's inspection with the round param", async () => { + vi.mocked(fetchColonyOverview).mockResolvedValue( + overview([row({ id: "c-1", code: "A1" }), row({ id: "c-2", code: "A2" })]), + ); + renderCard(); + + await screen.findByRole("heading", { name: "Völker (2)" }); + const start = screen.getByRole("button", { name: "Runde starten" }); + expect(start).toHaveAttribute("href", "/inspections/new?colonyId=c-1&round=a-1"); + await userEvent.click(start); + expect(readRound("user-1")).toMatchObject({ + apiaryId: "a-1", + apiaryName: "Obstwiese", + colonies: [ + { id: "c-1", code: "A1" }, + { id: "c-2", code: "A2" }, + ], + }); + }); + + it("„Runde starten\" is disabled without colonies", async () => { + vi.mocked(fetchColonyOverview).mockResolvedValue(overview([])); + renderCard(); + + expect(await screen.findByText(/Noch keine Völker/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Runde starten" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Runde starten" })).not.toHaveAttribute("href"); + }); + + it("shows a retryable error instead of an empty list when loading fails", async () => { + vi.mocked(fetchColonyOverview).mockRejectedValue(new Error("boom")); + renderCard(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Völker konnten nicht geladen werden"); + expect(screen.getByRole("button", { name: "Runde starten" })).toBeDisabled(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx new file mode 100644 index 0000000..32b5cbf --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx @@ -0,0 +1,107 @@ +"use client"; + +import Link from "next/link"; +import { ChevronRight, Play, Sprout } from "lucide-react"; +import { useColonyOverview } from "@/lib/apiaries/use-colony-overview"; +import { useCurrentUserId } from "@/lib/auth/auth-context"; +import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels"; +import { colonyStatusLight } from "@/lib/colonies/status-light"; +import { todayLocalIsoDate } from "@/lib/dates"; +import { startRound } from "@/lib/rounds/round-state"; +import { StatusLightBadge } from "@/components/colonies/status-light-badge"; +import { EmptyState } from "@/components/empty-state"; +import { ErrorState, LoadingState } from "@/components/query-state"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface ApiaryRef { + id: string; + name: string; +} + +/** The apiary page's one main action: walk through all colonies. */ +export function StartRoundButton({ apiary }: { apiary: ApiaryRef }) { + const userId = useCurrentUserId(); + const { data } = useColonyOverview(apiary.id); + const rows = data?.rows ?? []; + + if (rows.length === 0) { + return ( + + ); + } + return ( + + ); +} + +export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) { + const query = useColonyOverview(apiary.id); + const overview = query.data; + const today = todayLocalIsoDate(); + + return ( + + + +

Völker ({overview?.rows.length ?? 0})

+
+
+ + {query.isLoading ? : null} + {!query.isLoading && !overview ? ( + void query.refetch()} isRetrying={query.isFetching} /> + ) : null} + {overview && overview.rows.length === 0 ? ( + + ) : null} + {overview?.rows.map((row) => { + const { light, reasons } = colonyStatusLight(row, { + today, + inspectionsVisible: overview.inspectionsVisible, + }); + return ( + + + + {row.code} + + {COLONY_STATUS_LABELS[row.status]} + + {reasons.length > 0 ? ( + {reasons.join(" · ")} + ) : null} + + + + ); + })} + +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.spec.tsx index 2dd0968..4ec979f 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.spec.tsx @@ -20,6 +20,7 @@ const apiary: Apiary = { latitude: 48.47, longitude: 10.6, address: "Hauptstr. 1", + publicLocationLabel: "Nordhessen", status: "ACTIVE", notes: "Schlüssel beim Nachbarn", createdAt: "2026-01-01T00:00:00.000Z", @@ -41,7 +42,7 @@ describe("ApiaryEditCard", () => { , ); - for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen"]) { + for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen", "Ort für die Herkunftsseite"]) { await userEvent.clear(screen.getByLabelText(new RegExp(`^${label}`))); } await userEvent.click(screen.getByRole("button", { name: "Speichern" })); @@ -54,6 +55,7 @@ describe("ApiaryEditCard", () => { longitude: null, address: null, notes: null, + publicLocationLabel: null, }); }); }); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx index 46f36ea..35f3e23 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx @@ -14,6 +14,8 @@ import { ConfirmDialog } from "@/components/confirm-dialog"; import { Field } from "@/components/form/field"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { PUBLIC_LOCATION_HINT } from "../new/apiary-form-values"; + export function ApiaryEditCard({ apiary }: { apiary: Apiary }) { const queryClient = useQueryClient(); const [name, setName] = useState(apiary.name); @@ -23,6 +25,9 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) { const [longitude, setLongitude] = useState(apiary.longitude?.toString() ?? ""); const [address, setAddress] = useState(apiary.address ?? ""); const [notes, setNotes] = useState(apiary.notes ?? ""); + const [publicLocationLabel, setPublicLocationLabel] = useState( + apiary.publicLocationLabel ?? "", + ); const [nameError, setNameError] = useState(null); const [submitError, setSubmitError] = useState(null); @@ -36,6 +41,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) { longitude: emptyToNull(longitude, Number), address: emptyToNull(address), notes: emptyToNull(notes), + publicLocationLabel: emptyToNull(publicLocationLabel), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); @@ -159,6 +165,18 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) { /> )} + + {({ id, describedBy }) => ( + setPublicLocationLabel(event.target.value)} + /> + )} + {({ id }) => (