Files
Nick SzaunigandClaude Opus 5.5 11910dd6dd
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
refactor(mail): send via the SSO app registration, drop SMTP basic auth
Mail now uses the same Entra ID app registration as "Sign in with
Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so
only one enterprise application is needed. The app additionally gets
the SMTP.SendAsApp permission; Exchange still limits it to the mailbox
granted via Add-MailboxPermission.

Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT,
SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The
transport is fixed to smtp.office365.com:587 with required STARTTLS.
SMTP_USER (sending mailbox) and SMTP_FROM remain.

- lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail
- diagnosis drops basic-auth-only causes; hints name the ENTRA_* values
  and warn that an expired secret breaks sign-in and mail together
- admin e-mail test, .env.example, docker-compose, k8s manifests,
  preflight and README updated

Verified against Entra ID with fake values: token error classified,
client secret never exposed, missing ENTRA_CLIENT_SECRET reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 19:23:05 +02:00

387 lines
18 KiB
TypeScript

/**
* 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;
/**
* Eine Regel, die einen GESETZTEN Wert voraussetzt.
*
* Ohne diese Weiche meldete der Bericht gruene Haken fuer Variablen, die gar
* nicht existieren: "NEXT_PUBLIC_SITE_URL ist die echte Domain, kein
* Platzhalter" stand als bestanden da, weil eine leere Zeichenkette nun einmal
* kein "example.com" enthaelt — direkt neben dem Blocker
* "NEXT_PUBLIC_SITE_URL gesetzt". Dasselbe galt fuer den Test auf einen
* sk_test-Schluessel: ohne jeden Stripe-Schluessel galt der Live-Modus als
* bestaetigt.
*
* Das ist die gefaehrlichste Art von Fehlmeldung, die eine Go-Live-Pruefung
* machen kann — sie beruhigt ueber genau den Punkt, der offen ist. Fehlt der
* Wert, entfaellt die Folgeregel hier stillschweigend; gemeldet wird er
* ohnehin schon von der Regel, die seine blosse Existenz prueft.
*/
function mustWhenSet(name: string, label: string, ok: (raw: string) => boolean, hint: string): void {
if (!isSet(name)) return;
must(label, ok(value(name)), hint);
}
/** Wie mustWhenSet, nur als Warnung statt als Blocker. */
function shouldWhenSet(name: string, label: string, ok: (raw: string) => boolean, hint: string): void {
if (!isSet(name)) return;
should(label, ok(value(name)), hint);
}
// --- 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"
);
mustWhenSet(
"SESSION_SECRET",
"SESSION_SECRET ist nicht mehr der Beispielwert",
(raw) => !raw.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)."
);
mustWhenSet(
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_SITE_URL nutzt HTTPS und nicht localhost",
(raw) => raw.startsWith("https://") && !raw.includes("localhost"),
"Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap."
);
mustWhenSet(
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter",
(raw) => !raw.includes("example.com") && !raw.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.'
);
mustWhenSet(
"STRIPE_SECRET_KEY",
"Stripe laeuft im Live-Modus (kein sk_test-Schluessel)",
(raw) => !raw.startsWith("sk_test"),
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
);
must(
"STRIPE_PUBLISHABLE_KEY gesetzt",
isSet("STRIPE_PUBLISHABLE_KEY"),
"Ohne oeffentlichen Schluessel laesst sich das eingebettete Zahlungsformular nicht anzeigen — der Checkout nimmt keine Bestellung an. Steht in k8s/01-configmap.yaml."
);
mustWhenSet(
"STRIPE_PUBLISHABLE_KEY",
"STRIPE_PUBLISHABLE_KEY ist ein Live-Schluessel (pk_live_...)",
(raw) => raw.startsWith("pk_live_"),
"Ein pk_test-Schluessel passt nicht zum Live-Geheimschluessel: Stripe weist das Zahlungsformular dann ab. Steht hier gar ein sk_/rk_-Schluessel, gehoert er sofort rotiert — dieser Wert landet im HTML jeder Kassenseite."
);
// --- E-Mail ----------------------------------------------------------------
// Versand nur ueber Microsoft 365 mit OAuth 2.0 — mit der SSO-App-
// Registrierung (siehe src/lib/mail.ts).
must(
"Mailversand eingerichtet (SMTP_USER + ENTRA_TENANT_ID/ENTRA_CLIENT_ID/ENTRA_CLIENT_SECRET)",
isSet("SMTP_USER") && isSet("ENTRA_TENANT_ID") && isSet("ENTRA_CLIENT_ID") && isSet("ENTRA_CLIENT_SECRET"),
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
);
// Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur
// pruefen, wenn tatsaechlich ein eigener Wert hinterlegt ist.
shouldWhenSet(
"SMTP_FROM",
"SMTP_FROM zeigt auf eine eigene Domain",
(raw) => !raw.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."
);
shouldWhenSet(
"VAT_RATE_PERCENT",
"Umsatzsteuersatz ist ein in Deutschland ueblicher Wert",
(raw) => ["0", "7", "19"].includes(raw),
`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<void> {
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.`
);
// Ein LEERES Feld ist ausdruecklich in Ordnung und loest keine Warnung aus:
// ohne gepflegte Nummer entfaellt die Zeile auf der Kontaktseite und im
// Impressum einfach (siehe lib/site-config.ts). Gemeldet wird nur eine
// tatsaechlich eingetragene Platzhalternummer — die sieht nach einem
// Kontaktweg aus, an dessen Ende niemand abnimmt.
should(
"Telefonnummer ist keine Platzhalternummer",
!(await getContentValue("company.phone", "de")).includes("000 000000"),
'Eingetragen ist eine Platzhalternummer. Entweder die echte Nummer eintragen oder das Feld LEEREN — dann entfaellt die Angabe ueberall. 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<void> {
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<string>();
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}`);
// Ohne diesen Hinweis wird der Bericht regelmaessig falsch gelesen: auf einem
// Entwicklungsrechner meldet er Blocker fuer Werte, die in der ConfigMap des
// Clusters laengst richtig stehen (NEXT_PUBLIC_SITE_URL, SMTP_FROM,
// VAT_RATE_PERCENT). Wer das ein paarmal erlebt, sieht ueber den Bericht
// hinweg — und damit auch ueber den einen Blocker, der echt ist.
console.log(
`
Geprueft wird die Umgebung, in der dieses Skript LAEUFT. Fuer eine Aussage
ueber den Live-Betrieb im Pod ausfuehren, nicht auf dem eigenen Rechner —
siehe README, Abschnitt "Vor dem Live-Gang".`
);
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);