diff --git a/server/pruef-entwicklung.mjs b/server/pruef-entwicklung.mjs index e013565d..2120f33e 100644 --- a/server/pruef-entwicklung.mjs +++ b/server/pruef-entwicklung.mjs @@ -327,7 +327,7 @@ console.log("=== Die Kacheln ==="); Untertitel. Wer morgen eine Kachel "Dein Kopf" nennt und dasselbe verspricht, ist damit mitgeprueft; die Namensliste haette ihn durchgelassen. */ - const verspricht = (k) => /nur du|nur dich|siehst nur du/i.test(k.unter || ""); + const verspricht = (k) => /\bnur du\b|\bnur dich\b|siehst nur du/i.test(k.unter || ""); const heikel = alleX.filter((k) => verspricht(k) && k.ziel === "entwicklung.html"); ok(alleX.some(verspricht) && heikel.length === 0, `${wer}: keine Kachel verspricht Vertraulichkeit und fuehrt doch auf die` diff --git a/server/pruef-haerte.mjs b/server/pruef-haerte.mjs index d38d56d0..2d6f866d 100644 Binary files a/server/pruef-haerte.mjs and b/server/pruef-haerte.mjs differ diff --git a/server/pruef-nachfrage.mjs b/server/pruef-nachfrage.mjs index fba04ae1..166483af 100644 --- a/server/pruef-nachfrage.mjs +++ b/server/pruef-nachfrage.mjs @@ -159,7 +159,7 @@ for (const s of seiten) { einem reinen Formular-Dialog hier als „totes Gewicht" gemeldet -- und jemand naehme die Datei heraus, die dort gerade arbeitet. */ const hatDialogMitFeld = [...html.matchAll(/]*>[\s\S]*?<\/dialog>/g)] - .some((m) => /<(input|textarea|select)/.test(m[0])); + .some((m) => /<(input|textarea|select)\b/.test(m[0])); const kannFragen = geladen.some((g) => nutzer.has(g)) || hatDialogMitFeld; const hatDatei = html.includes("assets/js/nachfrage.js"); if (kannFragen) { mitNutzung++; fragendeSeiten.push(s); if (!hatDatei) ohneDatei.push(s); } diff --git a/server/pruef-nachwuchs.mjs b/server/pruef-nachwuchs.mjs index 99a3f192..b939a284 100644 --- a/server/pruef-nachwuchs.mjs +++ b/server/pruef-nachwuchs.mjs @@ -614,13 +614,47 @@ melde("=== 11. Probe → Zugang ==="); ok(!/personAnlegen|INSERT INTO personen/.test(eigen), "und der Talente-Server legt selbst keinen einzigen Zugang an"); - /* DIE RECHTE HAND SIEHT DEN TRICHTER, VERGIBT ABER KEINE ZUGAENGE. */ + /* DIE RECHTE HAND LEGT ZUGAENGE AN -- SEIT DEM 22.09.2026. + + Hier stand bis zum 23.09. das Gegenteil: "vergibt aber keine + Zugaenge", geprueft mit 404. Das war bis zum 22.09. richtig. Dann + hat Filipe es ausdruecklich umgedreht -- "damit sie das auch + machen kann wenn er live ist" -- und die Route in + workspace-personen.js oeffnet seither genau zwei Wege: anlegen + und einen Code neu erzeugen. + + DIE PRUEFUNG WAR ALSO SEIT EINEM TAG ROT, ohne dass es jemandem + aufgefallen waere -- sie lief in der Zwischenzeit nicht. Das ist + der Normalfall und nicht die Ausnahme: Man startet die + Pruefungen, an denen man gerade baut. + + UMGEDREHT WIRD SIE, NICHT GELOESCHT. Eine Erlaubnis ohne Grenze + ist keine Entscheidung, sondern ein Loch. Geprueft wird deshalb + ab jetzt beides: dass die rechte Hand es DARF -- und dass die + Grenzen daneben stehen bleiben. */ const hand = await roh("/workspace/api/verwaltung/personen", CREW, { cookie: kHand }, { name: "Heimlich", rolle: "modi" }); - ok(hand.code === 404, `die rechte Hand kommt am Anlegen nicht vorbei (${hand.code})`); + ok(hand.code === 201, `die rechte Hand darf einen Zugang anlegen (${hand.code})`); + + /* ABER NICHT ALLES. Sperren gehoert nicht dazu -- das ist die + Grenze, die "anlegen" von "verwalten" trennt. Ohne diese Zeile + waere die Aenderung oben eine Erlaubnis ohne Rand. */ + const handSperrt = await roh(`/workspace/api/verwaltung/personen/${json(hand).id}/aktiv`, + CREW, { cookie: kHand }, { aktiv: 0 }, "PUT"); + ok(handSperrt.code === 404 || handSperrt.code === 403, + `aber sperren darf sie niemanden (${handSperrt.code})`); + + /* UND DAS PROTOKOLL BLEIBT BEI DOGFATHER. Dort stehen die + Einstellungsaenderungen des ganzen Hauses. */ + const handProtokoll = await roh("/workspace/api/verwaltung/protokoll", CREW, { cookie: kHand }); + ok(handProtokoll.code === 404 || handProtokoll.code === 403, + `und ins Protokoll sieht sie nicht (${handProtokoll.code})`); + + /* EIN MODI DARF WEITERHIN GAR NICHTS DAVON. Das ist die eigentliche + Aussage dieses Blocks -- und sie ist unveraendert. */ const einModi = await roh("/workspace/api/verwaltung/personen", CREW, { cookie: kModi }, { name: "Heimlich", rolle: "modi" }); - ok(einModi.code === 404, `ein Modi erst recht nicht (${einModi.code})`); + ok(einModi.code === 404, `ein Modi kommt am Anlegen nicht vorbei (${einModi.code})`); /* GEGENPROBE: DogFather kann es -- sonst waere "404" oben auch dann gruen, wenn der Weg gar nicht existiert. */ @@ -1477,7 +1511,7 @@ melde("=== 17. Der Anschluss im Browser ==="); const neueTexte = await seite.evaluate(() => [...document.querySelectorAll(".t-start, .t-person")] .map((e) => e.textContent || "").join(" ")); - ok(!/modis?/i.test(neueTexte), + ok(!/\bmodis?\b/i.test(neueTexte), `die neuen Bausteine nennen die Rolle nicht (${neueTexte.length} Zeichen geprueft)`); /* GEGENPROBE zur Voraussetzung: Es wurde ueberhaupt etwas angesehen. Ohne Text waere die Zeile darueber auch dann gruen, diff --git a/server/pruef-personen-formular.mjs b/server/pruef-personen-formular.mjs index cd931682..b26bc23e 100644 --- a/server/pruef-personen-formular.mjs +++ b/server/pruef-personen-formular.mjs @@ -235,7 +235,7 @@ ok(mass.kartenZeichen.modi && mass.kartenZeichen.modi !== mass.kartenZeichen.adm Auf dem Bildschirmfoto stand "Sichtbar nur fuer die DogFather-Rolle" -- in einem Text, den jemand liest, sieht das aus wie ein Fehler, weil es einer ist. */ -ok(mass.kartenTexte.modi && !/(fuer|ueber|waehrend|koennen|moeglich)/ +ok(mass.kartenTexte.modi && !/\b(fuer|ueber|waehrend|koennen|moeglich)\b/ .test(mass.kartenTexte.modi), `und ihre Beschreibung hat echte Umlaute ("${mass.kartenTexte.modi.slice(0, 60)}…")`); ok(mass.wahlUnten <= mass.formUnten, diff --git a/server/pruef-zeichen.mjs b/server/pruef-zeichen.mjs new file mode 100644 index 00000000..0b088d5c --- /dev/null +++ b/server/pruef-zeichen.mjs @@ -0,0 +1,274 @@ +/* ===================================================================== + STEHT IN EINER AUSGELIEFERTEN DATEI EIN ZERSTOERTES ZEICHEN? + + WARUM ES DIESE PRUEFUNG GIBT: Weil genau das zweimal live stand, + und zwar am selben Tag (23.09.2026), in zwei Dateien. + + 1. `content: "<0x15>C9<0x00>A0"` in chat.css. Gemeint war U+25C9 + (dasselbe Zeichen, das chat.js fuer das Rudel benutzt) und ein + geschuetztes Leerzeichen. Im Quelltext standen stattdessen ZWEI + ROHE STEUERZEICHEN. Auf dem Bildschirm las man deshalb woertlich + "C9 A0" vor jedem hervorgehobenen @rudel -- die Steuerzeichen + selbst sind unsichtbar. + + 2. `content: "¹3"` in chat.css UND entwicklung.css. Gemeint + war ein Haken. Auf der gewaehlten Kachel stand "¹3". + + DAS BESONDERE AN DIESER FEHLERART: Niemand sieht sie beim Lesen. + Punkt 1 war sogar committet, ausgeliefert und lag live -- und als + ich in der Datei danach suchte, meldete grep "Binary file matches" + und zeigte die Zeile gar nicht erst an. Ein Mensch, der den + Unterschied durchsieht, liest `content: "C9 A0"` und denkt sich + nichts dabei. + + Gefunden hat es am Ende Filipes Bildschirmfoto, nicht der Quelltext. + Das ist zu spaet und zu zufaellig. Also eine Pruefung. + + --------------------------------------------------------------------- + WAS GEPRUEFT WIRD, UND WARUM DIE GRENZE DA LIEGT + + AUSGELIEFERTE DATEIEN (workspace/, assets/, *.html) -- dort ist + beides ein Fehler: ein Steuerzeichen UND ein Ersatzzeichen (U+FFFD). + Was dort steht, sieht ein Mensch. + + SERVERDATEIEN -- dort nur Steuerzeichen. Ein U+FFFD kann in einer + Pruefung absichtlich stehen: pruef-ics.mjs sucht genau danach in + Kalenderdateien. Eine Ausnahmeliste mit Dateinamen waere die + naheliegende Loesung und die schlechtere -- sie altert. Die Grenze + "wird es ausgeliefert?" altert nicht. + + TABULATOR, ZEILENVORSCHUB UND WAGENRUECKLAUF sind erlaubt. Alles + andere unter 0x20 ist in Quelltext nie Absicht. + ===================================================================== */ +import { readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs"; +import { join, dirname, extname, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WURZEL = join(HIER, ".."); + +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* NOTBREMSE. Auch eine Pruefung ohne Netzzugriff bekommt eine -- ein + Lauf, der haengt, ist schlimmer als einer, der scheitert (06.09.2026, + damals drei Stunden). */ +const notbremse = setTimeout(() => { + console.log(" FEHL Notbremse: die Pruefung haengt."); + process.exit(1); +}, 60_000); + +const ENDUNGEN = new Set([".js", ".mjs", ".html", ".css", ".json", ".svg", + ".txt", ".webmanifest"]); + +/* Ordner, die uns nicht gehoeren oder erzeugt sind. */ +const AUSSEN = new Set([".git", "node_modules", "sicherungen", ".tmp"]); + +/** Alle Dateien unterhalb eines Ordners, die uns angehen. */ +function dateien(ordner) { + const raus = []; + const gehe = (o) => { + let eintraege; + try { eintraege = readdirSync(o, { withFileTypes: true }); } catch { return; } + for (const e of eintraege) { + if (e.name.startsWith(".tmp-")) continue; + const p = join(o, e.name); + if (e.isDirectory()) { if (!AUSSEN.has(e.name)) gehe(p); continue; } + if (ENDUNGEN.has(extname(e.name).toLowerCase())) raus.push(p); + } + }; + gehe(ordner); + return raus; +} + +/* --------------------------------------------------------------------- + Die beiden Sucher. Getrennt, damit die Gegenprobe jeden einzeln + nachweisen kann. + --------------------------------------------------------------------- */ + +/** Rohe Steuerzeichen -- alles unter 0x20 ausser \t \n \r. */ +function steuerzeichenIn(roh) { + const treffer = []; + for (let i = 0; i < roh.length; i++) { + const b = roh[i]; + if (b >= 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) continue; + /* Die Zeile mitgeben -- eine Bytenummer allein sucht man lange. */ + const zeile = roh.subarray(0, i).toString("utf8").split("\n").length; + treffer.push({ zeile, byte: b }); + } + return treffer; +} + +/** Ersatzzeichen U+FFFD -- in UTF-8 die drei Bytes ef bf bd. */ +function ersatzzeichenIn(roh) { + const treffer = []; + let i = -1; + while ((i = roh.indexOf(Buffer.from([0xef, 0xbf, 0xbd]), i + 1)) !== -1) { + treffer.push({ zeile: roh.subarray(0, i).toString("utf8").split("\n").length }); + } + return treffer; +} + +/* ===================================================================== + 1. GEGENPROBE ZUERST + + Eine Pruefung, die immer bestaetigt, bestaetigt nichts. Bevor sie + ueber echte Dateien redet, weist sie nach, dass sie ueberhaupt + anschlagen KANN -- an einer Datei, die sie sich selbst baut. Ohne + das waere ein gruener Lauf hier auch dann gruen, wenn beide Sucher + ins Leere greifen (der Fehler vom 01.09.2026, zweimal an einem Tag). + ===================================================================== */ +console.log("=== 0. Kann die Pruefung ueberhaupt Alarm schlagen? ==="); +{ + const probe = join(tmpdir(), "pruef-zeichen-probe.css"); + try { + /* Genau die beiden Faelle, die live standen. */ + writeFileSync(probe, Buffer.concat([ + Buffer.from('a { content: "'), + Buffer.from([0x15]), Buffer.from("C9"), + Buffer.from([0x00]), Buffer.from('A0"; }\n'), + Buffer.from('b::after { content: "'), Buffer.from([0xef, 0xbf, 0xbd]), Buffer.from('"; }\n'), + ])); + const roh = readFileSync(probe); + const st = steuerzeichenIn(roh); + const er = ersatzzeichenIn(roh); + ok(st.length === 2, `sie findet rohe Steuerzeichen (${st.length}, erwartet 2)`); + ok(er.length === 1, `sie findet Ersatzzeichen (${er.length}, erwartet 1)`); + + /* Und die andere Richtung: saubere Datei, kein Alarm. Eine + Pruefung, die das richtige Verhalten bestraft, wird abgeschafft + (03.09.2026). */ + writeFileSync(probe, 'a { content: "\\25C9\\A0"; }\n\tb { color: red; }\r\n'); + const sauber = readFileSync(probe); + ok(steuerzeichenIn(sauber).length === 0 && ersatzzeichenIn(sauber).length === 0, + "und sie schweigt bei einer sauberen Datei -- auch mit Tabulator und CRLF"); + } finally { + try { unlinkSync(probe); } catch { /* beim Aufraeumen ist Scheitern egal */ } + } +} + +/* ===================================================================== + 2. DIE AUSGELIEFERTEN DATEIEN + ===================================================================== */ +console.log(""); +console.log("=== 1. Was der Browser bekommt ==="); + +const geliefert = [ + ...dateien(join(WURZEL, "workspace")), + ...dateien(join(WURZEL, "assets")), + ...dateien(WURZEL).filter((p) => dirname(p) === WURZEL && extname(p) === ".html"), +]; + +{ + const schlimm = []; + for (const p of geliefert) { + const roh = readFileSync(p); + for (const t of steuerzeichenIn(roh)) { + schlimm.push(`${relative(WURZEL, p)}:${t.zeile} (Byte 0x${t.byte.toString(16).padStart(2, "0")})`); + } + for (const t of ersatzzeichenIn(roh)) { + schlimm.push(`${relative(WURZEL, p)}:${t.zeile} (Ersatzzeichen)`); + } + } + /* DIE ANZAHL STEHT IN DER BEDINGUNG, nicht nur im Text. Waere der + Ordner leer oder der Pfad falsch, meldete "keine Fundstellen" + sonst Erfolg -- gruen, ohne eine einzige Datei angesehen zu + haben. */ + ok(geliefert.length > 50 && schlimm.length === 0, + `${geliefert.length} ausgelieferte Dateien, ${schlimm.length} mit zerstoerten Zeichen`); + for (const s of schlimm.slice(0, 12)) console.log(" " + s); + if (schlimm.length > 12) console.log(` ... und ${schlimm.length - 12} weitere`); +} + +/* ===================================================================== + 3. DIE SERVERDATEIEN -- nur Steuerzeichen + ===================================================================== */ +console.log(""); +console.log("=== 2. Der Server ==="); + +{ + const schlimm = []; + const serverDateien = [...dateien(join(WURZEL, "server")), ...dateien(join(WURZEL, "tools"))]; + for (const p of serverDateien) { + const roh = readFileSync(p); + for (const t of steuerzeichenIn(roh)) { + schlimm.push(`${relative(WURZEL, p)}:${t.zeile} (Byte 0x${t.byte.toString(16).padStart(2, "0")})`); + } + } + ok(serverDateien.length > 50 && schlimm.length === 0, + `${serverDateien.length} Serverdateien, ${schlimm.length} mit Steuerzeichen`); + for (const s of schlimm.slice(0, 12)) console.log(" " + s); +} + +/* ===================================================================== + 4. DIE STELLE, AN DER ES ZWEIMAL PASSIERT IST + + `content:` in CSS ist der eine Ort, an dem ein Zeichen SICHTBAR + wird, ohne dass es im Text steht. Beide Fehler sassen dort. + + --------------------------------------------------------------------- + DIE REGEL WAR IM ERSTEN ANLAUF ZU WEIT -- und das ist hier notiert, + weil es der wichtigere Teil ist. + + Zuerst stand hier "jedes Zeichen ueber 0x7f ist verdaechtig, schreib + es als Escape". Das meldete auf Anhieb ACHT Stellen: ✓ in vier + Dateien, ↯, ↻, „ und ⚠. Alle acht sind richtig, absichtlich und + seit Monaten in Ordnung. Eine Pruefung, die beim ersten Lauf acht + korrekte Stellen anmeckert, wird beim neunten Mal weggeklickt -- und + dann faengt sie auch den echten Fall nicht mehr (03.09.2026). + + DIE ENGERE REGEL KOMMT AUS DEM BEFUND SELBST. Das kaputte Zeichen + war U+00B9 (hochgestellte Eins) -- ein Rest aus dem Latin-1-Block. + Genau dort, zwischen U+0080 und U+00BF, liegen die typischen + Truemmer einer verungluekten Kodierung: ¡ ¢ £ ¤ ¥ ¦ § ¨ © ª « ¬ ® + ° ± ² ³ ´ µ ¶ · ¸ ¹ º » ¼ ½ ¾. + + Nachgemessen ueber alle 203 content-Angaben im Haus: KEINE + einzige benutzt ein Zeichen aus diesem Block. Alle echten liegen + darueber (✓ = U+2713, ⚠ = U+26A0, „ = U+201E). Die Regel ist damit + heute gruen, haette den Fehler aber gefunden -- und sie ist nicht + geraten, sondern an den vorhandenen Daten geprueft. + ===================================================================== */ +console.log(""); +console.log("=== 3. Zeichen, die CSS auf den Bildschirm schreibt ==="); + +/** Truemmerverdacht: Latin-1-Block, wo echte Typografie nie hingehoert. */ +const istTruemmer = (c) => c >= 0x80 && c <= 0xbf; + +{ + const verdaechtig = []; + let stellen = 0; + for (const p of geliefert.filter((x) => extname(x) === ".css")) { + const text = readFileSync(p, "utf8"); + for (const m of text.matchAll(/content:\s*"([^"]*)"/g)) { + stellen++; + for (const z of m[1]) { + const c = z.codePointAt(0); + if (istTruemmer(c)) { + verdaechtig.push(`${relative(WURZEL, p)}: content "${m[1]}" enthaelt U+${c.toString(16).toUpperCase().padStart(4, "0")}`); + } + } + } + } + ok(stellen > 50 && verdaechtig.length === 0, + `${stellen} content-Angaben in CSS, ${verdaechtig.length} mit Kodierungstruemmern`); + for (const s of verdaechtig.slice(0, 12)) console.log(" " + s); + + /* GEGENPROBE: Wuerde sie den echten Fall sehen? Nicht theoretisch -- + an genau der Zeichenfolge, die in chat.css stand. */ + const kaputt = [...'content: "¹3"'.matchAll(/content:\s*"([^"]*)"/g)] + .flatMap((m) => [...m[1]]).some((z) => istTruemmer(z.codePointAt(0))); + const heil = [...'content: "✓"'.matchAll(/content:\s*"([^"]*)"/g)] + .flatMap((m) => [...m[1]]).some((z) => istTruemmer(z.codePointAt(0))); + ok(kaputt && !heil, + 'sie erkennt das echte "¹3" und laesst den richtigen Haken in Ruhe'); +} + +clearTimeout(notbremse); +console.log(""); +console.log(fehler === 0 + ? `Alles in Ordnung — ${geprueft} Prüfungen` + : `${fehler} FEHLER — ${geprueft} Prüfungen`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/workspace-treff.js b/server/workspace-treff.js index 82dbf8ac..7a27c1c9 100644 --- a/server/workspace-treff.js +++ b/server/workspace-treff.js @@ -452,7 +452,7 @@ treffRouter.get("/workspace/api/treff/regeln", (req, res) => { Skript. Jetzt kommt er von hier. UND DER SCHLUESSEL HIESS SELBST SO: `modi_frist_tage`. Das - Wortleck-Muster ist `modi` und trifft das nicht, weil danach + Wortleck-Muster ist `\bmodi\b` und trifft das nicht, weil danach ein Unterstrich folgt -- ein Leck durch eine Luecke im Muster, nicht durch eine Entscheidung. Deshalb heisst das Feld jetzt nach dem, was es bedeutet. */ diff --git a/server/workspace.js b/server/workspace.js index a0a81c19..121707c4 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -7220,15 +7220,53 @@ export function einstellung(schluessel, vorgabe = null) { } } +/* WELCHE EINSTELLUNGEN NICHT IM KLARTEXT INS PROTOKOLL GEHOEREN + --------------------------------------------------------------------- + Gefunden am 23.09.2026, beim Vorbereiten des KLIPY-Zugangs. + + einstellungSetzen() schrieb bis dahin IMMER Name UND Wert ins + Protokoll. Fuer "nachtruhe_an = 1" ist das genau richtig -- man + will sehen, wer wann was umgestellt hat. Fuer einen Schluessel + ist es das Gegenteil. + + NACHGEMESSEN in der echten Datenbank, nicht vermutet: + + kopie_schluessel (31.08.) steht VOLLSTAENDIG im Protokoll + vapid_paar (05.09.) wurde bei 120 Zeichen abgeschnitten, + kurz VOR dem privaten Teil + + Dass der zweite heil blieb, lag an einer Laengengrenze, nicht an + einer Absicht. Das ist keine offene Tuer -- das Protokoll liegt + hinter nurAdmin --, aber der Wert wandert in JEDE Sicherung und in + jeden Export. Und der naechste Schluessel waere denselben Weg + gegangen. + + ES IST EINE REGEL UND KEINE LISTE. Eine Liste + ["kopie_schluessel","vapid_paar"] waere beim naechsten Geheimnis + still unvollstaendig -- derselbe Fehler wie bei den abgeschriebenen + Spaltennamen am 11.09. Wer eine Einstellung so BENENNT, meint auch + ein Geheimnis. Faellt ein harmloser Name versehentlich darunter, + ist der Schaden, dass im Protokoll etwas weniger Text steht. + + WAS STEHEN BLEIBT: dass sich die Einstellung geaendert hat, wann, + und durch wen. Nur der Wert fehlt. Ein Protokolleintrag ohne + Eintrag waere die schlechtere Loesung -- dann saehe niemand mehr, + dass jemand den Schluessel ausgetauscht hat. */ +const GEHEIME_EINSTELLUNG = /(schluessel|schlüssel|geheim|token|passwort|kennwort|secret|paar|(^|[_-])key([_-]|$))/i; + export function einstellungSetzen(schluessel, wert, akteur = null) { db().prepare(` INSERT INTO einstellungen (schluessel, wert, geaendert, von) VALUES (?,?,?,?) ON CONFLICT(schluessel) DO UPDATE SET wert = excluded.wert, geaendert = excluded.geaendert, von = excluded.von`).run( schluessel, String(wert), new Date().toISOString(), akteur?.id ?? null); + const roh = String(wert ?? ""); + const detail = GEHEIME_EINSTELLUNG.test(String(schluessel)) + ? `${schluessel} = ${roh ? "(gesetzt, " + roh.length + " Zeichen)" : "(geleert)"}` + : `${schluessel} = ${wert}`; protokolliere("einstellung_geaendert", { personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null, - detail: `${schluessel} = ${wert}`.slice(0, 120), + detail: detail.slice(0, 120), }); } diff --git a/tools/nachtlauf.mjs b/tools/nachtlauf.mjs new file mode 100644 index 00000000..3c89b18f --- /dev/null +++ b/tools/nachtlauf.mjs @@ -0,0 +1,327 @@ +/* ===================================================================== + nachtlauf.mjs — einmal in der Nacht alles prüfen und es AUFSCHREIBEN + + Filipe am 23.09.2026 auf die Frage, ob ein nächtlicher Lauf die + Prüfungen selbst anstoßen und morgens sagen soll, welche rot sind: + „ja". + + --------------------------------------------------------------------- + WARUM ES DAS BRAUCHT — drei Funde an EINEM Tag + + Am 23.09.2026 sind mir drei Prüfungen aufgefallen, die rot waren, + ohne dass es jemandem aufgefallen wäre: + + pruef-modi-wortleck seit dem 22.09. — der verborgene Rollenname + stand offen im Netz + pruef-zuteilung stürzte ab und prüfte danach gar nichts mehr + pruef-push-ziel meldete einen Befund, der in Wahrheit eine + erfüllte Bestellung war + + Alle drei habe ich durch ZUFALL gefunden — weil sie neben dem lagen, + woran ich gerade gebaut habe. Prüfungen laufen nur, wenn jemand sie + startet, und man startet die, an denen man arbeitet. + + --------------------------------------------------------------------- + WOHIN DAS ERGEBNIS GEHT — und wohin ausdrücklich NICHT + + In eine Notiz im Vault. NICHT in die laufende Anwendung. + + Das ist keine Bequemlichkeit, sondern die Lehre vom 04.09.2026: Ein + Prüflauf ist kein harmloser Lesevorgang, sein Ergebnis wird + VERÖFFENTLICHT. Damals hat ein Netzproblem bei npm einen nächtlichen + Selbsttest rot gemacht, und ab da stand VanVan in roter Schrift auf + dem Startbildschirm, die Buchführung sei womöglich verletzt. An der + Buchführung war nichts. + + Wer einen Prüflauf anstößt, muss vorher überlegen, was der + SCHLECHTESTE Ausgang auf dem Bildschirm einer Nutzerin anrichtet. + Hier richtet er nichts an: Er schreibt eine Notiz, die Filipe und ich + lesen. Die Community sieht davon nichts. + + --------------------------------------------------------------------- + DREI AUSGÄNGE, NICHT ZWEI + + in Ordnung alles grün + nicht in Ordnung mindestens eine Datei rot + KONNTE NICHT NACHSEHEN der Lauf hat gar nicht stattgefunden + + Der dritte ist der wichtige. Wenn der Rechner nachts aus war, gibt es + kein Ergebnis — und ein FEHLENDES Ergebnis sieht sonst genauso aus + wie ein altes gutes. Die Notiz sagt deshalb immer, WANN zuletzt + wirklich gemessen wurde, und ab zwei Tagen ohne Lauf steht das ganz + oben. + + --------------------------------------------------------------------- + DIE ZAHL WIRD MIT GESTERN VERGLICHEN + + Ein grüner Lauf ist kein Beweis, solange nicht auch die ANZAHL + stimmt (28.08.2026, RunOne: 789 statt 804 Prüfungen, weil acht je + Gerät stillschweigend weggefallen waren). Diese Datei merkt sich + deshalb den letzten Stand und meldet jeden Rückgang — auch wenn + alles grün ist. + + AUFRUF: + node tools/nachtlauf.mjs normal + node tools/nachtlauf.mjs --jetzt auch wenn gerade gearbeitet wird + ===================================================================== */ + +import { spawn } from "node:child_process"; +import { + writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync, statSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WURZEL = join(HIER, ".."); + +/* Der Vault -- dort liest Filipe. Absichtlich fest und nicht geraten: + Wenn der Ordner umzieht, soll das auffallen und nicht still in einen + neuen Ordner schreiben, den niemand aufmacht. */ +const VAULT = "C:/Users/qciga/Documents/Obelix/DogFather"; +const NOTIZ = join(VAULT, "02 Projekte", "Pruefstand.md"); +const ZUSTAND = join(WURZEL, "tools", ".nachtlauf-stand.json"); +const SCHLOSS = join(WURZEL, "tools", ".nachtlauf-laeuft"); + +/* ZWEI LÄUFE GLEICHZEITIG SIND KEIN LAUF, SONDERN ZWEI KAPUTTE + (06.09.2026, RunOne). Beide belegen dieselben Ports und legen + dieselben Testdaten an; heraus kommen Fehler, die es nicht gibt -- + und wer sie für echt hält, sucht stundenlang an Stellen, an denen + nichts kaputt ist. + + DREI STUNDEN, dann gilt das Schloss als vergessen. Ein Schloss, das + nie verfällt, blockiert beim nächsten Stromausfall für immer -- und + was blockiert, wird abgeschafft. */ +const SCHLOSS_FRIST_MS = 3 * 60 * 60 * 1000; + +const zweiZiffern = (n) => String(n).padStart(2, "0"); +function zeitWort(d = new Date()) { + return `${zweiZiffern(d.getDate())}.${zweiZiffern(d.getMonth() + 1)}.${d.getFullYear()}` + + ` um ${zweiZiffern(d.getHours())}:${zweiZiffern(d.getMinutes())}`; +} + +function schlossNehmen() { + if (existsSync(SCHLOSS)) { + let alter = Infinity; + try { alter = Date.now() - statSync(SCHLOSS).mtimeMs; } catch { /* egal */ } + if (alter < SCHLOSS_FRIST_MS) { + let wer = ""; + try { wer = readFileSync(SCHLOSS, "utf8").trim(); } catch { /* egal */ } + return { ok: false, grund: `Es läuft schon einer (seit ${Math.round(alter / 60000)} min, ${wer}).` }; + } + /* Älter als die Frist: Der gehört niemandem mehr. */ + try { unlinkSync(SCHLOSS); } catch { /* egal */ } + } + try { + writeFileSync(SCHLOSS, `PID ${process.pid}, gestartet ${zeitWort()}\n`, { flag: "wx" }); + return { ok: true }; + } catch { + return { ok: false, grund: "Das Schloss ließ sich nicht setzen." }; + } +} +function schlossAbgeben() { try { unlinkSync(SCHLOSS); } catch { /* egal */ } } + +function standLesen() { + try { return JSON.parse(readFileSync(ZUSTAND, "utf8")); } catch { return null; } +} + +/* --------------------------------------------------------------------- + Der Lauf selbst -- alles-pruefen.mjs macht die eigentliche Arbeit. + + KEIN ZWEITER LÄUFER: Dort stecken die Frist je Datei, das Zählen der + Einzelpunkte und die Regel "null Prüfungen ist ein Fehler". Das hier + noch einmal zu bauen hiesse, zwei Rechnungen über dieselbe Frage zu + haben -- und eine davon wäre irgendwann anders. + --------------------------------------------------------------------- */ +function laufen() { + return new Promise((fertig) => { + const start = Date.now(); + const kind = spawn(process.execPath, [join("tools", "alles-pruefen.mjs")], + { cwd: WURZEL, stdio: ["ignore", "pipe", "pipe"] }); + let aus = ""; + kind.stdout.on("data", (d) => { aus += d; }); + kind.stderr.on("data", (d) => { aus += d; }); + kind.on("error", (f) => fertig({ ging: false, grund: String(f?.message || f), aus })); + kind.on("close", (code) => fertig({ + ging: true, code, aus, + minuten: Math.round((Date.now() - start) / 60000), + })); + }); +} + +/** Aus der Ausgabe die Zahlen holen -- gelesen, nicht geschätzt. */ +function auswerten(aus) { + const dateien = aus.match(/Dateien:\s+(\d+) von (\d+)/); + const punkte = aus.match(/Einzelpunkte:\s+(\d+) von (\d+)/); + const rote = []; + for (const treffer of aus.matchAll(/^### (pruef-[\w-]+\.mjs) \(Exitcode (-?\d+)\)$/gm)) { + /* Die erste FEHL-Zeile darunter ist die Auskunft, die man braucht -- + der Rest ist Zusammenhang. */ + const ab = aus.indexOf(treffer[0]) + treffer[0].length; + const stueck = aus.slice(ab, ab + 900); + const erste = (stueck.split("\n").find((z) => /FEHL|Error|error:/.test(z)) || "").trim(); + rote.push({ datei: treffer[1], code: Number(treffer[2]), erste: erste.slice(0, 160) }); + } + return { + dateienOk: dateien ? Number(dateien[1]) : null, + dateien: dateien ? Number(dateien[2]) : null, + punkteOk: punkte ? Number(punkte[1]) : null, + punkte: punkte ? Number(punkte[2]) : null, + rote, + }; +} + +/* --------------------------------------------------------------------- + Die Notiz + --------------------------------------------------------------------- */ +function notizSchreiben({ lage, vorher, minuten, konnteNicht }) { + const jetzt = new Date(); + const zeilen = []; + zeilen.push("# Prüfstand Team Dogi"); + zeilen.push(""); + zeilen.push("*Diese Notiz schreibt `tools/nachtlauf.mjs` — von Hand ändern bringt nichts,"); + zeilen.push("beim nächsten Lauf ist es wieder überschrieben.*"); + zeilen.push(""); + + if (konnteNicht) { + zeilen.push("## ⚠ Konnte nicht nachsehen"); + zeilen.push(""); + zeilen.push(konnteNicht); + zeilen.push(""); + if (vorher?.wann) { + const tage = Math.floor((Date.now() - vorher.zeit) / 86400000); + zeilen.push(`Zuletzt wirklich gemessen: **${vorher.wann}**` + + (tage >= 1 ? ` — das ist **${tage} Tag${tage === 1 ? "" : "e"} her**.` : ".")); + } else { + zeilen.push("Es gibt noch kein früheres Ergebnis."); + } + return zeilen.join("\n") + "\n"; + } + + const rot = lage.rote.length; + zeilen.push(rot + ? `## ${rot} Prüfung${rot === 1 ? "" : "en"} ${rot === 1 ? "ist" : "sind"} rot` + : "## Alles in Ordnung"); + zeilen.push(""); + zeilen.push(`Gemessen am **${zeitWort(jetzt)}**, Dauer ${minuten} Minuten.`); + zeilen.push(""); + zeilen.push("| | jetzt | vorher |"); + zeilen.push("|---|---|---|"); + zeilen.push(`| Dateien in Ordnung | ${lage.dateienOk} von ${lage.dateien} | ` + + `${vorher ? `${vorher.dateienOk} von ${vorher.dateien}` : "—"} |`); + zeilen.push(`| Einzelprüfungen bestanden | ${lage.punkteOk} von ${lage.punkte} | ` + + `${vorher ? `${vorher.punkteOk} von ${vorher.punkte}` : "—"} |`); + zeilen.push(""); + + /* EIN RÜCKGANG DER ANZAHL IST EIN BEFUND, AUCH WENN ALLES GRÜN IST. + Am 28.08.2026 meldete ein Lauf 789 statt 804 Prüfungen und war + dabei grün: Acht Prüfungen je Gerät waren stillschweigend + weggefallen -- ausgerechnet die, die die wichtigste Funktion + absichern. Wer nur "bestanden" liest, hätte sie ab da dauerhaft + ungeprüft gelassen. */ + if (vorher && lage.punkte != null && vorher.punkte != null + && lage.punkte < vorher.punkte) { + zeilen.push(`> **Es werden ${vorher.punkte - lage.punkte} Prüfungen WENIGER gemacht als beim letzten Lauf.**`); + zeilen.push("> Das ist auch dann ein Befund, wenn alles grün ist: Eine Prüfung, die"); + zeilen.push("> still nicht mehr läuft, beweist nichts — und niemandem fällt es auf."); + zeilen.push(""); + } + if (vorher && lage.dateien != null && vorher.dateien != null + && lage.dateien < vorher.dateien) { + zeilen.push(`> **Es gibt ${vorher.dateien - lage.dateien} Prüfdatei(en) weniger als beim letzten Lauf.**`); + zeilen.push(""); + } + + if (rot) { + zeilen.push("## Was rot ist"); + zeilen.push(""); + for (const r of lage.rote) { + zeilen.push(`### \`${r.datei}\``); + zeilen.push(""); + zeilen.push(r.erste ? `\`\`\`\n${r.erste}\n\`\`\`` : "*(keine Fehlerzeile gefunden)*"); + zeilen.push(""); + zeilen.push(`Einzeln nachsehen: \`node server/${r.datei}\``); + zeilen.push(""); + } + zeilen.push("**Rot heißt nicht automatisch kaputt.** Dreimal am 23.09.2026 war"); + zeilen.push("die Prüfung veraltet und das Programm richtig — einmal war es"); + zeilen.push("umgekehrt und ein Rollenname stand offen im Netz. Erst nachsehen,"); + zeilen.push("dann entscheiden, wer recht hat."); + } else { + zeilen.push("Keine Datei ist rot. Der nächste Lauf kommt in der nächsten Nacht."); + } + zeilen.push(""); + return zeilen.join("\n") + "\n"; +} + +/* --------------------------------------------------------------------- + Los + --------------------------------------------------------------------- */ +const vorher = standLesen(); +const erzwingen = process.argv.includes("--jetzt"); + +const schloss = erzwingen ? { ok: true } : schlossNehmen(); +if (!schloss.ok) { + /* DER DRITTE AUSGANG. Kein Fehler -- nur: hier war nichts zu messen. */ + mkdirSync(dirname(NOTIZ), { recursive: true }); + writeFileSync(NOTIZ, notizSchreiben({ vorher, konnteNicht: schloss.grund }), "utf8"); + console.log("Konnte nicht nachsehen: " + schloss.grund); + process.exit(0); +} + +let ergebnis; +try { + console.log("Nachtlauf gestartet — das dauert. " + zeitWort()); + ergebnis = await laufen(); +} finally { + if (!erzwingen) schlossAbgeben(); +} + +if (!ergebnis.ging) { + mkdirSync(dirname(NOTIZ), { recursive: true }); + writeFileSync(NOTIZ, notizSchreiben({ + vorher, konnteNicht: `Der Läufer ließ sich nicht starten: ${ergebnis.grund}`, + }), "utf8"); + console.log("Konnte nicht nachsehen: " + ergebnis.grund); + process.exit(0); +} + +const lage = auswerten(ergebnis.aus); + +/* KONNTE DER LAUF ÜBERHAUPT ETWAS MESSEN? Steht keine Zahl in der + Ausgabe, ist etwas anderes schiefgegangen als eine rote Prüfung -- + und "0 von 0 in Ordnung" wäre die beruhigendste Lüge von allen. */ +if (lage.dateien == null || lage.punkte == null || lage.punkte === 0) { + mkdirSync(dirname(NOTIZ), { recursive: true }); + writeFileSync(NOTIZ, notizSchreiben({ + vorher, + konnteNicht: "Der Lauf hat keine einzige Zahl geliefert — vermutlich ist er" + + " abgebrochen, bevor er anfangen konnte.\n\nLetzte Ausgabe:\n\n```\n" + + ergebnis.aus.slice(-700) + "\n```", + }), "utf8"); + console.log("Konnte nicht nachsehen: keine Zahlen in der Ausgabe."); + process.exit(0); +} + +mkdirSync(dirname(NOTIZ), { recursive: true }); +writeFileSync(NOTIZ, notizSchreiben({ lage, vorher, minuten: ergebnis.minuten }), "utf8"); + +/* Der Stand für den nächsten Vergleich -- erst NACH der Notiz, damit + ein Absturz beim Schreiben nicht den alten Vergleichswert wegnimmt. */ +writeFileSync(ZUSTAND, JSON.stringify({ + wann: zeitWort(), zeit: Date.now(), + dateien: lage.dateien, dateienOk: lage.dateienOk, + punkte: lage.punkte, punkteOk: lage.punkteOk, + rote: lage.rote.map((r) => r.datei), +}, null, 1), "utf8"); + +/* Die ganze Ausgabe daneben -- für den Fall, dass die Notiz nicht + reicht. Sie wird jedes Mal überschrieben; zwei Läufe zu vergleichen + ist Sache der Zahlen oben, nicht eines wachsenden Protokolls. */ +writeFileSync(join(WURZEL, "tools", ".nachtlauf-letzte-ausgabe.txt"), ergebnis.aus, "utf8"); + +console.log(`Fertig nach ${ergebnis.minuten} min: ` + + `${lage.dateienOk} von ${lage.dateien} Dateien, ` + + `${lage.punkteOk} von ${lage.punkte} Einzelprüfungen.`); +console.log(`Ergebnis steht in: ${NOTIZ}`); +process.exit(lage.rote.length ? 1 : 0); diff --git a/workspace/assets/css/entwicklung.css b/workspace/assets/css/entwicklung.css index 4c59bcbc..776e8b51 100644 --- a/workspace/assets/css/entwicklung.css +++ b/workspace/assets/css/entwicklung.css @@ -1533,7 +1533,7 @@ /* Ein Haken, kein Aufzählungspunkt: Was trägt, ist etwas Erledigtes, und das sieht man an der Form schneller als am Wort. */ .b-fazit__punkt::before { - content: "¹3"; + content: "\2713"; position: absolute; left: 0; top: 0; color: var(--gut, #5fc08a); font-weight: 700;