diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-21 01:35:13 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-21 01:35:13 +0200 |
| commit | 03bcfcdef2ff9a3d6ed0776f7580a45a5401ef37 (patch) | |
| tree | 84fa39ced04e0d82da1f40176e218b200c274761 | |
| parent | 90e26beff212e4994b8e4f18c8260f2aa8a9b40c (diff) | |
| download | meshbay-03bcfcdef2ff9a3d6ed0776f7580a45a5401ef37.tar.gz | |
fix(hub): removing from a group no longer unpins the account
The node half called `unpin` after `revoke`, and an unpin takes no group:
it deletes the identity and every member row the account holds on this
node, and drops the stored keypair bundle with them. Taking somebody out
of one group took them out of all of them, silently. `revoke` is
group-scoped and is what withdraws the key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | 20 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_member_removal.py | 21 |
2 files changed, 37 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 16f6901..d7a1a66 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -738,6 +738,22 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, * take them back out. The hub half only ever removes access, so it is not * the half to skip when the other is in doubt; what the node said is * reported once the removal has been done, rather than in place of it. + * + * **The node half is `revoke`, and only `revoke`.** It used to unpin as + * well, and an unpin takes no group: `roster.unpin` deletes the identity and + * *every* member row the account holds on this node, and `ops.unpin_member` + * drops the stored keypair bundle with them. So taking somebody out of one + * group took them out of all of them — and a person removed from a group + * they had only been invited to lost the one they had been reading all + * afternoon, six hours before anyone noticed. Nothing on the node said so: + * the loopback path writes no audit entry, so the journal showed a clean + * join and then, hours later, a refusal with nothing in between. Their + * invitation was already spent, so there was no way back that did not start + * with a new code. Membership is per group; a pinned identity is the + * person's key for this whole node, and forgetting it is a separate + * operator decision with its own button (`node-page.js`). The MNP branch + * below never unpinned, which is the tell that this call was the odd one + * out rather than the pair of the other. */ const removeMember = useCallback(async (member) => { const transport = transportRef && transportRef.current; @@ -750,10 +766,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, await platform.node.call('POST', `/api/members/${member.user_id}/revoke?group_id=${groupId}`); } catch { /* best effort — node may not host this group */ } - try { - await platform.node.call('POST', - `/api/members/${member.user_id}/unpin`); - } catch { /* best effort */ } } else if (transport && transport.connected && operatorPaired) { const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) diff --git a/packages/meshbay-hub/tests/test_member_removal.py b/packages/meshbay-hub/tests/test_member_removal.py index 609ef6a..7af73a0 100644 --- a/packages/meshbay-hub/tests/test_member_removal.py +++ b/packages/meshbay-hub/tests/test_member_removal.py @@ -53,6 +53,27 @@ def test_the_hub_half_runs_even_when_the_node_refuses(): "member on the hub, in this list and in every other session") +def test_removing_from_one_group_does_not_unpin_the_account(): + """ + `unpin` takes no group. `roster.unpin` deletes the identity and *every* + member row the account holds on this node, and `ops.unpin_member` drops the + stored keypair bundle with them — so calling it from a per-group removal + took the person out of every other group on the node as well. Silently: + the loopback path writes no audit entry, so the node's journal showed a + clean join and then, hours later, a refusal with nothing in between. And + unrecoverably for anyone whose invitation was already spent, since the way + back starts with a code the operator has to issue again. + + Forgetting a pinned key is a separate operator decision with its own button + on the node page. If one is ever wanted here, it has to be scoped to the + group first. + """ + body = _remove_member_body() + assert "/unpin" not in body, ( + "a per-group removal unpins the account node-wide: every other group " + "this person holds on this node goes with it") + + def test_a_refusal_is_still_reported(): """ Swallowing it would be the opposite mistake — the group key is what the |