diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-23 18:16:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-23 18:16:02 +0200 |
| commit | 1d169141f3a5542efda2f7fdb0bb88308c06191b (patch) | |
| tree | 86162905ad819d8c9dd325ccf9629bcdacc50bbf | |
| parent | 35a7764db3f58a93c32206cb3ce74bb2f03967e7 (diff) | |
| download | meshbay-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>
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 3 | ||||
| -rw-r--r-- | docs/USERGUIDE.md | 14 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_invite_link_client.py | 10 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 42 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/hub_client.py | 42 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 78 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 8 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_cli_dispatch.py | 3 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_ops_links.py | 107 |
9 files changed, 296 insertions, 11 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 861e233..e7d7f0c 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1616,7 +1616,7 @@ meshbay-node group list|add|remove meshbay-node root list|add|remove|set|eject|plug meshbay-node gek init|rotate meshbay-node operator pair -meshbay-node member list|invite|revoke|unpin +meshbay-node member list|invite [--link]|cancel|revoke|unpin meshbay-node file list|rm meshbay-node video rematch meshbay-node chat status|rotate|encrypt-history|prune @@ -3264,7 +3264,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | — | DLNA/UPnP casting (§11.4) | | — | **Bitmap subtitles** (PGS, VOBSUB — about a fifth of the embedded streams). No WebVTT without OCR; they are not listed rather than listed and blank. Burn-in covers them and costs `-c:v copy`, which is what the eight-slot sizing assumes never happens | | — | Delegation (§3.4) | -| — | **`meshbay-node member invite --link`** (§3.4). Invitation links are built on the node, the hub and the interface; the CLI still issues account codes only | | — | Tier 3 roster attestation (§3.3) | | — | Android client | | — | **Federation between two hubs.** The protocol is written and switched off in the code (§7.6); what is not built is one run between two machines | diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index afe63fd..dda69d2 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -567,8 +567,15 @@ membership on the hub and produces a code that never goes near it. Send the code out of band; they enter it the first time they open the group. You do not need to be online then. -**Inviting someone who has no account yet** is the **Invite by link** box, under -the first one: type their e-mail address and **Create link**. Send them the +**Inviting someone who has no account yet** is a link: + +```bash +meshbay-node member invite alice@example.org --link +meshbay-node member cancel <link id> # printed with the link, and in member list +``` + +or the **Invite by link** box in the Members tab, under the first one: type +their e-mail address and **Create link**. Send them the link, or leave **Send the invitation by e-mail** ticked and the hub mails it. They register with that address and land in the group without typing a code. The link works once and only for an account with that address, so a copy that @@ -800,7 +807,8 @@ meshbay-node root eject|plug removable drives meshbay-node gek init|rotate the group's encryption key meshbay-node operator pair authorise a browser -meshbay-node member list|invite|revoke|unpin people +meshbay-node member list|invite|cancel|revoke|unpin + people, and invitation links meshbay-node file list|rm files, from the machine itself meshbay-node chat status|rotate|encrypt-history|prune diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py index c5c0101..be9885a 100644 --- a/packages/meshbay-hub/tests/test_invite_link_client.py +++ b/packages/meshbay-hub/tests/test_invite_link_client.py @@ -96,6 +96,16 @@ def test_one_shape_between_the_hub_and_the_page(tmp_path, monkeypatch): assert got["lower"]["c"] == CODE +def test_the_cli_writes_the_same_link(monkeypatch): + """`meshbay-node member invite --link` builds its link on the node, from the + hub address it was configured with; it must be the hub's own shape.""" + from meshbay_node.ops import _invite_url + monkeypatch.setattr(mail_mod, "_hub_url", "https://hub.example") + n = NODE_PK_STD.replace("+", "-").replace("/", "_").rstrip("=") + assert (_invite_url("https://hub.example/", GROUP, TICKET, NODE_PK_STD, CODE) + == invite_links.invite_url(GROUP, TICKET, n, CODE)) + + @pytest.mark.parametrize("tamper", [ lambda u: u.replace("v=1", "v=2"), lambda u: u.replace(CODE, "K7P2-9WQ"), diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 34d186a..a3bde8f 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -33,6 +33,7 @@ import sys import time from dataclasses import asdict, replace from pathlib import Path +from urllib.parse import quote import uvicorn from meshbay_common import MNP_VERSION @@ -1959,7 +1960,7 @@ def main() -> None: help="init: provision config + keystore | reset: erase all " "node state | status: node state and keys " "| operator pair: pair a " - "browser with this node | member list|invite|revoke|unpin " + "browser with this node | member list|invite|cancel|revoke|unpin " "| group list|add|remove " "| root list|add|remove|set|eject|plug " "| gek init|rotate | file list|rm " @@ -1993,7 +1994,8 @@ def main() -> None: "install|remove|start|stop|status for autostart and " "for service") parser.add_argument("target", nargs="?", - help="username for member invite|revoke|unpin; group name " + help="username for member invite|revoke|unpin (an e-mail address with " + "--link, a link id for member cancel); group name " "for group add; file id for file rm; identifier for " "denylist clear; download cap for transfers set; " "size in GB for transfers max-size") @@ -2012,6 +2014,9 @@ def main() -> None: help="Config file path") parser.add_argument("--group", default=None, help="group id (optional if only one is configured)") + parser.add_argument("--link", action="store_true", + help="member invite: an invitation link for this e-mail " + "address, for someone who may have no account yet") parser.add_argument("--writable", action="store_true", default=None, dest="writable", help="root accepts member uploads (root add/set)") @@ -2383,6 +2388,26 @@ def main() -> None: print(f"usage: meshbay-node member {sub} <username>") sys.exit(1) + if sub == "invite" and args.link: + # A link for someone who may have no account yet, bound to their + # address on the hub. The code and the ticket are both in it, so + # it goes to them and to nobody else — the CLI mails nothing. + group_id = _resolve_group(cfg, args.group) + out = _daemon_api( + cfg, f"/api/groups/{group_id}/invite-links?email={quote(args.target)}", + method="POST") + from meshbay_node.roster import write_code_file + write_code_file(cfg.data_dir, out["link"], out.get("expires_at", ""), + name="invite-link") + print(f"INVITATION LINK {out['link']}") + print(f"valid until {out.get('expires_at', '?')}") + print(f"cancel with meshbay-node member cancel {out['invite_id']}") + print() + print(f"Send it to {args.target} yourself. It works once, and only for an") + print("account registered with that address: they open it, create their") + print("account or sign in, and land in the group without typing a code.") + return + if sub == "invite": group_id = _resolve_group(cfg, args.group) out = _daemon_api( @@ -2402,6 +2427,17 @@ def main() -> None: print(f"also written to {path}") return + if sub == "cancel": + # Takes back a link that has not been used, on the node and the hub. + group_id = _resolve_group(cfg, args.group) + out = _daemon_api( + cfg, f"/api/groups/{group_id}/invite-links/{quote(args.target)}", + method="DELETE") + print(f"invitation link {args.target[:8]} cancelled") + if not out.get("hub", True): + print("The hub's half could not be reached; it expires on its own.") + return + # revoke and unpin both name a person; the daemon resolves the account. # It tries its own roster first and falls back to the hub, so a node that # pinned someone before invitations carried a name is still manageable. @@ -2432,7 +2468,7 @@ def main() -> None: print(f"issue a code: meshbay-node member invite {args.target}") return - print("usage: meshbay-node member list|invite|revoke|unpin") + print("usage: meshbay-node member list|invite|cancel|revoke|unpin") sys.exit(1) if args.command in ("gek-init", "gek"): diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 58bf657..06d3503 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -85,6 +85,11 @@ class HubClient: async def __aenter__(self): return self + @property + def hub_url(self) -> str: + """The hub's address as this node was configured with it.""" + return self._config.hub_url + async def __aexit__(self, *_): await self.close() @@ -258,6 +263,43 @@ class HubClient: r.raise_for_status() return r.json() + async def create_invite_link(self, group_id: str, email: str, expires_at: str, + node_invite_id: str) -> dict: + """ + The hub's half of an invitation link: a ticket bound to `email`. + + Never with `send_email`: the hub refuses mail to a node token, and the + CLI mails nothing — the operator sends the link (docs/USERGUIDE.md §7). + """ + if self._session is None: + raise RuntimeError("Not logged in") + await self.ensure_fresh_token() + r = await self._http.post( + f"/v1/groups/{group_id}/invite-links", + json={"email": email, "expires_at": expires_at, + "node_invite_id": node_invite_id}, + headers=self._session.auth_headers, + ) + r.raise_for_status() + return r.json() + + async def list_invite_links(self, group_id: str) -> list[dict]: + if self._session is None: + raise RuntimeError("Not logged in") + await self.ensure_fresh_token() + r = await self._http.get(f"/v1/groups/{group_id}/invite-links", + headers=self._session.auth_headers) + r.raise_for_status() + return r.json().get("links", []) + + async def delete_invite_link(self, group_id: str, link_id: str) -> None: + if self._session is None: + raise RuntimeError("Not logged in") + await self.ensure_fresh_token() + r = await self._http.delete(f"/v1/groups/{group_id}/invite-links/{link_id}", + headers=self._session.auth_headers) + r.raise_for_status() + # ── Persistent WebSocket (signaling + revocations) ────────────────────── async def send_ws(self, data: str) -> None: 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. diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index f6b93ea..ff28e53 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -322,13 +322,15 @@ def create_ui_app(state: dict) -> FastAPI: async def create_invite(group_id: str, username: str): return await _op(lambda: ops.create_invite(state, group_id, username)) + # Both halves, node and hub, for the CLI: an operator at the machine gets a + # whole link, not a code without a ticket. @app.post("/api/groups/{group_id}/invite-links") - async def create_link_invite(group_id: str): - return await _op(lambda: ops.create_link_invite(state, group_id)) + async def create_link_invite(group_id: str, email: str): + return await _op(lambda: ops.create_link_invitation(state, group_id, email)) @app.delete("/api/groups/{group_id}/invite-links/{invite_id}") async def cancel_invite(group_id: str, invite_id: str): - return await _op(lambda: ops.cancel_invite(state, group_id, invite_id)) + return await _op(lambda: ops.cancel_link_invitation(state, group_id, invite_id)) @app.get("/api/resolve") async def resolve_user(username: str): diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index cf2fd6e..a0b2a64 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -38,6 +38,8 @@ VERBS = [ ["gek-init"], ["member", "list"], ["member", "invite", "bob"], + ["member", "invite", "bob@example.test", "--link"], + ["member", "cancel", "ab" * 16], ["member", "revoke", "bob"], ["member", "unpin", "bob"], # Removed, and it has to say so rather than offering a username for a verb @@ -91,6 +93,7 @@ def stub_daemon(monkeypatch, tmp_path): "groups": [], "files": [], "identities": [], "members": [], "invites": [], "users": [], "jtis": [], "count": 0, "removed": 0, "subject": "all", "code": "TEST-CODE", "expires_at": "", + "link": "https://example.invalid/#/invite?v=1", "invite_id": "ab" * 16, "user_id": "u", "authorized_members": 0, "errors": [], "name": "g", "group_id": "g", "shared_dir": str(tmp_path), "config": str(tmp_path / "node.toml"), diff --git a/packages/meshbay-node/tests/test_ops_links.py b/packages/meshbay-node/tests/test_ops_links.py new file mode 100644 index 0000000..00258d3 --- /dev/null +++ b/packages/meshbay-node/tests/test_ops_links.py @@ -0,0 +1,107 @@ +""" +Invitation links from the operator's own machine (`member invite --link`). + +The CLI makes both halves itself: the node's code, then the hub's ticket bound +to an address, then the link. What can go wrong is a half left behind — a code +the hub never ticketed, occupying one of the group's places, or a ticket whose +code was cancelled — and the CLI asking the hub to mail, which it may not. +""" + +import re + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_node import ops +from meshbay_node.roster import KIND_LINK, Roster + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" +LINK = re.compile( + r"^https://hub\.example/#/invite\?v=1&g=" + GROUP + + r"&t=[A-Za-z0-9_-]{22}&n=[A-Za-z0-9_-]{43}&c=[0-9A-Z]{4}-[0-9A-Z]{4}$") + + +class _Hub: + hub_url = "https://hub.example/some/path" + _session = True + + def __init__(self, refuse=False, unreachable=False): + self.refuse, self.unreachable = refuse, unreachable + self.created, self.deleted, self.links = [], [], [] + + async def create_invite_link(self, group_id, email, expires_at, node_invite_id, **kw): + assert not kw, "the CLI asks the hub for nothing else — no mail" + if self.refuse: + raise RuntimeError("429 too many links") + self.created.append((group_id, email, node_invite_id)) + self.links.append({"link_id": f"L{len(self.links)}", "status": "pending", + "node_invite_id": node_invite_id}) + return {"ticket": "AbCdEfGhIjKlMnOpQr-_12", "expires_at": expires_at} + + async def list_invite_links(self, group_id): + if self.unreachable: + raise RuntimeError("hub down") + return self.links + + async def delete_invite_link(self, group_id, link_id): + self.deleted.append(link_id) + + +@pytest.fixture +async def roster(tmp_path): + r = Roster(db_path=tmp_path / "roster.db") + await r.open() + yield r + await r.close() + + +def _state(roster, hub): + return {"roster": roster, "groups_ctx": {GROUP: {}}, "hub": hub, + "sk_node": Ed25519PrivateKey.generate(), "config": None} + + +async def test_a_whole_link_names_the_code_the_node_holds(roster): + hub = _Hub() + out = await ops.create_link_invitation(_state(roster, hub), GROUP, " Alice@Example.test ") + assert LINK.match(out["link"]), out["link"] + assert hub.created == [(GROUP, "Alice@Example.test", out["invite_id"])] + code = out["link"].rsplit("&c=", 1)[1] + assert await roster.consume_invite(code, "alice", group_id=GROUP) + + +async def test_a_refused_ticket_takes_the_code_back(roster): + with pytest.raises(ops.OpError) as refused: + await ops.create_link_invitation(_state(roster, _Hub(refuse=True)), GROUP, + "alice@example.test") + assert refused.value.status == 502 + assert [i for i in await roster.list_invites() if i["kind"] == KIND_LINK] == [] + + +async def test_an_address_is_required(roster): + with pytest.raises(ops.OpError) as refused: + await ops.create_link_invitation(_state(roster, _Hub()), GROUP, "alice") + assert refused.value.status == 422 + + +async def test_cancelling_takes_back_both_halves(roster): + hub = _Hub() + state = _state(roster, hub) + out = await ops.create_link_invitation(state, GROUP, "alice@example.test") + code = out["link"].rsplit("&c=", 1)[1] + done = await ops.cancel_link_invitation(state, GROUP, out["invite_id"]) + assert done["node"] and done["hub"] and hub.deleted == ["L0"] + assert await roster.consume_invite(code, "alice", group_id=GROUP) is None + + +async def test_an_unreachable_hub_does_not_stop_the_node_half(roster): + hub = _Hub() + state = _state(roster, hub) + out = await ops.create_link_invitation(state, GROUP, "alice@example.test") + hub.unreachable = True + done = await ops.cancel_link_invitation(state, GROUP, out["invite_id"]) + assert done["node"] is True and done["hub"] is False + + +async def test_an_unknown_link_is_a_refusal_not_a_success(roster): + with pytest.raises(ops.OpError) as refused: + await ops.cancel_link_invitation(_state(roster, _Hub()), GROUP, "ab" * 16) + assert refused.value.status == 404 |