aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 17:31:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 17:31:06 +0200
commitd30e95b2ce1ffe9dc4655855406f2784b5f7af34 (patch)
tree91d86ffd59eb3593a76139ab264e1eb5da1ae687 /packages/meshbay-node/src/meshbay_node
parent339cb427f886a0177014126bb684335837eff067 (diff)
downloadmeshbay-d30e95b2ce1ffe9dc4655855406f2784b5f7af34.tar.gz
feat(node): invitation links — a code bound to no account until redeemed
New invite kind "link": member of one group, once, never operator, not spendable by an active member, capped at 20 per group, cancellable by handle. Signed ops invite_link_create / invite_cancel, loopback routes, and the known-device join path now accepts a link code. Adds the plan, docs/invite-links.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py37
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py134
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py116
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py8
5 files changed, 276 insertions, 25 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index c16e49f..34d186a 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -2350,7 +2350,11 @@ def main() -> None:
if invites:
print()
for i in invites:
- print(f"pending invite user {i['user_id'][:12]} "
+ # A link names nobody until it is used, so its handle is what
+ # identifies it — and what `cancel` takes.
+ who = (f"link {i['invite_id']}" if i.get("kind") == "link"
+ else f"user {i['user_id'][:12]}")
+ print(f"pending invite {who} "
f"group {(i['group_id'] or 'node-wide')[:8]} "
f"expires {i['expires_at']}")
return
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index b680d43..e6b2d1f 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -41,7 +41,7 @@ from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
from meshbay_node.config import DEFAULT_CONFIG_PATH
from meshbay_node.roots import RootError, RootSet, off_disk
-from meshbay_node.roster import Roster
+from meshbay_node.roster import LinkInviteLimit, Roster
log = logging.getLogger(__name__)
@@ -236,6 +236,41 @@ async def create_invite(state: dict, group_id: str, username: str, *,
"username": username, "user_id": user_id}
+async def create_link_invite(state: dict, group_id: str, *,
+ created_by: str = "local-cli") -> dict:
+ """
+ Issue a code bound to no account, for an invitation link.
+
+ 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/invite-links.md §3.5).
+ """
+ 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)
+ 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])
+ return {"code": code, "invite_id": invite_id, "expires_at": expires,
+ "group_id": group_id}
+
+
+async def cancel_invite(state: dict, group_id: str, invite_id: str) -> dict:
+ """Take back an unredeemed invitation link. Unknown or spent is a refusal,
+ so a mistyped handle does not read as success."""
+ roster = _roster(state)
+ _group_ctx(state, group_id)
+ if not await roster.cancel_invite(group_id, invite_id):
+ raise OpError("No unredeemed invitation link with that id in this group",
+ status=404)
+ log.info("Invitation link cancelled: group=%s invite=%s", group_id[:8], invite_id[:8])
+ return {"cancelled": True, "invite_id": invite_id, "group_id": group_id}
+
+
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/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 9533f25..8360df3 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -29,6 +29,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
import aiosqlite
+from meshbay_common.join import ROLE_MEMBER
from meshbay_common.paths import fold
log = logging.getLogger(__name__)
@@ -58,6 +59,21 @@ DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing
# keys by its hash, so a longer window widens nothing an attacker can use.
DEFAULT_DEVICE_REQUEST_TTL = 3600
+# What an invitation is bound to. An `account` code names its user_id when it is
+# issued; a `link` code names nobody until it is redeemed, because a link goes to
+# someone who may not have an account yet (docs/invite-links.md). The kind is a
+# column rather than an empty user_id so that "bound to nobody" can never be
+# read as "bound to anybody" — the mistake AV1 records, once per codebase.
+KIND_ACCOUNT = "account"
+KIND_LINK = "link"
+# Unredeemed links one group may hold. A link is a bearer code at this node, so
+# the number outstanding is the number of strangers who could walk in with one.
+MAX_LINK_INVITES_PER_GROUP = 20
+
+
+class LinkInviteLimit(Exception):
+ """The group already holds as many unredeemed links as it may."""
+
_SCHEMA = """\
-- One row per DEVICE, not per person. A browser and a desktop client are two
-- keys belonging to one account, and `user_id` alone as the key made the second
@@ -146,7 +162,9 @@ CREATE TABLE IF NOT EXISTS invites (
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
- used_at TEXT
+ used_at TEXT,
+ kind TEXT NOT NULL DEFAULT 'account',
+ invite_id TEXT NOT NULL DEFAULT ''
);
"""
@@ -221,6 +239,14 @@ class Roster:
if "username" not in columns:
await self._db.execute(
"ALTER TABLE invites ADD COLUMN username TEXT NOT NULL DEFAULT ''")
+ # The same, for invitation links. Every row that predates them was
+ # issued to an account, which is what the default says.
+ if "kind" not in columns:
+ await self._db.execute(
+ "ALTER TABLE invites ADD COLUMN kind TEXT NOT NULL DEFAULT 'account'")
+ if "invite_id" not in columns:
+ await self._db.execute(
+ "ALTER TABLE invites ADD COLUMN invite_id TEXT NOT NULL DEFAULT ''")
await self._migrate_identities_to_devices()
await self._db.commit()
@@ -1067,20 +1093,72 @@ class Roster:
"""
assert self._db
await self._db.execute(
- "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL",
- (group_id, user_id),
+ "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL "
+ "AND kind = ?",
+ (group_id, user_id, KIND_ACCOUNT),
)
code = generate_code()
expires = datetime.now(UTC) + timedelta(seconds=ttl)
await self._db.execute(
"INSERT INTO invites (code_hash, group_id, user_id, username, role, "
- "created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ "created_by, created_at, expires_at, kind) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(hash_code(code), group_id, user_id, username, role, created_by, _now(),
- expires.isoformat(timespec="seconds")),
+ expires.isoformat(timespec="seconds"), KIND_ACCOUNT),
)
await self._db.commit()
return code
+ async def create_link_invite(
+ self, group_id: str, created_by: str, ttl: int = DEFAULT_INVITE_TTL,
+ ) -> tuple[str, str, str]:
+ """
+ Issue a code bound to no account: `(code, invite_id, expires_at)`.
+
+ Always a member of one named group — never node-wide, never an operator,
+ because whoever holds it is admitted without being named. `invite_id`
+ is a handle for cancelling it, random and unrelated to the code, so it
+ can be shown and sent about where the code must not be.
+ """
+ assert self._db
+ if not group_id:
+ raise ValueError("an invitation link names a group")
+ async with self._db.execute(
+ "SELECT COUNT(*) FROM invites WHERE group_id = ? AND kind = ? "
+ "AND used_at IS NULL AND expires_at > ?",
+ (group_id, KIND_LINK, _now()),
+ ) as cur:
+ (outstanding,) = await cur.fetchone()
+ if outstanding >= MAX_LINK_INVITES_PER_GROUP:
+ raise LinkInviteLimit(
+ f"this group already has {outstanding} unredeemed invitation links; "
+ "cancel one or wait for one to be used or to expire")
+ code = generate_code()
+ invite_id = secrets.token_hex(16)
+ expires = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat(timespec="seconds")
+ await self._db.execute(
+ "INSERT INTO invites (code_hash, group_id, user_id, username, role, "
+ "created_by, created_at, expires_at, kind, invite_id) "
+ "VALUES (?, ?, '', '', ?, ?, ?, ?, ?, ?)",
+ (hash_code(code), group_id, ROLE_MEMBER, created_by, _now(), expires,
+ KIND_LINK, invite_id),
+ )
+ await self._db.commit()
+ return code, invite_id, expires
+
+ async def cancel_invite(self, group_id: str, invite_id: str) -> bool:
+ """Take back an unredeemed link of this group. A redeemed one stays: it
+ is the record that the join happened."""
+ assert self._db
+ if not invite_id:
+ return False
+ cur = await self._db.execute(
+ "DELETE FROM invites WHERE kind = ? AND invite_id = ? AND group_id = ? "
+ "AND used_at IS NULL",
+ (KIND_LINK, invite_id, group_id),
+ )
+ await self._db.commit()
+ return cur.rowcount > 0
+
async def drop_invites(self, group_id: str, user_id: str) -> int:
"""
Cancel any code this person has not redeemed yet for this group.
@@ -1090,16 +1168,22 @@ class Roster:
"""
assert self._db
cur = await self._db.execute(
- "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL",
- (group_id, user_id),
+ "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL "
+ "AND kind = ?",
+ (group_id, user_id, KIND_ACCOUNT),
)
await self._db.commit()
return cur.rowcount
- async def consume_invite(self, code: str, user_id: str) -> dict | None:
+ async def consume_invite(self, code: str, user_id: str,
+ group_id: str = "") -> dict | None:
"""
Redeem a code for `user_id`, or return None.
+ `group_id` is the group the redeeming connection authenticated to. An
+ account code ignores it, as it always has; a link requires it, and it
+ must be the link's own group.
+
Single use is enforced by the UPDATE's WHERE clause: two connections racing
the same code cannot both see `used_at IS NULL`, so exactly one wins.
"""
@@ -1113,19 +1197,35 @@ class Roster:
return None
invite = dict(row)
- if invite["used_at"] is not None:
- return None
- # A code is valid for exactly one account, so a leaked code cannot be
- # redeemed by whoever finds it first.
- if invite["user_id"] != user_id:
+ if invite["used_at"] is not None or not user_id:
return None
if datetime.fromisoformat(invite["expires_at"]) < datetime.now(UTC):
return None
- cur = await self._db.execute(
- "UPDATE invites SET used_at = ? WHERE code_hash = ? AND used_at IS NULL",
- (_now(), code_hash),
- )
+ if invite["kind"] == KIND_ACCOUNT:
+ # A code is valid for exactly one account, so a leaked code cannot be
+ # redeemed by whoever finds it first.
+ if invite["user_id"] != user_id:
+ return None
+ update = ("UPDATE invites SET used_at = ? WHERE code_hash = ? "
+ "AND used_at IS NULL", (_now(), code_hash))
+ elif invite["kind"] == KIND_LINK:
+ # Whoever holds it — so everything else about it is fixed: a member,
+ # of the group it names, reached through that group, by somebody not
+ # already in it. A row that says anything else is not honoured.
+ if (invite["role"] != ROLE_MEMBER or not invite["group_id"]
+ or invite["group_id"] != group_id):
+ return None
+ member = await self.get_member(group_id, user_id)
+ if member and member["status"] == "active":
+ return None
+ update = ("UPDATE invites SET used_at = ?, user_id = ? WHERE code_hash = ? "
+ "AND used_at IS NULL", (_now(), user_id, code_hash))
+ invite["user_id"] = user_id
+ else:
+ return None
+
+ cur = await self._db.execute(*update)
await self._db.commit()
if cur.rowcount == 0:
return None
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 809c5c5..95b628f 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -55,7 +55,9 @@ from meshbay_common.adminop import (
OP_GEK_ROTATE,
OP_GROUP_ATTACH,
OP_GROUP_DETACH,
+ OP_INVITE_CANCEL,
OP_INVITE_CREATE,
+ OP_INVITE_LINK_CREATE,
OP_MEMBER_REVOKE,
OP_MEMBER_UNPIN,
OP_MUSICBRAINZ_ENABLED,
@@ -147,6 +149,7 @@ from meshbay_node.roots import (
off_disk,
safe_subdir,
)
+from meshbay_node.roster import KIND_ACCOUNT, KIND_LINK
from meshbay_node.transfers import TransferSlots
from meshbay_node.transport.wire import index_sync_message
@@ -181,6 +184,9 @@ USER_BLOB_ACCOUNT_MAX = 8 * 1024 * 1024
_USER_BLOB_KIND_RE = re.compile(
r"^(playlists|playlist:[A-Za-z0-9_-]{1,64})$")
+# An invitation link's handle, as `roster.create_link_invite` mints it.
+_INVITE_ID_RE = re.compile(r"[0-9a-f]{32}")
+
# Chat link-preview results, kept in memory only (docs/MESHBAY_DESIGN.md §6.5:
# the node
# produces enrichment on demand and keeps nothing durable — the asking device
@@ -704,6 +710,10 @@ class WebRTCPeerSession:
self._do_admin_response(msg)
elif mtype == MNP.INVITE_CREATE:
self._do_invite_create(msg)
+ elif mtype == MNP.INVITE_LINK_CREATE:
+ self._do_invite_link_create(msg)
+ elif mtype == MNP.INVITE_CANCEL:
+ self._do_invite_cancel(msg)
elif mtype == MNP.MEMBER_REVOKE:
self._do_member_revoke(msg)
elif mtype == MNP.DEVICE_REQUEST:
@@ -1228,6 +1238,44 @@ class WebRTCPeerSession:
"username": str(msg.get("username", ""))[:64],
})
+ def _do_invite_link_create(self, msg: dict) -> None:
+ """
+ Issue a code bound to no account, for an invitation link — into the
+ group this connection authenticated to, and no other: a link names its
+ group, so the operator signs for exactly that one (docs/invite-links.md).
+ """
+ group_id = self._group_id or ""
+ if not group_id:
+ self._send({"type": "error", "detail": "No group on this connection"})
+ return
+ if msg.get("group_id") and msg["group_id"] != group_id:
+ self._send({"type": "error", "detail": "Wrong group for this session"})
+ return
+ if not self._has_admin_authority():
+ self._send({
+ "type": "error",
+ "detail": "No operator paired — run `meshbay-node operator pair`",
+ })
+ return
+ self._issue_admin_challenge(
+ OP_INVITE_LINK_CREATE, f"link:{group_id}", {"group_id": group_id})
+
+ def _do_invite_cancel(self, msg: dict) -> None:
+ """Take back an unredeemed link of this group, by its handle."""
+ group_id = self._group_id or ""
+ invite_id = str(msg.get("invite_id", ""))
+ if not group_id:
+ self._send({"type": "error", "detail": "No group on this connection"})
+ return
+ if not _INVITE_ID_RE.fullmatch(invite_id):
+ self._send({"type": "error", "detail": "Not an invitation id"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(
+ OP_INVITE_CANCEL, invite_id, {"group_id": group_id, "invite_id": invite_id})
+
async def _do_keypair_bundle_fetch(self) -> None:
"""Serve the caller's encrypted keypair bundle during the handshake window."""
bundle_store = self._ctx.get("bundle_store")
@@ -1561,14 +1609,21 @@ class WebRTCPeerSession:
# re-invited). Without this gate a stale roster row lets them
# back in without proving they received the new code.
pending_invite = any(
- i["user_id"] == user_id
+ i["kind"] == KIND_ACCOUNT
+ and i["user_id"] == user_id
and i["group_id"] in (session_group, "")
for i in await roster.list_invites())
- if pending_invite:
+ # Or they bring a link for this group: somebody already pinned here
+ # through another group, which is the ordinary case for a link, or
+ # somebody removed from it and invited back. Only when they are not
+ # an active member — a member opening the group leaves the link for
+ # whoever it was meant for.
+ active = bool(member) and member.get("status") == "active"
+ if pending_invite or (code and not active):
if not code:
self._join_refuse("code_required")
return
- invite = await roster.consume_invite(code, user_id)
+ invite = await roster.consume_invite(code, user_id, session_group)
if not invite:
self._join_refuse("code_invalid")
return
@@ -1580,7 +1635,8 @@ class WebRTCPeerSession:
self._audit_join(
"join_pinned",
f"group={invite['group_id'][:8]} role={invite['role']} "
- "via=code (device already known)")
+ f"via={'link' if invite['kind'] == KIND_LINK else 'code'} "
+ "(device already known)")
member = (await roster.get_member(session_group, user_id)
or await roster.get_member(invite["group_id"], user_id))
@@ -1609,7 +1665,7 @@ class WebRTCPeerSession:
self._join_refuse("code_required")
return
- invite = await roster.consume_invite(code, user_id)
+ invite = await roster.consume_invite(code, user_id, session_group)
if not invite:
self._join_refuse("code_invalid")
return
@@ -1620,7 +1676,8 @@ class WebRTCPeerSession:
# left the roster nameless and `member revoke <name>` unable to match.
roster, user_id, invite["username"] or username, pk_ed_b64, pk_x_b64,
group_id=invite["group_id"], role=invite["role"],
- approved_by=invite["created_by"], via="code")
+ approved_by=invite["created_by"],
+ via="link" if invite["kind"] == KIND_LINK else "code")
# The roster row comes from the invitation; the key comes from the
# connection. An operator pairing is node-wide (empty group), but they
# redeemed the code while opening a group and expect to read it — and
@@ -5969,6 +6026,12 @@ class WebRTCPeerSession:
elif pending["op"] == OP_INVITE_CREATE:
self._spawn(
self._admin_exec_invite_create(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_INVITE_LINK_CREATE:
+ self._spawn(
+ self._admin_exec_invite_link_create(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_INVITE_CANCEL:
+ self._spawn(
+ self._admin_exec_invite_cancel(pending, transcript, sig_bytes))
elif pending["op"] == OP_GEK_ROTATE:
self._spawn(
self._admin_exec_gek_rotate(pending, transcript, sig_bytes))
@@ -6106,6 +6169,47 @@ class WebRTCPeerSession:
"username": result.get("username", ""),
})
+ async def _admin_exec_invite_link_create(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", "invite_link_create")
+ return
+ try:
+ result = await self._run_op(
+ ops.create_link_invite, pending["payload"]["group_id"],
+ created_by=self._user_id or "")
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("invite_link_create", f"invite={result['invite_id'][:8]}")
+ self._send({
+ "type": MNP.INVITE_LINK_RESULT,
+ "v": MNP_VERSION,
+ "code": result["code"],
+ "invite_id": result["invite_id"],
+ "expires_at": result["expires_at"],
+ "group_id": result["group_id"],
+ })
+
+ async def _admin_exec_invite_cancel(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"invite_cancel:{pending['subject'][:8]}")
+ return
+ payload = pending["payload"]
+ try:
+ await self._run_op(ops.cancel_invite, payload["group_id"], payload["invite_id"])
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("invite_cancel", f"invite={payload['invite_id'][:8]}")
+ self._send({"type": "ack", "v": MNP_VERSION, "detail": "invite_cancelled",
+ "invite_id": payload["invite_id"]})
+
async def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None:
file_path, refusal = await off_disk(ctx["roots"], _locate, ctx["roots"], entry)
if refusal == ROOT_NOT_SERVED:
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 888487c..f6b93ea 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -322,6 +322,14 @@ 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))
+ @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))
+
+ @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))
+
@app.get("/api/resolve")
async def resolve_user(username: str):
return await _op(lambda: ops.resolve_user(state, username))