aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/pyproject.toml2
-rw-r--r--packages/meshbay-node/src/meshbay_node/__init__.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/members.py25
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/parser.py8
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/members.py17
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py2
-rw-r--r--packages/meshbay-node/tests/golden/cli.json4
-rw-r--r--packages/meshbay-node/tests/test_ops_links.py32
10 files changed, 62 insertions, 38 deletions
diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml
index e5e44dd..1265da4 100644
--- a/packages/meshbay-node/pyproject.toml
+++ b/packages/meshbay-node/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "meshbay-node"
-version = "0.15.0"
+version = "0.16.0"
description = "MeshBay Node — local file host, streaming server, and group daemon"
requires-python = ">=3.12"
dependencies = [
diff --git a/packages/meshbay-node/src/meshbay_node/__init__.py b/packages/meshbay-node/src/meshbay_node/__init__.py
index b0a8528..5cbf5ea 100644
--- a/packages/meshbay-node/src/meshbay_node/__init__.py
+++ b/packages/meshbay-node/src/meshbay_node/__init__.py
@@ -1,3 +1,3 @@
"""MeshBay Node — local file host, streaming server, and group daemon."""
-__version__ = "0.15.0"
+__version__ = "0.16.0"
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)