summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/hub.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/hub.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/hub.py160
1 files changed, 160 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py
new file mode 100644
index 0000000..c8ce0c1
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py
@@ -0,0 +1,160 @@
+"""
+The hub's API, as a member's client uses it.
+
+The hub signs in, lists groups and the nodes serving them, 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)
+
+ # ── 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"]
+
+
+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}")