diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 17:10:15 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 17:10:15 +0200 |
| commit | 90c69477d5f701158112b3c294eff26312f89da6 (patch) | |
| tree | 2691ca096da5cf93482bc750c7a03599fbc44dc8 /packages/meshbay-node | |
| parent | 8bb94a39609f57be2c486f579849eebb04606808 (diff) | |
| download | meshbay-main.tar.gz | |
A link is redeemable by whoever opens it first, so it can be sent by any
messaging app. The address is optional (mail + label only); a link lives
7 days, fixed. Adds a Share button; see MESHBAY_DESIGN.md §3.4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
8 files changed, 60 insertions, 36 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/cli/members.py b/packages/meshbay-node/src/meshbay_node/cli/members.py index ba68056..5d26bd0 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/members.py +++ b/packages/meshbay-node/src/meshbay_node/cli/members.py @@ -62,18 +62,15 @@ def member(args) -> None: print("which is what turning the old switch off meant.") sys.exit(1) - if not args.target: - 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. + # A link for someone who may have no account yet, redeemable by the + # first account that opens it. The code and the ticket are both in + # it, so it goes to them and to nobody else — the CLI mails nothing. + # The address, if given, only labels the link in the owner's list. group_id = _resolve_group(cfg, args.group) + query = f"?email={quote(args.target)}" if args.target else "" out = _daemon_api( - cfg, f"/api/groups/{group_id}/invite-links?email={quote(args.target)}", - method="POST") + cfg, f"/api/groups/{group_id}/invite-links{query}", 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") @@ -81,11 +78,15 @@ def member(args) -> None: 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.") + print("Send it yourself, by any messaging app. It works once, for seven") + print("days, for whoever opens it first: they create their account or") + print("sign in, and land in the group without typing a code.") return + if not args.target: + print(f"usage: meshbay-node member {sub} <username>") + sys.exit(1) + if sub == "invite": group_id = _resolve_group(cfg, args.group) out = _daemon_api( diff --git a/packages/meshbay-node/src/meshbay_node/cli/parser.py b/packages/meshbay-node/src/meshbay_node/cli/parser.py index c1f15be..29a9f63 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/parser.py +++ b/packages/meshbay-node/src/meshbay_node/cli/parser.py @@ -52,8 +52,8 @@ def build_parser() -> argparse.ArgumentParser: "install|remove|start|stop|status for autostart and " "for service") parser.add_argument("target", nargs="?", - help="username for member invite|revoke|unpin (an e-mail address with " - "--link, a link id for member cancel); group name " + help="username for member invite|revoke|unpin (an optional e-mail label " + "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") @@ -73,8 +73,8 @@ def build_parser() -> argparse.ArgumentParser: 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") + help="member invite: an invitation link, for someone who " + "may have no account yet (valid 7 days, single use)") parser.add_argument("--writable", action="store_true", default=None, dest="writable", help="root accepts member uploads (root add/set)") diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 06d3503..49bb920 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -266,7 +266,7 @@ class HubClient: 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`. + The hub's half of an invitation link: a ticket, labelled `email` if any. 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). diff --git a/packages/meshbay-node/src/meshbay_node/ops/members.py b/packages/meshbay-node/src/meshbay_node/ops/members.py index 2fd3899..b5da9c0 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/members.py +++ b/packages/meshbay-node/src/meshbay_node/ops/members.py @@ -159,15 +159,13 @@ async def create_link_invite(state: dict, group_id: str, *, 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/MESHBAY_DESIGN.md §7.3). + the hub for, redeemable by the first account that opens the link + (docs/MESHBAY_DESIGN.md §7.3). Seven days, whatever `invite_ttl_hours` says. """ 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) + code, invite_id, expires = await roster.create_link_invite(group_id, created_by) 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]) @@ -199,11 +197,12 @@ def _invite_url(hub_url: str, group_id: str, ticket: str, node_pk_b64: str, code 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, *, +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. + then the hub's ticket, then the link. `email` is optional and only labels + the link in the owner's list. 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 @@ -211,8 +210,8 @@ async def create_link_invitation(state: dict, group_id: str, email: str, *, 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) + if email and "@" not in email: + raise OpError("Not an e-mail address", status=422) hub = _hub(state) sk_node = state.get("sk_node") if sk_node is None: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 80bf16d..8144373 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -54,6 +54,10 @@ CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy # node-wide lockout. DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing +# An invitation link is a bearer secret that may travel through any messaging +# service, so its lifetime is fixed rather than the operator's setting: the +# hub clamps its ticket to the same seven days. +LINK_INVITE_TTL = 7 * 24 * 3600 # A device-add code is read off one screen and typed into another, in one # sitting. An hour is comfort, not security: the code is bound to the requesting # keys by its hash, so a longer window widens nothing an attacker can use. @@ -1109,7 +1113,7 @@ class Roster: return code async def create_link_invite( - self, group_id: str, created_by: str, ttl: int = DEFAULT_INVITE_TTL, + self, group_id: str, created_by: str, ttl: int = LINK_INVITE_TTL, ) -> tuple[str, str, str]: """ Issue a code bound to no account: `(code, invite_id, expires_at)`. diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index eabe97a..090eb21 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -325,7 +325,7 @@ def create_ui_app(state: dict) -> FastAPI: # 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, email: str): + 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}") diff --git a/packages/meshbay-node/tests/golden/cli.json b/packages/meshbay-node/tests/golden/cli.json index 5ddd7fd..8db5abd 100644 --- a/packages/meshbay-node/tests/golden/cli.json +++ b/packages/meshbay-node/tests/golden/cli.json @@ -4,7 +4,7 @@ "asked": [], "exit": 0, "stderr": "", - "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an e-mail address with --link, a\n link id for member cancel); group name for group add; file id for file rm;\n identifier for denylist clear; download cap for transfers set; size in GB\n for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link for this e-mail address, for someone who\n may have no account yet\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n", + "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an optional e-mail label with\n --link, a link id for member cancel); group name for group add; file id\n for file rm; identifier for denylist clear; download cap for transfers\n set; size in GB for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link, for someone who may have no account yet\n (valid 7 days, single use)\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n", "systemctl": [] }, "autostart no-such-sub": { @@ -348,7 +348,7 @@ "asked": [], "exit": 0, "stderr": "", - "stdout": "INVITATION LINK https://example.invalid/#/invite?v=1\nvalid until \ncancel with meshbay-node member cancel abababababababababababababababab\n\nSend it to bob@example.test yourself. It works once, and only for an\naccount registered with that address: they open it, create their\naccount or sign in, and land in the group without typing a code.\n", + "stdout": "INVITATION LINK https://example.invalid/#/invite?v=1\nvalid until \ncancel with meshbay-node member cancel abababababababababababababababab\n\nSend it yourself, by any messaging app. It works once, for seven\ndays, for whoever opens it first: they create their account or\nsign in, and land in the group without typing a code.\n", "systemctl": [] }, "member list": { diff --git a/packages/meshbay-node/tests/test_ops_links.py b/packages/meshbay-node/tests/test_ops_links.py index 00258d3..9ad01d2 100644 --- a/packages/meshbay-node/tests/test_ops_links.py +++ b/packages/meshbay-node/tests/test_ops_links.py @@ -1,13 +1,15 @@ """ 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. +The CLI makes both halves itself: the node's code, then the hub's ticket, then +the link. The address is optional and only labels 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 +from datetime import UTC, datetime, timedelta import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -76,12 +78,30 @@ async def test_a_refused_ticket_takes_the_code_back(roster): assert [i for i in await roster.list_invites() if i["kind"] == KIND_LINK] == [] -async def test_an_address_is_required(roster): +async def test_an_address_is_optional_but_must_be_one_when_given(roster): + hub = _Hub() + out = await ops.create_link_invitation(_state(roster, hub), GROUP) + assert LINK.match(out["link"]), out["link"] + assert hub.created == [(GROUP, "", out["invite_id"])] with pytest.raises(ops.OpError) as refused: - await ops.create_link_invitation(_state(roster, _Hub()), GROUP, "alice") + await ops.create_link_invitation(_state(roster, hub), GROUP, "alice") assert refused.value.status == 422 +async def test_a_link_lives_seven_days_whatever_the_invite_setting(roster): + class _Node: + invite_ttl_hours = 24 * 30 + + class _Config: + node = _Node() + + state = _state(roster, _Hub()) + state["config"] = _Config() + out = await ops.create_link_invite(state, GROUP) + left = datetime.fromisoformat(out["expires_at"]) - datetime.now(UTC) + assert timedelta(days=6, hours=23) < left <= timedelta(days=7) + + async def test_cancelling_takes_back_both_halves(roster): hub = _Hub() state = _state(roster, hub) |