Files
dogfather-universe/server/pruef-loeschen.mjs
T
DogFatherGitandClaude Opus 5 60802170a1 B4 + B5: Loeschen heisst Loeschen, die Schrift wechselt mit, und Blau wird Babyblau
Die beiden Auftraege gehoeren zusammen, und zwar in dieser Reihenfolge:
Eine babyblaue Kachel ist HELL. Ohne mitwechselnde Schrift waere sie
unlesbar (1,63:1 gemessen). Erst B4 macht B5 moeglich.

B4a -- LOESCHEN HEISST LOESCHEN
  "Geloeschte Nachrichten verschwinden vollstaendig - keine Spur, kein
   'wurde geloescht'-Hinweis, bei niemandem, auch nicht bei DogFather."

Bis heute blieb die Zeile stehen: Text geleert, weg_am gesetzt, und im
Chat stand "Nachricht zurueckgenommen". Das war ausdruecklich so
begruendet ("ein Loch im Verlauf wirft mehr Fragen auf"). Das Argument
beantwortet aber eine andere Frage: Ein Hinweis "hier stand etwas"
MARKIERT die Stelle. Wer etwas aus Versehen schreibt, will es weg
haben und nicht unterstrichen.

Jetzt ein echtes DELETE. Vier Raender, an denen eine Spur bleiben
koennte, alle gemessen:
  1. die Zeile selbst
  2. chat_reaktionen    (ON DELETE CASCADE - nachgesehen, nicht angenommen)
  3. chat_erwaehnungen  (ebenso)
  4. letzte_am am Raum  - wird neu gerechnet, sonst stuende er oben in
                          der Liste mit einem Zeitpunkt, zu dem es
                          nichts mehr gibt
Dazu: ein Zitat auf eine geloeschte Nachricht wird weggelassen; in der
Gespraechsliste steht kein Hinweis mehr; der Knopf heisst "loeschen".

Die 5 alten zurueckgenommenen Zeilen (Text bereits leer) raeumt eine
einmalige, wiederholbare Umstellung ab - mit Sicherung davor, und nur
wenn es wirklich etwas zu tun gibt.

B4b -- WER DARF WAS
  "jeder nur seine eigenen - ausser DogFather und rechte Hand"
`darfJedeNachrichtLoeschen` = admin oder hand. NICHT fuehrtTeamDogi
(das schloesse die linke Hand ein) und nicht istLeitung (das schloesse
Spicy Media ein, die private Chats nicht einmal sehen darf). Das Recht
kommt vom Server ins Skript, nicht aus einer Rolle im Browser: chat.js
bekommt jeder, der die Seite oeffnet.

B4c -- DIE SCHRIFT WECHSELT MIT
  "am besten schwarz auf hellen Kacheln - und weiss, wenn jemand eine
   schwarze Kachel waehlt"
`schriftFuer(farbe)` waehlt zwischen zwei Paaren, gerechnet aus der
Leuchtdichte, mit denselben Schwellen wie das Rechenwerkzeug (7:1 fuer
den Text, 4,5:1 fuer die Fusszeile). Keine dritte Spalte in
CHAT_KACHELN, die jemand pflegen muesste. Flaeche und Schrift werden
im Browser in EINEM Griff gesetzt (blaseFaerben) - zwei Stellen waeren
irgendwann eine helle Kachel mit heller Schrift.

B4d -- DIE NAMEN
Der Name ist jetzt ein eigener Streifen mit Kante darunter, .84rem,
und der Rollenpunkt wird ein 3x14-Balken. Vorher stand er als erste
ZEILE in der Blase und las sich wie der Anfang des Satzes.

B5 -- BABYBLAU
  "jedes normales blaues herz durch babyblaues herz ersetzen ... jeder
   normale blaue farbe, sei es die kachel im chat oder emojis, nur
   babyblau bitte."
