diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-23 17:31:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-23 17:31:06 +0200 |
| commit | d30e95b2ce1ffe9dc4655855406f2784b5f7af34 (patch) | |
| tree | 91d86ffd59eb3593a76139ab264e1eb5da1ae687 /packages/meshbay-node/src/meshbay_node/roster.py | |
| parent | 339cb427f886a0177014126bb684335837eff067 (diff) | |
| download | meshbay-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/roster.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 134 |
1 files changed, 117 insertions, 17 deletions
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 |