diff --git a/server/crew-adresse.js b/server/crew-adresse.js
index 238c5015..2f18360b 100644
--- a/server/crew-adresse.js
+++ b/server/crew-adresse.js
@@ -386,6 +386,7 @@ export const GEHOERT_ZU_ADRESSE = [
(nurTeamDogi in workspace-notizen.js) -- die Seite ist nur HTML,
die Daten sind die Sache. */
{ datei: "/workspace/notizen.html", zuhause: istCrewAdresse },
+ { datei: "/workspace/reaktion.html", zuhause: istCrewAdresse },
];
/** Wird hier eine Wand verlangt, die zu einer ANDEREN Tuer gehoert? */
diff --git a/server/index.js b/server/index.js
index 74297970..91d24dee 100644
--- a/server/index.js
+++ b/server/index.js
@@ -39,6 +39,7 @@ import { befindenRouter } from "./workspace-befinden.js";
import { hilfeRouter } from "./workspace-hilfe.js";
import { supportRouter } from "./workspace-support.js";
import { notizenRouter } from "./workspace-notizen.js";
+import { reaktionRouter } from "./workspace-reaktion.js";
import { unterstuetzungRouter } from "./workspace-unterstuetzung.js";
import { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
@@ -103,9 +104,24 @@ app.use((req, res, next) => {
`(self)` und nicht `*`: Nur diese Seite selbst, keine
eingebetteten Fremdinhalte. */
- const chatSeite = /^\/workspace\/chat\.html/.test(req.path);
+ /* DIE ZWEITE STELLE KAM AM 28.09.2026 DAZU: die Reaction. Sie
+ zeigt die Kamera des Hosts und bis zu zwei Gaeste, ueber
+ denselben Weg wie der Anruf im Chat.
+
+ Und sie kam als MENGE dazu, nicht als zweiter Sonderfall: Beim
+ Chat stand die Bedingung als Einzelfall im Code, und ein zweiter
+ Einzelfall daneben ist die Bauweise, bei der der dritte
+ vergessen wird. Vergessen heisst hier: Die Sendung startet, alle
+ sind da, und die Kamera geht nicht an -- mit einer Meldung, die
+ auf die falsche Faehrte fuehrt.
+
+ pruef-kamera-richtlinie.mjs haelt die Menge gegen den Quelltext:
+ Jede Seite, deren Skripte `getUserMedia` aufrufen, muss hier
+ stehen -- und jede andere darf es nicht. */
+ const MIT_KAMERA = [/^\/workspace\/chat\.html/, /^\/workspace\/reaktion\.html/];
+ const darfKamera = MIT_KAMERA.some((m) => m.test(req.path));
res.setHeader("Permissions-Policy",
- chatSeite
+ darfKamera
? "geolocation=(), microphone=(self), camera=(self), payment=()"
: "geolocation=(), microphone=(), camera=(), payment=()");
@@ -317,6 +333,7 @@ app.use(supportRouter);
sieht nur seinen eigenen -- auch DogFather. Begruendung im Kopf von
notiz-tabellen.js. */
app.use(notizenRouter);
+app.use(reaktionRouter);
/* Unterstützen (24.09.2026): die Wege, auf denen die Community etwas
beitragen kann – lesen alle, ändern nur DogFather. */
app.use(unterstuetzungRouter);
diff --git a/server/inhaltsrichtlinie.js b/server/inhaltsrichtlinie.js
index 8089a022..6f00aec8 100644
--- a/server/inhaltsrichtlinie.js
+++ b/server/inhaltsrichtlinie.js
@@ -76,6 +76,25 @@ import { join, normalize } from "node:path";
/* Spotify: die zwei Player im Musik-Feld (main.js, jede Seite). */
const SPOTIFY = "https://open.spotify.com";
+/* ==== YOUTUBE (28.09.2026, fuer die Reaction) ======================
+
+ Die Reaction zeigt ein YouTube-Video, und jeder Zuschauer laedt es
+ SELBST -- weitergesendet wird nur der Spielstand (siehe
+ reaktion-tabellen.js). Dafuer braucht das Dokument drei Dinge:
+
+ RAHMEN der eingebettete Player. `youtube-nocookie.com` setzt
+ keine Werbekennungen, solange niemand abspielt; das ist
+ der Grund, warum diese Adresse zuerst steht.
+ SKRIPT die Einbett-API (`/iframe_api`) liegt auf www.youtube.com
+ und laedt von dort ihren eigentlichen Teil nach. Ohne
+ sie liesse sich der Player nicht fernsteuern -- und genau
+ das ist der Kern: Alle sehen dieselbe Sekunde.
+ BILDER die Vorschaubilder (i.ytimg.com), die im Wartebereich
+ stehen, wenn der Host kein eigenes Bild hinterlegt hat. */
+const YOUTUBE_RAHMEN = "https://www.youtube-nocookie.com https://www.youtube.com";
+const YOUTUBE_SKRIPT = "https://www.youtube.com";
+const YOUTUBE_BILDER = "https://i.ytimg.com https://i9.ytimg.com";
+
/* PayPal: das SDK selbst liegt auf www.paypal.com, Bilder/Schriften der
Knöpfe auf www.paypalobjects.com. Der Bezahlvorgang läuft anschließend
in einem Rahmen von www.paypal.com.
@@ -241,7 +260,7 @@ export function inhaltsrichtlinie(wurzel) {
wenn ohnehin etwas grundlegend nicht stimmt. */
if (!summen) return next();
- const skript = ["'self'", ...summen, PAYPAL_SKRIPT, GOOGLE_SKRIPT].join(" ");
+ const skript = ["'self'", ...summen, PAYPAL_SKRIPT, GOOGLE_SKRIPT, YOUTUBE_SKRIPT].join(" ");
res.setHeader("Content-Security-Policy", [
"default-src 'self'",
@@ -259,7 +278,7 @@ export function inhaltsrichtlinie(wurzel) {
ihn: Der Deploy hätte Erfolg gemeldet, der Server wäre
gestartet, und der Schaden wäre erst jemandem aufgefallen, der
die Seite ansieht. */
- `img-src 'self' data: blob: https://postfach.dogfather-universe.com ${PAYPAL_BILDER}`,
+ `img-src 'self' data: blob: https://postfach.dogfather-universe.com ${PAYPAL_BILDER} ${YOUTUBE_BILDER}`,
"font-src 'self'",
/* Die Seite spricht mit dem eigenen Server und mit dem internen
Bereich unter postfach. -- mehr nicht. Ein eingeschleustes
@@ -302,7 +321,7 @@ export function inhaltsrichtlinie(wurzel) {
pruef-musik.mjs und pruef-kasse.mjs prüfen das im echten Browser
mit -- inklusive der Kasse, die vorher genau hier gescheitert
wäre. */
- `frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN}`,
+ `frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN} ${YOUTUBE_RAHMEN}`,
"worker-src 'self'",
/* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren.
diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs
new file mode 100644
index 00000000..6ed5b052
--- /dev/null
+++ b/server/mess-reaktion.mjs
@@ -0,0 +1,377 @@
+/* =====================================================================
+ WIE DIE REACTION AUSSIEHT (28.09.2026)
+
+ Die Pruefung nebenan misst, was die Schnittstelle antwortet. Das
+ sagt nichts darueber, ob ein Mensch damit einen Abend verbringen
+ moechte. Dieses Werkzeug oeffnet die Seite in einem echten Browser,
+ schaltet sie durch alle drei Zustaende und legt Bilder ab.
+
+ Gemessen wird dabei auch, was man sonst uebersieht:
+ - laeuft etwas in die Konsole?
+ - ragt etwas seitlich hinaus?
+ - ist der Saal so hoch wie das Fenster, oder muss man scrollen?
+
+ NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes
+ Zertifikat -- und am Ende ist alles weg.
+
+ Aufrufen mit: node server/mess-reaktion.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { request } from "node:http";
+import { execFileSync } from "node:child_process";
+import { createServer as httpsServer } from "node:https";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const ordner = mkdtempSync(join(tmpdir(), "mess-reakt-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+const PORT = 5483;
+const HTTPS_PORT = PORT + 1;
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+for (const art of ["uncaughtException", "unhandledRejection"]) {
+ process.on(art, (f) => {
+ console.error(`\nABBRUCH (${art}): ${f?.stack || f}`);
+ process.exit(8);
+ });
+}
+
+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");
+await new Promise((r) => setTimeout(r, 1000));
+
+const CREW = "crew.dogfather-universe.com";
+
+/* ---------- Leute ------------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const kenn = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kenn, new Date().toISOString());
+const ALT = new Date(Date.now() - 200 * 86400000).toISOString();
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", kenn).update(code).digest("hex"), ALT);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" };
+const IDS = {
+ admin: anlegen("DogFather", "admin", CODES.admin),
+ gast: anlegen("Nachtfalter", "gast", CODES.gast),
+ gast2: anlegen("Lenny", "gast", CODES.gast2),
+};
+d.close();
+
+function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) {
+ return new Promise((fertig, schief) => {
+ const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad, method,
+ headers: {
+ Host: CREW, Origin: `https://${CREW}`,
+ ...(keks ? { Cookie: keks } : {}),
+ ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
+ },
+ }, (antwort) => {
+ const teile = [];
+ antwort.on("data", (s) => teile.push(s));
+ antwort.on("end", () => {
+ const text = Buffer.concat(teile).toString("utf8");
+ let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
+ fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
+ });
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+async function anmelden(rolle) {
+ const a = await roh("/workspace/api/anmelden", {
+ method: "POST",
+ rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true },
+ });
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+const k = {
+ admin: await anmelden("admin"),
+ gast: await anmelden("gast"),
+ gast2: await anmelden("gast2"),
+};
+
+/* ---------- Die Buehne fuellen ------------------------------------------ */
+await roh("/workspace/api/reaktion", {
+ method: "PUT", keks: k.admin,
+ rumpf: {
+ titel: "Wir schauen den neuen Trailer",
+ video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(),
+ },
+});
+
+const REDEN = [
+ ["gast", "Bin schon daaa 🐺"],
+ ["gast2", "Ich auch! Hab extra Popcorn geholt"],
+ ["gast", "Wie lange noch?"],
+ ["admin", "Zwei Minuten, ich richte noch die Kamera"],
+ ["gast2", "Kein Stress, wir sitzen bequem"],
+ ["gast", "Letztes Mal war so gut, ich hab geheult"],
+];
+
+/* ---------- Der Vorbau -------------------------------------------------- */
+const schluesselDatei = join(ordner, "p.key");
+const zertDatei = join(ordner, "p.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", schluesselDatei, "-out", zertDatei, "-days", "2",
+ "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
+
+const vorbau = httpsServer({
+ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
+}, (von, nach) => {
+ const weiter = request({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
+ }, (antwort) => {
+ nach.writeHead(antwort.statusCode || 502, antwort.headers);
+ antwort.pipe(nach);
+ });
+ weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
+ von.pipe(weiter);
+});
+await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
+const BASIS = `https://${CREW}:${HTTPS_PORT}`;
+
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch({
+ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
+ /* Kamera und Mikrofon ohne Rueckfrage -- und ein bewegtes
+ Testbild statt eines schwarzen Rechtecks. Ohne das sieht jede
+ Aufnahme aus, als waere die Kamera kaputt. */
+ "--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
+});
+
+/* JEDE GEOEFFNETE SEITE HAELT ZWEI STEHENDE VERBINDUNGEN (der
+ Ereignisstrom der Kopfleiste und der dieser Seite). Ein Browser
+ laesst je Adresse nur wenige davon zu -- wer seine Seiten nicht
+ schliesst, nimmt den spaeteren den Platz weg.
+
+ GEMESSEN: Die Seiten aus den ersten beiden Abschnitten blieben
+ offen, und danach kam Lennys Strom gar nicht mehr zustande. Die
+ Signale gingen ins Leere, und die Kamerafenster blieben schwarz --
+ ein Messfehler, der wie ein Programmfehler aussah. */
+async function alsWer(rolle, breite, hoehe) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: hoehe },
+ ignoreHTTPSErrors: true,
+ permissions: ["camera", "microphone"],
+ });
+ await kontext.addCookies((k[rolle] || "").split("; ").filter(Boolean).map((z) => {
+ const [n, ...r] = z.split("=");
+ return { name: n, value: r.join("="), domain: CREW, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ const anfragen = [];
+ seite.on("request", (r) => { if (/\/api\//.test(r.url())) anfragen.push(r.url().replace(/^https?:\/\/[^/]+/, "")); });
+ seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
+ seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
+ seite.on("response", (a) => {
+ if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
+ });
+ return { kontext, seite, konsole, anfragen };
+}
+
+async function bild(seite, name) {
+ await seite.screenshot({ path: join(HIER, `mess-reaktion-${name}.png`) });
+ console.log(` Bild: server/mess-reaktion-${name}.png`);
+}
+
+async function vermessen(seite, wo) {
+ const m = await seite.evaluate(() => ({
+ ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
+ hoch: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight),
+ stand: document.getElementById("saal")?.dataset.stand || "?",
+ schild: document.getElementById("schild-wort")?.textContent || "",
+ }));
+ console.log(` ${wo}: Stand „${m.stand}", Schild „${m.schild}", `
+ + `seitlich ${m.ueber} px, senkrecht ${m.hoch} px`);
+ return m;
+}
+
+console.log("\n=== ZU ===");
+{
+ const { kontext, seite, konsole } = await alsWer("gast", 1440, 900);
+ await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(900);
+ await vermessen(seite, "Rechner");
+ await bild(seite, "1-zu-rechner");
+ if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | "));
+ await kontext.close();
+}
+
+console.log("\n=== VORBEREITUNG ===");
+await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } });
+for (const [wer, text] of REDEN) {
+ await roh("/workspace/api/reaktion/chat", { method: "POST", keks: k[wer], rumpf: { text } });
+}
+{
+ const { kontext, seite, konsole } = await alsWer("gast", 1440, 900);
+ await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1400);
+ await vermessen(seite, "Rechner");
+ await bild(seite, "2-warte-rechner");
+ if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | "));
+ await kontext.close();
+}
+
+console.log("\n=== LIVE ===");
+await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } });
+await roh("/workspace/api/reaktion/gast", { method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 } });
+
+/* ==== BEIDE SEITEN GLEICHZEITIG ====================================
+ Der Host sendet, die Zuschauerin empfaengt -- das geht nur, wenn
+ beide zur selben Zeit offen sind. */
+const host = await alsWer("admin", 1440, 900);
+await host.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
+await host.seite.waitForTimeout(2500);
+
+const gast2 = await alsWer("gast2", 1280, 800);
+await gast2.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
+await gast2.seite.waitForTimeout(2500);
+
+const zuschauer = await alsWer("gast", 1440, 900);
+await zuschauer.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
+
+/* Verbindungen brauchen ein paar Sekunden: Adressen holen, Angebot,
+ Antwort, Kandidaten. Acht Sekunden sind grosszuegig -- es geht
+ darum, ob es UEBERHAUPT ankommt, nicht wie schnell. */
+await zuschauer.seite.waitForTimeout(8000);
+
+async function bildBreiten(seite) {
+ return seite.evaluate(() => {
+ const raus = {};
+ for (const k2 of document.querySelectorAll("#kameras .kamera")) {
+ const v = k2.querySelector("video");
+ raus[k2.querySelector(".kamera__name")?.textContent || k2.dataset.wer] = {
+ breite: v ? v.videoWidth : -1,
+ strom: !!(v && v.srcObject),
+ laeuft: !!(v && !v.paused),
+ stumm: !!(v && v.muted),
+ };
+ }
+ return raus;
+ });
+}
+
+console.log("\n --- Fliesst ein Bild? ---");
+for (const [wer, w] of [["Host", host.seite], ["Gast", gast2.seite], ["Zuschauerin", zuschauer.seite]]) {
+ const z = await w.evaluate(() => ({
+ url: location.pathname,
+ saal: !!document.getElementById("saal"),
+ stand: document.getElementById("saal")?.dataset.stand,
+ kameras: document.querySelectorAll("#kameras .kamera").length,
+ })).catch((f) => ({ url: "FEHLER " + f.message }));
+ console.log(` ${wer.padEnd(12)} ${z.url} saal=${z.saal} stand=${z.stand} kameras=${z.kameras}`);
+ const q = { Host: host, Gast: gast2, Zuschauerin: zuschauer }[wer];
+ console.log(" Strom-Anfragen: "
+ + q.anfragen.filter((x) => x.includes("strom")).length + " | Signale: " + q.anfragen.filter((x) => x.includes("signal")).length);
+}
+const beiZuschauer = await bildBreiten(zuschauer.seite);
+for (const [name, m] of Object.entries(beiZuschauer)) {
+ const gut = m.breite > 0;
+ console.log(` Zuschauerin sieht ${name.padEnd(12)} `
+ + `${gut ? "BILD " + m.breite + " px" : (m.strom ? "Strom da, aber 0 px" : "NICHTS")}`
+ + ` ${m.laeuft ? "laeuft" : "STEHT"} ${m.stumm ? "stumm" : "mit Ton"}`);
+}
+/* AUCH DER HOST MUSS SEINEN GAST SEHEN. Wenn nur die Zuschauer
+ etwas sehen, redet der Host gegen eine schwarze Kachel -- und
+ merkt nicht, wenn beim Gast die Kamera aus ist. */
+const beimHost = await bildBreiten(host.seite);
+for (const [name, m] of Object.entries(beimHost)) {
+ console.log(` Host sieht ${name.padEnd(12)} `
+ + `${m.breite > 0 ? "BILD " + m.breite + " px" : "NICHTS"}`
+ + ` ${m.laeuft ? "laeuft" : "STEHT"}`);
+}
+const zaehlt = Object.values(beiZuschauer).filter((m) => m.breite > 0).length;
+console.log(` -> ${zaehlt} von ${Object.keys(beiZuschauer).length} Kameras kommen an.`);
+/* DIESE MESSUNG HAT EINEN RUECKGABEWERT -- und zwar wegen dieser
+ einen Zahl. Alles andere kann gruen sein: Die Schnittstelle
+ antwortet, die Seite sieht gut aus, die Verbindung steht. Wenn
+ hier 0 steht, sitzt trotzdem jeder vor einem schwarzen Rechteck.
+
+ Gemessen wurde genau das zweimal: einmal, weil das Angebot
+ hinausging, bevor der Empfaenger zuhoeren konnte -- einmal, weil
+ ein Video mit Ton nicht von allein startet. Beide Male sahen die
+ Bildschirmfotos plausibel aus. */
+let ausgang = 0;
+const beimHostAn = Object.values(beimHost).filter((m) => m.breite > 0).length;
+if (beimHostAn < Object.keys(beimHost).length) {
+ console.log(" ACHTUNG: Der Host sieht nicht alle Kameras.");
+ ausgang = 1;
+}
+if (!zaehlt) {
+ console.log(" ACHTUNG: Es kommt kein einziges Bild an.");
+ ausgang = 1;
+}
+const stehen = [...Object.values(beiZuschauer), ...Object.values(beimHost)]
+ .filter((m) => m.breite > 0 && !m.laeuft).length;
+if (stehen) {
+ console.log(` ACHTUNG: ${stehen} Bild(er) kommen an, stehen aber still `
+ + "-- ein Video mit Ton startet nicht von allein.");
+ ausgang = 1;
+}
+
+await host.seite.click("#pult-griff").catch(() => {});
+await host.seite.waitForTimeout(700);
+await vermessen(host.seite, "Host am Rechner");
+await bild(host.seite, "3-live-host");
+if (host.konsole.length) console.log(" KONSOLE:", host.konsole.slice(0, 4).join(" | "));
+
+await vermessen(zuschauer.seite, "Zuschauerin am Rechner");
+await bild(zuschauer.seite, "4-live-zuschauer");
+if (zuschauer.konsole.length) console.log(" KONSOLE:", zuschauer.konsole.slice(0, 4).join(" | "));
+
+await host.kontext.close();
+await gast2.kontext.close();
+await zuschauer.kontext.close();
+
+{
+ const handy = await alsWer("gast2", 390, 844);
+ await handy.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
+ await handy.seite.waitForTimeout(4000);
+ await vermessen(handy.seite, "Handy");
+ await bild(handy.seite, "5-live-handy");
+
+ await handy.seite.waitForTimeout(500);
+ await bild(handy.seite, "6-live-handy-chat");
+ const hm = await handy.seite.evaluate(() => {
+ const g = (w) => { const e = document.querySelector(w); if (!e) return "(fehlt)";
+ const r = e.getBoundingClientRect(); const c = getComputedStyle(e);
+ return `${Math.round(r.width)}x${Math.round(r.height)} display=${c.display}`; };
+ return { saal: g("#saal"), kino: g("#teil-live"), leinwand: g(".leinwand"),
+ schiene: g(".schiene"), schieneWert: document.getElementById("teil-live")?.dataset.schiene };
+ });
+ console.log(" Handy-Masse:", JSON.stringify(hm));
+ if (handy.konsole.length) console.log(" KONSOLE:", handy.konsole.slice(0, 4).join(" | "));
+}
+
+await browser.close();
+await new Promise((r) => vorbau.close(r));
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+console.log("\nFertig.");
+process.exit(ausgang);
diff --git a/server/pruef-kamera-richtlinie.mjs b/server/pruef-kamera-richtlinie.mjs
new file mode 100644
index 00000000..0eb2ea59
--- /dev/null
+++ b/server/pruef-kamera-richtlinie.mjs
@@ -0,0 +1,138 @@
+/* =====================================================================
+ WER DIE KAMERA DARF — und wer sie NICHT darf (28.09.2026)
+
+ In index.js steht eine Sperre: Mikrofon und Kamera sind auf JEDER
+ Seite verboten, ausser auf wenigen benannten. Das ist richtig und
+ soll so bleiben -- ohne sie koennte ein eingeschleustes Skript
+ ueberall das Mikrofon anschalten.
+
+ Die Sperre hat aber eine unangenehme Eigenschaft: Wenn sie zu weit
+ geht, merkt es niemand beim Bauen. Die Seite laedt, die Knoepfe
+ sind da, und erst wenn jemand auf „Kamera an" drueckt, kommt eine
+ Meldung, die auf die falsche Faehrte fuehrt -- man sucht dann in
+ den Browsereinstellungen. Genau so ist es am 18.09.2026 beim
+ Anruf passiert und am 28.09.2026 bei der Reaction.
+
+ ---------------------------------------------------------------------
+ DESHALB WIRD HIER NICHT EINE LISTE GEPRUEFT, SONDERN ABGELEITET
+
+ Gesucht wird im Quelltext: Welche Seite laedt ein Skript, das
+ `getUserMedia` aufruft? Genau diese Seiten brauchen die Erlaubnis.
+ Eine Liste, die gegen den Quelltext gehalten wird, kann nicht
+ veralten -- wer morgen eine dritte Seite mit Kamera baut, bekommt
+ hier einen roten Punkt statt in drei Wochen einen Anruf.
+
+ UND DIE GEGENRICHTUNG WIRD MITGEPRUEFT. Eine Seite, die die
+ Erlaubnis hat und sie nicht braucht, ist eine offene Tuer ohne
+ Grund. Auch das faellt hier auf.
+
+ Aufrufen mit: node server/pruef-kamera-richtlinie.mjs
+ ===================================================================== */
+import { readFileSync, readdirSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const WURZEL = join(HIER, "..");
+const SEITEN_ORDNER = join(WURZEL, "workspace");
+const JS_ORDNER = join(SEITEN_ORDNER, "assets", "js");
+
+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);
+
+/* ---------- 1. Welche Skripte fassen die Kamera an? --------------------- */
+melde("=== 1. Welche Skripte die Kamera anfassen ===");
+
+const jsDateien = readdirSync(JS_ORDNER).filter((n) => n.endsWith(".js"));
+const mitKamera = new Set();
+for (const name of jsDateien) {
+ const text = readFileSync(join(JS_ORDNER, name), "utf8");
+ /* Der Aufruf selbst, nicht das Wort in einem Kommentar: Es muss
+ eine Klammer folgen. Sonst zaehlte jede Erklaerung mit, in der
+ `getUserMedia` erwaehnt wird -- und davon gibt es hier viele. */
+ if (/getUserMedia\s*\(/.test(text.replace(/\/\*[\s\S]*?\*\//g, ""))) {
+ mitKamera.add(name);
+ }
+}
+ok(mitKamera.size > 0,
+ `${mitKamera.size} Skript(e) rufen getUserMedia auf: ${[...mitKamera].join(", ")}`);
+
+/* ---------- 2. Welche Seiten laden diese Skripte? ----------------------- */
+melde("");
+melde("=== 2. Welche Seiten sie laden ===");
+
+const seiten = readdirSync(SEITEN_ORDNER).filter((n) => n.endsWith(".html"));
+const brauchen = new Set();
+for (const seite of seiten) {
+ const html = readFileSync(join(SEITEN_ORDNER, seite), "utf8");
+ for (const js of mitKamera) {
+ if (html.includes(`assets/js/${js}`)) { brauchen.add(seite); break; }
+ }
+}
+ok(brauchen.size > 0,
+ `${brauchen.size} Seite(n) brauchen die Erlaubnis: ${[...brauchen].sort().join(", ")}`);
+
+/* ---------- 3. Und was steht in index.js? ------------------------------- */
+melde("");
+melde("=== 3. Der Abgleich ===");
+
+const indexText = readFileSync(join(HIER, "index.js"), "utf8");
+const zeile = indexText.match(/const MIT_KAMERA = \[([^\]]*)\]/);
+ok(!!zeile, "die Menge MIT_KAMERA steht in index.js");
+
+const erlaubt = new Set();
+if (zeile) {
+ for (const m of zeile[1].matchAll(/workspace\\\/([a-z0-9-]+)\\\.html/g)) {
+ erlaubt.add(m[1] + ".html");
+ }
+}
+ok(erlaubt.size > 0, `sie nennt ${erlaubt.size} Seite(n): ${[...erlaubt].sort().join(", ")}`);
+
+const fehlend = [...brauchen].filter((s) => !erlaubt.has(s));
+ok(fehlend.length === 0,
+ fehlend.length
+ ? `FEHLT: ${fehlend.join(", ")} laedt ein Kamera-Skript, steht aber nicht in MIT_KAMERA `
+ + "-- dort geht die Kamera nicht an, ohne dass jemand einen Fehler sieht"
+ : "jede Seite, die die Kamera braucht, steht drin");
+
+const zuviel = [...erlaubt].filter((s) => !brauchen.has(s));
+ok(zuviel.length === 0,
+ zuviel.length
+ ? `ZU VIEL: ${zuviel.join(", ")} hat die Erlaubnis, braucht sie aber nicht `
+ + "-- eine offene Tuer ohne Grund"
+ : "und keine Seite hat sie, ohne sie zu brauchen");
+
+/* ---------- 4. Die Gegenprobe ------------------------------------------
+ Eine Pruefung, die nur bestaetigen kann, bestaetigt nichts. Hier
+ wird erfunden, was passiert waere, haette jemand die Reaction
+ vergessen. */
+melde("");
+melde("=== 4. Gegenprobe ===");
+{
+ const erfunden = new Set([...erlaubt].filter((s) => s !== "reaktion.html"));
+ const haetteGefehlt = [...brauchen].filter((s) => !erfunden.has(s));
+ ok(haetteGefehlt.includes("reaktion.html"),
+ "ohne reaktion.html in der Menge wuerde die Pruefung rot "
+ + `(${haetteGefehlt.join(", ") || "sie wuerde NICHT anschlagen"})`);
+}
+
+/* ---------- 5. Und die Sperre steht ueberhaupt ---------------------------
+ Wenn jemand die Zeile ganz entfernt, waeren Kamera und Mikrofon
+ ueberall offen -- und keine der Pruefungen oben wuerde das sehen,
+ denn beide Mengen waeren dann leer und stimmten wunderbar ueberein. */
+melde("");
+melde("=== 5. Die Sperre selbst ===");
+{
+ ok(/microphone=\(\), camera=\(\)/.test(indexText),
+ "die Vorgabe verbietet Mikrofon und Kamera");
+ ok(/microphone=\(self\), camera=\(self\)/.test(indexText),
+ "und oeffnet sie nur fuer die eigene Seite, nicht fuer fremde Rahmen");
+ ok(/Permissions-Policy/.test(indexText),
+ "der Kopf heisst Permissions-Policy");
+}
+
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
+process.exit(fehler === 0 ? 0 : 1);
diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs
new file mode 100644
index 00000000..f04d8ddd
--- /dev/null
+++ b/server/pruef-reaktion.mjs
@@ -0,0 +1,578 @@
+/* =====================================================================
+ DIE REACTION (28.09.2026)
+
+ Geprueft wird der ganze Weg aus Filipes Kurznotiz:
+
+ Kachel sichtbar -> geschlossen -> Vorbereitung ->
+ Wartebereich/Chat -> Countdown -> LIVE ->
+ Reaction + Gaeste + Chat + PayPal -> Ende
+
+ ---------------------------------------------------------------------
+ WARUM DIESE PRUEFUNG UEBER DIE HAUSADRESSE SPRICHT
+
+ Die Community meldet sich ueber den STILLEN ZUGANG an -- ein Modi
+ oder ein Zuschauer tippt auf irgendeine Kachel und gibt seinen Code
+ ein, der Code allein entscheidet. Dieser Weg ist auf einer
+ Pruefadresse (127.0.0.1) absichtlich abgeschaltet: Er wuerde dort
+ eine messbare Zeitspur hinterlassen, und genau die soll er nicht.
+
+ Folge: Ohne Host-Kopfzeile koennte sich hier kein einziger
+ Zuschauer anmelden -- und eine Pruefung, die ihren Hauptdarsteller
+ nicht hereinbekommt, prueft nichts. `fetch` kann die Kopfzeile
+ nicht setzen, deshalb node:http.
+
+ Aufrufen mit: node server/pruef-reaktion.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { request } from "node:http";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const ordner = mkdtempSync(join(tmpdir(), "ws-reakt-"));
+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 Reaction-Pruefung");
+
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+notbremse(260_000, "pruef-reaktion");
+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);
+process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } });
+
+const CREW = "crew.dogfather-universe.com";
+const AGENTUR = "workspace.dogfather-universe.com";
+
+/* ---------- Zugaenge anlegen -------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const nun = new Date().toISOString();
+const kennSchluessel = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kennSchluessel, nun);
+
+/* 200 Tage alt: Der Treff hat Stufen, und wer neu ist, darf weniger.
+ Fuer die Reaction spielt das keine Rolle -- aber ein Zugang von
+ heute waere ein Sonderfall, den niemand im Blick hat. */
+const ALT = new Date(Date.now() - 200 * 86400000).toISOString();
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"],
+ ["modi", "Miss"], ["gast", "Nachtfalter"], ["gast2", "Lenny"], ["creator", "Luna"]];
+const CODES = {}; const IDS = {};
+for (const [i, [rolle, name]] of ROLLEN.entries()) {
+ CODES[rolle] = `CODE-R${String(i).padStart(2, "0")}-000001`;
+ IDS[rolle] = anlegen(name, rolle === "gast2" ? "gast" : rolle, CODES[rolle]);
+}
+d.close();
+
+/* ---------- Sprechen mit Hausadresse ------------------------------------ */
+function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) {
+ return new Promise((fertig, schief) => {
+ const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad, method,
+ headers: {
+ Host: host,
+ Origin: origin === undefined ? `https://${host}` : origin,
+ ...(keks ? { Cookie: keks } : {}),
+ ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
+ },
+ }, (antwort) => {
+ const teile = [];
+ antwort.on("data", (s) => teile.push(s));
+ antwort.on("end", () => {
+ const text = Buffer.concat(teile).toString("utf8");
+ let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
+ fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
+ });
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+async function anmelden(rolle, host) {
+ const a = await roh(host, "/workspace/api/anmelden", {
+ method: "POST", rumpf: { rolle: rolle === "gast2" ? "gast" : rolle,
+ code: CODES[rolle], alter_ok: true },
+ });
+ const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+ return a.code === 200 && keks ? keks : null;
+}
+
+const k = {};
+for (const [rolle] of ROLLEN) k[rolle] = await anmelden(rolle, CREW);
+
+/* =======================================================================
+ 1. ALLE KOMMEN HEREIN — auch die Community
+ ======================================================================= */
+melde("=== 1. Wer die Reaction sieht ===");
+{
+ const fehlend = ROLLEN.map(([r]) => r).filter((r) => r !== "creator" && !k[r]);
+ ok(fehlend.length === 0,
+ `alle Rollen des Hauses sind angemeldet (${fehlend.length ? "fehlt: " + fehlend.join(", ") : "5 von 5"})`);
+
+ for (const rolle of ["admin", "hand", "linke", "modi", "gast"]) {
+ const a = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] });
+ ok(a.code === 200 && a.j?.stand === "zu",
+ `${rolle}: sieht die Lage und sie ist „zu" (${a.code}/${a.j?.stand})`);
+ }
+ const s = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast });
+ ok(s.code === 200, `die Seite steht der Community offen (${s.code})`);
+}
+
+/* =======================================================================
+ 2. DAS ANDERE HAUS SIEHT NICHTS
+ ======================================================================= */
+melde("");
+melde("=== 2. Die Agentur kennt sie nicht ===");
+{
+ const kA = await anmelden("admin", AGENTUR);
+ ok(!!kA, "DogFather meldet sich auch auf der Agenturadresse an");
+ const a = await roh(AGENTUR, "/workspace/api/reaktion", { keks: kA });
+ ok(a.code === 404, `und bekommt dort 404 (${a.code})`);
+ const s = await roh(AGENTUR, "/workspace/reaktion.html", { keks: kA });
+ ok(s.code === 404, `auch die Seite gibt es dort nicht (${s.code})`);
+
+ /* Das andere Ende derselben Regel: Auf einer Pruefadresse hat
+ niemand ein Haus, und die Schranke darf das nicht mit dem
+ fremden Haus verwechseln. Genau dieser Fehler ist am 27.09.
+ beim Notizblock passiert. */
+ const kP = await anmelden("admin", "127.0.0.1");
+ const p = await roh("127.0.0.1", "/workspace/api/reaktion", { keks: kP });
+ ok(p.code === 200, `auf der Pruefadresse ist sie offen (${p.code}) -- ohne Haus wird nicht gesperrt`);
+}
+
+/* =======================================================================
+ 3. NUR DER HOST STELLT EIN
+ ======================================================================= */
+melde("");
+melde("=== 3. Wer die Sendung fuehrt ===");
+{
+ for (const rolle of ["hand", "linke", "modi", "gast"]) {
+ const a = await roh(CREW, "/workspace/api/reaktion", {
+ method: "PUT", keks: k[rolle], rumpf: { titel: "Nicht du" },
+ });
+ ok(a.code === 403, `${rolle}: darf nicht einstellen (${a.code})`);
+ }
+ const a = await roh(CREW, "/workspace/api/reaktion", {
+ method: "PUT", keks: k.admin,
+ rumpf: { titel: "Wir schauen den Trailer", video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },
+ });
+ ok(a.code === 200 && a.j?.video === "dQw4w9WgXcQ",
+ `DogFather schon -- und aus der Adresse wird die Kennung (${a.j?.video})`);
+}
+
+/* ---- Die Kennung kommt aus jeder Form, die man wirklich einfuegt ---- */
+{
+ const { videoKennung } = await import("./workspace-reaktion.js");
+ const faelle = [
+ ["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"],
+ ["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
+ ["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
+ ["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
+ ["dQw4w9WgXcQ", "dQw4w9WgXcQ"],
+ ];
+ const sitzt = faelle.filter(([roh2, soll]) => videoKennung(roh2) === soll).length;
+ ok(sitzt === faelle.length, `alle ${faelle.length} Formen der Adresse ergeben die Kennung`);
+ /* Gegenprobe: Was keine Kennung ist, darf auch keine werden -- sonst
+ stuende vor Publikum ein schwarzes Rechteck. */
+ const mist = ["", "https://vimeo.com/12345", "kein-video", "https://www.youtube.com/watch?v=zu_kurz"];
+ ok(mist.every((m) => videoKennung(m) === ""),
+ "und was keine ist, wird auch keine (Gegenprobe)");
+ const b = await roh(CREW, "/workspace/api/reaktion", {
+ method: "PUT", keks: k.admin, rumpf: { video: "https://vimeo.com/12345" },
+ });
+ ok(b.code === 400 && b.j?.fehler === "video_unklar",
+ `eine fremde Adresse wird abgelehnt (${b.code}/${b.j?.fehler})`);
+}
+
+/* =======================================================================
+ 4. DER ABLAUF
+ ======================================================================= */
+melde("");
+melde("=== 4. zu -> vorbereitung -> live -> zu ===");
+{
+ const a = await roh(CREW, "/workspace/api/reaktion/stand", {
+ method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" },
+ });
+ ok(a.code === 200 && a.j?.stand === "vorbereitung", `Vorbereitung geht (${a.j?.stand})`);
+
+ const b = await roh(CREW, "/workspace/api/reaktion/stand", {
+ method: "POST", keks: k.admin, rumpf: { stand: "erfunden" },
+ });
+ ok(b.code === 400, `ein erfundener Stand wird abgelehnt (${b.code})`);
+
+ /* Im Wartebereich darf schon geschrieben werden -- das ist der
+ halbe Sinn der Vorbereitung. */
+ const c = await roh(CREW, "/workspace/api/reaktion/chat", {
+ method: "POST", keks: k.gast, rumpf: { text: "Bin schon da!" },
+ });
+ ok(c.code === 201, `im Wartebereich ist der Chat offen (${c.code})`);
+
+ const e = await roh(CREW, "/workspace/api/reaktion/stand", {
+ method: "POST", keks: k.admin, rumpf: { stand: "live" },
+ });
+ ok(e.code === 200 && e.j?.stand === "live" && e.j?.gestartet_am,
+ `auf Sendung -- mit Startzeit (${e.j?.stand})`);
+}
+
+/* ---- Ohne Video kein Live ---- */
+{
+ await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "zu" } });
+ await roh(CREW, "/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { titel: "Ohne", video: "" } });
+ const a = await roh(CREW, "/workspace/api/reaktion/stand", {
+ method: "POST", keks: k.admin, rumpf: { stand: "live" },
+ });
+ ok(a.code === 400 && a.j?.fehler === "kein_video",
+ `ohne Video laesst sich nicht senden (${a.code}/${a.j?.fehler})`);
+ /* und danach wieder aufbauen */
+ await roh(CREW, "/workspace/api/reaktion", {
+ method: "PUT", keks: k.admin,
+ rumpf: { titel: "Wir schauen den Trailer", video: "dQw4w9WgXcQ" },
+ });
+ await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } });
+}
+
+/* =======================================================================
+ 5. WER DABEI IST
+ ======================================================================= */
+melde("");
+melde("=== 5. Dabei sein ===");
+{
+ for (const rolle of ["gast", "gast2", "modi"]) {
+ await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k[rolle] });
+ }
+ const a = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
+ ok(a.j?.dabei === 3, `drei sind dabei (${a.j?.dabei})`);
+
+ ok(Array.isArray(a.j?.zuschauer) && a.j.zuschauer.length === 3,
+ "DogFather sieht, WER dabei ist");
+
+ const z = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
+ ok(z.j?.zuschauer === null,
+ "ein Zuschauer bekommt die Namensliste NICHT");
+ ok(z.j?.dabei === 3, `die Zahl sieht er aber (${z.j?.dabei})`);
+
+ /* Jeder Dabeisitzende hat einen Kameraplatz bekommen -- es sind
+ erst drei, und zwoelf waeren erlaubt. */
+ ok(a.j.zuschauer.every((x) => x.sicht === true),
+ "alle drei haben einen Kameraplatz (bei 12 Plaetzen)");
+
+ /* Abmelden: der Weg, den `sendBeacon` beim Schliessen benutzt --
+ POST, nicht DELETE. Beides muss gehen. */
+ const w1 = await roh(CREW, "/workspace/api/reaktion/dabei-weg", { method: "POST", keks: k.modi });
+ ok(w1.code === 200, `abmelden per POST geht (fuer sendBeacon) (${w1.code})`);
+ const n1 = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
+ ok(n1.j?.dabei === 2, `danach sind es zwei (${n1.j?.dabei})`);
+ await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k.modi });
+}
+
+/* =======================================================================
+ 6. GAESTE
+ ======================================================================= */
+melde("");
+melde("=== 6. Ein bis zwei Gaeste ===");
+{
+ const { GAESTE_MAX } = await import("./reaktion-tabellen.js");
+ ok(GAESTE_MAX === 2, `zwei Plaetze, wie in der Notiz (${GAESTE_MAX})`);
+
+ const a = await roh(CREW, "/workspace/api/reaktion/gast", {
+ method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast },
+ });
+ ok(a.code === 201 && a.j?.gaeste?.length === 1, `Gast 1 dazu (${a.code})`);
+ ok(a.j.gaeste[0].platz === 1, `er bekommt Platz 1 (${a.j?.gaeste?.[0]?.platz})`);
+
+ const b = await roh(CREW, "/workspace/api/reaktion/gast", {
+ method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 },
+ });
+ ok(b.code === 201 && b.j?.gaeste?.length === 2, `Gast 2 dazu (${b.code})`);
+
+ const c = await roh(CREW, "/workspace/api/reaktion/gast", {
+ method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi },
+ });
+ ok(c.code === 409 && c.j?.fehler === "gaeste_voll",
+ `ein dritter passt nicht (${c.code}/${c.j?.fehler})`);
+
+ /* EIN GAST BEKOMMT DIE NAMENSLISTE -- und das ist kein Zufall:
+ Ohne sie wuesste er nicht, wen er anrufen soll, und sein Bild
+ kaeme bei keinem Zuschauer an. */
+ const g = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
+ ok(Array.isArray(g.j?.zuschauer),
+ "wer auf der Buehne steht, bekommt die Liste (sonst sieht ihn niemand)");
+ ok(g.j?.ich?.gast === true, "und er weiss, dass er Gast ist");
+
+ /* Stummschalten gilt fuer alle -- das steht am Server, nicht im
+ Browser des Hosts. */
+ const s = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast}/stumm`, {
+ method: "PUT", keks: k.admin, rumpf: { stumm: true },
+ });
+ ok(s.code === 200 && s.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true,
+ "stummschalten wirkt");
+ const s2 = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast2 });
+ ok(s2.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true,
+ "und alle anderen sehen es auch (nicht nur der Host)");
+
+ /* Ein Gast kann sich nicht selbst holen oder entfernen. */
+ const f = await roh(CREW, "/workspace/api/reaktion/gast", {
+ method: "POST", keks: k.gast, rumpf: { person_id: IDS.modi },
+ });
+ ok(f.code === 403, `ein Gast holt niemanden dazu (${f.code})`);
+
+ const w = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast2}`, {
+ method: "DELETE", keks: k.admin,
+ });
+ ok(w.code === 200 && w.j?.gaeste?.length === 1, `entfernen geht (${w.code})`);
+
+ /* Der frei gewordene Platz wird WIEDERVERWENDET. Mit „Anzahl + 1"
+ bliebe Platz 1 fuer immer leer, sobald einmal jemand gegangen
+ ist -- und das faellt erst im dritten Stream auf. */
+ const neu = await roh(CREW, "/workspace/api/reaktion/gast", {
+ method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi },
+ });
+ const plaetze = (neu.j?.gaeste || []).map((x) => x.platz).sort();
+ ok(plaetze.join(",") === "1,2",
+ `der frei gewordene Platz wird wiederverwendet (${plaetze.join(",")})`);
+}
+
+/* =======================================================================
+ 7. DER LIVE-CHAT UND SEINE MODERATION
+ ======================================================================= */
+melde("");
+melde("=== 7. Chat und Moderation ===");
+let fremderBeitrag = 0;
+{
+ const a = await roh(CREW, "/workspace/api/reaktion/chat", {
+ method: "POST", keks: k.gast2, rumpf: { text: "Das ist gut!" },
+ });
+ ok(a.code === 201 && a.j?.id > 0, `schreiben geht (${a.code})`);
+ fremderBeitrag = a.j.id;
+
+ const leer = await roh(CREW, "/workspace/api/reaktion/chat", {
+ method: "POST", keks: k.gast2, rumpf: { text: " " },
+ });
+ ok(leer.code === 400, `ein leerer Beitrag wird abgelehnt (${leer.code})`);
+
+ const liste = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast });
+ ok(liste.code === 200 && liste.j.beitraege.some((b) => b.id === fremderBeitrag),
+ "und steht in der Liste");
+
+ /* Ein Zuschauer nimmt einen FREMDEN Beitrag nicht weg. */
+ const nein = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, {
+ method: "DELETE", keks: k.gast,
+ });
+ ok(nein.code === 403, `ein Zuschauer nimmt fremde Beitraege nicht weg (${nein.code})`);
+
+ /* Sein eigener geht. */
+ const eigen = await roh(CREW, "/workspace/api/reaktion/chat", {
+ method: "POST", keks: k.gast, rumpf: { text: "ups" },
+ });
+ const weg = await roh(CREW, `/workspace/api/reaktion/chat/${eigen.j.id}`, {
+ method: "DELETE", keks: k.gast,
+ });
+ ok(weg.code === 200, `den eigenen schon (${weg.code})`);
+
+ /* Ein Modi moderiert. */
+ const mod = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, {
+ method: "DELETE", keks: k.modi,
+ });
+ ok(mod.code === 200, `ein Modi moderiert (${mod.code})`);
+
+ const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast });
+ ok(!nach.j.beitraege.some((b) => b.id === fremderBeitrag),
+ "und der Beitrag ist aus der Liste");
+
+ /* WEGGENOMMEN UND NICHT GELOESCHT -- wer moderiert, soll spaeter
+ sagen koennen, was er weggenommen hat. */
+ const { DatabaseSync: DB2 } = await import("node:sqlite");
+ const d2 = new DB2(process.env.WORKSPACE_DB, { readOnly: true });
+ const zeile = d2.prepare("SELECT weg_am, weg_von, text FROM reaktion_chat WHERE id = ?")
+ .get(fremderBeitrag);
+ d2.close();
+ ok(!!zeile && !!zeile.weg_am && zeile.weg_von === IDS.modi && zeile.text === "Das ist gut!",
+ "er steht noch da -- mit Zeitpunkt und Namen dessen, der ihn genommen hat");
+}
+
+/* ---- Die Schreibbremse ---- */
+{
+ const { SCHREIB_BREMSE } = await import("./reaktion-tabellen.js");
+ let gebremst = 0;
+ for (let i = 0; i < SCHREIB_BREMSE + 6; i++) {
+ const a = await roh(CREW, "/workspace/api/reaktion/chat", {
+ method: "POST", keks: k.linke, rumpf: { text: "spam " + i },
+ });
+ if (a.code === 429) gebremst++;
+ }
+ ok(gebremst > 0, `die Schreibbremse greift (${gebremst} von ${SCHREIB_BREMSE + 6} abgewiesen)`);
+}
+
+/* =======================================================================
+ 8. DIE VERBINDUNGSDATEN GEHEN NUR AN LEUTE, DIE DABEI SIND
+ ======================================================================= */
+melde("");
+melde("=== 8. Kameras: der Hinweg ===");
+{
+ const a = await roh(CREW, "/workspace/api/reaktion/signal", {
+ method: "POST", keks: k.admin, rumpf: { an: IDS.gast, daten: { typ: "angebot" } },
+ });
+ ok(a.code === 200, `an jemanden, der dabei ist, geht es (${a.code})`);
+
+ /* Die Creatorin ist nicht dabei -- und im anderen Haus. An sie darf
+ nichts gehen, sonst waere die Reaction ein Weg, jemanden
+ anzuklingeln. */
+ const b = await roh(CREW, "/workspace/api/reaktion/signal", {
+ method: "POST", keks: k.admin, rumpf: { an: IDS.creator, daten: { typ: "angebot" } },
+ });
+ ok(b.code === 409 && b.j?.fehler === "nicht_dabei",
+ `an jemanden, der nicht dabei ist, nicht (${b.code}/${b.j?.fehler})`);
+
+ const c = await roh(CREW, "/workspace/api/reaktion/signal", {
+ method: "POST", keks: k.admin, rumpf: { an: IDS.gast },
+ });
+ ok(c.code === 400, `ohne Inhalt wird abgelehnt (${c.code})`);
+}
+
+/* =======================================================================
+ 9. DAS ENDE RAEUMT AUF
+ ======================================================================= */
+melde("");
+melde("=== 9. Beenden ===");
+{
+ const vorher = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin });
+ ok(vorher.j.beitraege.length > 0, `vor dem Ende steht etwas im Chat (${vorher.j.beitraege.length})`);
+
+ const a = await roh(CREW, "/workspace/api/reaktion/stand", {
+ method: "POST", keks: k.admin, rumpf: { stand: "zu" },
+ });
+ ok(a.code === 200 && a.j?.stand === "zu", `beenden geht (${a.j?.stand})`);
+ ok(a.j.gaeste.length === 0, "die Gaeste sind entlassen");
+ ok(a.j.dabei === 0, "und niemand sitzt mehr im Saal");
+
+ const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin });
+ ok(nach.j.beitraege.length === 0,
+ "der Live-Chat dieser Sendung ist abgeraeumt -- die naechste faengt nicht mit den Rufen der letzten an");
+
+ /* Was gelaufen ist, steht im Verlauf. */
+ const { DatabaseSync: DB3 } = await import("node:sqlite");
+ const d3 = new DB3(process.env.WORKSPACE_DB, { readOnly: true });
+ const v = d3.prepare("SELECT * FROM reaktion_verlauf ORDER BY id DESC LIMIT 1").get();
+ d3.close();
+ ok(!!v && v.titel === "Wir schauen den Trailer" && v.beitraege > 0,
+ `die Sendung steht im Verlauf (${v?.titel}, ${v?.beitraege} Beitraege)`);
+
+ /* Bei „zu" ist der Chat zu -- sonst redeten die Leute in einen
+ leeren Saal. */
+ const c = await roh(CREW, "/workspace/api/reaktion/chat", {
+ method: "POST", keks: k.gast, rumpf: { text: "Hallo?" },
+ });
+ ok(c.code === 409 && c.j?.fehler === "geschlossen",
+ `und schreiben geht nicht mehr (${c.code}/${c.j?.fehler})`);
+}
+
+/* =======================================================================
+ 10. DIE KACHEL, DIE RECHTE UND DIE INHALTSRICHTLINIE
+ ======================================================================= */
+melde("");
+melde("=== 10. Was um die Seite herum stimmen muss ===");
+{
+ const { SEITEN } = await import("./rechte.js");
+ const erlaubt = SEITEN["/workspace/reaktion.html"] || [];
+ ok(erlaubt.includes("gast"),
+ `die Community steht in der Rechtetafel (${erlaubt.join(", ")})`);
+
+ /* Die Kachel. Gesucht wird sie am ZIEL und nicht am Namen -- Namen
+ haben sich in diesem Haus schon zweimal geaendert. */
+ const w = await import("./workspace.js");
+ const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW);
+ const kachel = (alle || []).find((b) => b.ziel === "reaktion.html");
+ ok(!!kachel, "die Community hat die Reaction-Kachel");
+ ok(kachel?.live === true,
+ "sie traegt das Merkmal `live` statt einer Ton-Nummer (der Farbraum ist bei 46 voll)");
+ ok(!kachel?.ton, "und ausdruecklich KEINE Ton-Nummer");
+
+ /* DAS MERKMAL HEISST NICHT `data-live`. Das ist seit dem 21.09.
+ vergeben -- die Draussen-Kachel bekommt es, sobald Filipe auf
+ Twitch sendet. Haetten beide denselben Namen, waere der
+ Draussen-Kachel waehrend jedes Streams die Farbe abhanden
+ gekommen. */
+ const startJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "start.js"), "utf8");
+ ok(/dataset\.sendung = 'ja'/.test(startJs),
+ "die Wand setzt `data-sendung` (und nicht `data-live`, das ist vergeben)");
+ const startCss = readFileSync(join(HIER, "..", "workspace", "assets", "css", "start.css"), "utf8");
+ ok(!/\.kachel\[data-live="ja"\]\s*\{\s*--ton/.test(startCss),
+ "und kein Stil greift der Draussen-Kachel ihre Farbe ab");
+
+ /* DIE INHALTSRICHTLINIE. Ohne YouTube darin startet die Sendung,
+ alle sind da, und die Leinwand bleibt schwarz -- ohne
+ Fehlermeldung, nur ein Eintrag in der Browser-Konsole. Genau so
+ ist am 27.08.2026 der Musik-Knopf ausgefallen. */
+ const seite = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast });
+ const regel = String(seite.kopf["content-security-policy"] || "");
+ ok(/frame-src[^;]*youtube-nocookie\.com/.test(regel),
+ "der YouTube-Rahmen ist erlaubt");
+ ok(/script-src[^;]*www\.youtube\.com/.test(regel),
+ "und die Einbett-API darf geladen werden");
+ ok(/img-src[^;]*ytimg\.com/.test(regel),
+ "und die Vorschaubilder auch");
+}
+
+/* =======================================================================
+ 11. KEIN WEG HEISST ZWEIMAL GLEICH
+ ======================================================================= */
+melde("");
+melde("=== 11. Keine doppelten Wege ===");
+{
+ /* Beim Notizblock hat genau das eine Stunde gekostet:
+ `DELETE /workspace/api/notizen/:id` gab es schon woanders, und
+ express nimmt schweigend die erste passende Route. */
+ const { default: app } = await import("./index.js");
+ const wege = [];
+ const sammeln = (stapel, vorn = "") => {
+ for (const s of stapel) {
+ if (s.route) {
+ for (const m of Object.keys(s.route.methods)) {
+ wege.push(`${m.toUpperCase()} ${vorn}${s.route.path}`);
+ }
+ } else if (s.handle?.stack) {
+ sammeln(s.handle.stack, vorn);
+ }
+ }
+ };
+ sammeln(app._router.stack);
+ ok(wege.length > 300, `${wege.length} Wege im Baum gefunden`);
+ const doppelt = wege.filter((x, i) => wege.indexOf(x) !== i);
+ ok(doppelt.length === 0,
+ doppelt.length ? `DOPPELT: ${[...new Set(doppelt)].join(", ")}` : "kein Weg kommt zweimal vor");
+ const reaktionswege = wege.filter((x) => x.includes("/api/reaktion")).length;
+ ok(reaktionswege >= 11, `die Reaction bringt ${reaktionswege} eigene Wege mit`);
+}
+
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
+process.exit(fehler === 0 ? 0 : 1);
diff --git a/server/reaktion-tabellen.js b/server/reaktion-tabellen.js
new file mode 100644
index 00000000..2911f187
--- /dev/null
+++ b/server/reaktion-tabellen.js
@@ -0,0 +1,169 @@
+/* =====================================================================
+ DIE REACTION — die Tabellen (28.09.2026)
+
+ Filipe hat eine Kurznotiz geschickt: eine Reaction-Kachel, die
+ fuer alle sichtbar ist, normalerweise geschlossen; bei
+ Vorbereitung ein Vorschaubild und ein Countdown; ein paar Minuten
+ vorher geht der Wartebereich auf, damit die Leute schon
+ dasitzen und schreiben koennen; beim Start laufen YouTube-Video
+ und Host-Kamera gleichzeitig; ein bis zwei Gaeste mit Kamera und
+ Mikrofon; getrennte Lautstaerken; ein Live-Chat mit Moderation;
+ und ein Weg, den Host zu unterstuetzen.
+
+ ---------------------------------------------------------------------
+ EINE SENDUNG, NICHT VIELE
+
+ Es gibt genau EINE Reaction-Zeile. Sie wird nicht angelegt und
+ geloescht, sie wechselt ihren Stand: zu -> vorbereitung -> live ->
+ zu. Der Grund ist nicht Bequemlichkeit, sondern Wahrheit: Eine
+ Kachel, die „offen" oder „geschlossen" anzeigt, muss auf EINE
+ Zeile zeigen koennen. Bei einer Liste von Sendungen waere die
+ erste Frage jedes Mal „welche denn?", und die Antwort darauf haette
+ jede der sieben Stellen einzeln gegeben, die den Stand anzeigen.
+
+ Was vorbei ist, wandert nach `reaktion_verlauf`. Damit bleibt die
+ Geschichte erhalten, ohne dass die Gegenwart mehrdeutig wird.
+
+ ---------------------------------------------------------------------
+ WARUM DAS VIDEO NICHT UEBER DEN SERVER LAEUFT
+
+ Naheliegend waere: der Host spielt das YouTube-Video ab, und alle
+ sehen seinen Bildschirm. Das waere aus zwei Gruenden falsch.
+
+ Erstens rechtlich: Ein YouTube-Video an Dritte weiterzusenden ist
+ eine oeffentliche Wiedergabe und damit genau die Sache, fuer die
+ Kanaele gesperrt werden. Jeder Zuschauer laedt das Video deshalb
+ SELBST von YouTube -- so wie bei jeder Watch-Party.
+
+ Zweitens technisch: Ein weitergesendetes Video kostet Bandbreite
+ und Qualitaet. Uebertragen wird stattdessen nur, WO das Video
+ gerade steht (Kennung, laeuft/pausiert, Sekunde). Das sind ein paar
+ Byte, und jeder sieht es in voller Qualitaet.
+
+ ---------------------------------------------------------------------
+ DIE KAMERAS LAUFEN DIREKT VON MENSCH ZU MENSCH
+
+ Der Anrufteil des Hauses (workspace-anruf.js, assets/js/anruf.js)
+ macht das seit dem 18.09. und hat STUN/TURN eingerichtet. Die
+ Reaction benutzt denselben Weg, nur mit mehr Empfaengern: Der Host
+ baut zu jedem Zuschauer eine eigene Verbindung auf.
+
+ DAS HAT EINE GRENZE, UND SIE IST GEMESSEN, NICHT GERATEN. Jede
+ Verbindung kostet den Host Upload. Bei 360p und rund 350 kbit/s
+ sind zwoelf Zuschauer etwa 4 Mbit/s -- das traegt eine normale
+ Leitung. Darueber schaltet die Sendung von selbst auf Ton um: Wer
+ keine Kamera mehr bekommt, hoert alles, sieht das Video und kann
+ schreiben. Das ist ehrlicher als eine Verbindung, die stockt, und
+ es ist sichtbar: Die Zahl steht im Regiepult.
+
+ Heute sind es elf Menschen im ganzen Haus (gemessen am 28.09.2026:
+ 1 admin, 1 hand, 1 linke, 4 modi, 4 gast). Die Grenze ist also
+ weit weg -- sie steht trotzdem hier, weil sie sonst erst auffaellt,
+ wenn es zu spaet ist.
+ ===================================================================== */
+
+/** Wie lange ein beendeter Verlaufseintrag aufgehoben wird. */
+export const VERLAUF_TAGE = 180;
+
+/** Hoechstens so viele Zuschauer bekommen Kamerabild. Darueber: nur Ton. */
+export const SICHT_PLAETZE = Number(process.env.REAKTION_SICHT_PLAETZE || 12);
+
+/** Hoechstens so viele Gaeste gleichzeitig (Filipes Notiz: 1-2). */
+export const GAESTE_MAX = 2;
+
+/** Ein Chatbeitrag in der Reaction. */
+export const TEXT_MAX = 400;
+
+/** So viele Beitraege je Minute und Person. */
+export const SCHREIB_BREMSE = 20;
+
+/** Die Staende, die es gibt -- und nur diese. */
+export const STAENDE = ["zu", "vorbereitung", "live"];
+
+/** Die Anordnungen des Bildes. */
+export const LAYOUTS = ["kino", "gleich", "kamera"];
+
+export function reaktionTabellen(d) {
+ d.exec(`
+ /* Genau EINE Zeile. Die Sperre steht in der Tabelle und nicht im
+ Programm: Ein Programm kann man umgehen, eine Bedingung nicht. */
+ CREATE TABLE IF NOT EXISTS reaktion (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ stand TEXT NOT NULL DEFAULT 'zu'
+ CHECK (stand IN ('zu','vorbereitung','live')),
+ titel TEXT NOT NULL DEFAULT '',
+ video TEXT NOT NULL DEFAULT '',
+ vorschau TEXT NOT NULL DEFAULT '',
+ beginnt_am TEXT,
+ host_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ layout TEXT NOT NULL DEFAULT 'kino',
+ laeuft INTEGER NOT NULL DEFAULT 0,
+ sekunde REAL NOT NULL DEFAULT 0,
+ stand_seit TEXT NOT NULL DEFAULT '',
+ gestartet_am TEXT,
+ geaendert TEXT NOT NULL DEFAULT ''
+ );
+
+ /* Wer gerade mit Kamera dabei ist. Der Host steht NICHT hier --
+ er ist an der Sendung selbst vermerkt. Ein Gast, der geht,
+ wird geloescht; wer stumm ist, bleibt stehen. */
+ CREATE TABLE IF NOT EXISTS reaktion_gaeste (
+ person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
+ seit TEXT NOT NULL,
+ stumm INTEGER NOT NULL DEFAULT 0,
+ platz INTEGER NOT NULL DEFAULT 1
+ );
+
+ /* Wer gerade zusieht. Wird beim Verlassen geloescht und von der
+ Pflege entfernt, wenn sich jemand zwei Minuten nicht meldet.
+ Daraus kommt die Zuschauerzahl -- und die Entscheidung, wer
+ noch ein Kamerabild bekommt. */
+ CREATE TABLE IF NOT EXISTS reaktion_dabei (
+ person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
+ seit TEXT NOT NULL,
+ gesehen TEXT NOT NULL,
+ sicht INTEGER NOT NULL DEFAULT 0
+ );
+
+ /* Der Live-Chat. Eigene Tabelle und nicht der Hauschat: Was hier
+ gesagt wird, gehoert zu dieser einen Sendung und ist danach
+ vorbei. Im Hauschat wuerde es die Gespraeche der Leute
+ ueberschwemmen, und die Moderation waere eine andere. */
+ CREATE TABLE IF NOT EXISTS reaktion_chat (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ name TEXT NOT NULL DEFAULT '',
+ rolle TEXT NOT NULL DEFAULT '',
+ text TEXT NOT NULL,
+ erstellt TEXT NOT NULL,
+ weg_am TEXT,
+ weg_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
+ );
+
+ /* Was gelaufen ist. Eine Sendung je Zeile, erst beim Beenden. */
+ CREATE TABLE IF NOT EXISTS reaktion_verlauf (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ titel TEXT NOT NULL DEFAULT '',
+ video TEXT NOT NULL DEFAULT '',
+ host_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ begonnen TEXT,
+ beendet TEXT NOT NULL,
+ zuschauer INTEGER NOT NULL DEFAULT 0,
+ beitraege INTEGER NOT NULL DEFAULT 0
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_reaktion_chat_zeit
+ ON reaktion_chat (weg_am, id);
+ CREATE INDEX IF NOT EXISTS idx_reaktion_dabei_gesehen
+ ON reaktion_dabei (gesehen);
+ `);
+
+ /* DIE EINE ZEILE ANLEGEN, WENN ES SIE NOCH NICHT GIBT.
+
+ `INSERT OR IGNORE` und nicht „erst fragen, dann schreiben": Zwei
+ Anfragen gleichzeitig beim ersten Start wuerden sonst beide
+ „gibt es nicht" lesen und beide schreiben wollen. */
+ d.prepare(`INSERT OR IGNORE INTO reaktion
+ (id, stand, stand_seit, geaendert) VALUES (1, 'zu', ?, ?)`)
+ .run(new Date().toISOString(), new Date().toISOString());
+}
diff --git a/server/rechte.js b/server/rechte.js
index 032ed40f..3bbcd129 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -524,6 +524,11 @@ export const SEITEN = {
Seite gehoert zum Haus von Team Dogi und ist auf der
Agenturadresse ein 404 (GEHOERT_ZU_ADRESSE in crew-adresse.js). */
"/workspace/notizen.html": ["admin", "hand", "linke", "modi"],
+ /* Die Reaction ist fuer ALLE im Haus -- Filipes Notiz sagt
+ woertlich „fuer alle sichtbar". Die Community steht deshalb
+ ausdruecklich mit drin; wer nicht Host ist, sieht dort die
+ Zuschauerseite. */
+ "/workspace/reaktion.html": ["admin", "hand", "linke", "modi", "gast"],
"/workspace/rechte.html": ["admin", "hand"],
};
diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js
index c1610a38..7f5f8438 100644
--- a/server/workspace-aufbewahrung.js
+++ b/server/workspace-aufbewahrung.js
@@ -46,6 +46,8 @@ import { AUFBEWAHRUNG_TAGE as HILFE_TAGE, FALL_STAND } from "./hilfe-tabellen.js
import { AUFBEWAHRUNG_TAGE as SUPPORT_TAGE } from "./support-tabellen.js";
import { supportAufraeumen } from "./workspace-support.js";
import { notizenAufraeumen } from "./workspace-notizen.js";
+import { reaktionAufraeumen } from "./workspace-reaktion.js";
+import { VERLAUF_TAGE } from "./reaktion-tabellen.js";
import { PAPIERKORB_TAGE as NOTIZ_TAGE } from "./notiz-tabellen.js";
import { ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js";
import { unterstuetzungAufraeumen } from "./workspace-unterstuetzung.js";
@@ -323,6 +325,23 @@ export const FRISTEN = [
vorTagen(NOTIZ_TAGE)),
raeumen: () => notizenAufraeumen(),
},
+ {
+ schluessel: "reaktion_verlauf",
+ was: "Verlauf der Reaction-Sendungen",
+ art: "raeumen",
+ frist: `${VERLAUF_TAGE} Tage`,
+ zweck: "Nachsehen können, was wir gemeinsam geschaut haben – "
+ + "und ob überhaupt jemand dabei war.",
+ grundlage: "Art. 5 Abs. 1 lit. e DSGVO – Speicherbegrenzung",
+ wirkung: "Die Zeile verschwindet: Titel, Video, Datum, wie viele "
+ + "zugesehen und wie viel geschrieben wurde. Der Live-Chat einer "
+ + "Sendung ist ohnehin schon beim Beenden weg – was hier "
+ + "läuft, ist nur die Erinnerung, dass es sie gab.",
+ offen: (d) => zaehle(d,
+ "SELECT COUNT(*) AS n FROM reaktion_verlauf WHERE beendet < ?",
+ vorTagen(VERLAUF_TAGE)),
+ raeumen: () => reaktionAufraeumen(),
+ },
{
schluessel: "unterstuetzung_striche",
was: "Tagesstriche der Unterstützen-Seite",
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index ebc40202..d7bf3f2b 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -3797,3 +3797,38 @@ export { chatEreignis };
export function siehtZu(personId) {
return (zuschauer.get(Number(personId)) || []).size > 0;
}
+
+/* =====================================================================
+ DER KANAL FUER ANDERE MODULE (28.09.2026)
+
+ Der Ereignisstrom liegt beim Chat, weil er dort entstanden ist --
+ aber er ist laengst der einzige offene Draht zwischen Server und
+ Browser. `chatEreignis()` schickt an die Teilnehmer eines CHATRAUMS;
+ wer keinen Raum hat, kommt damit nicht weiter.
+
+ Die Reaction hat keinen Chatraum. Sie hat einen Kreis von Leuten,
+ der sich jede Minute aendert -- wer gerade zusieht. Genau dafuer
+ ist das hier: Empfaenger werden uebergeben, nicht nachgeschlagen.
+
+ EIN ZWEITER STROM WAERE DER FALSCHE WEG. Jeder Browser haelt dann
+ zwei offene Verbindungen, jede mit eigenem Puls, eigenem
+ Wiederverbinden und eigener Luecke danach. Und Anker: Manche
+ Browser lassen nur sechs gleichzeitige Verbindungen je Adresse zu --
+ zwei davon fuer Ereignisse wegzugeben, ist teuer.
+ ===================================================================== */
+export function stromAn(personIds, art, daten) {
+ let erreicht = 0;
+ for (const roh of personIds) {
+ const id = Number(roh);
+ for (const antwort of zuschauer.get(id) || []) {
+ try {
+ antwort.write(`event: ${art}
+data: ${JSON.stringify(daten)}
+
+`);
+ erreicht++;
+ } catch { /* die Verbindung raeumt sich selbst ab */ }
+ }
+ }
+ return erreicht;
+}
diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js
new file mode 100644
index 00000000..13352772
--- /dev/null
+++ b/server/workspace-reaktion.js
@@ -0,0 +1,665 @@
+/* =====================================================================
+ DIE REACTION — die Wege (28.09.2026)
+
+ Der Ablauf aus Filipes Notiz, von links nach rechts:
+
+ Kachel sichtbar -> geschlossen -> Vorbereitung ->
+ Wartebereich/Chat -> Countdown -> LIVE ->
+ Reaction + Gaeste + Chat + PayPal -> Ende
+
+ Das sind drei Staende und nicht sieben. „Wartebereich" und
+ „Countdown" sind keine eigenen Zustaende, sondern das, was
+ „vorbereitung" auf dem Bildschirm TUT: Der Chat ist offen, der
+ Countdown laeuft, das Vorschaubild steht. Drei Staende, die sich
+ gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich
+ vier ueberlappen, sind es nicht.
+
+ ---------------------------------------------------------------------
+ WER WAS DARF
+
+ Host = DogFather. Er stellt ein, startet, beendet, holt und
+ entfernt Gaeste, schaltet stumm, bestimmt die Anordnung.
+ Moderation= DogFather, rechte und linke Hand, Modis. Sie koennen
+ Beitraege aus dem Live-Chat nehmen.
+ Gast = wen der Host holt. Jede Rolle des Hauses.
+ Zuschauer = jeder im Team-Dogi-Haus, auch die Community.
+
+ Die Reaction gibt es NUR im Team-Dogi-Haus. Auf der Agenturadresse
+ antwortet jeder Weg mit 404 -- nicht mit 403, denn ein „verboten"
+ verraet, dass es hier etwas gibt.
+
+ ---------------------------------------------------------------------
+ DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER
+
+ Uebertragen wird nur der Spielstand: Kennung, laeuft/pausiert,
+ Sekunde. Jeder Browser laedt das Video selbst von YouTube. Die
+ Begruendung steht in reaktion-tabellen.js.
+
+ Aufrufen laesst sich alles ohne Sonderfall: Wer zusieht, meldet
+ sich alle 30 Sekunden mit `/dabei`. Wer sich zwei Minuten nicht
+ meldet, faellt heraus -- ein sauberes „Tschuess" gibt es im Netz
+ nicht, ein Handy geht einfach aus.
+ ===================================================================== */
+import express from "express";
+import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js";
+import { stromAn } from "./workspace-chat.js";
+import { einstellung } from "./workspace.js";
+import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js";
+import {
+ SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS,
+ VERLAUF_TAGE,
+} from "./reaktion-tabellen.js";
+
+export const reaktionRouter = express.Router();
+
+/** Wer die Sendung fuehrt. */
+const HOST_ROLLEN = new Set(["admin"]);
+/** Wer Beitraege aus dem Chat nehmen darf. */
+const MODERIERT = new Set(["admin", "hand", "linke", "modi"]);
+
+export const REAKTION_HOST_ROLLEN = HOST_ROLLEN;
+
+const jetzt = () => new Date().toISOString();
+
+/* ---------- Schranken --------------------------------------------------- */
+
+function angemeldet(req, res, next) {
+ const person = sitzungLesen(req);
+ if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
+ req.person = person;
+ next();
+}
+
+/** Nicht das andere Haus.
+ *
+ * Gefragt wird „ist es die Agentur?" und nicht „ist es crew?" --
+ * auf einer Pruefadresse hat niemand ein Haus, und eine Schranke,
+ * die das mit dem fremden Haus verwechselt, macht die Seite dort
+ * still tot. Genau dieser Fehler ist am 27.09. beim Notizblock
+ * passiert und von pruef-handy gefunden worden. */
+function nurTeamDogi(req, res, next) {
+ if (req.person?.haus === "agentur") {
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+ }
+ next();
+}
+
+function nurHost(req, res, next) {
+ if (!HOST_ROLLEN.has(req.person?.rolle)) {
+ return res.status(403).json({ fehler: "nur_host" });
+ }
+ next();
+}
+
+/* Schutz gegen Anfragen von fremden Seiten -- wortgleich zu den
+ uebrigen Modulen. */
+function gleicheHerkunft(req, res, next) {
+ const herkunft = req.get("origin");
+ if (!herkunft) return next();
+ let erlaubt;
+ try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
+ if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
+ next();
+}
+
+/* ---------- Die Schreibbremse -------------------------------------------
+ Ein Live-Chat ist der eine Ort im Haus, an dem jemand in zehn
+ Sekunden dreissig Zeilen schreiben kann -- aus Begeisterung oder
+ aus Aerger. Die Bremse zaehlt je Person und Minute. */
+const bremse = new Map();
+function zuSchnell(personId) {
+ const minute = Math.floor(Date.now() / 60_000);
+ const s = bremse.get(personId);
+ if (!s || s.minute !== minute) { bremse.set(personId, { minute, n: 1 }); return false; }
+ s.n += 1;
+ return s.n > SCHREIB_BREMSE;
+}
+
+/* ---------- Hilfen ------------------------------------------------------ */
+
+const sendung = () => db().prepare("SELECT * FROM reaktion WHERE id = 1").get();
+
+function gaesteListe() {
+ return db().prepare(`
+ SELECT g.person_id AS id, g.stumm, g.platz, p.name, p.rolle
+ FROM reaktion_gaeste g JOIN personen p ON p.id = g.person_id
+ ORDER BY g.platz`).all();
+}
+
+/** Wer gerade dabei ist -- und ob noch ein Kameraplatz frei war.
+ *
+ * ZWEI MINUTEN OHNE LEBENSZEICHEN HEISST WEG. Ein Browser, der
+ * zugeklappt wird, schickt kein „ich gehe". Ohne diese Grenze
+ * waechst die Zuschauerzahl den ganzen Abend und stimmt nie. */
+function dabeiListe() {
+ const grenze = new Date(Date.now() - 120_000).toISOString();
+ db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?").run(grenze);
+ return db().prepare(`
+ SELECT d.person_id AS id, d.sicht, d.seit, p.name, p.rolle
+ FROM reaktion_dabei d JOIN personen p ON p.id = d.person_id
+ ORDER BY d.seit`).all();
+}
+
+/** Alle, die von einer Aenderung erfahren sollen. */
+function empfaenger() {
+ const ids = new Set(dabeiListe().map((d) => d.id));
+ for (const g of gaesteListe()) ids.add(g.id);
+ const s = sendung();
+ if (s?.host_id) ids.add(s.host_id);
+ return [...ids];
+}
+
+/** Was jeder sehen darf -- egal ob Zuschauer, Gast oder Host. */
+function lage(person) {
+ const s = sendung();
+ const gaeste = gaesteListe();
+ const dabei = dabeiListe();
+ const istHost = HOST_ROLLEN.has(person.rolle);
+ const binGast = gaeste.some((g) => g.id === person.id);
+ return {
+ stand: s.stand,
+ titel: s.titel,
+ video: s.video,
+ vorschau: s.vorschau,
+ beginnt_am: s.beginnt_am,
+ layout: s.layout,
+ laeuft: !!s.laeuft,
+ sekunde: s.sekunde,
+ gestartet_am: s.gestartet_am,
+ host: s.host_id
+ ? db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(s.host_id)
+ : null,
+ gaeste: gaeste.map((g) => ({ id: g.id, name: g.name, rolle: g.rolle, stumm: !!g.stumm, platz: g.platz })),
+ dabei: dabei.length,
+ /* ==== WER DIE LISTE BEKOMMT -- UND WARUM GENAU DIESE ==========
+
+ Namen der Zusehenden bekommt, wer auf der BUEHNE steht (Host,
+ Gaeste) oder moderiert. Ein Zuschauer bekommt sie nicht: Er
+ soll nicht ablesen koennen, wer sonst noch zusieht -- im Live
+ sieht man einander am Chat, und das reicht.
+
+ DIE GAESTE STEHEN HIER NICHT AUS HOEFLICHKEIT. Wer sendet,
+ muss wissen, wen er anrufen soll: Zuschauer rufen grundsaetzlich
+ nie an (sonst rufen zwei gleichzeitig an, und beide
+ scheitern -- „glare"). Ohne die Liste kaeme das Bild eines
+ Gastes deshalb bei keinem Zuschauer an. Zwei fuer sich richtige
+ Regeln, die zusammen ein Loch ergeben. */
+ zuschauer: (istHost || binGast || MODERIERT.has(person.rolle))
+ ? dabei.map((d) => ({ id: d.id, name: d.name, rolle: d.rolle, sicht: !!d.sicht }))
+ : null,
+ ich: {
+ id: person.id,
+ name: person.name,
+ rolle: person.rolle,
+ host: istHost,
+ gast: binGast,
+ moderiert: MODERIERT.has(person.rolle),
+ sicht: !!(dabei.find((d) => d.id === person.id)?.sicht),
+ },
+ plaetze: SICHT_PLAETZE,
+ gaeste_max: GAESTE_MAX,
+ text_max: TEXT_MAX,
+ };
+}
+
+function melden(art, daten) {
+ stromAn(empfaenger(), art, daten);
+}
+
+/* ======================================================================
+ DIE WEGE
+ ====================================================================== */
+reaktionRouter.use("/workspace/api/reaktion", angemeldet, nurTeamDogi, gleicheHerkunft);
+
+/** Wie steht es? — der eine Aufruf, den jede Seite beim Laden macht. */
+reaktionRouter.get("/workspace/api/reaktion", (req, res) => {
+ try {
+ res.json(lage(req.person));
+ } catch (fehler) {
+ console.error("[reaktion] Lage:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Der Host stellt ein: Titel, Video, Vorschaubild, Beginn. */
+reaktionRouter.put("/workspace/api/reaktion", nurHost, express.json({ limit: "32kb" }),
+ (req, res) => {
+ try {
+ const titel = String(req.body?.titel ?? "").trim().slice(0, 120);
+ const video = videoKennung(String(req.body?.video ?? ""));
+ const vorschau = String(req.body?.vorschau ?? "").trim().slice(0, 400);
+ const beginn = req.body?.beginnt_am ? String(req.body.beginnt_am).slice(0, 40) : null;
+
+ if (req.body?.video && !video) {
+ return res.status(400).json({ fehler: "video_unklar" });
+ }
+ /* EIN BEGINN IN DER VERGANGENHEIT IST KEIN BEGINN. Der Countdown
+ zaehlte sonst rueckwaerts ins Minus, und niemand koennte
+ sagen, ob das Absicht war. */
+ if (beginn && Number.isFinite(Date.parse(beginn)) && Date.parse(beginn) < Date.now() - 60_000) {
+ return res.status(400).json({ fehler: "beginn_vorbei" });
+ }
+
+ db().prepare(`UPDATE reaktion SET titel = ?, video = ?, vorschau = ?,
+ beginnt_am = ?, host_id = ?, geaendert = ? WHERE id = 1`)
+ .run(titel, video, vorschau, beginn, req.person.id, jetzt());
+
+ const l = lage(req.person);
+ melden("reaktion", { was: "eingestellt", lage: oeffentlich(l) });
+ res.json(l);
+ } catch (fehler) {
+ console.error("[reaktion] Einstellen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Der Stand wechselt: zu / vorbereitung / live. */
+reaktionRouter.post("/workspace/api/reaktion/stand", nurHost, express.json({ limit: "4kb" }),
+ (req, res) => {
+ try {
+ const neu = String(req.body?.stand ?? "");
+ if (!STAENDE.includes(neu)) return res.status(400).json({ fehler: "stand_unbekannt" });
+ const s = sendung();
+
+ /* OHNE VIDEO KEIN LIVE. Der Knopf waere sonst da, das Ergebnis
+ ein schwarzes Rechteck -- und die Leute sitzen davor. */
+ if (neu === "live" && !s.video) {
+ return res.status(400).json({ fehler: "kein_video" });
+ }
+
+ const felder = { stand: neu, stand_seit: jetzt(), geaendert: jetzt() };
+ if (neu === "live" && s.stand !== "live") {
+ felder.gestartet_am = jetzt();
+ felder.laeuft = 1;
+ felder.sekunde = 0;
+ }
+ if (neu === "zu" && s.stand === "live") {
+ /* WAS VORBEI IST, WANDERT IN DEN VERLAUF -- und der Live-Chat
+ dieser Sendung ist damit Geschichte. Wuerde er stehen
+ bleiben, faenge die naechste Sendung mit den Rufen der
+ letzten an. */
+ const zahl = db().prepare("SELECT COUNT(*) AS n FROM reaktion_chat WHERE weg_am IS NULL").get();
+ db().prepare(`INSERT INTO reaktion_verlauf
+ (titel, video, host_id, begonnen, beendet, zuschauer, beitraege)
+ VALUES (?,?,?,?,?,?,?)`)
+ .run(s.titel, s.video, s.host_id, s.gestartet_am, jetzt(),
+ dabeiListe().length, zahl?.n || 0);
+ db().exec("DELETE FROM reaktion_chat");
+ db().exec("DELETE FROM reaktion_gaeste");
+ felder.laeuft = 0;
+ felder.sekunde = 0;
+ felder.gestartet_am = null;
+ }
+ if (neu === "zu") db().exec("DELETE FROM reaktion_dabei");
+
+ const spalten = Object.keys(felder).map((k) => `${k} = ?`).join(", ");
+ db().prepare(`UPDATE reaktion SET ${spalten} WHERE id = 1`)
+ .run(...Object.values(felder));
+
+ protokolliere(req.person.id, "reaktion_stand", `${s.stand} -> ${neu}`, echteIp(req));
+ const l = lage(req.person);
+ melden("reaktion", { was: "stand", lage: oeffentlich(l) });
+ res.json(l);
+ } catch (fehler) {
+ console.error("[reaktion] Stand:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Ich bin da / ich bin noch da. Alle 30 Sekunden. */
+reaktionRouter.post("/workspace/api/reaktion/dabei", express.json({ limit: "2kb" }),
+ (req, res) => {
+ try {
+ const s = sendung();
+ if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" });
+
+ const schon = db().prepare("SELECT * FROM reaktion_dabei WHERE person_id = ?")
+ .get(req.person.id);
+ if (schon) {
+ db().prepare("UPDATE reaktion_dabei SET gesehen = ? WHERE person_id = ?")
+ .run(jetzt(), req.person.id);
+ } else {
+ /* DER KAMERAPLATZ WIRD BEIM EINTRETEN VERGEBEN, nicht beim
+ Zeichnen. Sonst haetten zwei gleichzeitig eintretende
+ Zuschauer denselben letzten Platz gelesen und beide
+ gerechnet, sie haetten ihn. */
+ const mitSicht = db().prepare(
+ "SELECT COUNT(*) AS n FROM reaktion_dabei WHERE sicht = 1").get()?.n || 0;
+ db().prepare("INSERT INTO reaktion_dabei (person_id, seit, gesehen, sicht) VALUES (?,?,?,?)")
+ .run(req.person.id, jetzt(), jetzt(), mitSicht < SICHT_PLAETZE ? 1 : 0);
+ melden("reaktion", { was: "dabei", lage: oeffentlich(lage(req.person)) });
+ }
+ res.json(lage(req.person));
+ } catch (fehler) {
+ console.error("[reaktion] Dabei:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+function abmelden(req, res) {
+ try {
+ db().prepare("DELETE FROM reaktion_dabei WHERE person_id = ?").run(req.person.id);
+ melden("reaktion", { was: "weg", lage: oeffentlich(lage(req.person)) });
+ res.json({ ok: true });
+ } catch (fehler) {
+ console.error("[reaktion] Weg:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+}
+
+reaktionRouter.delete("/workspace/api/reaktion/dabei", abmelden);
+
+/* DERSELBE WEG NOCH EINMAL ALS POST.
+
+ Beim Schliessen des Fensters wird ein gewoehnlicher Abruf
+ abgebrochen -- dafuer gibt es `navigator.sendBeacon`. Der schickt
+ aber IMMER POST; ein DELETE waere nie angekommen, und jeder haette
+ danach zwei Minuten lang als anwesend gegolten. Die Zuschauerzahl
+ waere den ganzen Abend gestiegen und nie gefallen.
+
+ Kein zweiter Code, nur ein zweiter Eingang: Beide zeigen auf
+ dieselbe Funktion. */
+reaktionRouter.post("/workspace/api/reaktion/dabei-weg", abmelden);
+
+/* ---------- Die Verbindungsadressen -------------------------------------
+ STUN sagt einem Browser, wie er von aussen aussieht; TURN leitet
+ weiter, wenn eine direkte Verbindung nicht zustande kommt.
+
+ WARUM DIE REACTION EINE EIGENE TUER HAT: Vor den Anruf-Wegen steht
+ `darfTelefonieren`, und die Community ist dort ausdruecklich nicht
+ dabei -- ein Zuschauer soll kein Teammitglied anklingeln koennen.
+ Das ist richtig und bleibt. Hier sitzt derselbe Mensch aber im
+ Saal, und ohne Adressen bliebe sein Bild schwarz.
+
+ Dieselbe Einstellung, dieselben Helfer, andere Tuer. Und die Tuer
+ ist nur offen, solange etwas laeuft: Bei „zu" gibt es nichts zu
+ holen -- sonst waere das ein dauerhaft offener Weg zu den
+ TURN-Zugangsdaten fuer jeden im Haus. */
+reaktionRouter.get("/workspace/api/reaktion/adressen", (req, res) => {
+ try {
+ if (sendung().stand === "zu") return res.json({ adressen: [] });
+ let liste = [];
+ try { liste = JSON.parse(einstellung("anruf_adressen") || "[]"); } catch { liste = []; }
+ /* Als Kennung die Personennummer und nicht der Name -- sie landet
+ im Protokoll des TURN-Servers, und das ist kein Ort fuer
+ Klarnamen. Wortgleich zum Anrufteil. */
+ const { adressen } = mitZugangsdaten(wegeAusfaechern(liste), `r${req.person.id}`);
+ res.json({ adressen });
+ } catch (fehler) {
+ console.error("[reaktion] Adressen:", fehler?.message);
+ /* KEIN 503. Ohne eigene Adressen nimmt der Browser seine Vorgabe
+ und die Sendung laeuft weiter -- eine leere Liste ist hier die
+ ehrlichere Antwort als ein Fehler, der nach Ausfall aussieht. */
+ res.json({ adressen: [] });
+ }
+});
+
+/* ---------- Gaeste ------------------------------------------------------ */
+
+reaktionRouter.post("/workspace/api/reaktion/gast", nurHost, express.json({ limit: "4kb" }),
+ (req, res) => {
+ try {
+ const id = Number(req.body?.person_id);
+ if (!Number.isInteger(id) || id < 1) return res.status(400).json({ fehler: "person_unklar" });
+ const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(id);
+ if (!p) return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (id === req.person.id) return res.status(400).json({ fehler: "host_ist_kein_gast" });
+
+ const da = db().prepare("SELECT COUNT(*) AS n FROM reaktion_gaeste").get()?.n || 0;
+ if (da >= GAESTE_MAX) return res.status(409).json({ fehler: "gaeste_voll" });
+
+ /* Der Platz ist die kleinste freie Zahl -- nicht „Anzahl + 1".
+ Wer Gast 1 entfernt und dann jemanden holt, bekaeme sonst
+ wieder Platz 2, und Platz 1 bliebe fuer immer leer. */
+ const belegt = new Set(gaesteListe().map((g) => g.platz));
+ let platz = 1;
+ while (belegt.has(platz)) platz++;
+
+ db().prepare("INSERT OR REPLACE INTO reaktion_gaeste (person_id, seit, stumm, platz) VALUES (?,?,0,?)")
+ .run(id, jetzt(), platz);
+ protokolliere(req.person.id, "reaktion_gast_dazu", `${p.name} (Platz ${platz})`, echteIp(req));
+ const l = lage(req.person);
+ melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
+ stromAn([id], "reaktion", { was: "du_bist_gast", lage: oeffentlich(l) });
+ res.status(201).json(l);
+ } catch (fehler) {
+ console.error("[reaktion] Gast:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+reaktionRouter.delete("/workspace/api/reaktion/gast/:id", nurHost, (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ const weg = db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(id);
+ if (!weg.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const l = lage(req.person);
+ melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
+ stromAn([id], "reaktion", { was: "du_bist_kein_gast", lage: oeffentlich(l) });
+ res.json(l);
+ } catch (fehler) {
+ console.error("[reaktion] Gast weg:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+reaktionRouter.put("/workspace/api/reaktion/gast/:id/stumm", nurHost,
+ express.json({ limit: "2kb" }), (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ const stumm = req.body?.stumm ? 1 : 0;
+ const a = db().prepare("UPDATE reaktion_gaeste SET stumm = ? WHERE person_id = ?")
+ .run(stumm, id);
+ if (!a.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const l = lage(req.person);
+ melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
+ res.json(l);
+ } catch (fehler) {
+ console.error("[reaktion] Stumm:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* ---------- Das Video --------------------------------------------------
+ Der Host sagt, wo das Video steht. Alle anderen folgen.
+
+ GESCHICKT WIRD DER SPIELSTAND, NICHT DAS BILD -- die Begruendung
+ steht in reaktion-tabellen.js. Hier steht nur die Folge davon:
+ Diese Route ist die einzige, die waehrend der Sendung oft laeuft,
+ und sie schreibt deshalb so wenig wie moeglich. */
+reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ limit: "2kb" }),
+ (req, res) => {
+ try {
+ const laeuft = req.body?.laeuft ? 1 : 0;
+ const sekunde = Math.max(0, Number(req.body?.sekunde) || 0);
+ const video = req.body?.video === undefined ? null : videoKennung(String(req.body.video));
+ if (req.body?.video !== undefined && !video) {
+ return res.status(400).json({ fehler: "video_unklar" });
+ }
+ if (video) {
+ db().prepare("UPDATE reaktion SET video = ?, laeuft = ?, sekunde = ?, geaendert = ? WHERE id = 1")
+ .run(video, laeuft, sekunde, jetzt());
+ } else {
+ db().prepare("UPDATE reaktion SET laeuft = ?, sekunde = ? WHERE id = 1")
+ .run(laeuft, sekunde);
+ }
+ const s = sendung();
+ melden("reaktion", {
+ was: "video",
+ video: s.video, laeuft: !!s.laeuft, sekunde: s.sekunde,
+ /* Die Uhrzeit des Servers geht mit: Der Empfaenger rechnet
+ die Laufzeit seit dem Absenden dazu. Ohne das waere jeder
+ genau so weit hinten, wie sein Netz langsam ist. */
+ gesendet: Date.now(),
+ });
+ res.json({ ok: true });
+ } catch (fehler) {
+ console.error("[reaktion] Video:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+reaktionRouter.post("/workspace/api/reaktion/layout", nurHost, express.json({ limit: "2kb" }),
+ (req, res) => {
+ try {
+ const l = String(req.body?.layout ?? "");
+ if (!LAYOUTS.includes(l)) return res.status(400).json({ fehler: "layout_unbekannt" });
+ db().prepare("UPDATE reaktion SET layout = ? WHERE id = 1").run(l);
+ melden("reaktion", { was: "layout", layout: l });
+ res.json({ ok: true, layout: l });
+ } catch (fehler) {
+ console.error("[reaktion] Layout:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* ---------- Der Live-Chat ----------------------------------------------- */
+
+reaktionRouter.get("/workspace/api/reaktion/chat", (req, res) => {
+ try {
+ const reihen = db().prepare(`
+ SELECT id, person_id, name, rolle, text, erstellt
+ FROM reaktion_chat WHERE weg_am IS NULL ORDER BY id DESC LIMIT 120`).all();
+ res.json({ beitraege: reihen.reverse() });
+ } catch (fehler) {
+ console.error("[reaktion] Chat:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+reaktionRouter.post("/workspace/api/reaktion/chat", express.json({ limit: "8kb" }),
+ (req, res) => {
+ try {
+ const s = sendung();
+ if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" });
+ const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
+ if (!text) return res.status(400).json({ fehler: "leer" });
+ if (zuSchnell(req.person.id)) return res.status(429).json({ fehler: "zu_schnell" });
+
+ const { lastInsertRowid } = db().prepare(`INSERT INTO reaktion_chat
+ (person_id, name, rolle, text, erstellt) VALUES (?,?,?,?,?)`)
+ .run(req.person.id, req.person.name, req.person.rolle, text, jetzt());
+ const beitrag = db().prepare(
+ "SELECT id, person_id, name, rolle, text, erstellt FROM reaktion_chat WHERE id = ?")
+ .get(Number(lastInsertRowid));
+ melden("reaktion_chat", { was: "neu", beitrag });
+ res.status(201).json(beitrag);
+ } catch (fehler) {
+ console.error("[reaktion] Schreiben:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Moderation: einen Beitrag wegnehmen.
+ *
+ * WEGGENOMMEN UND NICHT GELOESCHT. Wer moderiert, soll spaeter
+ * sagen koennen, was er weggenommen hat und warum -- eine geloeschte
+ * Zeile kann das nicht. */
+reaktionRouter.delete("/workspace/api/reaktion/chat/:id", (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ const b = db().prepare("SELECT * FROM reaktion_chat WHERE id = ?").get(id);
+ if (!b || b.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ const eigen = b.person_id === req.person.id;
+ if (!eigen && !MODERIERT.has(req.person.rolle)) {
+ return res.status(403).json({ fehler: "nicht_erlaubt" });
+ }
+ db().prepare("UPDATE reaktion_chat SET weg_am = ?, weg_von = ? WHERE id = ?")
+ .run(jetzt(), req.person.id, id);
+ if (!eigen) {
+ protokolliere(req.person.id, "reaktion_chat_weg", `Beitrag ${id} von ${b.name}`, echteIp(req));
+ }
+ melden("reaktion_chat", { was: "weg", id });
+ res.json({ ok: true });
+ } catch (fehler) {
+ console.error("[reaktion] Wegnehmen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------- Die Kameras ------------------------------------------------
+ Nur der Hinweg. Was in `signal` steht, liest dieser Server nicht --
+ es geht unveraendert an den Empfaenger. Genau so macht es der
+ Anrufteil seit dem 18.09.
+
+ GESCHICKT WIRD NUR AN LEUTE, DIE GERADE DABEI SIND. Ohne das
+ koennte jemand aus dem Haus Verbindungsdaten an jeden Beliebigen
+ schicken, und die Reaction waere ein Weg, jemanden anzuklingeln. */
+reaktionRouter.post("/workspace/api/reaktion/signal", express.json({ limit: "256kb" }),
+ (req, res) => {
+ try {
+ const an = Number(req.body?.an);
+ const daten = req.body?.daten;
+ if (!Number.isInteger(an) || daten === undefined) {
+ return res.status(400).json({ fehler: "unvollstaendig" });
+ }
+ const kreis = new Set(empfaenger());
+ if (!kreis.has(an) || !kreis.has(req.person.id)) {
+ return res.status(409).json({ fehler: "nicht_dabei" });
+ }
+ const wie_viele = stromAn([an], "reaktion_signal", {
+ von: req.person.id, name: req.person.name, daten,
+ });
+ res.json({ ok: true, zugestellt: wie_viele });
+ } catch (fehler) {
+ console.error("[reaktion] Signal:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* ======================================================================
+ HILFEN
+ ====================================================================== */
+
+/** Aus allem, was jemand einfuegen koennte, die YouTube-Kennung.
+ *
+ * Angenommen werden die vier Formen, die man wirklich in der
+ * Zwischenablage hat: die lange Adresse, die kurze, der
+ * Einbett-Pfad und die nackte Kennung. Alles andere wird
+ * abgelehnt -- eine falsche Kennung ist ein schwarzes Rechteck vor
+ * Publikum, und das faellt erst auf, wenn alle zusehen. */
+export function videoKennung(roh) {
+ const t = String(roh || "").trim();
+ if (!t) return "";
+ if (/^[A-Za-z0-9_-]{11}$/.test(t)) return t;
+ const muster = [
+ /[?&]v=([A-Za-z0-9_-]{11})/,
+ /youtu\.be\/([A-Za-z0-9_-]{11})/,
+ /youtube\.com\/embed\/([A-Za-z0-9_-]{11})/,
+ /youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/,
+ /youtube\.com\/live\/([A-Za-z0-9_-]{11})/,
+ ];
+ for (const m of muster) {
+ const t2 = t.match(m);
+ if (t2) return t2[1];
+ }
+ return "";
+}
+
+/** Die Lage ohne die Namensliste -- fuer den Rundruf.
+ *
+ * `lage()` gibt einem Host die Namen aller Zusehenden. Genau diese
+ * Antwort geht per Strom an ALLE. Ohne dieses Sieb bekaeme jeder
+ * Zuschauer die Liste, die nur die Leitung sehen darf -- und zwar
+ * ohne dass es jemandem auffaellt, weil die Oberflaeche sie ja
+ * nicht anzeigt. */
+function oeffentlich(l) {
+ const { zuschauer, ich, ...rest } = l;
+ return rest;
+}
+
+/** Aufraeumen: alter Verlauf und liegengebliebene Zusehende. */
+export function reaktionAufraeumen() {
+ try {
+ const grenze = new Date(Date.now() - VERLAUF_TAGE * 86400_000).toISOString();
+ const a = db().prepare("DELETE FROM reaktion_verlauf WHERE beendet < ?").run(grenze);
+ const b = db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?")
+ .run(new Date(Date.now() - 600_000).toISOString());
+ if (a.changes || b.changes) {
+ console.log(`[reaktion] Aufgeraeumt: ${a.changes} Verlauf, ${b.changes} Zusehende.`);
+ }
+ } catch (fehler) {
+ console.error("[reaktion] Aufraeumen:", fehler?.message);
+ }
+}
diff --git a/server/workspace.js b/server/workspace.js
index b975cf00..fa90cd3b 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -35,6 +35,7 @@ import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.
import { hilfeTabellen } from "./hilfe-tabellen.js";
import { supportTabellen } from "./support-tabellen.js";
import { notizTabellen } from "./notiz-tabellen.js";
+import { reaktionTabellen } from "./reaktion-tabellen.js";
import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js";
import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN,
TEAM_DOGI_ROLLEN, CREW_ADRESSE, WORKSPACE_ADRESSE, AGENTUR_ROLLEN, BEIDE_HAEUSER_ROLLEN,
@@ -1510,6 +1511,25 @@ const TREFF_BEREICHE = [
`?raum=treff` öffnet den Raum der Community direkt. Für die
Community selbst ändert sich am Namen nichts Wesentliches -- sie
hat nur diese eine. */
+ /* ==== DIE REACTION STEHT GANZ VORNE ============================
+ (28.09.2026, nach Filipes Kurznotiz.)
+
+ Sie ist die einzige Kachel im Haus, die einen ZUSTAND hat --
+ zu, gleich, jetzt. Was gerade laeuft, gehoert an die erste
+ Stelle; was immer da ist, kann warten. Steht sie geschlossen
+ da, sagt sie das und nimmt trotzdem den Platz: Eine Kachel,
+ die je nach Lage woanders steht, ist keine Kachel, die man
+ wiederfindet.
+
+ KEINE TON-NUMMER. Der Farbraum ist bei 46 Toenen voll (siehe
+ tools/kachelton-regeln.mjs), und eine 47. Farbe wuerde den
+ Mindestabstand reissen. Sie braucht auch keine: Ihre Farbe IST
+ ihr Zustand -- grau geschlossen, bernstein in Vorbereitung,
+ rot auf Sendung. `live: true` sagt der Wand, dass diese Kachel
+ ihre Flaeche selbst mitbringt, genau wie `papier` beim
+ Notizblock und `silber` bei den Aufgaben. */
+ { ...TREFF_GRUPPE, name: "Reaction", unter: "Zusammen schauen \u2013 live mit Kamera und Chat",
+ zeichen: "reaktion", live: true, ziel: "reaktion.html", szene: "arena" },
{ ...TREFF_GRUPPE, name: "Rudel-Chat", unter: "Miteinander reden – nachts schläft er",
zeichen: "chat", ton: 41, ziel: "chat.html?raum=treff", szene: "lounge" },
/* HIGHLIGHTS AN DIE SPITZE (22.09.2026).
@@ -6037,6 +6057,7 @@ export function db() {
/* Der Notizblock (26.09.2026). Haengt an personen (CASCADE) und
kommt deshalb hierher, nicht in den Bauplan darueber. */
notizTabellen(d);
+ reaktionTabellen(d);
/* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen
(geaendert_von) und kommen deshalb hierher, nicht in den Bauplan
darüber. */
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 191f2208..c41fd152 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+