* Herz: U+1F499 -> U+1FA75. Nicht geglaubt, sondern gemessen: 43,9 px
  breit wie die anderen Herzen, ein Ersatzkaestchen waere 20,7. Die
  vier vorhandenen blauen Herzen in der Datenbank wandern mit - sonst
  waeren es vier Reaktionen, die ERLAUBT nicht mehr kennt: still weg.
* Kachel: neue, HELLE Kachel "Babyblau" mit dem Wert von --akzent.
  Gemessen: Text 10,78:1, Fusszeile 4,87:1. Sie steht bewusst nicht in
  der Rechnung des Werkzeugs (das rechnet elf Toene auf EINE
  Leuchtdichte) - sie hat eine andere Leuchtdichte und dafuer ihre
  eigene Schrift. Dasselbe Versprechen, anderer Weg.

PRUEFUNGEN
  pruef-loeschen.mjs (NEU, 20/0): alle vier Raender, die vier Faelle
    der Rechtegrenze (auch: die linke Hand darf NICHT), das babyblaue
    Herz am laufenden Server, und dass der Satz "Nachricht
    zurueckgenommen" nur noch in Kommentaren steht - mit Gegenprobe,
    dass die Suche diesen Unterschied wirklich macht.
  pruef-chatkachel.mjs (33/0): misst jede Kachel mit IHRER Schrift und
    fragt dafuer dieselbe Funktion wie der Server. Neu: dass die
    Schrift ueberhaupt wechselt. Die Leuchtdichte-Regel gilt jetzt fuer
    die gerechneten Toene - das Versprechen dahinter loest die
    Kontrastzeile darueber direkt ein.
  Vier alte Pruefungen umgedreht, die den alten Zustand festgeschrieben
    hatten: pruef-chat, pruef-chat-ausbau, pruef-chat-aufloesen,
    pruef-chat-optik. Alle mit scharferer Messung als vorher (Zahl
    davor/danach statt "ein Hinweis ist da").
  Mitgelaufen und gruen: pruef-alle-sehen-es, pruef-treffchat (110/0).

Gesichert: workspace-vor-loeschumbau-20260922-233313.db
(946 KB, integrity_check ok, 111 Nachrichten, 49 Reaktionen).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 23:33:43 +02:00

