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`. --> - - + + - +