aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/group_hosts_probe.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
commitd3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 (patch)
tree95ff1252c80a71e93c5098822d31b835572d8b52 /packages/meshbay-hub/tests/harness/group_hosts_probe.py
parent69554fac7eba6eef7eb8a1c0111c5b92e7f21256 (diff)
downloadmeshbay-d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4.tar.gz
fix: only the owner decides who hosts a group, and nobody is made a member unasked
- 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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/harness/group_hosts_probe.py')
-rw-r--r--packages/meshbay-hub/tests/harness/group_hosts_probe.py175
1 files changed, 175 insertions, 0 deletions
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"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<div id="root"></div>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { GroupSettingsPanel } from '/group-settings.js';
+
+const realFetch = window.fetch.bind(window);
+const calls = [];
+const json = (body) => ({ ok: true, status: 200, statusText: '', headers: new Headers(),
+ json: async () => body, text: async () => JSON.stringify(body) });
+window.fetch = async (url, init = {}) => {
+ const u = String(url);
+ calls.push(`${init.method || 'GET'} ${u.replace(/^https?:\/\/[^/]+/, '')}`);
+ if (u.endsWith('/v1/groups/g1/members')) return json({
+ group_id: 'g1', admin_id: 'u1',
+ members: [{ user_id: 'u1', username: 'the-owner' }],
+ invited: [{ user_id: 'u9', username: 'someone_asked' }] });
+ if (u.endsWith('/v1/groups/g1/hosts')) return json({ hosts: [
+ { node_id: 'n-asking', pk_node: 'AAAA', username: 'a-member', status: 'pending',
+ online: true },
+ { node_id: 'n-ok', pk_node: 'BBBB', username: 'another-member', status: 'approved',
+ online: false }] });
+ return json({});
+};
+
+await initLocale();
+render(html`<${GroupSettingsPanel} groupId="g1" token="t" userId="u1"
+ group=${{ id: 'g1', name: 'a group', owner_username: 'the-owner', is_admin: true }}
+ transportRef=${{ current: null }} gekRef=${{ current: null }}
+ isNodeAdmin=${false} operatorPaired=${false} connected=${false}
+ enabledApps=${[]} entries=${[]} nodeDirs=${[]} />`, document.getElementById('root'));
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const out = {};
+try {
+ await wait(1200);
+ const rows = [...document.querySelectorAll('.admin-table tr')].map((r) => r.innerText);
+ out.invited_row = rows.some((r) => r.includes('someone_asked'));
+ out.host_rows = rows.filter((r) => r.includes('AAAA') || r.includes('BBBB')).length;
+ const asking = [...document.querySelectorAll('.admin-table tr')]
+ .find((r) => r.innerText.includes('AAAA'));
+ out.buttons_on_request = asking ? asking.querySelectorAll('button').length : -1;
+ const approved = [...document.querySelectorAll('.admin-table tr')]
+ .find((r) => r.innerText.includes('BBBB'));
+ out.buttons_on_approved = approved ? approved.querySelectorAll('button').length : -1;
+ if (asking) { asking.querySelector('button').click(); await wait(800); }
+ out.decision = calls.filter((c) => c.includes('/hosts/'));
+} catch (e) {
+ out.error = String(e && e.stack || e);
+}
+realFetch('/log', { method: 'POST', body: JSON.stringify(out) });
+</script>__HOLD__</body></html>"""
+
+HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">'
+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())