diff --git a/server/index.js b/server/index.js index 97b0f858..825b130d 100644 --- a/server/index.js +++ b/server/index.js @@ -18,6 +18,7 @@ import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js"; import { workspaceRouter, sichtSetzen } from "./workspace.js"; import { aufgabenRouter } from "./workspace-aufgaben.js"; import { zuteilungRouter } from "./workspace-zuteilung.js"; +import { willkommenRouter } from "./workspace-willkommen.js"; import { personenRouter } from "./workspace-personen.js"; import { profilRouter } from "./workspace-profil.js"; import { kalenderRouter } from "./workspace-kalender.js"; @@ -278,6 +279,7 @@ app.use(teamlageRouter); /* VOR aufgabenRouter: Der faengt "/workspace/api/aufgaben/:id" ab und wuerde "/workspace/api/aufgaben/resuemee" als Aufgabe mit der Nummer "resuemee" behandeln. Die Reihenfolge ist hier keine Stilfrage. */ +app.use(willkommenRouter); app.use(zuteilungRouter); app.use(aufgabenRouter); app.use(personenRouter); diff --git a/server/pruef-buehne.mjs b/server/pruef-buehne.mjs index d52a6cb1..2340d59e 100644 --- a/server/pruef-buehne.mjs +++ b/server/pruef-buehne.mjs @@ -70,6 +70,8 @@ const ALLE_SEITEN = [ "werdegang.html", "entwicklung.html", "talente.html", "befinden.html", "hilfe.html", "rechte.html", "teamlage.html", "teilen.html", "bewerbungen.html", "treff-moderation.html", "treff-regeln.html", + /* Neu am 21.09.2026 -- sie traegt eine Buehne und viel Text. */ + "willkommen.html", "unsere-seiten.html", ]; const SEITEN = process.env.BUEHNE_SEITEN diff --git a/server/pruef-treff.mjs b/server/pruef-treff.mjs index 09d292d1..2bb98658 100644 --- a/server/pruef-treff.mjs +++ b/server/pruef-treff.mjs @@ -251,8 +251,22 @@ melde("=== 1b. Eine Wand für beide ==="); ok(wand.code === 200 && /Team Dogi|Team Dogi/.test(wand.text), `die Wand von Team Dogi steht (${wand.code})`); const kacheln = [...wand.text.matchAll(/data-rolle="([a-z]+)"/g)].map((m) => m[1]); - ok(kacheln.length === 4 && kacheln.includes("gast"), - `vier Kacheln, eine davon die Community: ${kacheln.join(", ")}`); + /* ABGELEITET, NICHT GEZAEHLT (berichtigt 21.09.2026). + ---------------------------------------------------------------- + Hier stand `kacheln.length === 4`. Am 21.09. kam die linke Hand + dazu, und die Zeile wurde rot, obwohl die Wand richtig war. + + Die eigentliche Aussage ist auch eine andere und wiegt schwerer: + Die Anmeldung sucht `WHERE rolle = ?`. Eine Rolle OHNE Kachel + kommt damit ueberhaupt nicht herein -- der Zugang existiert, und + niemand kann ihn benutzen. Geprueft wird deshalb, dass jede Rolle + dieser Adresse ihre Kachel hat. Das kann nicht veralten. */ + const sollRollen = [...w.TREFF_ROLLEN]; + const ohneKachel = sollRollen.filter((r) => !kacheln.includes(r)); + ok(ohneKachel.length === 0, ohneKachel.length === 0 + ? `jede Rolle dieser Adresse hat ihre Kachel: ${kacheln.join(", ")}` + : `OHNE KACHEL und damit unerreichbar: ${ohneKachel.join(", ")}`); + ok(kacheln.includes("gast"), "die Community ist eine davon"); ok(kacheln[kacheln.length - 1] === "gast", "und sie steht zuletzt -- wer täglich kommt, findet oben, was er täglich braucht"); @@ -334,12 +348,16 @@ let namen = []; rechnet zusaetzlich nach, ob daraus ein Loch entsteht. */ ok(namen.join(" | ") === erwartet.join(" | "), `und zwar diese: ${namen.join(", ")}`); - /* DIE BREITE KACHEL STEHT VORNE, und das bleibt eine echte Aussage: - Das Raster hat drei Spalten, "Der Treff" ist doppelt breit. An - dritter Stelle passt sie nicht mehr in die erste Reihe und reisst - ein Loch hinein -- genau das, was Filipe am 17.09. auf dem - Bildschirmfoto gesehen hat. */ - ok(namen[0] === "Der Treff", `und „Der Treff“ steht vorne (${namen[0]})`); + /* UEBER DIE EIGENSCHAFT, NICHT UEBER DEN NAMEN (berichtigt + 21.09.2026). Hier stand `namen[0] === "Der Treff"`. Am 21.09. + wurde aus der Kachel die Willkommensseite, und die Zeile wurde + rot, obwohl das Raster stimmte. Gemeint war nie der Name, sondern: + die DOPPELT BREITE Kachel steht vorne. Genau das wird jetzt + gefragt. */ + const breite = bereicheFuer({ rolle: "gast" }).filter((k) => k.gross); + ok(breite.length === 1, `genau eine Kachel ist doppelt breit (${breite.length})`); + ok(namen[0] === breite[0]?.name, + `und sie steht vorne (${namen[0]})`); /* NICHTS VOM TEAM. Der eigentliche Zweck dieses Abschnitts: Eine Kachel des Teams bei der Community waere lautlos falsch -- sie saehe aus wie die anderen, und erst beim Klicken sagte der Server @@ -849,6 +867,97 @@ melde("=== 7. Das Wort für „Gilt für“ kommt vom Server ==="); "und der Sucher findet so ein Wort auch wirklich"); } +/* ===================================================================== + KEIN BRETT DARF ZWISCHEN DIE REGELN FALLEN (21.09.2026) + ===================================================================== + + Dreimal derselbe Fehler, dreimal derselbe Hergang: + + 19.09. Die Kachel "Regeln & Hilfe" bekam ein neues Ziel. + `regeln` fiel aus TREFF_BRETTER -- und war fuer die + Community 404, obwohl treff-regeln.html darauf verweist. + 20.09. Dasselbe noch einmal, bei MODI_BEREICHE_ERLAUBT. + 21.09. Die Kachel "Der Treff" wurde zur Willkommensseite. + `treff` fiel heraus -- und diesmal ging es in die andere + Richtung: Die Schranke laesst eine AGENTURROLLE unbesehen + durch, wenn ein Brett in keiner Liste steht. Eine + Creatorin konnte das Brett des Treffs lesen. + + DER GEMEINSAME KERN: Die Schranke kennt drei Toere -- Treff-Bretter, + Bretter mit eigener Kachel, Bretter hinter einer Seite. Ein Brett, + das durch KEINES davon abgedeckt ist, faellt nicht etwa heraus, + sondern DURCH: Fuer eine Agenturrolle endet der Weg bei + `return next()`. + + Genau das wird hier gezaehlt. Nicht "ist treff drin" -- das waere + die Reparatur von gestern -- sondern: faellt IRGENDEIN Brett durch + alle Regeln? Wer morgen eine Kachel umleitet, bekommt es damit + gesagt, statt es zu verschenken. + ===================================================================== */ +melde("\n--- Kein Brett faellt zwischen die Regeln ---"); +{ + const bModul = await import("./workspace-bereiche.js"); + const alleBretter = Object.keys(bModul.BEREICHE || {}); + ok(alleBretter.length > 10, + `${alleBretter.length} Bretter angesehen`); + + /* Die Tore der Schranke, aus dem Code geholt statt abgeschrieben. */ + const durchTreff = new Set(w.TREFF_BRETTER); + const durchKachel = w.MODI_BEREICHE_ERLAUBT; + + /* DIE EHRLICHE REGEL, GEMESSEN STATT GERATEN. + ---------------------------------------------------------------- + Mein erster Entwurf fragte "haengt dieses Brett an irgendeiner + Regel" -- und meldete sofort `content` und `schutz`. Beides sind + AGENTURBRETTER ("Content-Ideen", "Schutz & Regeln"), die eine + Agenturrolle zu Recht liest. Die Zeile war also zu breit und + haette zwei Fehlalarme dauerhaft festgeschrieben. Eine Pruefung, + die zu viel meldet, wird abgeschaltet -- dann haelt sie gar + nichts mehr. + + Nachgemessen ist die Trennung exakt und in BEIDE Richtungen + deckungsgleich: Die Bretter mit `fuerAlle` sind genau die des + Treffs, plus die Agenturablage. Das ist eine Eigenschaft des + BRETTS und wandert nicht, wenn jemand eine Kachel umleitet -- + genau der Unterschied, an dem es dreimal gescheitert ist. */ + const fuerAlle = Object.entries(bModul.BEREICHE) + .filter(([, v]) => v && v.fuerAlle).map(([k]) => k); + ok(fuerAlle.length >= 7, `${fuerAlle.length} Bretter stehen allen offen`); + + /* Die Agenturablage ist der eine gewollte Fall: Sie steht allen + offen, aber im ANDEREN Haus. Sie namentlich auszunehmen ist etwas + anderes als eine Liste zu pflegen -- es ist eine Ausnahme, die + man sieht. */ + const GEMEINSCHAFT = fuerAlle.filter((b) => b !== "agentur"); + const ungeschuetzt = GEMEINSCHAFT.filter((b) => !durchTreff.has(b)); + ok(ungeschuetzt.length === 0, ungeschuetzt.length === 0 + ? `alle ${GEMEINSCHAFT.length} Bretter des Treffs stehen in TREFF_BRETTER` + : `NICHT GESCHUETZT -- eine Agenturrolle kaeme hinein: ${ungeschuetzt.join(", ")}`); + + /* UND DIE ANDERE RICHTUNG: Nichts darf in TREFF_BRETTER stehen, das + dort nicht hingehoert -- sonst spannte die Liste einen Schutz ueber + ein Brett, das der Agentur gehoert, und sperrte sie aus. */ + const zuviel = [...durchTreff].filter((b) => !GEMEINSCHAFT.includes(b)); + ok(zuviel.length === 0, zuviel.length === 0 + ? "und nichts Fremdes steht darin" + : `ZUVIEL in TREFF_BRETTER: ${zuviel.join(", ")}`); + + /* UND EIN MODI MUSS SIE AUCH OEFFNEN KOENNEN. Dieselbe Falle, zweite + Liste: `MODI_BEREICHE_ERLAUBT` wird ebenfalls aus Kachelzielen + abgeleitet und verliert ein Brett, sobald eine Kachel umzieht. */ + const fuerModiZu = GEMEINSCHAFT.filter((b) => !durchKachel.has(b)); + ok(fuerModiZu.length === 0, fuerModiZu.length === 0 + ? "und ein Modi kommt in jedes davon hinein" + : `fuer einen Modi gesperrt: ${fuerModiZu.join(", ")}`); + + /* GEGENPROBE: Wuerden diese Zeilen ein ungeschuetztes Brett + ueberhaupt sehen? Ein erfundener Name steht in keiner Menge und + muss deshalb auffallen. */ + const erfunden = "brett-das-es-nicht-gibt"; + ok(!durchTreff.has(erfunden) && !durchKachel.has(erfunden), + "und ein Brett ohne Schutz wuerde hier wirklich auffallen"); +} + melde(""); console.log(`${geprueft} Pruefungen, ${fehler} Fehler`); console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); diff --git a/server/pruef-willkommen.mjs b/server/pruef-willkommen.mjs new file mode 100644 index 00000000..51efc910 --- /dev/null +++ b/server/pruef-willkommen.mjs @@ -0,0 +1,424 @@ +/* ===================================================================== + pruef-willkommen.mjs — DIE SEITE, DIE ERKLAERT, WAS ES HIER GIBT + ===================================================================== + + Auftrag vom 21.09.2026, Abschnitte 7 und 8. + + WAS HIER GEMESSEN WIRD — und warum gerade das: + + (1) DASS DIE ERKLAERUNG ZU DEN RECHTEN PASST. Abschnitt 8 verlangt + woertlich, die Erklaerung solle sich "automatisch an die + tatsaechlich vorhandenen Rechte" anpassen. Der Fehler, den man + dabei macht, ist eine gepflegte Liste: Sie stimmt am ersten Tag + und erzaehlt am zweiten von einer Seite, die es fuer diesen + Menschen nie gab. Geprueft wird deshalb nicht "steht da etwas", + sondern: JEDE erklaerte Kachel muss diese Person auch oeffnen + duerfen — und zwar gemessen an derselben Rechtetafel, die der + Server durchsetzt. + + (2) DIE GEGENPROBE DAZU. Eine Pruefung, die nur "alles erlaubt" + bestaetigt, bestaetigt nichts. Also wird ausdruecklich gezaehlt, + dass ein Modi die Seiten, die er NICHT hat (Personen, Hilfe, + Bewerbungen), auch nicht erklaert bekommt — und dass DogFather + mehr erklaert bekommt als er. + + (3) DASS DIE ROLLEN WIRKLICH VERSCHIEDENE TEXTE BEKOMMEN. "Sie muss + vollstaendig rollenbasiert aufgebaut werden" ist nur erfuellt, + wenn sich die Texte auch unterscheiden. Vier gleiche Absaetze + mit vier verschiedenen Namen davor waeren keine Rollenseite. + + (4) DASS DIE AUSGELIEFERTE DATEI KEINE ROLLENNAMEN TRAEGT. Sie geht + an jeden Browser. Steht die Aufteilung dort, ist sie lesbar und + veraltet ausserdem. Dieselbe Regel wie bei pruef-modi-wortleck. + + (5) UND AM ECHTEN BILDSCHIRM, dass ein Mensch es lesen und antippen + kann. Ein richtiger Datensatz, den niemand sieht, ist kein + Erfolg. + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import http from "node:http"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-willk-")); +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 Willkommenspruefung"); + +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(300_000, "pruef-willkommen"); +await new Promise((r) => setTimeout(r, 1200)); + +let fehler = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const HOST = "crew.dogfather-universe.com"; +function rufe(pfad, wie = {}) { + return new Promise((fertig) => { + const daten = wie.body === undefined ? null : JSON.stringify(wie.body); + const a = http.request({ + host: "127.0.0.1", port: PORT, path: pfad, method: wie.method || "GET", + headers: { + Host: HOST, + ...(daten ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(daten) } : {}), + ...(wie.keks ? { Cookie: wie.keks } : {}), + Origin: `https://${HOST}`, Referer: `https://${HOST}/workspace/`, + }, + }, (a2) => { + let t = ""; + a2.on("data", (c) => (t += c)); + a2.on("end", () => fertig({ + code: a2.statusCode, kopf: a2.headers, text: t, + json: (() => { try { return JSON.parse(t); } catch { return null; } })(), + })); + }); + a.on("error", (e) => fertig({ code: 0, text: e.message, json: null })); + if (daten) a.write(daten); + a.end(); + }); +} + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await rufe("/workspace/api/anmelden", { method: "POST", body: { rolle: "admin", code: "x" } }); +for (let i = 0; i < 60; i++) { + try { + const t = new DatabaseSync(process.env.WORKSPACE_DB); + try { t.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { t.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +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,aktiv,erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Funny", "hand", "CODE-HAND-0001"); +anlegen("Lino", "linke", "CODE-LINK-0001"); +anlegen("Kessi", "modi", "CODE-MOD1-0001"); +/* Ein Gast muss sein Alter bestaetigt haben, sonst kommt er nicht durch. */ +const idGast = anlegen("Robin", "gast", "CODE-GAST-0001"); +d.prepare("UPDATE personen SET alter_bestaetigt_am = ? WHERE id = ?").run(jetzt, idGast); +d.close(); + +async function alsWer(rolle, code) { + const a = await rufe("/workspace/api/anmelden", { method: "POST", body: { rolle, code } }); + return (a.kopf?.["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); +} + +/* --------------------------------------------------------------------- + 1. DIE SCHRANKE + --------------------------------------------------------------------- */ +melde("\n=== Die Schranke ==="); +{ + const ohne = await rufe("/workspace/api/willkommen"); + ok(ohne.code === 401, `unangemeldet gibt es nichts (${ohne.code})`); +} + +/* --------------------------------------------------------------------- + 2. JEDE ROLLE BEKOMMT IHRE EIGENE SEITE + --------------------------------------------------------------------- */ +melde("\n=== Jede Rolle bekommt ihre eigene Seite ==="); +const LEUTE = [ + { rolle: "admin", code: "CODE-DOGI-0001", name: "Filipe" }, + { rolle: "hand", code: "CODE-HAND-0001", name: "Funny" }, + { rolle: "linke", code: "CODE-LINK-0001", name: "Lino" }, + { rolle: "modi", code: "CODE-MOD1-0001", name: "Kessi" }, + { rolle: "gast", code: "CODE-GAST-0001", name: "Robin" }, +]; +const gesehen = {}; +for (const p of LEUTE) { + const keks = await alsWer(p.rolle, p.code); + const a = await rufe("/workspace/api/willkommen", { keks }); + gesehen[p.rolle] = { keks, daten: a.json }; + ok(a.code === 200, `${p.rolle}: die Seite antwortet (${a.code})`); + ok(a.json?.gruss === `Willkommen, ${p.name}!`, + ` und begruesst mit Namen ("${a.json?.gruss}")`); + ok(!!a.json?.einleitung?.was && !!a.json?.einleitung?.dazu, + " mit einer Einleitung, die aus zwei Teilen besteht"); +} + +/* DIE EINLEITUNGEN MUESSEN SICH UNTERSCHEIDEN. Sonst ist es keine + rollenbasierte Seite, sondern eine mit Namensfeld. */ +{ + const saetze = LEUTE.map((p) => gesehen[p.rolle]?.daten?.einleitung?.was); + const verschieden = new Set(saetze).size; + ok(verschieden === LEUTE.length, + `${verschieden} von ${LEUTE.length} Einleitungen sind verschieden`); +} + +/* --------------------------------------------------------------------- + 3. DER KERN: ERKLAERT WIRD NUR, WAS MAN AUCH DARF + --------------------------------------------------------------------- + + Gemessen gegen die Rechtetafel selbst, nicht gegen eine Liste in + dieser Datei -- sonst pruefe ich meine Abschrift gegen seine. */ +melde("\n=== Erklaert wird nur, was man auch oeffnen darf ==="); +const { darfSeite } = await import("./rechte.js"); +const { bereicheFuer, zusatzBereicheFuer } = await import("./workspace.js"); +const { istCrewAdresse } = await import("./crew-adresse.js"); + +/* DAS HAUS GEHOERT DAZU, und es wird ABGELEITET wie beim Server. + Gemessen am 21.09.: DogFather hat auf der Crew-Adresse 30 Kacheln + und auf der Agenturadresse 1 (die Tuer hinueber) -- er ist die + einzige Rolle, die in beiden Haeusern lebt. Eine Vergleichsrechnung + ohne Haus misst deshalb ausgerechnet bei ihm das falsche Haus und + meldet einen Fehler, den es nicht gibt. */ +const HAUS = istCrewAdresse(HOST) ? "crew" : "agentur"; +const alsPerson = (rolle) => ({ rolle, haus: HAUS }); + +for (const p of LEUTE) { + const daten = gesehen[p.rolle]?.daten; + const kacheln = (daten?.gruppen || []).flatMap((g) => g.kacheln || []); + ok(kacheln.length > 0, `${p.rolle}: es wird ueberhaupt etwas erklaert (${kacheln.length})`); + + const verboten = kacheln.filter((k) => { + const ziel = String(k.ziel || "").split("?")[0]; + if (!ziel || /^https?:/.test(ziel)) return false; + return !darfSeite(alsPerson(p.rolle), "/workspace/" + ziel); + }); + ok(verboten.length === 0, verboten.length === 0 + ? " keine einzige davon ist fuer ihn gesperrt" + : ` ERKLAERT GESPERRTE SEITEN: ${verboten.map((v) => v.ziel).join(", ")}`); + + /* Und die andere Richtung: die Zahl muss zu seinen echten Kacheln + passen. Zu WENIG waere genauso falsch -- dann verschweigt die + Seite etwas, das er hat. */ + const roh = [ + ...(bereicheFuer(alsPerson(p.rolle)) || []), + ...(zusatzBereicheFuer(alsPerson(p.rolle)) || []), + ]; + const sollte = roh.filter((k) => { + if (k?.aussen) return true; + const ziel = String(k?.ziel || ""); + if (!ziel) return false; + return darfSeite(alsPerson(p.rolle), "/workspace/" + ziel.split("?")[0]); + }).length; + ok(kacheln.length === sollte, + ` und es sind genau seine ${sollte} Kacheln (erklaert: ${kacheln.length})`); +} + +/* GEGENPROBE MIT ZAHLEN: Kann die Pruefung ueberhaupt Unterschiede + sehen? Wenn alle gleich viel bekaemen, waere oben nichts bewiesen. */ +{ + const zahl = (r) => (gesehen[r]?.daten?.gruppen || []).flatMap((g) => g.kacheln || []).length; + const chef = zahl("admin"), modi = zahl("modi"), gast = zahl("gast"); + ok(chef > modi && modi > gast, + `DogFather ${chef} > Modi ${modi} > Community ${gast} -- die Seite unterscheidet wirklich`); + + /* DIE DREI, DIE EIN MODI NICHT HAT. Namentlich, damit ein stiller + Durchrutscher auffaellt. */ + const modiZiele = (gesehen.modi?.daten?.gruppen || []) + .flatMap((g) => g.kacheln || []).map((k) => String(k.ziel).split("?")[0]); + for (const seite of ["personen.html", "bewerbungen.html", "rechte.html"]) { + ok(!modiZiele.includes(seite), `einem Modi wird "${seite}" nicht erklaert`); + } + /* Und der Gegenbeweis, dass diese Namen ueberhaupt vorkommen koennen: */ + const chefZiele = (gesehen.admin?.daten?.gruppen || []) + .flatMap((g) => g.kacheln || []).map((k) => String(k.ziel).split("?")[0]); + ok(chefZiele.includes("personen.html"), + "DogFather dagegen schon (sonst waere die Probe oben wertlos)"); +} + +/* --------------------------------------------------------------------- + 4. DIE LINKE HAND BEKOMMT IHRE GRENZEN GESAGT + --------------------------------------------------------------------- */ +melde("\n=== Die linke Hand ==="); +{ + const h = gesehen.linke?.daten?.hinweise || []; + ok(h.some((x) => /nicht/i.test(x.titel + x.text)), + "ihr wird gesagt, was sie nicht hat -- einmal, klar, ohne Liste"); + const ziele = (gesehen.linke?.daten?.gruppen || []) + .flatMap((g) => g.kacheln || []).map((k) => String(k.ziel).split("?")[0]); + ok(!ziele.includes("personen.html") && !ziele.includes("hilfe.html"), + "und Personen/Meldeweg stehen nicht in ihrer Erklaerung"); + ok(ziele.includes("aufgaben.html"), + "die Aufgaben dagegen schon"); +} + +/* --------------------------------------------------------------------- + 5. DIE AUSGELIEFERTE DATEI TRAEGT KEINE ROLLENAUFTEILUNG + --------------------------------------------------------------------- */ +melde("\n=== Was im Browser landet ==="); +{ + const js = readFileSync(new URL("../workspace/assets/js/willkommen.js", import.meta.url), "utf8"); + const html = readFileSync(new URL("../workspace/willkommen.html", import.meta.url), "utf8"); + const ohneKommentar = js + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); + + for (const wort of ["admin", "linke", "gast"]) { + const treffer = new RegExp("[\"'`]" + wort + "[\"'`]").test(ohneKommentar); + ok(!treffer, `die Datei kennt "${wort}" nicht (sie kommt vom Server)`); + } + ok(!/aufgaben\.html|personen\.html/.test(ohneKommentar), + "und fuehrt auch keine eigene Kachelliste"); + ok(html.includes("willkommen.css"), "die Seite laedt ihre Gestaltung"); + ok(html.includes("meldung.js"), "und den gemeinsamen Meldeweg"); + + /* GEGENPROBE: Findet dieses Muster ueberhaupt etwas? Sonst waere die + Aussage oben nur die Bestaetigung eines kaputten Suchmusters. */ + const probe = 'const x = "admin";'; + ok(new RegExp("[\"'`]admin[\"'`]").test(probe), + "das Suchmuster wuerde einen Rollennamen finden"); +} + +/* --------------------------------------------------------------------- + 6. DIE KACHEL FUEHRT HIN + --------------------------------------------------------------------- */ +melde("\n=== Der Weg zur Seite ==="); +{ + const kacheln = [ + ...(bereicheFuer(alsPerson("gast")) || []), + ...(zusatzBereicheFuer(alsPerson("gast")) || []), + ]; + const willk = kacheln.filter((k) => String(k.ziel || "").startsWith("willkommen.html")); + ok(willk.length === 1, `die Community hat genau eine Kachel dorthin (${willk.length})`); + ok(willk[0]?.name === "Willkommen", `und sie heisst so (${willk[0]?.name})`); + + /* DAS TREFF-BRETT DARF NICHT STILL VERSCHWINDEN. Gemessen am + 21.09.: null Eintraege, 29 Chatnachrichten daneben -- geredet wird + im Chat. Falls das Brett je wieder eine Kachel bekommt, soll diese + Zeile daran erinnern, dass hier einmal eine war. */ + const brett = kacheln.filter((k) => String(k.ziel || "").includes("b=treff")); + ok(brett.length === 0, + `das leere Treff-Brett hat keine eigene Kachel mehr (${brett.length})`); + const chat = kacheln.filter((k) => String(k.ziel || "").includes("chat.html")); + ok(chat.length >= 1, `der Treff-Chat dagegen schon (${chat.length})`); +} + +/* --------------------------------------------------------------------- + 7. AM ECHTEN BILDSCHIRM + --------------------------------------------------------------------- */ +melde("\n=== Am echten Bildschirm ==="); +{ + const { execFileSync } = await import("node:child_process"); + const { createServer: httpsServer } = await import("node:https"); + const { request: httpAnfrage } = await import("node:http"); + + const schluessel = join(ordner, "pruef.key"); + const zertifikat = join(ordner, "pruef.crt"); + execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", schluessel, "-out", zertifikat, "-days", "2", + "-subj", `/CN=${HOST}`, "-addext", `subjectAltName=DNS:${HOST}`], + { stdio: "ignore" }); + + const HTTPS_PORT = PORT + 1; + const vorbau = httpsServer({ + key: readFileSync(schluessel), cert: readFileSync(zertifikat), + }, (von, nach) => { + const weiter = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: von.url, method: von.method, + headers: { ...von.headers, host: `${HOST}:${HTTPS_PORT}` }, + }, (antwort) => { + nach.writeHead(antwort.statusCode || 502, antwort.headers); + antwort.pipe(nach); + }); + von.pipe(weiter); + }); + await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); + const SEITE = `https://${HOST}:${HTTPS_PORT}`; + + const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${HOST} 127.0.0.1`, "--ignore-certificate-errors"], + }); + const seitenFehler = []; + + async function alsSeite(rolle, code, breite = 1280) { + const ctx = await browser.newContext({ viewport: { width: breite, height: 950 } }); + const s = await ctx.newPage(); + s.on("pageerror", (e) => seitenFehler.push(`${rolle}: ${String(e).slice(0, 110)}`)); + await s.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" }); + await s.click(`.rolle[data-rolle="${rolle}"]`); + await s.fill("#code", code); + await s.click("button[type=submit]"); + await s.waitForTimeout(800); + await s.goto(`${SEITE}/workspace/willkommen.html`, { waitUntil: "networkidle" }); + await s.waitForTimeout(900); + return { ctx, seite: s }; + } + + /* ---- Ein Modi, am Handy ---- */ + const pModi = await alsSeite("modi", "CODE-MOD1-0001", 390); + try { + const bild = await pModi.seite.evaluate(() => { + const kacheln = [...document.querySelectorAll(".w-kachel")]; + const breite = document.documentElement.scrollWidth; + return { + gruss: document.getElementById("gruss")?.textContent || "", + rolle: document.getElementById("rollentext")?.textContent || "", + was: document.getElementById("einleitung-was")?.textContent || "", + kacheln: kacheln.length, + mitLang: kacheln.filter((k) => k.querySelector(".w-kachel__lang")).length, + laedtWeg: document.getElementById("laedt")?.hidden === true, + busy: document.getElementById("haupt")?.getAttribute("aria-busy"), + breite, + ueberlauf: kacheln.filter((k) => k.getBoundingClientRect().right > breite + 1).length, + zuKlein: kacheln.filter((k) => k.getBoundingClientRect().height < 44).length, + ersteZiel: kacheln[0]?.getAttribute("href") || "", + }; + }); + ok(bild.gruss.includes("Kessi"), `der Name steht oben ("${bild.gruss}")`); + ok(bild.was.length > 20, "darunter ein Satz, der etwas sagt"); + ok(bild.kacheln > 0, `${bild.kacheln} Kacheln werden erklaert`); + ok(bild.mitLang > 0, `davon ${bild.mitLang} mit ausfuehrlichem Satz`); + ok(bild.laedtWeg, "der Ladehinweis ist weg"); + ok(bild.busy !== "true", "und die Seite meldet sich nicht mehr als ladend"); + ok(bild.breite <= 390, `nichts sprengt das Handy (${bild.breite}px)`); + ok(bild.ueberlauf === 0, `keine Kachel ragt hinaus (${bild.ueberlauf})`); + ok(bild.zuKlein === 0, `jede ist mindestens 44px hoch (${bild.zuKlein} zu klein)`); + + /* SIE MUSS AUCH HINFUEHREN. Eine Erklaerung ohne Weg ist ein + Prospekt. */ + await pModi.seite.click(".w-kachel"); + await pModi.seite.waitForTimeout(700); + const wohin = pModi.seite.url(); + ok(!wohin.includes("willkommen.html"), + `ein Antippen fuehrt wirklich hin (${wohin.split("/").pop()})`); + } finally { await pModi.ctx.close(); } + + /* ---- DogFather, am Rechner ---- */ + const pChef = await alsSeite("admin", "CODE-DOGI-0001"); + try { + const bild = await pChef.seite.evaluate(() => ({ + gruss: document.getElementById("gruss")?.textContent || "", + kacheln: document.querySelectorAll(".w-kachel").length, + gruppen: [...document.querySelectorAll(".w-gruppe__titel")].map((t) => t.textContent), + hinweise: document.querySelectorAll(".w-hinweis").length, + })); + ok(bild.gruss.includes("Filipe"), `sein Name steht oben ("${bild.gruss}")`); + ok(bild.gruppen.length >= 2, + `nach Gruppen geordnet (${bild.gruppen.join(" · ")})`); + ok(bild.hinweise > 0, `mit ${bild.hinweise} Hinweisen zu seiner Rolle`); + ok(bild.kacheln > 10, `und allem, was er hat (${bild.kacheln})`); + } finally { await pChef.ctx.close(); } + + ok(seitenFehler.length === 0, seitenFehler.length === 0 + ? "kein Skript ist unterwegs gestolpert" + : `Skriptfehler: ${seitenFehler.join(" | ")}`); + + await browser.close(); + await new Promise((r) => vorbau.close(r)); +} + +melde(fehler === 0 + ? "\nAlles in Ordnung.\n" + : `\n${fehler} FEHLER.\n`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/rechte.js b/server/rechte.js index 3719826e..71306585 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -309,6 +309,12 @@ export const SEITEN = { AUSDRUECKLICH NICHT fuer Manager, Scouts, Creator und Spicy: Sie haben mit dem Treff nichts zu tun, und die Seite nennt Fristen, Stufen und die Bedingungen des Ausschlusses. */ + /* WILLKOMMEN (21.09.2026). Die Orientierungsseite. Sie erklaert nur, + was die Person ohnehin hat -- deshalb ist sie fuer JEDE gueltige + Rolle offen, genau wie die Startseite. Wer sie sperrt, sperrt die + Erklaerung, nicht den Zugang. */ + "/workspace/willkommen.html": [...ALLE, "gast"], + "/workspace/treff-regeln.html": ["gast", "modi", "hand", "admin"], /* DIE ANRUF-PROBE (19.09.2026). Sie sagt einem Menschen in seinem eigenen Browser, warum es nicht klingelt -- die Frage, die sich diff --git a/server/workspace-willkommen.js b/server/workspace-willkommen.js new file mode 100644 index 00000000..409dc267 --- /dev/null +++ b/server/workspace-willkommen.js @@ -0,0 +1,257 @@ +/* ===================================================================== + WILLKOMMEN — die Seite, die erklärt, was es hier gibt + ===================================================================== + + Auftrag vom 21.09.2026, Abschnitte 7 und 8: + + *"Beim Öffnen soll die Person persönlich begrüßt werden ... + Willkommen, [Name]! – danach folgt eine verständliche Einführung + in die Website ... Die Willkommensseite darf nicht für alle Nutzer + identisch sein. Sie muss vollständig rollenbasiert und individuell + aufgebaut werden ... Die Erklärung soll sich automatisch an die + tatsächlich vorhandenen Rechte des jeweiligen Nutzers anpassen."* + + DAS LETZTE IST DER GANZE BAU. Eine Erklärung, die aufzählt, was es + gibt, ist am Tag danach falsch — jemand baut eine Kachel dazu, und + die Seite verschweigt sie. Deshalb erklärt sie GENAU DIE KACHELN, + die diese Person wirklich hat: dieselbe Liste, aus der die + Startseite gebaut wird (`bereicheFuer`), durch denselben Filter + (`darfSeite`). Wer morgen eine Kachel bekommt, bekommt automatisch + auch ihre Erklärung. + + DREI EBENEN, und jede hat einen Grund: + + Der Untertitel steht schon an der Kachel. Er ist die kürzeste + wahre Auskunft und wird deshalb nicht neu + erfunden. + Der lange Satz nur bei den Seiten, wo eine Zeile zu wenig ist. + NACH ZIEL abgelegt, nicht nach Name: Namen + ändern sich (am 21.09. gleich zweimal), das + Ziel bleibt. + Die Einleitung je Rolle ein eigener Absatz. Das ist die eine + Stelle, an der wirklich nach Rolle unterschieden + wird — und es sind vier Sätze, keine vier + Seitenlisten. + + WAS NICHT ERKLÄRT WIRD, STEHT AUCH NICHT DA. Der Auftrag lässt die + Wahl zwischen "gar nicht erklären" und "deutlich als nicht relevant + behandeln". Gar nicht ist richtig: Eine Liste von Dingen, die man + nicht darf, ist keine Orientierung, sondern eine Kränkung. + ===================================================================== */ + +import express from "express"; +import { + sitzungLesen, bereicheFuer, zusatzBereicheFuer, rollentextFuer, + ROLLEN_NAME, istDogFather, darfAufgabenVerteilen, +} from "./workspace.js"; +import { darfSeite } from "./rechte.js"; + +export const willkommenRouter = express.Router(); + +/* --------------------------------------------------------------------- + DIE EINLEITUNG JE ROLLE + --------------------------------------------------------------------- + + Sie sagt in zwei, drei Sätzen, WOZU jemand hier ist. Die Kacheln + darunter sagen, WOMIT. Beides zu mischen ergäbe einen Text, den + niemand liest. + + Der Ton ist bewusst derselbe wie im ganzen Haus: auf Augenhöhe, + ohne jemanden über jemanden zu stellen. */ +const EINLEITUNG = { + admin: { + was: "Das hier ist deine Seite — von hier aus läuft alles.", + dazu: "Du siehst jede Kachel, die es gibt, und entscheidest am Ende, " + + "was passiert. Wenn dir etwas Vorgeschlagenes nicht passt, lehnst " + + "du es ab; nichts geschieht ohne dich.", + }, + hand: { + was: "Du vertrittst DogFather im Alltag und hältst das Team zusammen.", + dazu: "Du siehst dieselben Aufgaben, Ideen und Auswertungen wie er und " + + "kannst Aufgaben an die Modis verteilen. Private Bereiche von " + + "DogFather bleiben seine.", + }, + linke: { + was: "Du führst das Team durch den Tag — mit denselben Werkzeugen wie " + + "die rechte Hand.", + dazu: "Aufgaben verteilen, Stände verfolgen, Rückmeldung geben: alles " + + "wie sie. Was du NICHT hast: Personen anlegen, Rollen ändern und " + + "den vertraulichen Meldeweg. Das ist kein Versehen, sondern so " + + "eingerichtet.", + }, + modi: { + was: "Schön, dass du da bist. Hier findest du alles für deinen Tag.", + dazu: "Unter „Aufgaben\" steht, was gerade für dich dran ist — du " + + "nimmst an, was du schaffst, und sagst ab, was nicht geht. Eine " + + "Absage ist völlig in Ordnung, schreib einfach kurz warum.", + }, + gast: { + was: "Schön, dass du da bist.", + dazu: "Hier triffst du die anderen aus der Community. Schau dich um, " + + "schreib etwas, oder lies erst mal mit — beides ist richtig.", + }, +}; + +/* --------------------------------------------------------------------- + DIE LANGEN SÄTZE — nach ZIEL, nicht nach Name + --------------------------------------------------------------------- + + Nur dort, wo eine Zeile zu wenig ist. Wer zu jeder Kachel einen + Absatz schreibt, schreibt eine Seite, die niemand zu Ende liest. + + Der Schlüssel ist der Dateiname ohne Parameter. Am 21.09. sind in + diesem Haus zwei Kachelnamen gewandert; ein Text, der am Namen + hängt, wäre dabei stumm geworden, ohne dass es jemand merkt. */ +const LANG = { + "aufgaben.html": + "Oben steht, wie es bei dir gerade aussieht — was offen ist, was läuft, " + + "was erledigt ist. Darunter die Karten. Bekommst du eine Aufgabe, " + + "kannst du sie annehmen oder ablehnen; wenn du ablehnst, schreib " + + "kurz warum, dann muss niemand nachfragen.", + "chat.html": + "Einzelgespräche und Gruppen. Mit @ und dem Namen sprichst du jemanden " + + "direkt an — er bekommt dann eine eigene Meldung, auch wenn er den " + + "Chat gerade nicht offen hat.", + "kalender.html": + "Alles mit Datum an einer Stelle. Termine über mehrere Tage sind " + + "durchgehend markiert, am Handy tippst du einen Tag an und siehst, " + + "was dort ansteht.", + "bereich.html": + "Ein Brett zum Sammeln. Schreib hinein, was dir einfällt — es geht " + + "nichts verloren, und niemand muss es sofort beantworten.", + "befinden.html": + "Ein paar Fragen über dich, nur für dich. Niemand aus dem Team sieht " + + "deine Antworten — am Ende bekommst du eine kurze Zusammenfassung.", + "steckbrief.html": + "Deine Seite: Foto, ein paar Worte über dich. Du entscheidest, was " + + "darauf steht.", + "hilfe.html": + "Wenn etwas vorgefallen ist, das niemand mitlesen soll. Nur DogFather " + + "und die rechte Hand sehen diese Meldungen.", + "treff-regeln.html": + "Wie wir hier miteinander umgehen. Kurz, und es lohnt sich einmal.", + "unsere-seiten.html": + "Ob gerade gestreamt wird, wann es losgeht, und wo du die Kanäle " + + "findest.", + "personen.html": + "Zugänge anlegen, sperren und Rollen ändern. Der Code wird genau " + + "einmal angezeigt — danach nie wieder.", + "teamlage.html": + "Wer gerade wie unterwegs ist. Gedacht zum Hinsehen, nicht zum " + + "Vergleichen.", + "werdegang.html": + "Wie sich jemand über die Zeit entwickelt hat. Keine Note, kein " + + "Ranking — nur die Bewegung.", + "willkommen.html": null, /* sich selbst erklärt sie nicht */ +}; + +/* --------------------------------------------------------------------- + DIE ROUTE + --------------------------------------------------------------------- */ +willkommenRouter.get("/workspace/api/willkommen", (req, res) => { + try { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + + /* DIESELBE LISTE WIE DIE STARTSEITE, durch denselben Filter. Eine + eigene Aufzählung wäre die, die beim nächsten Umbau veraltet. */ + const roh = [ + ...(bereicheFuer(person) || []), + ...(zusatzBereicheFuer(person) || []), + ]; + const offen = roh.filter((k) => { + if (k?.aussen) return true; + const ziel = String(k?.ziel || ""); + if (!ziel) return false; + return darfSeite(person, "/workspace/" + ziel.split("?")[0]); + }); + + /* NACH GRUPPEN, in der Reihenfolge, in der sie auf der Startseite + stehen -- wer dort von oben liest, findet hier dasselbe wieder. */ + const gruppen = []; + for (const k of offen) { + const name = k.gruppe || "Sonst"; + let g = gruppen.find((x) => x.name === name); + if (!g) { g = { name, kacheln: [] }; gruppen.push(g); } + const datei = String(k.ziel || "").split("?")[0]; + g.kacheln.push({ + name: k.name, + unter: k.unter || "", + ziel: k.ziel, + zeichen: k.zeichen || null, + /* Der lange Satz nur, wenn es einen gibt -- `null` heißt + ausdrücklich "hier reicht die Zeile". */ + lang: Object.prototype.hasOwnProperty.call(LANG, datei) ? LANG[datei] : null, + }); + } + + const ein = EINLEITUNG[person.rolle] || { + was: "Schön, dass du da bist.", + dazu: "Hier findest du alles, was für dich freigeschaltet ist.", + }; + + res.json({ + name: person.name, + rolle: person.rolle, + rollenname: ROLLEN_NAME[person.rolle] || null, + rollentext: rollentextFuer(person), + gruss: `Willkommen, ${person.name}!`, + einleitung: ein, + gruppen, + /* Ein paar Sätze, die nur für bestimmte Rollen gelten und zu + keiner einzelnen Kachel gehören. */ + hinweise: hinweiseFuer(person), + /* Damit die Seite die Zahl nennen kann, ohne sie zu zählen. */ + kachelzahl: offen.length, + }); + } catch (f) { + console.error("[willkommen]:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Sätze, die zu keiner Kachel gehören, aber zur Rolle. */ +function hinweiseFuer(person) { + const aus = []; + if (darfAufgabenVerteilen(person)) { + aus.push({ + titel: "Aufgaben verteilen", + text: "Beim Anlegen einer Aufgabe kannst du eine Person aussuchen — " + + "oder mehrere. Bei mehreren fragst du noch, ob jede ihren Teil " + + "macht oder ob die Erste, die Zeit hat, übernimmt. Ist jemand " + + "fertig, kannst du kurz Rückmeldung geben.", + }); + } + if (person.rolle === "modi") { + aus.push({ + titel: "Du musst nicht alles annehmen", + text: "Wenn eine Aufgabe gerade nicht geht, lehn sie ab und schreib " + + "einen Satz dazu. Das ist ausdrücklich vorgesehen und niemandem " + + "unangenehm.", + }); + } + if (person.rolle === "linke") { + aus.push({ + titel: "Was du nicht siehst", + text: "Personen anlegen, Rollen ändern, Bewerbungen und der " + + "vertrauliche Meldeweg sind nicht für dich freigeschaltet. " + + "Alles, was mit Aufgaben und dem Team zu tun hat, schon.", + }); + } + if (istDogFather(person)) { + aus.push({ + titel: "Du entscheidest am Ende", + text: "Überall, wo dir etwas vorgeschlagen wird, kannst du annehmen " + + "oder ablehnen. Ein Vorschlag ist ein Vorschlag.", + }); + } + if (person.rolle === "gast") { + aus.push({ + titel: "Nachts ist Ruhe", + text: "Zwischen 0 und 6 Uhr schläft der Chat — gelesen werden kann " + + "weiter, geschrieben ab 6 Uhr wieder. Das gilt für alle, auch " + + "für DogFather.", + }); + } + return aus; +} diff --git a/server/workspace.js b/server/workspace.js index a62ab663..c9aec03b 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1183,8 +1183,13 @@ const TREFF_GRUPPE = { Bereichs -- dass er vorne steht, ist keine Notloesung fuer das Raster, sondern die Reihenfolge, die ohnehin stimmt. */ const TREFF_BEREICHE = [ - { ...TREFF_GRUPPE, name: "Der Treff", unter: "Hallo sagen, fragen, loben", - zeichen: "community", ton: 34, gross: true, ziel: "bereich.html?b=treff", szene: "lounge" }, + /* AUS DEM TREFF-BRETT WIRD DIE WILLKOMMENSSEITE (21.09.2026, Auftrag + Abschnitt 7). Gemessen davor: Das Brett "treff" hatte NULL Eintraege, + der Treff-Chat daneben 29 Nachrichten -- geredet wird im Chat, das + Brett war eine Kachel ohne Inhalt. Sie zeigt jetzt auf die Seite, + die erklaert, was es hier ueberhaupt gibt. */ + { ...TREFF_GRUPPE, name: "Willkommen", unter: "Was es hier gibt, kurz erklaert", + zeichen: "community", ton: 34, gross: true, ziel: "willkommen.html", szene: "lounge" }, /* DER CHAT (19.09.2026). Filipe: "eine geile mischung von punkt 2 und drei ... nach mitternacht bis morgens 6 uhr keiner schreiben kann damit auch die privatsphaere beruecksichtig wird ueber nacht @@ -1367,6 +1372,25 @@ export const TREFF_BRETTER = [...new Set([ zu haben. Wer die Ableitung liest und diese Zeile nicht sieht, haelt die Liste fuer vollstaendig. */ "regeln", + + /* UND DASSELBE EIN DRITTES MAL (21.09.2026) -- deshalb steht unten + jetzt eine Pruefung, die es laut macht. + ---------------------------------------------------------------- + Am 21.09. wurde die Kachel "Der Treff" zur Willkommensseite + (Auftrag Abschnitt 7). Damit fiel `treff` aus dieser Ableitung. + + WAS DAS ANRICHTETE, ist schlimmer als beim letzten Mal: Die + Schranke in workspace-bereiche.js faengt Aussenrollen und + Team-Dogi-Rollen ab, laesst eine AGENTURROLLE aber unbesehen + durch ("if (!TEAM_DOGI_ROLLEN.has(rolle)) return next()"). Solange + `treff` hier stand, fing der Zweig darueber sie ab -- danach nicht + mehr. Eine Creatorin konnte das Brett des Treffs lesen. + + Gefunden hat es wieder pruef-treff, nicht das Nachdenken. Und + wieder war es kein neuer Fehler, sondern derselbe: Ein Brett + gehoert zum Treff, WEIL es dazugehoert -- nicht, weil gerade + jemand eine Kachel darauf zeigen laesst. */ + "treff", ])]; /* Wer den Treff ueberhaupt sehen darf: die Community selbst und das @@ -1652,6 +1676,11 @@ export const MODI_BEREICHE_ERLAUBT = new Set([ Wer eine Kachel umleitet, nimmt einem Brett damit stillschweigend die Erreichbarkeit. Das muss sichtbar sein, nicht gerechnet. */ "regeln", + /* Und `treff` aus demselben Grund (21.09.2026): Seine Kachel zeigt + seit heute auf die Willkommensseite. Ohne diese Zeile sieht ein + Modi sechs von sieben Treff-Brettern -- und das siebte ist + ausgerechnet das, das dem Bereich den Namen gibt. */ + "treff", ]); /** Und die Bretter hinter einer Seite -- nur fuer den, der die Seite darf. diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 8a833e76..836e9492 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +