diff --git a/server/pruef-angemeldet-bleiben.mjs b/server/pruef-angemeldet-bleiben.mjs
new file mode 100644
index 00000000..7ca0c639
--- /dev/null
+++ b/server/pruef-angemeldet-bleiben.mjs
@@ -0,0 +1,246 @@
+/* =====================================================================
+ ANGEMELDET BLEIBEN, BIS MAN SICH SELBST ABMELDET (06.10.2026)
+ =====================================================================
+
+ Filipe: „ich will dass wenn sich jemand bei der workspace seite
+ anmeldet, angemeldet bleibt bis er sich selbst abmeldet."
+
+ ---------------------------------------------------------------------
+ WAS DIE MESSUNG VORHER ERGEBEN HAT -- und warum die naheliegende
+ Antwort die falsche gewesen waere
+
+ Die naheliegende Antwort waere gewesen, die Sitzung zu verlaengern.
+ Sie war aber nie zu kurz:
+
+ * Der Keks gilt 180 Tage und verlaengert sich beim Benutzen.
+ * In der echten Datenbank lief KEINE der 28 Sitzungen vor Maerz
+ 2027 ab.
+ * Am echten Browser: Er uebersteht Neuladen, den Service Worker
+ und das Schliessen des Browsers.
+
+ Trotzdem meldeten sich dieselben Geraete staendig neu an --
+ dieselbe IP, dieselbe Browserkennung, einmal zweimal innerhalb
+ einer Minute. Der Grund lag woanders: `/workspace/` zeigte die
+ Anmeldung, OHNE zu fragen, ob schon jemand angemeldet ist. Das ist
+ die Adresse, die man tippt und weitergibt.
+
+ Haette ich die Sitzungsdauer erhoeht, waere der Fehler geblieben
+ und die Sicherheit schlechter geworden.
+
+ ---------------------------------------------------------------------
+ DIE WICHTIGSTE PRUEFUNG IST DIE ZAHL DER SITZUNGEN
+
+ „Man bleibt angemeldet" laesst sich leicht vortaeuschen: Wer bei
+ jedem Besuch stillschweigend neu anmeldet, sieht auch nie ein
+ Codefeld. Deshalb wird hier durchgehend gezaehlt, wie viele
+ Sitzungen in der Datenbank stehen. Bleibt es bei EINER, ist
+ niemand neu angemeldet worden.
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-ang-"));
+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 Anmeldepruefung");
+
+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-angemeldet-bleiben");
+await new Promise((r) => setTimeout(r, 900));
+
+let fehler = 0, geprueft = 0;
+const melde = (t) => console.log(t);
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+function braucht(bedingung, was) {
+ if (bedingung) return;
+ console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}\n`);
+ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+ process.exit(2);
+}
+
+/* ---------- Ein Zugang ------------------------------------------- */
+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 CODE = "CODE-BLEIB-0001";
+const salz = randomBytes(16).toString("hex");
+const h = scryptSync(CODE, salz, 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("Bleibt", "scout", h, salz, 32768,
+ createHmac("sha256", kenn).update(CODE).digest("hex"), "2025-10-01T08:00:00Z");
+d.close();
+
+const sitzungen = () => {
+ const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
+ const n = db2.prepare("SELECT COUNT(*) n FROM sitzungen").get().n;
+ db2.close();
+ return n;
+};
+
+/* ---------- Ein Browser MIT Profil auf der Platte -----------------
+ Nur so ueberlebt ein Keks das Schliessen des Browsers. Ein
+ gewoehnlicher Kontext vergisst alles -- dann maesse diese Pruefung
+ ihre eigene Einrichtung statt der Software. */
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const profil = join(ordner, "profil");
+const BASIS = `http://127.0.0.1:${PORT}`;
+const auf = () => pw.chromium.launchPersistentContext(profil, {
+ viewport: { width: 412, height: 800 },
+});
+
+/** Wohin fuehrt diese Adresse -- und ist die Seite danach sichtbar? */
+async function hin(seite, pfad) {
+ await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
+ /* Laenger als die feste Frist im Skript (900 ms): Sonst misst diese
+ Pruefung den Zwischenzustand und nicht das Ergebnis. */
+ await seite.waitForTimeout(1300);
+ return {
+ weg: seite.url().replace(BASIS, ""),
+ sichtbar: await seite.evaluate(() => getComputedStyle(document.body).visibility),
+ codefeld: await seite.evaluate(() => !!document.getElementById("code")),
+ };
+}
+
+/* =====================================================================
+ 1 · OHNE ANMELDUNG
+ ===================================================================== */
+melde("\n=== Ohne Anmeldung ===");
+let kontext = await auf();
+let seite = await kontext.newPage();
+let r = await hin(seite, "/workspace/");
+ok(r.weg === "/workspace/", `die Anmeldung bleibt stehen (${r.weg})`);
+ok(r.codefeld, "das Codefeld ist da");
+/* DIE WICHTIGSTE ZEILE DIESES ABSCHNITTS. Die Verdeckung waehrend der
+ Abfrage darf NIE haengenbleiben -- sonst koennte sich niemand mehr
+ anmelden, und zwar genau dann, wenn das Netz schlecht ist. */
+ok(r.sichtbar === "visible", `und die Seite ist sichtbar (${r.sichtbar})`);
+ok(sitzungen() === 0, `noch keine Sitzung in der Datenbank (${sitzungen()})`);
+
+/* =====================================================================
+ 2 · ANMELDEN
+ ===================================================================== */
+melde("\n=== Anmelden ===");
+await seite.click('.rolle[data-rolle="scout"]');
+await seite.fill("#code", CODE);
+await seite.click("button[type=submit]");
+await seite.waitForURL("**/start.html", { timeout: 15000 });
+ok(sitzungen() === 1, `genau eine Sitzung entstanden (${sitzungen()})`);
+
+const keks = (await kontext.cookies()).find((c) => c.name === "dfw_sitzung");
+braucht(!!keks, "der Sitzungskeks wurde gar nicht gesetzt");
+const tage = Math.round((keks.expires * 1000 - Date.now()) / 86400000);
+ok(tage > 170, `der Keks ueberlebt das Schliessen des Browsers (${tage} Tage)`);
+
+/* =====================================================================
+ 3 · DIE ADRESSE, DIE MAN TIPPT
+ =====================================================================
+ Genau hier lag der Fehler: `/workspace/` zeigte die Anmeldung,
+ obwohl die Sitzung gueltig war.
+ ===================================================================== */
+melde("\n=== Die Adresse, die man tippt ===");
+for (const runde of [1, 2, 3]) {
+ r = await hin(seite, "/workspace/");
+ ok(r.weg === "/workspace/start.html",
+ `Aufruf ${runde}: fuehrt direkt zur Startseite (${r.weg})`);
+ ok(!r.codefeld, `Aufruf ${runde}: kein Codefeld`);
+}
+ok(sitzungen() === 1,
+ `nach drei Aufrufen immer noch EINE Sitzung (${sitzungen()})`
+ + " — niemand wurde stillschweigend neu angemeldet");
+
+/* =====================================================================
+ 4 · BROWSER ZU UND WIEDER AUF
+ ===================================================================== */
+melde("\n=== Browser schliessen und neu oeffnen ===");
+await kontext.close();
+kontext = await auf();
+seite = await kontext.newPage();
+r = await hin(seite, "/workspace/");
+ok(r.weg === "/workspace/start.html", `auch danach direkt zur Startseite (${r.weg})`);
+ok(sitzungen() === 1, `und immer noch eine einzige Sitzung (${sitzungen()})`);
+
+/* =====================================================================
+ 5 · ABMELDEN -- und dann muss Schluss sein
+ =====================================================================
+ Die Gegenprobe zu allem davor. Ohne sie hiesse „man bleibt
+ angemeldet" moeglicherweise „man kommt gar nicht mehr heraus".
+ ===================================================================== */
+melde("\n=== Abmelden ===");
+await seite.goto(`${BASIS}/workspace/start.html`, { waitUntil: "networkidle" });
+await seite.waitForTimeout(900);
+const abmelden = await seite.$("#abmelden");
+braucht(!!abmelden, "der Abmelden-Knopf ist nicht da");
+await abmelden.click();
+/* ABMELDEN FRAGT ERST NACH (kopf.js, seit 19.09.2026) -- und das aus
+ gutem Grund: Zum Wiederanmelden braucht man den persoenlichen
+ Zugangscode, und den bekommt jeder nur einmal.
+
+ Beim ersten Lauf hat diese Pruefung nur geklickt und dann gemeldet
+ „die Sitzung ist nicht weg". Das war kein Befund am Code, sondern
+ eine Pruefung, die auf halbem Weg stehen blieb. */
+await seite.waitForTimeout(600);
+/* GENAU der Bestaetigungsknopf. Beim ersten Anlauf stand hier eine
+ Auswahl mit Komma, und die traf den erstbesten `.knopf` im Dialog
+ -- das war „Bild waehlen", unsichtbar, und der Klick lief 30
+ Sekunden in eine Zeitueberschreitung. */
+const bestaetigen = await seite.$('dialog[open] [data-teil="ja"]');
+braucht(!!bestaetigen, "die Rueckfrage beim Abmelden kam nicht");
+await bestaetigen.click();
+await seite.waitForTimeout(1800);
+ok(sitzungen() === 0, `die Sitzung ist wirklich weg (${sitzungen()})`);
+
+r = await hin(seite, "/workspace/");
+ok(r.weg === "/workspace/", `danach steht die Anmeldung da (${r.weg})`);
+ok(r.codefeld, "mit Codefeld");
+ok(r.sichtbar === "visible", `und sichtbar (${r.sichtbar})`);
+
+/* Und sie bleibt stehen -- kein Hin und Her zwischen zwei Seiten. */
+r = await hin(seite, "/workspace/");
+ok(r.weg === "/workspace/" && r.codefeld,
+ "auch beim zweiten Aufruf — kein Hin und Her");
+
+/* =====================================================================
+ 6 · GEGENPROBE: MISST DIESE PRUEFUNG UEBERHAUPT ETWAS?
+ =====================================================================
+ Wenn „fuehrt zur Startseite" auch ohne gueltige Sitzung herauskaeme,
+ waere der ganze Abschnitt 3 wertlos. Also noch einmal anmelden, den
+ Keks von Hand wegnehmen und nachsehen, ob die Messung es merkt.
+ ===================================================================== */
+melde("\n=== Gegenprobe ===");
+await seite.goto(`${BASIS}/workspace/`, { waitUntil: "networkidle" });
+await seite.waitForTimeout(900);
+await seite.click('.rolle[data-rolle="scout"]');
+await seite.fill("#code", CODE);
+await seite.click("button[type=submit]");
+await seite.waitForURL("**/start.html", { timeout: 15000 });
+r = await hin(seite, "/workspace/");
+ok(r.weg === "/workspace/start.html", "angemeldet: fuehrt zur Startseite");
+
+await kontext.clearCookies();
+r = await hin(seite, "/workspace/");
+ok(r.weg === "/workspace/" && r.codefeld,
+ "ohne Keks: die Anmeldung — die Messung kann also auch Nein sagen");
+
+await kontext.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
+process.exit(fehler ? 1 : 0);
diff --git a/server/workspace.js b/server/workspace.js
index 23b8ad86..2ff3aef2 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -6984,6 +6984,64 @@ export function gehoertAufDieseAdresse(person, pfad) {
return ziele.has(pfad);
}
+/* =====================================================================
+ WER SCHON ANGEMELDET IST, SIEHT DIE ANMELDUNG NICHT (06.10.2026)
+ =====================================================================
+
+ Filipe: „ich will dass wenn sich jemand bei der workspace seite
+ anmeldet, angemeldet bleibt bis er sich selbst abmeldet."
+
+ GEMESSEN, BEVOR ETWAS GEBAUT WURDE -- und das hat die Richtung
+ gedreht. Die Sitzung war nie zu kurz:
+
+ * Der Keks gilt 180 Tage und verlaengert sich beim Benutzen.
+ * In der echten Datenbank lief KEINE der 28 Sitzungen vor
+ Maerz 2027 ab.
+ * Am echten Browser: Er uebersteht Neuladen, den Service Worker
+ und das Schliessen des Browsers.
+
+ Trotzdem meldeten sich dieselben Geraete staendig neu an --
+ DogFathers Android am 05.10. um 22:54 UND um 22:55, gleiche IP,
+ gleiche Browserkennung. Der Grund: `/workspace/` lieferte die
+ Anmeldung aus, ohne zu fragen, ob schon jemand angemeldet ist.
+ Das ist die Adresse, die man tippt, als Lesezeichen hat und
+ weitergibt. Wer sie oeffnete, sah das Codefeld und tippte den Code
+ noch einmal -- obwohl seine Sitzung gueltig war. Jede Eingabe legt
+ eine neue Sitzung an; deshalb standen sieben davon bei einer
+ Person.
+
+ Haette ich die Sitzungsdauer erhoeht -- die naheliegende Antwort --,
+ waere der Fehler geblieben und die Sicherheit schlechter geworden.
+
+ (Die installierte App startet auf `start.html` und war nie
+ betroffen. Es traf nur den Weg ueber die Adresszeile, also den
+ haeufigsten.)
+
+ WARUM AUF DEM SERVER UND NICHT IM BROWSER: Der erste Entwurf stand
+ in gate.js und fragte `/api/ich`. Das brauchte eine Verdeckung
+ gegen das Aufblitzen, eine Notfrist gegen eine haengende Abfrage
+ und eine Schleifensperre mit Zeitstempel in zwei Dateien -- und
+ hinterliess bei jedem Aufruf der Anmeldung ein 401 in der
+ Browserkonsole (pruef-neue-seiten wurde davon zehnmal rot). Hier
+ ist es eine Zeile: Die Sitzung wird ohnehin bei jeder Anfrage
+ gelesen.
+
+ KEINE SCHLEIFENGEFAHR: Schickt die Startseite jemanden zurueck
+ (nur bei einer Sitzung, die zwischendurch ungueltig wurde), findet
+ diese Stelle keine Sitzung mehr und liefert die Anmeldung aus.
+
+ BEIDE HAEUSER: Auf der Crew-Adresse liefert `/workspace/` die
+ Datei crew-index.html; der Weg dorthin ist derselbe, und das Ziel
+ `start.html` gilt dort ebenso. Der Keks ist host-gebunden -- wer
+ nur im anderen Haus angemeldet ist, bekommt hier weiterhin die
+ Anmeldung, genau wie es die Haustrennung vom 24.09.2026 verlangt. */
+workspaceRouter.get(
+ ["/workspace/", "/workspace/index.html", "/workspace/crew-index.html"],
+ (req, res, next) => {
+ if (!sitzungLesen(req)) return next();
+ return res.redirect(302, "/workspace/start.html");
+ });
+
workspaceRouter.use((req, res, next) => {
const pfad = schrankenPfad(req.path);
if (!pfad.startsWith("/workspace/") || !pfad.endsWith(".html")) return next();
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 4ef33eb5..48e7c690 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`. -->
-
-
+
+
-
+