/* ===================================================================== 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(); /** Auf welcher Grundlage die Daten eines Menschen verarbeitet werden. * * ABGELEITET, NICHT GESPEICHERT. Ein Modi arbeitet hier — das ist ein * Vertragsverhaeltnis. Ein Mitglied der Community ist freiwillig da — * das ist eine Einwilligung. Die Grundlage folgt damit aus der Rolle, * und was sich ableiten laesst, bekommt keine eigene Spalte: Sonst * gaebe es zwei Wahrheiten, von denen eine veraltet, sobald jemand * die Rolle wechselt. * * Gebraucht wird sie an zwei Stellen: in der Auskunft (Art. 15 Abs. 1 * lit. a — der Mensch darf den Zweck erfahren) und im Loeschkonzept. */ export function grundlageVon(rolle) { if (rolle === "gast") { return { grundlage: "Art. 6 Abs. 1 lit. a DSGVO – Einwilligung", warum: "Du bist freiwillig im Treff. Du kannst jederzeit gehen, und deine " + "Daten gehen mit.", }; } return { grundlage: "Art. 6 Abs. 1 lit. b DSGVO – zur Durchführung des Vertrags", warum: "Du arbeitest hier. Ohne diese Daten lässt sich die Zusammenarbeit " + "nicht durchführen.", }; } /** 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: "alter_bestaetigt", was: "Der Zeitpunkt der Altersbestätigung (Community)", art: "bleibt", frist: "solange der Zugang besteht", zweck: "Nachweisen können, dass das Mindestalter bestätigt wurde – " + "eine Regel, die man nicht belegen kann, hat man nicht.", grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse an Nachweisbarkeit", wirkung: "Gespeichert wird NUR der Zeitpunkt, kein Geburtsdatum. Für die Frage " + "„hat bestätigt, und wann?“ reicht das – ein Geburtstag wären mehr Daten " + "für dieselbe Auskunft.", offen: (d) => zaehle(d, "SELECT COUNT(*) AS n FROM personen WHERE alter_bestaetigt_am IS NOT NULL"), }, { 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" }); } });