#!/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" );