From d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 30 Sep 2026 11:49:56 +0200 Subject: fix: only the owner decides who hosts a group, and nobody is made a member unasked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hub: a node may host a group only if its account owns it or the owner approved that node (new `group_hosts`). Membership was the ceiling, and every member holds the group key, so any member's node could register as a host and be the one clients kept. A node claiming a group it may not host is recorded as a request; the owner is notified once and approves or refuses it (GET/POST/DELETE /v1/groups/{id}/hosts[/{node_id}]), which takes effect on a connected node at once. - hub: an owner adding a username creates an invitation (new `group_invitations`), accepted or declined by the invitee (/v1/groups/invitations, /{id}/invitation/accept|decline). Until then the group is not listed, not dialled, not searched and not in any token. Invitation links, open joins and group creation still make members directly: they are the account's own act. - hub: the MNP token names only the group it is minted for (group_id is now required), so a node operator no longer learns a member's other groups. - SPA: invitations on the home page; invited people and host requests in the group's settings; the transport sends group_id. Ten catalogues. - Browser probes for both screens, run in Chrome and Firefox. - Design §5.2, §7.2, §7.3, AV32, AV33; protocol §6.3; user guide. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-hub/tests/harness/group_hosts_probe.py | 175 ++++++++++++++++++ .../meshbay-hub/tests/harness/invitation_probe.py | 201 +++++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 packages/meshbay-hub/tests/harness/group_hosts_probe.py create mode 100644 packages/meshbay-hub/tests/harness/invitation_probe.py (limited to 'packages/meshbay-hub/tests/harness') diff --git a/packages/meshbay-hub/tests/harness/group_hosts_probe.py b/packages/meshbay-hub/tests/harness/group_hosts_probe.py new file mode 100644 index 0000000..0611e3d --- /dev/null +++ b/packages/meshbay-hub/tests/harness/group_hosts_probe.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +A group owner's settings: who was invited, and which other nodes asked to host. + +Renders the shipped `GroupSettingsPanel` with `fetch` stubbed: one member, one +unanswered invitation, one node asking to host and one already approved. Then +clicks Approve on the request and reports what reached the hub. + + group_hosts_probe.py [--engine chrome|firefox] + +Prints JSON. +""" + +import argparse +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8773 +RECORDS = [] +FINISHED = threading.Event() +socketserver.TCPServer.allow_reuse_address = True + +PAGE = r""" + +
+__HOLD__""" + +HOLD_TAG = '' +HOLD = "" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + FINISHED.set() + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/hold": + FINISHED.wait(60) + self._send(b"", "image/gif") + elif path == "/": + self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +# Launchers and profile rule: see sticky_header_probe.py. +ENGINES = { + "chrome": lambda profile: [ + "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900"], + "firefox": lambda profile: [ + "firefox", "--headless", "--profile", profile, + "--screenshot", str(Path(profile) / "shot.png"), "--window-size", "1100,900"], +} + + +def main() -> int: + global HOLD + ap = argparse.ArgumentParser() + ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") + args = ap.parse_args() + HOLD = HOLD_TAG if args.engine == "firefox" else "" + parent = None + if args.engine == "firefox": + snap = Path.home() / "snap" / "firefox" / "common" + parent = str(snap if snap.is_dir() else Path.home()) + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, + prefix="meshbay-probe-", dir=parent) as profile: + proc = subprocess.Popen(ENGINES[args.engine](profile) + + [f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(dict(RECORDS[0], engine=args.engine), indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/harness/invitation_probe.py b/packages/meshbay-hub/tests/harness/invitation_probe.py new file mode 100644 index 0000000..29133ae --- /dev/null +++ b/packages/meshbay-hub/tests/harness/invitation_probe.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +An invitation waiting on the home page, answered in the real application. + +Being added to a group is an invitation until it is accepted (AV33). What only +the running application shows is that the home page lists it, that Accept and +Decline reach the hub, and that an accepted group then appears among the +reader's groups — while a declined one does not. + +Loads the shipped `app.js` with `fetch` stubbed, once per case: + + accept — the invitation is listed, Accept is clicked + decline — the invitation is listed, Decline is clicked + + invitation_probe.py [--engine chrome|firefox] + +Prints JSON: one object per case. +""" + +import argparse +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8772 +RECORDS = [] +FINISHED = threading.Event() +socketserver.TCPServer.allow_reuse_address = True + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" + +PAGE = r""" +
+__HOLD__ +""".replace("__GROUP__", GROUP) + +HOLD_TAG = '' +HOLD = "" + + +class H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + FINISHED.set() + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/hold": + FINISHED.wait(60) + self._send(b"", "image/gif") + return + if path == "/": + self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8") + return + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else ( + "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream") + self._send(asset.read_bytes(), ctype) + + +# Same launchers and the same profile rule as sticky_header_probe.py, which +# explains both: Firefox is a snap here, and needs a profile under $HOME. +ENGINES = { + "chrome": lambda profile: [ + "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}"], + "firefox": lambda profile: [ + "firefox", "--headless", "--profile", profile, + "--screenshot", str(Path(profile) / "shot.png")], +} + + +def _profile_parent(engine: str) -> str | None: + if engine != "firefox": + return None + snap = Path.home() / "snap" / "firefox" / "common" + return str(snap if snap.is_dir() else Path.home()) + + +def _run(engine: str, case: str) -> dict | None: + before = len(RECORDS) + FINISHED.clear() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, prefix="meshbay-probe-", + dir=_profile_parent(engine)) as profile: + proc = subprocess.Popen( + ENGINES[engine](profile) + [f"http://127.0.0.1:{PORT}/?case={case}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if len(RECORDS) > before: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return RECORDS[before] if len(RECORDS) > before else None + + +def main() -> int: + global HOLD + ap = argparse.ArgumentParser() + ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") + args = ap.parse_args() + HOLD = HOLD_TAG if args.engine == "firefox" else "" + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + results = [_run(args.engine, "accept"), _run(args.engine, "decline")] + if not all(results): + print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr) + return 1 + print(json.dumps([dict(r, engine=args.engine) for r in results], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) -- cgit v1.2.3