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]>
168 lines
6.9 KiB
JavaScript
168 lines
6.9 KiB
JavaScript
#!/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"
|
|
);
|