diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-07 22:12:54 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-07 22:20:45 +0200 |
| commit | 462d76898a306981fbeac859cd54da1468e80639 (patch) | |
| tree | 9a49723da751b4a7225e3dd37181ecceed192f3a /packages/meshbay-node | |
| parent | 92e6b9823119b5461efc304a81e79e186a928e6d (diff) | |
| download | meshbay-462d76898a306981fbeac859cd54da1468e80639.tar.gz | |
fix(node): name members admitted without an invitation name
A member who joined by link, by a new device or into an open group was
pinned in the roster with no name, so the audit log showed only the
first characters of their id. The hub's MNP token now carries the
account's username, and after the handshake the node writes it into the
roster for an account whose name is empty. An invitation's name is never
overwritten; the name is a label, authority stays on `sub`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
5 files changed, 64 insertions, 3 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index eb8c031..07b9ea6 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -398,6 +398,24 @@ class Roster: (user_id,)) as cur: return [dict(r) for r in await cur.fetchall()] + async def name_identity(self, user_id: str, username: str) -> bool: + """ + Give a nameless account the name its hub token carries. + + Only an empty name is filled: an invitation names the person the + operator meant, and that stays. Links, devices and open groups pin an + account with no name, which left the audit log showing a bare id. + """ + assert self._db + if not username: + return False + cur = await self._db.execute( + "UPDATE identities SET username = ? " + "WHERE user_id = ? AND username = ''", + (username, user_id)) + await self._db.commit() + return cur.rowcount > 0 + async def revoke_device(self, user_id: str, pk_ed25519: str) -> bool: """ Retire one device, leaving the account's others alone. diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py index 9a6cbd1..48734ab 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py @@ -157,6 +157,13 @@ class AdminMixin: if ident and not self._device_confirmed: self._pinned_pk = ident["pk_ed25519"] + async def _name_identity(self) -> None: + """Record the token's username for an account the roster has unnamed.""" + roster = self._ctx.get("roster") + if roster is None or not self._user_id or not self._username: + return + await roster.name_identity(self._user_id, self._username) + def _is_node_admin(self) -> bool: """ Whether the **account** on this connection is the one the node belongs to. diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py index 20ebc79..fd9c756 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py @@ -380,9 +380,8 @@ class AdmissionMixin: return await self._pin_and_admit( - # The name comes from the invitation, not from the token: the hub does - # not put a username claim in a JWT, so pinning from the session alone - # left the roster nameless and `member revoke <name>` unable to match. + # The invitation's name first: it is the person the operator meant. + # The token's name covers an invitation that carries none. 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"], diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py index 7822186..f3f4aee 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py @@ -212,6 +212,7 @@ class HandshakeMixin: self._group_id = self._pending_group self._username = self._pending_username self._spawn(self._load_pinned_pk()) + self._spawn(self._name_identity()) self._register_peer() diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 585a909..5687215 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -1339,3 +1339,39 @@ async def test_an_operator_cannot_revoke_themselves(tmp_path, roster): session._do_member_revoke({"user_id": session._user_id}) assert _last(session).get("detail") == "Cannot revoke yourself" + + +# ── Names from the token ────────────────────────────────────────────────────── + +async def test_link_admission_is_named_from_the_token(roster): + """A link carries no name, so the account was pinned nameless and the audit + log showed it as a bare id. The token's name fills it.""" + _, pk_ed, pk_x = _keypair() + await roster.pin_identity(user_id="u-link", username="", pk_ed25519=pk_ed, + pk_x25519=pk_x, via="link") + assert await roster.name_identity("u-link", "StephISGoD") + assert (await roster.get_identity("u-link"))["username"] == "StephISGoD" + + +async def test_token_name_never_overwrites_the_invitation(roster): + _, pk_ed, pk_x = _keypair() + await roster.pin_identity(user_id="u-code", username="grenet", pk_ed25519=pk_ed, + pk_x25519=pk_x, via="code") + assert not await roster.name_identity("u-code", "someone-else") + assert not await roster.name_identity("u-code", "") + assert (await roster.get_identity("u-code"))["username"] == "grenet" + + +async def test_handshake_records_the_token_name(roster): + _, pk_ed, pk_x = _keypair() + await roster.pin_identity(user_id="u-open", username="", pk_ed25519=pk_ed, + pk_x25519=pk_x, via="tofu") + + class _Session: + _ctx = {"roster": roster} + _user_id = "u-open" + _username = "alice" + + await WebRTCPeerSession._name_identity(_Session()) + assert (await roster.get_identity("u-open"))["username"] == "alice" + assert "self._spawn(self._name_identity())" in session_method("_complete_handshake") |