diff --git a/.gitignore b/.gitignore
index 500bfcd0..b41f3f2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,12 @@ bild-*.png
# Aenderung erscheinen.
handy-*.png
-# Laufprotokoll der Browserpruefung -- Ergebnis, kein Quelltext
-pruef-browser-lauf.txt
-server/pruef-browser-lauf.txt
+# Laufprotokolle der Pruefungen -- Ergebnis, kein Quelltext.
+# Ein Muster statt einer Liste: Sonst haette jede neue Pruefung ihre
+# eigene Zeile gebraucht, und die vergisst man. Genau das ist am
+# 05.09.2026 passiert -- vier neue Protokolle standen ploetzlich als
+# Aenderung im Arbeitsstand.
+pruef-*-lauf.txt
+server/pruef-*-lauf.txt
+# Ergebnis des Sammellaufs (tools/alles-pruefen.mjs)
+gesamtlauf.txt
diff --git a/server/index.js b/server/index.js
index cf33ed60..2df9b908 100644
--- a/server/index.js
+++ b/server/index.js
@@ -36,6 +36,7 @@ import { vorlagenRouter } from "./workspace-vorlagen.js";
import { schulungRouter } from "./workspace-schulung.js";
import { ampelRouter } from "./workspace-ampel.js";
import { checklisteRouter } from "./workspace-checkliste.js";
+import { pushRouter, pushStarten } from "./workspace-push.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SITE_DIR = join(__dirname, "..");
@@ -148,6 +149,10 @@ app.use(sicherungRouter);
JSON in einem ergibt nur ein kaputtes Bild ohne jeden Hinweis.
Der Steckbrief bringt seine eigene Schranke mit. */
app.use(steckbriefRouter);
+/* Ebenfalls VOR aufgabenRouter: Der Service Worker holt den
+ oeffentlichen Schluessel, bevor irgendetwas anderes laeuft, und die
+ Push-Wege bringen ihre eigene Schranke mit. */
+app.use(pushRouter);
app.use(vorlagenRouter);
app.use(schulungRouter);
app.use(ampelRouter);
@@ -182,6 +187,12 @@ sicherungStarten();
Call-Liste oder die Suche benutzt -- die lesen dieselbe Tabelle. */
serienStarten();
+/* Benachrichtigungen. Eigener Takt im Prozess -- wie die Sicherung und
+ der Serien-Nachfueller: kein Systemdienst, kein Cron, eine Sache
+ weniger, die stillschweigend ausfallen kann. Begruendung im Kopf von
+ workspace-push.js. */
+pushStarten();
+
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server-
diff --git a/server/pruef-abbrechen-optik.mjs b/server/pruef-abbrechen-optik.mjs
new file mode 100644
index 00000000..4e78ee77
--- /dev/null
+++ b/server/pruef-abbrechen-optik.mjs
@@ -0,0 +1,334 @@
+/* ABBRECHEN — WIE ES SICH BEDIENEN LÄSST.
+
+ pruef-abbrechen.mjs beweist, dass der Server richtig entscheidet.
+ Diese Prüfung fragt das, was heute früh niemand gefragt hatte:
+ Kommt davon auch etwas auf dem Bildschirm an?
+
+ Der Anlass ist derselbe Tag. Vierzig grüne Prüfungen zur
+ Teilnehmerwahl -- und auf Filipes Schirm klebten die Namen
+ aneinander, weil kein einziger Test je hingesehen hatte. Ein Server,
+ der alles richtig macht, nützt nichts, wenn der Knopf dazu fehlt.
+
+ GEMESSEN WIRD:
+ * Sieht ein Creator den Abbrechen-Knopf ÜBERHAUPT NICHT?
+ (Der Server lehnt ihn ohnehin ab -- aber einen Knopf anzubieten,
+ der dann 403 sagt, ist eine Zumutung.)
+ * Steht er in JEDEM Status an der Karte?
+ * Verlangt der Dialog wirklich einen Grund?
+ * Verschwindet die Aufgabe aus dem Brett und taucht unten wieder
+ auf -- mit Grund, Person und dem Status, in dem sie war?
+ * Und der Rückweg.
+
+ GEGENPROBEN: Jede Sperre wird auch von der erlaubten Seite versucht. */
+
+import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-abopt-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+const { portMussFreiSein } = await import("./helfer-port.mjs");
+await portMussFreiSein(4297, "die Optikpruefung des Abbrechens");
+
+process.env.PORT = "4297";
+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");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = "http://127.0.0.1:4297";
+
+const PROTOKOLL = join(process.cwd(), "pruef-abbrechen-optik-lauf.txt");
+try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
+const melde = (z) => {
+ console.log(z);
+ try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
+};
+let fehler = 0;
+const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---- Bestand ------------------------------------------------------------ */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
+const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idScout, jetzt);
+
+/* Eine Aufgabe je Status -- der Wunsch lautet ausdrücklich "in JEDEM
+ Status", also muss auch jeder einzeln nachgewiesen werden. */
+for (const s of ["offen", "arbeit", "review", "erledigt"]) {
+ d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id,
+ verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?)`)
+ .run(`Aufgabe ${s}`, "Beschreibung.", s, idLuna, idLuna, jetzt, idDogi);
+}
+d.close();
+
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+
+/* Was der Browser meldet, wird mitgeschrieben. Beim ersten Lauf blieb
+ diese Prüfung beim Absenden des Dialogs stehen, und die stumme
+ Konsole sagte nicht, warum -- ein JavaScript-Fehler sieht von außen
+ genauso aus wie "der Server antwortet nicht". */
+const konsole = [];
+async function alsRolle(rolle, code, breite = 1280) {
+ const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 } });
+ const seite = await ctx.newPage();
+ seite.on("pageerror", (f) => konsole.push(`pageerror: ${f.message}`));
+ seite.on("console", (m) => {
+ if (m.type() === "error") konsole.push(`console: ${m.text().slice(0, 160)}`);
+ });
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await seite.click(`.rolle[data-rolle="${rolle}"]`);
+ await seite.fill("#code", code);
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 25000 });
+ await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
+ await seite.waitForSelector(".karte", { timeout: 15000 });
+ return { ctx, seite };
+}
+
+/* =======================================================================
+ 1. Der Knopf — bei wem, und in welchem Status
+ ======================================================================= */
+melde("\n=== Der Abbrechen-Knopf ===");
+{
+ const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
+
+ const karten = await seite.$$(".karte");
+ /* DIE ANZAHL IN DIE BEDINGUNG: Ohne Karten liefe alles Folgende über
+ eine leere Menge und meldete grün. */
+ ok(karten.length === 4, `${karten.length} Karten auf dem Brett (erwartet 4, je Status eine)`);
+
+ /* In JEDEM Status -- einzeln nachgewiesen, nicht als Summe. */
+ for (const status of ["offen", "arbeit", "review", "erledigt"]) {
+ const da = await seite.$(`.karte[data-status="${status}"] .schritt--ab`);
+ ok(!!da, `Status "${status}": Abbrechen-Knopf ist da`);
+ }
+
+ /* Er darf die täglichen Knöpfe nicht verdrängen. */
+ const nebeneinander = await seite.$$eval('.karte[data-status="offen"] .schritt',
+ (b) => b.map((x) => x.textContent.trim()));
+ ok(nebeneinander.includes("starten ▶") && nebeneinander.includes("abbrechen"),
+ `auf der Karte stehen: ${nebeneinander.join(" | ")}`);
+
+ await ctx.close();
+}
+
+melde("\n=== Ein Creator sieht ihn gar nicht erst ===");
+{
+ const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001");
+ const karten = (await seite.$$(".karte")).length;
+ const knoepfe = (await seite.$$(".schritt--ab")).length;
+ /* Die Karten müssen DA sein -- sonst bewiese "0 Knöpfe" nur, dass
+ die Seite leer ist. */
+ ok(karten === 4 && knoepfe === 0,
+ `${karten} Karten, aber ${knoepfe} Abbrechen-Knöpfe`);
+ await ctx.close();
+}
+
+/* =======================================================================
+ 2. Der Dialog
+ ======================================================================= */
+melde("\n=== Der Dialog verlangt einen Grund ===");
+{
+ const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001");
+ await seite.click('.karte[data-status="review"] .schritt--ab');
+ await seite.waitForSelector("#abbruch-dialog[open]", { timeout: 5000 });
+
+ const titel = await seite.$eval("#abbruch-titel", (e) => e.textContent);
+ ok(/Aufgabe review/.test(titel), `der Dialog nennt die Aufgabe: ${titel}`);
+
+ /* Sagt er, was passiert -- und was NICHT passiert? Die Sorge bei so
+ einem Knopf ist immer, ob etwas verloren geht. */
+ const was = await seite.$eval("#abbruch-was", (e) => e.textContent);
+ ok(/nicht gelöscht/i.test(was) && /Review/.test(was),
+ `er erklärt es: "${was.trim().slice(0, 95)}…"`);
+
+ /* Ohne Grund abschicken -- der Dialog muss offen bleiben. */
+ await seite.click("#abbruch-ok");
+ await seite.waitForTimeout(300);
+ const nochOffen = await seite.$("#abbruch-dialog[open]");
+ const meldung = await seite.$eval("#abbruch-fehler", (e) => e.textContent.trim());
+ ok(!!nochOffen && meldung.length > 0,
+ `ohne Grund bleibt er offen und sagt: "${meldung}"`);
+
+ /* GEGENPROBE: mit Grund geht er zu und die Aufgabe wandert. */
+ await seite.fill("#abbruch-grund", "Kampagne verschoben – kommt im Oktober neu.");
+ await seite.click("#abbruch-ok");
+ /* NICHT mit waitForSelector, sondern messen und melden: Bleibt der
+ Dialog offen, ist das ein Befund -- kein Grund, die ganze Prüfung
+ mit einer Zeitüberschreitung abbrechen zu lassen. So steht am Ende
+ da, WAS nicht ging, statt gar nichts. */
+ /* `state: "attached"` ist hier Pflicht. waitForSelector wartet sonst
+ darauf, dass das Element SICHTBAR wird -- und ein geschlossener
+