297 lines
14 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.
/* =====================================================================
LÖSCHEN HEISST LÖSCHEN — und wer es darf
=====================================================================
Filipe am 22.09.2026:
„Gelöschte Nachrichten verschwinden vollständig – keine Spur, kein
‚wurde gelöscht'-Hinweis, bei niemandem, auch nicht bei
DogFather."
„Löschen darf weiterhin jeder nur seine eigenen – außer DogFather
und rechte Hand: die dürfen jede Nachricht löschen, für den
Notfall."
---------------------------------------------------------------------
WARUM DAS NICHT „EIN STATUS WENIGER" IST
Vorher blieb die Zeile stehen: Text geleert, `weg_am` gesetzt, und
der Chat zeigte „Nachricht zurückgenommen". Es war so begründet --
„ein Loch im Verlauf wirft mehr Fragen auf als der Hinweis". Das
Argument beantwortet nur eine andere Frage: Ein Hinweis „hier stand
etwas" MARKIERT die Stelle. Wer etwas aus Versehen schreibt, will es
weg haben und nicht unterstrichen.
Ein echtes DELETE hat aber vier Ränder, und an jedem kann eine Spur
übrig bleiben. Genau die werden hier gemessen:
1. die Zeile selbst
2. Reaktionen darauf (chat_reaktionen, ON DELETE CASCADE)
3. Erwähnungen darin (chat_erwaehnungen, ON DELETE CASCADE)
4. `letzte_am` am Raum -- sonst stünde er in der Liste ganz
oben, mit einem Zeitpunkt, zu dem
es nichts mehr gibt
Die Punkte 2 und 3 hängen an `PRAGMA foreign_keys = ON`. Dass die
Regel dort steht, ist eine Behauptung im Quelltext; dass sie WIRKT,
sagt nur eine Messung.
---------------------------------------------------------------------
DIE RECHTEGRENZE HAT VIER FÄLLE, NICHT ZWEI
Eine Prüfung, die nur „der Eigene darf" und „ein Fremder darf nicht"
misst, übersieht die Hälfte: Die linke Hand führt Team Dogi mit
(`fuehrtTeamDogi`) und darf es trotzdem NICHT -- Filipe hat zwei
Rollen genannt, nicht drei. Und Spicy Media, das sonst fast alles
darf, darf private Chats nicht einmal sehen.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { notbremse } from "./helfer-notbremse.mjs";
import { eigenerPort } from "./helfer-port.mjs";
const ordner = mkdtempSync(join(tmpdir(), "ws-loesch-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const PORT = await eigenerPort(import.meta, "pruef-loeschen");
process.env.PORT = `${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 }); };
await import("./index.js");
notbremse(120_000, "pruef-loeschen");
await new Promise((r) => setTimeout(r, 800));
let fehler = 0, geprueft = 0, offen = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const unklar = (t) => { offen++; console.log(" -- " + t); };
/* ---------- Leute ------------------------------------------------------ */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
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);
function 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;
}
const idChef = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
const idLinke = anlegen("Lino", "linke", "CODE-LINK-0001");
const idModi = anlegen("Frida", "modi", "CODE-MODI-0001");
const idModi2 = anlegen("Nele", "modi", "CODE-MODI-0002");
/* ---------- Anfragen (node:http wegen des Host-Kopfes) ----------------- */
const { request: httpAnfrage } = await import("node:http");
const CREW = "crew.dogfather-universe.com";
function anfrage(pfad, { methode = "GET", keks = "", rumpf = null } = {}) {
return new Promise((fertig, schief) => {
const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: {
Host: CREW, Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/`,
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
...(keks ? { Cookie: keks } : {}),
},
}, (antwort) => {
const st = [];
antwort.on("data", (x) => st.push(x));
antwort.on("end", () => {
const text = Buffer.concat(st).toString("utf8");
let json = null; try { json = JSON.parse(text); } catch { /* kein JSON */ }
fertig({ code: antwort.statusCode, json,
keks: [].concat(antwort.headers["set-cookie"] || [])
.map((c) => c.split(";")[0]).join("; ") });
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
const anmelden = async (rolle, code) =>
(await anfrage("/workspace/api/anmelden", { methode: "POST", rumpf: { rolle, code } })).keks;
const ruf = (keks, pfad, o = {}) => anfrage(pfad, { ...o, keks });
const keksChef = await anmelden("admin", "CODE-DOGI-0001");
const keksHand = await anmelden("hand", "CODE-HAND-0001");
const keksLinke = await anmelden("linke", "CODE-LINK-0001");
const keksModi = await anmelden("modi", "CODE-MODI-0001");
/* Nele schreibt die fremden Nachrichten -- sie ist in keinem der
vier Faelle die Loeschende, damit "fremd" fuer alle vier
wirklich fremd ist. */
const keksModi2 = await anmelden("modi", "CODE-MODI-0002");
console.log("=== Löschen heißt Löschen ===");
console.log("");
/* Ein Kanal, in dem alle drin sind -- die Kanalregel vom selben Tag
holt sie beim ersten Blick in die Liste hinein. */
const kanal = await ruf(keksChef, "/workspace/api/chat/kanal",
{ methode: "POST", rumpf: { kategorie: "chat", mit: [] } });
const raumId = kanal.json?.raum_id;
await ruf(keksChef, "/workspace/api/chat/raeume");
const drin = d.prepare(
"SELECT COUNT(*) AS n FROM chat_teilnehmer WHERE raum_id = ? AND raus_am IS NULL").get(raumId).n;
ok(kanal.code === 201 && drin === 5, `ein Kanal mit allen fünf steht (${drin})`);
const schreiben = async (keks, text) => {
const a = await ruf(keks, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ methode: "POST", rumpf: { text } });
return a.json?.id || a.json?.nachricht?.id || null;
};
/* =====================================================================
1. EINE EIGENE NACHRICHT -- MIT ALLEM DRAN
===================================================================== */
console.log("--- Eine eigene löschen, mit Reaktion und Erwähnung ---");
const idNachricht = await schreiben(keksModi, "Hallo @Rieke, das hier ist ein Versehen");
ok(Number.isInteger(idNachricht), `sie ist geschrieben (#${idNachricht})`);
await ruf(keksChef, `/workspace/api/chat/nachrichten/${idNachricht}/reaktion`,
{ methode: "PUT", rumpf: { zeichen: "\u{1F44D}" } });
const vorher = {
zeile: d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE id = ?").get(idNachricht).n,
reaktion: d.prepare("SELECT COUNT(*) AS n FROM chat_reaktionen WHERE nachricht_id = ?")
.get(idNachricht).n,
erwaehnung: d.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ?")
.get(idNachricht).n,
};
ok(vorher.zeile === 1 && vorher.reaktion === 1 && vorher.erwaehnung === 1,
`vorher: Zeile ${vorher.zeile}, Reaktion ${vorher.reaktion}, Erwähnung ${vorher.erwaehnung}`
+ " — es gibt also wirklich etwas zu löschen");
const weg = await ruf(keksModi, `/workspace/api/chat/nachrichten/${idNachricht}`,
{ methode: "DELETE" });
ok(weg.code === 200, `die eigene löschen geht (${weg.code})`);
const nachher = {
zeile: d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE id = ?").get(idNachricht).n,
reaktion: d.prepare("SELECT COUNT(*) AS n FROM chat_reaktionen WHERE nachricht_id = ?")
.get(idNachricht).n,
erwaehnung: d.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ?")
.get(idNachricht).n,
};
ok(nachher.zeile === 0, "die Zeile ist weg — nicht geleert, weg");
ok(nachher.reaktion === 0 && nachher.erwaehnung === 0,
`und Reaktion (${nachher.reaktion}) und Erwähnung (${nachher.erwaehnung}) mit ihr`
+ " — ON DELETE CASCADE wirkt wirklich");
/* KEINE SPUR IM VERLAUF -- auch nicht bei DogFather. */
const verlaufChef = await ruf(keksChef, `/workspace/api/chat/raeume/${raumId}/nachrichten`);
const alle = verlaufChef.json?.nachrichten || [];
ok(!alle.some((n) => n.id === idNachricht),
"sie steht in keinem Verlauf mehr — auch nicht in dem von DogFather");
ok(!alle.some((n) => n.zurueckgenommen),
"und es steht nirgends eine zurückgenommene Nachricht");
/* =====================================================================
2. DIE VIER FÄLLE DER RECHTEGRENZE
===================================================================== */
console.log("");
console.log("--- Wer darf eine FREMDE löschen ---");
const faelle = [
["DogFather", keksChef, 200, "er ist einer der zwei, die es dürfen"],
["die rechte Hand", keksHand, 200, "sie ist die zweite"],
["die linke Hand", keksLinke, 403,
"sie führt Team Dogi mit und darf es trotzdem nicht — genannt waren zwei Rollen"],
["ein Modi", keksModi, 403, "er nicht"],
];
let geprueftFaelle = 0;
for (const [wer, keks, erwartet, warum] of faelle) {
const id = await schreiben(keksModi2, `Fremde Nachricht für ${wer}`);
if (!Number.isInteger(id)) { unklar(`für ${wer} ließ sich keine Nachricht anlegen`); continue; }
const a = await ruf(keks, `/workspace/api/chat/nachrichten/${id}`, { methode: "DELETE" });
const nochDa = d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE id = ?").get(id).n;
geprueftFaelle++;
ok(a.code === erwartet && (erwartet === 200 ? nochDa === 0 : nochDa === 1),
`${wer}: ${a.code} (erwartet ${erwartet}), Zeile danach ${nochDa} — ${warum}`);
}
ok(geprueftFaelle === faelle.length,
`alle ${faelle.length} Fälle wirklich durchgespielt (${geprueftFaelle})`);
/* =====================================================================
3. DER RAUM VERGISST DEN ZEITPUNKT
===================================================================== */
console.log("");
console.log("--- Was der Raum danach über sich sagt ---");
const letzteId = await schreiben(keksModi, "Die allerletzte");
const vorLoeschen = d.prepare("SELECT letzte_am FROM chat_raeume WHERE id = ?").get(raumId).letzte_am;
await ruf(keksModi, `/workspace/api/chat/nachrichten/${letzteId}`, { methode: "DELETE" });
const nachLoeschen = d.prepare("SELECT letzte_am FROM chat_raeume WHERE id = ?").get(raumId).letzte_am;
ok(!!vorLoeschen && vorLoeschen !== nachLoeschen,
`der Raum rechnet seinen letzten Zeitpunkt neu (${vorLoeschen} → ${nachLoeschen})`);
/* =====================================================================
4. DAS BABYBLAUE HERZ
===================================================================== */
console.log("");
console.log("--- Babyblau statt Blau ---");
const { ERLAUBT } = await import("./workspace-reaktionen.js");
ok(ERLAUBT.has("\u{1FA75}"), "das babyblaue Herz steht im Katalog");
ok(!ERLAUBT.has("\u{1F499}"), "das normale blaue nicht mehr");
/* GEGENPROBE AM LAUFENDEN SERVER: Was der Katalog sagt, muss auch der
Weg zum Setzen sagen. Zwei Listen, die auseinanderlaufen, wären hier
besonders unangenehm -- man setzt ein Zeichen und es verschwindet. */
const idFuerHerz = await schreiben(keksModi, "Ein Satz zum Liebhaben");
const babyOk = await ruf(keksChef, `/workspace/api/chat/nachrichten/${idFuerHerz}/reaktion`,
{ methode: "PUT", rumpf: { zeichen: "\u{1FA75}" } });
const blauAb = await ruf(keksHand, `/workspace/api/chat/nachrichten/${idFuerHerz}/reaktion`,
{ methode: "PUT", rumpf: { zeichen: "\u{1F499}" } });
ok(babyOk.code === 200, `ein babyblaues Herz geht durch (${babyOk.code})`);
ok(blauAb.code === 400, `ein blaues wird abgewiesen (${blauAb.code})`);
/* =====================================================================
5. IM CHAT-SKRIPT STEHT DER SATZ NICHT MEHR
===================================================================== */
console.log("");
console.log("--- Und im Browser ---");
const { readFileSync } = await import("node:fs");
const { dirname } = await import("node:path");
const { fileURLToPath } = await import("node:url");
const HIER = dirname(fileURLToPath(import.meta.url));
const chatJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "chat.js"), "utf8");
/* NUR AUSSERHALB VON KOMMENTAREN. Die Begründung, warum der Satz weg
ist, nennt ihn -- und muss das auch dürfen. Gesucht wird deshalb
nach ihm als Zeichenkette in Anführungszeichen, also so, wie er auf
den Bildschirm käme. */
const alsText = /(['"`])Nachricht zurückgenommen\1/.test(chatJs);
ok(!alsText, "der Satz steht in chat.js an keiner Stelle mehr,"
+ " an der ihn jemand lesen koennte");
ok(/Nachricht zurückgenommen/.test(chatJs),
"Gegenprobe: der Satz kommt in den Kommentaren durchaus vor —"
+ " die Suche oben unterscheidet also wirklich");
/* ---------- Ende ------------------------------------------------------ */
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt manchmal */ }
console.log("");
console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}`
+ ` -- ${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
process.exit(fehler === 0 ? 0 : 1);