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`. --> - - + + - +