Files
dogfather-universe/server/pruef-rechtetafel.mjs
T
DogFatherGitandClaude Opus 5 47a91e9708 Die goldene Regel: was nicht in der Tafel steht, ist verboten
Filipe: "egal welche rolle oder person hinzugefuegt wird soll immer nur
das sehen wass ich erlaube. mehr nicht. soll nichts so sein dass wenn
mann eine rolle oder jemanden hinzufuegt dass er dan alles sieht."

DER BEFUND -- GEZAEHLT, NICHT GESCHAETZT

NEUN von zwanzig Seiten standen auf `null`, und `null` hiess "jede
angemeldete Rolle": Start, Uebersicht, Aufgaben, Chat, Kalender,
Dateien, Calls, Start-Check, Wissen. Eine neue Rolle erbte sie alle,
ohne dass jemand etwas erlaubt haette.

UND EIN ZWEITES LOCH, das ich vorher nicht gemessen hatte: Die Schranke
las `const erlaubt = GESCHUETZT[pfad]` und prueffte `if (erlaubt && ...)`.
Eine Seite, die GAR NICHT in der Tabelle stand, ergab `undefined`, fiel
durch dieselbe Bedingung und war damit ebenfalls fuer jede angemeldete
Rolle offen. Am 11.09.2026 betraf das keine einzige Seite (nachgezaehlt:
22 Dateien = 20 Eintraege + 2 Zugangswaende) -- aber jede NEUE waere so
entstanden.

Die Rolle wird im Haus an 74 Stellen in 20 Modulen abgefragt, und die
Endzweige widersprechen einander: Aufgaben enden mit `default: 0=1`
(sieht nichts), Bereiche und Dateien fallen in "eigene plus betreute".
Drei Module, drei Antworten auf dieselbe Frage.

WARUM NICHT "DIE LISTE DURCHGEHEN"

Das waere die naheliegende Antwort und die falsche. Im Code stand seit
dem 10.09. genau dieser Satz -- "Wer eine Rolle hinzufuegt, muss diese
Liste durchgehen" -- und einen Tag spaeter standen immer noch neun
Seiten auf `null`. Ein Satz, der sich auf ein Gedaechtnis verlaesst, ist
keine Sicherung. Dazu: eine vergessene Erlaubnis MELDET SICH NIE. Die
Seite laedt ja. In der anderen Richtung ist es schlimmer -- eine Seite,
die zu viel zeigt, sieht aus wie eine Seite, die funktioniert.

WAS JETZT GILT

server/rechte.js traegt die Tafel, `darfSeite()` liest sie, und sie
kennt zwei Antworten: Seite in der Tafel UND Rolle darin -> ja. Alles
andere -> nein. Kein `null`, kein `undefined`, kein Zweig, der etwas
durchlaesst.

