Das Loeschkonzept ist kein Dokument, sondern eine Tabelle, die sich durchsetzt

Stufe 8 aus dem Plan zur Perfektion: "Loeschkonzept je Datenart ·
IP-Adressen im Protokoll nach 30 Tagen weg. Je mehr Daten, desto teurer
das Nachholen."

WARUM NICHT ALS NOTIZ

Ein Loeschkonzept als Dokument ist nach dem ersten neuen Feld falsch,
und niemand merkt es. workspace-aufbewahrung.js ist beides: die
Uebersicht, was wie lange aufgehoben wird -- UND das Programm, das es
tut. Was dort nicht steht, wird nicht geraeumt; was dort steht, wird
geraeumt.

GEMESSEN, BEVOR ETWAS GEBAUT WURDE

42 Tabellen durchgesehen. Drei halten IP-Adressen:

  versuche.ip    wird beim Anmelden im Fenster geraeumt        ok
  sitzungen.ip   nur beim Abmelden -- ABGELAUFENE Sitzungen
                 standen ewig da, samt IP und Browser          Luecke
  protokoll.ip   wurde nirgends geraeumt, wuchs ewig           Luecke

Zwei echte Luecken also, nicht fuenf vermutete.

DREI SORTEN EINTRAG, UND DIE DRITTE IST DIE WICHTIGSTE

  raeumen   Diese Datei loescht selbst.
  fremd     Eine andere Stelle loescht -- hier wird nur GEZAEHLT.
            Bricht die andere Stelle, steht die Zahl trotzdem da.
  bleibt    Wird absichtlich aufgehoben, mit Begruendung.

Ein Loeschkonzept, das nur Loeschungen auffuehrt, ist ein halbes: Die
begruendete Aufbewahrung ist der Teil, nach dem gefragt wird. Jede
Zeile nennt Frist, Zweck, Rechtsgrundlage und Wirkung.

SICHTBAR, NICHT NUR WIRKSAM

Auf automation.html -- dort steht alles, was ohne Zutun laeuft, und
genau das ist es. Bei jeder Zeile die Zahl, wie viele Datensaetze sie
gerade betrifft, und ein Knopf "Jetzt aufraeumen", damit es kein
schwarzer Kasten ist. Die Sorte steht als WORT daneben, nicht nur als
Farbe.

BEI ETWAS, DAS LOESCHT, IST DIE WICHTIGERE HAELFTE: WAS ES NICHT LOESCHT

Zu jeder Loeschung eine Gegenprobe:

  IP nach 30 Tagen weg        -> und die von vor 29 Tagen bleibt
  IP weg                      -> und die ZEILE bleibt (nur das Feld)
  abgelaufene Sitzung weg     -> und die gueltige bleibt
  abgelaufene Sitzung weg     -> und ich bin danach noch angemeldet

Die letzte ist die unbequemste und die wichtigste: Ein Aufraeumlauf,
der die eigene Anmeldung mitnimmt, wirft im Betrieb alle hinaus -- und
zwar einmal taeglich. Dazu: ein zweiter Lauf muss NICHTS mehr finden
(sonst waere "aufgeraeumt" nicht von "nichts getan" zu unterscheiden),
und eine Zaehlung ueber eine fehlende Tabelle ergibt `null`, nicht 0.

NEBENBEI: EINE EIGENE DOPPLUNG VON GESTERN

Drei der Seitenerklaerungen sagten in "Wofuer" und "Du tust" fast
dasselbe (report 80 %, treff-regeln 67 %, startcheck 50 %). Gestern
hatte ich nur den Fall geprueft, an dem ich mich verbrannt hatte --
"Wofuer" gegen die Unterzeile -- und die naheliegendere Dopplung
uebersehen. Jetzt gemessen, schlechtester Wert 25 %.

