/* ===================================================================== WOHIN EINE BENACHRICHTIGUNG FÜHRT (16.09.2026) Seit dem 15.09. entscheidet die Adresse, welche Seiten es gibt: Auf der Adresse von Team Dogi gibt es die Agenturseiten nicht. Für Seiten, Hinweise und Suchtreffer ist das umgesetzt — für Push war es das nicht, und der Grund dafür stimmte: Eine Benachrichtigung entsteht, wenn NIEMAND auf einer Adresse steht. Sie geht an ein Gerät, und das öffnet die Adresse, als die es installiert wurde. --------------------------------------------------------------------- DIE FRAGE LÄSST SICH TROTZDEM BEANTWORTEN — ÜBER DEN MENSCHEN Wer im Team ist (rechte Hand, Modi), kommt NUR auf crew. herein. Das steht in `sitzungPasstZurAdresse` und gilt in beide Richtungen. Für ihn gibt es die Agenturseiten nirgends — auf keinem Gerät, in keiner installierten App. Ein Ziel wie `calls.html` führt bei ihm also sicher ins Leere, egal welches Gerät er in der Hand hält. DogFather und Spicy Media arbeiten in beiden Häusern. Für sie bleibt jedes Ziel, das in EINEM der beiden existiert. --------------------------------------------------------------------- UND DIE BENACHRICHTIGUNG WIRD NICHT UNTERDRÜCKT Das ist die eigentliche Entscheidung. Ein Modi, der als Teilnehmer in einem Call steht, SOLL erfahren, dass er gleich anfängt — er soll nur nicht auf einer Seite landen, die es für ihn nicht gibt. Statt der Seite kommt die Startseite; dort steht, was ansteht. Aufrufen mit: node server/pruef-push-ziel.mjs ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServer } from "node:http"; import { eigenerPort } from "./helfer-port.mjs"; const PORT = await eigenerPort(import.meta, "pruef-push-ziel"); const DIENST = await eigenerPort(import.meta, "pruef-push-ziel (Push-Dienst)", 1); const ordner = mkdtempSync(join(tmpdir(), "ws-pz-")); 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"; /* ---- DIE RUHEZEIT WIRD HERGESTELLT, NICHT ABGEWARTET ------------- Ab dem 29.09.2026 traegt jede Art ihre eigene Ruheregel. Um zu pruefen, dass sie wirkt, muss Ruhezeit sein -- und darauf bis 22 Uhr zu warten waere keine Pruefung, sondern ein Kalendereintrag. PUSH_RUHE_AB = 0 „gesperrt ab Stunde 0“ -> „immer“ ist IMMER Ruhezeit, zu jeder Tageszeit. PUSH_SPAET_AB = 24 „gesperrt ab Stunde 24" -- die es nicht gibt -> „spaet“ ist NIE gesperrt. Beides zusammen macht den Unterschied zwischen den Regeln unabhaengig von der Uhr sichtbar. Ohne das waere diese Pruefung eine Zeitbombe: gruen am Nachmittag, rot um halb drei nachts, und beides ohne Aussage ueber die Software. DIE VORHANDENEN ELF PRUEFUNGEN BLEIBEN UNBERUEHRT -- sie benutzen alle die Art „test“, und die traegt `ruhe: "nie"`. */ process.env.PUSH_RUHE_AB = "0"; process.env.PUSH_SPAET_AB = "24"; 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(180_000, "pruef-push-ziel"); await new Promise((r) => setTimeout(r, 700)); process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const { DatabaseSync } = await import("node:sqlite"); const { randomBytes, createECDH } = await import("node:crypto"); const push = await import("./workspace-push.js"); /* Ein Push-Dienst, der alles annimmt. Er muss nichts entschluesseln -- die Frage hier ist, WOHIN geschickt wurde, und das sagt `benachrichtige` selbst zurueck. Dass der Inhalt unterwegs heil bleibt, beweist pruef-push-weg mit einem nachgebauten Browser. */ let angekommen = 0; const dienst = createServer((q, a) => { q.on("data", () => {}); q.on("end", () => { angekommen++; a.writeHead(201); a.end(); }); }); await new Promise((r) => dienst.listen(DIENST, "127.0.0.1", r)); const jetzt = new Date().toISOString(); const d = new DatabaseSync(process.env.WORKSPACE_DB); /* Ein vollstaendiger Zugang, auch wenn sich hier niemand anmeldet: `code_hash` und `code_salt` sind Pflichtfelder. Eine Person ohne Code waere kein Mensch, den es geben koennte -- und die Pruefung soll mit echten Zeilen rechnen, nicht mit halben. */ const anlegen = (name, rolle) => { d.prepare(`INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,32768,1,?)`) .run(name, rolle, randomBytes(32).toString("hex"), randomBytes(16).toString("hex"), jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; }; const idDogi = anlegen("Filipe", "admin"); const idModi = anlegen("Frida", "modi"); const idHand = anlegen("Rieke", "hand"); const idMana = anlegen("Mara", "manager"); /* Je ein Geraet. Die Schluessel sind echt gebaut -- ein erfundener Schluessel wuerde beim Verschluesseln scheitern, und dann waere "nicht zugestellt" die Antwort auf die falsche Frage. */ function geraet(personId) { const k = createECDH("prime256v1"); k.generateKeys(); d.prepare(`INSERT INTO push_anmeldungen (person_id, endpunkt, p256dh, auth, erstellt) VALUES (?,?,?,?,?)`) .run(personId, `http://127.0.0.1:${DIENST}/push/${personId}`, k.getPublicKey().toString("base64url"), randomBytes(16).toString("base64url"), jetzt); } for (const id of [idDogi, idModi, idHand, idMana]) geraet(id); d.close(); /* ======================================================================= 1. EIN MODI WIRD NICHT AUF EINE AGENTURSEITE GESCHICKT ======================================================================= */ melde(""); melde("=== 1. Das Team ==="); { /* ==== DIE CALLS-SEITE GEHOERT INZWISCHEN DAZU (berichtigt 23.09.2026) Hier stand, dass ein Modi von "/workspace/calls.html" auf die Startseite umgelenkt wird -- die Seite gab es fuer ihn nicht. Seither hat Filipe das Gegenteil bestellt: "jeder der einen kalender hat soll auch sowas haben." Hinter jedem Kalender steht jetzt eine Calls-Kachel (mitCalls in workspace.js), und zwar fuer Modis, rechte und linke Hand. Nachgemessen: je eine Kachel, alle drei. Die Pruefung war damit seit dem Umbau ROT -- und zwar mit einem Satz, der wie ein Befund aussah ("aber nicht auf eine Seite, die es fuer ihn nicht gibt") und in Wahrheit eine erfuellte Bestellung meldete. Sie steht jetzt andersherum: Die Calls-Seite MUSS stehen bleiben. So schuetzt dieselbe Zeile das, was sie vorher verboten hat -- und wenn die Kachel je wieder verschwindet, wird sie rot. */ const a = await push.benachrichtige(idModi, "test", { titel: "Call gleich", text: "In zehn Minuten", ziel: "/workspace/calls.html" }); ok(a.verschickt === 1, `die Benachrichtigung geht raus (${a.verschickt})`); ok(a.ziel === "/workspace/calls.html", `und fuehrt auf die Calls-Seite, die es fuer ihn seit dem 22.09. gibt (${a.ziel})`); /* Das Umlenken selbst muss trotzdem greifen -- sonst bewiese die Zeile darueber nur, dass jedes Ziel stehen bleibt. Das Scouting ist und bleibt Agentur. */ const b = await push.benachrichtige(idHand, "test", { titel: "Scouting", text: "Follow-up faellig", ziel: "/workspace/scouting.html" }); ok(b.ziel === "/workspace/start.html", `auch die rechte Hand landet nicht im Scouting (${b.ziel})`); const b2 = await push.benachrichtige(idModi, "test", { titel: "Scouting", text: "Follow-up faellig", ziel: "/workspace/scouting.html" }); ok(b2.ziel === "/workspace/start.html", `und ein Modi ebenso wenig (${b2.ziel})`); /* UND DIE GEGENPROBE, ohne die die zwei Zeilen darueber nichts beweisen: Ein Ziel, das es auf crew. GIBT, bleibt stehen. Sonst hiesse "wird zur Startseite" nur, dass jedes Ziel dorthin faellt. */ const c = await push.benachrichtige(idModi, "test", { titel: "Aufgabe", text: "Morgen faellig", ziel: "/workspace/aufgaben.html" }); ok(c.ziel === "/workspace/aufgaben.html", `das Aufgabenbrett bleibt das Aufgabenbrett (${c.ziel})`); const e = await push.benachrichtige(idModi, "test", { titel: "Wie geht's dir?", text: "Sechs Fragen", ziel: "/workspace/befinden.html" }); ok(e.ziel === "/workspace/befinden.html", `und die eigene Seite auch (${e.ziel})`); } /* ======================================================================= 2. DIE AGENTUR BEHÄLT IHRE ZIELE ======================================================================= */ melde(""); melde("=== 2. Die Agentur ==="); { const a = await push.benachrichtige(idMana, "test", { titel: "Call", text: "Gleich", ziel: "/workspace/calls.html" }); ok(a.ziel === "/workspace/calls.html", `eine Managerin wird weiterhin zu den Calls geschickt (${a.ziel})`); /* DOGFATHER ARBEITET IN BEIDEN HAEUSERN. Welches Geraet er gerade in der Hand haelt, weiss hier niemand -- also bleibt jedes Ziel, das es in EINEM der beiden gibt. Die Seitenschranke faengt den Rest ab. */ const b = await push.benachrichtige(idDogi, "test", { titel: "Call", text: "Gleich", ziel: "/workspace/calls.html" }); ok(b.ziel === "/workspace/calls.html", `DogFather ebenfalls — er arbeitet in beiden Häusern (${b.ziel})`); } /* ======================================================================= 3. WAS OHNE ZIEL PASSIERT ======================================================================= */ melde(""); melde("=== 3. Ohne Ziel ==="); { const a = await push.benachrichtige(idModi, "test", { titel: "Hallo", text: "Nur so" }); ok(a.ziel === "/workspace/start.html", `ohne Ziel die Startseite (${a.ziel})`); /* EIN ZIEL, DAS ES NIRGENDS GIBT, ist kein Sonderfall -- es faellt durch dieselbe Regel. */ const b = await push.benachrichtige(idModi, "test", { titel: "?", text: "?", ziel: "/workspace/gibt-es-nicht.html" }); ok(b.ziel === "/workspace/start.html", `eine erfundene Seite ebenfalls (${b.ziel})`); } /* ======================================================================= 4. ES IST WIRKLICH ETWAS ANGEKOMMEN ======================================================================= */ melde(""); melde("=== 4. Der Weg ==="); { /* OHNE DIESE ZEILE WAEREN ALLE OBEN GRUEN, auch wenn gar nichts verschickt wurde: `ziel` steht im Rueckgabewert, unabhaengig davon, ob ein Geraet antwortet. */ ok(angekommen >= 8, `${angekommen} Nachrichten sind beim Push-Dienst angekommen`); } /* ------------------------------------------------------------------- */ /* ======================================================================= DAS GESICHT DES HAUSES (29.09.2026) ======================================================================= Im Service Worker stand fest `workspace-192.png` -- das Symbol der Agentur. Gemessen am Live-Bestand gehoeren fuenf von acht Menschen mit angemeldetem Geraet ins Crew-Haus; die sahen auf JEDER Benachrichtigung das Symbol des Hauses, in dem sie nicht arbeiten. Entschieden wird es auf dem Server und nicht im Browser: Dort stuende sonst die verborgene Crew-Adresse in einer Datei, die ohne Anmeldung abrufbar ist -- genau der Befund vom 21.09. bei crew-haus.css. ======================================================================= */ melde(""); melde("=== Das Gesicht des Hauses ==="); { const m = await push.benachrichtige(idModi, "test", { titel: "x", text: "y", ziel: "/workspace/start.html" }); ok(m.haus === "crew", `ein Modi bekommt das Crew-Gesicht (${m.haus})`); const g = await push.benachrichtige(idMana, "test", { titel: "x", text: "y", ziel: "/workspace/start.html" }); ok(g.haus === "agentur", `eine Managerin das der Agentur (${g.haus})`); /* Dass die beiden VERSCHIEDEN sind, ist die eigentliche Aussage -- eine Funktion, die immer „agentur“ sagt, haette die zweite Zeile bestanden und die erste nicht. */ ok(m.haus !== g.haus, "die beiden Haeuser bekommen nicht dasselbe Gesicht"); /* DIE SEITE SCHLAEGT DIE ROLLE. DogFather arbeitet in beiden Haeusern; eine Einladung ins Crew-Kino traegt trotzdem das Crew-Gesicht, weil `/workspace/reaktion.html` in genau ein Haus gehoert (GEHOERT_ZU_ADRESSE). */ const r = await push.benachrichtige(idDogi, "test", { titel: "x", text: "y", ziel: "/workspace/reaktion.html" }); ok(r.haus === "crew", `eine Einladung zur Reaction traegt das Crew-Gesicht (${r.haus})`); /* Gegenprobe zur Zeile darueber: Dieselbe Person, ein neutrales Ziel -- die Seite entscheidet also wirklich, nicht die Person allein. Waere es umgekehrt, stuende hier zweimal dasselbe. */ const n = await push.benachrichtige(idMana, "test", { titel: "x", text: "y", ziel: "/workspace/reaktion.html" }); ok(n.haus === "crew", `auch fuer die Agentur, wenn das ZIEL im Crew-Haus liegt (${n.haus})`); } /* ======================================================================= DAS ABZEICHEN -- ein Zeichen oder ein Klotz? ======================================================================= Das `badge` ist das winzige Zeichen oben in der Statusleiste. Android liest davon AUSSCHLIESSLICH den Alphakanal und malt ihn einfarbig weiss. Bis zum 29.09.2026 stand dort dasselbe `workspace-192.png` wie beim Symbol -- ein vollflaechiges Quadrat OHNE Alphakanal. In der Statusleiste erschien deshalb ein ausgefuellter Klotz. Es stuerzt nichts ab, es sieht nur billig aus, und genau deshalb faellt so etwas niemandem als Fehler auf. EINE PRUEFUNG AUF „DIE DATEI GIBT ES“ HAETTE DAS NIE GEFUNDEN -- die Datei gab es ja. Gefragt ist der INHALT. ======================================================================= */ melde(""); melde("=== Das Abzeichen ==="); { const { readFileSync } = await import("node:fs"); const { dirname, join: fuege } = await import("node:path"); const { fileURLToPath } = await import("node:url"); const { pngDeckung } = await import("./helfer-png-alpha.mjs"); const HIER = dirname(fileURLToPath(import.meta.url)); const REPO = fuege(HIER, ".."); const sw = readFileSync(fuege(REPO, "workspace", "sw.js"), "utf8"); /* ABGELESEN, NICHT ABGESCHRIEBEN: Der Pfad kommt aus dem Service Worker selbst. Stuende hier der Name noch einmal, pruefte die Zeile ihre eigene Kopie. */ const wo = /badge:\s*"([^"]+)"/.exec(sw); ok(!!wo, `der Service Worker nennt ein Abzeichen (${wo?.[1] || "keins"})`); const datei = fuege(REPO, (wo?.[1] || "").replace(/^\//, "")); const e = pngDeckung(datei); ok(e.lesbar, `es liegt als lesbares PNG vor (${e.grund || `${e.breite}x${e.hoch}`})`); ok(e.hatAlpha === true, "es hat einen Alphakanal -- sonst ist es ein Klotz"); /* ZWISCHEN STAUBKORN UND KLOTZ. Unter 10 % ist in der Statusleiste nichts mehr zu sehen, ueber 80 % ist es wieder die Flaeche, die wir gerade abgeschafft haben. */ ok(e.deckung > 0.10 && e.deckung < 0.80, `und deckt ${(e.deckung * 100).toFixed(0)} % seiner Flaeche (10-80 erwartet)`); /* ---- DIE GEGENPROBE, und sie ist der alte Zustand selbst ------- Dieselbe Rechnung auf das App-Symbol, das bis zum 29.09. als Abzeichen diente. Meldet sie dort nicht „kein Alphakanal, 100 %", kann sie den Unterschied nicht sehen -- und alles darueber waere wertlos. */ const alt = pngDeckung(fuege(REPO, "assets/img/app-symbole/workspace-192.png")); ok(alt.lesbar && alt.hatAlpha === false && alt.deckung === 1, `Gegenprobe: das alte Abzeichen wird als Klotz erkannt ` + `(Alpha ${alt.hatAlpha}, Deckung ${alt.deckung})`); /* Und die zwei Gesichter muss es wirklich geben -- ein Symbol, das 404 antwortet, zeigt Android gar nicht an. */ for (const n of ["crew-192.png", "workspace-192.png"]) { const g = pngDeckung(fuege(REPO, "assets/img/app-symbole", n)); ok(g.lesbar, `${n} liegt vor (${g.grund || `${g.breite}x${g.hoch}`})`); ok(sw.includes(n), `und der Service Worker nennt es`); } } /* ======================================================================= DARF DAS NACHTS KOMMEN? -- drei Antworten statt zwei ======================================================================= Bis zum 29.09.2026 stand die Antwort als fest verdrahtete Aufzaehlung in `benachrichtige`, 230 Zeilen von der Artenliste entfernt: const dringend = art === "test" || art === "anruf"; Am 18.09. hat genau das eine Nacht lang alle Anrufe verschluckt. Behoben wurde damals dieser eine Fall; die Frage dahinter blieb stehen, und `dogfather_live` fiel bis heute in dieselbe Falle. ======================================================================= */ melde(""); melde("=== Darf das nachts kommen? ==="); { /* FESTE STUNDEN ALS EINGABE, nicht als Annahme ueber „jetzt". Ein Datum, das der Kalender einholt, waere eine Zeitbombe -- so wie die Weckerpruefung am 07.09. */ const um23 = new Date(2026, 8, 29, 23, 0, 0); const um3 = new Date(2026, 8, 30, 3, 0, 0); const um15 = new Date(2026, 8, 29, 15, 0, 0); /* Die Umgebung ist oben umgestellt; fuer die reine Rechnung wird hier die Vorgabe gefragt -- die drei Regeln, nicht die Zahlen. */ ok(push.ruheRegel("aufgabe_faellig") === "immer", "eine Erinnerung traegt „immer“"); ok(push.ruheRegel("anruf") === "nie", "ein Anruf traegt „nie“"); ok(push.ruheRegel("dogfather_live") === "spaet", "„DogFather ist live“ traegt „spaet“"); ok(push.ruheRegel("reaktion_live") === "spaet", "die Reaction-Einladung auch"); ok(push.ruheRegel("gibtsnicht") === "immer", "eine unbekannte Art bekommt die strengste Regel"); /* Die Rechnung selbst -- mit den Grenzen dieses Laufs (ab 0 bzw. nie). Dass die Vorgaben 22 und 1 sind, steht in der Artenliste; hier wird geprueft, dass die REGELN verschieden wirken. */ ok(push.istRuhezeit(um23, "immer") === true, "um 23 Uhr ist „immer“ gesperrt"); ok(push.istRuhezeit(um23, "nie") === false, "„nie“ nicht"); ok(push.istRuhezeit(um15, "immer") === true, "in diesem Lauf ist „immer“ rund um die Uhr gesperrt (hergestellt)"); ok(push.istRuhezeit(um3, "spaet") === false, "und „spaet“ nie (hergestellt)"); /* ---- UND JETZT DAS VERHALTEN, nicht die Rechnung -------------- Eine Prüfung der Formel allein sagt nichts darueber, ob `benachrichtige` sie auch BENUTZT. Genau diese Luecke war der Fehler: Die Formel war richtig, die Aufzaehlung daneben nicht. */ const erinnerung = await push.benachrichtige(idHand, "aufgabe_faellig", { titel: "x", text: "y", ziel: "/workspace/aufgaben.html" }); ok(erinnerung.grund === "ruhezeit", `eine Erinnerung wird nachts zurueckgehalten (${erinnerung.grund})`); const live = await push.benachrichtige(idHand, "dogfather_live", { titel: "x", text: "y", ziel: "/workspace/unsere-seiten.html" }); ok(live.verschickt === 1, `„DogFather ist live“ geht trotzdem raus (${live.verschickt}, ${live.grund})`); const kino = await push.benachrichtige(idHand, "reaktion_live", { titel: "x", text: "y", ziel: "/workspace/reaktion.html" }); ok(kino.verschickt === 1, `die Einladung zur Reaction auch (${kino.verschickt}, ${kino.grund})`); const ruf = await push.benachrichtige(idHand, "anruf", { titel: "x", text: "y", ziel: "/workspace/chat.html" }); ok(ruf.verschickt === 1, `und ein Anruf erst recht (${ruf.verschickt})`); } melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); dienst.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } process.exit(fehler ? 1 : 0);