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
@@ -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