Filipe: "eine community rolle und community kategorie, wo die community auch dann nur die sieht" · "wieso nur eine kachel — ich will mehrere, mit mehreren optionen" · "egal welche rolle hinzugefügt wird soll immer nur das sehen was ich erlaube". DIE DRITTE ADRESSE. treff.dogfather-universe.com bekommt eine eigene Weiche (treff-adresse.js), eine eigene Zugangswand, ein eigenes Manifest und acht eigene Bühnenbilder. Ohne sie stünde dort die Wand der Agentur: Spicy-Logo, "Creator Workspace", fünf Rollenkacheln mit den Namen Admin, Manager, Scout, Creator — die komplette Struktur des Unternehmens, vor der Anmeldung, für jeden mit der Adresse. Welche Wand zu welcher Tür gehört, steht jetzt in EINER Tafel (istFremdeWand) statt als Sonderfall im Code. Bei zwei Wänden war ein Sonderfall richtig; bei drei wären es drei geworden, bei vier sechs. SIEBEN BRETTER UND EIN ACHTES NUR FÜRS TEAM. Anschlagbrett, Was ansteht, Der Treff, Wunschliste, Highlights, Regeln & Hilfe, Mitmachen — dazu "Meldungen & Maßnahmen", das ausdrücklich NICHT in TREFF_BEREICHE steht: dort wäre es lautlos bei der Community gelandet und hätte ausgesehen wie die anderen sieben. WAS AUF DEN BRETTERN PASSIERT: "Will ich auch" (eine Stimme je Person, erzwungen durch den Schlüssel, nicht durch eine Prüfung) · Anheften, höchstens drei, gezählt IN der Transaktion · Freigabe vor Sichtbarkeit für Termine und Highlights, wobei Abwesenheit der Ruhezustand ist · Melden mit Pflicht-Grund (DSA Art. 16) · Entfernen mit Grund, der den Beitrag ÜBERLEBT (DSA Art. 17) · drei Stufen, gerechnet statt gespeichert · die Bannleiter: Modi bis 7 Tage, dauerhaft nur DogFather (seine Entscheidung) · "Mitmachen" wird zum Talent, genau einmal. Der Ausschluss wirkt in sitzungLesen() — also nicht nur beim Schreiben. Wer ausgeschlossen ist, ist weg, nicht stumm. ZWEI SEITEN MEHR. treff-regeln.html holt seine ZAHLEN vom Server (Mindestalter 18, Fristen, Anschläge) — ein Regeltext, der eine andere Zahl nennt als die Regel, ist schlimmer als keiner. rechte.html rechnet "Wer sieht was" aus derselben Tafel aus, aus der die Schranke ihre Entscheidung holt. Die Community steht dort bei 3 von 23. SECHS BEFUNDE, KEINEN HAT DAS LESEN GEFUNDEN: · Ein Modi kam über den verborgenen Zugang in den Treff — die Bedingung war durch Ausschluss formuliert und nahm die neue Wand automatisch mit · /api/personen gab einem Mitglied Namen und Rolle von DogFather · Ein DELETE mit Körper wird von Nodes HTTP-Parser mit einem leeren 400 abgewiesen, bevor Express ihn sieht (gemessen). Der Grund reist jetzt in der Adresse · Der Kommentar, der erklärt, warum die Zugangswand keine Rollennamen nennt, nannte selbst einen — in einer ausgelieferten Datei · Zwei Listen für "welche Seiten sind ohne Anmeldung offen". Sie stimmten, weil beide am selben Tag entstanden · Ein Portkonflikt, den ich selbst gebaut hatte (4391 gehört pruef-chat-aufloesen) Dazu zwei feste Zahlen abgeschafft (die 20 in pruef-rechtetafel, die Kachelzahl in pruef-treff) und zwei Werkzeuge, die jetzt SUCHEN statt aufzuzählen: Der Versionsstempel findet seine Manifeste selbst — das dritte hätte sonst keinen bekommen, und die neue App hätte bis zu vier Stunden lang das falsche Zeichen getragen. Das Bildwerkzeug baut beide Zugangswände aus einer Vorlage mit zwei Werten. Zwei neue Kacheltöne, gemessen statt gewählt: #2f2f7f (Abstand 31,4 in Lab) und #7c2771 (29,7). Das schwächste Paar der vorhandenen 34 liegt bei 24,9. Geprüft: treff 69 · treff-werkzeuge 60 · rollen 312 · start-ansicht 147 · crew-adresse 129 · sicht 84 · modi-verborgen 80 · haus-trennung 62 · bereiche-lesend 37 · css-klassen 30 · zwischenspeicher 21 · alle-wege 19 · rechtetafel 19 — alles grün. Co-Authored-By: Claude Opus 5 <[email protected]>
302 lines
14 KiB
JavaScript
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 { 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 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);
|