diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 00:56:04 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 00:56:04 +0200 |
| commit | 6f68bb6c1970e26de1f66c073ea5d7fecd21840d (patch) | |
| tree | 859c74db5819f1d49046e71a53e12afe5eb4193a /packages/meshbay-cli/src/meshbay_cli/client/invite.py | |
| parent | 16d92c96bde3a65fc54a93b4662df30cafc831e4 (diff) | |
| download | meshbay-6f68bb6c1970e26de1f66c073ea5d7fecd21840d.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/invite.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/invite.py | 50 |
1 files changed, 50 insertions, 0 deletions
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://<hub>/#/invite?v=1&g=<group>&t=<ticket>&n=<node key>&c=<CODE>`. + +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) |