Files
dogfather-universe/server/pruef-sackgassen.mjs
T
DogFatherGitandClaude Opus 5 a654f74dd1 Vier tote Kacheln bei der linken Hand -- gefunden beim Abgleich des Bauplans
Filipe hat den Bauplan "Willkommen & Anleitung" geschickt und um eine
vollstaendige Pruefung gebeten. Beim Abgleich der Kachelzahlen stimmte
eine Zeile nicht:

    Rolle          Bauplan              App
    Community      14 + Willkommen      15   ok
    Modi           29 + Willkommen      30   ok
    Linke Hand     29 + Willkommen      34   NEIN
    Rechte Hand    34 + Willkommen      35   ok

Am laufenden Server nachgemessen: Die linke Hand sah vier Kacheln, die
ins Leere fuehren -- Talente, Personen & Zugaenge, Wer sieht was,
Vertraulich melden. Alle vier antworten mit 302 und leiten sie weg;
aufgaben.html als Gegenprobe mit 200. Der Bauplan spricht ihr genau
diese vier ab. Der Plan hatte recht, die Kachelliste nicht.

WARUM ES PASSIERT IST: Ihre Liste entsteht aus der der rechten Hand
MINUS einer Kachel (Dein Team). Jede Seite, die spaeter nur fuer die
rechte Hand freigegeben wurde, kam damit still bei ihr mit -- die
Rechtetafel wusste es, die Kachelliste nicht.

ABGELEITET STATT GEPFLEGT: Eine Kachel, deren Seite die Rolle nicht
oeffnen darf, erscheint nicht mehr. Gefragt wird dieselbe Tafel, die
auch die Seite selbst schuetzt (`darfSeite`, in workspace.js schon
importiert). Damit kann diese Fehlersorte bei KEINER Rolle
wiederkommen, nicht nur bei dieser einen.

Nachher hat die linke Hand 30 Kacheln -- genau die Zahl, die im
Bauplan steht ("Du siehst dieselben 30 Kacheln wie ein Modi"). Alle
vier Zahlen des Plans stimmen damit. Die uebrigen Rollen sind
unveraendert (gemessen: gast 15, modi 30, hand 35, admin 35).

pruef-sackgassen +1, und die Ergaenzung ist der eigentliche Punkt:
Die Datei heisst "Sackgassen" und hat das NICHT gefunden, weil sie
ihre Rollen von der Anmeldewand nimmt -- und die steht auf der
Agenturadresse. Modi, linke Hand und Gast gibt es dort nicht; sie
wurden nie geprueft. Eine Pruefung, die ihre Faelle aus einer Liste
nimmt, prueft nur, was auf der Liste steht.

Die neue Zeile kommt ohne Browser und ohne Anmeldung aus und fragt
die beiden Stellen, die es wissen: `bereicheFuer` und `darfSeite`.
Damit sind alle Rollen BEIDER Haeuser erfasst. Gegenprobe gefahren:
ohne die Korrektur nennt sie alle vier beim Namen, mit ihr sind
145 Kacheln sauber.

Gegengemessen: pruef-rollen 452, pruef-rechtetafel 19,
pruef-sackgassen 13 -- 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 16:21:10 +02:00

326 lines
14 KiB
JavaScript

