From d30e95b2ce1ffe9dc4655855406f2784b5f7af34 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 17:31:06 +0200 Subject: feat(node): invitation links — a code bound to no account until redeemed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New invite kind "link": member of one group, once, never operator, not spendable by an active member, capped at 20 per group, cancellable by handle. Signed ops invite_link_create / invite_cancel, loopback routes, and the known-device join path now accepts a link code. Adds the plan, docs/invite-links.md. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-node/src/meshbay_node/ops.py | 37 ++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-node/src/meshbay_node/ops.py') diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index b680d43..e6b2d1f 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -41,7 +41,7 @@ from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_node.roots import RootError, RootSet, off_disk -from meshbay_node.roster import Roster +from meshbay_node.roster import LinkInviteLimit, Roster log = logging.getLogger(__name__) @@ -236,6 +236,41 @@ async def create_invite(state: dict, group_id: str, username: str, *, "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/invite-links.md §3.5). + """ + 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} + + async def revoke_member(state: dict, user_id: str, group_id: str) -> dict: """ Stop serving the group key to someone. -- cgit v1.2.3