Auf der Regelseite des Treffs steht seit dem 11.09. "ab 18". Geprueft
wurde es nirgends: MINDESTALTER war ein Text auf einer Seite, sonst
nichts. Eine Regel, die nur dasteht, ist keine -- im Streitfall ist sie
sogar schlechter als keine, weil man sie versprochen hat.
DIE FRAGE STEHT AN DER TUER
Ein Community-Mitglied bestaetigt beim ERSTEN Hereinkommen, mindestens
18 zu sein. Danach nie wieder.
An der Tuer und nicht auf einer Seite dahinter: Eine Sperre auf den
Brettern liesse sich ueber eine andere Adresse umgehen und muesste
auf jeder kuenftigen Seite mitgedacht werden. Die Tuer gibt es genau
einmal.
Ein EIGENER Fehler (400 alter_offen), nicht "ungueltig": Hier ist der
Code richtig. Wer dieselbe Antwort bekaeme wie bei einem Tippfehler,
tippte seinen Code immer wieder neu ein, und es wuerde nie besser.
Und es zaehlt NICHT als Fehlversuch. Sonst sperrt sich jemand mit dem
richtigen Code nach acht Anlaeufen selbst aus.
Nur die Community: Wer zum Team gehoert, hat seinen Zugang von
DogFather persoenlich bekommen -- da ist die Frage vorher geklaert.
NUR EIN ZEITSTEMPEL, KEIN GEBURTSDATUM
Gebraucht wird die Antwort auf "hat bestaetigt, und wann" -- nicht der
Geburtstag. Ein Geburtsdatum waeren mehr Daten fuer dieselbe Auskunft,
und Datenminimierung gilt auch fuer das eigene Nachweisbeduerfnis. Eine
Spalte, mehr nicht.
DIE RECHTSGRUNDLAGE FOLGT AUS DER ROLLE -- UND WIRD NICHT GESPEICHERT
Ein Modi arbeitet hier (Vertrag), ein Community-Mitglied ist freiwillig
da (Einwilligung). Was sich ableiten laesst, bekommt keine Spalte:
sonst gaebe es zwei Wahrheiten, von denen eine veraltet, sobald jemand
die Rolle wechselt. Sie steht jetzt in jeder Auskunft (Art. 15 Abs. 1
lit. a) und im Loeschkonzept.
MINDESTALTER ZIEHT INS IMPORTFREIE BLATT
Die Tuer braucht die Zahl jetzt auch, und ein Import zwischen
workspace.js und workspace-treff.js waere ein Kreis -- genau der Grund,
aus dem treff-tabellen.js existiert. Die Zahl steht weiterhin an EINER
Stelle; der Regeltext bekommt sie unveraendert.
EIN EIGENER FEHLER, GEFUNDEN VON DER PRUEFUNG
Ich hatte `export { MINDESTALTER } from "./treff-tabellen.js"` benutzt --
eine Durchreiche. Der Name ist damit fuer IMPORTEURE da, aber nicht in
der Datei selbst. Die Regelroute benutzt ihn selbst und lief in einen
ReferenceError, und zwar erst beim Aufruf. pruef-alter hat es gesehen,
nicht das Auge.
VIER PRUEFUNGEN MUSSTEN NACHZIEHEN
pruef-treff, pruef-treff-werkzeuge, pruef-auskunft und pruef-neue-seiten
melden sich als Community an. Sie schicken das Haekchen jetzt mit --
aber NUR fuer 'gast'. Ginge es immer mit, koennte keine Pruefung mehr
sehen, ob der Server es fuer das Team ueberhaupt ignoriert.
PRUEFUNGEN: pruef-alter neu mit 35, davon 7 im Browser. Darunter die,
die man vergisst: Wird beim ZWEITEN Mal wieder gefragt? (nein) -- denn
eine Abfrage, die jedes Mal kommt, wird weggeklickt, ohne gelesen zu
werden, und bestaetigt ab da nichts mehr.
Co-Authored-By: Claude Opus 5 <[email protected]>
304 lines
13 KiB
JavaScript
304 lines
13 KiB
JavaScript
/* =====================================================================
|
||
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" });
|
||
}
|
||
});
|