Files
dogfather-universe/server/pruef-rechtetafel.mjs
T
DogFatherGitandClaude Opus 5 6e46a08543 Portnummern werden abgeleitet, nicht mehr vergeben
Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.

Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.

Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.

--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------

1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
   Muster: "([^"]*4231[^"]*)". Das hielt

     { host: "127.0.0.1", port: 4231, path: "/404.html" }

   fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
   einer schliessenden Anfuehrung unterscheiden. Heraus kam

     { host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }

   also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
   alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
   gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
   39/0 vorher, 38/1 nachher.

   Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
   ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
   regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
   regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.

2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
   zweite Durchgang importierte den ersten, um seine Mechanik zu
   benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
   zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
   bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
   zweimal.

--- WAS DAS DAUERHAFT HAELT -----------------------------------------

pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.

Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.

--- NACHGEMESSEN ----------------------------------------------------

Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):

  crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
  anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
  kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
  push-ziel 10/0 · portnummern 8/0

Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 22:51:21 +02:00

302 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`);
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 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);