Files
DogFatherGit 21793b6c36 Neue Rolle: die linke Hand
Aus dem Auftrag vom 21.09.2026, Abschnitt 6: "Die linke Hand erhaelt
grundsaetzlich dieselben Rechte wie die rechte Hand in allen Bereichen,
die die Modis, Aufgaben, Aufgabenzuweisungen, Uebersichten,
Kommunikation und Auswertungen betreffen. Die linke Hand darf keine
neuen Personen oder Accounts hinzufuegen. Die linke Hand darf keine
privaten bzw. persoenlichen Bereiche oder Informationen von Dogfather
sehen."

GEMESSEN VOR DEM BAUEN: Die Rolle "hand" steht an 35 Stellen im
Servercode und 17-mal in der Rechtetafel. Haette ich ueberall ein
"linke" danebengeschrieben, waere die naechste Stelle, die jemand
hinzufuegt, die erste, die es vergisst -- und eine vergessene Stelle
heisst hier: Die linke Hand sieht etwas nicht, ohne dass jemand merkt,
warum.

DESHALB EINE REGEL STATT DREISSIG ABSCHRIFTEN:

  WIE_RECHTE_HAND = new Set(["hand", "linke"])   in workspace.js
  istHand(person)                                fuer die Faehigkeiten
  eine Schleife ueber SEITEN                     fuer die Rechtetafel

Die Schleife gibt der linken Hand jede Seite der rechten -- ausser
fuenf, und jede davon steht mit Grund da:

  personen.html      legt Personen an
  bewerbungen.html   fuehrt zu neuen Personen
  talente.html       fuehrt zu neuen Personen
  hilfe.html         vertraulicher Meldeweg
  rechte.html        Rechteverwaltung

Gemessen: 25 Seiten fuer die rechte Hand, 20 fuer die linke. Als
Kacheln: 30 gegen 26 -- die vier verbotenen erscheinen gar nicht erst,
statt beim Antippen abgelehnt zu werden.

ZWEI DINGE, DIE ERST DAS MESSEN GEZEIGT HAT:

(1) OHNE KACHEL AUF DER ANMELDEWAND GIBT ES DIE ROLLE NICHT. Die
    Anmeldung sucht `WHERE rolle = ?` auf die angetippte Kachel -- es
    gibt keinen rollenuebergreifenden Sonderweg, auch nicht auf der
    Crew-Wand. Eine linke Hand haette sich mit dem richtigen Code
    NIEMALS anmelden koennen, und der Fehler haette ausgesehen wie ein
    falscher Code.

(2) DIE REIHENFOLGE DER DATENBANK-UMSTELLUNG ZAEHLT.
    `checkListeErweitern` baut die CHECK-Regel NEU aus der Liste, die
    man ihm gibt. Mein Schritt stand zuerst bei der rechten Hand --
    der Schritt fuer "gast" darunter nahm die Rolle damit wieder
    heraus, denn seine Liste kennt sie nicht. Die Kette ist kumulativ;
    neue Schritte gehoeren ans ENDE. Das steht jetzt auch dort.

Dazu zwei kleinere Funde beim Bauen:

  - Meine erste Ableitung aenderte die Rollenliste AN ORT UND STELLE.
    Mehrere Seiten teilen sich dieselbe Liste (`ALLE`) -- das haette
    die Rolle allen Seiten gegeben, die sie benutzen, auch einer
    Ausnahme. Faellt nicht auf, solange keine Ausnahme `ALLE` benutzt.
    Jetzt eine Kopie je Seite.
  - ROLLEN_NAME fehlte der Eintrag: Die linke Hand haette ueberall
    "linke" geheissen statt "Linke Hand". Gefunden hat das
    pruef-rechtetafel, weil sie diese Tabelle gegen ROLLEN haelt.
    Ihre Meldung nennt jetzt, WELCHE Liste was vermisst -- vorher
    zeigte sie beim Fehlschlag nur eine der beiden.

NICHT BEKOMMEN -- und jedes einzeln begruendet im Code:
Personen anlegen (ANLEGBAR.linke = []), Rollen aendern
(darfRollenWechseln), Bewerbungen, der vertrauliche Meldeweg. Beim
Meldeweg ist es bewusst das kleinere Recht: Ein Wort von Filipe
oeffnet ihn, eine versehentlich gelesene Meldung laesst sich nicht
zurueckholen.

UNBERUEHRT GEBLIEBEN, obwohl dort "hand" steht:
workspace-leistung.js:406 -- dort heisst "hand" nicht die Rolle,
sondern "von Hand eingetragen".

Am laufenden System gemessen: Datenbank nimmt die Rolle, Anmeldung
HTTP 200, 26 Kacheln, darf_verteilen = true, personen/hilfe/rechte
antworten mit 302, aufgaben und teamlage mit 200.

pruef-rechtetafel 19/0 (war 18/1), pruef-schranke 39/0,
pruef-umstellung 18/0.
2026-09-21 17:44:48 +02:00

311 lines
14 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 { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "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`);
/* DIE MELDUNG MUSS SAGEN, WAS FEHLT -- nicht nur, dass etwas fehlt.
Vorher stand hier nur EINE der beiden Listen; beim Fehlschlag las
man "beide Listen sind identisch (a, b, c)" und musste raten,
welche der beiden das war. */
const nurTafel = ausTafel.filter((r) => !ausWorkspace.includes(r));
const nurWorkspace = ausWorkspace.filter((r) => !ausTafel.includes(r));
ok(nurTafel.length === 0 && nurWorkspace.length === 0,
nurTafel.length === 0 && nurWorkspace.length === 0
? `beide Listen sind identisch (${ausTafel.join(", ")})`
: `nur in rechte.js: [${nurTafel.join(", ")}] · `
+ `nur in workspace.js: [${nurWorkspace.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 dateien = DATEIEN;
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));
/* KEINE FESTE ZAHL MEHR (11.09.2026).
Hier stand `alle.length === 20`. Das war richtig an dem Tag, an dem
es geschrieben wurde, und wurde bei der naechsten neuen Seite rot --
ohne dass irgendetwas kaputt war. Eine feste Schwelle ist eine
Rechnung von gestern; genau deshalb steht sie in diesem Haus in der
Liste der teuren Fehler.
Gemessen wird jetzt, was die Zeile eigentlich wissen will: Die
Tafel deckt die Dateien ab, die es WIRKLICH gibt -- jede
ausgelieferte Seite genau einmal, die Zugangswaende ausgenommen
(die stehen in OHNE_ANMELDUNG, weil sich dort noch niemand
angemeldet hat).
UND SIE DARF NICHT LEER SEIN: `alle.length > 0` gehoert in die
BEDINGUNG, nicht nur in den Meldetext. Sonst waere die Zeile bei
einer leeren Tafel und einem leeren Ordner gruen -- der gruene
Haken ueber der Null. */
const erwartet = DATEIEN.length - rechte.OHNE_ANMELDUNG.length;
ok(alle.length > 0 && alle.length === erwartet,
`${alle.length} Seiten in der Tafel (${DATEIEN.length} Dateien minus ${rechte.OHNE_ANMELDUNG.length} Waende)`);
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);