diff --git a/server/pruef-rollstelle.mjs b/server/pruef-rollstelle.mjs
new file mode 100644
index 00000000..0124eacd
--- /dev/null
+++ b/server/pruef-rollstelle.mjs
@@ -0,0 +1,179 @@
+/* =====================================================================
+ pruef-rollstelle.mjs — Beim Zurückgehen dort bleiben, wo man war
+
+ Filipe, zum dritten Mal und in Großbuchstaben (20.09.2026):
+ „WENN ICH IN EINE KATEGORIE REIN GEHE UND DAN ZURÜCK, GEHT DIE
+ HAUPTSEITE IMMER WIEDER GANZ HOCH; DAS NERVT ... SO DASS WENN MAN
+ ZURÜCK GEHT MAN DAN GENAU DA IST UND BLEIBT WO MAN VORHER WAR."
+
+ ---------------------------------------------------------------------
+ WAS GEMESSEN WURDE, BEVOR ETWAS GEBAUT WURDE
+
+ Scrollen auf 900, eine Kachel anklicken, zurück:
+
+ gemerkt: 900
+ gelandet: 1054 <- 154 px daneben, zuverlässig
+
+ Die Wiederherstellung LIEF also — sie traf nur nicht. Die Erklärung
+ ist Chromes Scroll-Verankerung: Wächst Inhalt OBERHALB der Stelle,
+ verschiebt der Browser den Bildlauf mit, damit das Sichtbare stehen
+ bleibt. Im Alltag richtig, beim Wiederherstellen das Gegenteil: Wir
+ setzen 900, danach lädt der Inhalt darüber nach, und aus 900 wird
+ 1054.
+
+ ---------------------------------------------------------------------
+ ZWEI FALLEN BEIM MESSEN, IN BEIDE BIN ICH GELAUFEN
+
+ 1. `scrollIntoViewIfNeeded()` vor dem Klick scrollt SELBST. Gemerkt
+ wurde dann die neue Stelle, und die Prüfung meldete einen Fehler,
+ den es nicht gab. Deshalb wird hier ein Link genommen, der schon
+ im Bild steht.
+
+ 2. Playwright schaltet den Vor-/Zurück-Speicher des Browsers ab.
+ Diese Prüfung misst deshalb ausdrücklich den Weg OHNE ihn — also
+ den schlechtesten Fall. Ist er im echten Browser aktiv, ist es
+ ohnehin sofort und exakt.
+
+ AUFRUF: node server/pruef-rollstelle.mjs
+ ===================================================================== */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { portMussFreiSein } from "./helfer-port.mjs";
+import { notbremse } from "./helfer-notbremse.mjs";
+
+const PORT = await portMussFreiSein(4432, "pruef-rollstelle");
+const ordner = mkdtempSync(join(tmpdir(), "ws-rollstelle-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(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(240_000, "pruef-rollstelle");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = `http://127.0.0.1:${PORT}`;
+
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+console.log("\n=== Beim Zurueckgehen dort bleiben, wo man war ===\n");
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const salt = randomBytes(16).toString("hex");
+const hash = scryptSync("CODE-DOGI-0001", salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,1,?)").run("Filipe", "admin", hash, salt, 32768, new Date().toISOString());
+d.close();
+
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+try {
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
+ const s = await ctx.newPage();
+ await s.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await s.click('.rolle[data-rolle="admin"]');
+ await s.fill("#code", "CODE-DOGI-0001");
+ await s.click("button[type=submit]");
+ await s.waitForURL("**/start.html", { timeout: 25000 });
+ await s.waitForTimeout(1800);
+
+ const hoch = await s.evaluate(() => document.documentElement.scrollHeight);
+ ok(hoch > 1600, `die Startseite ist hoch genug zum Messen (${hoch} px)`);
+
+ const ZIEL = 900;
+ await s.evaluate((y) => window.scrollTo(0, y), ZIEL);
+ await s.waitForTimeout(400);
+ const vorKlick = await s.evaluate(() => Math.round(window.scrollY));
+ ok(vorKlick === ZIEL, `gescrollt auf ${vorKlick}`);
+
+ /* EINEN LINK NEHMEN, DER SCHON IM BILD STEHT -- siehe Falle 1 oben. */
+ const ziel = await s.evaluate(() => {
+ const mitte = window.scrollY + window.innerHeight / 2;
+ let best = null, dist = 1e9;
+ for (const a of document.querySelectorAll("a.kachel__link")) {
+ const r = a.getBoundingClientRect();
+ if (r.top < 0 || r.bottom > window.innerHeight) continue;
+ const y = window.scrollY + r.top + r.height / 2;
+ if (Math.abs(y - mitte) < dist) { dist = Math.abs(y - mitte); best = a; }
+ }
+ if (!best) return null;
+ best.id = "pruef-ziel";
+ return best.getAttribute("href");
+ });
+ ok(!!ziel, `eine Kachel im Bild gefunden (${ziel})`);
+
+ await s.click("#pruef-ziel");
+ await s.waitForLoadState("networkidle");
+ await s.waitForTimeout(1200);
+ ok((await s.url()).includes(String(ziel).split("?")[0]),
+ "die Kachel hat wirklich geöffnet");
+
+ /* MITSCHREIBEN, WAS NACH DEM ZURUECKGEHEN PASSIERT. Ein Endwert sagt
+ nicht, ob die Seite erst richtig stand und dann verschoben wurde. */
+ await s.addInitScript(() => {
+ window.__spur = [];
+ setInterval(() => window.__spur.push(Math.round(window.scrollY)), 120);
+ });
+ await s.click("#zurueck");
+ await s.waitForLoadState("networkidle");
+ await s.waitForTimeout(2600);
+
+ const nachher = await s.evaluate(() => Math.round(window.scrollY));
+ const spur = await s.evaluate(() => (window.__spur || []).slice(0, 25));
+ ok(Math.abs(nachher - ZIEL) <= 8,
+ `nach dem Zurückgehen wieder bei ${nachher} (war ${ZIEL})`);
+
+ /* UND SIE BLEIBT DORT. Der erste Anlauf traf 900 und wurde danach von
+ der Scroll-Verankerung auf 1054 geschoben -- ein Endwert allein
+ hätte das nicht gezeigt. */
+ const spaet = spur.slice(-8);
+ ok(spaet.length >= 4 && spaet.every((y) => Math.abs(y - ZIEL) <= 8),
+ `und bleibt dort (letzte Messwerte: ${spaet.join(", ")})`);
+
+ /* ---- GEGENPROBE 1: ohne gemerkte Stelle darf nichts springen ----
+
+ OHNE KLICK. Zweimal bin ich in dieselbe Falle gelaufen: Playwright
+ scrollt von selbst zu einem Element ausserhalb des Bildes, die
+ Seite merkt sich diese Stelle, und die Gegenprobe meldet einen
+ Fehler, den es nicht gibt. Hier wird deshalb direkt navigiert.
+
+ ganz oben (unter 80 px) LOESCHT die gemerkte Stelle sogar -- so
+ steht es in kopf.js: Wer zehn Pixel gescrollt hat, merkt vom
+ Wiederherstellen nichts. Genau das wird hier ausgenutzt. */
+ await s.evaluate(() => window.scrollTo(0, 0));
+ await s.waitForTimeout(300);
+ await s.goto(`${BASIS}/workspace/uebersicht.html`, { waitUntil: "networkidle" });
+ await s.waitForTimeout(700);
+ await s.goBack({ waitUntil: "networkidle" });
+ await s.waitForTimeout(2000);
+ const ohne = await s.evaluate(() => Math.round(window.scrollY));
+ ok(ohne < 80,
+ `Gegenprobe: ohne gemerkte Stelle beginnt die Seite oben (${ohne})`);
+
+ /* ---- GEGENPROBE 2: die Verankerung ist danach wieder an --------
+ Sie wird nur waehrend des Wiederherstellens abgeschaltet. Bliebe
+ sie aus, spraenge einem im Chat der Text unter dem Finger weg --
+ eine Reparatur, die woanders etwas kaputt macht. */
+ const anker = await s.evaluate(() =>
+ getComputedStyle(document.documentElement).overflowAnchor);
+ ok(anker !== "none",
+ `die Scroll-Verankerung ist danach wieder an (${anker})`);
+
+ await ctx.close();
+} finally {
+ await browser.close();
+}
+
+console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-schranke.mjs b/server/pruef-schranke.mjs
index 981b53e4..8b9bcf25 100644
--- a/server/pruef-schranke.mjs
+++ b/server/pruef-schranke.mjs
@@ -108,15 +108,25 @@ const hol = (pfad, keks) => fetch(BASIS + pfad, {
======================================================================= */
console.log("\n=== Ohne Anmeldung ===");
-/* Die Liste aus workspace.js, damit hier keine zweite entsteht, die
- irgendwann abweicht. */
-const { readFileSync } = await import("node:fs");
-const quelle = readFileSync(new URL("./workspace.js", import.meta.url), "utf8");
-const block = quelle.slice(quelle.indexOf("const GESCHUETZT = {"));
-const SEITEN = [...block.slice(0, block.indexOf("\n};")).matchAll(/"(\/workspace\/[a-z]+\.html)"/g)]
- .map((m) => m[1]);
+/* DIE LISTE WIRD IMPORTIERT, NICHT AUS DEM QUELLTEXT GEKRATZT
+ (berichtigt 20.09.2026).
-ok(SEITEN.length >= 15, `${SEITEN.length} geschuetzte Seiten aus workspace.js gelesen`);
+ Hier stand ein Textmuster auf "const GESCHUETZT = {" in
+ workspace.js. Diese Tabelle ist am 11.09.2026 nach rechte.js
+ UMGEZOGEN -- und seitdem fand das Muster nichts. Die Pruefung lief
+ weiter, meldete "0 geschuetzte Seiten" und pruefte damit neun Tage
+ lang NICHTS: keine Umleitung, keine Schreibweise, keine Schranke.
+ Drei rote Zeilen, die niemand las, weil sie nach einem kleinen
+ Zaehlfehler aussahen.
+
+ Gefunden beim Umbau der Anmeldedauer -- also zufaellig. Ein
+ Textmuster auf fremden Quelltext ist genau die Art Verbindung, die
+ beim naechsten Umzug still reisst. Ein import reisst laut. */
+const { SEITEN: RECHTE_SEITEN } = await import("./rechte.js");
+const SEITEN = Object.keys(RECHTE_SEITEN)
+ .filter((x) => /workspace/.test(x) && x.endsWith(".html"));
+
+ok(SEITEN.length >= 15, `${SEITEN.length} geschuetzte Seiten aus rechte.js gelesen`);
let geprueft = 0;
for (const seite of SEITEN) {
diff --git a/server/workspace.js b/server/workspace.js
index 17d7528c..89b67e61 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -65,7 +65,37 @@ export const DB_PFAD = process.env.WORKSPACE_DB
export const DATEN_ORDNER = dirname(DB_PFAD);
const COOKIE = "dfw_sitzung";
-const SITZUNG_STUNDEN = 12;
+/* ===== WIE LANGE GILT EINE ANMELDUNG? (20.09.2026) ==================
+
+ Filipe: "kannst du bitte machen dass die leute sich nur einmal
+ anmelden muessen und dan nur noch abgemeldet werden wenn sie sich
+ selbst abmelden. damit sie auch die benarichtigungen sofort kriegen
+ ... und auch sofort die anrufe annehmen koennen."
+
+ VORHER: zwoelf Stunden, feste Frist ab dem Anmelden. Wer morgens um
+ acht anfing, flog abends um acht raus -- mitten im Betrieb. Und wer
+ abgemeldet ist, hat die Seite nicht offen; ein Anruf erreicht ihn
+ dann nur noch ueber die Benachrichtigung, und bis er sich wieder
+ angemeldet hat, ist das Klingeln vorbei.
+
+ JETZT: ein GLEITENDES Fenster. Wer die Seite benutzt, bleibt
+ angemeldet -- ohne Ende, ohne Nachfrage. Abgemeldet wird nur, wer
+ sich selbst abmeldet.
+
+ WARUM NICHT "unendlich": Ein Zugang, der nie ablaeuft, ist auf einem
+ verlorenen Handy fuer immer offen -- und in diesem Haus liegen
+ vertrauliche Meldungen ueber Menschen. Wer ein halbes Jahr nicht da
+ war, meldet sich neu an; das ist niemandem zuzumuten zu viel und
+ schliesst das verlorene Geraet.
+
+ WARUM NICHT BEI JEDEM AUFRUF NEU SCHREIBEN: Das waere ein
+ Schreibzugriff auf die Datenbank bei jedem Bild, jeder Abfrage,
+ jedem Herzschlag des Ereignisstroms. Verlaengert wird erst, wenn
+ weniger als die Haelfte des Fensters uebrig ist -- also hoechstens
+ einmal alle drei Monate je Geraet.
+ ==================================================================== */
+const SITZUNG_TAGE = 180;
+const SITZUNG_MS = SITZUNG_TAGE * 86400_000;
const VERSUCHE_MAX = 8; // pro IP
const VERSUCHE_FENSTER_MIN = 10;
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "hand", "modi", "gast"]);
@@ -4448,6 +4478,38 @@ export function sitzungLesen(req) {
db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token));
return null;
}
+
+ /* ==== DAS FENSTER GLEITET MIT (20.09.2026) =======================
+
+ Wer die Seite benutzt, bleibt angemeldet. Verlaengert wird erst,
+ wenn weniger als die HAELFTE des Fensters uebrig ist -- sonst
+ waere hier ein Schreibzugriff bei jedem einzelnen Aufruf, auch
+ bei jedem Herzschlag des Ereignisstroms.
+
+ DER KEKS MUSS MIT. Ohne ihn liefe die Zeile in der Datenbank
+ weiter und der Browser wuerfe den Keks trotzdem weg -- die
+ Person waere abgemeldet, obwohl der Server sie noch kennt. Das
+ ist die Sorte Fehler, die niemand findet, weil beide Seiten fuer
+ sich richtig aussehen.
+
+ `req.res` STATT EINES ZWEITEN ARGUMENTS: Diese Funktion wird an
+ 26 Stellen aufgerufen -- in jedem Fachmodul einmal. Einen
+ Parameter zu ergaenzen hiesse, 26 Aufrufe zu aendern und beim
+ 27. Modul daran zu denken. Express haengt die Antwort ohnehin an
+ die Anfrage; wo sie fehlt (etwa in einer Pruefung), wird eben
+ nur die Datenbank verlaengert. */
+ const restMs = new Date(reihe.gueltig_bis).getTime() - Date.now();
+ if (restMs < SITZUNG_MS / 2) {
+ const neuBis = new Date(Date.now() + SITZUNG_MS).toISOString();
+ db().prepare("UPDATE sitzungen SET gueltig_bis = ? WHERE token_hash = ?")
+ .run(neuBis, tokenHash(token));
+ try {
+ req.res?.cookie?.(COOKIE, token, {
+ httpOnly: true, secure: !!req.secure, sameSite: "lax",
+ path: "/workspace", maxAge: SITZUNG_MS,
+ });
+ } catch { /* kein res (z. B. in einer Pruefung) -- die Zeile reicht */ }
+ }
/* DIE ADRESSE ENTSCHEIDET MIT (10.09.2026).
Seit die Modi-App unter crew.dogfather-universe.com laeuft, gehoert
@@ -4527,7 +4589,7 @@ export function sitzungLesen(req) {
function sitzungSetzen(res, person, req) {
const token = randomBytes(32).toString("hex");
- const bis = new Date(Date.now() + SITZUNG_STUNDEN * 3600_000).toISOString();
+ const bis = new Date(Date.now() + SITZUNG_MS).toISOString();
db().prepare(
"INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser) VALUES (?,?,?,?,?,?)"
).run(tokenHash(token), person.id, jetzt(), bis, echteIp(req),
@@ -4542,7 +4604,7 @@ function sitzungSetzen(res, person, req) {
secure: req.secure,
sameSite: "lax", // schützt vor fremden Formularen (CSRF)
path: "/workspace", // gilt nur hier, nicht auf der ganzen Domain
- maxAge: SITZUNG_STUNDEN * 3600_000,
+ maxAge: SITZUNG_MS,
});
}
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index e5c48b15..16513e64 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,15 +16,15 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+