PRUEFUNGEN: pruef-aufbewahrung neu mit 41, davon 7 im Browser
(der Knopf muss eine Zahl wirklich fallen lassen, und die IP muss
danach in der Datenbank weg sein). pruef-erklaerung 44 -> 47.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-15 00:36:57 +02:00
co-authored by Claude Opus 5
parent 4571b15494
commit e975983ad1
36 changed files with 1214 additions and 373 deletions
+263
View File
@@ -0,0 +1,263 @@
/* =====================================================================
Das Löschkonzept — als Tabelle, die sich selbst durchsetzt (15.09.2026)
Stufe 8 aus [[Creator Workspace – Plan zur Perfektion Stufe 2]]:
„Löschkonzept je Datenart · IP-Adressen im Protokoll nach 30 Tagen
weg. Je mehr Daten, desto teurer das Nachholen."
---------------------------------------------------------------------
WARUM DAS KEIN DOKUMENT IST
Ein Löschkonzept als Notiz ist nach dem ersten neuen Feld falsch, und
niemand merkt es. Diese Datei ist deshalb beides: die Übersicht, was
wie lange aufgehoben wird — UND das Programm, das es tut. Was hier
nicht steht, wird nicht geräumt; was hier steht, wird geräumt.
---------------------------------------------------------------------
WAS GEMESSEN WURDE, BEVOR HIER ETWAS STAND
42 Tabellen durchgesehen, drei halten IP-Adressen:
versuche.ip wird beim Anmelden selbst im Fenster geräumt (ok)
sitzungen.ip wird beim Abmelden geloescht -- ABGELAUFENE
Sitzungen aber nie (Luecke)
protokoll.ip wird nirgends geraeumt, waechst ewig (Luecke)
Zwei echte Luecken also, nicht fuenf vermutete.
---------------------------------------------------------------------
DREI SORTEN EINTRAG, UND DIE DRITTE IST DIE WICHTIGSTE
raeumen Diese Datei loescht selbst.
fremd Eine andere Stelle loescht -- hier wird nur GEZAEHLT.
Bricht die andere Stelle, steht die Zahl trotzdem da.
bleibt Wird absichtlich aufgehoben, mit Begruendung.
Ein Loeschkonzept, das nur Loeschungen auffuehrt, ist ein halbes:
Die begruendete Aufbewahrung ist der Teil, nach dem gefragt wird.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, istDogFather, protokolliere, echteIp,
einstellung, einstellungSetzen,
} from "./workspace.js";
export const aufbewahrungRouter = express.Router();
/** Wie lange IP-Adressen im Protokoll stehen bleiben.
*
* 30 Tage: lang genug, um einen Missbrauch nachzuvollziehen, den man
* erst spaeter bemerkt -- kurz genug, dass daraus kein Bewegungsbild
* wird. Dieselbe Frist, die Filipe im Plan genannt hat. */
export const IP_TAGE = 30;
const vorTagen = (n) => new Date(Date.now() - n * 86400000).toISOString();
const jetzt = () => new Date().toISOString();
/** Zaehlt, ohne zu aendern. Ein Fehler hier ist kein Befund, sondern
* eine Zahl, die fehlt -- deshalb null statt 0, damit „konnte nicht
* nachsehen" nicht wie „nichts zu tun" aussieht. */
function zaehle(d, sql, ...werte) {
try { return d.prepare(sql).get(...werte)?.n ?? null; } catch { return null; }
}
/* =====================================================================
DIE TAFEL
===================================================================== */
export const FRISTEN = [
{
schluessel: "protokoll_ip",
was: "IP-Adressen im Protokoll",
art: "raeumen",
frist: `${IP_TAGE} Tage`,
zweck: "Einen Missbrauch nachvollziehen können, der erst später auffällt.",
grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse",
wirkung: "Nur die IP wird geleert. Wer wann was getan hat, bleibt stehen – "
+ "das Protokoll ist die Sicherung gegen stille Änderungen.",
offen: (d) => zaehle(d,
"SELECT COUNT(*) AS n FROM protokoll WHERE ip IS NOT NULL AND zeitpunkt < ?", vorTagen(IP_TAGE)),
raeumen: (d) => d.prepare(
"UPDATE protokoll SET ip = NULL WHERE ip IS NOT NULL AND zeitpunkt < ?")
.run(vorTagen(IP_TAGE)).changes,
},
{
schluessel: "sitzungen_abgelaufen",
was: "Abgelaufene Anmeldungen (mit IP und Browser)",
art: "raeumen",
frist: "sofort nach Ablauf",
zweck: "Angemeldet bleiben, solange die Sitzung gilt.",
grundlage: "Art. 6 Abs. 1 lit. b DSGVO – zur Durchführung erforderlich",
wirkung: "Die Zeile verschwindet ganz. Nach Ablauf ist sie zu nichts mehr nütze – "
+ "sie stand bisher nur da und trug eine IP mit sich.",
offen: (d) => zaehle(d,
"SELECT COUNT(*) AS n FROM sitzungen WHERE gueltig_bis < ?", jetzt()),
raeumen: (d) => d.prepare("DELETE FROM sitzungen WHERE gueltig_bis < ?")
.run(jetzt()).changes,
},
{
schluessel: "versuche",
was: "Fehlversuche beim Anmelden (IP)",
art: "fremd",
frist: "10 Minuten",
zweck: "Ausprobieren von Codes bremsen.",
grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse",
wirkung: "Wird beim Anmelden selbst geräumt (workspace.js). Hier steht die Zahl "
+ "trotzdem – bricht die andere Stelle, sieht man es hier.",
offen: (d) => zaehle(d,
"SELECT COUNT(*) AS n FROM versuche WHERE zeitpunkt < ?",
new Date(Date.now() - 60 * 60000).toISOString()),
},
{
schluessel: "protokoll_eintraege",
was: "Die Protokolleinträge selbst (ohne IP)",
art: "bleibt",
frist: "unbefristet",
zweck: "Nachvollziehen, wer wann einen Zugang angelegt, gesperrt oder gelöscht hat.",
grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse an Nachvollziehbarkeit",
wirkung: "Bleibt. Ein Protokoll, das sich selbst löscht, sichert nichts.",
offen: (d) => zaehle(d, "SELECT COUNT(*) AS n FROM protokoll"),
},
{
schluessel: "treff_massnahmen",
was: "Maßnahmen im Treff (Hinweis, Pause, Ausschluss) mit Begründung",
art: "bleibt",
frist: "solange die Person ein Konto hat",
zweck: "Eine Entscheidung über einen Menschen muss begründet nachlesbar sein – "
+ "das schützt beide Seiten.",
grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse",
wirkung: "Bleibt, solange es den Zugang gibt. Wird der Zugang gelöscht, geht die "
+ "Maßnahme mit (ON DELETE CASCADE).",
offen: (d) => zaehle(d, "SELECT COUNT(*) AS n FROM treff_massnahmen"),
},
{
schluessel: "personen_geloescht",
was: "Daten einer gelöschten Person",
art: "bleibt",
frist: "gehen mit dem Zugang",
zweck: "—",
grundlage: "Art. 17 DSGVO – Recht auf Löschung",
wirkung: "Wer in „Personen & Zugänge“ gelöscht wird, nimmt Sitzungen, Maßnahmen, "
+ "Entwicklungsstände und Chat-Zugehörigkeit mit – die Datenbank erzwingt das "
+ "über ON DELETE CASCADE, nicht eine Liste, die jemand pflegt.",
offen: (d) => zaehle(d, "SELECT COUNT(*) AS n FROM personen WHERE aktiv = 0"),
},
];
/* =====================================================================
DER LAUF
===================================================================== */
/** Räumt alles, was zu räumen ist.
*
* @returns {{getan: Array, fehler: Array}} was passiert ist
*/
export function aufbewahrungLauf() {
const d = db();
const getan = [];
const fehler = [];
for (const f of FRISTEN) {
if (typeof f.raeumen !== "function") continue;
try {
const anzahl = f.raeumen(d);
if (anzahl) getan.push({ schluessel: f.schluessel, anzahl });
} catch (fe) {
/* EIN FEHLSCHLAG DARF DEN REST NICHT AUFHALTEN -- und er darf
nicht still bleiben. Waere er still, hiesse „aufgeraeumt"
irgendwann „einmal probiert". */
fehler.push({ schluessel: f.schluessel, grund: String(fe?.message || fe).slice(0, 200) });
console.error("[aufbewahrung]", f.schluessel, fe?.message);
}
}
try {
einstellungSetzen("aufbewahrung_letzte",
JSON.stringify({ zeitpunkt: jetzt(), getan, fehler }));
} catch { /* der Lauf zaehlt, nicht sein Vermerk */ }
/* Nur protokollieren, wenn wirklich etwas passiert ist. Ein taeglicher
Eintrag „nichts zu tun" waere genau die Art Rauschen, die das
Protokoll unlesbar macht -- und ausgerechnet dieses Protokoll ist
die Sicherung gegen stille Aenderungen. */
if (getan.length || fehler.length) {
try {
protokolliere("aufbewahrung", {
detail: getan.map((g) => `${g.schluessel}: ${g.anzahl}`).join(", ")
+ (fehler.length ? ` | FEHLER: ${fehler.map((f) => f.schluessel).join(", ")}` : ""),
});
} catch { /* egal */ }
}
return { getan, fehler };
}
/** Einmal am Tag, und einmal kurz nach dem Start.
*
* Nicht im Startmoment: Erst soll die Website antworten. Dieselbe
* Ueberlegung wie bei der Sicherung -- ein Aufraeumlauf im
* Startmoment wuerde die Datenbank nur unnoetig frueh oeffnen und
* einen Fehler dort zum Startfehler machen. */
export function aufbewahrungStarten() {
const ersterLauf = setTimeout(() => { try { aufbewahrungLauf(); } catch { /* egal */ } }, 90_000);
const takt = setInterval(() => { try { aufbewahrungLauf(); } catch { /* egal */ } }, 24 * 3600_000);
ersterLauf.unref?.();
takt.unref?.();
}
/* =====================================================================
DIE TAFEL ZUM ANSEHEN
===================================================================== */
function nurDogFather(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
/* 404 und nicht 403: Wer „kein Zugriff" liest, weiss, dass es die
Seite gibt. Dieselbe Antwort wie ueberall im Haus. */
if (!istDogFather(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
req.person = person;
next();
}
aufbewahrungRouter.get("/workspace/api/aufbewahrung", nurDogFather, (req, res) => {
try {
const d = db();
let letzte = null;
try { letzte = JSON.parse(einstellung("aufbewahrung_letzte") || "null"); } catch { letzte = null; }
const zeilen = FRISTEN.map((f) => ({
schluessel: f.schluessel,
was: f.was,
art: f.art,
frist: f.frist,
zweck: f.zweck,
grundlage: f.grundlage,
wirkung: f.wirkung,
/* `offen` heisst bei „raeumen": so viele warten. Bei „bleibt":
so viele liegen da. Zwei verschiedene Bedeutungen unter einem
Namen waeren eine Falle -- deshalb steht `art` daneben und die
Oberflaeche beschriftet die Zahl danach. */
anzahl: f.offen ? f.offen(d) : null,
}));
res.json({ fristen: zeilen, letzte, ip_tage: IP_TAGE });
} catch (fehler) {
console.error("[aufbewahrung] Tafel:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Von Hand anstossen.
*
* Damit es kein schwarzer Kasten ist: Wer sehen will, ob es wirkt,
* drueckt drauf und sieht die Zahlen fallen. */
aufbewahrungRouter.post("/workspace/api/aufbewahrung/jetzt", nurDogFather, (req, res) => {
try {
const ergebnis = aufbewahrungLauf();
protokolliere("aufbewahrung_von_hand", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: ergebnis.getan.map((g) => `${g.schluessel}: ${g.anzahl}`).join(", ") || "nichts zu tun",
});
res.json(ergebnis);
} catch (fehler) {
console.error("[aufbewahrung] Lauf:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});