Files
dogfather-universe/server/pruef-auskunft.mjs
T
DogFatherGitandClaude Opus 5 b9f261bbac Das Mindestalter wird eingeloest statt behauptet -- Stufe 8 ist damit durch
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]>
2026-09-15 01:09:52 +02:00

341 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
Auskunft auf Knopfdruck — Art. 15 DSGVO (15.09.2026)
---------------------------------------------------------------------
DIE EINE FRAGE, DIE ZÄHLT: IST SIE VOLLSTÄNDIG?
Eine Auskunft, die eine Tabelle vergisst, ist schlimmer als keine —
sie ist eine falsche Aussage über die Daten eines Menschen. Deshalb
prüft diese Datei nicht, ob *etwas* zurückkommt, sondern ob **jede**
Stelle gefunden wird, an der etwas liegt:
1. Jede Spalte mit Fremdschlüssel auf `personen` ist erfasst –
abgeleitet aus der Datenbank, nicht gegen eine Zahl.
2. Jede Spalte, die auf `_id` endet und KEINEN Fremdschlüssel hat,
ist eingeordnet: entweder „ist eine Person" oder „ist keine".
Kommt eine fünfte dazu, wird das hier rot — und genau daran
stirbt sonst jede gepflegte Liste.
3. Daten werden wirklich gefunden: In sechs Tabellen wird etwas
angelegt, und alle sechs müssen in der Auskunft auftauchen.
---------------------------------------------------------------------
UND ZWEI, DIE NIEMALS DRINSTEHEN DÜRFEN
Zugangsgeheimnisse (Hash, Salt, Kennung) — Risiko ohne Nutzen
Andere Menschen — deren Daten gehören ihnen
Aufrufen mit: node server/pruef-auskunft.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4412, "pruef-auskunft");
const ordner = mkdtempSync(join(tmpdir(), "ws-ausk-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(180_000, "pruef-auskunft");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const CREW = "crew.dogfather-universe.com";
const HAUS = "workspace.dogfather-universe.com";
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const modul = await import("./workspace-auskunft.js");
const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
let idDogi, idRieke, idGast;
{
const d = oeffne();
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
const anlegen = (name, rolle, code) => {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
idRieke = anlegen("Rieke", "hand", "CODE-HAND-0001");
idGast = anlegen("Kessi", "gast", "CODE-GAST-0001");
/* SPUREN IN SECHS TABELLEN -- eine Auskunft ueber einen Menschen ohne
Daten beweist nichts. Bewusst verteilt: eigener Eintrag, fremder
Eintrag mit mir darin, Chat, Protokoll (ohne Fremdschluessel) und
treff_entfernt.autor_id (ebenfalls ohne). */
d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)"
+ " VALUES ('treff','hallo','KESSI-EINTRAG','Hallo zusammen',?,?,?)")
.run(jetzt.slice(0, 10), jetzt, idGast);
const raum = (() => {
d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von) VALUES ('gruppe','Testraum',?,?)")
.run(jetzt, idDogi);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
})();
d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit) VALUES (?,?,?)").run(raum, idGast, jetzt);
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
.run(raum, idGast, "KESSI-NACHRICHT", jetzt);
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
.run(raum, idDogi, "FREMDE-NACHRICHT", jetzt);
d.prepare("INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip)"
+ " VALUES (?,?,?,?,?,?)").run(jetzt, idGast, "gast", "anmeldung", "KESSI-PROTOKOLL", "203.0.113.5");
d.prepare("INSERT INTO treff_massnahmen (person_id, art, grund, von, zeitpunkt)"
+ " VALUES (?,?,?,?,?)").run(idGast, "hinweis", "KESSI-MASSNAHME", idDogi, jetzt);
d.close();
}
function roh(pfad, host, kopf = {}) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: "GET",
headers: { Host: host, ...kopf } }, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
a.end();
});
}
function post(pfad, host, kopf, koerper) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: "POST",
headers: { Host: host, "Content-Type": "application/json", ...kopf } }, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
a.write(JSON.stringify(koerper));
a.end();
});
}
const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
async function anmelden(rolle, code, host) {
/* Die Community bestaetigt beim ersten Mal ihr Alter (seit 15.09.2026).
Nur fuer 'gast' -- fuer alle anderen muss der Server das Feld gar
nicht erst sehen. */
const r = await post("/workspace/api/anmelden", host, {},
rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code });
return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS);
const kRieke = await anmelden("hand", "CODE-HAND-0001", CREW);
const kGast = await anmelden("gast", "CODE-GAST-0001", CREW);
ok(!!kDogi && !!kRieke && !!kGast, "DogFather, rechte Hand und ein Community-Mitglied sind angemeldet");
/* =======================================================================
1. DIE QUELLEN SIND ABGELEITET — UND VOLLSTÄNDIG EINGEORDNET
======================================================================= */
melde("");
melde("=== 1. Vollständigkeit der Quellen ===");
{
const d = oeffne();
const spalten = modul.personenSpalten(d);
ok(spalten.length > 30, `${spalten.length} Spalten im Haus zeigen auf einen Menschen`);
ok(spalten.some((s) => s.quelle === "fremdschluessel"), "die meisten über einen Fremdschlüssel");
ok(spalten.some((s) => s.quelle === "nachgetragen"), "und zwei sind nachgetragen");
/* DIE PRUEFUNG, DIE DIE LISTE AM LEBEN HAELT: Jede Spalte auf `_id`
ohne Fremdschluessel muss eingeordnet sein -- als Person oder als
keine. Baut jemand eine fuenfte, wird das hier rot, statt dass sie
still aus der Auskunft faellt. */
const tabellen = d.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
const undeklariert = [];
for (const { name } of tabellen) {
const fk = new Set(d.prepare(`PRAGMA foreign_key_list(${name})`).all().map((f) => f.from));
for (const c of d.prepare(`PRAGMA table_info(${name})`).all()) {
if (!/_id$/i.test(c.name) || !/INT/i.test(c.type)) continue;
if (fk.has(c.name)) continue;
undeklariert.push(`${name}.${c.name}`);
}
}
ok(undeklariert.length > 0, `${undeklariert.length} Spalten auf _id ohne Fremdschlüssel`);
const alsPerson = new Set(modul.ZUSATZ.map((z) => `${z.tabelle}.${z.spalte}`));
const alsNicht = new Set(modul.KEINE_PERSON);
const uneingeordnet = undeklariert.filter((s) => !alsPerson.has(s) && !alsNicht.has(s));
ok(uneingeordnet.length === 0,
uneingeordnet.length ? `NICHT EINGEORDNET: ${uneingeordnet.join(", ")}`
: `alle ${undeklariert.length} sind eingeordnet (${alsPerson.size} Person, ${alsNicht.size} nicht)`);
/* Und keine Karteileiche: Was eingeordnet ist, muss es auch geben. */
const erfunden = [...alsPerson, ...alsNicht].filter((s) => !undeklariert.includes(s));
ok(erfunden.length === 0,
erfunden.length ? `EINGEORDNET, GIBT ES ABER NICHT: ${erfunden.join(", ")}` : "und keine Karteileiche dabei");
ok(modul.ZUSATZ.every((z) => z.warum && z.warum.length > 20),
"jede nachgetragene Spalte sagt, warum sie keinen Fremdschlüssel hat");
d.close();
}
/* =======================================================================
2. DIE DATEN WERDEN WIRKLICH GEFUNDEN
======================================================================= */
melde("");
melde("=== 2. Was über Kessi gespeichert ist ===");
{
const a = await roh("/workspace/api/auskunft/mich", CREW, { cookie: kGast });
ok(a.code === 200, `ein Community-Mitglied bekommt seine Auskunft (${a.code})`);
const d = json(a);
ok(d.person?.name === "Kessi", `die eigene Zeile steht drin (${d.person?.name})`);
ok(d.durchsucht > 30, `${d.durchsucht} Stellen durchsucht`);
ok(d.gefunden >= 5, `an ${d.gefunden} davon liegt etwas über sie`);
const namen = (d.tabellen || []).map((t) => t.tabelle);
for (const muss of ["eintraege", "chat_nachrichten", "chat_teilnehmer",
"protokoll", "treff_massnahmen"]) {
ok(namen.includes(muss), `${muss} ist dabei`);
}
/* protokoll und treff_entfernt.autor_id haben KEINEN Fremdschluessel.
Dass protokoll trotzdem auftaucht, beweist, dass die nachgetragene
Liste wirkt -- ohne sie fiele es lautlos heraus. */
ok(namen.includes("protokoll"),
"auch das Protokoll – und das hat gar keinen Fremdschlüssel");
const roher = a.text;
ok(/KESSI-EINTRAG/.test(roher) && /KESSI-NACHRICHT/.test(roher) && /KESSI-MASSNAHME/.test(roher),
"die Inhalte stehen wirklich drin, nicht nur die Tabellennamen");
/* GEGENPROBE: Was NICHT ihres ist, ist auch nicht drin. */
ok(!/FREMDE-NACHRICHT/.test(roher),
"die Nachricht eines anderen aus demselben Raum steht NICHT drin");
ok(d.nicht_lesbar?.length === 0, "und jede Stelle ließ sich lesen");
}
/* =======================================================================
3. ZWEI DINGE, DIE NIEMALS DRINSTEHEN
======================================================================= */
melde("");
melde("=== 3. Was nicht drinsteht ===");
{
const a = await roh("/workspace/api/auskunft/mich", CREW, { cookie: kGast });
const roher = a.text;
const d = json(a);
/* 1. Zugangsgeheimnisse. */
ok(/Zugangsgeheimnis/.test(roher), "die Geheimnisfelder sind als solche markiert");
const dd = oeffne();
const echt = dd.prepare("SELECT code_hash, code_salt, code_kennung FROM personen WHERE id = ?").get(idGast);
dd.close();
ok(!roher.includes(echt.code_hash), "der echte Hash steht NICHT darin");
ok(!roher.includes(echt.code_salt), "das Salt auch nicht");
ok(!roher.includes(echt.code_kennung), "und die Kennung ebenso wenig");
/* 2. Andere Menschen. Die Maßnahme wurde von DogFather gesetzt --
seine Nummer ist sein Datum, nicht ihres. */
const massnahme = (d.tabellen || []).find((t) => t.tabelle === "treff_massnahmen");
const zeile = massnahme?.zeilen?.[0];
ok(zeile && zeile.von === "(eine andere Person)",
`wer die Maßnahme gesetzt hat, steht als „eine andere Person“ da (${zeile?.von})`);
/* GEGENPROBE -- ohne sie beweist die Zeile darueber nichts: IHRE
eigene Nummer bleibt stehen, sonst waere die Auskunft unbrauchbar. */
ok(zeile && Number(zeile.person_id) === Number(idGast),
`ihre eigene Nummer steht unverändert da (${zeile?.person_id})`);
ok(!new RegExp(`"von":\\s*${idDogi}\\b`).test(roher),
"und nirgends im Text steht die Nummer des anderen in einer Personenspalte");
}
/* =======================================================================
4. WER WEN FRAGEN DARF
======================================================================= */
melde("");
melde("=== 4. Wer wen fragen darf ===");
{
const eigene = await roh("/workspace/api/auskunft/mich", CREW, { cookie: kRieke });
ok(eigene.code === 200, `die rechte Hand bekommt ihre eigene (${eigene.code})`);
ok(json(eigene).person?.name === "Rieke", "und zwar ihre, nicht die eines anderen");
const fremd = await roh(`/workspace/api/auskunft/${idGast}`, CREW, { cookie: kRieke });
ok(fremd.code === 404, `die eines anderen bekommt sie nicht (${fremd.code})`);
ok(!/KESSI-NACHRICHT/.test(fremd.text), "und kein Wort daraus");
const gastFremd = await roh(`/workspace/api/auskunft/${idRieke}`, CREW, { cookie: kGast });
ok(gastFremd.code === 404, `ein Community-Mitglied erst recht nicht (${gastFremd.code})`);
/* GEGENPROBE: DogFather kann es -- er ist der Verantwortliche. Ohne
diese Zeile waere „404" oben auch dann gruen, wenn es den Weg gar
nicht gibt. */
const chef = await roh(`/workspace/api/auskunft/${idGast}`, HAUS, { cookie: kDogi });
ok(chef.code === 200, `DogFather schon (${chef.code})`);
ok(json(chef).person?.name === "Kessi", "und bekommt die richtige Person");
const ohne = await roh("/workspace/api/auskunft/mich", HAUS, {});
ok(ohne.code === 401, `ohne Anmeldung gar nichts (${ohne.code})`);
const erfunden = await roh("/workspace/api/auskunft/999999", HAUS, { cookie: kDogi });
ok(erfunden.code === 404, `eine erfundene Nummer ergibt 404 (${erfunden.code})`);
}
/* =======================================================================
5. JEDE AUSKUNFT HINTERLÄSST EINE SPUR
======================================================================= */
melde("");
melde("=== 5. Nachvollziehbar ===");
{
const d = oeffne();
const selbst = d.prepare("SELECT COUNT(*) AS n FROM protokoll WHERE aktion = 'auskunft_selbst'").get().n;
const fremd = d.prepare("SELECT detail FROM protokoll WHERE aktion = 'auskunft_fremd'").all();
d.close();
ok(selbst >= 3, `${selbst} eigene Auskünfte im Protokoll`);
ok(fremd.length >= 1, `${fremd.length} Auskunft über einen anderen Menschen`);
ok(/Kessi/.test(fremd[0]?.detail || ""),
`und dort steht, über wen (${fremd[0]?.detail})`);
}
/* =======================================================================
6. DER KNOPF STEHT AUF DEN SEITEN, DIE DEM MENSCHEN GEHÖREN
======================================================================= */
melde("");
melde("=== 6. Der Knopf ===");
{
const { readFileSync } = await import("node:fs");
for (const seite of ["steckbrief.html", "treff-regeln.html"]) {
const s = readFileSync(new URL("../workspace/" + seite, import.meta.url), "utf8");
ok(/id="auskunft-knopf"/.test(s), `${seite} hat den Knopf`);
ok(/assets\/js\/auskunft\.js/.test(s), `${seite} lädt das Skript dazu`);
}
/* WARUM AUSGERECHNET DIESE ZWEI: Die Community hat keinen Steckbrief.
Ohne die zweite Stelle haette die Gruppe mit den wenigsten Rechten
auch dieses nicht -- und sie ist die groesste. */
const rechte = await import("./rechte.js");
const tafel = rechte.tafelJetzt();
ok(!(tafel["/workspace/steckbrief.html"] || []).includes("gast"),
"die Community hat keinen Steckbrief");
ok((tafel["/workspace/treff-regeln.html"] || []).includes("gast"),
"aber die Treff-Regeln – deshalb steht er auch dort");
}
/* ------------------------------------------------------------------- */
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);