diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 78 |
1 files changed, 78 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index aae0a33..bdcb130 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -29,11 +29,13 @@ import time as _time from dataclasses import asdict from pathlib import Path from typing import Any +from urllib.parse import urlsplit from meshbay_common.background import spawn from meshbay_common.chatbox import new_epoch_key from meshbay_common.crypto import ( generate_gek, + pk_to_b64, unwrap_gek_aes, wrap_gek_aes, ) @@ -271,6 +273,82 @@ async def cancel_invite(state: dict, group_id: str, invite_id: str) -> dict: 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. |