summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md3
-rw-r--r--docs/USERGUIDE.md14
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_client.py10
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py42
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py42
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py78
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py8
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py3
-rw-r--r--packages/meshbay-node/tests/test_ops_links.py107
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