feat: Go-Live-Pruefung "pnpm preflight"
Die letzten offenen Punkte vor dem Live-Gang sind Inhaber-Aufgaben, keine Code-Aufgaben — und genau die Sorte, die still durchrutscht, weil der Shop mit ihnen trotzdem startet. Das Skript macht daraus eine Pruefliste, die mit Exit-Code 1 abbricht, solange ein Blocker offen ist, und laesst sich damit unveraendert als Schritt in die Deploy-Pipeline haengen. Geprueft wird, was einen oeffentlichen Shop unbemerkt unbrauchbar macht: - SESSION_SECRET/ADMIN_EMAIL/ADMIN_PASSWORD auf Laenge und darauf, dass noch der Beispielwert aus .env.example darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung faelschbar). - NEXT_PUBLIC_SITE_URL auf HTTPS und kein localhost — dieser Wert landet in jeder Bestellbestaetigung, in sitemap.xml und in allen Canonicals. - Stripe-Schluessel und Webhook-Secret vorhanden, und ob versehentlich noch ein sk_test-Schluessel gesetzt ist. Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf "NEU", ohne Rechnung und ohne Bestaetigungsmail. - SMTP vollstaendig — fehlt es, landen E-Mails nur im Server-Log statt beim Kunden (lib/mail.ts). - Impressum ohne [PLATZHALTER]/[PLACEHOLDER]-Markierungen. Bewusst beide Markierungen: die deutsche Fassung nutzt die eine, die englische die andere — eine Pruefung auf nur eine liesse die jeweils andere Sprachfassung mit Luecken online gehen. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig. Bewusst ohne Datenbankzugriff, damit die Pruefung auch von einer Maschine ohne Zugang zur Produktionsdatenbank laeuft. Felder, die zusaetzlich im Admin-Bereich unter "Inhalte" pflegbar sind (Telefonnummer, Anschrift), sind deshalb Warnung statt Blocker. README: neue Abschnitte "Vor dem Live-Gang" und "Health-Endpunkte"; in der Tech-Stack-Tabelle ausserdem die Zeile "GSAP + ScrollTrigger" korrigiert — GSAP wurde laengst durch motion/react ersetzt und steht weder in package.json noch im Quellcode. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c50e5e0e08
commit
c9ec2b4998
2 files changed
+196
-2
No files matched your search
@@ -9,8 +9,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
||||
| Sprache | TypeScript |
|
||||
| Framework | Next.js 16 (App Router) + React 19 |
|
||||
| Styling | Tailwind CSS v4 |
|
||||
| Scroll-/Hero-Animationen | GSAP + ScrollTrigger |
|
||||
| UI-Animationen | Motion for React (Menü, Warenkorb, Karten) |
|
||||
| Animationen | Motion for React (Scroll-Reveal, Hero, Menü, Warenkorb, Karten) |
|
||||
| Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" |
|
||||
| ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) |
|
||||
| Payment | Stripe Checkout |
|
||||
@@ -204,6 +203,33 @@ docker compose exec app pnpm db:seed:demo # optional: Beispieldaten zum Auspro
|
||||
|
||||
Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://localhost`, PostgreSQL unter Port `5432`. `app` selbst ist **nicht** direkt vom Host aus erreichbar (kein `ports:`-Mapping mehr, nur `expose`) — Caddy ist bewusst der einzige nach außen offene Dienst, weil er die tatsächliche Client-Verbindung terminiert und `X-Forwarded-For` dabei neu setzt statt einen vom Client selbst mitgeschickten Wert zu übernehmen. Ohne diesen Proxy davor ließe sich jedes IP-basierte Rate-Limit (Login, 2FA-Code, Checkout, Registrierung, siehe `lib/rate-limit.ts`) durch einen gefälschten Header umgehen. Für eine echte Domain mit automatischem HTTPS `SITE_ADDRESS` in `.env` setzen und Port `443` in `docker-compose.yml` freigeben (siehe Kommentar dort und in der `Caddyfile`).
|
||||
|
||||
## Vor dem Live-Gang
|
||||
|
||||
```bash
|
||||
pnpm preflight
|
||||
```
|
||||
|
||||
Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauchbar machen kann, und beendet sich mit Exit-Code 1, solange ein Blocker offen ist — dadurch lässt sich der Aufruf unverändert als Schritt in die Deploy-Pipeline hängen. Geprüft werden unter anderem:
|
||||
|
||||
- `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar).
|
||||
- `NEXT_PUBLIC_SITE_URL` auf HTTPS und darauf, dass dort nicht `localhost` steht — genau dieser Wert landet in Bestellbestätigungen, in `sitemap.xml` und in allen Canonical-Tags.
|
||||
- Stripe: Schlüssel und Webhook-Secret vorhanden, und ob versehentlich noch ein `sk_test`-Schlüssel gesetzt ist. Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „NEU" stehen, ohne Rechnung und ohne Bestätigungsmail.
|
||||
- SMTP vollständig gesetzt — fehlt es, werden E-Mails nur ins Server-Log geschrieben statt versendet (siehe `lib/mail.ts`), also keine Bestellbestätigung und keine Passwort-Zurücksetzung.
|
||||
- Impressum: ob in `src/i18n/dictionaries/de.ts` bzw. `en.ts` noch `[PLATZHALTER]`/`[PLACEHOLDER]`-Markierungen stehen. Ein unvollständiges Impressum nach § 5 TMG ist in Deutschland abmahnfähig.
|
||||
|
||||
Unabhängig davon bleiben die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) Platzhalter und **müssen** vor dem Live-Gang anwaltlich geprüft werden — das kann kein Skript feststellen.
|
||||
|
||||
## Health-Endpunkte
|
||||
|
||||
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
|
||||
|
||||
| Endpunkt | Prüft | Verwendung |
|
||||
|---|---|---|
|
||||
| `/api/health` | nur, ob der Node-Prozess antwortet — **kein** Datenbankzugriff | Liveness-Probe |
|
||||
| `/api/health/ready` | zusätzlich `SELECT 1` gegen die Datenbank, antwortet sonst mit 503 | Readiness-Probe, Docker-Healthcheck |
|
||||
|
||||
Die Trennung ist der Grund für die Endpunkte: eine Liveness-Probe, die an der Datenbank hängt, macht aus einer vorübergehenden DB-Störung eine Neustart-Schleife, weil der neu startende Container laut `Dockerfile`-CMD zuerst `prisma migrate deploy` gegen genau diese gestörte Datenbank fährt. Die Readiness-Probe darf dagegen ruhig fehlschlagen — Kubernetes nimmt den Pod dann nur aus dem Load-Balancing, statt ihn zu töten. Vorher zeigten beide Probes auf `/`, also alle 10 bzw. 30 Sekunden auf eine `force-dynamic`-Seite samt DB-Abfragen und vollem React-Rendering.
|
||||
|
||||
## Kubernetes / Gitea CI
|
||||
|
||||
`.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` automatisch ein Docker-Image und veröffentlicht es in der Gitea-eigenen Container-Registry. Der Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md).
|
||||
@@ -259,6 +285,7 @@ Beim Ausbau der Warenkorb-/Checkout-Tests kam ein echter, vorher unentdeckter Bu
|
||||
pnpm dev # Entwicklungsserver
|
||||
pnpm build # Produktions-Build
|
||||
pnpm lint # ESLint
|
||||
pnpm preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang")
|
||||
pnpm db:studio # Prisma Studio (DB-GUI)
|
||||
pnpm db:migrate # neue Migration erstellen/anwenden (Dev)
|
||||
pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Go-Live-Pruefung: faellt laut durch, statt den Shop still mit
|
||||
* Platzhaltern oder Test-Zugangsdaten oeffentlich zu stellen.
|
||||
*
|
||||
* Aufruf: `pnpm preflight` (liest .env) — gedacht als letzter Schritt vor
|
||||
* dem ersten oeffentlichen Deploy und als Schritt in der Deploy-Pipeline.
|
||||
*
|
||||
* Bewusst OHNE Datenbankzugriff: die Pruefung soll auch dann laufen, wenn
|
||||
* die Produktionsdatenbank von der ausfuehrenden Maschine aus nicht
|
||||
* erreichbar ist. Felder, die zusaetzlich im Admin-Bereich unter "Inhalte"
|
||||
* gepflegt werden koennen (Telefonnummer, Anschrift), sind deshalb als
|
||||
* WARNUNG eingestuft, nicht als BLOCKER — der hier gepruefte Wert ist nur
|
||||
* der Rueckfallwert aus src/lib/site-config.ts.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { config as loadEnv } from "dotenv";
|
||||
|
||||
loadEnv();
|
||||
|
||||
const blockers = [];
|
||||
const warnings = [];
|
||||
const passed = [];
|
||||
|
||||
/** @param {string} label @param {boolean} ok @param {string} hint */
|
||||
function must(label, ok, hint) {
|
||||
if (ok) passed.push(label);
|
||||
else blockers.push(`${label}\n ${hint}`);
|
||||
}
|
||||
|
||||
/** @param {string} label @param {boolean} ok @param {string} hint */
|
||||
function should(label, ok, hint) {
|
||||
if (ok) passed.push(label);
|
||||
else warnings.push(`${label}\n ${hint}`);
|
||||
}
|
||||
|
||||
const env = process.env;
|
||||
/** @param {string} name */
|
||||
const value = (name) => (env[name] ?? "").trim();
|
||||
/** @param {string} name */
|
||||
const isSet = (name) => value(name).length > 0;
|
||||
|
||||
// --- Sitzungs-/Zugangsgeheimnisse -----------------------------------------
|
||||
const sessionSecret = value("SESSION_SECRET");
|
||||
must(
|
||||
"SESSION_SECRET gesetzt und mindestens 32 Zeichen lang",
|
||||
sessionSecret.length >= 32,
|
||||
"Signiert die Login-Cookies. Erzeugen mit: openssl rand -base64 32"
|
||||
);
|
||||
must(
|
||||
"SESSION_SECRET ist nicht mehr der Beispielwert",
|
||||
!sessionSecret.includes("change-me"),
|
||||
"Der Wert aus .env.example ist oeffentlich bekannt — jede Sitzung waere faelschbar."
|
||||
);
|
||||
|
||||
const adminEmail = value("ADMIN_EMAIL");
|
||||
must(
|
||||
"ADMIN_EMAIL gesetzt und kein Beispielwert",
|
||||
adminEmail.length > 0 && !adminEmail.includes("change-me") && !adminEmail.endsWith("@example.com"),
|
||||
"Legt das erste Admin-Konto an (prisma db seed). Eine unvorhersehbare Adresse waehlen, nicht \"admin@...\"."
|
||||
);
|
||||
const adminPassword = value("ADMIN_PASSWORD");
|
||||
must(
|
||||
"ADMIN_PASSWORD gesetzt, kein Beispielwert, mindestens 12 Zeichen",
|
||||
adminPassword.length >= 12 && !adminPassword.includes("change-me"),
|
||||
"Dieses Konto darf Rueckerstattungen ausloesen und alle Kundendaten einsehen."
|
||||
);
|
||||
|
||||
// --- Oeffentliche Adresse der Seite ---------------------------------------
|
||||
const siteUrl = value("NEXT_PUBLIC_SITE_URL");
|
||||
must(
|
||||
"NEXT_PUBLIC_SITE_URL gesetzt",
|
||||
siteUrl.length > 0,
|
||||
"Einzige Quelle fuer Sitemap, Canonical-Tags, JSON-LD und alle Links in E-Mails (src/lib/url.ts)."
|
||||
);
|
||||
must(
|
||||
"NEXT_PUBLIC_SITE_URL nutzt HTTPS und nicht localhost",
|
||||
siteUrl.startsWith("https://") && !siteUrl.includes("localhost"),
|
||||
"Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap."
|
||||
);
|
||||
|
||||
// --- Zahlung ---------------------------------------------------------------
|
||||
must(
|
||||
"STRIPE_SECRET_KEY gesetzt",
|
||||
isSet("STRIPE_SECRET_KEY"),
|
||||
"Ohne Stripe-Schluessel schlaegt jeder Checkout fehl — der Shop nimmt keine Bestellung an."
|
||||
);
|
||||
must(
|
||||
"STRIPE_WEBHOOK_SECRET gesetzt",
|
||||
isSet("STRIPE_WEBHOOK_SECRET"),
|
||||
"Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf \"NEU\" stehen, es gibt keine Rechnung und keine Bestaetigungsmail."
|
||||
);
|
||||
must(
|
||||
"Stripe laeuft im Live-Modus (kein sk_test-Schluessel)",
|
||||
!value("STRIPE_SECRET_KEY").startsWith("sk_test"),
|
||||
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
|
||||
);
|
||||
|
||||
// --- E-Mail ----------------------------------------------------------------
|
||||
must(
|
||||
"SMTP_HOST, SMTP_USER und SMTP_PASSWORD gesetzt",
|
||||
isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"),
|
||||
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
|
||||
);
|
||||
should(
|
||||
"SMTP_FROM zeigt auf eine eigene Domain",
|
||||
!value("SMTP_FROM").includes("example.com"),
|
||||
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
|
||||
);
|
||||
|
||||
// --- Datenbank -------------------------------------------------------------
|
||||
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
|
||||
|
||||
// --- Missbrauchsschutz -----------------------------------------------------
|
||||
should(
|
||||
"Cloudflare Turnstile konfiguriert",
|
||||
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
|
||||
"Ohne Turnstile laufen Registrierung, Newsletter und Firmenanfrage ohne CAPTCHA — bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege."
|
||||
);
|
||||
|
||||
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
|
||||
const dictionaries = ["src/i18n/dictionaries/de.ts", "src/i18n/dictionaries/en.ts"];
|
||||
// Beide Markierungen pruefen: die deutsche Fassung nutzt "[PLATZHALTER",
|
||||
// die englische "[PLACEHOLDER" — eine Pruefung auf nur eine von beiden liesse
|
||||
// die jeweils andere Sprachfassung unbemerkt mit Luecken online gehen.
|
||||
const dictionariesWithPlaceholder = dictionaries.filter((file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
return source.includes("[PLATZHALTER") || source.includes("[PLACEHOLDER");
|
||||
});
|
||||
must(
|
||||
"Impressum enthaelt keine [PLATZHALTER]-Markierungen mehr",
|
||||
dictionariesWithPlaceholder.length === 0,
|
||||
`Noch offen in: ${dictionariesWithPlaceholder.join(", ") || "-"}. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig — Name, Rechtsform und Umsatzsteuer-ID bzw. der Hinweis auf § 19 UStG muessen eingetragen sein.`
|
||||
);
|
||||
|
||||
const siteConfigSource = readFileSync("src/lib/site-config.ts", "utf8");
|
||||
should(
|
||||
"Telefonnummer in site-config.ts ist keine Platzhalternummer",
|
||||
!siteConfigSource.includes("000 000000"),
|
||||
"Sichtbar im Footer und auf der Kontaktseite. Alternativ im Admin-Bereich unter \"Inhalte\" pflegbar — dann ist diese Warnung gegenstandslos."
|
||||
);
|
||||
|
||||
// --- Ausgabe ---------------------------------------------------------------
|
||||
const line = "─".repeat(72);
|
||||
console.log(`\n${line}\nGo-Live-Pruefung Wabenhain\n${line}`);
|
||||
console.log(`\n✔ Bestanden: ${passed.length}`);
|
||||
for (const item of passed) console.log(` ✔ ${item}`);
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log(`\n⚠ Warnungen: ${warnings.length}`);
|
||||
for (const item of warnings) console.log(` ⚠ ${item}`);
|
||||
}
|
||||
|
||||
if (blockers.length > 0) {
|
||||
console.log(`\n✖ Blocker: ${blockers.length}`);
|
||||
for (const item of blockers) console.log(` ✖ ${item}`);
|
||||
console.log(`\n${line}`);
|
||||
console.log("Nicht bereit fuer den Live-Betrieb. Die Blocker oben zuerst beheben.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n${line}`);
|
||||
console.log(
|
||||
warnings.length > 0
|
||||
? "Keine Blocker. Warnungen oben pruefen, dann ist der Live-Betrieb moeglich.\n"
|
||||
: "Alle Pruefungen bestanden — bereit fuer den Live-Betrieb.\n"
|
||||
);
|
||||
Reference in new issue
Block a user