Compare commits

...
19 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 8a85bfaed4 docs: Doku-Abgleich mit origin/main (Stand 2026-10-16)
Docker-Images bauen und veroeffentlichen / lint (push) Successful in 1m30s
Docker-Images bauen und veroeffentlichen / typecheck (push) Successful in 1m48s
Docker-Images bauen und veroeffentlichen / test (push) Successful in 1m33s
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 7m20s
README mit Projektstand (Links auf Issues #33, #32), fehlenden Befehlen und
CI-Gate. BETRIEB: sha-gepinnte Manifeste, DB-Sicherung, Rollout per set image,
Label-Selector im pg_dump-Befehl, Storage Box. k8s/README: Schritt 09,
Warnung vor delete namespace. TESTS, OFFENE-PUNKTE, ARCHITEKTUR (ADR-8, ADR-9)
aktualisiert; Kommentare in .env.example und Worker-CronJob korrigiert.

Refs #31

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 07:57:43 +02:00
Nick Szaunig 9785eb221f fix(auth): Anmeldung unter Node 24 (Proxy um NextRequest)
Docker-Images bauen und veroeffentlichen / lint (push) Successful in 1m15s
Docker-Images bauen und veroeffentlichen / typecheck (push) Successful in 1m20s
Docker-Images bauen und veroeffentlichen / test (push) Successful in 1m19s
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m14s
Next reicht Route-Handlern einen Proxy um NextRequest. undici 7 (Node 24)
prueft private Felder (#state), die ein Proxy nicht besitzt; new Request(request)
warf "Cannot read private member #state" und jede /api/auth-Anfrage endete
lokal mit 500. Produktion (Node 22, undici 6) war nicht betroffen.

withClientIp baut die Anfrage jetzt aus URL, Methode, Headern und Body neu.
Regressionstest mit einem Proxy wie in Next.
2026-10-15 05:10:51 +02:00
Nick SzaunigandClaude Opus 5.5 0421d7d57a ci: Qualitaets-Gate vor dem Image-Build
Docker-Images bauen und veroeffentlichen / lint (push) Successful in 1m54s
Docker-Images bauen und veroeffentlichen / typecheck (push) Successful in 1m14s
Docker-Images bauen und veroeffentlichen / test (push) Successful in 1m35s
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 6m9s
Der Workflow baute bisher bei jedem Push auf main ungeprueft beide Images.
Jetzt laufen lint (inkl. pnpm audit ab high), typecheck und test auf dem
isolierten Runner (ci-isolated, ohne Docker-Socket und Clusternetz); die
Tests bekommen Postgres 18.6 als Service ueber TEST_DATABASE_URL.
build-and-push startet nur, wenn alle drei gruen sind. Muster und
Runner-Pruefskript aus dem VyCept-Repo (docs/BETRIEB.md 11b).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 09:02:55 +02:00
Nick SzaunigandClaude Opus 5.5 f29dbcf392 fix(deps): High-Advisories schliessen
pnpm audit --prod meldete 5 high:
- next 16.3.6 -> 16.3.8, eslint-config-next mitgezogen
- sharp 0.35.5 direkt und per Override (next pinnt sonst 0.35.4)
- source-map-js >= 1.2.2 (transitiv ueber next)
- mysql2 >= 3.23.1 und deepmerge-ts >= 8.0.0 (prisma-CLI, gleiche
  Untergrenzen wie im Wabenhain-Repo)

Lokal geprueft: lint, typecheck, 132 Tests, next build gruen; audit sauber.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 09:02:55 +02:00
Nick SzaunigandClaude Opus 5.5 5a88f3dcd6 fix: Client-IP haerten (CF-Connecting-IP, X-Real-IP, Admin-Session)
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 8m8s
- CF-Connecting-IP gilt nur noch als oeffentliche Unicast-Adresse
  (nicht 0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12,
  192.168/16, 240/4, Broadcast, ::, ::1, fc00::/7, fe80::/10,
  ::ffff:privat); sonst gilt die Gegenstelle.
- X-Real-IP ohne X-Forwarded-For nur noch ausserhalb von Produktion;
  in Produktion ergibt das "unknown".
- Neuer Helfer headersWithClientIp, von withClientIp und der
  Admin-Session-Pruefung genutzt: ein vom Client gesendeter
  x-artista-client-ip-Wert wird nie geglaubt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:26:56 +02:00
Nick SzaunigandClaude Opus 5.5 34ccbc3b9a fix: SMTP-Fehler nur bei Empfaengerablehnung endgueltig
Bisher setzte jeder SMTP-5xx die Mail sofort auf FAILED, auch Anmelde-
und Richtlinienfehler (EAUTH, EMESSAGE, 552). Ein kaputtes SMTP-Secret
haette alle Buchungs- und Zahlungsmails verworfen, ohne Rueckweg
(dedupeKey verhindert erneutes Einreihen).

Endgueltig ist jetzt nur EENVELOPE mit 550, 551, 553, 556 oder 5xx mit
Status 5.1.x. Alles andere bleibt PENDING und wird bis
MAX_EMAIL_ATTEMPTS wiederholt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:20:34 +02:00
Nick SzaunigandClaude Opus 5.5 e6fa383934 fix(security): eigene Rate-Limits zaehlen IPv6 je /64-Netz
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 7m24s
consumeRateLimit bekommt IPv6-Adressen als /64-Praefix (rateLimitSubject):
ein Anschluss hat meist ein ganzes /64 und koennte sonst durch Adresswechsel
jedes Limit umgehen. better-auth bekommt weiter die einzelne Adresse und
normalisiert sie selbst auf /64 (ipv6Subnet, Standard 64).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 10:06:28 +02:00
Nick SzaunigandClaude Opus 5.5 f75a5f7769 fix(security): Client-IP nicht aus X-Real-IP, better-auth nur mit Server-Wert
Docker-Images bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Traefik vertraut seit 2026-10-12 den weitergeleiteten Headern aus
Cloudflare-Netzen. Wer eine eigene Cloudflare-Zone auf den Server zeigen
laesst, konnte damit X-Real-IP und den ersten X-Forwarded-For-Eintrag
faelschen und die Rate-Limits (Buchung, Kontakt, Login, 2FA) umgehen.

- clientIp: Gegenstelle ist der letzte X-Forwarded-For-Eintrag (von Traefik
  angehaengt); CF-Connecting-IP nur, wenn sie eine Cloudflare-Edge ist.
- better-auth liest nur noch x-artista-client-ip, den der Auth-Endpunkt
  per withClientIp setzt (ein mitgeschickter Wert wird ersetzt).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:49:58 +02:00
Nick SzaunigandClaude Opus 5.5 c3fdfbdc77 fix(worker): endgueltig abgelehnte Mails nicht wiederholen, Adressen nicht loggen
Docker-Images bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Eine Mail an eine Adresse ohne Mailserver (SMTP 556, nullMX) wurde bis zu
8-mal erneut versendet und liess jeden Worker-Lauf mit Exit-Code 1 enden;
das SMTP-Fehlerlog enthielt dabei die Empfaengeradresse.

- SMTP-Antworten 5xx markieren die Mail sofort als FAILED, 4xx und
  Verbindungsfehler bleiben wiederholbar.
- Adressen werden aus Log und lastError entfernt.
- Worker-CronJob: backoffLimit 0 (kein zweiter Pod je Fehlschlag) und
  ttlSecondsAfterFinished 1 Tag, damit Fehler-Pods nicht stehen bleiben.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:24:28 +02:00
Nick SzaunigandClaude Opus 5.5 f2b548cb98 chore(k8s): Images auf CI-Build pinnen statt :latest
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 5m51s
App, Migration und Worker zeigen auf sha-<commit> mit
imagePullPolicy IfNotPresent. Damit gibt es ein Rollback per
rollout undo, und ein Neustart zieht nicht mehr ungeprueft das
neueste Image (oder scheitert, wenn Gitea gerade nicht laeuft).
README beschreibt das Ausrollen mit kubectl set image.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:27:47 +02:00
Nick SzaunigandClaude Opus 5.5 b725aa230c feat(k8s): taegliche pg_dump-Sicherung der Datenbank
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 7m39s
CronJob artista-db-backup schreibt per pg_dump auf die Storage Box
(StorageClass smb-backup, SMB 3.1.1 mit seal). 14 Tage taeglich,
8 Wochen sonntags.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 02:04:24 +02:00
Nick SzaunigandClaude Opus 5.5 d3a7f8b824 chore(k8s): CPU- und Speicher-Reservierung entfernen
Docker-Images bauen und veroeffentlichen / build-and-push (push) Canceled after 1m17s
Requests stehen explizit auf 0 statt zu fehlen, weil Kubernetes einen
fehlenden Request auf das Limit setzt und dann noch mehr reserviert.
Die Limits bleiben als Obergrenze.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 04:08:14 +02:00
Nick SzaunigandClaude Opus 5.5 98d1f7686c chore: postgres auf 18.6-alpine aktualisieren
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 6m47s
18.1 war fünf Minor-Releases alt. Minor-Updates sind binärkompatibel,
das Datenverzeichnis bleibt unverändert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 02:02:55 +02:00
Nick SzaunigandClaude Opus 5.5 a47e91e9d5 feat: Telefonnummer im Kontaktformular ist Pflicht
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 2m50s
Gleiche Prüfung wie bei der Buchung: mindestens 6 Zeichen, nur Ziffern, Leerzeichen und + ( ) / -.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 00:56:30 +02:00
Nick SzaunigandClaude Opus 5.5 20adfaa97e feat: Gutschein-Detailseite zeigt, ob der Code gerade einlösbar ist und warum nicht
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 2m54s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 23:26:14 +02:00
Nick SzaunigandClaude Opus 5.5 7a3e37f96f feat: Buchungsdialog zeigt Preis erst unter der gewählten Zahlungsweise
Keine Vorauswahl mehr. Unter der gewählten Option stehen Preisaufstellung und Raten mit Fälligkeit; Weiter erst nach Auswahl.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 23:26:14 +02:00
Nick SzaunigandClaude Opus 5.5 5c2878875b feat: Buchung sofort möglich, sobald alle Freigaben erteilt sind
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 2m54s
Vor dem 01.11.2026 gilt dann der Early-Bird-Preis. Fehlt eine Freigabe, bleibt die Vorschau.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 23:07:39 +02:00
Nick SzaunigandClaude Opus 5.5 a76a9cf616 fix: Teambilder in gerader Reihe, Tagesablauf-Karte auf dem Smartphone mittig
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 23:07:38 +02:00
Nick SzaunigandClaude Opus 5.5 2be1de6a63 fix: Intro-Video passt sich dem Format an, keine schwarzen Balken bei Hochformat
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 23:00:24 +02:00
35 changed files with 1038 additions and 316 deletions

No files matched your search

+3 -1
View File
@@ -26,7 +26,9 @@ BETTER_AUTH_SECRET=<anderer-zufaelliger-wert-mindestens-32-zeichen>
# sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich) # sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich)
# sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben) # sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben)
# Empfohlen: eingeschraenkter Schluessel (rk_…) mit Schreibrechten fuer # Empfohlen: eingeschraenkter Schluessel (rk_…) mit Schreibrechten fuer
# Customers, Checkout Sessions, PaymentIntents, PaymentMethods, Refunds. # Customers, Checkout Sessions, PaymentIntents, PaymentMethods, Refunds und
# fuer die Folgeraten (Stripe Billing) zusaetzlich Products, Subscription
# Schedules, Subscriptions, Invoices (docs/BETRIEB.md, Abschnitt 4).
STRIPE_SECRET_KEY=<sk_test_…> STRIPE_SECRET_KEY=<sk_test_…>
# Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md. # Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md.
STRIPE_WEBHOOK_SECRET=<whsec_…> STRIPE_WEBHOOK_SECRET=<whsec_…>
+87
View File
@@ -7,14 +7,101 @@ name: Docker-Images bauen und veroeffentlichen
# #
# Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3" # Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3"
# (zusaetzlich ":1.2.3"), manuell ueber "Run workflow". # (zusaetzlich ":1.2.3"), manuell ueber "Run workflow".
#
# Qualitaets-Gate: Images entstehen nur, wenn lint (inkl. audit), typecheck
# und test gruen sind. Die Pruefungen laufen auf "ci-isolated" (Jobs ohne
# Docker-Socket und ohne Clusternetz, Runner-Doku im VyCept-Repo,
# docs/BETRIEB.md 11b); nur der Image-Build braucht "docker-builder".
on: on:
push: push:
branches: [main] branches: [main]
tags: ["v*"] tags: ["v*"]
workflow_dispatch: workflow_dispatch:
# Ein neuer Push bricht den ueberholten Lauf ab, statt ihn zu Ende zu rechnen.
concurrency:
group: docker-build-${{ gitea.ref }}
cancel-in-progress: true
env:
NEXT_TELEMETRY_DISABLED: "1"
jobs: jobs:
lint:
runs-on: ci-isolated
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Runner-Isolation pruefen
run: bash scripts/ci-runner-isolation.sh
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: { node-version: "22" }
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/pnpm
~/.cache/pnpm-store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
- run: pnpm lint
# Bekannte Luecken in Laufzeit-Abhaengigkeiten (ab "high") brechen den Lauf ab.
- run: pnpm audit --prod --audit-level high
typecheck:
runs-on: ci-isolated
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Runner-Isolation pruefen
run: bash scripts/ci-runner-isolation.sh
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: { node-version: "22" }
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/pnpm
~/.cache/pnpm-store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
- run: pnpm typecheck
test:
runs-on: ci-isolated
timeout-minutes: 15
# embedded-postgres startet nicht als root; deshalb ein Datenbank-Service
# (gleiche Version wie im Cluster), genutzt ueber TEST_DATABASE_URL
# (tests/support/global-setup.ts).
services:
postgres:
image: postgres:18.6-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: artista_test
options: >-
--health-cmd "pg_isready -U test" --health-interval 5s --health-retries 10
env:
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/artista_test
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Runner-Isolation pruefen
run: bash scripts/ci-runner-isolation.sh
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: { node-version: "22" }
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/pnpm
~/.cache/pnpm-store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
- run: pnpm test
build-and-push: build-and-push:
needs: [lint, typecheck, test]
runs-on: docker-builder runs-on: docker-builder
env: env:
# Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels). # Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels).
+16 -4
View File
@@ -1,13 +1,19 @@
# ARTISTA MODELCAMP 2027 – Buchungswebsite # ARTISTA MODELCAMP 2027 – Buchungswebsite
Deutschsprachige Onepage mit echter Buchungsstrecke: Stripe-Zahlung (einmalig oder in bis zu drei Raten), persistente Bestell- und Gutscheinverwaltung, geschützter Adminbereich mit Zwei-Faktor-Anmeldung, Docker- und RKE2-Deployment. Deutschsprachige Onepage mit echter Buchungsstrecke: Stripe-Zahlung (einmalig oder in bis zu drei Raten), persistente Bestell- und Gutscheinverwaltung, Kontaktformular für ein Erstgespräch, geschützter Adminbereich mit Zwei-Faktor-Anmeldung, Docker- und RKE2-Deployment.
**Stand:** technisch vollständig, im Stripe-Testmodus buchbar. Der Live-Verkauf ist serverseitig gesperrt, bis alle Inhalte und Freigaben aus [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) im Adminbereich bestätigt sind. **Stand:** technisch vollständig, im Stripe-Testmodus buchbar. Die Seite läuft im Cluster unter https://modelcamp.artistasphotography.de (Namespace `artista`). Der Live-Verkauf ist serverseitig gesperrt, bis alle Inhalte und Freigaben aus [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) im Adminbereich bestätigt sind.
## Projektstand
Was gebaut, geprüft und ausgerollt ist und was noch offen ist, steht im Übersichts-Issue [#33 „Projektstand und Übersicht“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/33). Aufgaben, die nur der Inhaber erledigen kann (Freigaben, Rechtstexte, Secrets, Rollouts), sammelt [#32 „Inhaber-Aufgaben“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/32). Jedes Arbeitspaket hat ein eigenes Issue.
| Dokument | Inhalt | | Dokument | Inhalt |
|---|---| |---|---|
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Design | | [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Kontaktformular, Design |
| [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung | | [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, CI, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung |
| [k8s/README.md](k8s/README.md) | Manifeste Schritt für Schritt anwenden, neues Image ausrollen, Fehlersuche |
| [docs/MEDIEN.md](docs/MEDIEN.md) | Dateinamen für Logo, Headerbild, Intro-Video, Porträts und Reels; Medien aufbereiten |
| [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) | Noch benötigte Inhalte und Freigaben | | [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) | Noch benötigte Inhalte und Freigaben |
| [docs/TESTS.md](docs/TESTS.md) | Ausgeführte Tests und verbleibende Einschränkungen | | [docs/TESTS.md](docs/TESTS.md) | Ausgeführte Tests und verbleibende Einschränkungen |
@@ -46,9 +52,14 @@ Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E
| `pnpm typecheck` / `pnpm lint` | Typprüfung, ESLint | | `pnpm typecheck` / `pnpm lint` | Typprüfung, ESLint |
| `pnpm test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) | | `pnpm test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) |
| `pnpm test:e2e` | Playwright: Smartphone, Tablet, Desktop, axe-Barrierefreiheit (gegen laufenden Server) | | `pnpm test:e2e` | Playwright: Smartphone, Tablet, Desktop, axe-Barrierefreiheit (gegen laufenden Server) |
| `pnpm db:dev` | Eingebettetes PostgreSQL für die Entwicklung (Port 54329) |
| `pnpm db:migrate` / `pnpm db:deploy` | Migration erstellen / anwenden | | `pnpm db:migrate` / `pnpm db:deploy` | Migration erstellen / anwenden |
| `pnpm db:seed` | Nur Entwicklung: Early Bird simulieren, Test-Gutschein `TEST-STAMMMODEL` |
| `pnpm worker` | Einmaliger Worker-Lauf | | `pnpm worker` | Einmaliger Worker-Lauf |
| `pnpm admin:create` | Admin-Konto anlegen (kein Standardpasswort) | | `pnpm admin:create` | Admin-Konto anlegen (kein Standardpasswort) |
| `pnpm media:optimize <ordner>` | Videos und große Bilder vor dem Hochladen aufbereiten (siehe docs/MEDIEN.md) |
Bei jedem Push auf `main` prüft die CI (`.gitea/workflows/docker-build.yml`) zuerst `lint` samt `pnpm audit`, `typecheck` und `test`; erst wenn alle drei grün sind, baut sie die beiden Images. Die Playwright-Tests laufen nicht in der CI.
## Wo was gepflegt wird ## Wo was gepflegt wird
@@ -59,4 +70,5 @@ Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E
- **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben). - **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben).
- **Logo, Headerbild, Porträts, Reels:** feste Dateinamen im Medienverzeichnis, werden automatisch erkannt. Liste: [docs/MEDIEN.md](docs/MEDIEN.md). - **Logo, Headerbild, Porträts, Reels:** feste Dateinamen im Medienverzeichnis, werden automatisch erkannt. Liste: [docs/MEDIEN.md](docs/MEDIEN.md).
- **Widerrufsbelehrung:** Adminbereich → Einstellungen, erscheint unter `/widerruf`. - **Widerrufsbelehrung:** Adminbereich → Einstellungen, erscheint unter `/widerruf`.
- **Empfänger des Formulars „Erstgespräch“:** die Veranstalter-E-Mail aus dem Adminbereich, ersatzweise `ADMIN_NOTIFY_EMAIL`. Ist keine von beiden gesetzt, lehnt das Formular Anfragen ab.
- **Weitere Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen. - **Weitere Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
+1 -1
View File
@@ -8,7 +8,7 @@
# Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file. # Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file.
services: services:
db: db:
image: postgres:18.1-alpine image: postgres:18.6-alpine
environment: environment:
POSTGRES_USER: artista POSTGRES_USER: artista
POSTGRES_PASSWORD: artista-dev POSTGRES_PASSWORD: artista-dev
+13 -1
View File
@@ -85,10 +85,22 @@ Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, da
`src/lib/sales-gate.ts` wird von jedem Checkout-Endpunkt aufgerufen. `src/lib/sales-gate.ts` wird von jedem Checkout-Endpunkt aufgerufen.
- **Livemodus (`sk_live_`):** Verkauf nur mit laufender echter Preisphase, eingeschaltetem Live-Verkauf und allen elf Bestätigungen. - **Livemodus (`sk_live_`):** Verkauf nur mit eingeschaltetem Live-Verkauf und allen elf Bestätigungen. Sind alle erteilt, startet der Verkauf sofort, auch vor dem 01.11.2026; bis dahin gilt der Early-Bird-Preis (seit 06.10.2026).
- **Testmodus:** Checkout ist ohne Freigaben möglich, die Preisphase lässt sich simulieren. - **Testmodus:** Checkout ist ohne Freigaben möglich, die Preisphase lässt sich simulieren.
- **Schutz vor Fehlkonfiguration:** Ein Live-Schlüssel außerhalb von `APP_ENV=production` bricht mit einem Fehler ab. - **Schutz vor Fehlkonfiguration:** Ein Live-Schlüssel außerhalb von `APP_ENV=production` bricht mit einem Fehler ab.
## ADR-7: Adminbereich ## ADR-7: Adminbereich
Better Auth übernimmt E-Mail und Passwort, Registrierung ist ausgeschaltet. Konten entstehen nur über die Kommandozeile, TOTP-2FA ist Pflicht. Rate Limits der Anmeldung liegen in PostgreSQL, Passwörter werden gegen Datenlecks geprüft. Jede Seite, jede Server-Action und der CSV-Export prüfen Sitzung und 2FA selbst. Der Proxy leitet nur vorab um. Alle Buchungs-, Zahlungs- und Konfigurationsaktionen landen im AuditLog. Better Auth übernimmt E-Mail und Passwort, Registrierung ist ausgeschaltet. Konten entstehen nur über die Kommandozeile, TOTP-2FA ist Pflicht. Rate Limits der Anmeldung liegen in PostgreSQL, Passwörter werden gegen Datenlecks geprüft. Jede Seite, jede Server-Action und der CSV-Export prüfen Sitzung und 2FA selbst. Der Proxy leitet nur vorab um. Alle Buchungs-, Zahlungs- und Konfigurationsaktionen landen im AuditLog.
## ADR-8: Kontaktformular „Erstgespräch“ (seit 06.10.2026)
- Eigener Abschnitt nach den Fragen (Anker `#erstgespraech`, `POST /api/contact`). Pflicht sind Name, E-Mail, Telefon (seit 07.10.2026, gleiche Regel wie bei der Buchung) und die Bestätigung der Datenschutzhinweise; Erreichbarkeit und Nachricht sind optional.
- Kein eigenes Datenmodell: Die Anfrage geht als E-Mail über den transaktionalen Postausgang an die Veranstalter-E-Mail aus dem Admin, ersatzweise `ADMIN_NOTIFY_EMAIL`. Ohne Empfänger lehnt das Formular ab.
- Bewusst keine automatische Bestätigungsmail an die angegebene Adresse, damit das Formular nicht zum Versand an Dritte missbraucht werden kann.
- Schutz wie bei der Buchung: nur JSON von der eigenen Seite, Rate Limit 5 Anfragen je Stunde und IP, Honeypot-Feld gegen Bots, doppeltes Absenden in derselben Minute ergibt eine Mail.
## ADR-9: Client-IP und Rate Limits (seit 12.10.2026)
- Rate Limits liegen in PostgreSQL (`RateLimitBucket`, gehashter IP-Schlüssel, Aufräumen nach einem Tag durch den Worker). IPv6-Adressen zählen je /64-Netz.
- Die Client-IP ist die Gegenstelle laut Traefik (letzter `X-Forwarded-For`-Eintrag). `CF-Connecting-IP` gilt nur als öffentliche Unicast-Adresse, `X-Real-IP` ohne `X-Forwarded-For` nur außerhalb der Produktion. Better Auth bekommt die IP ausschließlich über den serverseitig gesetzten Header `x-artista-client-ip`; ein vom Browser mitgeschickter Wert wird verworfen.
+22 -12
View File
@@ -2,16 +2,18 @@
## 1. Images bauen ## 1. Images bauen
**Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“ beide Images und pusht sie in die Gitea-Registry: **Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` läuft bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“. Zuerst prüfen drei Jobs auf dem isolierten Runner (`ci-isolated`, ohne Docker-Socket) den Code: `lint` (inklusive `pnpm audit --prod --audit-level high`), `typecheck` und `test` (mit PostgreSQL 18.6 als Service). Nur wenn alle drei grün sind, baut `build-and-push` auf dem Runner `docker-builder` beide Images und pusht sie in die Gitea-Registry:
- `main` ergibt die Tags `latest` und `sha-<kurz>` - `main` ergibt die Tags `latest` und `sha-<kurz>`
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0` - `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
Einmalig einrichten: Einmalig einrichten:
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets) - ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
- einen Runner mit dem Label `docker-builder` bereitstellen (wie bei Wabenhain) - Runner mit den Labels `ci-isolated` (Prüfungen) und `docker-builder` (Image-Build) bereitstellen; im EnCry-Cluster sind beide vorhanden
Die Manifeste in `k8s/` nutzen den Tag `latest`. Für feste Versionen dort `:latest` durch z. B. `:0.1.0` ersetzen (`git tag v0.1.0 && git push origin v0.1.0`). Die Manifeste in `k8s/` sind auf einen CI-Build gepinnt (`sha-<commit>`, Platzhalter `sha-REPLACE_ME` im Repo), nicht auf `latest`. Ausgerollt wird mit `kubectl set image`, siehe Abschnitt 3 und [k8s/README.md](../k8s/README.md).
Scheitert `build-and-push` beim Push mit `500 … cannot allocate memory`, lag das bisher an der Gitea-Registry, wenn gleichzeitig ein anderes Repo pushte. Dann den Lauf neu starten, sobald der andere Build fertig ist.
**Manuell:** **Manuell:**
@@ -29,24 +31,32 @@ Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber
- der Worker-CronJob, nötig für Reservierungsfreigabe, Abgleich der Folgeraten und Mail-Wiederholung - der Worker-CronJob, nötig für Reservierungsfreigabe, Abgleich der Folgeraten und Mail-Wiederholung
- ConfigMap - ConfigMap
- zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank) - zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
- der Sicherungs-CronJob `artista-db-backup` (täglicher `pg_dump`, Abschnitt 7)
Eine eigene Sicherung im Cluster gibt es nicht, die SMB-Freigabe wird auf dem NAS gesichert. **Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-namespace` bis `09-db-backup-cronjob`, Namensschema wie bei Wabenhain) und werden in dieser Reihenfolge einzeln angewendet. Die Secrets kommen aus `k8s/02-secret.yaml.example` und werden nicht eingecheckt. Pod Security: Erzwungen wird `baseline`, damit der Zertifikats-Solver von cert-manager starten kann. `restricted` wird nur gemeldet, die eigenen Pods erfüllen es.
**Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-namespace` bis `08-networkpolicy`, Namensschema wie bei Wabenhain) und werden in dieser Reihenfolge einzeln angewendet. Die Secrets kommen aus `k8s/02-secret.yaml.example` und werden nicht eingecheckt. Pod Security: Erzwungen wird `baseline`, damit der Zertifikats-Solver von cert-manager starten kann. `restricted` wird nur gemeldet, die eigenen Pods erfüllen es. Im EnCry-Cluster liegen die SMB-Freigaben auf der Hetzner Storage Box, nicht auf einem lokalen NAS; „NAS“ meint im Folgenden diese Freigabe. Im Namespace `artista` läuft außerdem Artista-Telefonie (eigenes Repo `Artista-TeleTracking`). Sie nutzt eine eigene Datenbank `telefonie` in derselben PostgreSQL-Instanz `artista-modelcamp-postgres`. Wer die Instanz oder den Namespace anfasst, trifft beide Anwendungen.
Zu PostgreSQL: Zu PostgreSQL:
- **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden. - **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden.
- **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar. - **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar.
- **Sicherung:** Die NAS-Sicherung ist für die laufende Datenbank nur mit **atomaren Snapshots** der Freigabe (Btrfs/ZFS) konsistent. Zusätzlicher Schutz bei Bedarf: gelegentlich einen `pg_dump` ziehen (Abschnitt 7). - **Sicherung:** Eine Kopie der Freigabe ist für die laufende Datenbank nur mit **atomaren Snapshots** (Btrfs/ZFS) konsistent. Deshalb zieht der CronJob `artista-db-backup` jede Nacht einen `pg_dump` (Abschnitt 7).
- **Nach SMB-Unterbrechungen** melden lang laufende Postgres-Prozesse gelegentlich `could not seek to end of file … Bad file descriptor`. Abhilfe: `kubectl -n artista rollout restart sts/artista-modelcamp-postgres` (fährt sauber herunter).
## 3. Update ausrollen ## 3. Update ausrollen
App, Migration und Worker immer mit demselben Tag `sha-<commit>` aus der CI ausrollen (genauer Befehl in [k8s/README.md](../k8s/README.md), Abschnitt „Bei einem neuen Image“):
```bash ```bash
kubectl -n artista rollout restart deploy/artista-modelcamp-app TAG=sha-<commit>
kubectl -n artista set image deployment/artista-modelcamp-app migrate=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG app=git.encry.de/nick.rosenloecher/artista-modelcamp:$TAG
kubectl -n artista set image cronjob/artista-modelcamp-worker worker=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG
kubectl -n artista rollout status deploy/artista-modelcamp-app kubectl -n artista rollout status deploy/artista-modelcamp-app
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe
``` ```
Ein `rollout restart` allein holt kein neues Image, weil die Manifeste auf einen festen Tag zeigen.
Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter. Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter.
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können. Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
@@ -85,10 +95,10 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
## 7. Sicherung und Wiederherstellung ## 7. Sicherung und Wiederherstellung
- **Sicherung:** Die NAS-Sicherung der SMB-Freigabe übernimmt das Speichersystem. Für PostgreSQL gilt das nur mit atomaren Snapshots, siehe Abschnitt 2. Im Cluster läuft kein Backup-Job. - **Täglicher Dump im Cluster:** Der CronJob `artista-db-backup` (`k8s/09-db-backup-cronjob.yaml`) läuft um 02:25 Uhr (Zeitzone des Clusters) und schreibt `pg_dump -Fc` der Datenbank `artista` in die PVC `artista-db-backup` (StorageClass `smb-backup`, Ordner `Backups/artista/` auf der Storage Box). Aufbewahrung: 14 Tage täglich (`daily/`), 8 Wochen sonntags (`weekly/`). Die Datenbank `telefonie` derselben Instanz ist darin nicht enthalten. Stand prüfen: `kubectl -n artista get cronjob artista-db-backup` und `kubectl -n artista get jobs | grep artista-db-backup`.
- **Logischer Dump** (empfohlen vor Updates und vor dem Live-Verkauf): - **Logischer Dump von Hand** (zusätzlich vor Updates und vor dem Live-Verkauf):
```bash ```bash
POD=$(kubectl -n artista get pod -l app.kubernetes.io/name=artista-modelcamp-postgres -o name | head -1) POD=$(kubectl -n artista get pod -l app=artista-modelcamp-postgres -o name | head -1)
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
``` ```
- **Wiederherstellen:** - **Wiederherstellen:**
@@ -96,8 +106,8 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump` 2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
3. App wieder hochskalieren (der initContainer migriert dabei), Worker fortsetzen. 3. App wieder hochskalieren (der initContainer migriert dabei), Worker fortsetzen.
4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“). 4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“).
- **Medien:** liegen im SMB-Ordner `modelcamp-media` und sind in der NAS-Sicherung enthalten. - **Medien:** liegen im SMB-Ordner `modelcamp-media`; gesichert sind sie nur über die Sicherung der Freigabe selbst.
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace. - **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace. Das ist noch nicht geschehen.
## 8. Stornierung und Erstattung ## 8. Stornierung und Erstattung
+9 -6
View File
@@ -1,6 +1,8 @@
# Noch benötigte Inhalte und Freigaben # Noch benötigte Inhalte und Freigaben
Stand 01.10.2026. Bereits bestätigt und per Migration `20261001120000_camp_2027_bestaetigt` eingetragen (im Admin änderbar): Stand 16.10.2026. Dieselben Punkte stehen mit Verweis auf die Detail-Issues in der Gitea-Sammelliste [#32 „Inhaber-Aufgaben“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/32).
Bereits bestätigt und per Migration `20261001120000_camp_2027_bestaetigt` bzw. `20261002090000_termin_mai_2027` eingetragen (im Admin änderbar). Damit sind im Admin die Freigaben Termin, Ort, Unterkunft und Platzzahl gesetzt, sofern sie dort niemand zurückgenommen hat:
- Termin 03.05. bis 09.05.2027 (laut Wochenplan vom 02.10.2026), Villa La Chiesina, Arcidosso (Toskana) - Termin 03.05. bis 09.05.2027 (laut Wochenplan vom 02.10.2026), Villa La Chiesina, Arcidosso (Toskana)
- Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer - Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer
@@ -9,7 +11,7 @@ Stand 01.10.2026. Bereits bestätigt und per Migration `20261001120000_camp_2027
- Tagesablauf aller sieben Tage, Bustransfer ab und bis Florenz, Verpflegung (Frühstück, Snacks, Abendessen) - Tagesablauf aller sieben Tage, Bustransfer ab und bis Florenz, Verpflegung (Frühstück, Snacks, Abendessen)
- Early Bird 01.11. bis 31.12.2026, ohne Anzeige eines Verkaufsstarts - Early Bird 01.11. bis 31.12.2026, ohne Anzeige eines Verkaufsstarts
Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“. Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“. Sind alle Freigaben erteilt und ist der Live-Verkauf eingeschaltet, startet der Verkauf sofort zum Early-Bird-Preis; die Early-Bird-Phase läuft vom 01.11. bis 31.12.2026.
## Inhalte ## Inhalte
@@ -40,7 +42,8 @@ Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf
## Betrieb ## Betrieb
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt. - Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt, Billing-Einstellungen für Folgeraten gesetzt (docs/BETRIEB.md, Abschnitt 4).
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain. - SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain; Veranstalter-E-Mail oder `ADMIN_NOTIFY_EMAIL` gesetzt, sonst lehnt das Formular „Erstgespräch“ Anfragen ab.
- Domain, TLS und StorageClasses im Cluster (siehe docs/BETRIEB.md). - Erledigt: Domain `modelcamp.artistasphotography.de`, TLS über cert-manager, SMB-Speicher und die StorageClass `smb-backup` im Cluster.
- Wiederherstellung aus der Sicherung einmal geübt. - Wiederherstellung aus der Sicherung einmal geübt (noch offen).
- Optional: Google-Rezensionen (`GOOGLE_PLACES_API_KEY`, `GOOGLE_PLACE_ID` im Secret); ohne Werte bleibt der Abschnitt ausgeblendet.
+11 -6
View File
@@ -1,10 +1,14 @@
# Tests und Abnahme # Tests und Abnahme
Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden. Stand 16.10.2026. Die Abschnitte unten sind chronologisch gewachsen; der aktuelle Stand steht hier oben.
**Aktuell:** `pnpm test` umfasst 10 Testdateien (rund 130 Tests; 132 beim lokalen Lauf am 14.10.2026 zu Commit `f29dbcf`, seither kam mit `9785eb2` ein Regressionstest hinzu). Seit `0421d7d` (14.10.2026) laufen `lint` mit `pnpm audit`, `typecheck` und `test` bei jedem Push auf `main` in der CI; der Test-Job nutzt PostgreSQL 18.6 als Service (`TEST_DATABASE_URL`). Letzter CI-Lauf (`9785eb2`, 15.10.2026): lint, typecheck und test grün, nur der Image-Build schlug fehl (Issue #20). Die Playwright-Tests (`pnpm test:e2e`, 3 Tests je Profil) laufen nur lokal.
Seit dem ersten Stand hinzugekommene Testdateien: `client-ip.test.ts` (Client-IP hinter Traefik und Cloudflare, IPv6 je /64, Proxy um NextRequest), `contact.integration.test.ts` (Formular „Erstgespräch“), `email-outbox.integration.test.ts` (Postausgang, endgültige und vorübergehende SMTP-Fehler), `review-format.test.ts` (Google-Rezensionen).
## Automatisierte Tests ## Automatisierte Tests
`pnpm test` umfasst 72 Tests in 6 Dateien. Sie laufen gegen eine echte, frisch gestartete PostgreSQL-18-Instanz, damit Zeilensperren und Trigger mitgetestet werden. Stand 27.09.2026: `pnpm test` umfasste 72 Tests in 6 Dateien. Sie laufen gegen eine echte, frisch gestartete PostgreSQL-18-Instanz, damit Zeilensperren und Trigger mitgetestet werden.
| Geschäftsrisiko | Datei | | Geschäftsrisiko | Datei |
|---|---| |---|---|
@@ -33,7 +37,7 @@ Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden
Bildschirmfotos aller drei Größen wurden visuell geprüft. Bildschirmfotos aller drei Größen wurden visuell geprüft.
Letzter Lauf 01.10.2026 nach Einbau von Team, Villa-Bildern und bestätigtem Termin: 81/81 Unit- und Integrationstests, 9/9 E2E-Tests, Produktionsbuild grün. Team und Unterkunft wurden zusätzlich per Bildschirmfoto auf Smartphone (390 px) und Desktop (1440 px) geprüft. Mit Termin 08.05.2027 fällt die letzte Rate auf den 08.04.2027. Letzter Lauf 01.10.2026 nach Einbau von Team, Villa-Bildern und bestätigtem Termin: 81/81 Unit- und Integrationstests, 9/9 E2E-Tests, Produktionsbuild grün. Team und Unterkunft wurden zusätzlich per Bildschirmfoto auf Smartphone (390 px) und Desktop (1440 px) geprüft. Mit dem damaligen Termin 08.05.2027 fiel die letzte Rate auf den 08.04.2027; seit dem Termin 03.05.2027 (Migration vom 02.10.2026) ist es der 03.04.2027.
Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der Same-Origin-Schutz die Preisabfrage ab (403). Dann hängt der Buchungsdialog bei „Preise werden geladen …“. Server deshalb mit passender `APP_URL` starten, z. B. `APP_URL=http://localhost:3005 PORT=3005 pnpm start` und `E2E_BASE_URL=http://localhost:3005 pnpm test:e2e`. Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der Same-Origin-Schutz die Preisabfrage ab (403). Dann hängt der Buchungsdialog bei „Preise werden geladen …“. Server deshalb mit passender `APP_URL` starten, z. B. `APP_URL=http://localhost:3005 PORT=3005 pnpm start` und `E2E_BASE_URL=http://localhost:3005 pnpm test:e2e`.
@@ -53,7 +57,8 @@ Lauf 06.10.2026 nach Umstellung der Folgeraten auf Stripe Billing (inkl. Anhalte
## Nicht ausgeführt / Einschränkungen ## Nicht ausgeführt / Einschränkungen
- **Echte Stripe-Testbuchung** gegen die Stripe-API: Ein Stripe-Testschlüssel lag nicht vor. Die Stripe-Logik ist gegen eine Attrappe mit Stripe-Semantik getestet (Idempotenz, Session-Zustände, Rechnungen des Ratenplans), die Webhook-Signatur mit der echten Stripe-Bibliothek. Mit Schlüssel: Anleitung in der README (`stripe listen`, Testkarten). - **Vollständige Stripe-Testbuchung** über die Oberfläche bis zum Webhook: In der Testsuite läuft die Stripe-Logik gegen eine Attrappe mit Stripe-Semantik (Idempotenz, Session-Zustände, Rechnungen des Ratenplans), die Webhook-Signatur mit der echten Stripe-Bibliothek. Gegen die echte Stripe-Test-API geprüft ist bisher nur der Ratenplan (`StripeGateway` mit Testuhr, Lauf vom 06.10.2026). Ein dokumentierter Durchlauf Buchung → Checkout → Webhook → Bestätigungsmail auf dem Cluster fehlt (Issue #27). Anleitung lokal in der README (`stripe listen`, Testkarten).
- **Docker-Image und Cluster:** Auf dem Entwicklungsrechner gibt es kein Docker und keinen Cluster-Zugang. Das Dockerfile ist nicht gebaut und nicht im Cluster ausgerollt. Geprüft sind der Standalone-Build und das Rendern der Manifeste. - **Docker-Image und Cluster:** Seit dem 27.09.2026 baut die CI beide Images; die Seite läuft im Cluster (zuletzt belegt ausgerollt: `sha-5a88f3d`, 13.10.2026). Eine automatisierte Prüfung nach dem Rollout gibt es nicht; der Monitoring-Dienst Gatus im Cluster prüft die Erreichbarkeit.
- **SMTP-Versand:** nur über den Datei-Testausgang geprüft. - **SMTP-Versand:** im Repo nur über den Datei-Testausgang geprüft. Ob im Cluster ein echter SMTP-Zugang eingetragen ist, ist hier nicht dokumentiert.
- **E2E-Tests** laufen nicht in der CI.
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md). - **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
seccompProfile: { type: RuntimeDefault } seccompProfile: { type: RuntimeDefault }
containers: containers:
- name: postgres - name: postgres
image: postgres:18.1-alpine image: postgres:18.6-alpine
ports: ports:
- containerPort: 5432 - containerPort: 5432
env: env:
@@ -41,7 +41,7 @@ spec:
exec: { command: ["pg_isready", "-U", "artista"] } exec: { command: ["pg_isready", "-U", "artista"] }
periodSeconds: 5 periodSeconds: 5
resources: resources:
requests: { cpu: 100m, memory: 256Mi } requests: { cpu: "0", memory: "0" }
limits: { memory: 1Gi } limits: { memory: 1Gi }
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
+7 -5
View File
@@ -24,20 +24,22 @@ spec:
seccompProfile: { type: RuntimeDefault } seccompProfile: { type: RuntimeDefault }
initContainers: initContainers:
- name: migrate - name: migrate
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest # Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
imagePullPolicy: Always image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
imagePullPolicy: IfNotPresent
command: ["prisma", "migrate", "deploy"] command: ["prisma", "migrate", "deploy"]
envFrom: envFrom:
- secretRef: { name: artista-modelcamp-secrets } - secretRef: { name: artista-modelcamp-secrets }
resources: resources:
requests: { cpu: "0", memory: "0" }
limits: { memory: 512Mi } limits: { memory: 512Mi }
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] } capabilities: { drop: ["ALL"] }
containers: containers:
- name: app - name: app
image: git.encry.de/nick.rosenloecher/artista-modelcamp:latest image: git.encry.de/nick.rosenloecher/artista-modelcamp:sha-REPLACE_ME
imagePullPolicy: Always imagePullPolicy: IfNotPresent
ports: ports:
- name: http - name: http
containerPort: 3000 containerPort: 3000
@@ -52,7 +54,7 @@ spec:
initialDelaySeconds: 20 initialDelaySeconds: 20
periodSeconds: 20 periodSeconds: 20
resources: resources:
requests: { cpu: 200m, memory: 256Mi } requests: { cpu: "0", memory: "0" }
limits: { memory: 768Mi } limits: { memory: 768Mi }
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
+14 -5
View File
@@ -1,5 +1,7 @@
# Alle 10 Minuten: faellige Raten einziehen, abgelaufene Reservierungen # Alle 10 Minuten: abgelaufene Reservierungen freigeben, fehlende
# freigeben, E-Mails nachsenden. Ohne ihn funktioniert Ratenzahlung nicht. # Stripe-Ratenplaene anlegen, ueberfaellige Raten mit Stripe abgleichen,
# E-Mails nachsenden (Einzug der Folgeraten macht Stripe Billing,
# docs/ARCHITEKTUR.md ADR-3). Ohne ihn funktioniert Ratenzahlung nicht.
apiVersion: batch/v1 apiVersion: batch/v1
kind: CronJob kind: CronJob
metadata: metadata:
@@ -10,8 +12,13 @@ spec:
concurrencyPolicy: Forbid concurrencyPolicy: Forbid
jobTemplate: jobTemplate:
spec: spec:
backoffLimit: 1 # Kein Sofort-Neustart: der naechste Lauf kommt nach 10 Minuten ohnehin,
# ein zweiter Pod je Fehlschlag verdoppelt nur Fehler-Pods und Versuche.
backoffLimit: 0
activeDeadlineSeconds: 900 activeDeadlineSeconds: 900
# Beendete Jobs samt Pods nach einem Tag entfernen; fehlgeschlagene
# blieben sonst bis zum naechsten Fehlschlag stehen (failedJobsHistoryLimit 3).
ttlSecondsAfterFinished: 86400
template: template:
metadata: metadata:
labels: labels:
@@ -25,13 +32,15 @@ spec:
seccompProfile: { type: RuntimeDefault } seccompProfile: { type: RuntimeDefault }
containers: containers:
- name: worker - name: worker
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest # Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
imagePullPolicy: Always image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
imagePullPolicy: IfNotPresent
command: ["tsx", "scripts/worker.ts"] command: ["tsx", "scripts/worker.ts"]
envFrom: envFrom:
- configMapRef: { name: artista-modelcamp-config } - configMapRef: { name: artista-modelcamp-config }
- secretRef: { name: artista-modelcamp-secrets } - secretRef: { name: artista-modelcamp-secrets }
resources: resources:
requests: { cpu: "0", memory: "0" }
limits: { memory: 512Mi } limits: { memory: 512Mi }
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
+76
View File
@@ -0,0 +1,76 @@
# Taegliche pg_dump-Sicherung der Modelcamp-Datenbank auf die Storage Box.
# Voraussetzung im Cluster: StorageClass smb-backup (SMB 3.1.1 mit seal),
# legt pro PVC einen Ordner unter Server/Backups/<namespace>/ an.
# Aufbewahrung: 14 Tage taeglich, 8 Wochen sonntags.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: artista-db-backup
namespace: artista
spec:
accessModes: [ReadWriteMany]
storageClassName: smb-backup
resources:
requests:
storage: 10Gi
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: artista-db-backup
namespace: artista
spec:
schedule: "25 2 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 1800
template:
metadata:
labels:
app: artista-db-backup
db-client: "true"
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsUser: 70
runAsGroup: 70
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: backup
image: postgres:18.6-alpine
command:
- /bin/sh
- -ec
- |
stamp=$(date +%Y-%m-%d)
kind=daily; [ "$(date +%u)" = "7" ] && kind=weekly
mkdir -p /backups/daily /backups/weekly
pg_dump -h artista-modelcamp-postgres -Fc -f /backups/$kind/artista-$stamp.dump.tmp
mv /backups/$kind/artista-$stamp.dump.tmp /backups/$kind/artista-$stamp.dump
find /backups/daily -name '*.dump' -mtime +14 -delete
find /backups/weekly -name '*.dump' -mtime +56 -delete
ls -l /backups/$kind | tail -3
env:
- {name: PGUSER, value: artista}
- {name: PGDATABASE, value: artista}
- name: PGPASSWORD
valueFrom: {secretKeyRef: {name: artista-modelcamp-secrets, key: DB_PASSWORD}}
resources:
requests: {cpu: "0", memory: "0"}
limits: {memory: 512Mi}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
volumeMounts:
- {name: backups, mountPath: /backups}
volumes:
- name: backups
persistentVolumeClaim: {claimName: artista-db-backup}
+19 -3
View File
@@ -4,6 +4,7 @@ Rohe Manifeste wie bei Wabenhain, Nummerierung = Reihenfolge. Voraussetzungen im
- **Traefik** als Ingress-Controller (IngressClass `traefik`) - **Traefik** als Ingress-Controller (IngressClass `traefik`)
- **cert-manager** mit dem ClusterIssuer `letsencrypt-prod` - **cert-manager** mit dem ClusterIssuer `letsencrypt-prod`
- **csi-driver-smb** für die SMB-Volumes - **csi-driver-smb** für die SMB-Volumes
- die StorageClass **`smb-backup`** für den Sicherungs-CronJob (`09-db-backup-cronjob.yaml`); im EnCry-Cluster seit 12.10.2026 vorhanden
Prüfen: Prüfen:
@@ -11,8 +12,11 @@ Prüfen:
kubectl get ingressclass kubectl get ingressclass
kubectl get clusterissuer letsencrypt-prod kubectl get clusterissuer letsencrypt-prod
kubectl get csidrivers | grep smb kubectl get csidrivers | grep smb
kubectl get storageclass smb-backup
``` ```
Im EnCry-Cluster läuft die Seite unter `modelcamp.artistasphotography.de`. Der DNS-Eintrag ist bei Cloudflare bewusst nicht geproxyt (DNS-only), deshalb bekommt dieser Ingress keine `cloudflare-only`-Middleware. Im selben Namespace läuft Artista-Telefonie (Repo `Artista-TeleTracking`) mit einer eigenen Datenbank `telefonie` in `artista-modelcamp-postgres`.
## Einmalige Vorbereitung ## Einmalige Vorbereitung
1. **Images:** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` die Images `git.encry.de/nick.rosenloecher/artista-modelcamp:latest` und `…-tools:latest`. Voraussetzung ist das Repo-Secret `REGISTRY_TOKEN` (Personal Access Token mit `package`-Scope), wie bei Wabenhain. 1. **Images:** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` die Images `git.encry.de/nick.rosenloecher/artista-modelcamp:latest` und `…-tools:latest`. Voraussetzung ist das Repo-Secret `REGISTRY_TOKEN` (Personal Access Token mit `package`-Scope), wie bei Wabenhain.
@@ -22,8 +26,9 @@ kubectl get csidrivers | grep smb
helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system
``` ```
3. **NAS:** Die Ordner `artista/modelcamp-postgres` (leer) und `artista/modelcamp-media` anlegen und SMB-Verschlüsselung erlauben. `nas.example.lan` in `03-storage.yaml` anpassen. 3. **NAS:** Die Ordner `artista/modelcamp-postgres` (leer) und `artista/modelcamp-media` anlegen und SMB-Verschlüsselung erlauben. `nas.example.lan` in `03-storage.yaml` anpassen.
4. **Domain:** `modelcamp.example.org` in `01-configmap.yaml` (APP_URL) und `06-ingress.yaml` (2×) ersetzen. Der DNS-Eintrag muss auf den Cluster zeigen, sonst stellt cert-manager kein Zertifikat aus. 4. **Domain:** `modelcamp.example.org` in `01-configmap.yaml` (APP_URL) und `06-ingress.yaml` (2×) ersetzen (im EnCry-Cluster `modelcamp.artistasphotography.de`). Der DNS-Eintrag muss auf den Cluster zeigen, sonst stellt cert-manager kein Zertifikat aus.
5. **Secrets:** `cp k8s/02-secret.yaml.example k8s/02-secret.yaml` und ausfüllen. Die Datei steht in `.gitignore`, bitte **nie** mit echten Werten committen. 5. **Secrets:** `cp k8s/02-secret.yaml.example k8s/02-secret.yaml` und ausfüllen. Die Datei steht in `.gitignore`, bitte **nie** mit echten Werten committen.
6. **Image-Tag:** In `05-app-deployment.yaml` (2×) und `07-worker-cronjob.yaml` steht der Platzhalter `sha-REPLACE_ME`. Vor dem ersten Anwenden durch den Tag des letzten grünen CI-Laufs ersetzen (`sha-<commit>`, 7 Zeichen) oder nach dem Anwenden wie unter „Bei einem neuen Image“ setzen.
## Deployment (auch für einen kompletten Neuaufbau) ## Deployment (auch für einen kompletten Neuaufbau)
@@ -38,6 +43,7 @@ kubectl apply -f k8s/05-app-deployment.yaml
kubectl apply -f k8s/06-ingress.yaml kubectl apply -f k8s/06-ingress.yaml
kubectl apply -f k8s/07-worker-cronjob.yaml kubectl apply -f k8s/07-worker-cronjob.yaml
kubectl apply -f k8s/08-networkpolicy.yaml kubectl apply -f k8s/08-networkpolicy.yaml
kubectl apply -f k8s/09-db-backup-cronjob.yaml
``` ```
Die Migration läuft automatisch als initContainer vor dem App-Start. Den Stand zeigen: Die Migration läuft automatisch als initContainer vor dem App-Start. Den Stand zeigen:
@@ -47,7 +53,7 @@ kubectl rollout status deployment/artista-modelcamp-app -n artista
kubectl get certificate -n artista # READY muss True werden (meist < 2 Minuten) kubectl get certificate -n artista # READY muss True werden (meist < 2 Minuten)
``` ```
**Neuaufbau mit `kubectl delete namespace artista`:** Die PersistentVolumes (`artista-modelcamp-postgres`, `artista-modelcamp-media`) gehören keinem Namespace und bleiben auf `Released` stehen. Die Daten auf dem NAS bleiben erhalten (`Retain`). Damit die neuen Claims sie wieder binden können, vor dem erneuten Anlegen: **Neuaufbau mit `kubectl delete namespace artista`:** Achtung, das löscht auch Artista-Telefonie samt deren Secret; deren Datenbank liegt auf demselben Postgres-Volume. Die PersistentVolumes (`artista-modelcamp-postgres`, `artista-modelcamp-media`) gehören keinem Namespace und bleiben auf `Released` stehen. Die Daten auf dem NAS bleiben erhalten (`Retain`). Damit die neuen Claims sie wieder binden können, vor dem erneuten Anlegen:
```bash ```bash
kubectl patch pv artista-modelcamp-postgres -p '{"spec":{"claimRef":null}}' kubectl patch pv artista-modelcamp-postgres -p '{"spec":{"claimRef":null}}'
@@ -68,10 +74,20 @@ kubectl -n artista run admin-create --rm -i --restart=Never \
## Bei einem neuen Image (Redeploy) ## Bei einem neuen Image (Redeploy)
Die Manifeste sind auf einen Build gepinnt, nicht auf `:latest`. Den Tag `sha-<commit>` (7 Zeichen) zeigt der CI-Lauf in der Zusammenfassung. App und Werkzeuge immer mit demselben Tag ausrollen:
```bash ```bash
kubectl rollout restart deployment/artista-modelcamp-app -n artista TAG=sha-<commit>
kubectl -n artista set image deployment/artista-modelcamp-app \
migrate=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG \
app=git.encry.de/nick.rosenloecher/artista-modelcamp:$TAG
kubectl -n artista set image cronjob/artista-modelcamp-worker \
worker=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG
kubectl -n artista rollout status deployment/artista-modelcamp-app
``` ```
Zurueck auf den vorherigen Stand: `kubectl -n artista rollout undo deployment/artista-modelcamp-app` (den CronJob dann wieder auf den alten Tag setzen).
## Fehlersuche ## Fehlersuche
```bash ```bash
+3 -3
View File
@@ -27,14 +27,14 @@
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"better-auth": "1.7.6", "better-auth": "1.7.6",
"motion": "13.4.4", "motion": "13.4.4",
"next": "16.3.6", "next": "16.3.8",
"nodemailer": "10.0.10", "nodemailer": "10.0.10",
"pg": "8.23.0", "pg": "8.23.0",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"server-only": "0.0.1", "server-only": "0.0.1",
"sharp": "0.35.4", "sharp": "0.35.5",
"stripe": "22.6.2", "stripe": "22.6.2",
"zod": "4.6.5" "zod": "4.6.5"
}, },
@@ -51,7 +51,7 @@
"dotenv": "18.0.4", "dotenv": "18.0.4",
"embedded-postgres": "18.4.0-beta.17", "embedded-postgres": "18.4.0-beta.17",
"eslint": "^9.39.5", "eslint": "^9.39.5",
"eslint-config-next": "16.3.6", "eslint-config-next": "16.3.8",
"ffmpeg-static": "5.3.0", "ffmpeg-static": "5.3.0",
"prisma": "7.10.0", "prisma": "7.10.0",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.3",
+217 -220
View File
@@ -162,6 +162,12 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
mysql2: '>=3.23.1'
deepmerge-ts: '>=8.0.0'
sharp: '>=0.35.5'
source-map-js: '>=1.2.2'
importers: importers:
.: .:
@@ -171,16 +177,16 @@ importers:
version: 7.10.0 version: 7.10.0
'@prisma/client': '@prisma/client':
specifier: 7.10.0 specifier: 7.10.0
version: 7.10.0([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]) version: 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
better-auth: better-auth:
specifier: 1.7.6 specifier: 1.7.6
version: 1.7.6(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))(mysql2@3.15.3)([email protected].6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))) version: 1.7.6(@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))(mysql2@3.24.5(@types/[email protected]))([email protected].8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])))
motion: motion:
specifier: 13.4.4 specifier: 13.4.4
version: 13.4.4([email protected]([email protected]))([email protected]) version: 13.4.4([email protected]([email protected]))([email protected])
next: next:
specifier: 16.3.6 specifier: 16.3.8
version: 16.3.6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]) version: 16.3.8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
nodemailer: nodemailer:
specifier: 10.0.10 specifier: 10.0.10
version: 10.0.10 version: 10.0.10
@@ -200,8 +206,8 @@ importers:
specifier: 0.0.1 specifier: 0.0.1
version: 0.0.1 version: 0.0.1
sharp: sharp:
specifier: 0.35.4 specifier: '>=0.35.5'
version: 0.35.4(@types/[email protected]) version: 0.35.5(@types/[email protected])
stripe: stripe:
specifier: 22.6.2 specifier: 22.6.2
version: 22.6.2(@types/[email protected]) version: 22.6.2(@types/[email protected])
@@ -246,14 +252,14 @@ importers:
specifier: ^9.39.5 specifier: ^9.39.5
version: 9.39.5([email protected])([email protected]) version: 9.39.5([email protected])([email protected])
eslint-config-next: eslint-config-next:
specifier: 16.3.6 specifier: 16.3.8
version: 16.3.6(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected]) version: 16.3.8(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected])
ffmpeg-static: ffmpeg-static:
specifier: 5.3.0 specifier: 5.3.0
version: 5.3.0([email protected]) version: 5.3.0([email protected])
prisma: prisma:
specifier: 7.10.0 specifier: 7.10.0
version: 7.10.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]) version: 7.10.0(@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
tailwindcss: tailwindcss:
specifier: 4.3.3 specifier: 4.3.3
version: 4.3.3 version: 4.3.3
@@ -726,160 +732,160 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
os: [freebsd] os: [freebsd]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/[email protected].3': '@img/[email protected].4':
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [wasm32] cpu: [wasm32]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
engines: {node: ^20.9.0} engines: {node: ^20.9.0}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@img/[email protected].4': '@img/[email protected].5':
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -907,60 +913,60 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==} resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==}
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/[email protected].6': '@next/[email protected].8':
resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -1788,7 +1794,7 @@ packages:
drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
mongodb: ^6.0.0 || ^7.0.0 mongodb: ^6.0.0 || ^7.0.0
mysql2: ^3.0.0 mysql2: '>=3.23.1'
next: ^14.0.0 || ^15.0.0 || ^16.0.0 next: ^14.0.0 || ^15.0.0 || ^16.0.0
pg: ^8.0.0 pg: ^8.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
@@ -2038,9 +2044,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deepmerge-ts@7.1.5: deepmerge-ts@8.0.2:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.9.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
@@ -2056,10 +2062,6 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
[email protected]:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
[email protected]: [email protected]:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
@@ -2174,8 +2176,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
[email protected].6: [email protected].8:
resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==} resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==}
peerDependencies: peerDependencies:
eslint: '>=9.0.0' eslint: '>=9.0.0'
typescript: '>=3.3.1' typescript: '>=3.3.1'
@@ -2956,9 +2958,11 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mysql2@3.15.3: mysql2@3.24.5:
resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} resolution: {integrity: sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==}
engines: {node: '>= 8.0'} engines: {node: '>= 8.0'}
peerDependencies:
'@types/node': '>= 8'
[email protected]: [email protected]:
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
@@ -2981,8 +2985,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
[email protected].6: [email protected].8:
resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -3362,9 +3366,6 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
[email protected]: [email protected]:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
@@ -3386,8 +3387,8 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected].4: [email protected].5:
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
peerDependencies: peerDependencies:
'@types/node': '*' '@types/node': '*'
@@ -3429,17 +3430,17 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
[email protected].1: [email protected].2:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
sql[email protected]: sql[email protected]:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==}
engines: {node: '>= 0.6'} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -3938,13 +3939,13 @@ snapshots:
'@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]) '@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
'@better-auth/utils': 0.4.2 '@better-auth/utils': 0.4.2
'@better-auth/[email protected](@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))': '@better-auth/[email protected](@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))':
dependencies: dependencies:
'@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]) '@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
'@better-auth/utils': 0.4.2 '@better-auth/utils': 0.4.2
optionalDependencies: optionalDependencies:
'@prisma/client': 7.10.0([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]) '@prisma/client': 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
prisma: 7.10.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]) prisma: 7.10.0(@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
'@better-auth/[email protected](@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@better-fetch/[email protected])': '@better-auth/[email protected](@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@better-fetch/[email protected])':
dependencies: dependencies:
@@ -4171,108 +4172,108 @@ snapshots:
'@img/[email protected]': {} '@img/[email protected]': {}
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.3 '@img/sharp-libvips-darwin-arm64': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.3 '@img/sharp-libvips-darwin-x64': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
dependencies: dependencies:
'@img/sharp-wasm32': 0.35.4 '@img/sharp-wasm32': 0.35.5
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].3': '@img/[email protected].4':
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.3 '@img/sharp-libvips-linux-arm64': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.3 '@img/sharp-libvips-linux-arm': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.3 '@img/sharp-libvips-linux-ppc64': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.3 '@img/sharp-libvips-linux-riscv64': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.3 '@img/sharp-libvips-linux-s390x': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.3 '@img/sharp-libvips-linux-x64': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3 '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.3 '@img/sharp-libvips-linuxmusl-x64': 1.3.4
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
dependencies: dependencies:
'@emnapi/runtime': 1.11.3 '@emnapi/runtime': 1.11.3
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
dependencies: dependencies:
'@img/sharp-wasm32': 0.35.4 '@img/sharp-wasm32': 0.35.5
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optional: true optional: true
'@img/[email protected].4': '@img/[email protected].5':
optional: true optional: true
'@jridgewell/[email protected]': '@jridgewell/[email protected]':
@@ -4301,37 +4302,37 @@ snapshots:
'@tybys/wasm-util': 0.10.4 '@tybys/wasm-util': 0.10.4
optional: true optional: true
'@next/[email protected].6': {} '@next/[email protected].8': {}
'@next/[email protected].6([email protected]([email protected])([email protected]))': '@next/[email protected].8([email protected]([email protected])([email protected]))':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1([email protected]([email protected])([email protected])) '@eslint-community/eslint-utils': 4.9.1([email protected]([email protected])([email protected]))
fast-glob: 3.3.1 fast-glob: 3.3.1
transitivePeerDependencies: transitivePeerDependencies:
- eslint - eslint
'@next/[email protected].6': '@next/[email protected].8':
optional: true optional: true
'@next/[email protected].6': '@next/[email protected].8':
optional: true optional: true
'@next/[email protected].6': '@next/[email protected].8':
optional: true optional: true
'@next/[email protected].6': '@next/[email protected].8':
optional: true optional: true
'@next/[email protected].6': '@next/[email protected].8':
optional: true optional: true
'@next/[email protected].6': '@next/[email protected].8':
optional: true optional: true
'@next/[email protected].6': '@next/[email protected].8':
optional: true optional: true
'@next/[email protected].6': '@next/[email protected].8':
optional: true optional: true
'@noble/[email protected]': {} '@noble/[email protected]': {}
@@ -4371,17 +4372,17 @@ snapshots:
'@prisma/[email protected]': {} '@prisma/[email protected]': {}
'@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])': '@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])':
dependencies: dependencies:
'@prisma/client-runtime-utils': 7.10.0 '@prisma/client-runtime-utils': 7.10.0
optionalDependencies: optionalDependencies:
prisma: 7.10.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]) prisma: 7.10.0(@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
typescript: 5.9.3 typescript: 5.9.3
'@prisma/[email protected]': '@prisma/[email protected]':
dependencies: dependencies:
c12: 3.3.4 c12: 3.3.4
deepmerge-ts: 7.1.5 deepmerge-ts: 8.0.2
effect: 3.20.0 effect: 3.20.0
empathic: 2.0.0 empathic: 2.0.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -4584,7 +4585,7 @@ snapshots:
jiti: 2.7.0 jiti: 2.7.0
lightningcss: 1.32.0 lightningcss: 1.32.0
magic-string: 0.30.21 magic-string: 0.30.21
source-map-js: 1.2.1 source-map-js: 1.2.2
tailwindcss: 4.3.3 tailwindcss: 4.3.3
'@tailwindcss/[email protected]': '@tailwindcss/[email protected]':
@@ -5142,14 +5143,14 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected](@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))(mysql2@3.15.3)([email protected].6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))): [email protected](@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))(mysql2@3.24.5(@types/[email protected]))([email protected].8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))):
dependencies: dependencies:
'@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]) '@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
'@better-auth/drizzle-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected]) '@better-auth/drizzle-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])
'@better-auth/kysely-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])([email protected]) '@better-auth/kysely-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])([email protected])
'@better-auth/memory-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected]) '@better-auth/memory-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])
'@better-auth/mongo-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected]) '@better-auth/mongo-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])
'@better-auth/prisma-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])) '@better-auth/prisma-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))
'@better-auth/telemetry': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@better-fetch/[email protected]) '@better-auth/telemetry': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@better-fetch/[email protected])
'@better-auth/utils': 0.4.2 '@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.2 '@better-fetch/fetch': 1.3.2
@@ -5162,11 +5163,11 @@ snapshots:
nanostores: 1.5.4 nanostores: 1.5.4
zod: 4.6.5 zod: 4.6.5
optionalDependencies: optionalDependencies:
'@prisma/client': 7.10.0([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]) '@prisma/client': 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
mysql2: 3.15.3 mysql2: 3.24.5(@types/[email protected])
next: 16.3.6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]) next: 16.3.8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
pg: 8.23.0 pg: 8.23.0
prisma: 7.10.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]) prisma: 7.10.0(@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
react: 19.3.0 react: 19.3.0
react-dom: 19.3.0([email protected]) react-dom: 19.3.0([email protected])
vitest: 4.1.11(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])) vitest: 4.1.11(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
@@ -5379,7 +5380,7 @@ snapshots:
[email protected]: {} [email protected]: {}
deepmerge-ts@7.1.5: {} deepmerge-ts@8.0.2: {}
[email protected]: [email protected]:
dependencies: dependencies:
@@ -5399,8 +5400,6 @@ snapshots:
dependencies: dependencies:
robust-predicates: 3.0.3 robust-predicates: 3.0.3
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -5607,9 +5606,9 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected].6(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected]): [email protected].8(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected]):
dependencies: dependencies:
'@next/eslint-plugin-next': 16.3.6([email protected]([email protected])([email protected])) '@next/eslint-plugin-next': 16.3.8([email protected]([email protected])([email protected]))
eslint: 9.39.5([email protected])([email protected]) eslint: 9.39.5([email protected])([email protected])
eslint-import-resolver-node: 0.3.10([email protected]) eslint-import-resolver-node: 0.3.10([email protected])
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected])([email protected]))([email protected]) eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected])([email protected]))([email protected])
@@ -6406,17 +6405,16 @@ snapshots:
[email protected]: {} [email protected]: {}
mysql2@3.15.3: mysql2@3.24.5(@types/[email protected]):
dependencies: dependencies:
'@types/node': 22.20.4
aws-ssl-profiles: 1.1.2 aws-ssl-profiles: 1.1.2
denque: 2.1.0
generate-function: 2.3.1 generate-function: 2.3.1
iconv-lite: 0.7.3 iconv-lite: 0.7.3
long: 5.3.2 long: 5.3.2
lru.min: 1.1.5 lru.min: 1.1.5
named-placeholders: 1.1.6 named-placeholders: 1.1.6
seq-queue: 0.0.5 sql-escaper: 1.5.2
sqlstring: 2.3.3
[email protected]: [email protected]:
dependencies: dependencies:
@@ -6430,9 +6428,9 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected].6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]): [email protected].8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/env': 16.3.6 '@next/env': 16.3.8
'@swc/helpers': 0.5.23 '@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.26 baseline-browser-mapping: 2.11.26
caniuse-lite: 1.0.30001812 caniuse-lite: 1.0.30001812
@@ -6441,16 +6439,16 @@ snapshots:
react-dom: 19.3.0([email protected]) react-dom: 19.3.0([email protected])
styled-jsx: 5.1.6(@babel/[email protected]([email protected]))([email protected]) styled-jsx: 5.1.6(@babel/[email protected]([email protected]))([email protected])
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 16.3.6 '@next/swc-darwin-arm64': 16.3.8
'@next/swc-darwin-x64': 16.3.6 '@next/swc-darwin-x64': 16.3.8
'@next/swc-linux-arm64-gnu': 16.3.6 '@next/swc-linux-arm64-gnu': 16.3.8
'@next/swc-linux-arm64-musl': 16.3.6 '@next/swc-linux-arm64-musl': 16.3.8
'@next/swc-linux-x64-gnu': 16.3.6 '@next/swc-linux-x64-gnu': 16.3.8
'@next/swc-linux-x64-musl': 16.3.6 '@next/swc-linux-x64-musl': 16.3.8
'@next/swc-win32-arm64-msvc': 16.3.6 '@next/swc-win32-arm64-msvc': 16.3.8
'@next/swc-win32-x64-msvc': 16.3.6 '@next/swc-win32-x64-msvc': 16.3.8
'@playwright/test': 1.63.0 '@playwright/test': 1.63.0
sharp: 0.35.4(@types/[email protected]) sharp: 0.35.5(@types/[email protected])
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- '@types/node' - '@types/node'
@@ -6624,13 +6622,13 @@ snapshots:
dependencies: dependencies:
nanoid: 3.3.19 nanoid: 3.3.19
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.2
[email protected]: [email protected]:
dependencies: dependencies:
nanoid: 3.3.19 nanoid: 3.3.19
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.2
[email protected]: {} [email protected]: {}
@@ -6648,17 +6646,18 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]): [email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]):
dependencies: dependencies:
'@prisma/config': 7.10.0 '@prisma/config': 7.10.0
'@prisma/dev': 0.24.17([email protected]) '@prisma/dev': 0.24.17([email protected])
'@prisma/engines': 7.10.0 '@prisma/engines': 7.10.0
'@prisma/studio-core': 0.33.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) '@prisma/studio-core': 0.33.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
mysql2: 3.15.3 mysql2: 3.24.5(@types/[email protected])
postgres: 3.4.7 postgres: 3.4.7
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node'
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
- magicast - magicast
@@ -6822,8 +6821,6 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -6852,37 +6849,37 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
[email protected].4(@types/[email protected]): [email protected].5(@types/[email protected]):
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
semver: 7.8.5 semver: 7.8.5
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.4 '@img/sharp-darwin-arm64': 0.35.5
'@img/sharp-darwin-x64': 0.35.4 '@img/sharp-darwin-x64': 0.35.5
'@img/sharp-freebsd-wasm32': 0.35.4 '@img/sharp-freebsd-wasm32': 0.35.5
'@img/sharp-libvips-darwin-arm64': 1.3.3 '@img/sharp-libvips-darwin-arm64': 1.3.4
'@img/sharp-libvips-darwin-x64': 1.3.3 '@img/sharp-libvips-darwin-x64': 1.3.4
'@img/sharp-libvips-linux-arm': 1.3.3 '@img/sharp-libvips-linux-arm': 1.3.4
'@img/sharp-libvips-linux-arm64': 1.3.3 '@img/sharp-libvips-linux-arm64': 1.3.4
'@img/sharp-libvips-linux-ppc64': 1.3.3 '@img/sharp-libvips-linux-ppc64': 1.3.4
'@img/sharp-libvips-linux-riscv64': 1.3.3 '@img/sharp-libvips-linux-riscv64': 1.3.4
'@img/sharp-libvips-linux-s390x': 1.3.3 '@img/sharp-libvips-linux-s390x': 1.3.4
'@img/sharp-libvips-linux-x64': 1.3.3 '@img/sharp-libvips-linux-x64': 1.3.4
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3 '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
'@img/sharp-libvips-linuxmusl-x64': 1.3.3 '@img/sharp-libvips-linuxmusl-x64': 1.3.4
'@img/sharp-linux-arm': 0.35.4 '@img/sharp-linux-arm': 0.35.5
'@img/sharp-linux-arm64': 0.35.4 '@img/sharp-linux-arm64': 0.35.5
'@img/sharp-linux-ppc64': 0.35.4 '@img/sharp-linux-ppc64': 0.35.5
'@img/sharp-linux-riscv64': 0.35.4 '@img/sharp-linux-riscv64': 0.35.5
'@img/sharp-linux-s390x': 0.35.4 '@img/sharp-linux-s390x': 0.35.5
'@img/sharp-linux-x64': 0.35.4 '@img/sharp-linux-x64': 0.35.5
'@img/sharp-linuxmusl-arm64': 0.35.4 '@img/sharp-linuxmusl-arm64': 0.35.5
'@img/sharp-linuxmusl-x64': 0.35.4 '@img/sharp-linuxmusl-x64': 0.35.5
'@img/sharp-webcontainers-wasm32': 0.35.4 '@img/sharp-webcontainers-wasm32': 0.35.5
'@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-arm64': 0.35.5
'@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.4 '@img/sharp-win32-x64': 0.35.5
'@types/node': 22.20.4 '@types/node': 22.20.4
[email protected]: [email protected]:
@@ -6925,11 +6922,11 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected].1: {} [email protected].2: {}
[email protected]: {} [email protected]: {}
sql[email protected]: {} sql[email protected]: {}
[email protected]: {} [email protected]: {}
+13
View File
@@ -8,3 +8,16 @@ allowBuilds:
'@embedded-postgres/windows-x64': true '@embedded-postgres/windows-x64': true
'@embedded-postgres/linux-x64': true '@embedded-postgres/linux-x64': true
unrs-resolver: false unrs-resolver: false
# Transitive Luecken ab "high" (pnpm audit --prod, CI-Gate in
# .gitea/workflows/docker-build.yml). Gleiche Untergrenzen wie im Wabenhain-Repo.
overrides:
# mysql2 und deepmerge-ts kommen ueber die prisma-CLI (@prisma/config) herein,
# nicht ueber den Laufzeitpfad (die App nutzt @prisma/adapter-pg).
# deepmerge-ts ist ein Hauptversionssprung; "prisma generate" laeuft durch
# @prisma/config und prueft ihn bei jedem typecheck/build mit.
mysql2: '>=3.23.1'
deepmerge-ts: '>=8.0.0'
# Transitiv ueber next (next pinnt sharp sonst auf 0.35.4); reine Patch-Spruenge.
sharp: '>=0.35.5'
source-map-js: '>=1.2.2'
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Prüft im CI-Job die Grenzen des isolierten Runners (Label ci-isolated,
# Runner-Doku im VyCept-Repo, docs/BETRIEB.md 11b; Kopie von dort). Läuft als erster Schritt jedes Prüf-Jobs, damit jeder
# Runner bei jedem Lauf geprüft wird. Schlägt fehl, wenn der Job mehr darf als
# vorgesehen – oder wenn die Prüfung selbst nichts aussagen würde.
set -euo pipefail
fail() {
echo "Runner-Isolation verletzt: $*" >&2
exit 1
}
reachable() {
timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null
}
# Namen mit Wiederholung auflösen: Das DNS des Job-Netzes antwortet unter Last
# gelegentlich erst beim zweiten Versuch.
resolve() {
local address=""
for _ in 1 2 3 4 5; do
address=$(getent hosts "$1" | awk '{ print $1; exit }' || true)
if [ -n "$address" ]; then echo "$address"; return 0; fi
sleep 2
done
return 1
}
# 1. Kein Zugang zum Docker-Daemon über den Socket.
[ -z "${DOCKER_HOST:-}" ] || fail "DOCKER_HOST ist gesetzt ($DOCKER_HOST)"
for socket in /var/run/docker.sock /run/docker.sock; do
[ ! -e "$socket" ] || fail "Docker-Socket $socket ist sichtbar"
done
# 2. Kein Zugang zur Docker-API des Daemons im Runner-Pod (Gateway des Job-Netzes):
# 2375 (ohne TLS) zu, 2376 nur mit Client-Zertifikat, das der Job nicht hat.
gateway_hex=$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route)
[ -n "$gateway_hex" ] || fail "kein Standard-Gateway gefunden – Prüfung wäre wirkungslos"
gateway=$(printf '%d.%d.%d.%d' "0x${gateway_hex:6:2}" "0x${gateway_hex:4:2}" "0x${gateway_hex:2:2}" "0x${gateway_hex:0:2}")
if reachable "$gateway" 2375; then fail "Docker-API ohne TLS am Gateway $gateway:2375"; fi
if curl -sk --max-time 5 "https://$gateway:2376/version" >/dev/null 2>&1; then
fail "Docker-API am Gateway $gateway:2376 ohne Client-Zertifikat nutzbar"
fi
# 3. Kein Clusternetz: Kubernetes-API (Service und Knoten) und fremde Namespaces.
for target in 10.43.0.1:443 10.50.1.51:6443 10.50.1.52:6443 10.50.1.53:6443; do
if reachable "${target%:*}" "${target##*:}"; then fail "$target ist erreichbar"; fi
done
resolve kubernetes.default.svc.cluster.local >/dev/null || fail "Clusternamen nicht auflösbar – Prüfung wäre wirkungslos"
# Fremder Namespace (VyCept-Datenbank) und eigener Namespace (Gitea-Datenbank).
for target in vycept-postgres.vycept.svc.cluster.local:5432 gitea-postgres.gitea.svc.cluster.local:5432; do
host=${target%:*}
address=$(resolve "$host") || fail "$host nicht auflösbar – Prüfung wäre wirkungslos"
if reachable "$address" "${target##*:}"; then fail "$host ($address) ist erreichbar"; fi
done
# 4. Internet bleibt erreichbar (npm, GitHub, Images).
reachable registry.npmjs.org 443 || fail "Internet (registry.npmjs.org) nicht erreichbar"
echo "Runner-Isolation in Ordnung (Gateway $gateway, kein Socket, kein Clusternetz)"
@@ -3,11 +3,33 @@ import { notFound } from "next/navigation";
import { connection } from "next/server"; import { connection } from "next/server";
import { Flash, PageTitle } from "@/components/admin/AdminUi"; import { Flash, PageTitle } from "@/components/admin/AdminUi";
import { VoucherForm } from "@/components/admin/VoucherForm"; import { VoucherForm } from "@/components/admin/VoucherForm";
import { getCamp, salesStateFor } from "@/lib/camp";
import { getDb } from "@/lib/db"; import { getDb } from "@/lib/db";
import { stripeModeOrNull } from "@/lib/env";
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels"; import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
import { evaluateVoucher, VOUCHER_REJECTION_ADMIN_TEXT } from "@/lib/vouchers";
export const metadata = { title: "Gutschein" }; export const metadata = { title: "Gutschein" };
/**
* Prueft wie der Buchungsdialog, ob der Code gerade einloesbar ist, und nennt
* den Grund, den Kundinnen bewusst nicht sehen (neutrale Ablehnung).
* Eine E-Mail-Bindung prueft erst der Checkout.
*/
async function redeemability(code: string): Promise<{ ok: boolean; text: string }> {
const mode = stripeModeOrNull();
if (!mode) return { ok: false, text: "Nicht prüfbar: STRIPE_SECRET_KEY fehlt oder ist ungültig." };
const db = getDb();
const now = new Date();
const camp = await getCamp(db);
const evaluation = await evaluateVoucher(db, { code, email: null, now, livemode: mode === "live", quota: camp.stammmodelQuota, lock: false });
if (!evaluation.ok) return { ok: false, text: `Derzeit nicht einlösbar: ${VOUCHER_REJECTION_ADMIN_TEXT[evaluation.reason]}.` };
if (!salesStateFor(camp, mode, now).checkoutOpen) {
return { ok: false, text: "Code ist gültig, aber die Buchung ist noch nicht geöffnet. Der Dialog zeigt deshalb keinen Preis und keinen Rabatt." };
}
return { ok: true, text: "Derzeit einlösbar (10 % auf den gültigen Preis)." };
}
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) { export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
await connection(); await connection();
const { id } = await params; const { id } = await params;
@@ -17,11 +39,13 @@ export default async function VoucherPage({ params, searchParams }: { params: Pr
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } }, include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
}); });
if (!voucher) notFound(); if (!voucher) notFound();
const check = await redeemability(voucher.code);
return ( return (
<> <>
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link> <Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
<PageTitle>{voucher.code}</PageTitle> <PageTitle>{voucher.code}</PageTitle>
<Flash ok={flash.ok} fehler={flash.fehler} /> <Flash ok={flash.ok} fehler={flash.fehler} />
<p className={`mb-6 rounded-lg border p-4 ${check.ok ? "border-line" : "border-rosso/40 text-rosso"}`}>{check.text}</p>
<div className="grid gap-10 lg:grid-cols-2"> <div className="grid gap-10 lg:grid-cols-2">
<VoucherForm voucher={voucher} /> <VoucherForm voucher={voucher} />
<section> <section>
+3 -2
View File
@@ -1,10 +1,11 @@
import { toNextJsHandler } from "better-auth/next-js"; import { toNextJsHandler } from "better-auth/next-js";
import { getAuth } from "@/lib/auth"; import { getAuth } from "@/lib/auth";
import { withClientIp } from "@/lib/rate-limit";
export async function GET(request: Request) { export async function GET(request: Request) {
return toNextJsHandler(getAuth()).GET(request); return toNextJsHandler(getAuth()).GET(await withClientIp(request));
} }
export async function POST(request: Request) { export async function POST(request: Request) {
return toNextJsHandler(getAuth()).POST(request); return toNextJsHandler(getAuth()).POST(await withClientIp(request));
} }
+4 -3
View File
@@ -109,10 +109,11 @@ export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
aria-label="Tage im Camp" aria-label="Tage im Camp"
// Per Tastatur scrollbar (Pfeiltasten), WCAG 2.1.1. // Per Tastatur scrollbar (Pfeiltasten), WCAG 2.1.1.
tabIndex={0} tabIndex={0}
// Smartphone: Karten ragen bis an den Rand, die naechste lugt hervor. // Smartphone: Karte 85vw breit und mittig (7,5vw Rand links und
// rechts), Nachbarkarten lugen an beiden Seiten hervor.
// Ab md: Karten passen vollstaendig in den Inhaltsbereich (2 bzw. 3 // Ab md: Karten passen vollstaendig in den Inhaltsbereich (2 bzw. 3
// nebeneinander), nichts wird am Rand abgeschnitten. // nebeneinander), nichts wird am Rand abgeschnitten.
className="-mx-4 flex snap-x snap-mandatory scroll-px-4 gap-4 overflow-x-auto px-4 pb-4 [scrollbar-width:none] md:mx-0 md:scroll-px-0 md:gap-6 md:px-0 [&::-webkit-scrollbar]:hidden" className="-mx-4 flex snap-x snap-mandatory scroll-px-[7.5vw] gap-4 overflow-x-auto px-[7.5vw] pb-4 [scrollbar-width:none] sm:scroll-px-4 sm:px-4 md:mx-0 md:scroll-px-0 md:gap-6 md:px-0 [&::-webkit-scrollbar]:hidden"
> >
{days.map((day, index) => ( {days.map((day, index) => (
<li <li
@@ -120,7 +121,7 @@ export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
ref={(el) => { ref={(el) => {
cards.current[index] = el; cards.current[index] = el;
}} }}
className="flex w-[85%] shrink-0 snap-start flex-col rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] sm:w-[62%] md:w-[calc((100%-1.5rem)/2)] md:p-8 lg:w-[calc((100%-3rem)/3)]" className="flex w-[85vw] shrink-0 snap-start flex-col rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] sm:w-[62%] md:w-[calc((100%-1.5rem)/2)] md:p-8 lg:w-[calc((100%-3rem)/3)]"
> >
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-rosso"> <p className="text-sm font-semibold uppercase tracking-[0.16em] text-rosso">
{day.label} · {day.title} {day.label} · {day.title}
+35 -17
View File
@@ -60,7 +60,8 @@ export function BookingDialog({
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [participant, setParticipant] = useState<Participant>(EMPTY); const [participant, setParticipant] = useState<Participant>(EMPTY);
const [chosenPlan, setPlanType] = useState<PlanType>("FULL"); // Keine Vorauswahl: Preise erscheinen erst unter der gewaehlten Zahlungsweise.
const [chosenPlan, setPlanType] = useState<PlanType | null>(null);
const [voucherInput, setVoucherInput] = useState(""); const [voucherInput, setVoucherInput] = useState("");
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null); const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
const [voucherMessage, setVoucherMessage] = useState<string | null>(null); const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
@@ -114,11 +115,11 @@ export function BookingDialog({
}; };
}, [open, quote]); }, [open, quote]);
// Eine nicht (mehr) verfuegbare Ratenvariante faellt auf Einmalzahlung zurueck. // Eine nicht (mehr) verfuegbare Variante gilt als nicht gewaehlt.
const chosen = quote?.plans.find((p) => p.type === chosenPlan); const chosen = quote?.plans.find((p) => p.type === chosenPlan);
const planType: PlanType = chosen && !chosen.available ? "FULL" : chosenPlan; const planType: PlanType | null = chosen?.available ? chosenPlan : null;
const selectedPlan = quote?.plans.find((p) => p.type === planType) ?? null; const selectedPlan = chosen?.available ? chosen : null;
function updateParticipant(field: keyof Participant, value: string) { function updateParticipant(field: keyof Participant, value: string) {
setParticipant((prev) => ({ ...prev, [field]: value })); setParticipant((prev) => ({ ...prev, [field]: value }));
@@ -174,6 +175,7 @@ export function BookingDialog({
async function submit() { async function submit() {
setFormError(null); setFormError(null);
if (!planType) return;
const missing: FieldErrors = {}; const missing: FieldErrors = {};
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist."; if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
if (!consents.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen."; if (!consents.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
@@ -324,10 +326,11 @@ export function BookingDialog({
<legend className="field-label">Wie möchtest du bezahlen?</legend> <legend className="field-label">Wie möchtest du bezahlen?</legend>
<div className="mt-2 space-y-3"> <div className="mt-2 space-y-3">
{quote?.plans.map((plan) => ( {quote?.plans.map((plan) => (
<label <div
key={plan.type} key={plan.type}
className={`flex cursor-pointer gap-4 rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "cursor-not-allowed opacity-60"}`} className={`rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "opacity-60"}`}
> >
<label className={`flex gap-4 ${plan.available ? "cursor-pointer" : "cursor-not-allowed"}`}>
<input <input
type="radio" type="radio"
name="planType" name="planType"
@@ -341,15 +344,11 @@ export function BookingDialog({
/> />
<span className="flex-1"> <span className="flex-1">
<span className="block font-semibold">{plan.label}</span> <span className="block font-semibold">{plan.label}</span>
{plan.available ? ( {!plan.available && <span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>}
<span className="mt-1 block text-[0.9375rem] text-ink/80">
{plan.installments.map((i) => money(i.amountCents)).join(" + ")}
</span>
) : (
<span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>
)}
</span> </span>
</label> </label>
{plan.available && planType === plan.type && quote.price && <PlanDetails plan={plan} quote={quote} />}
</div>
)) ?? <p className="text-stone">Preise werden geladen …</p>} )) ?? <p className="text-stone">Preise werden geladen …</p>}
</div> </div>
</fieldset> </fieldset>
@@ -384,8 +383,6 @@ export function BookingDialog({
{voucherMessage} {voucherMessage}
</p> </p>
</div> </div>
{quote?.price && <PriceBreakdown quote={quote} />}
</div> </div>
)} )}
@@ -462,7 +459,7 @@ export function BookingDialog({
</a>{" "} </a>{" "}
gelesen. gelesen.
</Consent> </Consent>
{planType !== "FULL" && ( {planType !== null && planType !== "FULL" && (
<Consent <Consent
id="installmentMandate" id="installmentMandate"
checked={consents.installmentMandate} checked={consents.installmentMandate}
@@ -494,7 +491,7 @@ export function BookingDialog({
<span /> <span />
)} )}
{step < STEPS.length - 1 ? ( {step < STEPS.length - 1 ? (
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && !quote?.price}> <button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && (!quote?.price || !selectedPlan)}>
Weiter Weiter
</button> </button>
) : ( ) : (
@@ -509,6 +506,27 @@ export function BookingDialog({
); );
} }
/** Preis und Raten unter der gewaehlten Zahlungsweise. */
function PlanDetails({ plan, quote }: { plan: QuoteData["plans"][number]; quote: QuoteData }) {
return (
<div className="mt-3 pl-9">
<PriceBreakdown quote={quote} />
{plan.installments.length > 1 && (
<ul className="mt-3 space-y-1 text-[0.9375rem] text-ink/80">
{plan.installments.map((i) => (
<li key={i.seq} className="flex justify-between gap-4">
<span>
Rate {i.seq}, {i.dueDate ? `fällig ${isoDate(i.dueDate)}` : "heute"}
</span>
<span className="tabular-nums">{money(i.amountCents)}</span>
</li>
))}
</ul>
)}
</div>
);
}
function PriceBreakdown({ quote }: { quote: QuoteData }) { function PriceBreakdown({ quote }: { quote: QuoteData }) {
const p = quote.price!; const p = quote.price!;
return ( return (
+2 -2
View File
@@ -101,8 +101,8 @@ export function ContactSection() {
{error("email")} {error("email")}
</div> </div>
<div> <div>
<label className="field-label" htmlFor="ct-phone">Telefon (optional)</label> <label className="field-label" htmlFor="ct-phone">Telefon</label>
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" maxLength={60} /> <input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" required maxLength={40} />
{error("phone")} {error("phone")}
</div> </div>
<div> <div>
@@ -33,8 +33,9 @@ export function IntroVideoSection({ video, showDrafts }: { video: IntroVideo; sh
playsInline playsInline
preload={poster ? "none" : "metadata"} preload={poster ? "none" : "metadata"}
aria-label="Video ARTISTA Modelcamp" aria-label="Video ARTISTA Modelcamp"
// Eigenes Seitenverhaeltnis (quer oder hoch), wie bei den Reels. // Groesse folgt dem Video (quer oder hoch): ohne volle Breite keine
className="mx-auto block max-h-[85vh] w-full rounded-2xl bg-black object-contain" // schwarzen Balken neben einem Hochformat-Video.
className="mx-auto block h-auto max-h-[85vh] w-auto max-w-full rounded-2xl bg-black"
> >
<track kind="captions" /> <track kind="captions" />
</video> </video>
+1 -1
View File
@@ -44,7 +44,7 @@ export function TeamSection({ showDrafts, portraits }: { showDrafts: boolean; po
<ul className="mt-20 grid grid-cols-2 gap-x-4 gap-y-12 md:mt-28 md:grid-cols-4 md:gap-x-8 md:gap-y-16"> <ul className="mt-20 grid grid-cols-2 gap-x-4 gap-y-12 md:mt-28 md:grid-cols-4 md:gap-x-8 md:gap-y-16">
{team.people.map((person: TeamMember, index) => ( {team.people.map((person: TeamMember, index) => (
<li key={person.slug} className={index % 2 === 1 ? "md:mt-16" : ""}> <li key={person.slug}>
<article aria-label={person.name}> <article aria-label={person.name}>
<Reveal delay={(index % 4) * 0.06}> <Reveal delay={(index % 4) * 0.06}>
<MediaFrame media={portraitOf(person)} aspect="aspect-[3/4]" shape="arch" sizes="(min-width: 768px) 22vw, 50vw" showLabel={false} /> <MediaFrame media={portraitOf(person)} aspect="aspect-[3/4]" shape="arch" sizes="(min-width: 768px) 22vw, 50vw" showLabel={false} />
+2 -1
View File
@@ -7,6 +7,7 @@ import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { Actor } from "./audit"; import type { Actor } from "./audit";
import { getAuth } from "./auth"; import { getAuth } from "./auth";
import { headersWithClientIp } from "./rate-limit";
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean }; export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
@@ -15,7 +16,7 @@ async function currentAdmin(): Promise<AdminUser | null> {
// Sonst versucht "next build" sie vorab zu rendern und scheitert ohne // Sonst versucht "next build" sie vorab zu rendern und scheitert ohne
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET). // Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
const requestHeaders = await headers(); const requestHeaders = await headers();
const session = await getAuth().api.getSession({ headers: requestHeaders }); const session = await getAuth().api.getSession({ headers: headersWithClientIp(requestHeaders) });
if (!session) return null; if (!session) return null;
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null }; const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null };
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) }; return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
+2 -2
View File
@@ -7,7 +7,7 @@ import { NextResponse } from "next/server";
import type { z } from "zod"; import type { z } from "zod";
import { getDb } from "./db"; import { getDb } from "./db";
import { env, requireAppSecret } from "./env"; import { env, requireAppSecret } from "./env";
import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit"; import { clientIp, consumeRateLimit, rateLimitSubject, type RateLimitRule } from "./rate-limit";
export type ApiError = { code: string; message: string; fields?: Record<string, string> }; export type ApiError = { code: string; message: string; fields?: Record<string, string> };
@@ -71,7 +71,7 @@ export async function readJson<T extends z.ZodType>(
} }
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> { export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret()); const result = await consumeRateLimit(getDb(), rule, rateLimitSubject(clientIp(request.headers)), requireAppSecret());
if (result.allowed) return null; if (result.allowed) return null;
return fail( return fail(
429, 429,
+4 -1
View File
@@ -17,6 +17,7 @@ import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
import { twoFactor } from "better-auth/plugins/two-factor"; import { twoFactor } from "better-auth/plugins/two-factor";
import { getDb } from "./db"; import { getDb } from "./db";
import { env } from "./env"; import { env } from "./env";
import { CLIENT_IP_HEADER } from "./rate-limit";
function createAuth(options: { allowSignUp?: boolean } = {}) { function createAuth(options: { allowSignUp?: boolean } = {}) {
const e = env(); const e = env();
@@ -51,7 +52,9 @@ function createAuth(options: { allowSignUp?: boolean } = {}) {
}, },
advanced: { advanced: {
useSecureCookies: e.APP_URL.startsWith("https://"), useSecureCookies: e.APP_URL.startsWith("https://"),
ipAddress: { ipAddressHeaders: ["x-real-ip", "x-forwarded-for"] }, // Nur die vom Server ermittelte IP (withClientIp im Auth-Endpunkt), nie X-Real-IP
// oder X-Forwarded-For direkt: ueber Cloudflare kaemen sie vom Client.
ipAddress: { ipAddressHeaders: [CLIENT_IP_HEADER] },
}, },
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()], plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
}); });
+7 -1
View File
@@ -28,7 +28,13 @@ const optionalText = (max: number) =>
export const contactRequestSchema = z.object({ export const contactRequestSchema = z.object({
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120), name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
email: z.string().trim().max(200).pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")), email: z.string().trim().max(200).pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")),
phone: optionalText(60), // Gleiche Regel wie bei der Buchung.
phone: z
.string("Bitte gib eine Telefonnummer an.")
.trim()
.min(6, "Bitte gib eine Telefonnummer an.")
.max(40)
.regex(/^[+0-9 ()/-]+$/, "Die Telefonnummer darf nur Ziffern, Leerzeichen und + ( ) / - enthalten."),
availability: optionalText(200), availability: optionalText(200),
message: optionalText(2000), message: optionalText(2000),
privacy: z.literal(true, { error: "Bitte bestätige die Datenschutzhinweise." }), privacy: z.literal(true, { error: "Bitte bestätige die Datenschutzhinweise." }),
+27 -3
View File
@@ -85,6 +85,28 @@ async function deliver(message: { id: string; toAddress: string; subject: string
}); });
} }
const RECIPIENT_REJECTION_CODES = new Set([550, 551, 553, 556]);
/**
* Endgueltig ist nur eine Ablehnung des Empfaengers (nodemailer EENVELOPE, RCPT TO):
* 550/551/553/556 oder 5xx mit Status 5.1.x (Adresse/Domain falsch). Alles andere
* (Anmeldung EAUTH, Richtlinie/Blocklist EMESSAGE, 552, 4xx, Verbindung) liegt am
* Server oder Absender; die Mail bleibt PENDING und wird bis MAX_EMAIL_ATTEMPTS
* wiederholt, damit ein kaputtes SMTP-Secret keine Mails endgueltig verwirft.
*/
export function isPermanentMailError(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false;
const { code, responseCode, response } = error as { code?: unknown; responseCode?: unknown; response?: unknown };
if (code !== "EENVELOPE" || typeof responseCode !== "number") return false;
if (RECIPIENT_REJECTION_CODES.has(responseCode)) return true;
return responseCode >= 500 && responseCode < 600 && typeof response === "string" && /(^|\s)5\.1\.\d+(\s|$)/.test(response);
}
/** SMTP-Fehlermeldungen nennen den Empfaenger; ins Log und in lastError kommt er nicht. */
export function redactAddresses(text: string): string {
return text.replace(/[^\s<>"',;:()]+@[^\s<>"',;:()]+/g, "[Adresse]");
}
/** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */ /** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> { export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
const pending = await db.emailMessage.findMany({ const pending = await db.emailMessage.findMany({
@@ -111,15 +133,17 @@ export async function flushEmails(db: Db, params: { bookingId?: string; limit?:
sent++; sent++;
} catch (error) { } catch (error) {
failed++; failed++;
const reason = error instanceof Error ? error.message.slice(0, 300) : "unbekannter Fehler"; const permanent = isPermanentMailError(error);
const reason = redactAddresses(error instanceof Error ? error.message : "unbekannter Fehler").slice(0, 300);
await db.emailMessage.update({ await db.emailMessage.update({
where: { id: message.id }, where: { id: message.id },
data: { data: {
lastError: reason, lastError: reason,
status: message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING", status: permanent || message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING",
}, },
}); });
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}): ${reason}`); const final = permanent ? ", endgueltig abgelehnt" : "";
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}${final}): ${reason}`);
} }
} }
return { sent, failed }; return { sent, failed };
+104 -7
View File
@@ -5,6 +5,7 @@
* *
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon. * Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
*/ */
import { BlockList, isIP } from "node:net";
import type { Db } from "./db"; import type { Db } from "./db";
import { hmacHex } from "./tokens"; import { hmacHex } from "./tokens";
@@ -51,14 +52,110 @@ export async function purgeRateLimits(db: Db): Promise<number> {
return db.$executeRaw`DELETE FROM "RateLimitBucket" WHERE "windowStart" < now() - interval '1 day'`; return db.$executeRaw`DELETE FROM "RateLimitBucket" WHERE "windowStart" < now() - interval '1 day'`;
} }
/** Vom Server gesetzte Client-IP fuer better-auth (withClientIp), nie vom Client uebernommen. */
export const CLIENT_IP_HEADER = "x-artista-client-ip";
/** https://www.cloudflare.com/ips/ (Stand 2026-10). */
const CLOUDFLARE: [string, number, "ipv4" | "ipv6"][] = [
...["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22"].map(
(cidr) => [cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv4"] as [string, number, "ipv4"],
),
...["2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32"].map(
(cidr) => [cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv6"] as [string, number, "ipv6"],
),
];
const cloudflare = new BlockList();
for (const [net, prefix, family] of CLOUDFLARE) cloudflare.addSubnet(net, prefix, family);
/** Nicht oeffentliche Bereiche; 240/4 nutzt Cloudflare fuer "Pseudo IPv4". */
const NON_PUBLIC_V4 = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "240.0.0.0/4"];
const nonPublic = new BlockList();
for (const cidr of NON_PUBLIC_V4) nonPublic.addSubnet(cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv4");
nonPublic.addAddress("255.255.255.255", "ipv4");
nonPublic.addAddress("::", "ipv6");
nonPublic.addAddress("::1", "ipv6");
nonPublic.addSubnet("fc00::", 7, "ipv6");
nonPublic.addSubnet("fe80::", 10, "ipv6");
function isPublicUnicast(ip: string): boolean {
if (isIP(ip) === 4) return !nonPublic.check(ip, "ipv4");
const groups = ipv6Groups(ip);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
const [high = 0, low = 0] = groups.slice(6);
return !nonPublic.check([high >> 8, high & 0xff, low >> 8, low & 0xff].join("."), "ipv4");
}
return !nonPublic.check(ip, "ipv6");
}
function validIp(value: string | null | undefined): string | null {
const ip = value?.trim();
return ip && isIP(ip) ? ip : null;
}
/** /**
* Client-IP hinter Traefik. Traefik setzt X-Real-Ip selbst und ersetzt * Client-IP hinter Traefik. Gegenstelle ist der letzte Eintrag in
* X-Forwarded-For von nicht vertrauenswuerdigen Absendern (EntryPoint- * X-Forwarded-For, den Traefik aus der TCP-Verbindung anhaengt; alles links
* Einstellung forwardedHeaders.trustedIPs, siehe docs/BETRIEB.md). * davon und X-Real-IP kann vom Client stammen, sobald die Anfrage ueber
* Cloudflare kommt (Traefik vertraut Cloudflare, forwardedHeaders.trustedIPs).
* Ist die Gegenstelle eine Cloudflare-Edge, gilt CF-Connecting-IP. Ohne
* X-Forwarded-For (kein Traefik davor, lokal) gilt X-Real-IP.
*/ */
export function clientIp(headers: Headers): string { export function clientIp(headers: Headers): string {
const realIp = headers.get("x-real-ip")?.trim(); const forwarded = headers.get("x-forwarded-for");
if (realIp) return realIp; const realIp = process.env.NODE_ENV === "production" ? null : validIp(headers.get("x-real-ip"));
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim(); const peer = forwarded === null ? realIp : validIp(forwarded.split(",").at(-1));
return forwarded || "unknown"; if (!peer) return "unknown";
const viaCloudflare = validIp(headers.get("cf-connecting-ip"));
return viaCloudflare && isPublicUnicast(viaCloudflare) && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
}
/** Die acht 16-Bit-Gruppen einer gueltigen IPv6-Adresse; eine IPv4-Endung (::ffff:a.b.c.d) wird umgerechnet. */
function ipv6Groups(ip: string): number[] {
let text = ip.toLowerCase().split("%")[0] ?? "";
const dotted = /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
if (dotted) {
const [a = 0, b = 0, c = 0, d = 0] = dotted.slice(1).map(Number);
text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
}
const parse = (part: string) => (part ? part.split(":").map((group) => Number.parseInt(group, 16)) : []);
const [head = "", tail] = text.split("::");
const left = parse(head);
if (tail === undefined) return left;
const right = parse(tail);
return [...left, ...new Array<number>(8 - left.length - right.length).fill(0), ...right];
}
/**
* Schluessel fuer consumeRateLimit: IPv4 unveraendert, IPv6 als /64-Netz. Ein
* Anschluss bekommt meist ein ganzes /64; einzeln gezaehlt liesse sich jedes
* Limit durch Wechsel der Adresse innerhalb des eigenen Netzes umgehen.
*/
export function rateLimitSubject(ip: string): string {
if (isIP(ip) !== 6) return ip;
const groups = ipv6Groups(ip);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
const [high = 0, low = 0] = groups.slice(6);
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
}
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(":")}::/64`;
}
/** Kopie der Header mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */
export function headersWithClientIp(source: Headers): Headers {
const headers = new Headers(source);
headers.set(CLIENT_IP_HEADER, clientIp(source));
return headers;
}
/**
* Kopie der Anfrage mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt.
*
* Bewusst aus URL, Methode und Body neu gebaut statt `new Request(request, …)`:
* Next reicht Route-Handlern einen Proxy um NextRequest, und undici 7 (Node 24)
* prueft private Felder (#state), die ein Proxy nicht hat ("Cannot read private
* member #state"). Unter Node 22 (undici 6, Symbole) fiel das nicht auf.
*/
export async function withClientIp(request: Request): Promise<Request> {
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer();
return new Request(request.url, { method: request.method, headers: headersWithClientIp(request.headers), body, signal: request.signal });
} }
+4 -1
View File
@@ -107,8 +107,11 @@ export function releaseBlockers(camp: CampGateFields): ReleaseBlocker[] {
export function evaluateSales(now: Date, camp: CampGateFields, mode: StripeMode): SalesState { export function evaluateSales(now: Date, camp: CampGateFields, mode: StripeMode): SalesState {
const phase = pricePhaseAt(now); const phase = pricePhaseAt(now);
const simulate = mode === "test" && camp.testPhaseOverride !== null && camp.testPhaseOverride !== "PREVIEW"; const simulate = mode === "test" && camp.testPhaseOverride !== null && camp.testPhaseOverride !== "PREVIEW";
const effectivePhase = simulate ? camp.testPhaseOverride! : phase;
const blockers = releaseBlockers(camp); const blockers = releaseBlockers(camp);
// Sind alle Freigaben bestaetigt, startet der Verkauf sofort, auch vor dem
// 01.11.2026: Bis dahin gilt der Early-Bird-Preis.
const releasedEarly = phase === "PREVIEW" && blockers.length === 0;
const effectivePhase = simulate ? camp.testPhaseOverride! : releasedEarly ? "EARLY_BIRD" : phase;
const campStarted = camp.campStartDate !== null && now.getTime() >= berlinStartOfDay(camp.campStartDate).getTime(); const campStarted = camp.campStartDate !== null && now.getTime() >= berlinStartOfDay(camp.campStartDate).getTime();
let closedReason: SalesState["closedReason"] = null; let closedReason: SalesState["closedReason"] = null;
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { CLIENT_IP_HEADER, clientIp, headersWithClientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit";
const h = (values: Record<string, string>) => new Headers(values);
describe("clientIp hinter Traefik", () => {
test("Gegenstelle ist der letzte Eintrag in X-Forwarded-For, nie ein Eintrag des Clients", () => {
expect(clientIp(h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", "x-real-ip": "1.2.3.4" }))).toBe("198.51.100.9");
});
test("über Cloudflare gefälschtes X-Real-IP zählt nicht, CF-Connecting-IP nur von Cloudflare-Edges", () => {
const viaCloudflare = { "x-real-ip": "1.2.3.4", "x-forwarded-for": "1.2.3.4, 203.0.113.7, 172.70.1.2", "cf-connecting-ip": "203.0.113.7" };
expect(clientIp(h(viaCloudflare))).toBe("203.0.113.7");
expect(clientIp(h({ "x-forwarded-for": "198.51.100.9", "cf-connecting-ip": "203.0.113.7" }))).toBe("198.51.100.9");
});
test("ohne X-Forwarded-For (lokal) gilt X-Real-IP, ungültige Werte ergeben „unknown“", () => {
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
expect(clientIp(h({}))).toBe("unknown");
expect(clientIp(h({ "x-forwarded-for": "1.2.3.4, kein-ip" }))).toBe("unknown");
});
test("Rate-Limits zählen IPv6 je /64-Netz, IPv4-gemappte Adressen wie IPv4", () => {
expect(rateLimitSubject(clientIp(h({ "x-forwarded-for": "2001:db8:1:2:aaaa::1" })))).toBe("2001:db8:1:2::/64");
expect(rateLimitSubject("2001:0DB8:0001:0002:ffff:ffff:ffff:ffff")).toBe("2001:db8:1:2::/64");
expect(rateLimitSubject("2001:db8:1:3::1")).not.toBe(rateLimitSubject("2001:db8:1:2::1"));
expect(rateLimitSubject("::ffff:198.51.100.9")).toBe("198.51.100.9");
expect(rateLimitSubject("198.51.100.9")).toBe("198.51.100.9");
});
test("better-auth bekommt eine einzelne gültige IP (es normalisiert IPv6 selbst auf /64)", async () => {
const request = new Request("https://example.org/api/auth/sign-in/email", { headers: { "x-forwarded-for": "2001:db8:1:2:aaaa::1" } });
expect((await withClientIp(request)).headers.get(CLIENT_IP_HEADER)).toBe("2001:db8:1:2:aaaa::1");
});
test("withClientIp setzt den Header für better-auth und überschreibt einen mitgeschickten Wert", async () => {
const request = new Request("https://example.org/api/auth/sign-in/email", {
method: "POST",
headers: { "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" },
body: "{}",
});
expect((await withClientIp(request)).headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
});
test("withClientIp verträgt Nexts Proxy um NextRequest (Node 24, undici 7) und behält Methode und Body", async () => {
// Next 16 reicht Route-Handlern einen Proxy, dessen Methoden an das Original gebunden sind.
// undici 7 prüft private Felder (#state); ein Proxy besteht diese Prüfung nicht.
const original = new Request("https://example.org/api/auth/sign-in/email", {
method: "POST",
headers: { "x-forwarded-for": "198.51.100.9", "content-type": "application/json" },
body: '{"email":"[email protected]"}',
});
const wieNext = new Proxy(original, {
get(target, key) {
const wert = Reflect.get(target, key, target);
return typeof wert === "function" ? wert.bind(target) : wert;
},
});
const kopie = await withClientIp(wieNext);
expect(kopie.method).toBe("POST");
expect(kopie.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
expect(await kopie.text()).toBe('{"email":"[email protected]"}');
});
});
describe("CF-Connecting-IP nur als öffentliche Unicast-Adresse", () => {
const edge = "172.70.1.2";
const viaEdge = (cf: string) => clientIp(h({ "x-forwarded-for": `1.2.3.4, ${edge}`, "cf-connecting-ip": cf }));
test.each([
"0.1.2.3", "10.0.0.5", "100.64.0.1", "100.127.255.255", "127.0.0.1", "169.254.1.1", "172.16.0.1", "172.31.255.255",
"192.168.1.1", "240.0.0.1", "255.255.255.255", "::", "::1", "fc00::1", "fd12::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:192.168.0.1",
])("%s wird verworfen, die Gegenstelle gilt", (cf) => {
expect(viaEdge(cf)).toBe(edge);
});
test.each(["203.0.113.7", "100.63.255.255", "172.32.0.1", "2001:db8::1", "::ffff:203.0.113.7"])("%s wird übernommen", (cf) => {
expect(viaEdge(cf)).toBe(cf);
});
});
describe("X-Real-IP in Produktion", () => {
afterEach(() => vi.unstubAllEnvs());
test("ohne X-Forwarded-For ergibt X-Real-IP in Produktion „unknown“", () => {
vi.stubEnv("NODE_ENV", "production");
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("unknown");
expect(clientIp(h({ "x-forwarded-for": "198.51.100.9" }))).toBe("198.51.100.9");
});
test("außerhalb von Produktion gilt X-Real-IP weiter", () => {
vi.stubEnv("NODE_ENV", "development");
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
});
});
describe("headersWithClientIp", () => {
test("überschreibt einen mitgeschickten Wert und lässt das Original unverändert", () => {
const original = h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" });
const result = headersWithClientIp(original);
expect(result.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
expect(original.get(CLIENT_IP_HEADER)).toBe("1.2.3.4");
});
test("ohne ermittelbare IP steht „unknown“ im Header, nie der Clientwert", () => {
expect(headersWithClientIp(h({ [CLIENT_IP_HEADER]: "1.2.3.4" })).get(CLIENT_IP_HEADER)).toBe("unknown");
});
});
+6 -2
View File
@@ -10,7 +10,7 @@ function request(overrides: Record<string, unknown> = {}) {
return contactRequestSchema.parse({ return contactRequestSchema.parse({
name: "Lena Beispiel", name: "Lena Beispiel",
email: "[email protected]", email: "[email protected]",
phone: "", phone: "+49 170 1234567",
availability: "abends", availability: "abends",
message: "Passt das Camp für Einsteigerinnen?", message: "Passt das Camp für Einsteigerinnen?",
privacy: true, privacy: true,
@@ -61,6 +61,10 @@ describe("Erstgespräch", () => {
test("Pflichtangaben und Einwilligung werden serverseitig geprüft", () => { test("Pflichtangaben und Einwilligung werden serverseitig geprüft", () => {
expect(contactRequestSchema.safeParse({ name: "L", email: "kein-mail", privacy: false }).success).toBe(false); expect(contactRequestSchema.safeParse({ name: "L", email: "kein-mail", privacy: false }).success).toBe(false);
expect(contactRequestSchema.safeParse({ name: "Lena", email: "[email protected]", privacy: false }).success).toBe(false); expect(contactRequestSchema.safeParse({ name: "Lena", email: "[email protected]", privacy: false }).success).toBe(false);
expect(request({ phone: " " }).phone).toBeNull(); const base = { name: "Lena", email: "[email protected]", privacy: true };
expect(contactRequestSchema.safeParse(base).success).toBe(false);
expect(contactRequestSchema.safeParse({ ...base, phone: " " }).success).toBe(false);
expect(contactRequestSchema.safeParse({ ...base, phone: "abc123456" }).success).toBe(false);
expect(request({ phone: " +49 170 1234567 " }).phone).toBe("+49 170 1234567");
}); });
}); });
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { enqueueEmail, flushEmails } from "@/lib/email";
import { resetEnvCache } from "@/lib/env";
import { db, resetDb } from "./support/db";
const sendMail = vi.fn();
// SMTP-Versand simulieren; der Datei-Transport (streamTransport) bleibt echt.
vi.mock("nodemailer", async (importOriginal) => {
const actual = await importOriginal<typeof import("nodemailer")>();
const createTransport = (options: Record<string, unknown>) =>
options.streamTransport ? actual.default.createTransport(options) : { sendMail };
return { ...actual, default: { ...actual.default, createTransport } };
});
function smtpError(message: string, responseCode?: number, code = "EENVELOPE") {
return Object.assign(new Error(message), { code, responseCode });
}
async function queueMail(to = "[email protected]") {
await enqueueEmail(db, { dedupeKey: `test:${to}`, to, subject: "Buchung bestätigt", text: "Hallo", html: "<p>Hallo</p>" });
return db.emailMessage.findFirstOrThrow({ where: { toAddress: to } });
}
beforeEach(async () => {
await resetDb();
process.env.MAIL_TRANSPORT = "smtp";
process.env.SMTP_HOST = "smtp.example.org";
resetEnvCache();
sendMail.mockReset();
});
afterEach(() => {
delete process.env.MAIL_TRANSPORT;
delete process.env.SMTP_HOST;
resetEnvCache();
vi.restoreAllMocks();
});
describe("Postausgang: Fehler beim SMTP-Versand", () => {
test("dauerhafte Ablehnung (5xx) wird sofort FAILED und nie wiederholt", async () => {
const mail = await queueMail();
sendMail.mockRejectedValue(
smtpError("Can't send mail - all recipients were rejected: 556 5.1.10 <[email protected]>: Recipient address rejected: Domain test.de does not accept mail (nullMX)", 556),
);
vi.spyOn(console, "error").mockImplementation(() => {});
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 1 });
const after = await db.emailMessage.findUniqueOrThrow({ where: { id: mail.id } });
expect(after.status).toBe("FAILED");
expect(after.attempts).toBe(1);
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 0 });
expect(sendMail).toHaveBeenCalledTimes(1);
});
test("Log und gespeicherter Fehler enthalten keine E-Mail-Adresse", async () => {
await queueMail();
sendMail.mockRejectedValue(smtpError("all recipients were rejected: 556 5.1.10 <[email protected]>: Recipient address rejected", 556));
const log = vi.spyOn(console, "error").mockImplementation(() => {});
await flushEmails(db);
const logged = log.mock.calls.flat().join(" ");
expect(logged).toContain("556 5.1.10");
expect(logged).not.toContain("[email protected]");
expect((await db.emailMessage.findFirstOrThrow()).lastError).not.toContain("[email protected]");
});
test("vorübergehende Fehler (4xx, Verbindung) bleiben PENDING und werden wiederholt", async () => {
const greylisted = await queueMail("[email protected]");
const offline = await queueMail("[email protected]");
sendMail
.mockRejectedValueOnce(smtpError("421 4.7.0 Try again later", 421))
.mockRejectedValueOnce(Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNECTION" }));
vi.spyOn(console, "error").mockImplementation(() => {});
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 2 });
for (const id of [greylisted.id, offline.id]) {
expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 });
}
});
test("Server- und Absenderfehler (Anmeldung, Richtlinie) bleiben PENDING statt FAILED", async () => {
const auth = await queueMail("[email protected]");
const policy = await queueMail("[email protected]");
sendMail
.mockRejectedValueOnce(smtpError("535 5.7.8 Authentication failed", 535, "EAUTH"))
.mockRejectedValueOnce(smtpError("554 5.7.1 Message rejected as spam", 554, "EMESSAGE"));
vi.spyOn(console, "error").mockImplementation(() => {});
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 2 });
for (const id of [auth.id, policy.id]) {
expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 });
}
});
test("EENVELOPE mit 550/551/553/556 oder 5.1.x ist endgueltig, 552 und 4xx nicht", async () => {
const cases: [number, string, boolean][] = [
[550, "550 5.1.1 User unknown", true],
[551, "551 User not local", true],
[553, "553 Mailbox name not allowed", true],
[554, "554 5.1.2 Bad destination system address", true],
[552, "552 Mailbox full", false],
[451, "451 4.1.1 Try later", false],
];
vi.spyOn(console, "error").mockImplementation(() => {});
for (const [index, [code, response, permanent]] of cases.entries()) {
await resetDb();
const mail = await queueMail(`r${index}@example.org`);
sendMail.mockRejectedValueOnce(Object.assign(smtpError(response, code), { response }));
await flushEmails(db);
const after = await db.emailMessage.findUniqueOrThrow({ where: { id: mail.id } });
expect(after.status, response).toBe(permanent ? "FAILED" : "PENDING");
}
});
});
+13 -2
View File
@@ -73,8 +73,19 @@ describe("Verkaufsfreigabe", () => {
expect(evaluateSales(earlyBird, released, "live").checkoutOpen).toBe(true); expect(evaluateSales(earlyBird, released, "live").checkoutOpen).toBe(true);
}); });
test("Livemodus vor dem 01.11.2026: Vorschau ohne Kaufmoeglichkeit, Override wirkungslos", () => { test("Livemodus vor dem 01.11.2026 mit allen Freigaben: offen zum Early-Bird-Preis", () => {
const state = evaluateSales(new Date("2026-10-01T12:00:00Z"), { ...released, testPhaseOverride: "EARLY_BIRD" }, "live"); const state = evaluateSales(new Date("2026-10-01T12:00:00Z"), released, "live");
expect(state.checkoutOpen).toBe(true);
expect(state.effectivePhase).toBe("EARLY_BIRD");
expect(state.phaseSimulated).toBe(false);
});
test("Livemodus vor dem 01.11.2026 mit fehlender Freigabe: Vorschau, Override wirkungslos", () => {
const state = evaluateSales(
new Date("2026-10-01T12:00:00Z"),
{ ...released, travelLawChecked: false, testPhaseOverride: "REGULAR" },
"live",
);
expect(state.checkoutOpen).toBe(false); expect(state.checkoutOpen).toBe(false);
expect(state.closedReason).toBe("PREVIEW"); expect(state.closedReason).toBe("PREVIEW");
}); });