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]>
This commit is contained in:
2026-10-06 16:21:10 +02:00
co-authored by Claude Opus 5
parent 98cfdc3ebc
commit a654f74dd1
4 changed files with 389 additions and 1 deletions
+134
View File
@@ -0,0 +1,134 @@
/* =====================================================================
WAS SEHEN DIE VIER CREW-ROLLEN HEUTE AUF willkommen.html?
Ausgangsmessung vom 06.10.2026, bevor der Bauplan auf der
Crew-Seite umgesetzt wird. Ohne sie waere hinterher nicht zu
sagen, was die Arbeit bewirkt hat -- und ob die Seite vorher
wirklich leer war oder nur anders.
Gemessen wird am HOSTNAMEN, nicht an der Rolle: Erst der
Hostname macht `person.haus` zu "crew", und genau daran haengt
die Schranke im Servermodul.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request } from "node:http";
const ordner = mkdtempSync(join(tmpdir(), "ws-anlcrew-"));
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, "mess-anleitung-crew");
process.env.PORT = `${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(120_000, "mess-anleitung-crew");
await new Promise((r) => setTimeout(r, 900));
const CREW = "crew.dogfather-universe.com";
/** Ein Aufruf mit frei waehlbarem Host-Kopf -- `fetch` laesst `Host`
* nicht setzen, deshalb node:http. */
function ruf(pfad, { keks = "", methode = "GET", rumpf = null } = {}) {
return new Promise((fertig) => {
const kopf = { Host: CREW };
if (keks) kopf.Cookie = keks;
if (rumpf) { kopf["Content-Type"] = "application/json"; }
const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, headers: kopf },
(r) => {
let t = "";
r.on("data", (c) => { t += c; });
r.on("end", () => fertig({
status: r.statusCode,
keks: (r.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "),
text: t,
json: (() => { try { return JSON.parse(t); } catch { return null; } })(),
}));
});
a.on("error", () => fertig({ status: 0, text: "", json: null, keks: "" }));
if (rumpf) a.write(rumpf);
a.end();
});
}
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await ruf("/workspace/api/anmelden", { methode: "POST", rumpf: JSON.stringify({ rolle: "admin", code: "x" }) });
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 ROLLEN = [["gast", "Community"], ["modi", "Modi"], ["linke", "Linke Hand"], ["hand", "Rechte Hand"]];
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
for (const [rolle] of ROLLEN) {
const salt = randomBytes(16).toString("hex");
const code = `CODE-${rolle.toUpperCase()}-0001`;
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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(rolle, rolle, hash, salt, 32768, new Date().toISOString());
}
d.close();
}
console.log("\n=== Was die Crew-Rollen heute auf willkommen.html bekommen ===");
for (const [rolle, wie] of ROLLEN) {
const an = await ruf("/workspace/api/anmelden", {
methode: "POST", rumpf: JSON.stringify({ rolle, code: `CODE-${rolle.toUpperCase()}-0001` }),
});
if (an.status !== 200) { console.log(` ${wie.padEnd(12)} kommt nicht herein (${an.status})`); continue; }
const seite = await ruf("/workspace/willkommen.html", { keks: an.keks });
const api = await ruf("/workspace/api/anleitung", { keks: an.keks });
const karten = api.json?.karten?.length ?? "-";
console.log(` ${wie.padEnd(12)} Seite ${seite.status} · Schnittstelle ${api.status}`
+ ` · Karten ${karten}`
+ (api.json?.fehler ? ` · "${api.json.fehler}"` : ""));
}
/* ==== FUEHREN DIE KACHELN DER LINKEN HAND INS LEERE? ================
Der Bauplan spricht der Linken Hand vier Kacheln ab (Talente,
Personen & Zugaenge, Wer sieht was, Vertraulich melden). Die
Kachelliste gibt sie ihr trotzdem. Entweder irrt der Plan -- oder
sie sieht vier Kacheln, die auf Seiten fuehren, die sie nicht
oeffnen darf. Gefragt wird der laufende Server, nicht die Tabelle. */
{
console.log("\n=== Linke Hand: fuehren ihre Kacheln irgendwohin? ===");
const an = await ruf("/workspace/api/anmelden", {
methode: "POST", rumpf: JSON.stringify({ rolle: "linke", code: "CODE-LINKE-0001" }),
});
for (const pfad of ["/workspace/talente.html", "/workspace/personen.html",
"/workspace/rechte.html", "/workspace/hilfe.html", "/workspace/aufgaben.html"]) {
const a2 = await ruf(pfad, { keks: an.keks });
const wohin = a2.status >= 300 && a2.status < 400 ? " -> umgeleitet" : "";
console.log(` ${pfad.replace("/workspace/", "").padEnd(18)} ${a2.status}${wohin}`);
}
}
/* Und was steht in der Datenbank -- je Haus. */
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
console.log("\n=== Inhalte in der Datenbank ===");
for (const r of d.prepare(
"SELECT haus, rolle, COUNT(*) n FROM anl_karte GROUP BY haus, rolle ORDER BY haus, rolle").all()) {
console.log(` ${r.haus.padEnd(8)} ${r.rolle.padEnd(10)} ${r.n} Karten`);
}
const crew = d.prepare("SELECT COUNT(*) n FROM anl_karte WHERE haus = 'crew'").get().n;
console.log(` -> fuer die Crew: ${crew} Karten`);
d.close();
}
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+48
View File
@@ -143,6 +143,54 @@ melde("\n=== Sackgassen: führt irgendein Weg ins Leere? ===\n");
ok(ROLLEN.length >= 3, `die Anmeldewand bietet ${ROLLEN.length} Rollen an: ${ROLLEN.join(", ")}`); ok(ROLLEN.length >= 3, `die Anmeldewand bietet ${ROLLEN.length} Rollen an: ${ROLLEN.join(", ")}`);
ok(alleSeiten.length >= 20, `die Rechtetafel kennt ${alleSeiten.length} Seiten`); 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 ------------------------------------------------------- */ /* ---- Anmelden ------------------------------------------------------- */
async function anmelden(rolle) { async function anmelden(rolle) {
let a = await roh("/workspace/api/anmelden", {}, { rolle, code: CODES[rolle] }); let a = await roh("/workspace/api/anmelden", {}, { rolle, code: CODES[rolle] });
+33 -1
View File
@@ -2208,8 +2208,40 @@ export const brettAufDieserAdresse = (person, bereich) =>
beim Lesen aus wie der Schutz, und man sucht den echten nicht mehr. beim Lesen aus wie der Schutz, und man sucht den echten nicht mehr.
Der echte ist das Tor in workspace-bereiche.js und in der Der echte ist das Tor in workspace-bereiche.js und in der
Video-Route -- beide gemessen in pruef-haus-trennung. */ Video-Route -- beide gemessen in pruef-haus-trennung. */
/** Eine Kachel, deren Seite diese Rolle nicht oeffnen darf, gibt es nicht.
*
* GEMESSEN AM 06.10.2026, beim Abgleich des Bauplans mit dem Haus:
* Die LINKE HAND sah vier Kacheln, die ins Leere fuehrten --
* Talente, Personen & Zugaenge, Wer sieht was, Vertraulich melden.
* Alle vier antworteten mit 302 und leiteten sie weg; `aufgaben.html`
* als Gegenprobe mit 200. Der Bauplan hatte recht, die Kachelliste
* nicht.
*
* WARUM ES PASSIERT IST: Die Liste der linken Hand entsteht aus der
* der rechten MINUS einer Kachel. Jede Seite, die spaeter nur fuer
* die rechte Hand freigegeben wurde, kam damit still bei der linken
* mit -- die Rechtetafel wusste es, die Kachelliste nicht.
*
* ABGELEITET STATT GEPFLEGT: Gefragt wird dieselbe Tafel, die auch
* die Seite selbst schuetzt (`darfSeite`). Damit kann diese Sorte
* Fehler bei KEINER Rolle wiederkommen, nicht nur bei dieser einen.
* Nachgemessen ueber alle Rollen beider Haeuser: Es waren genau
* diese vier, sonst nichts -- die Regel nimmt also niemandem etwas
* weg, was er heute benutzt.
*
* Ziele ohne `.html` (aussen liegende Verweise) bleiben unberuehrt;
* die Rechtetafel kennt sie nicht und soll sie nicht kennen. */
function ohneTotwege(liste, person) {
if (!Array.isArray(liste)) return liste;
return liste.filter((k) => {
const z = String(k?.ziel || "").split("?")[0];
if (!z.endsWith(".html")) return true;
return darfSeite(person, "/workspace/" + z);
});
}
export function bereicheFuer(person) { export function bereicheFuer(person) {
const liste = bereicheRoh(person); const liste = ohneTotwege(bereicheRoh(person), person);
if (liste === null || !Array.isArray(liste) || !liste.length) return liste; if (liste === null || !Array.isArray(liste) || !liste.length) return liste;
if (liste.some((k) => k?.ziel === "support.html")) return liste; if (liste.some((k) => k?.ziel === "support.html")) return liste;
+174
View File
@@ -0,0 +1,174 @@
/* =====================================================================
PDF-Text herausholen -- ohne fremde Bibliothek.
Gebaut am 06.10.2026, weil auf diesem Rechner weder poppler noch
eine PDF-Bibliothek liegt und ein Bauplan gelesen werden musste.
WIE ES ARBEITET: Alle Stroeme entpacken, die Seiten in ihrer
Reihenfolge einsammeln, und den Text ueber die ToUnicode-Tabellen
der jeweiligen Schrift zurueckuebersetzen. Ohne diese Tabellen
kaeme Buchstabensalat heraus -- die Schriften sind Teilmengen, in
denen <54> irgendein Zeichen bedeuten kann.
AUFRUF: node tools/pdf-text.mjs <datei.pdf> [ziel.txt]
===================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import { inflateSync } from "node:zlib";
const quelle = process.argv[2];
const ziel = process.argv[3] || null;
if (!quelle) { console.error("Aufruf: node tools/pdf-text.mjs <datei.pdf> [ziel.txt]"); process.exit(2); }
const roh = readFileSync(quelle);
const text = roh.toString("latin1");
/* ---- 1. Alle Objekte einsammeln ------------------------------------ */
const objekte = new Map(); // nummer -> { dict, daten }
{
const re = /(\d+)\s+(\d+)\s+obj\b/g;
let m;
while ((m = re.exec(text))) {
const nr = Number(m[1]);
const ab = m.index + m[0].length;
const bis = text.indexOf("endobj", ab);
if (bis < 0) continue;
const koerper = text.slice(ab, bis);
let daten = null;
const sm = /(^|[^d])stream\r?\n/.exec(koerper);
if (sm) {
const sAb = ab + sm.index + sm[0].length;
const sBis = text.indexOf("endstream", sAb);
if (sBis > 0) {
try { daten = inflateSync(roh.subarray(sAb, sBis)); }
catch { daten = roh.subarray(sAb, sBis); }
}
}
objekte.set(nr, { dict: sm ? koerper.slice(0, sm.index) : koerper, daten });
}
}
/* ---- 2. ToUnicode-Tabellen ----------------------------------------- */
const tabellen = new Map(); // objektnummer -> Map(code -> zeichen)
function tabelleLesen(nr) {
if (tabellen.has(nr)) return tabellen.get(nr);
const o = objekte.get(nr);
const karte = new Map();
tabellen.set(nr, karte);
if (!o?.daten) return karte;
const t = o.daten.toString("latin1");
/* Einzelzuordnungen: <01> <0041> */
for (const block of t.match(/beginbfchar([\s\S]*?)endbfchar/g) || []) {
const re = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g; let m;
while ((m = re.exec(block))) karte.set(parseInt(m[1], 16), ausHex(m[2]));
}
/* Bereiche: <01> <05> <0041> oder <01> <03> [<0041> <0042> <0043>] */
for (const block of t.match(/beginbfrange([\s\S]*?)endbfrange/g) || []) {
const re = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*(?:<([0-9A-Fa-f]+)>|\[([\s\S]*?)\])/g;
let m;
while ((m = re.exec(block))) {
const von = parseInt(m[1], 16), bis = parseInt(m[2], 16);
if (m[3]) {
const basis = parseInt(m[3], 16);
for (let c = von; c <= bis && c - von < 65536; c++) {
karte.set(c, String.fromCodePoint(basis + (c - von)));
}
} else if (m[4]) {
const liste = m[4].match(/<([0-9A-Fa-f]+)>/g) || [];
liste.forEach((h, i) => karte.set(von + i, ausHex(h.slice(1, -1))));
}
}
}
return karte;
}
function ausHex(h) {
let s = "";
for (let i = 0; i + 3 < h.length + 1; i += 4) {
const c = parseInt(h.slice(i, i + 4), 16);
if (!Number.isNaN(c)) s += String.fromCharCode(c);
}
return s;
}
/* ---- 3. Seiten in der richtigen Reihenfolge ------------------------ */
const seiten = [];
for (const [nr, o] of objekte) {
if (!/\/Type\s*\/Page[^s]/.test(o.dict)) continue;
seiten.push({ nr, dict: o.dict });
}
seiten.sort((a, b) => a.nr - b.nr);
/* ---- 4. Text je Seite ---------------------------------------------- */
const alles = [];
for (const [i, seite] of seiten.entries()) {
/* Welche Schrift heisst wie? /F5 12 0 R */
const schriften = new Map();
const fm = /\/Font\s*<<([\s\S]*?)>>/.exec(seite.dict);
if (fm) {
const re = /\/(\w+)\s+(\d+)\s+\d+\s+R/g; let m;
while ((m = re.exec(fm[1]))) {
const fo = objekte.get(Number(m[2]));
const tu = fo && /\/ToUnicode\s+(\d+)\s+\d+\s+R/.exec(fo.dict);
schriften.set("/" + m[1], tu ? tabelleLesen(Number(tu[1])) : new Map());
}
}
/* Inhalt -- kann ein Verweis oder eine Liste sein. */
const cm = /\/Contents\s+(?:(\d+)\s+\d+\s+R|\[([^\]]*)\])/.exec(seite.dict);
let inhalt = "";
if (cm?.[1]) inhalt = objekte.get(Number(cm[1]))?.daten?.toString("latin1") || "";
else if (cm?.[2]) {
for (const r of cm[2].match(/(\d+)\s+\d+\s+R/g) || []) {
inhalt += (objekte.get(Number(r))?.daten?.toString("latin1") || "") + "\n";
}
}
let karte = new Map();
let zeile = "";
const raus = [];
/* Jede Textanweisung der Reihe nach. `Td`/`TD`/`Tm`/`T*` mit
Zeilenvorschub trennt Zeilen -- sonst klebt die ganze Seite
zusammen und kein Mensch liest es. */
const re = /\/(\w+)\s+[\d.]+\s+Tf|<([0-9A-Fa-f\s]*)>\s*Tj|\[([\s\S]*?)\]\s*TJ|([-\d.]+)\s+([-\d.]+)\s+Td|T\*|ET/g;
let m;
let letztesY = null;
while ((m = re.exec(inhalt))) {
if (m[1]) { karte = schriften.get("/" + m[1]) || new Map(); continue; }
if (m[2] !== undefined) { zeile += entziffern(m[2], karte); continue; }
if (m[3] !== undefined) {
for (const st of m[3].match(/<([0-9A-Fa-f\s]*)>/g) || []) {
zeile += entziffern(st.slice(1, -1), karte);
}
/* Grosse Rueckschritte im TJ sind Wortabstaende. */
continue;
}
if (m[4] !== undefined) {
const y = Number(m[5]);
if (letztesY !== null && Math.abs(y) > 0.1) { raus.push(zeile); zeile = ""; }
letztesY = y;
continue;
}
/* T* oder ET */
if (zeile.trim()) { raus.push(zeile); zeile = ""; }
}
if (zeile.trim()) raus.push(zeile);
alles.push(`\n═══ Seite ${i + 1} ═══\n` + raus.join("\n"));
}
function entziffern(hex, karte) {
const h = hex.replace(/\s+/g, "");
let s = "";
for (let i = 0; i + 1 < h.length + 1; i += 2) {
const c = parseInt(h.slice(i, i + 2), 16);
if (Number.isNaN(c)) continue;
s += karte.get(c) ?? "";
}
return s;
}
const fertig = alles.join("\n");
if (ziel) { writeFileSync(ziel, fertig, "utf8"); console.log(`${seiten.length} Seiten -> ${ziel} (${fertig.length} Zeichen)`); }
else console.log(fertig);