""" The hub's API, as a member's client uses it. The hub signs in, lists groups and the nodes serving them, keeps memberships (invitations, links, leaving) and preferences, mints the token a node is shown, and relays the WebRTC offer. Nothing else: no listing, no file and no byte of content passes through it (docs/MESHBAY_DESIGN.md §1.3, §8.4). """ import logging import time import httpx from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from .. import __version__ from ..errors import AUTH, NODE, NOT_FOUND, VERSION, CliError from .keys import b64 log = logging.getLogger(__name__) DEFAULT_HUB = "https://meshbay.org" class HubError(Exception): def __init__(self, status: int, detail: str): super().__init__(f"{status}: {detail}") self.status = status self.detail = detail def _version_tuple(v: str) -> tuple[int, ...]: out = [] for part in v.split("."): digits = "".join(c for c in part if c.isdigit()) out.append(int(digits or 0)) return tuple(out) class Hub: def __init__(self, url: str): self.url = url.rstrip("/") self._http = httpx.AsyncClient( base_url=self.url, timeout=30, headers={"User-Agent": f"meshbay-cli/{__version__}"}) async def aclose(self) -> None: await self._http.aclose() async def _call(self, method: str, path: str, *, token: str | None = None, json: dict | None = None) -> dict: headers = {"Authorization": f"Bearer {token}"} if token else {} try: r = await self._http.request(method, path, json=json, headers=headers) except httpx.HTTPError as e: raise CliError(NODE, f"Cannot reach the hub at {self.url}: {e}") from e if r.status_code >= 400: try: detail = r.json().get("detail", r.text) except Exception: detail = r.text raise HubError(r.status_code, str(detail)) return r.json() if r.content else {} # ── Version ────────────────────────────────────────────────────────────── async def check_version(self) -> str | None: """Refuse a client below the hub's minimum. Returns a notice, or None.""" try: info = await self._call("GET", "/v1/hub/version") except HubError as e: raise CliError(NODE, f"{self.url} does not answer as a MeshBay hub " f"({e.status})") from e client = info.get("client") or {} mine = _version_tuple(__version__) minimum = client.get("minimum") if minimum and mine < _version_tuple(minimum): raise CliError(VERSION, f"This hub needs meshbay-cli {minimum} or later; " f"this is {__version__}.") recommended = client.get("recommended") if recommended and mine < _version_tuple(recommended): return f"meshbay-cli {recommended} is available; this is {__version__}." return None # ── Signing in ─────────────────────────────────────────────────────────── async def login(self, username: str, auth_key: str, known_browser: str | None) -> dict: body = {"username": username, "auth_key": auth_key} if known_browser: body["known_browser"] = known_browser try: return await self._call("POST", "/v1/users/login", json=body) except HubError as e: raise _sign_in_refusal(e) from e async def register_device(self, token: str, pk: str, label: str, auth_key: str) -> dict: return await self._call("POST", "/v1/users/devices", token=token, json={ "pk_auth_ed25519": pk, "label": label[:64], "auth_key": auth_key}) async def delete_device(self, token: str, device_id: str) -> None: await self._call("DELETE", f"/v1/users/devices/{device_id}", token=token) async def device_auth(self, username: str, sk: Ed25519PrivateKey) -> dict: ts = int(time.time()) sig = sk.sign(f"meshbay:user_auth:{username}:{ts}".encode()) try: return await self._call("POST", "/v1/users/auth", json={ "username": username, "timestamp": ts, "signature": b64(sig)}) except HubError as e: raise _sign_in_refusal(e) from e async def refresh(self, refresh_token: str) -> dict: return await self._call("POST", "/v1/users/token/refresh", json={"refresh_token": refresh_token}) async def logout(self, refresh_token: str) -> None: await self._call("POST", "/v1/users/logout", json={"refresh_token": refresh_token}) async def me(self, token: str) -> dict: return await self._call("GET", "/v1/users/me", token=token) # ── The account ────────────────────────────────────────────────────────── async def captcha(self) -> dict | None: """A proof-of-work challenge, or None when this hub has the check off.""" try: return await self._call("GET", "/v1/users/captcha") except HubError as e: if e.status == 404: return None raise async def register(self, username: str, email: str, auth_key: str, captcha_token: str | None) -> dict: # Never the recovery key: the hub does not see it and refuses a # register that carries one (docs/MESHBAY_DESIGN.md §3.6). body = {"username": username, "email": email, "auth_key": auth_key} if captcha_token: body["captcha_token"] = captcha_token return await self._call("POST", "/v1/users/register", json=body) async def verify_email(self, email: str, code: str) -> dict: return await self._call("POST", "/v1/users/verify-email", json={"email": email, "code": code}) async def bundle_pepper(self, token: str, auth_key: str) -> dict: return await self._call("POST", "/v1/users/me/bundle-pepper", token=token, json={"auth_key": auth_key}) async def change_passphrase(self, token: str, old_auth_key: str, new_auth_key: str) -> dict: return await self._call("POST", "/v1/users/password", token=token, json={"old_auth_key": old_auth_key, "new_auth_key": new_auth_key}) async def reset_request(self, username: str, email: str, captcha_token: str | None) -> dict: body = {"username": username, "email": email} if captcha_token: body["captcha_token"] = captcha_token return await self._call("POST", "/v1/users/password/reset-request", json=body) async def reset_passphrase(self, username: str, code: str, new_auth_key: str) -> dict: return await self._call("POST", "/v1/users/password/reset", json={ "username": username, "code": code, "new_auth_key": new_auth_key}) async def change_email(self, token: str, email: str, auth_key: str) -> dict: return await self._call("PATCH", "/v1/users/me", token=token, json={"email": email, "auth_key": auth_key}) async def verify_email_change(self, token: str, code: str) -> dict: return await self._call("POST", "/v1/users/verify-email-change", token=token, json={"code": code}) async def revoke_sessions(self, token: str) -> dict: return await self._call("POST", "/v1/users/me/sessions/revoke", token=token) async def delete_account(self, token: str, auth_key: str) -> dict: return await self._call("DELETE", "/v1/users/me", token=token, json={"auth_key": auth_key}) # ── Groups and nodes ───────────────────────────────────────────────────── async def my_groups(self, token: str) -> list[dict]: return (await self._call("GET", "/v1/groups/mine", token=token))["groups"] async def group_nodes(self, token: str, group_id: str) -> list[dict]: try: return (await self._call("GET", f"/v1/groups/{group_id}/nodes", token=token))["nodes"] except HubError as e: if e.status in (403, 404): raise CliError(NOT_FOUND, f"The hub refused the group: {e.detail}") from e raise async def mnp_token(self, token: str, node_pk: str, group_id: str) -> str: """A token for one node and one group, never the hub session (E10).""" r = await self._call("POST", "/v1/nodes/mnp-token", token=token, json={"node_pk": node_pk, "group_id": group_id}) return r["mnp_token"] async def offer(self, token: str, node_id: str, sdp: str) -> str: try: r = await self._call("POST", f"/v1/nodes/{node_id}/webrtc/offer", token=token, json={"sdp": sdp, "ice_candidates": []}) except HubError as e: raise CliError(NODE, f"The node did not answer the offer: {e.detail}") from e return r["sdp"] # ── Joining and leaving ────────────────────────────────────────────────── async def invitations(self, token: str) -> list[dict]: return (await self._call("GET", "/v1/groups/invitations", token=token))["invitations"] async def accept_invitation(self, token: str, group_id: str) -> dict: return await self._call("POST", f"/v1/groups/{group_id}/invitation/accept", token=token) async def decline_invitation(self, token: str, group_id: str) -> dict: return await self._call("POST", f"/v1/groups/{group_id}/invitation/decline", token=token) async def preview_link(self, token: str, ticket: str) -> dict: return await self._call("POST", "/v1/invite-links/preview", token=token, json={"ticket": ticket}) async def redeem_link(self, token: str, ticket: str) -> dict: return await self._call("POST", "/v1/invite-links/redeem", token=token, json={"ticket": ticket}) async def leave_group(self, token: str, group_id: str) -> dict: return await self._call("POST", f"/v1/groups/{group_id}/leave", token=token) # ── Preferences ────────────────────────────────────────────────────────── async def preferences(self, token: str) -> dict: return await self._call("GET", "/v1/users/me/preferences", token=token) async def set_preference(self, token: str, key: str, value: str) -> dict: return await self._call("PUT", f"/v1/users/me/preferences/{key}", token=token, json={"value": value}) def _sign_in_refusal(e: HubError) -> CliError: if e.status == 401: return CliError(AUTH, "Sign-in refused: wrong username or passphrase.") if e.status == 403 and e.detail == "email_verification_required": return CliError(AUTH, "This account's e-mail address is not verified yet.") if e.status == 403: return CliError(AUTH, f"Sign-in refused: {e.detail}.") if e.status == 429: return CliError(AUTH, f"Too many attempts: {e.detail}") return CliError(AUTH, f"Sign-in failed ({e.status}): {e.detail}")