"""Who may use the node: pairing, invitations, revocation.""" from __future__ import annotations import logging from urllib.parse import urlsplit from meshbay_common.crypto import pk_to_b64 from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR from meshbay_node.ops.core import OpError, _group_ctx, _hub, _roster from meshbay_node.roster import LinkInviteLimit log = logging.getLogger("meshbay_node.ops") # ── Roster ─────────────────────────────────────────────────────────────────── async def read_roster(state: dict, group_id: str = "") -> dict: roster = state.get("roster") if not roster: return {"identities": [], "members": [], "invites": []} members = await roster.list_members(group_id or None) members = [m for m in members if m.get("pk_ed25519") is not None] return { "identities": await roster.list_identities(), "members": members, "invites": await roster.list_invites(), } async def resolve_user(state: dict, username: str) -> dict: """ Map a username to an account id. The roster answers first — it is the node's own record. The hub is the fallback for identities pinned before invitations carried a name, and for people admitted through an open-join group. Only an account id comes back; no key is ever taken from there. """ roster = state.get("roster") if roster: for ident in await roster.list_identities(): if ident["username"] == username: return {"user_id": ident["user_id"], "source": "roster"} hub = state.get("hub") if hub and hub._session: try: account = await hub.get_user_pubkeys(username) return {"user_id": account["user_id"], "source": "hub"} except Exception: pass raise OpError(f"Unknown user {username!r}", status=404) async def pair_operator(state: dict) -> dict: """ Issue a one-time code that pairs a browser as this node's operator. The code is the whole point: it binds the operator's browser identity key to their account without asking the hub, which is what stops a hub from naming itself node administrator (M3, and the same substitution as H3). Returned once and stored only as a hash. """ roster = _roster(state) user_id = state.get("node_user_id") if not user_id: raise OpError("Node not connected to hub yet", status=503) config = state.get("config") ttl = (config.node.pair_ttl_hours if config else 24) * 3600 code = await roster.create_invite( group_id="", # operator authority is node-wide user_id=user_id, role=ROLE_OPERATOR, created_by="local-cli", ttl=ttl, username=(config.hub.username if config else ""), ) invites = await roster.list_invites() expires = next((i["expires_at"] for i in invites if i["user_id"] == user_id and i["role"] == ROLE_OPERATOR), "") return {"code": code, "expires_at": expires, "user_id": user_id} async def create_invite(state: dict, group_id: str, username: str, *, user_id: str = "", created_by: str = "local-cli") -> dict: """ Issue an invitation code. The hub is asked for the account id and nothing else — never for a key. A hub that answered with the wrong account would produce an invite whose code it never learns, since the code goes to a human out of band. When ``user_id`` is supplied directly (MNP path), the hub lookup is skipped. """ roster = _roster(state) _group_ctx(state, group_id) if not user_id: hub = _hub(state) try: account = await hub.get_user_pubkeys(username) except Exception as e: raise OpError(f"Unknown user {username!r}: {e}", status=404) from e user_id = account["user_id"] # Hub membership first, and fatal if it fails. # # `/v1/groups/mine` joins `GroupMember`, so someone who was never registered # does not see the group at all and can never redeem the code. Creating the # invite first and tolerating a failed registration — which is what this did # — hands the operator a code that cannot work, and says nothing. Worse, an # unreachable hub raised *after* the roster write, leaving a valid code # nobody was ever given; every retry left another. # # Registering before the roster write means a failure costs nothing: no code # exists to be orphaned. A membership row without an invite is harmless — # without the code there is still no group key. # # The endpoint is idempotent (`if not mem: db.add(...)`, no 409), so the SPA # registering the same membership again right after `createInvite` # (group-settings.js) costs nothing either. # # Skipped only when there is no username to register with: the MNP path # allows an empty one (`username || ''` in transport.js), and there the SPA # is the one that registers. if username: try: await _hub(state).add_group_member(group_id, username) except Exception as e: raise OpError( f"Could not register {username!r} on the hub, so the invite " f"could not be redeemed: {e}", status=502) from e config = state.get("config") ttl = (config.node.invite_ttl_hours if config else 168) * 3600 code = await roster.create_invite( group_id=group_id, user_id=user_id, role=ROLE_MEMBER, created_by=created_by, ttl=ttl, username=username, ) invites = await roster.list_invites() expires = next((i["expires_at"] for i in invites if i["user_id"] == user_id and i["group_id"] == group_id), "") return {"code": code, "expires_at": expires, "username": username, "user_id": user_id} async def create_link_invite(state: dict, group_id: str, *, created_by: str = "local-cli") -> dict: """ Issue a code bound to no account, for an invitation link. Nothing is registered on the hub here, unlike `create_invite`: there is no account to register yet. The hub half is a ticket the inviter's client asks the hub for, bound to the invitee's address (docs/MESHBAY_DESIGN.md §7.3). """ roster = _roster(state) _group_ctx(state, group_id) config = state.get("config") ttl = (config.node.invite_ttl_hours if config else 168) * 3600 try: code, invite_id, expires = await roster.create_link_invite( group_id, created_by, ttl=ttl) except LinkInviteLimit as e: raise OpError(str(e), status=429) from e log.info("Invitation link issued: group=%s invite=%s", group_id[:8], invite_id[:8]) return {"code": code, "invite_id": invite_id, "expires_at": expires, "group_id": group_id} async def cancel_invite(state: dict, group_id: str, invite_id: str) -> dict: """Take back an unredeemed invitation link. Unknown or spent is a refusal, so a mistyped handle does not read as success.""" roster = _roster(state) _group_ctx(state, group_id) if not await roster.cancel_invite(group_id, invite_id): raise OpError("No unredeemed invitation link with that id in this group", status=404) log.info("Invitation link cancelled: group=%s invite=%s", group_id[:8], invite_id[:8]) return {"cancelled": True, "invite_id": invite_id, "group_id": group_id} def _invite_url(hub_url: str, group_id: str, ticket: str, node_pk_b64: str, code: str) -> str: """ An invitation link, in the one shape the hub and the interface also write (docs/MESHBAY_DESIGN.md §3.4): everything after `#`, and the node key URL-safe and unpadded. `test_invite_link_client.py` (hub) holds it to the hub's. """ parts = urlsplit(hub_url) origin = f"{parts.scheme}://{parts.netloc}" n = node_pk_b64.replace("+", "-").replace("/", "_").rstrip("=") return f"{origin}/#/invite?v=1&g={group_id}&t={ticket}&n={n}&c={code}" async def create_link_invitation(state: dict, group_id: str, email: str, *, created_by: str = "local-cli") -> dict: """ A whole invitation link, from the operator's own machine: the node's code, then the hub's ticket bound to `email`, then the link. In that order because the ticket names the code's handle. A ticket the hub refuses takes the code back with it — a code nobody can reach the node with would only hold one of the group's places. The hub is never asked to mail: the operator sends the link. """ email = (email or "").strip() if "@" not in email: raise OpError("An invitation link is bound to an e-mail address", status=422) hub = _hub(state) sk_node = state.get("sk_node") if sk_node is None: raise OpError("Node key not loaded", status=503) node = await create_link_invite(state, group_id, created_by=created_by) try: ticket = await hub.create_invite_link( group_id, email, node["expires_at"], node["invite_id"]) except Exception as e: await _roster(state).cancel_invite(group_id, node["invite_id"]) raise OpError(f"The hub refused the link, so none was made: {e}", status=502) from e return { "link": _invite_url(hub.hub_url, group_id, ticket["ticket"], pk_to_b64(sk_node.public_key()), node["code"]), "expires_at": ticket["expires_at"], "invite_id": node["invite_id"], "email": email, } async def cancel_link_invitation(state: dict, group_id: str, invite_id: str) -> dict: """ Take a link back, both halves: the node's code first, which is what stops anyone joining, then the hub's ticket — attempted even when the first half finds nothing to cancel, so neither is left behind (the member-removal rule). """ roster = _roster(state) _group_ctx(state, group_id) node_cancelled = await roster.cancel_invite(group_id, invite_id) hub_cancelled = False hub = state.get("hub") if hub and hub._session: try: for link in await hub.list_invite_links(group_id): if link.get("node_invite_id") == invite_id and link.get("status") == "pending": await hub.delete_invite_link(group_id, link["link_id"]) hub_cancelled = True except Exception as e: log.warning("Invitation link %s: the hub half was not cancelled: %s", invite_id[:8], e) if not node_cancelled and not hub_cancelled: raise OpError("No unredeemed invitation link with that id in this group", status=404) log.info("Invitation link cancelled: group=%s invite=%s node=%s hub=%s", group_id[:8], invite_id[:8], node_cancelled, hub_cancelled) return {"cancelled": True, "invite_id": invite_id, "node": node_cancelled, "hub": hub_cancelled} async def revoke_member(state: dict, user_id: str, group_id: str) -> dict: """ Stop serving the group key to someone. Takes effect on their next connection: the key is wrapped on demand, so there is no stored bundle left behind that would outlive this. Rotating the group key is still required — they hold the current one. **An unredeemed invite is a membership that has not happened yet**, so it is revoked here too, and on its own it is enough for this to be a removal. A member row appears only when a code is consumed: somebody invited to the wrong group has none, this refused them with "no such member", and the browser's removal — node half first, deliberately — died on that refusal before it reached the hub half. They stayed a member on the hub, with a live code, and the interface offered no other way to take either back. """ roster = _roster(state) revoked = await roster.set_status(group_id, user_id, "revoked") dropped = await roster.drop_invites(group_id, user_id) if not revoked and not dropped: raise OpError("No such member in that group", status=404) log.info("Member revoked: user=%s group=%s member=%s invites_dropped=%d", user_id[:8], group_id[:8], revoked, dropped) return {"status": "revoked", "user_id": user_id, "group_id": group_id, "was_member": revoked, "invites_dropped": dropped, # Only what is true: somebody who never redeemed a code never held # the key, and telling an operator to rotate it teaches them that # the advice is noise. "reminder": ("rotate the group key: meshbay-node gek rotate" if revoked else "")} async def unpin_member(state: dict, user_id: str) -> dict: """Forget a pinned identity, so the person can pair again with a new key.""" roster = _roster(state) if not await roster.unpin(user_id): raise OpError("No such pinned identity", status=404) # Drop the stored keypair bundle too. Left behind, it is served to the next # connection, which then cannot open it (the passphrase may have changed # since) and dies in the identity step before it ever reaches the join the # unpin was meant to enable. bundle_store = state.get("bundle_store") if bundle_store: try: await bundle_store.delete_keypair(user_id) except Exception: log.warning("unpin: could not drop keypair bundle for %s", user_id[:8]) log.info("Identity unpinned: user=%s", user_id[:8]) return {"status": "unpinned", "user_id": user_id}