/** * 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. * Endet mit Exit-Code 1, sobald ein Blocker offen ist. * * Die Inhaltspruefungen fragen bewusst die DATENBANK und nicht den * Quelltext: Impressum, Datenschutz, AGB, Widerrufsbelehrung und die * Telefonnummer sind im Admin-Bereich unter "Inhalte" pflegbar (siehe * lib/content-registry.ts). Die Texte in i18n/dictionaries/ sind nur der * Rueckfallwert, solange dort nichts gepflegt ist. Eine Pruefung, die nur * in den Quelltext schaut, meldet deshalb einen Blocker, den die * Betreiberin im Admin laengst geschlossen hat — und schweigt umgekehrt, * wenn jemand einen Platzhaltertext erst im Admin eintraegt. * * Ist die Datenbank nicht erreichbar (Aufruf von einer Maschine ohne * Zugang zur Produktionsdatenbank), werden diese Pruefungen ausdruecklich * als ungeprueft gemeldet statt geraten. */ import "dotenv/config"; import path from "node:path"; import { prisma } from "../src/lib/prisma"; import { getContentValue } from "../src/lib/content"; import { locales } from "../src/i18n/config"; import { getStripeClient } from "../src/lib/stripe"; const blockers: string[] = []; const warnings: string[] = []; const passed: string[] = []; function must(label: string, ok: boolean, hint: string): void { if (ok) passed.push(label); else blockers.push(`${label}\n ${hint}`); } function should(label: string, ok: boolean, hint: string): void { if (ok) passed.push(label); else warnings.push(`${label}\n ${hint}`); } const value = (name: string): string => (process.env[name] ?? "").trim(); const isSet = (name: string): boolean => 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@...".' ); must( "ADMIN_PASSWORD gesetzt, kein Beispielwert, mindestens 12 Zeichen", value("ADMIN_PASSWORD").length >= 12 && !value("ADMIN_PASSWORD").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." ); must( "NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter", !siteUrl.includes("example.com") && !siteUrl.includes("example.org"), 'Die echte Domain steht in k8s/01-configmap.yaml (NEXT_PUBLIC_SITE_URL) und k8s/06-ingress.yaml (host/tls.hosts) und muss an beiden Stellen dieselbe sein. Steht hier eine Beispiel-Domain, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain.' ); // --- 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." ); // --- Umsatzsteuer ---------------------------------------------------------- // Ein falscher Steuersatz faellt im Betrieb kaum auf: die Rechnung sieht // plausibel aus, nur die ausgewiesene Steuer stimmt nicht. Deshalb hier // ausdruecklich pruefen, statt sich auf den Rueckfallwert zu verlassen. const vatRate = value("VAT_RATE_PERCENT"); const vatRateNumber = Number(vatRate); must( "VAT_RATE_PERCENT gesetzt und ein ganzzahliger Prozentwert", vatRate.length > 0 && Number.isInteger(vatRateNumber) && vatRateNumber >= 0 && vatRateNumber <= 100, "Wird auf jeder Rechnung ausgewiesen. 7 = ermaessigt (Honig), 19 = voll, 0 = Kleinunternehmerregelung nach § 19 UStG. Ohne gueltigen Wert rechnet die Anwendung mit 7% weiter — fuer Kerzen zu niedrig, fuer Kleinunternehmer schlicht falsch." ); should( "Umsatzsteuersatz ist ein in Deutschland ueblicher Wert", ["0", "7", "19"].includes(vatRate), `Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.` ); // --- Rechnungsablage ------------------------------------------------------- // Das Upload-Verzeichnis wird oeffentlich und ohne Anmeldung ausgeliefert // (app/uploads/[...path]). Liegt die Rechnungsablage darin, sind fremde // Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar, der den // Pfad erraet — und im Betrieb faellt das nicht auf, weil weiterhin alles // funktioniert. const invoiceDir = path.resolve(value("INVOICE_DIR") || "./invoices"); const uploadDir = path.resolve(value("UPLOAD_DIR") || "./uploads"); must( "INVOICE_DIR liegt ausserhalb von UPLOAD_DIR", invoiceDir !== uploadDir && !invoiceDir.startsWith(uploadDir + path.sep), `Rechnungen (${invoiceDir}) duerfen nicht unterhalb des oeffentlich ausgelieferten Upload-Verzeichnisses (${uploadDir}) liegen. Eigenes Verzeichnis waehlen, siehe .env.example.` ); // --- Datenbank ------------------------------------------------------------- must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht."); // --- Missbrauchsschutz ----------------------------------------------------- must( "Cloudflare Turnstile konfiguriert", isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"), "Blocker, weil die Pruefung bei fehlendem Schluessel nicht etwa fehlschlaegt, sondern STILL UEBERSPRUNGEN wird (src/lib/turnstile.ts) — alle oeffentlichen Formulare stuenden dann ungeschuetzt offen, ohne dass es auffiele: Login und Zwei-Faktor-Eingabe (Ausprobieren von Zugangsdaten), Passwort-Reset (Mailversand an fremde Adressen), Registrierung, Checkout (Bestandsblockade, Kartentests), Newsletter, Firmenanfrage und Verfuegbarkeits-Benachrichtigung. Beide Werte gehoeren zusammen: der Site-Key ist oeffentlich, der Secret-Key gehoert ins Secret." ); // --- Inhalte, die rechtlich verpflichtend sind ----------------------------- // Beide Markierungen pruefen: die deutsche Fassung nutzt "[PLATZHALTER", // die englische "[PLACEHOLDER". Eine Pruefung auf nur eine von beiden liesse // die jeweils andere Sprachfassung mit Luecken online gehen. /** * Erste aussagekraeftige Zeile einer Fehlermeldung. Prisma-Verbindungsfehler * beginnen mit Leerzeilen — ein blosses split("\n")[0] ergaebe eine leere * Klammer statt der Ursache. */ function firstMeaningfulLine(error: unknown): string { if (!(error instanceof Error)) return "unbekannter Fehler"; const line = error.message.split("\n").map((l) => l.trim()).find((l) => l.length > 0); return line ?? error.name; } const PLACEHOLDER_MARKERS = ["[PLATZHALTER", "[PLACEHOLDER"]; const hasPlaceholder = (text: string): boolean => PLACEHOLDER_MARKERS.some((marker) => text.includes(marker)); const LEGAL_KEYS = [ { key: "legal.impressum", label: "Impressum" }, { key: "legal.datenschutz", label: "Datenschutz" }, { key: "legal.agb", label: "AGB" }, { key: "legal.widerruf", label: "Widerrufsbelehrung" }, ] as const; // Kein Top-Level-await: das Projekt hat kein "type": "module" in der // package.json, tsx uebersetzt dieses Skript daher nach CommonJS. async function checkDatabaseContent(): Promise { try { // Verbindung zuerst pruefen, damit ein DB-Ausfall unten nicht faelschlich // als "Inhalt in Ordnung" durchgeht. await prisma.$queryRaw`SELECT 1`; const withPlaceholder: string[] = []; for (const { key, label } of LEGAL_KEYS) { for (const locale of locales) { if (hasPlaceholder(await getContentValue(key, locale))) { withPlaceholder.push(`${label} (${locale})`); } } } must( "Rechtstexte enthalten keine Platzhalter-Markierungen mehr", withPlaceholder.length === 0, `Noch offen: ${withPlaceholder.join(", ")}. Im Admin-Bereich unter "Inhalte" pflegbar — kein Deploy noetig. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig: Name, Rechtsform und Umsatzsteuer-ID bzw. der Hinweis auf § 19 UStG muessen eingetragen sein.` ); should( "Telefonnummer ist keine Platzhalternummer", !(await getContentValue("company.phone", "de")).includes("000 000000"), 'Sichtbar im Footer, auf der Kontaktseite und im Impressum. Im Admin-Bereich unter "Inhalte" pflegbar.' ); } catch (error: unknown) { // Bewusst KEIN Blocker und KEIN stilles Bestehen: ohne Datenbank laesst // sich der tatsaechlich ausgelieferte Text nicht feststellen. warnings.push( `Rechtstexte und Telefonnummer konnten NICHT geprueft werden\n Die Datenbank ist von hier aus nicht erreichbar (${firstMeaningfulLine( error )}). Diese Inhalte liegen in der Datenbank, nicht im Quelltext — die Pruefung mit Zugriff auf die Produktionsdatenbank wiederholen oder /impressum im Browser aufrufen.` ); } finally { await prisma.$disconnect(); } } /** * Prueft, ob der Stripe-Webhook-Endpunkt die vier Ereignisse abonniert hat, * auf die der Shop angewiesen ist. * * Das ist keine Formalie: fehlt checkout.session.expired, bleibt der beim * Checkout reservierte Lagerbestand jedes abgebrochenen Kaufs dauerhaft * blockiert. Fehlt checkout.session.completed, wird ueberhaupt keine Zahlung * bestaetigt. Beides faellt im Betrieb erst auf, wenn Ware unverkaeuflich im * System steht oder Geld eingezogen wurde, ohne dass eine Rechnung entstand — * beides zu spaet. */ const REQUIRED_WEBHOOK_EVENTS = [ "checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "checkout.session.expired", // Geld, das den Shop wieder verlaesst, ohne dass hier jemand etwas // angeklickt hat: eine Erstattung von Hand im Stripe-Dashboard und eine // Rueckbuchung durch die Bank. Ohne diese beiden Ereignisse steht die // Zahlung im Shop weiter als erfolgreich da, und eine fristgebundene // Rueckbuchung faellt nur auf, wenn jemand ins Dashboard sieht. "charge.refunded", "charge.dispute.created", ] as const; async function checkStripeWebhookEvents(): Promise { if (!isSet("STRIPE_SECRET_KEY")) return; // fehlt bereits als Blocker oben try { const stripe = getStripeClient(); const endpoints = await stripe.webhookEndpoints.list({ limit: 100 }); const active = endpoints.data.filter((endpoint) => endpoint.status === "enabled"); // "*" abonniert alle Ereignisse — dann ist alles Noetige dabei. const covered = new Set(); for (const endpoint of active) { for (const event of endpoint.enabled_events) covered.add(event); } const missing = covered.has("*") ? [] : REQUIRED_WEBHOOK_EVENTS.filter((event) => !covered.has(event)); if (active.length === 0) { warnings.push( "Kein aktiver Stripe-Webhook-Endpunkt gefunden\n Ohne ihn wird keine Zahlung bestaetigt und kein abgebrochener Checkout aufgeraeumt. Unter https://dashboard.stripe.com/webhooks einen Endpunkt auf /api/checkout/webhook anlegen. Hinweis: mit einem eingeschraenkten Schluessel (rk_...) kann diese Pruefung die Liste moeglicherweise nicht sehen, obwohl ein Endpunkt existiert." ); return; } must( `Stripe-Webhook abonniert alle ${REQUIRED_WEBHOOK_EVENTS.length} benoetigten Ereignisse`, missing.length === 0, `Nicht abonniert: ${missing.join(", ")}. Besonders checkout.session.expired: ohne dieses Ereignis bleibt der Lagerbestand jedes abgebrochenen Checkouts blockiert. Unter https://dashboard.stripe.com/webhooks am Endpunkt nachtragen.` ); } catch (error: unknown) { // Weder Blocker noch stilles Bestehen: der Schluessel darf die Liste // vielleicht gar nicht lesen (eingeschraenkter Schluessel), und daraus // laesst sich nichts ueber die tatsaechliche Konfiguration ableiten. warnings.push( `Stripe-Webhook-Ereignisse konnten NICHT geprueft werden\n ${firstMeaningfulLine( error )}. Im Dashboard unter https://dashboard.stripe.com/webhooks von Hand nachsehen, ob ${REQUIRED_WEBHOOK_EVENTS.join( ", " )} abonniert sind.` ); } } function report(): void { // --- 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" ); } void Promise.all([checkDatabaseContent(), checkStripeWebhookEvents()]).then(report);