From 6f68bb6c1970e26de1f66c073ea5d7fecd21840d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 00:56:04 +0200 Subject: feat(cli): invitations, join, decline, leave, devices, browser access join accepts an invitation or redeems a link pasted as sent, then has the node admit a key pair made here, with the invitation's code (a link's code goes only to the node it names; one for a node that is away is kept for the next connection). decline, leave and devices revoke ask first, and refuse without --yes where there is no terminal. devices approve countersigns the one waiting request a code matches, none when several do. browser-access is the application's setting, kept per machine and never enabled from the hub's mirror: on leaves a copy sealed under M on each node, off withdraws it, unset touches nothing. A key pair made for a first contact is now kept. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-cli/src/meshbay_cli/client/invite.py | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/meshbay-cli/src/meshbay_cli/client/invite.py (limited to 'packages/meshbay-cli/src/meshbay_cli/client/invite.py') diff --git a/packages/meshbay-cli/src/meshbay_cli/client/invite.py b/packages/meshbay-cli/src/meshbay_cli/client/invite.py new file mode 100644 index 0000000..6b26c97 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/invite.py @@ -0,0 +1,50 @@ +""" +Invitation links: `https:///#/invite?v=1&g=&t=&n=&c=`. + +The shape `static/invite-link.js` reads and `api/invite_links.py` writes +(docs/MESHBAY_DESIGN.md ยง3.4). Two secrets: the ticket is for the hub, which +makes the first account to redeem it a member; the code is for the node the +link names, and goes to no other node and never to the hub. Every field is +checked for its exact shape, as the application checks it. +""" + +import re +from dataclasses import dataclass +from urllib.parse import parse_qs, urlsplit + +_GROUP = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +_TICKET = re.compile(r"^[A-Za-z0-9_-]{22}$") +_NODE_PK = re.compile(r"^[A-Za-z0-9_-]{43}$") +_CODE = re.compile(r"^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$") + + +@dataclass +class InviteLink: + origin: str # the hub the link was made on, '' when only the fragment was given + group_id: str + ticket: str + node_pk: str # standard base64, as the hub lists node keys + code: str + + +def node_pk_from_link(n: str) -> str: + """The link's URL-safe, unpadded key, as the hub and the transport write it.""" + b64 = n.replace("-", "+").replace("_", "/") + return b64 + "=" * (-len(b64) % 4) + + +def parse_link(text: str) -> InviteLink | None: + """The link in `text`, or None when it holds none (or a malformed one).""" + s = text.strip() + at = s.find("/invite?") + if at < 0: + return None + q = {k: v[0] for k, v in parse_qs(s[at + len("/invite?"):]).items()} + g, t, n = q.get("g", ""), q.get("t", ""), q.get("n", "") + c = q.get("c", "").upper() + if q.get("v") != "1" or not (_GROUP.match(g) and _TICKET.match(t) + and _NODE_PK.match(n) and _CODE.match(c)): + return None + parts = urlsplit(s) + origin = f"{parts.scheme}://{parts.netloc}" if parts.scheme and parts.netloc else "" + return InviteLink(origin, g, t, node_pk_from_link(n), c) -- cgit v1.2.3