diff --git a/server/pruef-push-ziel.mjs b/server/pruef-push-ziel.mjs new file mode 100644 index 00000000..de3c9e54 --- /dev/null +++ b/server/pruef-push-ziel.mjs @@ -0,0 +1,198 @@ +/* ===================================================================== + 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 { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4423, "pruef-push-ziel"); +const DIENST = await portMussFreiSein(4424, "pruef-push-ziel (Push-Dienst)"); + +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"; + +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 ==="); +{ + 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})`); + /* DAS IST DER PUNKT: Sie wird NICHT unterdrueckt. Ein Modi, der in + einem Call steht, soll erfahren, dass er gleich anfaengt. */ + ok(a.ziel === "/workspace/start.html", + `aber nicht auf eine Seite, die es fuer ihn nicht gibt (${a.ziel})`); + + 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})`); + + /* 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`); +} + +/* ------------------------------------------------------------------- */ +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); diff --git a/server/workspace-push.js b/server/workspace-push.js index 0e93ea42..010cdb1a 100644 --- a/server/workspace-push.js +++ b/server/workspace-push.js @@ -55,6 +55,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, istDogFather, einstellung, einstellungSetzen, heuteLokal, + gehoertAufDieseAdresse, TEAM_DOGI_ROLLEN, } from "./workspace.js"; import { schluesselErzeugen, schicken } from "./workspace-push-krypto.js"; @@ -237,6 +238,56 @@ export function istUhrzeit(t) { Aufraeumen toter Anmeldungen. Eine zweite Stelle, die selbst verschickt, wuerde eine dieser Regeln irgendwann vergessen. ------------------------------------------------------------------ */ +/* ===================================================================== + EINE BENACHRICHTIGUNG MUSS IRGENDWOHIN FUEHREN (16.09.2026) + + Seit dem 15.09. entscheidet die Adresse, welche Seiten es gibt: Auf + crew. gibt es die Agenturseiten nicht. Fuer Seiten, Hinweise und + Suchtreffer ist das umgesetzt -- fuer Push nicht, und zwar aus einem + Grund, der stimmt: Eine Benachrichtigung entsteht, wenn NIEMAND auf + einer Adresse steht. Sie geht an ein Geraet, und das oeffnet die + Adresse, als die es installiert wurde. + + Die Frage laesst sich trotzdem beantworten, nur anders: nicht ueber + die Adresse, sondern ueber den MENSCHEN. Wer im Team ist (rechte + Hand, Modi), kommt nur auf crew. herein -- das steht in + sitzungPasstZurAdresse und gilt in beide Richtungen. Fuer ihn gibt + es die Agenturseiten nirgends, auf keinem Geraet. + + Also: Ein Ziel, das dieser Mensch nirgends oeffnen kann, wird zur + Startseite. Dort steht, was fuer ihn ansteht -- das ist immer noch + eine Auskunft, waehrend eine Weiterleitung ins Leere wie ein Fehler + aussieht. + + DOGFATHER UND SPICY ARBEITEN IN BEIDEN HAEUSERN. Fuer sie gilt jedes + Ziel, das in EINEM der beiden existiert -- welches Geraet sie gerade + in der Hand haben, weiss hier niemand, und die Seitenschranke faengt + den Rest ab. + + WARUM NICHT DIE BENACHRICHTIGUNG GANZ UNTERDRUECKEN: Weil sie meist + trotzdem stimmt. Ein Modi, der als Teilnehmer in einem Call steht, + SOLL erfahren, dass er gleich anfaengt -- nur nicht auf einer Seite + landen, die es fuer ihn nicht gibt. */ +function zielFuer(personId, ziel) { + const heim = "/workspace/start.html"; + if (!ziel) return heim; + try { + const p = db().prepare("SELECT rolle FROM personen WHERE id = ?").get(personId); + if (!p) return heim; + /* Nur das Team ist auf eine Adresse festgelegt. Alle anderen + arbeiten im Agenturhaus, und dort gibt es jede Seite. */ + if (!TEAM_DOGI_ROLLEN.has(p.rolle)) return ziel; + const pfad = String(ziel).split("?")[0].split("#")[0]; + return gehoertAufDieseAdresse({ rolle: p.rolle, haus: "crew" }, pfad) ? ziel : heim; + } catch (f) { + /* IM ZWEIFEL DIE STARTSEITE. Eine Benachrichtigung, die wegen + eines Datenbankfehlers gar nicht erst rausgeht, waere der + groessere Schaden. */ + console.error("[push] Ziel pruefen:", f?.message); + return heim; + } +} + export async function benachrichtige(personId, art, { titel, text, ziel, merkmal }) { if (!ARTEN_SCHLUESSEL.has(art)) return { verschickt: 0, grund: "unbekannte_art" }; if (!willHaben(personId, art)) return { verschickt: 0, grund: "abgeschaltet" }; @@ -257,7 +308,11 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal if (!anmeldungen.length) return { verschickt: 0, grund: "keine_geraete" }; const paar = paarHolen(); - const inhalt = JSON.stringify({ titel, text, ziel: ziel || "/workspace/start.html", art }); + /* Das Ziel wird EINMAL bestimmt und mitgegeben -- damit eine Pruefung + nachsehen kann, wohin wirklich geschickt wurde, ohne die + Nachricht aufmachen zu muessen. */ + const zielEcht = zielFuer(personId, ziel); + const inhalt = JSON.stringify({ titel, text, art, ziel: zielEcht }); let raus = 0; for (const a of anmeldungen) { @@ -281,7 +336,7 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal db().prepare("INSERT OR REPLACE INTO push_verschickt (person_id, merkmal, zeit) VALUES (?,?,?)") .run(personId, merkmal, jetzt()); } - return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt" }; + return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt", ziel: zielEcht }; } /* ---------------------------------------------------------------------