aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 18:16:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 18:16:02 +0200
commit1d169141f3a5542efda2f7fdb0bb88308c06191b (patch)
tree86162905ad819d8c9dd325ccf9629bcdacc50bbf /packages/meshbay-node/src/meshbay_node/ops.py
parent35a7764db3f58a93c32206cb3ce74bb2f03967e7 (diff)
downloadmeshbay-1d169141f3a5542efda2f7fdb0bb88308c06191b.tar.gz
feat(node): member invite --link and member cancel in the CLI
The CLI makes both halves itself — the node's code, then the hub's ticket bound to the address — and prints the link; a refused ticket takes the code back, and cancel takes back both. The CLI never asks the hub to mail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py78
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.