diff --git a/server/pruef-hand-personen.mjs b/server/pruef-hand-personen.mjs
new file mode 100644
index 00000000..2801ae39
--- /dev/null
+++ b/server/pruef-hand-personen.mjs
@@ -0,0 +1,333 @@
+/* =====================================================================
+ DIE RECHTE HAND AUF DER PERSONENSEITE (24.09.2026)
+
+ Filipe: „die rechte hand soll das auch sehen. und die selben rechte
+ da haben wie dogfather. das einzige was sie nicht kann ist die
+ dogfather rolle oder leute anfassen. also da kann sie nichts
+ verändern."
+
+ WAS HIER GEPRUEFT WIRD, und warum jede Zeile:
+
+ * SIE DARF, was DogFather darf -- anlegen, Code, Rolle, sperren,
+ loeschen. Bis heute war die Oberflaeche strenger als der Server:
+ Sie hatte drei dieser Rechte und sah keinen einzigen Knopf.
+ * SIE DARF NICHT an DogFather und an die Manager. Das ist die
+ Grenze aus seinem Satz, und sie wird EINZELN geprueft -- fuer
+ jeden der fuenf Wege.
+ * DER LETZTE DOGFATHER-ZUGANG bleibt. Diese Sicherung stand
+ vorher hinter „nur DogFather darf loeschen"; seit die rechte
+ Hand loescht, muss sie allein tragen.
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-hp-"));
+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 Hand-Personen-Pruefung");
+
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+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(240_000, "pruef-hand-personen");
+await new Promise((r) => setTimeout(r, 900));
+
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+const melde = (t) => console.log(t);
+
+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 ks = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", ks, 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", ks).update(code).digest("hex"), jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idHand = anlegen("VanVan", "hand", "CODE-HAND-0001");
+const idLinke = anlegen("Funny", "linke", "CODE-LINK-0001");
+const idModi = anlegen("Miss", "modi", "CODE-MODI-0001");
+const idModi2 = anlegen("Ghost", "modi", "CODE-MODI-0002");
+const idMana = anlegen("Patrick", "manager", "CODE-MANA-0001");
+const idGast = anlegen("Kessi", "gast", "CODE-GAST-0001");
+d.close();
+
+const CREW = "crew.dogfather-universe.com";
+const { request } = await import("node:http");
+
+/** Ein Aufruf mit Hausadresse -- `fetch` verwirft den Host-Kopf. */
+function roh(pfad, keks, koerper, methode) {
+ return new Promise((fertig, schief) => {
+ const daten = koerper ? JSON.stringify(koerper) : null;
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: methode || (daten ? "POST" : "GET"),
+ headers: {
+ Host: CREW, Origin: `https://${CREW}`,
+ ...(keks ? { Cookie: keks } : {}),
+ ...(daten ? { "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(daten) } : {}),
+ },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (t) => { text += t; });
+ antwort.on("end", () => fertig({ code: antwort.statusCode, text }));
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+const json = (r) => { try { return JSON.parse(r.text); } catch { return {}; } };
+
+async function anmelden(rolle, code) {
+ const r = await new Promise((fertig, schief) => {
+ const daten = JSON.stringify({ rolle, code, alter_ok: true });
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden",
+ method: "POST",
+ headers: { Host: CREW, Origin: `https://${CREW}`,
+ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(daten) },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (t) => { text += t; });
+ antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers }));
+ });
+ a.on("error", schief);
+ a.write(daten); a.end();
+ });
+ if (r.code !== 200) return null;
+ return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+
+const kDogi = await anmelden("admin", "CODE-DOGI-0001");
+const kHand = await anmelden("hand", "CODE-HAND-0001");
+const kLinke = await anmelden("linke", "CODE-LINK-0001");
+const kModi = await anmelden("modi", "CODE-MODI-0001");
+
+melde("\n=== 0. Alle vier kommen herein ===");
+ok(!!kDogi && !!kHand && !!kLinke && !!kModi, "DogFather, rechte Hand, linke Hand, Modi");
+
+/* =====================================================================
+ 1. WAS DIE OBERFLAECHE ERFAEHRT
+ ===================================================================== */
+melde("\n=== 1. Die Auskunft an die Oberflaeche ===");
+{
+ const ichDogi = json(await roh("/workspace/api/ich", kDogi));
+ const ichHand = json(await roh("/workspace/api/ich", kHand));
+ const ichLinke = json(await roh("/workspace/api/ich", kLinke));
+ const ichModi = json(await roh("/workspace/api/ich", kModi));
+
+ ok(ichDogi.darf_zugaenge_verwalten === true, "DogFather verwaltet Zugaenge");
+ ok(ichHand.darf_zugaenge_verwalten === true, "die rechte Hand auch");
+ ok(ichLinke.darf_zugaenge_verwalten === false,
+ "die LINKE Hand nicht -- das war eine eigene Entscheidung vom 21.09.");
+ ok(ichModi.darf_zugaenge_verwalten !== true, "ein Modi erst recht nicht");
+
+ ok(ichHand.darf_personen_loeschen === true, "sie darf loeschen");
+ ok(ichLinke.darf_personen_loeschen !== true, "die linke Hand nicht");
+
+ /* WEN sie anfassen darf -- die Grenze aus Filipes Satz. */
+ const anf = ichHand.rollen_anfassbar || [];
+ ok(!anf.includes("admin"), `DogFather steht NICHT darin (${anf.join(", ")})`);
+ /* DIE MANAGER STEHEN IN `rollen_anfassbar` -- und das ist KEIN
+ Mangel, sondern die falsche Frage. `rollen_anfassbar` sagt, welche
+ Rollen sie grundsaetzlich aendern duerfte; ob ein bestimmter
+ Mensch fuer sie existiert, entscheidet die Haustrennung: Auf
+ crew. liefert `hausBedingung` nur Team-Dogi-Rollen, ein Manager
+ ist dort fuer NIEMANDEN sichtbar, auch nicht fuer DogFather.
+ Gemessen wird das in Abschnitt 3 an den echten Wegen. */
+ ok(Array.isArray(anf), `rollen_anfassbar ist eine Liste (${anf.join(", ")})`);
+ ok(anf.includes("modi") || anf.includes("gast"),
+ "Modis und Community schon -- sonst waere das Recht leer");
+}
+
+/* =====================================================================
+ 2. WAS SIE DARF
+ ===================================================================== */
+melde("\n=== 2. Was sie darf ===");
+let neuerModi = 0;
+{
+ const anlegen2 = await roh("/workspace/api/verwaltung/personen", kHand,
+ { name: "Neue Modi", rolle: "modi" });
+ ok(anlegen2.code === 200 || anlegen2.code === 201,
+ `sie legt einen Modi an (${anlegen2.code})`);
+ neuerModi = json(anlegen2).id || 0;
+ ok(!!json(anlegen2).code, "und sieht den Code genau einmal");
+
+ const code = await roh(`/workspace/api/verwaltung/personen/${idModi}/code`, kHand, {}, "POST");
+ ok(code.code === 200, `sie erzeugt einen Code neu (${code.code})`);
+
+ const rolle = await roh(`/workspace/api/verwaltung/personen/${idModi2}/rolle`, kHand,
+ { rolle: "gast" }, "PUT");
+ ok(rolle.code === 200, `sie aendert eine Rolle (${rolle.code})`);
+
+ const sperren = await roh(`/workspace/api/verwaltung/personen/${idModi}`, kHand,
+ { aktiv: false }, "PATCH");
+ ok(sperren.code === 200, `sie sperrt (${sperren.code}) -- das ist neu seit heute`);
+
+ const entsperren = await roh(`/workspace/api/verwaltung/personen/${idModi}`, kHand,
+ { aktiv: true }, "PATCH");
+ ok(entsperren.code === 200, `und entsperrt wieder (${entsperren.code})`);
+
+ if (neuerModi) {
+ const vorschau = await roh(`/workspace/api/verwaltung/personen/${neuerModi}/loeschbar`, kHand);
+ ok(vorschau.code === 200, `sie sieht die Loesch-Vorschau (${vorschau.code})`);
+ const weg = await roh(`/workspace/api/verwaltung/personen/${neuerModi}`, kHand,
+ { name: "Neue Modi" }, "DELETE");
+ ok(weg.code === 200, `sie loescht (${weg.code}) -- das ist neu seit heute`);
+ } else {
+ ok(false, "kein angelegter Modi zum Loeschen");
+ }
+}
+
+/* =====================================================================
+ 3. WAS SIE NICHT DARF -- jeden Weg einzeln
+ ===================================================================== */
+melde("\n=== 3. An DogFather und den Managern nicht ===");
+{
+ /* GEPRUEFT WIRD MIT TEAM-DOGI-ROLLEN. Ein Manager gehoert zur
+ Agentur und ist auf crew. fuer niemanden sichtbar -- ihn hier zu
+ nehmen haette 404 gemessen und „geschuetzt" genannt, obwohl es
+ nur die Haustrennung war. Die linke Hand ist der richtige Fall:
+ Sie IST auf dieser Adresse, und die rechte Hand darf sie
+ trotzdem nicht anfassen. */
+ for (const [wen, id, name] of [
+ ["DogFather", idDogi, "Filipe"], ["die linke Hand", idLinke, "Funny"]]) {
+ const code = await roh(`/workspace/api/verwaltung/personen/${id}/code`, kHand, {}, "POST");
+ ok(code.code === 403 || code.code === 404,
+ `kein neuer Code fuer ${wen} (${code.code})`);
+
+ const rolle = await roh(`/workspace/api/verwaltung/personen/${id}/rolle`, kHand,
+ { rolle: "modi" }, "PUT");
+ ok(rolle.code === 403 || rolle.code === 404,
+ `keine Rollenaenderung bei ${wen} (${rolle.code})`);
+
+ const sperren = await roh(`/workspace/api/verwaltung/personen/${id}`, kHand,
+ { aktiv: false }, "PATCH");
+ ok(sperren.code === 403 || sperren.code === 404,
+ `kein Sperren von ${wen} (${sperren.code})`);
+
+ const weg = await roh(`/workspace/api/verwaltung/personen/${id}`, kHand,
+ { name }, "DELETE");
+ ok(weg.code === 403 || weg.code === 404,
+ `kein Loeschen von ${wen} (${weg.code})`);
+
+ const vorschau = await roh(`/workspace/api/verwaltung/personen/${id}/loeschbar`, kHand);
+ ok(vorschau.code === 403 || vorschau.code === 404,
+ `nicht einmal die Loesch-Vorschau von ${wen} (${vorschau.code})`);
+ }
+
+ /* UND DIE MANAGER -- hier zeigt sich der Unterschied am deutlichsten.
+
+ Mein erster Anlauf erwartete, dass BEIDE abgewiesen werden ("auf
+ crew. gibt es keine Manager"). Gemessen: Hand 403, DogFather 200.
+ Das war kein Loch, sondern genau die bestellte Ordnung -- die
+ Haustrennung gilt fuer die LISTE, nicht fuer den direkten Zugriff
+ von DogFather. Ich hatte das eine fuer das andere gehalten.
+
+ Beide Zeilen stehen jetzt getrennt da, und gerade ihr Unterschied
+ ist die Aussage: Derselbe Weg, zwei Rollen, zwei Antworten. */
+ const handMana = await roh(`/workspace/api/verwaltung/personen/${idMana}`, kHand,
+ { aktiv: false }, "PATCH");
+ ok(handMana.code === 403 || handMana.code === 404,
+ `die rechte Hand kommt an einen Manager nicht heran (${handMana.code})`);
+ const dogiMana = await roh(`/workspace/api/verwaltung/personen/${idMana}`, kDogi,
+ { aktiv: false }, "PATCH");
+ ok(dogiMana.code === 200,
+ `DogFather schon (${dogiMana.code}) -- sonst hiesse "sie darf es nicht" nur, `
+ + `dass es niemand darf`);
+ /* Wieder entsperren, damit die Schlussprobe den Grundstand sieht. */
+ await roh(`/workspace/api/verwaltung/personen/${idMana}`, kDogi, { aktiv: true }, "PATCH");
+
+ /* SIE KANN KEINEN DOGFATHER ERSCHAFFEN. Die Rolle ist fuer niemanden
+ vergebbar, auch nicht fuer DogFather selbst -- hier zaehlt, dass
+ der Weg ueber sie nicht offensteht. */
+ const machDogi = await roh(`/workspace/api/verwaltung/personen/${idModi}/rolle`, kHand,
+ { rolle: "admin" }, "PUT");
+ ok(machDogi.code >= 400, `sie macht niemanden zum DogFather (${machDogi.code})`);
+
+ const anlegeDogi = await roh("/workspace/api/verwaltung/personen", kHand,
+ { name: "Zweiter Chef", rolle: "admin" });
+ ok(anlegeDogi.code >= 400, `und legt keinen an (${anlegeDogi.code})`);
+}
+
+/* =====================================================================
+ 4. DIE ANDEREN ROLLEN KOMMEN GAR NICHT HERAN
+ ===================================================================== */
+melde("\n=== 4. Alle anderen ===");
+{
+ for (const [wer, keks] of [["die linke Hand", kLinke], ["ein Modi", kModi]]) {
+ const anl = await roh("/workspace/api/verwaltung/personen", keks,
+ { name: "Versuch", rolle: "modi" });
+ ok(anl.code >= 400, `${wer} legt niemanden an (${anl.code})`);
+ const weg = await roh(`/workspace/api/verwaltung/personen/${idGast}`, keks,
+ { name: "Kessi" }, "DELETE");
+ ok(weg.code >= 400, `${wer} loescht niemanden (${weg.code})`);
+ }
+}
+
+/* =====================================================================
+ 5. GEGENPROBEN -- kann das hier ueberhaupt rot werden?
+ ===================================================================== */
+melde("\n=== 5. Gegenproben (jede MUSS anschlagen) ===");
+{
+ /* DogFather selbst kann alles -- sonst hiesse „sie darf dasselbe"
+ nur, dass beide nichts duerfen. */
+ const dogiCode = await roh(`/workspace/api/verwaltung/personen/${idLinke}/code`, kDogi, {}, "POST");
+ ok(dogiCode.code === 200,
+ `DogFather erzeugt einen Code fuer die linke Hand (${dogiCode.code}) -- `
+ + `sie kann es nicht, er schon`);
+
+ /* DER LETZTE DOGFATHER-ZUGANG BLEIBT -- auch fuer ihn selbst.
+ Diese Sicherung stand vorher hinter „nur DogFather darf
+ loeschen"; jetzt traegt sie allein. */
+ const letzter = await roh(`/workspace/api/verwaltung/personen/${idDogi}`, kDogi,
+ { name: "Filipe" }, "DELETE");
+ ok(letzter.code >= 400,
+ `der letzte DogFather-Zugang laesst sich nicht loeschen (${letzter.code})`);
+
+ /* Ohne Anmeldung gar nichts. */
+ const ohne = await roh("/workspace/api/verwaltung/personen", null,
+ { name: "Fremd", rolle: "modi" });
+ ok(ohne.code >= 400, `ohne Anmeldung nichts (${ohne.code})`);
+
+ /* UND DIE TAFEL IST DANACH UNVERSEHRT: DogFather, Manager und die
+ linke Hand stehen noch da. Ein abgewiesener Versuch, der trotzdem
+ etwas aendert, waere der schlimmste Ausgang. */
+ const liste = json(await roh("/workspace/api/verwaltung/personen", kDogi));
+ const namen = (liste.personen || []).map((x) => x.rolle);
+ /* Ohne "manager" -- der ist auf crew. gar nicht in der Liste, siehe
+ oben. Ihn hier zu erwarten hiesse, die Haustrennung fuer einen
+ Fehler zu halten. */
+ ok(namen.includes("admin") && namen.includes("linke") && namen.includes("hand"),
+ `nach allen Versuchen stehen sie noch da (${namen.length} Personen: `
+ + `${[...new Set(namen)].join(", ")})`);
+}
+
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+
+console.log("");
+console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
+console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-rechte-umstellen.mjs b/server/pruef-rechte-umstellen.mjs
index ccf66421..66e9cf39 100644
--- a/server/pruef-rechte-umstellen.mjs
+++ b/server/pruef-rechte-umstellen.mjs
@@ -127,7 +127,13 @@ melde("=== 1. Wer die Tafel sieht ===");
const alsDogi = json(await roh("/workspace/api/rechte/tafel", HAUS, { cookie: kDogi }));
const alsHand = json(await roh("/workspace/api/rechte/tafel", CREW, { cookie: kHand }));
ok(alsDogi.darf_aendern === true, "DogFather darf umstellen");
- ok(alsHand.darf_aendern === false, "die rechte Hand sieht dieselbe Tafel, darf aber nicht");
+ /* SEIT DEM 24.09.2026 DARF SIE AUCH UMSTELLEN.
+ Filipe: „die selben rechte da haben wie dogfather. das einzige
+ was sie nicht kann ist die dogfather rolle oder leute anfassen."
+ Was sie NICHT darf, wird in Abschnitt 5 einzeln geprueft. */
+ ok(alsHand.darf_aendern === true, "die rechte Hand darf jetzt auch umstellen");
+ ok(alsDogi.darf_zuruecksetzen === true && alsHand.darf_zuruecksetzen === false,
+ "zuruecksetzen bleibt bei DogFather -- es naehme auch ihre Sperren mit");
ok(alsHand.seiten?.length === alsDogi.seiten?.length,
`und sie bekommt wirklich dieselbe (${alsHand.seiten?.length} Seiten)`);
@@ -257,20 +263,76 @@ melde("=== 4. Aussperren geht nicht ===");
5. WER NICHT DARF, DARF AUCH ÜBER DIE SCHNITTSTELLE NICHT
======================================================================= */
melde("");
-melde("=== 5. Nur DogFather stellt um ===");
+melde("=== 5. Die rechte Hand stellt um -- aber nicht alles ===");
{
- const hand = await roh("/workspace/api/rechte/feld", CREW, { cookie: kHand },
+ /* WAS SIE DARF (seit 24.09.2026). */
+ const darf = await roh("/workspace/api/rechte/feld", CREW, { cookie: kHand },
{ pfad: PROBE, rolle: "modi", erlaubt: false }, "PUT");
- ok(hand.code === 403, `die rechte Hand bekommt 403 (${hand.code})`);
- ok(/DogFather/.test(json(hand).fehler || ""), "und erfährt, wer es darf");
+ ok(darf.code === 200, `sie stellt ein gewoehnliches Feld um (${darf.code})`);
+ /* UND ES WIRKT WIRKLICH -- nicht nur die Anzeige. */
+ const zu = await roh(PROBE, CREW, { cookie: kModi });
+ ok(zu.code !== 200, `danach kommt der Modi nicht mehr auf die Seite (${zu.code})`);
+ /* Gleich wieder zurueck, damit die folgenden Abschnitte den
+ Grundstand vorfinden. */
+ await roh("/workspace/api/rechte/feld", CREW, { cookie: kHand },
+ { pfad: PROBE, rolle: "modi", erlaubt: true }, "PUT");
+ /* WAS SIE NICHT DARF -- die drei Sperren, einzeln. */
+ const dogiSpalte = await roh("/workspace/api/rechte/feld", CREW, { cookie: kHand },
+ { pfad: PROBE, rolle: "admin", erlaubt: false }, "PUT");
+ ok(dogiSpalte.code === 400,
+ `die Spalte DogFather nicht (${dogiSpalte.code})`);
+ ok(/DogFather/.test(json(dogiSpalte).fehler || ""),
+ "und sie erfaehrt, warum");
+
+ /* AUCH NICHT ZUSCHALTEN. Nur das Wegnehmen zu sperren waere
+ dieselbe Tuer, nur andersherum -- sie koennte ihm jede Seite
+ AUFdruecken. */
+ const dogiZu = await roh("/workspace/api/rechte/feld", CREW, { cookie: kHand },
+ { pfad: "/workspace/befinden.html", rolle: "admin", erlaubt: true }, "PUT");
+ ok(dogiZu.code === 400,
+ `auch zuschalten kann sie ihm nichts (${dogiZu.code})`);
+
+ const leute = await roh("/workspace/api/rechte/feld", CREW, { cookie: kHand },
+ { pfad: "/workspace/personen.html", rolle: "modi", erlaubt: true }, "PUT");
+ ok(leute.code === 400,
+ `und "Personen & Zugaenge" fuer niemanden (${leute.code})`);
+
+ /* UND SIE KANN SICH NICHT SELBST AUSSPERREN. Das hat Filipe nicht
+ gesagt; eine Sperre, aus der man sich aussperren kann, ist aber
+ keine Einstellung, sondern eine Falle. */
+ const selbst = await roh("/workspace/api/rechte/feld", CREW, { cookie: kHand },
+ { pfad: "/workspace/rechte.html", rolle: "hand", erlaubt: false }, "PUT");
+ ok(selbst.code === 400,
+ `sie kann sich die Rechteseite nicht selbst wegnehmen (${selbst.code})`);
+
+ /* DIE OBERFLAECHE WEISS DASSELBE -- sonst zeigt sie einen Knopf,
+ den der Server danach ablehnt. */
+ const tafelHand = json(await roh("/workspace/api/rechte/tafel", CREW, { cookie: kHand }));
+ const zeileProbe = tafelHand.seiten.find((x) => x.pfad === PROBE);
+ const zeilePers = tafelHand.seiten.find((x) => x.pfad === "/workspace/personen.html");
+ ok(zeileProbe?.fest?.includes("admin"),
+ "die Tafel zeichnet ihr die Spalte DogFather als fest");
+ ok(zeilePers?.fest?.length >= 5,
+ `und die ganze Zeile "Personen & Zugaenge" (${zeilePers?.fest?.length} Felder)`);
+ ok(!!zeileProbe?.fest_grund?.admin,
+ "mit einem Grund, der danebensteht");
+
+ /* GEGENPROBE: Bei DogFather ist genau das NICHT fest -- sonst
+ hiesse "fest" nur, dass die Tafel immer dasselbe sagt. */
+ const tafelDogi2 = json(await roh("/workspace/api/rechte/tafel", HAUS, { cookie: kDogi }));
+ const dogiProbe = tafelDogi2.seiten.find((x) => x.pfad === PROBE);
+ ok(!dogiProbe?.fest?.includes("admin"),
+ "fuer DogFather selbst ist dieselbe Spalte NICHT fest");
+
+ /* Andere Rollen kommen weiterhin gar nicht heran. */
const modi = await roh("/workspace/api/rechte/feld", CREW, { cookie: kModi },
{ pfad: PROBE, rolle: "modi", erlaubt: true }, "PUT");
ok(modi.code === 404, `ein Modi bekommt 404 – für ihn gibt es die Tafel nicht (${modi.code})`);
const alles = await roh("/workspace/api/rechte/abweichungen", CREW, { cookie: kHand },
null, "DELETE");
- ok(alles.code === 403, `und zurücksetzen darf sie auch nicht (${alles.code})`);
+ ok(alles.code === 403, `und zurücksetzen darf sie nicht (${alles.code})`);
/* GEGENPROBE: Nach all dem ist die Tafel unverändert. Ein
abgewiesener Versuch, der trotzdem etwas ändert, wäre der
diff --git a/server/rechte.js b/server/rechte.js
index abb65316..b54d8ffb 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -676,10 +676,97 @@ export const UNANTASTBAR = [
{ pfad: "/workspace/start.html", rolle: "admin" },
];
+/* =====================================================================
+ WAS DIE RECHTE HAND NICHT ANFASSEN DARF (24.09.2026)
+
+ Filipe: „die rechte hand soll das auch sehen. und die selben rechte
+ da haben wie dogfather. das einzige was sie nicht kann ist die
+ dogfather rolle oder leute anfassen. also da kann sie nichts
+ verändern."
+
+ SEHEN KONNTE SIE DIE TAFEL SCHON -- `nurLeitung` liess sie herein,
+ und die Kachel steht in ihrer Liste. Was ihr fehlte, war das
+ Umstellen: Jeder Knopf antwortete mit „Umstellen kann das nur
+ DogFather."
+
+ ZWEI SPERREN, und beide stehen hier statt in der Route -- damit die
+ Oberflaeche dieselbe Auskunft bekommt und den Knopf gar nicht erst
+ als Knopf zeigt:
+
+ 1. DIE SPALTE „DogFather". Sie entscheidet, was er selbst sieht.
+ Wer sie aendern kann, kann ihm Seiten wegnehmen.
+ 2. DIE ZEILE „Personen & Zugaenge" -- „leute anfassen". Wer
+ personen.html fuer eine Rolle freischaltet, hat damit
+ Zugaenge vergeben, ohne einen einzigen anzulegen.
+
+ DRITTENS, UND DAS HAT ER NICHT GESAGT: Sie kann sich auch selbst
+ nicht aussperren. Dieselbe Ueberlegung wie bei UNANTASTBAR fuer
+ DogFather -- eine Sperre, aus der man sich aussperren kann, ist
+ keine Einstellung, sondern eine Falle. Haette ich es weggelassen,
+ waere der erste Fehlklick auf „rechte.html / Rechte Hand" das Ende
+ ihres Zugangs zu dieser Seite gewesen.
+ ===================================================================== */
+
+/** Die Spalte, die nur DogFather selbst aendern darf. */
+const NUR_DOGFATHER_SPALTE = "admin";
+/** Die Zeile, die nur DogFather aendern darf -- fuer JEDE Rolle. */
+const NUR_DOGFATHER_ZEILE = "/workspace/personen.html";
+/** Und was die rechte Hand sich selbst nicht wegnehmen kann. */
+const HAND_BRAUCHT = ["/workspace/rechte.html", "/workspace/start.html"];
+
+/** Darf DIESE PERSON dieses Feld anfassen? -- unabhaengig davon, ob
+ * sie es an- oder abwaehlen will.
+ *
+ * Getrennt von `darfUmstellen`, weil die Oberflaeche genau diese
+ * Frage stellt, bevor sie ein Feld zeichnet: Ein Knopf, der eine
+ * Absage bringt, ist schlimmer als keiner.
+ *
+ * @returns {string|null} Grund -- oder null, wenn es geht. */
+export function darfFeldAnfassen(person, pfad, rolle) {
+ const wer = person?.rolle;
+ if (wer === "admin") return null;
+ if (wer !== "hand") return "Umstellen kann das nur DogFather und die rechte Hand.";
+
+ if (rolle === NUR_DOGFATHER_SPALTE) {
+ return "Was DogFather sieht, stellt nur er selbst um.";
+ }
+ if (pfad === NUR_DOGFATHER_ZEILE) {
+ return "Wer Zugänge vergeben darf, entscheidet nur DogFather.";
+ }
+ if (rolle === "hand" && HAND_BRAUCHT.includes(pfad)) {
+ return "Diese Seite kannst du dir nicht selbst wegnehmen – "
+ + "sonst kämst du an die Rechte nicht mehr heran.";
+ }
+ return null;
+}
+
+/** Alle Felder, die diese Person nicht anfassen darf -- fuer die
+ * Oberflaeche, damit sie sie als fest zeichnet.
+ *
+ * ABGELEITET AUS `darfFeldAnfassen`, nicht danebengeschrieben: Zwei
+ * Listen fuer dieselbe Regel waeren die, die beim naechsten Umbau
+ * auseinanderlaufen -- und die in der Oberflaeche waere die, die
+ * jeder herunterlaedt. */
+export function festeFelder(person, pfad) {
+ const aus = UNANTASTBAR.filter((u) => u.pfad === pfad).map((u) => u.rolle);
+ for (const r of ALLE_ROLLEN) {
+ if (aus.includes(r)) continue;
+ if (darfFeldAnfassen(person, pfad, r)) aus.push(r);
+ }
+ return aus;
+}
+
/** @returns {string|null} Grund, warum das nicht geht -- oder null. */
-export function darfUmstellen(pfad, rolle, erlaubt) {
+export function darfUmstellen(pfad, rolle, erlaubt, person) {
if (!Object.prototype.hasOwnProperty.call(SEITEN, pfad)) return "Diese Seite gibt es nicht.";
if (!ALLE_ROLLEN.includes(rolle)) return "Diese Rolle gibt es nicht.";
+ /* ZUERST DIE PERSON, DANN DER INHALT. Wer ein Feld gar nicht
+ anfassen darf, bekommt denselben Grund -- egal ob er an- oder
+ abwaehlen wollte. Stuende diese Frage nach `if (erlaubt) return
+ null`, koennte die rechte Hand DogFather jede Seite ZUSCHALTEN
+ und nur nicht wegnehmen; das waere dieselbe Tuer, nur andersherum. */
+ const nein = person ? darfFeldAnfassen(person, pfad, rolle) : null;
+ if (nein) return nein;
if (erlaubt) return null;
const fest = UNANTASTBAR.find((u) => u.pfad === pfad && u.rolle === rolle);
if (fest) {
diff --git a/server/workspace-personen.js b/server/workspace-personen.js
index 0085ee5e..bfd045cc 100644
--- a/server/workspace-personen.js
+++ b/server/workspace-personen.js
@@ -153,7 +153,43 @@ function nurAdmin(req, res, next) {
const codeWeg = req.method === "POST"
&& /^\/workspace\/api\/verwaltung\/personen\/[0-9]+\/code$/
.test(req.baseUrl + req.path);
- if (person.rolle === "hand" && (anlegeWeg || codeWeg)) {
+ /* SPERREN UND LOESCHEN KOMMEN DAZU (24.09.2026).
+
+ Filipe: „die rechte hand soll das auch sehen. und die selben
+ rechte da haben wie dogfather. das einzige was sie nicht kann ist
+ die dogfather rolle oder leute anfassen. also da kann sie nichts
+ verändern."
+
+ „Das einzige" ist der Punkt. Bis heute blieben Sperren und
+ Loeschen ausdruecklich bei DogFather (siehe der Absatz darueber,
+ 22.09.) -- das ist damit ueberholt.
+
+ WAS SIE WEITERHIN NICHT KANN, steht nicht hier, sondern in den
+ Routen: An DogFather und Managern aendert nur DogFather etwas
+ (`ziel.rolle === "admin" || "manager"`), und welche Rollen sie
+ ueberhaupt anfassen darf, sagt ANLEGBAR. Diese Tuer oeffnet nur
+ den Weg -- wen er trifft, entscheidet die Route dahinter.
+
+ LOESCHEN IST ENDGUELTIG, und der Hausgrundsatz „Nur DogFather hat
+ alle endgueltigen Rechte" stand dem entgegen. Filipes Ansage ist
+ juenger und ausdruecklich; die Sicherungen, die wirklich zaehlen
+ (nie den letzten DogFather, nie an einem Admin), greifen
+ unabhaengig davon. */
+ const sperrWeg = req.method === "PATCH"
+ && /^\/workspace\/api\/verwaltung\/personen\/[0-9]+$/
+ .test(req.baseUrl + req.path);
+ const loeschWeg = req.method === "DELETE"
+ && /^\/workspace\/api\/verwaltung\/personen\/[0-9]+$/
+ .test(req.baseUrl + req.path);
+ /* DIE VORSCHAU GEHOERT ZUM LOESCHEN. Sie sagt, wie viel an einer
+ Person haengt -- wer loeschen darf, muss sie sehen, sonst
+ entscheidet er blind. Vergessen beim ersten Anlauf, gefunden von
+ pruef-hand-personen ("sie sieht die Loesch-Vorschau: 404"). */
+ const vorschauWeg = req.method === "GET"
+ && /^\/workspace\/api\/verwaltung\/personen\/[0-9]+\/loeschbar$/
+ .test(req.baseUrl + req.path);
+ if (person.rolle === "hand"
+ && (anlegeWeg || codeWeg || sperrWeg || loeschWeg || vorschauWeg)) {
req.person = person;
return next();
}
@@ -947,8 +983,18 @@ personenRouter.post("/workspace/api/verwaltung/personen/:id/code", gleicheHerkun
ohne es zu merken. Wer einen neuen Code will, hat den Knopf
daneben.
===================================================================== */
+/* `nurDogFatherBeiLeitung` FEHLTE HIER (ergaenzt 24.09.2026, gefunden
+ von pruef-hand-personen).
+
+ Bis heute war das ohne Folgen: Die Route hat eigene Pruefungen, und
+ wer sie ueberhaupt erreichte, war DogFather oder jemand mit
+ `darfRollenWechseln`. Gemessen hat die neue Pruefung aber, dass die
+ rechte Hand darueber die Rolle eines MANAGERS aendern konnte --
+ waehrend derselbe Manager fuer DogFather auf dieser Adresse gar
+ nicht existiert. Zwei Wege, zwei Antworten, und der laxere war der
+ fuer die Rolle mit weniger Rechten. */
personenRouter.put("/workspace/api/verwaltung/personen/:id/rolle", gleicheHerkunft,
- (req, res) => {
+ nurDogFatherBeiLeitung, (req, res) => {
try {
const id = Number(req.params.id);
const rolle = String(req.body?.rolle ?? "");
@@ -1209,22 +1255,39 @@ function anhang(id) {
Steht getrennt, damit Vorschau und Löschen GARANTIERT dieselbe Antwort
geben. Zwei Stellen mit derselben Regel laufen irgendwann auseinander. */
function darfGeloeschtWerden(person, akteur) {
- if (!istDogFather(akteur)) return "Löschen darf nur DogFather.";
+ /* DIE RECHTE HAND DARF SEIT DEM 24.09.2026 AUCH LOESCHEN.
+ Filipe: „die selben rechte da haben wie dogfather. das einzige
+ was sie nicht kann ist die dogfather rolle oder leute anfassen."
+ WEN sie nicht anfassen darf, entscheidet `nurDogFatherBeiLeitung`
+ vor dieser Funktion -- DogFather, Manager und alles ausserhalb
+ von ANLEGBAR. Hier bleiben die Sicherungen, die fuer JEDEN
+ gelten. */
+ if (!istDogFather(akteur) && akteur?.rolle !== "hand") {
+ return "Löschen darf nur DogFather und die rechte Hand.";
+ }
if (person.id === akteur.id) return "Dich selbst kannst du nicht löschen.";
if (istDogFather(person)) {
+ /* DOPPELT, UND DAS IST ABSICHT: `nurDogFatherBeiLeitung` haelt die
+ rechte Hand schon vorher auf. Diese Zeile gilt DogFather selbst
+ -- der letzte Zugang bleibt, auch wenn er es selbst versucht. */
const { n } = db().prepare("SELECT COUNT(*) AS n FROM personen WHERE rolle = 'admin'").get();
if (n <= 1) return "Das ist der letzte DogFather-Zugang.";
}
return null;
}
-personenRouter.get("/workspace/api/verwaltung/personen/:id/loeschbar", (req, res) => {
+personenRouter.get("/workspace/api/verwaltung/personen/:id/loeschbar",
+ nurDogFatherBeiLeitung, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
/* Wer gar nicht löschen darf, sieht auch die Vorschau nicht -- sie
- verrät, wie viel an einer Person hängt. */
- if (!istDogFather(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ verrät, wie viel an einer Person hängt. Seit dem 24.09.2026
+ gehoert die rechte Hand dazu; WEN sie sehen darf, regelt
+ `nurDogFatherBeiLeitung` auf dieser Route. */
+ if (!istDogFather(req.person) && req.person?.rolle !== "hand") {
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+ }
const person = db().prepare("SELECT id, name, rolle, aktiv FROM personen WHERE id = ?").get(id);
if (!person) return res.status(404).json({ fehler: "nicht_gefunden" });
@@ -1240,11 +1303,19 @@ personenRouter.get("/workspace/api/verwaltung/personen/:id/loeschbar", (req, res
}
});
-personenRouter.delete("/workspace/api/verwaltung/personen/:id", gleicheHerkunft, (req, res) => {
+/* `nurDogFatherBeiLeitung` STAND HIER NICHT -- und das war bis heute
+ harmlos, weil die Route eine Zeile weiter unten ohnehin nur
+ DogFather durchliess. Seit die rechte Hand loeschen darf, ist es
+ die Stelle, an der DogFather und die Manager geschuetzt werden.
+ Ohne diese Middleware koennte sie DogFather loeschen. */
+personenRouter.delete("/workspace/api/verwaltung/personen/:id", gleicheHerkunft,
+ nurDogFatherBeiLeitung, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
- if (!istDogFather(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (!istDogFather(req.person) && req.person?.rolle !== "hand") {
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+ }
const person = db().prepare("SELECT id, name, rolle, aktiv FROM personen WHERE id = ?").get(id);
if (!person) return res.status(404).json({ fehler: "nicht_gefunden" });
diff --git a/server/workspace-rechte.js b/server/workspace-rechte.js
index 6274f7ae..d52828e2 100644
--- a/server/workspace-rechte.js
+++ b/server/workspace-rechte.js
@@ -60,7 +60,8 @@ import {
} from "./workspace.js";
import {
SEITEN, ALLE_ROLLEN, OHNE_ANMELDUNG, UNANTASTBAR,
- tafelSetzen, tafelJetzt, darfUmstellen, alleSeiten,
+ tafelSetzen, tafelJetzt, darfUmstellen, alleSeiten, festeFelder,
+ darfFeldAnfassen,
} from "./rechte.js";
export const rechteRouter = express.Router();
@@ -149,11 +150,36 @@ function nurLeitung(req, res, next) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
-/** Umstellen: nur DogFather. */
+/** Umstellen: DogFather und die rechte Hand (24.09.2026).
+ *
+ * Filipe: „die selben rechte da haben wie dogfather. das einzige was
+ * sie nicht kann ist die dogfather rolle oder leute anfassen."
+ *
+ * WAS SIE NICHT DARF, STEHT NICHT HIER, sondern in
+ * `darfFeldAnfassen` (rechte.js) -- an genau einer Stelle, aus der
+ * auch die Oberflaeche ihre Auskunft zieht. Dieser Waechter laesst
+ * nur die beiden Rollen ueberhaupt herein; welches FELD sie anfassen
+ * duerfen, entscheidet die Route darunter. */
+function nurLeitungUmstellen(req, res, next) {
+ if (req.person?.rolle === "admin" || req.person?.rolle === "hand") return next();
+ return res.status(403).json({
+ fehler: "Umstellen kann das nur DogFather und die rechte Hand. "
+ + "Ansehen darfst du alles.",
+ });
+}
+
+/** Alles zurueck auf den Grundstand: weiterhin nur DogFather.
+ *
+ * UND DAS IST ABSICHT, keine Vergesslichkeit. Ein Knopf, der die
+ * ganze Tafel zuruecksetzt, wuerde auch Aenderungen an der Spalte
+ * „DogFather" und an der Zeile „Personen & Zugaenge" mitnehmen --
+ * genau die zwei Dinge, die die rechte Hand nicht anfassen soll.
+ * Eine Sperre, die ein zweiter Knopf daneben aufhebt, ist keine. */
function nurDogFather(req, res, next) {
if (req.person?.rolle === "admin") return next();
return res.status(403).json({
- fehler: "Umstellen kann das nur DogFather. Ansehen darfst du alles.",
+ fehler: "Alles zurücksetzen kann nur DogFather – es würde auch "
+ + "Einstellungen mitnehmen, die dir nicht offenstehen.",
});
}
@@ -179,11 +205,35 @@ rechteRouter.get("/workspace/api/rechte/tafel", (req, res) => {
/* Was DogFather hier NICHT abwaehlen kann -- damit die Oberflaeche
den Knopf gar nicht erst als Knopf zeigt statt ihn eine Absage
bringen zu lassen. */
- fest: UNANTASTBAR.filter((u) => u.pfad === pfad).map((u) => u.rolle),
+ /* WAS DIESE PERSON NICHT ANFASSEN DARF -- nicht nur, was
+ allgemein festliegt (24.09.2026). Fuer DogFather unveraendert
+ UNANTASTBAR; die rechte Hand bekommt zusaetzlich die Spalte
+ „DogFather", die Zeile „Personen & Zugaenge" und ihre eigenen
+ zwei Zugaenge als fest gezeichnet.
+
+ Abgeleitet aus derselben Funktion, die die Route gleich
+ darunter befragt -- damit die Oberflaeche nie einen Knopf
+ zeigt, den der Server danach ablehnt. */
+ fest: festeFelder(req.person, pfad),
+ /* UND WARUM. Ein festes Feld ohne Grund ist eine graue Fläche,
+ bei der man sich fragt, ob etwas kaputt ist. Der Text kommt
+ aus derselben Funktion, die das Feld festgesetzt hat -- nicht
+ aus einer Liste in der Oberfläche, die beim nächsten neuen
+ Grund nicht mitwächst. */
+ fest_grund: Object.fromEntries(ALLE_ROLLEN
+ .map((r) => [r, darfFeldAnfassen(req.person, pfad, r)
+ || (UNANTASTBAR.some((u) => u.pfad === pfad && u.rolle === r)
+ ? "Diese Seite kannst du dir nicht selbst wegnehmen – sonst käme "
+ + "niemand mehr an die Rechte, an die Zugänge oder auf die Startseite."
+ : null)])
+ .filter(([, grund]) => grund)),
}));
res.json({
- darf_aendern: req.person.rolle === "admin",
+ darf_aendern: req.person.rolle === "admin" || req.person.rolle === "hand",
+ /* Zuruecksetzen bleibt bei DogFather -- die Oberflaeche zeigt
+ den Knopf deshalb nur ihm. */
+ darf_zuruecksetzen: req.person.rolle === "admin",
rollen: ALLE_ROLLEN.map((r) => ({
schluessel: r,
name: ROLLEN_NAME[r] ?? r,
@@ -209,13 +259,16 @@ rechteRouter.get("/workspace/api/rechte/tafel", (req, res) => {
Aenderung verliert -- und bei Rechten ist eine halbe Aenderung die
gefaehrlichste Lage von allen. Zurueckgenommen wird mit demselben
Klick. */
-rechteRouter.put("/workspace/api/rechte/feld", gleicheHerkunft, nurDogFather, (req, res) => {
+rechteRouter.put("/workspace/api/rechte/feld", gleicheHerkunft, nurLeitungUmstellen, (req, res) => {
try {
const pfad = String(req.body?.pfad || "");
const rolle = String(req.body?.rolle || "");
const erlaubt = req.body?.erlaubt === true;
- const nein = darfUmstellen(pfad, rolle, erlaubt);
+ /* DIE PERSON GEHT MIT. Ohne sie prueft `darfUmstellen` nur die
+ allgemeinen Sperren -- und die rechte Hand koennte DogFathers
+ Spalte aendern. */
+ const nein = darfUmstellen(pfad, rolle, erlaubt, req.person);
if (nein) return res.status(400).json({ fehler: nein });
const imGrundstand = (SEITEN[pfad] || []).includes(rolle);
diff --git a/server/workspace.js b/server/workspace.js
index a66f6b94..28f7108a 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -6247,6 +6247,31 @@ workspaceRouter.get("/workspace/api/ich", (req, res) => {
sich niemand zu "Community" machen liess. */
rollen_anfassbar: [...ROLLEN].filter((r) =>
darfRolleAendern(person, { id: -1, rolle: r })),
+ /* DARF ICH ZUGAENGE VERWALTEN? (24.09.2026)
+
+ Filipe: „die rechte hand soll das auch sehen. und die selben
+ rechte da haben wie dogfather. das einzige was sie nicht kann
+ ist die dogfather rolle oder leute anfassen."
+
+ WARUM DAS HIER STEHT UND NICHT IN personen.js: Dort stand
+ `if (ich.rolle === 'hand') { keine Knoepfe }` -- mit der
+ Begruendung „Der Server antwortet ihr auf jeden davon mit 404".
+ Das stimmte am 22.09., als sie nur anlegen und Codes erzeugen
+ durfte. Seither wurde der Server zweimal erweitert und die
+ Oberflaeche nie nachgezogen: Sie durfte Rollen aendern und
+ Codes erzeugen, sah aber keinen einzigen Knopf. Eine
+ Rollenabfrage im Browser ist genau die zweite Wahrheit, die
+ still veraltet.
+
+ `darfAnlegen(person).length > 0` ist die Ableitung, nicht eine
+ Liste: Wer ueberhaupt jemanden anlegen darf, darf auch mit ihm
+ weiterarbeiten. Fuer DogFather und die rechte Hand ist sie
+ nicht leer, fuer alle anderen schon. */
+ darf_zugaenge_verwalten: darfAnlegen(person).length > 0,
+ /* UND LOESCHEN -- getrennt, weil es das Einzige ist, was sich nicht
+ zuruecknehmen laesst. Heute dieselbe Antwort; sollte Filipe es
+ spaeter wieder einschraenken, ist hier die Stelle. */
+ darf_personen_loeschen: person?.rolle === "admin" || person?.rolle === "hand",
/* OB DER CHAT-KNOPF IN DER KOPFLEISTE ERSCHEINT (18.09.2026).
Er wurde auf JEDER Seite gebaut. Fuer ein Mitglied der Community
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 5e1643d0..ff5ee714 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`. -->
-
-
+
+
-
+