Phase 11: Freigabereife
Pruefung / pruefung (pull_request) Canceled after 0s
Pruefung / oberflaeche (pull_request) Canceled after 0s

Kein Modul, sondern die drei Software-Voraussetzungen fuer 1.0.0:
Auskunft nach Art. 15 DSGVO, ein geprueftes Sicherungskonzept und eine
Pipeline.

Das Auskunftsrecht war ein Versprechen ohne Umsetzung: `person.export`
stand seit Phase 0 im Katalog und war dem Kommandanten zugewiesen,
implementiert war nichts. Ohne Auskunftsseite ist ein Loeschkonzept nicht
abnehmbar. Jetzt sammelt FLORIAN dreizehn Abschnitte ueber eine Person,
mit dem Zweck ueber jedem - Art. 15 verlangt nicht nur die Daten, sondern
auch Zwecke und Rechte, und eine blosse Liste erfuellt die Pflicht nicht.
Ein Durchgang liefert Anzeige, PDF und JSON nach Art. 20.

Die Trennlinie ist "ueber mich" gegen "von mir": was ueber die Person
gespeichert ist, steht vollstaendig drin, auch das Unangenehme. Was sie
getan hat, steht als Zahl drin - die Pruefprotokolle gehoeren zum
Nachweisbestand der Wehr, und vollstaendig ausgeliefert kaeme jeder ueber
eine Selbstauskunft an die Geraeteakten.

Die Rueckspielprobe hat sich sofort bezahlt gemacht: der erste Versuch
lief sauber durch und hatte dabei saemtliche Unveraenderlichkeitsgarantien
der Datenbank entfernt. Die Vorgabeprivilegierung aus dem Init-Skript gibt
jeder von pg_restore neu angelegten Tabelle wieder alle Rechte, und GRANT
kann nur hinzufuegen. Die Anwendungsrolle durfte danach das Audit-Log
aendern, Wiki-Fassungen loeschen, Kiosk-Vorgaenge umschreiben - und nichts
haette darauf hingewiesen. Gefunden haben es vier Datenbanktests
unmittelbar nach der Probe, also genau die Probe, wegen der 1.0.0 sie
verlangt.

Die Pipeline ist geschrieben, aber NICHT erprobt: in Gitea ist kein
act_runner registriert. Das steht so im README und im Devlog, damit sich
niemand auf eine Pruefung verlaesst, die nicht stattfindet.

601 Unit-Tests gruen; Sicherung und Wiederherstellung einmal vollstaendig
durchgefuehrt und die Tests danach erneut gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-05 17:16:08 +02:00
co-authored by Claude Opus 5
parent 91742a0dd7
commit 9156dcc144
21 changed files with 2495 additions and 8 deletions
+94
View File
@@ -0,0 +1,94 @@
/**
* Die Sperren der Anwendungsrolle — nach einer Wiederherstellung.
*
* Hintergrund, und er ist unerwartet:
*
* Beim ersten Start richtet `docker/postgres/10-init-app-role.sh` eine
* Vorgabeprivilegierung ein — jede künftig angelegte Tabelle bekommt
* automatisch alle Rechte für `florian_app`. Das ist im Betrieb genau richtig:
* eine neue Migration soll nicht daran scheitern, dass jemand vergessen hat,
* Rechte zu vergeben.
*
* `pg_restore` legt die Tabellen aber ebenfalls neu an. Die Vorgabe greift
* also auch dort — und die `GRANT`-Anweisungen aus dem Dump können nur
* hinzufügen, nie wegnehmen. Nach einer Wiederherstellung stünde die
* Anwendungsrolle damit wieder mit allen Rechten da: sie dürfte das Audit-Log
* ändern, Wiki-Fassungen löschen, Kiosk-Vorgänge umschreiben.
*
* Nichts an der Anwendung würde darauf hinweisen. Die Unveränderlichkeit, auf
* der jeder Nachweis gegenüber der Unfallkasse beruht, wäre stillschweigend
* weg — bis jemand danach fragt.
*
* Gefunden haben das die Datenbanktests nach der ersten Rückspielprobe.
* Deshalb gibt es diese Datei: **eine** Liste, aus der sowohl das
* Wiederherstellen als auch das Prüfen der Sperren entsteht. Zwei Listen
* wären zwei Wahrheiten.
*/
/** Tabelle → Rechte, die `florian_app` NICHT haben darf, mit Begründung. */
export const SPERREN: Array<{ tabelle: string; rechte: string[]; grund: string }> = [
{
tabelle: "berechtigung",
rechte: ["INSERT", "UPDATE", "DELETE"],
grund: "systemweiter Rechtekatalog — nur lesbar",
},
{
tabelle: "audit_log",
rechte: ["UPDATE", "DELETE"],
grund: "append-only; das Löschen geht nur im Aufbewahrungslauf",
},
{
tabelle: "kiosk_vorgang",
rechte: ["UPDATE", "DELETE"],
grund: "übermittelte Terminalvorgänge sind unveränderlich",
},
{
tabelle: "druckmessung",
rechte: ["UPDATE", "DELETE"],
grund: "Atemschutzüberwachung — Druckwerte werden nie korrigiert",
},
{
tabelle: "atemschutz_ereignis",
rechte: ["UPDATE", "DELETE"],
grund: "wie oben: der Verlauf einer Überwachung bleibt, wie er war",
},
{
tabelle: "benachrichtigung",
rechte: ["DELETE"],
grund: "was zugestellt wurde, bleibt nachweisbar",
},
{
tabelle: "ki_vorgang",
rechte: ["DELETE"],
grund: "das KI-Protokoll belegt, was die Maschine getan hat",
},
{
tabelle: "wikiversion",
rechte: ["UPDATE", "DELETE"],
grund: "jede Fassung einer Wiki-Seite bleibt erhalten",
},
{
tabelle: "kassenabschluss",
rechte: ["DELETE"],
grund: "ein festgeschriebener Abschluss lässt sich nicht wegräumen",
},
{
tabelle: "benutzer",
rechte: ["INSERT", "DELETE"],
grund: "Konten entstehen und vergehen über den Systemzugang",
},
];
/** Die SQL-Anweisungen, die die Sperren wiederherstellen. */
export function sperrenSql(): string[] {
return SPERREN.map(
({ tabelle, rechte }) => `REVOKE ${rechte.join(", ")} ON "${tabelle}" FROM florian_app`,
);
}
/** Alle Kombinationen, die nach der Wiederherstellung fehlen müssen. */
export function verboteneRechte(): Array<{ tabelle: string; recht: string }> {
return SPERREN.flatMap(({ tabelle, rechte }) =>
rechte.map((recht) => ({ tabelle, recht })),
);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Die Verbindungsangaben für `pg_dump` und `pg_restore`.
*
* Eigene Datei, weil beide Skripte sie brauchen — und weil ein falsch
* zerlegter Zugang der unangenehmste Fehler in einem Sicherungsskript wäre:
* es liefe durch, schriebe eine Datei, und die enthielte die falsche
* Datenbank. Auffallen würde es beim Zurückspielen, also genau dann, wenn man
* die Sicherung braucht.
*/
export interface Zugang {
benutzer: string;
passwort: string;
datenbank: string;
}
/**
* Zerlegt eine Verbindungszeichenkette in ihre Teile.
*
* Die URL lässt sich nicht als Ganzes an `pg_dump` durchreichen: Prisma hängt
* eigene Parameter an (`?schema=public`), die es mit "invalid URI query
* parameter" ablehnt. Und der Port darin gilt vom Rechner aus — im Container,
* wo die Werkzeuge laufen, liegt Postgres auf 5432.
*/
export function zugangAus(roh: string | undefined): Zugang {
if (!roh) {
throw new Error("DIRECT_DATABASE_URL fehlt.");
}
let url: URL;
try {
url = new URL(roh);
} catch {
throw new Error(`Die Verbindungszeichenkette ist keine gültige URL: ${roh}`);
}
if (!url.protocol.startsWith("postgres")) {
throw new Error(`Erwartet wird eine postgresql://-Adresse, bekommen: ${url.protocol}//`);
}
const datenbank = url.pathname.replace(/^\//, "");
if (!datenbank) {
throw new Error("In der Verbindungszeichenkette fehlt der Datenbankname.");
}
return {
benutzer: decodeURIComponent(url.username),
passwort: decodeURIComponent(url.password),
datenbank,
};
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Sicherung: Datenbank und Dateien in ein Verzeichnis.
*
* npm run sicherung
* npm run sicherung -- --ziel D:\backups
*
* Eine Feuerwehrverwaltung ohne geprüfte Sicherung ist ein Aktenschrank ohne
* Schloss — bis zum ersten Festplattenfehler sieht man den Unterschied nicht.
* Deshalb gehört zu diesem Skript zwingend `npm run wiederherstellen`: eine
* Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.
*
* Gesichert wird beides, und zwar zusammen:
*
* * **Die Datenbank** über `pg_dump` im Postgres-Container. Im eigenen
* Format (`--format=custom`), weil sich daraus einzelne Tabellen
* zurückholen lassen und es kleiner ist.
* * **Die Dateien** aus MinIO — Prüfprotokolle, Einsatzberichte, Fotos,
* Rechnungen. Ohne sie wäre die Datenbank voller Verweise ins Leere.
*
* Beides muss aus demselben Moment stammen. Eine Datenbank von heute mit
* Dateien von gestern ergibt Datensätze, die auf nichts zeigen.
*/
import "dotenv/config";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync, createWriteStream } from "node:fs";
import { join, dirname } from "node:path";
import { pipeline } from "node:stream/promises";
import { Client as MinioClient } from "minio";
import { zugangAus } from "./sicherung-zugang";
const ZIEL_VORGABE = "sicherungen";
const CONTAINER = process.env.SICHERUNG_POSTGRES_CONTAINER ?? "florian-postgres";
function log(nachricht: string): void {
console.info(`[sicherung] ${nachricht}`);
}
/** Ein Zeitstempel, der sich als Verzeichnisname eignet. */
function stempel(): string {
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
}
function argument(name: string): string | undefined {
const stelle = process.argv.indexOf(`--${name}`);
return stelle >= 0 ? process.argv[stelle + 1] : undefined;
}
/**
* Die Datenbank sichern.
*
* Über `docker compose exec` statt über einen lokalen `pg_dump`: die Version
* des Clients muss zur Serverversion passen, und im Container ist sie das
* garantiert. Auf einem Rechner ohne passendes `pg_dump` schlüge es sonst mit
* einer Meldung fehl, die niemand mit der Ursache in Verbindung bringt.
*/
function datenbankSichern(ziel: string): { datei: string; bytes: number } {
const { benutzer, passwort, datenbank } = zugangAus(
process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL,
);
const datei = join(ziel, "datenbank.dump");
log(`Datenbank aus Container "${CONTAINER}" ...`);
const lauf = spawnSync(
"docker",
[
"compose",
"exec",
"-T",
"postgres",
"pg_dump",
"--format=custom",
// Der Eigentuemer wird nicht mitgesichert (er heisst auf jedem Server
// anders), die RECHTE dagegen schon. Ohne sie waeren nach dem
// Zurueckspielen alle REVOKEs der Migrationen weg: die Anwendungsrolle
// duerfte das Audit-Log aendern, Wiki-Fassungen loeschen,
// Kiosk-Vorgaenge umschreiben. Nichts wuerde darauf hinweisen.
"--no-owner",
"--username",
benutzer,
"--dbname",
datenbank,
],
{
encoding: "buffer",
maxBuffer: 2_000_000_000,
shell: true,
env: { ...process.env, PGPASSWORD: passwort },
},
);
if (lauf.status !== 0) {
const fehler = lauf.stderr?.toString() ?? "unbekannt";
throw new Error(`pg_dump ist fehlgeschlagen:\n${fehler}`);
}
writeFileSync(datei, lauf.stdout);
return { datei: "datenbank.dump", bytes: lauf.stdout.byteLength };
}
/** Alle Objekte aus MinIO holen. */
async function dateienSichern(ziel: string): Promise<{ anzahl: number; bytes: number }> {
const bucket = process.env.S3_BUCKET ?? "florian";
const client = new MinioClient({
endPoint: process.env.S3_ENDPOINT ?? "localhost",
port: Number(process.env.S3_PORT ?? 9000),
useSSL: (process.env.S3_USE_SSL ?? "false") === "true",
accessKey: process.env.S3_ACCESS_KEY ?? "",
secretKey: process.env.S3_SECRET_KEY ?? "",
});
if (!(await client.bucketExists(bucket))) {
log(`Bucket "${bucket}" gibt es nicht — keine Dateien zu sichern.`);
return { anzahl: 0, bytes: 0 };
}
const ordner = join(ziel, "dateien");
mkdirSync(ordner, { recursive: true });
let anzahl = 0;
let bytes = 0;
const strom = client.listObjectsV2(bucket, "", true);
for await (const objekt of strom) {
if (!objekt.name) continue;
const pfad = join(ordner, objekt.name);
mkdirSync(dirname(pfad), { recursive: true });
const quelle = await client.getObject(bucket, objekt.name);
await pipeline(quelle, createWriteStream(pfad));
anzahl += 1;
bytes += objekt.size ?? 0;
if (anzahl % 50 === 0) log(` ${anzahl} Dateien ...`);
}
return { anzahl, bytes };
}
async function main(): Promise<void> {
const wurzel = argument("ziel") ?? ZIEL_VORGABE;
const ziel = join(wurzel, stempel());
mkdirSync(ziel, { recursive: true });
log(`Ziel: ${ziel}`);
const datenbank = datenbankSichern(ziel);
log(`Datenbank: ${(datenbank.bytes / 1024 / 1024).toFixed(1)} MB`);
const dateien = await dateienSichern(ziel);
log(`Dateien: ${dateien.anzahl} Stück, ${(dateien.bytes / 1024 / 1024).toFixed(1)} MB`);
/**
* Das Manifest.
*
* Es hält fest, woraus die Sicherung stammt und wie groß sie ist. Beim
* Zurückspielen wird es gelesen und angezeigt — damit niemand versehentlich
* eine Sicherung von vor drei Monaten einspielt, weil das Verzeichnis
* ähnlich hieß.
*/
const manifest = {
erstelltAm: new Date().toISOString(),
version: process.env.npm_package_version ?? "unbekannt",
datenbank,
dateien,
pruefsumme: createHash("sha256")
.update(`${datenbank.bytes}:${dateien.anzahl}:${dateien.bytes}`)
.digest("hex"),
};
writeFileSync(join(ziel, "manifest.json"), JSON.stringify(manifest, null, 2));
log("");
log("Fertig. Zum Prüfen der Sicherung:");
log(` npm run wiederherstellen -- --quelle ${ziel} --wirklich`);
log("");
log("Eine Sicherung, die nie zurueckgespielt wurde, ist eine Vermutung.");
}
main().catch((fehler) => {
console.error("[sicherung] Abbruch:", fehler);
process.exit(1);
});
+270
View File
@@ -0,0 +1,270 @@
/**
* Wiederherstellung aus einer Sicherung.
*
* npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-00-00
* npm run wiederherstellen -- --quelle ... --wirklich
*
* Ohne `--wirklich` wird nur angezeigt, was geschehen würde. Das ist keine
* Bequemlichkeit: dieses Skript **überschreibt den gesamten Bestand**, und es
* gibt keinen Weg zurück außer einer weiteren Sicherung.
*
* Der eigentliche Zweck ist nicht der Notfall, sondern die Probe. Eine
* Sicherung, die nie zurückgespielt wurde, ist eine Vermutung — der Ordner
* kann leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es
* an dem Tag, an dem man es nicht erfahren will. Deshalb gehört dieser Lauf
* einmal durchgeführt und im Sicherungskonzept vermerkt.
*/
import "dotenv/config";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync, statSync, createReadStream } from "node:fs";
import { join, relative, sep } from "node:path";
import { Client as MinioClient } from "minio";
import { zugangAus } from "./sicherung-zugang";
import { SPERREN, sperrenSql, verboteneRechte } from "./sicherung-sperren";
function log(nachricht: string): void {
console.info(`[wiederherstellen] ${nachricht}`);
}
function argument(name: string): string | undefined {
const stelle = process.argv.indexOf(`--${name}`);
return stelle >= 0 ? process.argv[stelle + 1] : undefined;
}
const wirklich = process.argv.includes("--wirklich");
/** Alle Dateien unterhalb eines Verzeichnisses, rekursiv. */
function alleDateien(wurzel: string): string[] {
if (!existsSync(wurzel)) return [];
const gefunden: string[] = [];
for (const eintrag of readdirSync(wurzel)) {
const pfad = join(wurzel, eintrag);
if (statSync(pfad).isDirectory()) gefunden.push(...alleDateien(pfad));
else gefunden.push(pfad);
}
return gefunden;
}
function datenbankZurueck(quelle: string): void {
const { benutzer, passwort, datenbank } = zugangAus(
process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL,
);
const dump = join(quelle, "datenbank.dump");
if (!existsSync(dump)) throw new Error(`In ${quelle} liegt keine datenbank.dump.`);
log("Spiele die Datenbank zurück ...");
/**
* `--clean --if-exists` räumt vorher auf.
*
* Ohne das Aufräumen schlüge jede `CREATE TABLE` fehl, und übrig bliebe ein
* Mischbestand aus alten und neuen Zeilen — das Schlimmste, was bei einer
* Wiederherstellung herauskommen kann, weil es auf den ersten Blick
* funktioniert.
*/
const lauf = spawnSync(
"docker",
[
"compose",
"exec",
"-T",
"postgres",
"pg_restore",
"--clean",
"--if-exists",
"--no-owner",
"--username",
benutzer,
"--dbname",
datenbank,
],
{
input: readFileSync(dump),
encoding: "buffer",
maxBuffer: 2_000_000_000,
shell: true,
env: { ...process.env, PGPASSWORD: passwort },
},
);
// pg_restore meldet auch dann einen Fehlercode, wenn nur einzelne DROP-
// Anweisungen ins Leere liefen. Das ist bei `--if-exists` normal, deshalb
// wird die Ausgabe angesehen statt nur der Rückgabewert.
const ausgabe = lauf.stderr?.toString() ?? "";
const echteFehler = ausgabe
.split("\n")
.filter((zeile) => zeile.includes("error:") && !zeile.includes("does not exist"));
if (echteFehler.length > 0) {
throw new Error(`pg_restore ist fehlgeschlagen:\n${echteFehler.join("\n")}`);
}
log("Datenbank zurückgespielt.");
}
async function dateienZurueck(quelle: string): Promise<number> {
const bucket = process.env.S3_BUCKET ?? "florian";
const ordner = join(quelle, "dateien");
const client = new MinioClient({
endPoint: process.env.S3_ENDPOINT ?? "localhost",
port: Number(process.env.S3_PORT ?? 9000),
useSSL: (process.env.S3_USE_SSL ?? "false") === "true",
accessKey: process.env.S3_ACCESS_KEY ?? "",
secretKey: process.env.S3_SECRET_KEY ?? "",
});
if (!(await client.bucketExists(bucket))) {
await client.makeBucket(bucket);
log(`Bucket "${bucket}" angelegt.`);
}
const dateien = alleDateien(ordner);
let hochgeladen = 0;
for (const pfad of dateien) {
// Der Objektschlüssel ist der Pfad unterhalb von "dateien/" — mit
// Schrägstrichen, auch unter Windows.
const schluessel = relative(ordner, pfad).split(sep).join("/");
await client.putObject(bucket, schluessel, createReadStream(pfad), statSync(pfad).size);
hochgeladen += 1;
if (hochgeladen % 50 === 0) log(` ${hochgeladen} Dateien ...`);
}
return hochgeladen;
}
/**
* Die Sperren wieder setzen — und danach nachsehen, ob es gewirkt hat.
*
* Warum das nötig ist, steht in `sicherung-sperren.ts`: die
* Vorgabeprivilegierung des Init-Skripts gibt jeder von `pg_restore` neu
* angelegten Tabelle wieder alle Rechte, und die `GRANT`-Zeilen aus dem Dump
* können nur hinzufügen, nie wegnehmen.
*
* Erst setzen, dann prüfen — und nicht nur prüfen. Ein Skript, das ein
* bekanntes Problem meldet, statt es zu beheben, verlagert die Arbeit auf den
* ungünstigsten Zeitpunkt: den Notfall, in dem jemand gerade eine Sicherung
* zurückspielt.
*/
async function sperrenSetzen(): Promise<void> {
const { PrismaClient } = await import("@prisma/client");
const prisma = new PrismaClient({
datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? "" } },
});
try {
const rolleDa = await prisma.$queryRawUnsafe<Array<{ da: boolean }>>(
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da",
);
if (!rolleDa[0]?.da) {
log("Die Rolle florian_app gibt es nicht — Sperren übersprungen.");
return;
}
for (const anweisung of sperrenSql()) {
await prisma.$executeRawUnsafe(anweisung);
}
log(`Sperren gesetzt: ${SPERREN.length} Tabellen.`);
// Gegenprobe. Sie fragt die Datenbank, nicht das eigene Vorhaben ab.
const zeilen = await prisma.$queryRawUnsafe<
Array<{ table_name: string; privilege_type: string }>
>(
`SELECT table_name, privilege_type
FROM information_schema.role_table_grants
WHERE grantee = 'florian_app'`,
);
const vorhanden = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`));
const befunde = verboteneRechte()
.filter(({ tabelle, recht }) => vorhanden.has(`${tabelle}:${recht}`))
.map(({ tabelle, recht }) => ` ${tabelle}: ${recht}`);
if (befunde.length > 0) {
log("");
log("ACHTUNG: Die Anwendungsrolle hat Rechte, die sie nicht haben darf:");
befunde.forEach((zeile) => log(zeile));
log("Die Unveraenderlichkeit von Protokollen ist damit aufgehoben.");
process.exitCode = 1;
return;
}
log("Gegenprobe: keine Tabelle hat mehr Rechte als vorgesehen.");
} finally {
await prisma.$disconnect();
}
}
async function main(): Promise<void> {
const quelle = argument("quelle");
if (!quelle) {
console.error("[wiederherstellen] --quelle <Verzeichnis> fehlt.");
process.exit(1);
}
const manifestPfad = join(quelle, "manifest.json");
if (!existsSync(manifestPfad)) {
console.error(
`[wiederherstellen] In ${quelle} liegt keine manifest.json. ` +
"Ist das wirklich ein Sicherungsverzeichnis?",
);
process.exit(1);
}
const manifest = JSON.parse(readFileSync(manifestPfad, "utf-8")) as {
erstelltAm: string;
version: string;
datenbank: { bytes: number };
dateien: { anzahl: number; bytes: number };
};
const alter = Math.round(
(Date.now() - new Date(manifest.erstelltAm).getTime()) / 1000 / 60 / 60 / 24,
);
log("");
log(`Sicherung vom ${manifest.erstelltAm.slice(0, 16).replace("T", " ")} (${alter} Tage alt)`);
log(`Version: ${manifest.version}`);
log(`Datenbank: ${(manifest.datenbank.bytes / 1024 / 1024).toFixed(1)} MB`);
log(`Dateien: ${manifest.dateien.anzahl} Stück`);
log("");
if (!wirklich) {
log("PROBELAUF — es wird nichts geändert.");
log("");
log("Zum wirklichen Zurückspielen dieselbe Zeile mit --wirklich am Ende.");
log("Achtung: dabei wird der gesamte vorhandene Bestand überschrieben.");
return;
}
if (alter > 30) {
log(`WARNUNG: Diese Sicherung ist ${alter} Tage alt.`);
}
log("Spiele zurück — der vorhandene Bestand wird überschrieben.");
datenbankZurueck(quelle);
const dateien = await dateienZurueck(quelle);
log(`Dateien: ${dateien} zurückgespielt.`);
await sperrenSetzen();
log("");
log("Fertig. Bitte jetzt von Hand prüfen:");
log(" 1. Anmelden — kommt jemand hinein?");
log(" 2. Ein Prüfprotokoll öffnen — ist das PDF da?");
log(" 3. Die Mitgliederzahl mit der Erwartung vergleichen.");
log("");
log("Erst danach gilt die Sicherung als geprüft.");
}
main().catch((fehler) => {
console.error("[wiederherstellen] Abbruch:", fehler);
process.exit(1);
});