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/commands/join.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/commands/join.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/join.py | 139 |
1 files changed, 139 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/join.py b/packages/meshbay-cli/src/meshbay_cli/commands/join.py new file mode 100644 index 0000000..ee75c96 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/join.py @@ -0,0 +1,139 @@ +""" +`join`: become a member of a group, and be admitted by its node. + +Two halves, as in the application (docs/MESHBAY_DESIGN.md §3.4): the hub makes +the account a member — an invitation accepted, or an invitation link redeemed — +and the node admits it, wrapping the group key for a key pair made here and +kept. The node is dialled at once, so the group can be used as soon as this +returns. A link's code goes to the node the link names and to no other; a node +that is away gets the code at the next connection, kept until then. +""" + +import json +import sys + +from ..client.groups import my_groups, open_group +from ..client.hub import HubError +from ..client.invite import parse_link +from ..client.paths import group_arg, handle, resolve_group +from ..client.session import DeviceLinkPending, NodeRefusal +from ..errors import AUTH, NODE, NOT_FOUND, USAGE, CliError +from ..output import err, safe, sentence +from .context import Context +from .invitations import find_invitation + +HELP = "join a group: accept an invitation, or use an invitation link" + +BROWSER_HINT = ("A browser will not open {group} with your passphrase: this machine " + "leaves no copy of your keys on its node. To leave one, as the " + "application does with Browser access on: meshbay-cli browser-access on") + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("join", parents=[parent], help=HELP, description=sentence(HELP) + + ". The group's node is reached at once and admits this " + "machine with its own keys.") + p.add_argument("target", metavar="INVITATION|LINK", + help="an invitation as `invitations` lists it, an invitation link as " + "it was sent, or a group of yours whose node asked for a code") + p.add_argument("--code", help="the invitation's code (XXXX-XXXX), when the node asks " + "for one") + + +async def _membership(ctx: Context, account, target: str) -> tuple[str, str | None, + str | None]: + """The hub's half: the group's id, and the code and node a link carries.""" + link = parse_link(target) + if link is not None: + if link.origin and link.origin.rstrip("/") != ctx.hub_url: + raise CliError(USAGE, f"This link was made on {link.origin}; run the command " + f"with --hub {link.origin}.") + try: + preview = await account.call(account.hub.preview_link, link.ticket) + if not preview.get("already_member"): + await account.call(account.hub.redeem_link, link.ticket) + except HubError as e: + if e.status in (403, 404): + raise CliError(NOT_FOUND, "This invitation link no longer works: used by " + "another account, cancelled, or expired. Ask " + "for a new one.") from e + raise + if not ctx.args.quiet and not ctx.args.json: + err(f"Joining {safe(preview.get('group_name') or '')}, invited by " + f"{safe(preview.get('inviter') or '?')}.") + return link.group_id, link.code, link.node_pk + + invitations = await account.call(account.hub.invitations) + try: + group = find_invitation(invitations, target) + except CliError as e: + if e.code != NOT_FOUND: + raise + # Already a member: only the node's half is left to do. + group = resolve_group(await my_groups(account), group_arg(target)) + return group["id"], None, None + try: + await account.call(account.hub.accept_invitation, group["id"]) + except HubError as e: + if e.status == 404: + raise CliError(NOT_FOUND, "That invitation is no longer waiting.") from e + raise + return group["id"], None, None + + +async def run(ctx: Context) -> int: + a = ctx.args + await ctx.hub.check_version() + account = ctx.account() + if not account.remembered: + raise CliError(AUTH, "Joining needs a saved session: the keys made for the group " + "are kept on this machine. Run `meshbay-cli login` first.") + group_id, link_code, node_pk = await _membership(ctx, account, a.target) + group = next((g for g in await my_groups(account) if g["id"] == group_id), None) + if group is None: + raise CliError(NOT_FOUND, "The hub did not make this account a member.") + name = handle(group) + code = (a.code or link_code or "").strip().upper() or None + + while True: + try: + session = await open_group(account, group, join_code=code, node_pk=node_pk) + break + except NodeRefusal as e: + if e.refusal_code != "code_required" or code: + raise + if not sys.stdin.isatty(): + raise CliError(NODE, f"You are a member of {safe(name)}; its node has not " + "admitted you yet and needs your invitation's code:\n" + f" meshbay-cli join {name} --code CODE") from e + sys.stderr.write(f"The node asks for your invitation's code to {name} " + "(XXXX-XXXX): ") + sys.stderr.flush() + code = sys.stdin.readline().strip().upper() or None + if not code: + raise CliError(USAGE, "No code given.") from e + except CliError as e: + if e.code != NODE or not code or isinstance(e, NodeRefusal | DeviceLinkPending): + raise + await account.keep_join_code(group_id, code, node_pk) + raise CliError(NODE, f"You are a member of {safe(name)}, but its node cannot " + f"be reached now ({e.message}). The code is kept and " + "sent at the next connection, such as:\n" + f" meshbay-cli ls {name}/") from e + await session.close() + + if a.json: + print(json.dumps({"joined": True, "group": group["name"], + "owner": group.get("owner_username"), "id": group_id, + "path": name})) + else: + print(f"Joined {safe(name)}. Its files: meshbay-cli ls {safe(name)}/") + if account.browser_access is None and not a.quiet: + try: + prefs = await account.call(account.hub.preferences) + except (HubError, CliError): + prefs = {} + # The hub's word can only produce this line, never a copy of the keys. + if prefs.get("browser_access") != "off": + err(BROWSER_HINT.format(group=safe(name))) + return 0 |