DIE TABELLE IST UMGEZOGEN, NICHT NEU GESCHRIEBEN. In ihren Kommentaren
steckt das Gedaechtnis des Hauses -- jede Zeile traegt ein Datum und
einen Satz von Filipe ("nimm die kategorie zahlen bei jedem weg", "die
manager sollen diese kategorien garnicht sehen", "ich will dass die
rechte Hand auch alle sieht"). Die wegzurefactoren waere der teuerste
Fehler dieses Umbaus gewesen.

VERHALTENSGLEICH FUER DIE SIEBEN VORHANDENEN ROLLEN. Die neun
`null`-Seiten tragen jetzt alle sieben Namen. Eine Umkehrung, die
nebenbei Rechte entzieht, waeren zwei Aenderungen in einer -- und man
wuesste hinterher nicht, welche etwas kaputtgemacht hat. Enger stellen
ist ein eigener Schritt.

pruef-rechtetafel.mjs (NEU, 19 Pruefungen, Port 4397) macht daraus eine
Garantie statt einer Absicht:

  * Jede Rolle braucht einen Eintrag -- sonst rot. Man kann eine Rolle
    nicht mehr hinzufuegen, ohne zu entscheiden, was sie sieht.
  * Jede Seite braucht einen Eintrag -- verglichen gegen das
    DATEISYSTEM, nicht gegen eine zweite Liste. Eine neue Seite ist
    damit erst einmal fuer NIEMANDEN offen statt fuer alle.
  * Eine Phantomrolle kommt auf keine der zwanzig Seiten.
  * Gegenprobe: jede ECHTE Rolle kommt irgendwo hin (spicy 18, admin 20,
    manager 17, scout 16, creator 15, hand 13, modi 11) -- sonst waere
    die Zeile darueber auch gruen, wenn schlicht alles zu waere.
  * Am Server: eine Seite ohne Eintrag wird abgewiesen statt
    ausgeliefert, und alle 20 Seiten der DogFather-Rolle liefern wirklich.
  * Zerstoerende Probe ganz am Ende (die Lektion vom 09.09.: in der
    Mitte verbiegt sie alles danach).

ZWEI EIGENE FEHLER UNTERWEGS. `node --check` meldete "Syntax in
Ordnung", das Modul lud aber nicht -- beim Umzug war eine Konstante
verlorengegangen. Syntax ist kein Beweis. Und ein Kommentar behauptete
danach noch das Alte ("eine neue Seite ist mindestens nur fuer
Angemeldete"); der Rueckfall ist jetzt "fuer niemanden", und das steht
da auch so.

Gruen: pruef-rechtetafel 19 (neu), pruef-rollen 287, pruef-sicht 84,
pruef-crew-adresse 129, pruef-modi-verborgen 80, pruef-haus-trennung 62.
Stempel 202609111617.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 16:18:09 +02:00

281 lines
13 KiB
JavaScript

/* =====================================================================
DIE RECHTETAFEL — die Pruefung, die aus einer Absicht eine Garantie
macht (11.09.2026)
Filipe:
"egal welche rolle oder person hinzugefuegt wird soll immer nur das
sehen wass ich erlaube. mehr nicht. soll nichts so sein dass wenn
mann eine rolle oder jemanden hinzufuegt dass er dan alles sieht."
---------------------------------------------------------------------
WARUM DIESE DATEI UND NICHT EIN KOMMENTAR
In `rechte.js` stand bis heute ein Satz, der genau das Richtige
verlangte: *"Wer eine Rolle hinzufuegt, muss diese Liste durchgehen."*
Er stand seit dem 10.09. da, und am Tag darauf standen immer noch neun
Seiten auf `null`. Ein Satz, der sich auf ein Gedaechtnis verlaesst,
ist keine Sicherung.
Diese Datei verlaesst sich auf nichts. Sie wird rot, wenn
* eine Rolle keinen Eintrag hat,
* eine Seite keinen Eintrag hat,
* eine unbekannte Rolle irgendwo hineinkaeme,
* oder eine unbekannte Seite sich fuer irgendwen oeffnet.
Man KANN es damit nicht mehr vergessen -- der Prueflauf laesst es
nicht zu.
---------------------------------------------------------------------
DREI EBENEN, UND JEDE BEANTWORTET ETWAS ANDERES
1. DIE TAFEL SELBST, ohne Server. Vollstaendig, schnell, und sie
vergleicht gegen das DATEISYSTEM statt gegen eine zweite Liste --
eine zweite Liste waere die Stelle, an der beide auseinanderlaufen.
2. DER SERVER. Dass die Tafel richtig ist, heisst nicht, dass sie
auch gefragt wird. Genau dieser Unterschied hat am 10.09. drei
Stunden gekostet: Kachel und Schnittstelle stimmten, die Seite
warf trotzdem zurueck. Hier wird deshalb wirklich angeklopft.
3. DIE GEGENPROBE. Eine Pruefung, die immer bestaetigt, bestaetigt
nichts. Am Ende bekommt sie absichtlich eine kaputte Tafel und
MUSS sie als kaputt melden.
===================================================================== */
import { mkdtempSync, rmSync, readdirSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4397, "pruef-rechtetafel");
const ordner = mkdtempSync(join(tmpdir(), "ws-rechte-"));
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-rechtetafel");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
/* EIGENE ZUHOERER. index.js haengt fuer den Betrieb Auffangnetze an
`uncaughtException` -- eine Pruefung, die dieselbe Datei in denselben
Prozess laedt, erbt sie und wuerde nach einem Absturz weiterhaengen,
statt rot zu werden. */
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 rechte = await import("./rechte.js");
const w = await import("./workspace.js");
/* =======================================================================
1. DIE TAFEL IST VOLLSTAENDIG
======================================================================= */
melde("");
melde("=== 1. Jede Rolle und jede Seite hat einen Eintrag ===");
/* DIE ROLLENLISTE DARF NICHT ZWEIMAL EXISTIEREN.
rechte.js kennt ALLE_ROLLEN, workspace.js kennt ROLLEN. Zwei Listen
derselben Sache laufen auseinander -- die Frage ist nur wann. Hier
werden sie gegeneinandergehalten, damit das Wann "beim naechsten
Prueflauf" ist und nicht "wenn jemand hineinfaellt". */
{
const ausWorkspace = [...(w.ROLLEN_NAME ? Object.keys(w.ROLLEN_NAME) : [])].sort();
const ausTafel = [...rechte.ALLE_ROLLEN].sort();
ok(ausWorkspace.length > 0, `workspace.js kennt ${ausWorkspace.length} Rollen`);
ok(JSON.stringify(ausWorkspace) === JSON.stringify(ausTafel),
`beide Listen sind identisch (${ausTafel.join(", ")})`);
}
/* JEDE DATEI AUF DER PLATTE BRAUCHT EINEN EINTRAG.
Verglichen wird gegen das Dateisystem, nicht gegen eine Liste im Code.
Eine neue Seite entsteht als DATEI -- wer sie anlegt und den Eintrag
vergisst, faellt genau hier auf. */
const SEITEN_ORDNER = new URL("../workspace/", import.meta.url).pathname
.replace(/^\/([A-Za-z]:)/, "$1");
{
const dateien = readdirSync(SEITEN_ORDNER)
.filter((f) => f.endsWith(".html"))
.map((f) => "/workspace/" + f).sort();
const inTafel = new Set([...rechte.alleSeiten(), ...rechte.OHNE_ANMELDUNG]);
const fehlend = dateien.filter((p) => !inTafel.has(p));
const ueberzaehlig = rechte.alleSeiten().filter((p) => !dateien.includes(p));
ok(dateien.length >= 20, `${dateien.length} Seiten liegen im Ordner`);
ok(fehlend.length === 0,
`jede davon steht in der Tafel${fehlend.length ? " -- FEHLT: " + fehlend.join(", ") : ""}`);
ok(ueberzaehlig.length === 0,
`und jeder Eintrag hat eine Datei${ueberzaehlig.length ? " -- OHNE DATEI: " + ueberzaehlig.join(", ") : ""}`);
}
/* KEIN EINTRAG DARF UNSCHARF SEIN. `null` war der alte Fehler; eine
Rolle, die es gar nicht gibt, waere der naechste. */
{
const bekannt = new Set(rechte.ALLE_ROLLEN);
const kaputt = [];
for (const [pfad, rollen] of Object.entries(rechte.SEITEN)) {
if (!Array.isArray(rollen)) { kaputt.push(`${pfad}: kein Feld`); continue; }
for (const r of rollen) if (!bekannt.has(r)) kaputt.push(`${pfad}: unbekannte Rolle "${r}"`);
}
ok(kaputt.length === 0, `alle Eintraege sind Listen bekannter Rollen${kaputt.length ? " -- " + kaputt.join(" | ") : ""}`);
}
/* =======================================================================
2. WAS NICHT IN DER TAFEL STEHT, IST VERBOTEN
======================================================================= */
melde("");
melde("=== 2. Die Regel selbst ===");
const PHANTOM = "xx-gibt-es-nicht";
{
const alle = rechte.alleSeiten();
const durchgelassen = alle.filter((p) => rechte.darfSeite({ rolle: PHANTOM }, p));
ok(alle.length === 20, `${alle.length} Seiten in der Tafel`);
ok(durchgelassen.length === 0,
`eine unbekannte Rolle kommt auf KEINE davon${durchgelassen.length ? " -- durchgelassen: " + durchgelassen.join(", ") : ""}`);
/* DIE GEGENPROBE ZUR OBIGEN ZEILE. Ohne sie waere sie auch dann
gruen, wenn darfSeite() schlicht immer nein sagt -- und dann waere
das ganze Haus zu, ohne dass diese Datei es meldet. */
const echteWege = rechte.ALLE_ROLLEN
.map((r) => [r, alle.filter((p) => rechte.darfSeite({ rolle: r }, p)).length]);
const ohneSeite = echteWege.filter(([, n]) => n === 0).map(([r]) => r);
ok(ohneSeite.length === 0,
`und jede ECHTE Rolle kommt irgendwo hin (${echteWege.map(([r, n]) => r + ":" + n).join(" ")})`);
}
{
/* EINE SEITE, DIE ES NICHT GIBT. Das war das zweite Loch: `undefined`
fiel durch dieselbe Bedingung wie `null`. */
const erfunden = rechte.ALLE_ROLLEN
.filter((r) => rechte.darfSeite({ rolle: r }, "/workspace/gibt-es-nicht.html"));
ok(erfunden.length === 0,
`eine Seite ohne Eintrag oeffnet sich fuer niemanden -- auch nicht fuer DogFather`);
ok(rechte.darfSeite(null, "/workspace/start.html") === false,
"und ohne Anmeldung erst recht nicht");
}
/* =======================================================================
3. DIE TAFEL WIRD AUCH WIRKLICH GEFRAGT
======================================================================= */
melde("");
melde("=== 3. Der Server haelt sich daran ===");
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);
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Mara", "manager", "CODE-MANA-0001");
d.close();
async function anmelden(rolle, code) {
const a = await fetch(`${BASIS}/workspace/api/anmelden`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }),
});
return a.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001");
const kMana = await anmelden("manager", "CODE-MANA-0001");
async function hole(pfad, keks) {
const a = await fetch(BASIS + pfad, { headers: { cookie: keks }, redirect: "manual" });
return { status: a.status, ziel: a.headers.get("location") || "" };
}
ok(kDogi.length > 0 && kMana.length > 0, "beide Anmeldungen haben eine Sitzung");
{
const a = await hole("/workspace/automation.html", kDogi);
ok(a.status === 200, `DogFather kommt auf automation.html (${a.status})`);
const b = await hole("/workspace/automation.html", kMana);
ok(b.status === 302 && b.ziel.endsWith("start.html"),
`eine Managerin nicht -- sie landet auf der Startseite (${b.status} ${b.ziel})`);
}
{
/* DER EIGENTLICHE NEUE FALL: eine Seite, die es in der Tafel nicht
gibt. Vorher fiel sie durch die Schranke und traf danach auf
express.static -- also 404, was von aussen aussieht wie "gibt es
nicht", obwohl sie in Wahrheit einfach nicht geschuetzt war. Jetzt
greift die Schranke davor. */
const a = await hole("/workspace/gibt-es-nicht.html", kDogi);
ok(a.status === 302 && a.ziel.endsWith("start.html"),
`eine Seite ohne Eintrag wird abgewiesen, nicht ausgeliefert (${a.status} ${a.ziel})`);
}
{
/* Und die Gegenprobe: Die geoeffneten Seiten oeffnen sich wirklich.
Sonst waere "alles abgewiesen" ebenfalls gruen. */
let auf = 0;
for (const p of rechte.seitenFuer("admin")) {
const a = await hole(p, kDogi);
if (a.status === 200) auf++;
}
ok(auf === rechte.seitenFuer("admin").length,
`alle ${auf} Seiten der DogFather-Rolle liefern wirklich aus`);
}
/* =======================================================================
4. DIE GEGENPROBE: KANN DIESE PRUEFUNG UEBERHAUPT ROT WERDEN?
======================================================================= */
melde("");
melde("=== 4. Gegenprobe -- eine kaputte Tafel MUSS auffallen ===");
/* Dieselbe Vergleichslogik wie in Abschnitt 1, aber mit absichtlich
beschaedigten Daten. Steht ganz am Ende, damit ihr Schaden nichts
verfaelscht, was davor gemessen wurde -- die Lektion vom 09.09., als
eine zerstoerende Probe in der Mitte stand und alles danach verbog. */
{
const dateien = readdirSync(SEITEN_ORDNER).filter((f) => f.endsWith(".html"))
.map((f) => "/workspace/" + f);
const luecke = new Set([...rechte.alleSeiten(), ...rechte.OHNE_ANMELDUNG]);
luecke.delete("/workspace/start.html");
ok(dateien.some((p) => !luecke.has(p)),
"eine entfernte Seite wird als Luecke erkannt");
const falscheRolle = { "/workspace/x.html": ["gibt-es-nicht"] };
const bekannt = new Set(rechte.ALLE_ROLLEN);
const gefunden = Object.values(falscheRolle)[0].some((r) => !bekannt.has(r));
ok(gefunden, "eine unbekannte Rolle in einem Eintrag wird erkannt");
const alsNull = { "/workspace/x.html": null };
ok(!Array.isArray(Object.values(alsNull)[0]),
"und ein `null`-Eintrag wird als kein Feld erkannt");
}
/* Der Server haelt die Datenbankdatei offen -- unter Windows laesst
sie sich dann nicht loeschen. Das ist ein Aufraeumrest im
Temp-Ordner, kein Befund, und darf die Pruefung nicht rot machen. */
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("");
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
process.exit(fehler === 0 ? 0 : 1);