diff --git a/server/helfer-push-aufmachen.mjs b/server/helfer-push-aufmachen.mjs
new file mode 100644
index 00000000..d6848ade
--- /dev/null
+++ b/server/helfer-push-aufmachen.mjs
@@ -0,0 +1,61 @@
+/* =====================================================================
+ EINE BENACHRICHTIGUNG AUFMACHEN — so, wie der Browser es tut.
+
+ RFC 8291, in die andere Richtung gelesen: aus dem verschlüsselten
+ Paket wieder Klartext machen, mit dem privaten Schlüssel des
+ Geräts.
+
+ WARUM ALS EIGENE DATEI (30.09.2026)
+
+ Das stand als lokale Funktion in `pruef-push-weg.mjs`. Seit heute
+ braucht `pruef-abzeichen.mjs` dieselbe Rechnung — sie muss in den
+ Inhalt der Nachricht hineinsehen, um zu prüfen, dass die Zahl fürs
+ App-Symbol wirklich mitkommt.
+
+ Die Rechnung ein zweites Mal abzuschreiben wäre genau die Sorte
+ Doppelung, die sich später auseinanderentwickelt: Ändert sich am
+ Verfahren etwas, wird eine der beiden Abschriften nachgezogen, die
+ andere nicht — und dann meldet eine Prüfung einen Fehler, den es
+ nicht gibt, oder schlimmer: keinen, den es gibt.
+
+ NUR FÜR PRÜFUNGEN. Im Betrieb macht der Browser das selbst; der
+ Server hat den privaten Schlüssel des Geräts nie gesehen und soll
+ ihn auch nie sehen.
+ ===================================================================== */
+
+import { hkdfSync, createDecipheriv } from "node:crypto";
+
+/** Macht ein aes128gcm-Paket wieder auf.
+ *
+ * @param {Buffer} paket was auf der Leitung lag
+ * @param {object} wer der ECDH des Geräts (mit privatem Teil)
+ * @param {Buffer} geheim16 die 16 Bytes `auth` der Anmeldung
+ * @returns {string} der Klartext
+ *
+ * Wirft, wenn Schlüssel oder Geheimnis nicht passen — und genau das
+ * ist die Gegenprobe in `pruef-push-weg`: Mit dem falschen Schlüssel
+ * MUSS es scheitern.
+ */
+export function pushAufmachen(paket, wer, geheim16) {
+ const salz = paket.subarray(0, 16);
+ const len = paket.readUInt8(20);
+ const senderOeff = paket.subarray(21, 21 + len);
+ const rest = paket.subarray(21 + len);
+
+ const gemeinsam = wer.computeSecret(senderOeff);
+ const prkInfo = Buffer.concat([
+ Buffer.from("WebPush: info\0"), wer.getPublicKey(), senderOeff,
+ ]);
+ const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, geheim16, prkInfo, 32));
+ const cek = Buffer.from(hkdfSync("sha256", ikm, salz,
+ Buffer.from("Content-Encoding: aes128gcm\0"), 16));
+ const nonce = Buffer.from(hkdfSync("sha256", ikm, salz,
+ Buffer.from("Content-Encoding: nonce\0"), 12));
+
+ const tag = rest.subarray(rest.length - 16);
+ const daten = rest.subarray(0, rest.length - 16);
+ const dec = createDecipheriv("aes-128-gcm", cek, nonce);
+ dec.setAuthTag(tag);
+ const klar = Buffer.concat([dec.update(daten), dec.final()]);
+ return klar.subarray(0, klar.length - 1).toString("utf8"); // Polsterbyte weg
+}
diff --git a/server/pruef-abzeichen.mjs b/server/pruef-abzeichen.mjs
new file mode 100644
index 00000000..cdb939da
--- /dev/null
+++ b/server/pruef-abzeichen.mjs
@@ -0,0 +1,550 @@
+/* =====================================================================
+ DIE ZAHL AUF DEM APP-SYMBOL (30.09.2026)
+
+ Filipe: „ich will dass du die benarichtigungen perfektionierst …
+ und das sehr hochwertig."
+
+ Das Abzeichen ist die kleine Zahl, die auf dem Startbildschirm am
+ Symbol der installierten App klebt. Es fehlte ganz — `setAppBadge`
+ kam im ganzen Haus nicht ein einziges Mal vor.
+
+ ---------------------------------------------------------------------
+ WARUM ES UNGELESENE NACHRICHTEN ZÄHLT UND NICHTS SONST
+
+ Ein Abzeichen muss WEGGEHEN können. Ungelesene Nachrichten
+ verschwinden, sobald man sie liest. Eine überfällige Aufgabe
+ verschwindet nicht dadurch, dass man die App öffnet — eine Zahl,
+ die dauerhaft dasteht, ist nach drei Tagen keine Auskunft mehr,
+ sondern ein Fleck. Dann übersieht man auch die, die etwas bedeutet.
+
+ Dieselbe Überlegung wie bei der Warnung, die immer kommt.
+
+ Deshalb ist Abschnitt 3 der wichtigste in dieser Datei: Er misst
+ nicht, dass die Zahl STEIGT, sondern dass sie wieder FÄLLT.
+
+ ---------------------------------------------------------------------
+ WAS HIER GEMESSEN WIRD — UND ZWAR AM ECHTEN WEG
+
+ Ein nachgebauter Browser meldet ein Gerät an, behält seinen
+ privaten Schlüssel, und ein nachgebauter Push-Dienst nimmt die
+ Nachricht entgegen. Aufgemacht wird sie mit dem Schlüssel des
+ Geräts — also genau so, wie es auf Filipes Handy passiert.
+
+ Ohne das Aufmachen wäre die Prüfung wertlos: Der Inhalt ist
+ verschlüsselt, und `benachrichtige` gibt die Zahl nicht zurück. Man
+ sähe nur, DASS etwas verschickt wurde, nicht WAS darin steht.
+
+ ---------------------------------------------------------------------
+ GEGENPROBEN (Abschnitt 4)
+
+ Ohne sie hieße „die Zahl ist da" nur, dass irgendeine Zahl da ist:
+
+ * Eine Benachrichtigung OHNE Zahl darf auch keine mitschicken.
+ Sonst löschte eine Aufgabenerinnerung das Abzeichen des Chats.
+ * Etwas, das keine Zahl ist, darf nicht durchrutschen.
+
+ Aufrufen mit: node server/pruef-abzeichen.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 { createServer } from "node:http";
+import { eigenerPort } from "./helfer-port.mjs";
+import { pushAufmachen } from "./helfer-push-aufmachen.mjs";
+import { jsOhneKommentar } from "./helfer-ohne-kommentar.mjs";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const WURZEL = join(HIER, "..");
+
+const PORT = await eigenerPort(import.meta, "pruef-abzeichen");
+const DIENST = await eigenerPort(import.meta, "pruef-abzeichen (Push-Dienst)", 1);
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-abz-"));
+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";
+
+/* ---- KEINE RUHEZEIT, UND ZWAR HERGESTELLT STATT GEHOFFT ----------
+
+ `chat_nachricht` trägt keine eigene Ruheregel, gilt also als
+ „immer": zwischen 22 und 7 Uhr geht nichts hinaus. Ohne diese zwei
+ Zeilen wäre die Prüfung eine Zeitbombe — grün am Nachmittag, rot um
+ halb zwölf nachts, und beides ohne eine Aussage über die Software.
+
+ PUSH_RUHE_AB = 24 gesperrt ab Stunde 24, die es nicht gibt
+ PUSH_RUHE_BIS = 0 und frei ab Stunde 0
+
+ Damit ist `s >= 24 || s < 0` zu jeder Tageszeit falsch. Die Uhr
+ wird nicht gefälscht, nur die Regel klar gesetzt — dieselbe Technik
+ wie in pruef-push-ziel, dort andersherum. */
+process.env.PUSH_RUHE_AB = "24";
+process.env.PUSH_RUHE_BIS = "0";
+
+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 });
+};
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(120_000, "pruef-abzeichen");
+await new Promise((r) => setTimeout(r, 900));
+
+/* Eigene Zuhörer NEBEN denen aus index.js — `process.on` ergänzt, es
+ ersetzt nicht. Sonst notiert das Auffangnetz des Betriebs meinen
+ Absturz nur, und der Lauf sieht von außen aus wie „läuft noch". */
+process.on("uncaughtException", (f) => {
+ console.error("ABSTURZ:", f?.stack || f); process.exit(1);
+});
+process.on("unhandledRejection", (f) => {
+ console.error("ABSTURZ (Versprechen):", f?.stack || f); process.exit(1);
+});
+
+const BASIS = `http://127.0.0.1:${PORT}`;
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => {
+ geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++;
+};
+/* DER DRITTE AUSGANG. Eine Prüfung, die ihre Voraussetzung nicht hat,
+ muss das SAGEN — „grün" und „rot" sind beide gelogen. */
+const konntenicht = (t) => {
+ geprueft++; console.log(" ?? konnte nicht nachsehen: " + t); fehler++;
+};
+
+/* ---- Der nachgebaute Push-Dienst --------------------------------------
+ Er hebt die Rümpfe auf, damit wir hineinsehen können. Ein echter
+ Dienst kann das NICHT (das beweist pruef-push-weg) — hier geht es
+ nur deshalb, weil wir gleich auch den privaten Schlüssel des
+ „Geräts" besitzen. */
+const pakete = [];
+const dienst = createServer((q, a) => {
+ const teile = [];
+ q.on("data", (t) => teile.push(t));
+ q.on("end", () => { pakete.push(Buffer.concat(teile)); a.writeHead(201); a.end(); });
+});
+await new Promise((r) => dienst.listen(DIENST, "127.0.0.1", r));
+const schliessen = () => new Promise((f) => {
+ if (!dienst.listening) return f();
+ dienst.close(() => f());
+});
+
+/* ---- Bestand ----------------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createECDH } = await import("node:crypto");
+
+/* Einmal anklopfen, damit index.js die Datenbank angelegt hat. */
+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 salz = randomBytes(16).toString("hex");
+ const hash = 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, aktiv, erstellt)
+ VALUES (?,?,?,?,32768,1,?)`).run(name, rolle, hash, salz, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idAyla = anlegen("Ayla", "modi", "CODE-TEAM-0001");
+
+/* DAS GERÄT — und wir behalten seinen privaten Schlüssel.
+ Ein erfundener Schlüssel würde beim Verschlüsseln scheitern; dann
+ wäre „nichts angekommen" die Antwort auf eine ganz andere Frage. */
+const geraet = createECDH("prime256v1");
+geraet.generateKeys();
+const geheim = randomBytes(16);
+d.prepare(`INSERT INTO push_anmeldungen (person_id, endpunkt, p256dh, auth, erstellt)
+ VALUES (?,?,?,?,?)`)
+ .run(idAyla, `http://127.0.0.1:${DIENST}/push/${idAyla}`,
+ geraet.getPublicKey().toString("base64url"), geheim.toString("base64url"), jetzt);
+
+/* ---- Anmelden an der Crew-Wand ------------------------------------------
+ `fetch` setzt den Host-Kopf selbst; für die Wand braucht es
+ node:http. Danach genügt der Keks. */
+const { request: httpAnfrage } = await import("node:http");
+function anmeldenAn(rolle, code, wand = "crew.dogfather-universe.com") {
+ return new Promise((fertig) => {
+ const rumpf = JSON.stringify({ rolle, code });
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(rumpf),
+ Host: wand,
+ },
+ }, (antwort) => {
+ antwort.resume();
+ antwort.on("end", () => fertig({
+ ok: antwort.statusCode === 200,
+ keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "),
+ wand,
+ }));
+ });
+ a.on("error", () => fertig({ ok: false, keks: "", wand }));
+ a.end(rumpf);
+ });
+}
+
+/** Ein Aufruf MIT Wand — und warum das hier nicht `fetch` sein darf.
+ *
+ * Das Haus einer Sitzung kommt aus dem Host-Feld (sitzungLesen liest
+ * `req.get("host")`), und `fetch` setzt dieses Feld selbst aus der
+ * Adresse; ein mitgegebenes ignoriert es. Über 127.0.0.1 ist das
+ * Haus damit weder crew noch agentur — `hausWo` gibt „1=1" zurück,
+ * und Abschnitt 7 hätte die Hausregel an einer Verbindung gemessen,
+ * die gar kein Haus hat.
+ *
+ * Genau das ist beim ersten Anlauf passiert: Die Prüfung blieb rot,
+ * nachdem die Regel schon eingebaut war. Ein Messfehler, der wie ein
+ * Befund aussieht — und andersherum hätte er einen echten Befund
+ * verdeckt. */
+function rufe(sitzung, weg, koerper, methode = "GET") {
+ const rumpf = koerper === undefined ? undefined : JSON.stringify(koerper);
+ return new Promise((fertig) => {
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: weg, method: methode,
+ headers: {
+ "Content-Type": "application/json",
+ Cookie: sitzung.keks,
+ Host: sitzung.wand,
+ ...(rumpf === undefined ? {} : { "Content-Length": Buffer.byteLength(rumpf) }),
+ },
+ }, (antwort) => {
+ const teile = [];
+ antwort.on("data", (t) => teile.push(t));
+ antwort.on("end", () => {
+ const text = Buffer.concat(teile).toString("utf8");
+ fertig({
+ ok: antwort.statusCode >= 200 && antwort.statusCode < 300,
+ status: antwort.statusCode,
+ json: () => { try { return JSON.parse(text); } catch { return {}; } },
+ });
+ });
+ });
+ a.on("error", () => fertig({ ok: false, status: 0, json: () => ({}) }));
+ a.end(rumpf);
+ });
+}
+
+const dogi = await anmeldenAn("admin", "CODE-DOGI-0001");
+const ayla = await anmeldenAn("modi", "CODE-TEAM-0001");
+if (!dogi.ok || !ayla.ok) {
+ konntenicht("die Anmeldung hat nicht geklappt — ohne sie ist nichts zu messen");
+ await schliessen(); d.close();
+ rmSync(ordner, { recursive: true, force: true });
+ process.exit(1);
+}
+
+/* Ein Zweiergespräch. Ayla schaut NICHT zu (kein Ereignisstrom offen)
+ — nur dann entsteht überhaupt eine Benachrichtigung. */
+const raumId = ((await rufe(dogi, "/workspace/api/chat/direkt",
+ { mit: idAyla }, "POST")).json()).raum_id;
+
+/** Schickt eine Nachricht und wartet, bis das Paket beim Push-Dienst
+ * liegt — auf das ERGEBNIS, nicht auf die Uhr. `chatEreignis` ruft
+ * die Benachrichtigung mit `void` auf; die Antwort der Route ist
+ * also schon da, bevor irgendetwas verschickt wurde. */
+async function schreibenUndWarten(text, frist = 8000) {
+ const vorher = pakete.length;
+ const a = await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
+ { text }, "POST");
+ if (!a.ok) return { fehlgeschlagen: `HTTP ${a.status}` };
+ const bis = Date.now() + frist;
+ while (pakete.length === vorher && Date.now() < bis) {
+ await new Promise((r) => setTimeout(r, 60));
+ }
+ if (pakete.length === vorher) return { fehlgeschlagen: `nach ${frist / 1000} s nichts angekommen` };
+ return { paket: pakete[pakete.length - 1] };
+}
+
+/** Das letzte Paket aufmachen und als Objekt zurückgeben. */
+const inhalt = (paket) => JSON.parse(pushAufmachen(paket, geraet, geheim));
+
+/* =======================================================================
+ 1. DIE ZAHL KOMMT ÜBERHAUPT MIT
+ ======================================================================= */
+console.log("");
+console.log("=== 1. Die Zahl reist in der Benachrichtigung mit ===");
+let eins = null;
+{
+ const r = await schreibenUndWarten("Erste Nachricht");
+ if (r.fehlgeschlagen) {
+ konntenicht(`die erste Nachricht kam nicht am Push-Dienst an (${r.fehlgeschlagen})`);
+ } else {
+ eins = inhalt(r.paket);
+ ok(typeof eins.titel === "string" && eins.titel.length > 0,
+ `die Benachrichtigung ist lesbar angekommen ("${eins.titel}")`);
+ ok(eins.zahl === 1,
+ `und trägt die Zahl 1 für das App-Symbol (zahl=${JSON.stringify(eins.zahl)})`);
+ }
+}
+
+/* =======================================================================
+ 2. SIE ZÄHLT MIT
+ ======================================================================= */
+console.log("");
+console.log("=== 2. Bei der zweiten Nachricht steht 2 darauf ===");
+{
+ const r = await schreibenUndWarten("Zweite Nachricht");
+ if (r.fehlgeschlagen) {
+ konntenicht(`die zweite Nachricht kam nicht an (${r.fehlgeschlagen})`);
+ } else {
+ /* OHNE DIESE ZEILE beweist Abschnitt 1 nur, dass irgendeine Eins
+ mitkommt — eine fest eingebaute täte dasselbe. */
+ ok(inhalt(r.paket).zahl === 2,
+ `die Zahl wächst mit (zahl=${JSON.stringify(inhalt(r.paket).zahl)})`);
+ }
+}
+
+/* =======================================================================
+ 3. UND SIE GEHT WIEDER WEG — das Wichtigste an der ganzen Sache
+ ======================================================================= */
+console.log("");
+console.log("=== 3. Wer liest, wird das Abzeichen los ===");
+{
+ const letzte = d.prepare("SELECT MAX(id) AS n FROM chat_nachrichten WHERE raum_id = ?")
+ .get(raumId)?.n;
+ const a = await rufe(ayla, `/workspace/api/chat/raeume/${raumId}/gelesen`,
+ { bis: letzte }, "PUT");
+ ok(a.ok, `Ayla liest bis Nachricht ${letzte} (HTTP ${a.status})`);
+
+ const z = (await rufe(ayla, "/workspace/api/chat/ungelesen")).json();
+ ok(z.ungelesen === 0,
+ `danach steht die Zahl auf null — das Abzeichen verschwindet (${z.ungelesen})`);
+
+ /* UND SIE FÄNGT WIEDER BEI EINS AN. Das trennt „die Zahl wird
+ zurückgesetzt" von „die Zahl wird nur nicht mehr geschickt". */
+ const r = await schreibenUndWarten("Dritte Nachricht");
+ if (r.fehlgeschlagen) {
+ konntenicht(`die dritte Nachricht kam nicht an (${r.fehlgeschlagen})`);
+ } else {
+ ok(inhalt(r.paket).zahl === 1,
+ `die nächste Nachricht zählt wieder ab eins (zahl=${JSON.stringify(inhalt(r.paket).zahl)})`);
+ }
+}
+
+/* =======================================================================
+ 4. GEGENPROBEN — was KEINE Zahl tragen darf
+ ======================================================================= */
+console.log("");
+console.log("=== 4. Gegenproben ===");
+{
+ const push = await import("./workspace-push.js");
+
+ /* EINE MELDUNG OHNE ZAHL DARF AUCH KEINE MITSCHICKEN.
+
+ Das ist die Zeile, an der die ganze Überlegung hängt: Käme bei
+ einer Aufgabenerinnerung `zahl: 0` mit, würde sie das Abzeichen
+ löschen — und die ungelesenen Nachrichten wären vom
+ Startbildschirm verschwunden, ohne dass jemand sie gelesen hat. */
+ const vorher = pakete.length;
+ await push.benachrichtige(idAyla, "test",
+ { titel: "Erinnerung", text: "Etwas ganz anderes" });
+ if (pakete.length === vorher) {
+ konntenicht("die Probe-Benachrichtigung kam nicht an");
+ } else {
+ const o = inhalt(pakete[pakete.length - 1]);
+ ok(!("zahl" in o),
+ `eine Meldung ohne Zahl trägt auch keine (${JSON.stringify(Object.keys(o))})`);
+ }
+
+ /* UND ETWAS, DAS KEINE ZAHL IST, RUTSCHT NICHT DURCH. `Number
+ .isFinite` und nicht `typeof === "number"`: NaN wäre sonst eine
+ Zahl, und `setAppBadge(NaN)` ist keine Auskunft. */
+ const vorher2 = pakete.length;
+ await push.benachrichtige(idAyla, "test",
+ { titel: "Krumm", text: "Probe", zahl: Number.NaN });
+ if (pakete.length === vorher2) {
+ konntenicht("die zweite Probe kam nicht an");
+ } else {
+ ok(!("zahl" in inhalt(pakete[pakete.length - 1])),
+ "eine Zahl, die keine ist (NaN), kommt gar nicht erst mit");
+ }
+
+ /* DIE GEGENPROBE ZUR GEGENPROBE. Ohne sie hieße „keine zahl dabei"
+ womöglich nur, dass NIE eine mitgeht — dann wären die drei
+ Abschnitte darüber grün gewesen, weil sie alle dasselbe Nichts
+ gemessen hätten. */
+ const vorher3 = pakete.length;
+ await push.benachrichtige(idAyla, "test",
+ { titel: "Gerade", text: "Probe", zahl: 7 });
+ if (pakete.length === vorher3) {
+ konntenicht("die dritte Probe kam nicht an");
+ } else {
+ ok(inhalt(pakete[pakete.length - 1]).zahl === 7,
+ "eine echte Zahl geht dagegen mit — die Messung unterscheidet also");
+ }
+}
+
+/* =======================================================================
+ 5. WER DIE ZAHL IM BROWSER SETZT
+ ======================================================================= */
+console.log("");
+console.log("=== 5. Die eine Stelle in der Kopfleiste ===");
+{
+ /* ==== GEMESSEN WIRD AM KOMMENTARFREIEN TEXT (30.09.2026) =======
+
+ Beim ersten Lauf war Abschnitt 6 rot — zu Unrecht. Die Suche
+ nahm die ERSTE Fundstelle von `setAppBadge`, und die steht im
+ Kommentar darüber („`setAppBadge` gibt es nicht überall"). Sie
+ hat damit den Kommentar vermessen, nicht den Code.
+
+ Dieselbe Falle wie in pruef-bewegung, dort unter „ein Beispiel
+ im Kommentar zählt nicht". Sie fällt nur auf, wenn sie zufällig
+ rot wird — andersherum hätte ein Kommentar eine fehlende Zeile
+ jahrelang gedeckt. */
+ const kopf = jsOhneKommentar(
+ readFileSync(join(WURZEL, "workspace", "assets", "js", "kopf.js"), "utf8"));
+
+ /* SIE MUSS IN `chatZahlZeigen` STEHEN und nirgends sonst. Diese
+ Funktion wird beim Laden, aus dem Ereignisstrom und aus dem Chat
+ gerufen — eine zweite Zählung wäre die, die irgendwann abweicht. */
+ const block = kopf.slice(kopf.indexOf("window.chatZahlZeigen"));
+ const ende = block.indexOf("\n };");
+ const rumpf = ende > 0 ? block.slice(0, ende) : "";
+ ok(rumpf.includes("setAppBadge"),
+ "das Abzeichen wird in chatZahlZeigen() gesetzt");
+ ok(rumpf.includes("clearAppBadge"),
+ "und bei null wieder gelöscht — sonst bliebe es für immer stehen");
+
+ /* KEINE ZWEITE STELLE. `setAppBadge` darf im ganzen Haus nur dort
+ und im Service Worker vorkommen. */
+ const zweite = [];
+ const { readdirSync } = await import("node:fs");
+ const suche = (verz) => {
+ for (const e of readdirSync(verz, { withFileTypes: true })) {
+ const p = join(verz, e.name);
+ if (e.isDirectory()) { suche(p); continue; }
+ if (!e.name.endsWith(".js")) continue;
+ const t = jsOhneKommentar(readFileSync(p, "utf8"));
+ if (!/setAppBadge|clearAppBadge/.test(t)) continue;
+ const kurz = p.slice(WURZEL.length + 1).replace(/\\/g, "/");
+ if (kurz !== "workspace/assets/js/kopf.js" && kurz !== "workspace/sw.js") {
+ zweite.push(kurz);
+ }
+ }
+ };
+ suche(join(WURZEL, "workspace"));
+ ok(zweite.length === 0,
+ zweite.length === 0
+ ? "und es gibt keine zweite Stelle, die das Abzeichen anfasst"
+ : `ZWEITE STELLE: ${zweite.join(", ")}`);
+
+ /* GEGENPROBE: Die Suche findet überhaupt etwas — sonst bewiese die
+ Null oben nur, dass sie nichts liest. */
+ ok(/setAppBadge/.test(kopf),
+ "die Suche findet setAppBadge in kopf.js (sie liest also wirklich)");
+}
+
+/* =======================================================================
+ 6. DER SERVICE WORKER — für die geschlossene App
+ ======================================================================= */
+console.log("");
+console.log("=== 6. Wenn die App zu ist ===");
+{
+ const sw = jsOhneKommentar(readFileSync(join(WURZEL, "workspace", "sw.js"), "utf8"));
+
+ ok(/Number\.isFinite\(d\.zahl\)/.test(sw),
+ "der Service Worker rührt das Abzeichen nur an, wenn eine Zahl dabei ist");
+
+ /* KEIN BEDINGUNGSLOSES LÖSCHEN. Genau das wäre der stille Schaden:
+ Jede beliebige Meldung nähme dem Chat sein Abzeichen weg. */
+ const zeilen = sw.split("\n").filter((z) => /clearAppBadge/.test(z));
+ ok(zeilen.length > 0 && zeilen.every((z) => /else/.test(z)),
+ zeilen.length
+ ? `gelöscht wird nur im else-Zweig von „Zahl > 0" (${zeilen.length} Stelle(n))`
+ : "KEIN clearAppBadge gefunden — dann geht das Abzeichen nie weg");
+
+ /* Und es darf nichts zum Absturz bringen: Firefox kennt die
+ Funktion nicht, Safari nur für installierte Apps. Eine Zugabe,
+ die die Benachrichtigung mitreißt, wäre ein schlechtes Geschäft. */
+ /* AB DER WACHE GEMESSEN, nicht ab irgendeinem Vorkommen. `indexOf`
+ nimmt das erste — und das war beim ersten Lauf das im Kommentar. */
+ const stelle = sw.indexOf("Number.isFinite(d.zahl)");
+ const umher = sw.slice(stelle, stelle + 400);
+ ok(stelle >= 0 && /try\s*\{/.test(umher) && /catch/.test(umher),
+ "und der Versuch ist abgesichert — ein Gerät ohne Abzeichen bekommt trotzdem seine Meldung");
+}
+
+/* =======================================================================
+ 7. DIE ZAHL GEHOERT ZU EINEM HAUS
+
+ Seit dem 24.09.2026 sieht jedes Haus nur seinen eigenen Bestand --
+ auch DogFather, der in beiden arbeitet. Die Raumliste haelt sich
+ daran (`hausWo(req.person, "r")`), die Zaehlung der ungelesenen
+ Nachrichten wurde nie danach gefragt.
+
+ Das faellt jetzt ins Gewicht: Bis heute war es eine Zahl an einem
+ Knopf. Ab heute steht dieselbe Zahl auf dem Startbildschirm -- und
+ eine Zahl, die etwas Falsches zeigt, ist schlimmer als keine, weil
+ man ihr glaubt.
+ ======================================================================= */
+console.log("");
+console.log("=== 7. Jede Adresse zaehlt nur ihr eigenes Haus ===");
+{
+ const idMana = anlegen("Cigdem", "manager", "CODE-MANA-0001");
+ const mana = await anmeldenAn("manager", "CODE-MANA-0001",
+ "workspace.dogfather-universe.com");
+ if (!mana.ok) {
+ konntenicht("die Managerin kam nicht an der Agenturwand herein");
+ } else {
+ /* Ein Gespraech im ANDEREN Haus. `direktRaum` traegt das Haus der
+ Person ein, die es anlegt -- hier also "agentur". */
+ const raumA = ((await rufe(mana, "/workspace/api/chat/direkt",
+ { mit: idDogi }, "POST")).json()).raum_id;
+ ok(Number.isInteger(raumA), `die Agentur legt ein Gespraech an (Raum ${raumA})`);
+
+ const vorher = (await rufe(dogi, "/workspace/api/chat/ungelesen")).json();
+ await rufe(mana, `/workspace/api/chat/raeume/${raumA}/nachrichten`,
+ { text: "Agentursache" }, "POST");
+ const nachher = (await rufe(dogi, "/workspace/api/chat/ungelesen")).json();
+
+ ok(nachher.ungelesen === vorher.ungelesen,
+ `DogFather zaehlt auf crew. keine Agenturnachricht mit `
+ + `(vorher ${vorher.ungelesen}, nachher ${nachher.ungelesen})`);
+
+ /* GEGENPROBE: Auf der AGENTURADRESSE muss dieselbe Nachricht
+ gezaehlt werden. Ohne sie hiesse "nicht mitgezaehlt" womoeglich
+ nur, dass gar nichts gezaehlt wird. */
+ const dogiAgentur = await anmeldenAn("admin", "CODE-DOGI-0001",
+ "workspace.dogfather-universe.com");
+ if (!dogiAgentur.ok) {
+ konntenicht("DogFather kam nicht an der Agenturwand herein");
+ } else {
+ const dort = (await rufe(dogiAgentur,
+ "/workspace/api/chat/ungelesen")).json();
+ ok(dort.ungelesen >= 1,
+ `auf der Agenturadresse steht sie dagegen sehr wohl (${dort.ungelesen})`);
+ }
+ }
+}
+
+/* ------------------------------------------------------------------- */
+await schliessen();
+d.close();
+/* AUFRAEUMEN IST KEIN BEFUND. Der Server hält die Datenbank offen,
+ solange der Prozess läuft; unter Windows gibt `rmSync` darauf
+ EPERM. Das ist ein Rest im Temp-Ordner, kein Fehler in der
+ Software — und er als Absturz gemeldet hätte die ganze Prüfung rot
+ gefärbt, obwohl alle 15 Messungen grün waren. Genau so entsteht
+ ein Rot, das man sich abgewöhnt zu lesen. */
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest im Temp */ }
+
+console.log("");
+console.log(fehler === 0
+ ? `ALLES IN ORDNUNG (${geprueft} Pruefungen)`
+ : `${fehler} von ${geprueft} Pruefungen sind nicht in Ordnung.`);
+process.exit(fehler === 0 ? 0 : 1);
diff --git a/server/pruef-push-weg.mjs b/server/pruef-push-weg.mjs
index 39f87853..9afd6abd 100644
--- a/server/pruef-push-weg.mjs
+++ b/server/pruef-push-weg.mjs
@@ -24,10 +24,11 @@
Nachricht NICHT aufmachen lassen. */
import { createServer } from "node:http";
-import { createECDH, hkdfSync, createDecipheriv } from "node:crypto";
+import { createECDH } from "node:crypto";
import {
verschluesseln, schluesselErzeugen, schicken, b64u,
} from "./workspace-push-krypto.js";
+import { pushAufmachen } from "./helfer-push-aufmachen.mjs";
/* EINEN TESTDIENST SAUBER SCHLIESSEN -- und warum das eine eigene
Funktion wert ist.
@@ -77,31 +78,16 @@ browser.generateKeys();
const auth = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
const anmeldung = { p256dh: b64u(browser.getPublicKey()), auth: b64u(auth) };
-/** Macht die Nachricht auf -- genau wie es der echte Browser tut
- * (RFC 8291, in die andere Richtung gelesen). */
-function entschluesseln(paket, wer = browser, geheim16 = auth) {
- const salz = paket.subarray(0, 16);
- const len = paket.readUInt8(20);
- const senderOeff = paket.subarray(21, 21 + len);
- const rest = paket.subarray(21 + len);
-
- const gemeinsam = wer.computeSecret(senderOeff);
- const prkInfo = Buffer.concat([
- Buffer.from("WebPush: info\0"), wer.getPublicKey(), senderOeff,
- ]);
- const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, geheim16, prkInfo, 32));
- const cek = Buffer.from(hkdfSync("sha256", ikm, salz,
- Buffer.from("Content-Encoding: aes128gcm\0"), 16));
- const nonce = Buffer.from(hkdfSync("sha256", ikm, salz,
- Buffer.from("Content-Encoding: nonce\0"), 12));
-
- const tag = rest.subarray(rest.length - 16);
- const daten = rest.subarray(0, rest.length - 16);
- const dec = createDecipheriv("aes-128-gcm", cek, nonce);
- dec.setAuthTag(tag);
- const klar = Buffer.concat([dec.update(daten), dec.final()]);
- return klar.subarray(0, klar.length - 1).toString("utf8"); // Polsterbyte weg
-}
+/** Macht die Nachricht auf -- genau wie es der echte Browser tut.
+ *
+ * DIE RECHNUNG STEHT IN `helfer-push-aufmachen.mjs` (30.09.2026).
+ * Seit heute braucht `pruef-abzeichen.mjs` dieselbe; zwei
+ * Abschriften waeren die, die auseinanderlaufen. Der Name hier
+ * bleibt, damit die sieben Aufrufstellen unten unberuehrt bleiben --
+ * und weil die Vorgabewerte (dieser Browser, dieses Geheimnis) nur
+ * in dieser Datei einen Sinn ergeben. */
+const entschluesseln = (paket, wer = browser, geheim16 = auth) =>
+ pushAufmachen(paket, wer, geheim16);
const TEXT = JSON.stringify({
titel: "Es funktioniert",
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index d7bf3f2b..1e40ec30 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -3636,7 +3636,25 @@ async function meldeNeueNachricht(personId, raumId, nachricht, art = "neu") {
es demjenigen, der ruft; im Protokoll steht, wer das war. */
: (art === "erwaehnung" || art === "rudel") ? "chat_erwaehnung"
: "chat_nachricht";
- await benachrichtige(personId, pushArt, pushTextFuer(art, nachricht, raumId));
+ /* DIE ZAHL GEHT MIT (30.09.2026). Der Service Worker setzt damit
+ das Abzeichen am App-Symbol — und zwar genau dann, wenn es
+ etwas wert ist: bei geschlossener App. Ist die Seite offen,
+ steht die Zahl ohnehin am Chat-Knopf.
+
+ Sie wird HIER gerechnet und nicht im Push-Modul: Was ungelesen
+ ist, weiß der Chat. Und andersherum wäre es ein Kreis — dieses
+ Modul holt sich das Push-Modul, nicht umgekehrt. */
+ /* DAS HAUS KOMMT VOM RAUM (30.09.2026). Auf welcher Adresse das
+ Geraet installiert wurde, weiss hier niemand -- wohl aber, aus
+ welchem Haus die Nachricht stammt, die gerade eine Meldung
+ ausloest. Genau dessen Zahl gehoert aufs Abzeichen: Eine
+ Meldung aus dem Crew-Chat darf keine Agenturzahl tragen. */
+ const raumHaus = db().prepare("SELECT haus FROM chat_raeume WHERE id = ?")
+ .get(raumId)?.haus || null;
+ await benachrichtige(personId, pushArt, {
+ ...pushTextFuer(art, nachricht, raumId),
+ zahl: ungeleseneZahl({ id: personId, haus: raumHaus }),
+ });
} catch (fehler) {
console.error("[chat] Benachrichtigung:", fehler?.message);
}
@@ -3682,15 +3700,50 @@ chatRouter.get("/workspace/api/chat/strom", (req, res) => {
/** Wie viele Nachrichten warten insgesamt auf mich?
* Für die Zahl in der Kopfleiste -- eine Abfrage statt einer je Raum. */
+/** Wie viele Nachrichten hat diese Person in DIESEM Haus nicht gelesen?
+ *
+ * ALS FUNKTION UND NICHT NUR ALS WEG (30.09.2026): Dieselbe Zahl
+ * braucht seit heute auch die Benachrichtigung — sie geht in die
+ * Nachricht mit, damit der Service Worker das Abzeichen am
+ * App-Symbol setzen kann, während die App geschlossen ist. Zweimal
+ * abgeschrieben wäre sie die Zahl, die irgendwann abweicht.
+ *
+ * ==== UND SIE ZÄHLT NUR EIN HAUS (30.09.2026) =====================
+ *
+ * Hier stand keine Hausregel — die Abfrage verband Nachrichten und
+ * Teilnehmer und fragte nie, wohin der Raum gehört. Zwanzig Zeilen
+ * weiter oben macht es die Raumliste längst richtig
+ * (`hausWo(req.person, "r")`); diese eine Zählung war seit dem
+ * 24.09.2026 daran vorbeigelaufen.
+ *
+ * GEMESSEN, NICHT VERMUTET (pruef-abzeichen, Abschnitt 7): Eine
+ * Managerin schreibt DogFather an der Agenturwand an. Sein Zähler
+ * auf crew. sprang von 0 auf 1 — eine Auskunft über das andere
+ * Haus, genau das, was seit dem 24.09. nicht mehr sein soll.
+ *
+ * Aufgefallen ist es erst beim Abzeichen: Bis heute war das eine
+ * Zahl an einem Knopf, ab heute steht sie auf dem Startbildschirm.
+ * Eine Zahl, die etwas Falsches zeigt, ist schlimmer als keine —
+ * man glaubt ihr ja.
+ *
+ * `person` STATT `personId`: `hausWo` braucht das Haus, und das
+ * hängt an der Adresse, auf der gerade gearbeitet wird, nicht an der
+ * Person. Ein Raum ohne Haus (aus der Zeit davor) zählt weiterhin
+ * überall — dieselbe Regel wie in der Raumliste. */
+export function ungeleseneZahl(person) {
+ const z = db().prepare(`
+ SELECT COUNT(*) AS n FROM chat_nachrichten m
+ JOIN chat_teilnehmer t ON t.raum_id = m.raum_id AND t.person_id = ?
+ JOIN chat_raeume r ON r.id = m.raum_id
+ WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.id > t.geloescht_bis
+ AND m.person_id <> ?
+ AND ${hausWo(person, "r")}`).get(person.id, person.id);
+ return z?.n || 0;
+}
+
chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
try {
- const z = db().prepare(`
- SELECT COUNT(*) AS n FROM chat_nachrichten m
- JOIN chat_teilnehmer t ON t.raum_id = m.raum_id AND t.person_id = ?
- WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.id > t.geloescht_bis
- AND m.person_id <> ?`)
- .get(req.person.id, req.person.id);
- res.json({ ungelesen: z?.n || 0 });
+ res.json({ ungelesen: ungeleseneZahl(req.person) });
} catch (fehler) {
console.error("[chat] Ungelesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
diff --git a/server/workspace-push.js b/server/workspace-push.js
index 7b906240..4502ac47 100644
--- a/server/workspace-push.js
+++ b/server/workspace-push.js
@@ -552,7 +552,7 @@ export function hausFuerMeldung(personId, ziel) {
}
}
-export async function benachrichtige(personId, art, { titel, text, ziel, merkmal }) {
+export async function benachrichtige(personId, art, { titel, text, ziel, merkmal, zahl }) {
if (!ARTEN_SCHLUESSEL.has(art)) return { verschickt: 0, grund: "unbekannte_art" };
if (!willHaben(personId, art)) return { verschickt: 0, grund: "abgeschaltet" };
@@ -603,7 +603,14 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal
Gesicht dazu passen -- sonst steht das Crew-Symbol ueber einer
Meldung, die auf der Agenturstartseite landet. */
const haus = hausFuerMeldung(personId, zielEcht);
- const inhalt = JSON.stringify({ titel, text, art, ziel: zielEcht, haus });
+ /* `zahl` NUR, WENN EINE DABEI IST. Der Service Worker rührt das
+ Abzeichen sonst nicht an — eine Aufgabenerinnerung, die es auf
+ null setzte, löschte die ungelesenen Nachrichten vom
+ Startbildschirm. */
+ const inhalt = JSON.stringify({
+ titel, text, art, ziel: zielEcht, haus,
+ ...(Number.isFinite(zahl) ? { zahl: Math.max(0, Math.round(zahl)) } : {}),
+ });
let raus = 0;
for (const a of anmeldungen) {
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 3cd62cd4..6268a7fc 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`. -->
-
-
+
+
-
+