""" `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