aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops/members.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 01:30:47 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 01:30:47 +0200
commit762233772162a05be67432aa551a430b939250de (patch)
tree311a2f72fef30fec6017313c55ed5beefcf0237e /packages/meshbay-node/src/meshbay_node/ops/members.py
parent320620a18399eb43c4d9056e9fe4c3ffac8fcfd4 (diff)
downloadmeshbay-762233772162a05be67432aa551a430b939250de.tar.gz
refactor(node): split ops.py into the ops package
Each section of ops.py becomes a module of meshbay_node/ops/ (core, node_toml, members, chat, groups, roots, files, settings, apps), cut as text; ops/__init__.py keeps the docstring and re-exports every name, so `ops.<name>` is unchanged for every caller. Logger name unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops/members.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/members.py314
1 files changed, 314 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops/members.py b/packages/meshbay-node/src/meshbay_node/ops/members.py
new file mode 100644
index 0000000..2fd3899
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/ops/members.py
@@ -0,0 +1,314 @@
+"""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}