Compare commits
19
Commits
7745d5862d
..
main
No files matched your search
+3
-1
@@ -26,7 +26,9 @@ BETTER_AUTH_SECRET=<anderer-zufaelliger-wert-mindestens-32-zeichen>
|
||||
# sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich)
|
||||
# sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben)
|
||||
# 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_…>
|
||||
# Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md.
|
||||
STRIPE_WEBHOOK_SECRET=<whsec_…>
|
||||
|
||||
@@ -7,14 +7,101 @@ name: Docker-Images bauen und veroeffentlichen
|
||||
#
|
||||
# Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3"
|
||||
# (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:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
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:
|
||||
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:
|
||||
needs: [lint, typecheck, test]
|
||||
runs-on: docker-builder
|
||||
env:
|
||||
# Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels).
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
# 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 |
|
||||
|---|---|
|
||||
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Design |
|
||||
| [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung |
|
||||
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Kontaktformular, Design |
|
||||
| [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/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 test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) |
|
||||
| `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:seed` | Nur Entwicklung: Early Bird simulieren, Test-Gutschein `TEST-STAMMMODEL` |
|
||||
| `pnpm worker` | Einmaliger Worker-Lauf |
|
||||
| `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
|
||||
|
||||
@@ -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).
|
||||
- **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`.
|
||||
- **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.
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
# Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file.
|
||||
services:
|
||||
db:
|
||||
image: postgres:18.1-alpine
|
||||
image: postgres:18.6-alpine
|
||||
environment:
|
||||
POSTGRES_USER: artista
|
||||
POSTGRES_PASSWORD: artista-dev
|
||||
|
||||
+13
-1
@@ -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.
|
||||
|
||||
- **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.
|
||||
- **Schutz vor Fehlkonfiguration:** Ein Live-Schlüssel außerhalb von `APP_ENV=production` bricht mit einem Fehler ab.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
@@ -2,16 +2,18 @@
|
||||
|
||||
## 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>`
|
||||
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
|
||||
|
||||
Einmalig einrichten:
|
||||
- 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:**
|
||||
|
||||
@@ -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
|
||||
- ConfigMap
|
||||
- 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:
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
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
|
||||
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 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.
|
||||
|
||||
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
|
||||
|
||||
- **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.
|
||||
- **Logischer Dump** (empfohlen vor Updates und vor dem Live-Verkauf):
|
||||
- **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 von Hand** (zusätzlich vor Updates und vor dem Live-Verkauf):
|
||||
```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
|
||||
```
|
||||
- **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`
|
||||
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“).
|
||||
- **Medien:** liegen im SMB-Ordner `modelcamp-media` und sind in der NAS-Sicherung enthalten.
|
||||
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace.
|
||||
- **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. Das ist noch nicht geschehen.
|
||||
|
||||
## 8. Stornierung und Erstattung
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 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)
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
@@ -40,7 +42,8 @@ Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf
|
||||
|
||||
## Betrieb
|
||||
|
||||
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt.
|
||||
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain.
|
||||
- Domain, TLS und StorageClasses im Cluster (siehe docs/BETRIEB.md).
|
||||
- Wiederherstellung aus der Sicherung einmal geübt.
|
||||
- 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; Veranstalter-E-Mail oder `ADMIN_NOTIFY_EMAIL` gesetzt, sonst lehnt das Formular „Erstgespräch“ Anfragen ab.
|
||||
- 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 (noch offen).
|
||||
- Optional: Google-Rezensionen (`GOOGLE_PLACES_API_KEY`, `GOOGLE_PLACE_ID` im Secret); ohne Werte bleibt der Abschnitt ausgeblendet.
|
||||
+11
-6
@@ -1,10 +1,14 @@
|
||||
# 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
|
||||
|
||||
`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 |
|
||||
|---|---|
|
||||
@@ -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.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -53,7 +57,8 @@ Lauf 06.10.2026 nach Umstellung der Folgeraten auf Stripe Billing (inkl. Anhalte
|
||||
|
||||
## 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).
|
||||
- **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.
|
||||
- **SMTP-Versand:** nur über den Datei-Testausgang geprüft.
|
||||
- **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:** 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:** 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).
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
seccompProfile: { type: RuntimeDefault }
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:18.1-alpine
|
||||
image: postgres:18.6-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
env:
|
||||
@@ -41,7 +41,7 @@ spec:
|
||||
exec: { command: ["pg_isready", "-U", "artista"] }
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
requests: { cpu: 100m, memory: 256Mi }
|
||||
requests: { cpu: "0", memory: "0" }
|
||||
limits: { memory: 1Gi }
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@@ -24,20 +24,22 @@ spec:
|
||||
seccompProfile: { type: RuntimeDefault }
|
||||
initContainers:
|
||||
- name: migrate
|
||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest
|
||||
imagePullPolicy: Always
|
||||
# Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
|
||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["prisma", "migrate", "deploy"]
|
||||
envFrom:
|
||||
- secretRef: { name: artista-modelcamp-secrets }
|
||||
resources:
|
||||
requests: { cpu: "0", memory: "0" }
|
||||
limits: { memory: 512Mi }
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: { drop: ["ALL"] }
|
||||
containers:
|
||||
- name: app
|
||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp:latest
|
||||
imagePullPolicy: Always
|
||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp:sha-REPLACE_ME
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
@@ -52,7 +54,7 @@ spec:
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests: { cpu: 200m, memory: 256Mi }
|
||||
requests: { cpu: "0", memory: "0" }
|
||||
limits: { memory: 768Mi }
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Alle 10 Minuten: faellige Raten einziehen, abgelaufene Reservierungen
|
||||
# freigeben, E-Mails nachsenden. Ohne ihn funktioniert Ratenzahlung nicht.
|
||||
# Alle 10 Minuten: abgelaufene Reservierungen freigeben, fehlende
|
||||
# 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
|
||||
kind: CronJob
|
||||
metadata:
|
||||
@@ -10,8 +12,13 @@ spec:
|
||||
concurrencyPolicy: Forbid
|
||||
jobTemplate:
|
||||
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
|
||||
# Beendete Jobs samt Pods nach einem Tag entfernen; fehlgeschlagene
|
||||
# blieben sonst bis zum naechsten Fehlschlag stehen (failedJobsHistoryLimit 3).
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
@@ -25,13 +32,15 @@ spec:
|
||||
seccompProfile: { type: RuntimeDefault }
|
||||
containers:
|
||||
- name: worker
|
||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest
|
||||
imagePullPolicy: Always
|
||||
# Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
|
||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["tsx", "scripts/worker.ts"]
|
||||
envFrom:
|
||||
- configMapRef: { name: artista-modelcamp-config }
|
||||
- secretRef: { name: artista-modelcamp-secrets }
|
||||
resources:
|
||||
requests: { cpu: "0", memory: "0" }
|
||||
limits: { memory: 512Mi }
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@@ -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
@@ -4,6 +4,7 @@ Rohe Manifeste wie bei Wabenhain, Nummerierung = Reihenfolge. Voraussetzungen im
|
||||
- **Traefik** als Ingress-Controller (IngressClass `traefik`)
|
||||
- **cert-manager** mit dem ClusterIssuer `letsencrypt-prod`
|
||||
- **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:
|
||||
|
||||
@@ -11,8 +12,11 @@ Prüfen:
|
||||
kubectl get ingressclass
|
||||
kubectl get clusterissuer letsencrypt-prod
|
||||
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
|
||||
|
||||
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
|
||||
```
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -38,6 +43,7 @@ kubectl apply -f k8s/05-app-deployment.yaml
|
||||
kubectl apply -f k8s/06-ingress.yaml
|
||||
kubectl apply -f k8s/07-worker-cronjob.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:
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
**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
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
+3
-3
@@ -27,14 +27,14 @@
|
||||
"@prisma/client": "7.10.0",
|
||||
"better-auth": "1.7.6",
|
||||
"motion": "13.4.4",
|
||||
"next": "16.3.6",
|
||||
"next": "16.3.8",
|
||||
"nodemailer": "10.0.10",
|
||||
"pg": "8.23.0",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.35.4",
|
||||
"sharp": "0.35.5",
|
||||
"stripe": "22.6.2",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
@@ -51,7 +51,7 @@
|
||||
"dotenv": "18.0.4",
|
||||
"embedded-postgres": "18.4.0-beta.17",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.3.6",
|
||||
"eslint-config-next": "16.3.8",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"prisma": "7.10.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
|
||||
Generated
+217
-220
@@ -162,6 +162,12 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
mysql2: '>=3.23.1'
|
||||
deepmerge-ts: '>=8.0.0'
|
||||
sharp: '>=0.35.5'
|
||||
source-map-js: '>=1.2.2'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -171,16 +177,16 @@ importers:
|
||||
version: 7.10.0
|
||||
'@prisma/client':
|
||||
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:
|
||||
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:
|
||||
specifier: 13.4.4
|
||||
version: 13.4.4([email protected]([email protected]))([email protected])
|
||||
next:
|
||||
specifier: 16.3.6
|
||||
version: 16.3.6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 16.3.8
|
||||
version: 16.3.8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
nodemailer:
|
||||
specifier: 10.0.10
|
||||
version: 10.0.10
|
||||
@@ -200,8 +206,8 @@ importers:
|
||||
specifier: 0.0.1
|
||||
version: 0.0.1
|
||||
sharp:
|
||||
specifier: 0.35.4
|
||||
version: 0.35.4(@types/[email protected])
|
||||
specifier: '>=0.35.5'
|
||||
version: 0.35.5(@types/[email protected])
|
||||
stripe:
|
||||
specifier: 22.6.2
|
||||
version: 22.6.2(@types/[email protected])
|
||||
@@ -246,14 +252,14 @@ importers:
|
||||
specifier: ^9.39.5
|
||||
version: 9.39.5([email protected])([email protected])
|
||||
eslint-config-next:
|
||||
specifier: 16.3.6
|
||||
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])
|
||||
specifier: 16.3.8
|
||||
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:
|
||||
specifier: 5.3.0
|
||||
version: 5.3.0([email protected])
|
||||
prisma:
|
||||
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:
|
||||
specifier: 4.3.3
|
||||
version: 4.3.3
|
||||
@@ -726,160 +732,160 @@ packages:
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
os: [freebsd]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected].3':
|
||||
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
|
||||
engines: {node: ^20.9.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/[email protected].4':
|
||||
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
|
||||
'@img/[email protected].5':
|
||||
resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -907,60 +913,60 @@ packages:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==}
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/[email protected].6':
|
||||
resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1788,7 +1794,7 @@ packages:
|
||||
drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
|
||||
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.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
|
||||
pg: ^8.0.0
|
||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
@@ -2038,9 +2044,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
deepmerge-ts@7.1.5:
|
||||
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
deepmerge-ts@8.0.2:
|
||||
resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||
@@ -2056,10 +2062,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
@@ -2174,8 +2176,8 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==}
|
||||
[email protected].8:
|
||||
resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==}
|
||||
peerDependencies:
|
||||
eslint: '>=9.0.0'
|
||||
typescript: '>=3.3.1'
|
||||
@@ -2956,9 +2958,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
mysql2@3.15.3:
|
||||
resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==}
|
||||
mysql2@3.24.5:
|
||||
resolution: {integrity: sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==}
|
||||
engines: {node: '>= 8.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>= 8'
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||
@@ -2981,8 +2985,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==}
|
||||
[email protected].8:
|
||||
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -3362,9 +3366,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
@@ -3386,8 +3387,8 @@ packages:
|
||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
|
||||
[email protected].5:
|
||||
resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '*'
|
||||
@@ -3429,17 +3430,17 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sql[email protected]:
|
||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
sql[email protected]:
|
||||
resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
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/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:
|
||||
'@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
|
||||
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: 7.10.0(@types/[email protected](@types/[email protected]))(@types/[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]))(@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])':
|
||||
dependencies:
|
||||
@@ -4171,108 +4172,108 @@ snapshots:
|
||||
|
||||
'@img/[email protected]': {}
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
'@img/sharp-wasm32': 0.35.5
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].3':
|
||||
'@img/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.3
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
'@img/sharp-wasm32': 0.35.5
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optional: true
|
||||
|
||||
'@img/[email protected].4':
|
||||
'@img/[email protected].5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
@@ -4301,37 +4302,37 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.4
|
||||
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:
|
||||
'@eslint-community/eslint-utils': 4.9.1([email protected]([email protected])([email protected]))
|
||||
fast-glob: 3.3.1
|
||||
transitivePeerDependencies:
|
||||
- eslint
|
||||
|
||||
'@next/[email protected].6':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].6':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].6':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].6':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].6':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].6':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].6':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].6':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@noble/[email protected]': {}
|
||||
@@ -4371,17 +4372,17 @@ snapshots:
|
||||
|
||||
'@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:
|
||||
'@prisma/client-runtime-utils': 7.10.0
|
||||
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
|
||||
|
||||
'@prisma/[email protected]':
|
||||
dependencies:
|
||||
c12: 3.3.4
|
||||
deepmerge-ts: 7.1.5
|
||||
deepmerge-ts: 8.0.2
|
||||
effect: 3.20.0
|
||||
empathic: 2.0.0
|
||||
transitivePeerDependencies:
|
||||
@@ -4584,7 +4585,7 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
@@ -5142,14 +5143,14 @@ snapshots:
|
||||
|
||||
[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:
|
||||
'@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/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/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/utils': 0.4.2
|
||||
'@better-fetch/fetch': 1.3.2
|
||||
@@ -5162,11 +5163,11 @@ snapshots:
|
||||
nanostores: 1.5.4
|
||||
zod: 4.6.5
|
||||
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])
|
||||
mysql2: 3.15.3
|
||||
next: 16.3.6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[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.24.5(@types/[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
|
||||
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-dom: 19.3.0([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]: {}
|
||||
|
||||
deepmerge-ts@7.1.5: {}
|
||||
deepmerge-ts@8.0.2: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -5399,8 +5400,6 @@ snapshots:
|
||||
dependencies:
|
||||
robust-predicates: 3.0.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -5607,9 +5606,9 @@ snapshots:
|
||||
|
||||
[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:
|
||||
'@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-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])
|
||||
@@ -6406,17 +6405,16 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
mysql2@3.15.3:
|
||||
mysql2@3.24.5(@types/[email protected]):
|
||||
dependencies:
|
||||
'@types/node': 22.20.4
|
||||
aws-ssl-profiles: 1.1.2
|
||||
denque: 2.1.0
|
||||
generate-function: 2.3.1
|
||||
iconv-lite: 0.7.3
|
||||
long: 5.3.2
|
||||
lru.min: 1.1.5
|
||||
named-placeholders: 1.1.6
|
||||
seq-queue: 0.0.5
|
||||
sqlstring: 2.3.3
|
||||
sql-escaper: 1.5.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -6430,9 +6428,9 @@ snapshots:
|
||||
|
||||
[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:
|
||||
'@next/env': 16.3.6
|
||||
'@next/env': 16.3.8
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.26
|
||||
caniuse-lite: 1.0.30001812
|
||||
@@ -6441,16 +6439,16 @@ snapshots:
|
||||
react-dom: 19.3.0([email protected])
|
||||
styled-jsx: 5.1.6(@babel/[email protected]([email protected]))([email protected])
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.3.6
|
||||
'@next/swc-darwin-x64': 16.3.6
|
||||
'@next/swc-linux-arm64-gnu': 16.3.6
|
||||
'@next/swc-linux-arm64-musl': 16.3.6
|
||||
'@next/swc-linux-x64-gnu': 16.3.6
|
||||
'@next/swc-linux-x64-musl': 16.3.6
|
||||
'@next/swc-win32-arm64-msvc': 16.3.6
|
||||
'@next/swc-win32-x64-msvc': 16.3.6
|
||||
'@next/swc-darwin-arm64': 16.3.8
|
||||
'@next/swc-darwin-x64': 16.3.8
|
||||
'@next/swc-linux-arm64-gnu': 16.3.8
|
||||
'@next/swc-linux-arm64-musl': 16.3.8
|
||||
'@next/swc-linux-x64-gnu': 16.3.8
|
||||
'@next/swc-linux-x64-musl': 16.3.8
|
||||
'@next/swc-win32-arm64-msvc': 16.3.8
|
||||
'@next/swc-win32-x64-msvc': 16.3.8
|
||||
'@playwright/test': 1.63.0
|
||||
sharp: 0.35.4(@types/[email protected])
|
||||
sharp: 0.35.5(@types/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@types/node'
|
||||
@@ -6624,13 +6622,13 @@ snapshots:
|
||||
dependencies:
|
||||
nanoid: 3.3.19
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
nanoid: 3.3.19
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -6648,17 +6646,18 @@ snapshots:
|
||||
|
||||
[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:
|
||||
'@prisma/config': 7.10.0
|
||||
'@prisma/dev': 0.24.17([email protected])
|
||||
'@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])
|
||||
mysql2: 3.15.3
|
||||
mysql2: 3.24.5(@types/[email protected])
|
||||
postgres: 3.4.7
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- magicast
|
||||
@@ -6822,8 +6821,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -6852,37 +6849,37 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
[email protected].4(@types/[email protected]):
|
||||
[email protected].5(@types/[email protected]):
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.35.4
|
||||
'@img/sharp-darwin-x64': 0.35.4
|
||||
'@img/sharp-freebsd-wasm32': 0.35.4
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
'@img/sharp-linux-arm': 0.35.4
|
||||
'@img/sharp-linux-arm64': 0.35.4
|
||||
'@img/sharp-linux-ppc64': 0.35.4
|
||||
'@img/sharp-linux-riscv64': 0.35.4
|
||||
'@img/sharp-linux-s390x': 0.35.4
|
||||
'@img/sharp-linux-x64': 0.35.4
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.4
|
||||
'@img/sharp-linuxmusl-x64': 0.35.4
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.4
|
||||
'@img/sharp-win32-arm64': 0.35.4
|
||||
'@img/sharp-win32-ia32': 0.35.4
|
||||
'@img/sharp-win32-x64': 0.35.4
|
||||
'@img/sharp-darwin-arm64': 0.35.5
|
||||
'@img/sharp-darwin-x64': 0.35.5
|
||||
'@img/sharp-freebsd-wasm32': 0.35.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||
'@img/sharp-linux-arm': 0.35.5
|
||||
'@img/sharp-linux-arm64': 0.35.5
|
||||
'@img/sharp-linux-ppc64': 0.35.5
|
||||
'@img/sharp-linux-riscv64': 0.35.5
|
||||
'@img/sharp-linux-s390x': 0.35.5
|
||||
'@img/sharp-linux-x64': 0.35.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.5
|
||||
'@img/sharp-linuxmusl-x64': 0.35.5
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.5
|
||||
'@img/sharp-win32-arm64': 0.35.5
|
||||
'@img/sharp-win32-ia32': 0.35.5
|
||||
'@img/sharp-win32-x64': 0.35.5
|
||||
'@types/node': 22.20.4
|
||||
|
||||
[email protected]:
|
||||
@@ -6925,11 +6922,11 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].1: {}
|
||||
[email protected].2: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
sql[email protected]: {}
|
||||
sql[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
|
||||
@@ -8,3 +8,16 @@ allowBuilds:
|
||||
'@embedded-postgres/windows-x64': true
|
||||
'@embedded-postgres/linux-x64': true
|
||||
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'
|
||||
@@ -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 { Flash, PageTitle } from "@/components/admin/AdminUi";
|
||||
import { VoucherForm } from "@/components/admin/VoucherForm";
|
||||
import { getCamp, salesStateFor } from "@/lib/camp";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
|
||||
import { evaluateVoucher, VOUCHER_REJECTION_ADMIN_TEXT } from "@/lib/vouchers";
|
||||
|
||||
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 }> }) {
|
||||
await connection();
|
||||
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 } } } } },
|
||||
});
|
||||
if (!voucher) notFound();
|
||||
const check = await redeemability(voucher.code);
|
||||
return (
|
||||
<>
|
||||
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
|
||||
<PageTitle>{voucher.code}</PageTitle>
|
||||
<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">
|
||||
<VoucherForm voucher={voucher} />
|
||||
<section>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { withClientIp } from "@/lib/rate-limit";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return toNextJsHandler(getAuth()).GET(request);
|
||||
return toNextJsHandler(getAuth()).GET(await withClientIp(request));
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return toNextJsHandler(getAuth()).POST(request);
|
||||
return toNextJsHandler(getAuth()).POST(await withClientIp(request));
|
||||
}
|
||||
@@ -109,10 +109,11 @@ export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
|
||||
aria-label="Tage im Camp"
|
||||
// Per Tastatur scrollbar (Pfeiltasten), WCAG 2.1.1.
|
||||
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
|
||||
// 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) => (
|
||||
<li
|
||||
@@ -120,7 +121,7 @@ export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
|
||||
ref={(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">
|
||||
{day.label} · {day.title}
|
||||
|
||||
@@ -60,7 +60,8 @@ export function BookingDialog({
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
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 [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
||||
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
||||
@@ -114,11 +115,11 @@ export function BookingDialog({
|
||||
};
|
||||
}, [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 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) {
|
||||
setParticipant((prev) => ({ ...prev, [field]: value }));
|
||||
@@ -174,6 +175,7 @@ export function BookingDialog({
|
||||
|
||||
async function submit() {
|
||||
setFormError(null);
|
||||
if (!planType) return;
|
||||
const missing: FieldErrors = {};
|
||||
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
|
||||
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>
|
||||
<div className="mt-2 space-y-3">
|
||||
{quote?.plans.map((plan) => (
|
||||
<label
|
||||
<div
|
||||
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
|
||||
type="radio"
|
||||
name="planType"
|
||||
@@ -341,15 +344,11 @@ export function BookingDialog({
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="block font-semibold">{plan.label}</span>
|
||||
{plan.available ? (
|
||||
<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>
|
||||
)}
|
||||
{!plan.available && <span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>}
|
||||
</span>
|
||||
</label>
|
||||
{plan.available && planType === plan.type && quote.price && <PlanDetails plan={plan} quote={quote} />}
|
||||
</div>
|
||||
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -384,8 +383,6 @@ export function BookingDialog({
|
||||
{voucherMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quote?.price && <PriceBreakdown quote={quote} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -462,7 +459,7 @@ export function BookingDialog({
|
||||
</a>{" "}
|
||||
gelesen.
|
||||
</Consent>
|
||||
{planType !== "FULL" && (
|
||||
{planType !== null && planType !== "FULL" && (
|
||||
<Consent
|
||||
id="installmentMandate"
|
||||
checked={consents.installmentMandate}
|
||||
@@ -494,7 +491,7 @@ export function BookingDialog({
|
||||
<span />
|
||||
)}
|
||||
{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
|
||||
</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 }) {
|
||||
const p = quote.price!;
|
||||
return (
|
||||
|
||||
@@ -101,8 +101,8 @@ export function ContactSection() {
|
||||
{error("email")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="ct-phone">Telefon (optional)</label>
|
||||
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" maxLength={60} />
|
||||
<label className="field-label" htmlFor="ct-phone">Telefon</label>
|
||||
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" required maxLength={40} />
|
||||
{error("phone")}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -33,8 +33,9 @@ export function IntroVideoSection({ video, showDrafts }: { video: IntroVideo; sh
|
||||
playsInline
|
||||
preload={poster ? "none" : "metadata"}
|
||||
aria-label="Video ARTISTA Modelcamp"
|
||||
// Eigenes Seitenverhaeltnis (quer oder hoch), wie bei den Reels.
|
||||
className="mx-auto block max-h-[85vh] w-full rounded-2xl bg-black object-contain"
|
||||
// Groesse folgt dem Video (quer oder hoch): ohne volle Breite keine
|
||||
// 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" />
|
||||
</video>
|
||||
|
||||
@@ -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">
|
||||
{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}>
|
||||
<Reveal delay={(index % 4) * 0.06}>
|
||||
<MediaFrame media={portraitOf(person)} aspect="aspect-[3/4]" shape="arch" sizes="(min-width: 768px) 22vw, 50vw" showLabel={false} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Actor } from "./audit";
|
||||
import { getAuth } from "./auth";
|
||||
import { headersWithClientIp } from "./rate-limit";
|
||||
|
||||
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
|
||||
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
|
||||
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;
|
||||
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) };
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { NextResponse } from "next/server";
|
||||
import type { z } from "zod";
|
||||
import { getDb } from "./db";
|
||||
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> };
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function readJson<T extends z.ZodType>(
|
||||
}
|
||||
|
||||
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;
|
||||
return fail(
|
||||
429,
|
||||
|
||||
+4
-1
@@ -17,6 +17,7 @@ import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
|
||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||
import { getDb } from "./db";
|
||||
import { env } from "./env";
|
||||
import { CLIENT_IP_HEADER } from "./rate-limit";
|
||||
|
||||
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
||||
const e = env();
|
||||
@@ -51,7 +52,9 @@ function createAuth(options: { allowSignUp?: boolean } = {}) {
|
||||
},
|
||||
advanced: {
|
||||
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()],
|
||||
});
|
||||
|
||||
+7
-1
@@ -28,7 +28,13 @@ const optionalText = (max: number) =>
|
||||
export const contactRequestSchema = z.object({
|
||||
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.")),
|
||||
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),
|
||||
message: optionalText(2000),
|
||||
privacy: z.literal(true, { error: "Bitte bestätige die Datenschutzhinweise." }),
|
||||
|
||||
+27
-3
@@ -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. */
|
||||
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
|
||||
const pending = await db.emailMessage.findMany({
|
||||
@@ -111,15 +133,17 @@ export async function flushEmails(db: Db, params: { bookingId?: string; limit?:
|
||||
sent++;
|
||||
} catch (error) {
|
||||
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({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
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 };
|
||||
|
||||
+104
-7
@@ -5,6 +5,7 @@
|
||||
*
|
||||
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
|
||||
*/
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import type { Db } from "./db";
|
||||
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'`;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* X-Forwarded-For von nicht vertrauenswuerdigen Absendern (EntryPoint-
|
||||
* Einstellung forwardedHeaders.trustedIPs, siehe docs/BETRIEB.md).
|
||||
* Client-IP hinter Traefik. Gegenstelle ist der letzte Eintrag in
|
||||
* X-Forwarded-For, den Traefik aus der TCP-Verbindung anhaengt; alles links
|
||||
* 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 {
|
||||
const realIp = headers.get("x-real-ip")?.trim();
|
||||
if (realIp) return realIp;
|
||||
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||
return forwarded || "unknown";
|
||||
const forwarded = headers.get("x-forwarded-for");
|
||||
const realIp = process.env.NODE_ENV === "production" ? null : validIp(headers.get("x-real-ip"));
|
||||
const peer = forwarded === null ? realIp : validIp(forwarded.split(",").at(-1));
|
||||
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 });
|
||||
}
|
||||
@@ -107,8 +107,11 @@ export function releaseBlockers(camp: CampGateFields): ReleaseBlocker[] {
|
||||
export function evaluateSales(now: Date, camp: CampGateFields, mode: StripeMode): SalesState {
|
||||
const phase = pricePhaseAt(now);
|
||||
const simulate = mode === "test" && camp.testPhaseOverride !== null && camp.testPhaseOverride !== "PREVIEW";
|
||||
const effectivePhase = simulate ? camp.testPhaseOverride! : phase;
|
||||
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();
|
||||
|
||||
let closedReason: SalesState["closedReason"] = null;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ function request(overrides: Record<string, unknown> = {}) {
|
||||
return contactRequestSchema.parse({
|
||||
name: "Lena Beispiel",
|
||||
email: "[email protected]",
|
||||
phone: "",
|
||||
phone: "+49 170 1234567",
|
||||
availability: "abends",
|
||||
message: "Passt das Camp für Einsteigerinnen?",
|
||||
privacy: true,
|
||||
@@ -61,6 +61,10 @@ describe("Erstgespräch", () => {
|
||||
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: "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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -73,8 +73,19 @@ describe("Verkaufsfreigabe", () => {
|
||||
expect(evaluateSales(earlyBird, released, "live").checkoutOpen).toBe(true);
|
||||
});
|
||||
|
||||
test("Livemodus vor dem 01.11.2026: Vorschau ohne Kaufmoeglichkeit, Override wirkungslos", () => {
|
||||
const state = evaluateSales(new Date("2026-10-01T12:00:00Z"), { ...released, testPhaseOverride: "EARLY_BIRD" }, "live");
|
||||
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, "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.closedReason).toBe("PREVIEW");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user