/* =====================================================================
pruef-sackgassen.mjs — Kein Weg führt ins Leere. Für JEDE Rolle.
Filipe, 19.09.2026: „Es darf keine Sackgassen geben. Es darf keine
Funktionen geben, bei denen man sich fragt: Was soll ich hier jetzt
machen?"
---------------------------------------------------------------------
DIE LÜCKE, DIE ES ZU SCHLIESSEN GALT
`pruef-community-sicht` prüft genau das -- aber nur für den **Gast**.
Gemessen am 20.09.2026: Ein Modi hat 25 Kacheln, eine rechte Hand
30, DogFather 39. Keine dieser drei Rollen war je darauf geprüft
worden, ob ihre Seiten auf etwas verweisen, das sie nicht öffnen
darf.
Und genau dort ist es zweimal passiert: Am 19.09. warf „Klingelt
nichts?" ALLE DREI Team-Rollen auf die Startseite zurück, und der
Kalender-Verweis führte die Community seit jeher ins Leere. Beides
hat jemand zufällig gefunden, nicht eine Prüfung.
---------------------------------------------------------------------
WAS EINE SACKGASSE IST
Ein sichtbarer Verweis auf eine Seite, die diese Rolle nicht öffnen
darf. Wer darauf tippt, landet auf der Startseite -- ohne ein Wort
dazu, warum. Aus seiner Sicht hat die App ihn abgeworfen.
Gemessen wird gegen `darfSeite()` aus `rechte.js` -- dieselbe
Tafel, nach der der Server entscheidet. Keine zweite Liste, die
auseinanderlaufen könnte.
---------------------------------------------------------------------
WARUM DAS ÜBER 127.0.0.1 GEHT, OBWOHL ES CREW-ROLLEN SIND
Das Haus einer Person wird NICHT aus der Adresse abgeleitet,
sondern aus ihrer Rolle (`workspace.js`: `TEAM_DOGI_ROLLEN.has(rolle)
? "crew" : "agentur"`). Ein Modi ist also auch auf 127.0.0.1 im
Crew-Haus. Deshalb braucht diese Prüfung weder eine eigene
HTTPS-Front noch einen umgebogenen Namensdienst -- anders als
`pruef-handy-teamdogi`, die wirklich die Adresse messen muss.
AUFRUF: node server/pruef-sackgassen.mjs
Dauert rund eine Minute -- vier Rollen, alle ihre Seiten, ein Gerät.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
const ordner = mkdtempSync(join(tmpdir(), "ws-sackgasse-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Sackgassenpruefung");
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 }); };
await import("./index.js");
notbremse(420_000, "pruef-sackgassen");
await new Promise((r) => setTimeout(r, 1000));
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++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const CREW = "crew.dogfather-universe.com";
const { darfSeite, SEITEN } = await import("./rechte.js");
const { bereicheFuer } = await import("./workspace.js");
/** Alle Seiten des Workspace -- abgeleitet aus der Rechtetafel, nicht
* aus dem Verzeichnis: Was nicht in der Tafel steht, ist ohnehin
* verboten, und was nur im Verzeichnis liegt, ist keine Seite. */
const alleSeiten = Object.keys(SEITEN)
.filter((p) => p.startsWith("/workspace/") && p.endsWith(".html"))
.map((p) => p.replace("/workspace/", ""));
function roh(pfad, kopf = {}, koerper = null) {
return new Promise((f, s) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: koerper ? "POST" : "GET",
headers: { Host: CREW, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
}, (an) => {
let t = "";
an.on("data", (c) => { t += c; });
an.on("end", () => f({ code: an.statusCode, text: t, kopf: an.headers }));
});
a.on("error", s);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
/* ---- Bestand -------------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await roh("/workspace/api/ich").catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const db = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const h = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
db.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,1,?)").run(name, rolle, h, salt, 32768, jetzt);
}
/** DIE ROLLEN WERDEN ABGELEITET, NICHT AUFGEZÄHLT.
*
* Käme morgen eine fünfte Team-Dogi-Rolle dazu, stünde sie hier
* nicht -- und niemand würde es merken. Genommen wird deshalb, was
* die Anmeldewand selbst anbietet. */
const { readFileSync } = await import("node:fs");
const wand = readFileSync(new URL("../workspace/crew-index.html", import.meta.url), "utf8");
const ROLLEN = [...new Set([...wand.matchAll(/data-rolle="([a-z]+)"/g)].map((m) => m[1]))];
const CODES = {};
for (const r of ROLLEN) {
const code = `CODE-${r.toUpperCase().padEnd(4, "X").slice(0, 4)}-0001`;
CODES[r] = code;
anlegen(r.charAt(0).toUpperCase() + r.slice(1) + "test", r, code);
}
db.close();
melde("\n=== Sackgassen: führt irgendein Weg ins Leere? ===\n");
ok(ROLLEN.length >= 3, `die Anmeldewand bietet ${ROLLEN.length} Rollen an: ${ROLLEN.join(", ")}`);
ok(alleSeiten.length >= 20, `die Rechtetafel kennt ${alleSeiten.length} Seiten`);
/* ==== KEINE KACHEL FUEHRT AUF EINE GESPERRTE SEITE =================
NACHGETRAGEN AM 06.10.2026, nachdem genau das hier durchgerutscht
ist: Die linke Hand sah vier Kacheln (Talente, Personen & Zugaenge,
Wer sieht was, Vertraulich melden), die alle mit 302 wegleiteten.
WARUM DIESE DATEI ES NICHT GEMERKT HAT, obwohl sie "Sackgassen"
heisst: Sie nimmt die Rollen von der ANMELDEWAND -- und die steht
auf der Agenturadresse. Modi, linke Hand und Gast gibt es dort
nicht; sie wurden nie geprueft. Eine Pruefung, die ihre Faelle aus
einer Liste nimmt, prueft nur, was auf der Liste steht.
DESHALB OHNE BROWSER UND OHNE ANMELDUNG: Gefragt werden die beiden
Stellen, die es wissen -- `bereicheFuer` (welche Kacheln) und
`darfSeite` (welche Seiten). Damit sind ALLE Rollen BEIDER Haeuser
erfasst, auch die, die auf keiner Anmeldewand stehen. */
{
const { bereicheFuer } = await import("./workspace.js");
const { darfSeite } = await import("./rechte.js");
const FAELLE = [
["crew", "gast"], ["crew", "modi"], ["crew", "linke"], ["crew", "hand"], ["crew", "admin"],
["agentur", "creator"], ["agentur", "scout"], ["agentur", "manager"],
["agentur", "spicy"], ["agentur", "admin"],
];
let geprueftKacheln = 0;
const tote = [];
for (const [haus, rolle] of FAELLE) {
const person = { id: 1, rolle, haus };
const liste = bereicheFuer(person);
/* `null` heisst "der Browser nimmt seine eigene Liste" -- dort gibt
es nichts zu pruefen, und das ist kein Mangel. */
if (!Array.isArray(liste)) continue;
for (const k of liste) {
const z = String(k?.ziel || "").split("?")[0];
if (!z.endsWith(".html")) continue;
geprueftKacheln++;
if (!darfSeite(person, "/workspace/" + z)) tote.push(`${haus}/${rolle}: ${k.name} -> ${z}`);
}
}
/* Die Zahl gehoert in die Bedingung: Kaeme gar keine Kachel zurueck,
waere "keine tote dabei" gruen, ohne eine einzige angesehen zu
haben. */
ok(geprueftKacheln > 100 && tote.length === 0,
tote.length
? `${tote.length} Kachel(n) fuehren auf gesperrte Seiten: ${tote.join(" | ")}`
: `keine der ${geprueftKacheln} Kacheln fuehrt auf eine gesperrte Seite`);
}
/* ---- Anmelden ------------------------------------------------------- */
async function anmelden(rolle) {
let a = await roh("/workspace/api/anmelden", {}, { rolle, code: CODES[rolle] });
/* DIE ALTERSBESTAETIGUNG IST EIN EIGENER SCHRITT -- und zwar nur
fuer den Gast. Ohne sie antwortet der Server mit 400 und
`alter_offen`. Das ist kein Fehler, sondern die Schranke, die
dort hingehoert: Der Treff ist nichts fuer Kinder.
Erkannt wird sie an der ANTWORT, nicht an der Rolle -- eine
Liste "wer bestaetigen muss" waere morgen falsch, wenn die
Schranke noch woanders gilt. */
if (a.code === 400 && a.text.includes("alter_offen")) {
a = await roh("/workspace/api/anmelden", {}, { rolle, code: CODES[rolle], alter_ok: true });
}
const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
return { code: a.code, keks };
}
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
/* Ein Gerät genügt: Eine Sackgasse ist eine Frage der RECHTE, nicht
der Breite. Was am Handy ins Leere führt, tut es am Rechner auch --
`pruef-handy-teamdogi` misst dafür die Größen. */
const gesamt = { wege: 0, tot: 0, seiten: 0 };
const befunde = [];
try {
for (const rolle of ROLLEN) {
const an = await anmelden(rolle);
if (an.code !== 200 || !an.keks) { ok(false, `${rolle}: Anmeldung (${an.code})`); continue; }
const erlaubt = alleSeiten.filter((d) => darfSeite({ rolle, haus: "crew" }, `/workspace/${d}`));
const verboten = alleSeiten.filter((d) => !darfSeite({ rolle, haus: "crew" }, `/workspace/${d}`));
const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } });
await ctx.addCookies(an.keks.split("; ").filter(Boolean).map((z) => {
const [name, ...rest] = z.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await ctx.newPage();
const skriptfehler = [];
seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 120)));
/* Die Bretter hinter bereich.html sehen völlig verschieden aus --
jedes ist eine eigene Seite. Abgeleitet aus den Kacheln dieser
Rolle, nicht aus einer Liste. */
const kacheln = bereicheFuer({ rolle, haus: "crew", id: 1, name: "X" }) || [];
const ziele = new Set(erlaubt);
for (const k of kacheln) {
const z = String(k.ziel || "");
if (z.startsWith("bereich.html?")) ziele.add(z);
}
let tot = 0, wege = 0;
for (const ziel of ziele) {
await seite.goto(`http://127.0.0.1:${PORT}/workspace/${ziel}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(700);
gesamt.seiten++;
const gefunden = await seite.evaluate(() =>
[...document.querySelectorAll("a[href]")]
.filter((a) => a.offsetParent !== null)
.map((a) => ({ ziel: a.getAttribute("href"), text: a.textContent.trim().slice(0, 34) })));
for (const w of gefunden) {
const datei = String(w.ziel).split("?")[0].split("#")[0].replace(/^\.?\//, "");
if (!datei.endsWith(".html")) continue;
wege++;
if (verboten.includes(datei)) {
tot++;
befunde.push(`${rolle} · ${ziel}: „${w.text}" -> ${datei}`);
}
}
}
gesamt.wege += wege; gesamt.tot += tot;
/* DIE ANZAHL GEHÖRT IN DIE BEDINGUNG: „0 tote Wege" wäre auch
dann wahr, wenn gar keine Seite geladen hätte. */
ok(wege > 0 && tot === 0,
`${rolle}: ${ziele.size} Seiten, ${wege} sichtbare Wege, ${tot} ins Leere`);
ok(skriptfehler.length === 0,
`${rolle}: keine Skriptfehler${skriptfehler.length ? " — " + skriptfehler[0] : ""}`);
await ctx.close();
}
} finally {
await browser.close();
}
if (befunde.length) {
melde("\n --- Die Wege ins Leere ---");
for (const b of befunde.slice(0, 20)) melde(" " + b);
if (befunde.length > 20) melde(` … und ${befunde.length - 20} weitere`);
}
/* ---- DIE GEGENPROBE -------------------------------------------------
Eine Prüfung, die immer bestätigt, bestätigt nichts. Hier wird eine
Sackgasse ERFUNDEN und muss gefunden werden. */
melde("\n --- Gegenprobe (muss anschlagen) ---");
{
const rolle = ROLLEN.includes("gast") ? "gast" : ROLLEN[ROLLEN.length - 1];
const verboten = alleSeiten.filter((d) => !darfSeite({ rolle, haus: "crew" }, `/workspace/${d}`));
const b2 = await pw.chromium.launch();
try {
const an = await anmelden(rolle);
const ctx = await b2.newContext({ viewport: { width: 1280, height: 950 } });
await ctx.addCookies(an.keks.split("; ").filter(Boolean).map((z) => {
const [name, ...rest] = z.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const s2 = await ctx.newPage();
await s2.goto(`http://127.0.0.1:${PORT}/workspace/start.html`, { waitUntil: "networkidle" });
const erfunden = verboten[0];
await s2.evaluate((d) => {
const a = document.createElement("a");
a.href = d; a.textContent = "erfundener Weg";
document.body.append(a);
}, erfunden);
const jetztTot = await s2.evaluate(() =>
[...document.querySelectorAll("a[href]")]
.filter((a) => a.offsetParent !== null)
.map((a) => a.getAttribute("href")));
ok(jetztTot.includes(erfunden) && verboten.includes(erfunden),
`ein erfundener Weg nach ${erfunden} wird gefunden`);
await ctx.close();
} finally { await b2.close(); }
}
melde(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen`);
melde(` (${ROLLEN.length} Rollen, ${gesamt.seiten} Seitenansichten, `
+ `${gesamt.wege} sichtbare Wege, ${gesamt.tot} ins Leere)\n`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);