1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
|
"""Who may use the node: pairing, invitations, revocation."""
from __future__ import annotations
import logging
from urllib.parse import urlsplit
from meshbay_common.crypto import pk_to_b64
from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
from meshbay_node.ops.core import OpError, _group_ctx, _hub, _roster
from meshbay_node.roster import LinkInviteLimit
log = logging.getLogger("meshbay_node.ops")
# ── Roster ───────────────────────────────────────────────────────────────────
async def read_roster(state: dict, group_id: str = "") -> dict:
roster = state.get("roster")
if not roster:
return {"identities": [], "members": [], "invites": []}
members = await roster.list_members(group_id or None)
members = [m for m in members if m.get("pk_ed25519") is not None]
return {
"identities": await roster.list_identities(),
"members": members,
"invites": await roster.list_invites(),
}
async def resolve_user(state: dict, username: str) -> dict:
"""
Map a username to an account id.
The roster answers first — it is the node's own record. The hub is the
fallback for identities pinned before invitations carried a name, and for
people admitted through an open-join group. Only an account id comes back;
no key is ever taken from there.
"""
roster = state.get("roster")
if roster:
for ident in await roster.list_identities():
if ident["username"] == username:
return {"user_id": ident["user_id"], "source": "roster"}
hub = state.get("hub")
if hub and hub._session:
try:
account = await hub.get_user_pubkeys(username)
return {"user_id": account["user_id"], "source": "hub"}
except Exception:
pass
raise OpError(f"Unknown user {username!r}", status=404)
async def pair_operator(state: dict) -> dict:
"""
Issue a one-time code that pairs a browser as this node's operator.
The code is the whole point: it binds the operator's browser identity key to
their account without asking the hub, which is what stops a hub from naming
itself node administrator (M3, and the same substitution as H3). Returned
once and stored only as a hash.
"""
roster = _roster(state)
user_id = state.get("node_user_id")
if not user_id:
raise OpError("Node not connected to hub yet", status=503)
config = state.get("config")
ttl = (config.node.pair_ttl_hours if config else 24) * 3600
code = await roster.create_invite(
group_id="", # operator authority is node-wide
user_id=user_id,
role=ROLE_OPERATOR,
created_by="local-cli",
ttl=ttl,
username=(config.hub.username if config else ""),
)
invites = await roster.list_invites()
expires = next((i["expires_at"] for i in invites
if i["user_id"] == user_id and i["role"] == ROLE_OPERATOR), "")
return {"code": code, "expires_at": expires, "user_id": user_id}
async def create_invite(state: dict, group_id: str, username: str, *,
user_id: str = "",
created_by: str = "local-cli") -> dict:
"""
Issue an invitation code.
The hub is asked for the account id and nothing else — never for a key. A hub
that answered with the wrong account would produce an invite whose code it
never learns, since the code goes to a human out of band.
When ``user_id`` is supplied directly (MNP path), the hub lookup is skipped.
"""
roster = _roster(state)
_group_ctx(state, group_id)
if not user_id:
hub = _hub(state)
try:
account = await hub.get_user_pubkeys(username)
except Exception as e:
raise OpError(f"Unknown user {username!r}: {e}", status=404) from e
user_id = account["user_id"]
# Hub membership first, and fatal if it fails.
#
# `/v1/groups/mine` joins `GroupMember`, so someone who was never registered
# does not see the group at all and can never redeem the code. Creating the
# invite first and tolerating a failed registration — which is what this did
# — hands the operator a code that cannot work, and says nothing. Worse, an
# unreachable hub raised *after* the roster write, leaving a valid code
# nobody was ever given; every retry left another.
#
# Registering before the roster write means a failure costs nothing: no code
# exists to be orphaned. A membership row without an invite is harmless —
# without the code there is still no group key.
#
# The endpoint is idempotent (`if not mem: db.add(...)`, no 409), so the SPA
# registering the same membership again right after `createInvite`
# (group-settings.js) costs nothing either.
#
# Skipped only when there is no username to register with: the MNP path
# allows an empty one (`username || ''` in transport.js), and there the SPA
# is the one that registers.
if username:
try:
await _hub(state).add_group_member(group_id, username)
except Exception as e:
raise OpError(
f"Could not register {username!r} on the hub, so the invite "
f"could not be redeemed: {e}", status=502) from e
config = state.get("config")
ttl = (config.node.invite_ttl_hours if config else 168) * 3600
code = await roster.create_invite(
group_id=group_id,
user_id=user_id,
role=ROLE_MEMBER,
created_by=created_by,
ttl=ttl,
username=username,
)
invites = await roster.list_invites()
expires = next((i["expires_at"] for i in invites
if i["user_id"] == user_id
and i["group_id"] == group_id), "")
return {"code": code, "expires_at": expires,
"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/MESHBAY_DESIGN.md §7.3).
"""
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}
def _invite_url(hub_url: str, group_id: str, ticket: str, node_pk_b64: str, code: str) -> str:
"""
An invitation link, in the one shape the hub and the interface also write
(docs/MESHBAY_DESIGN.md §3.4): everything after `#`, and the node key
URL-safe and unpadded. `test_invite_link_client.py` (hub) holds it to the hub's.
"""
parts = urlsplit(hub_url)
origin = f"{parts.scheme}://{parts.netloc}"
n = node_pk_b64.replace("+", "-").replace("/", "_").rstrip("=")
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, *,
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.
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
would only hold one of the group's places. The hub is never asked to mail:
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)
hub = _hub(state)
sk_node = state.get("sk_node")
if sk_node is None:
raise OpError("Node key not loaded", status=503)
node = await create_link_invite(state, group_id, created_by=created_by)
try:
ticket = await hub.create_invite_link(
group_id, email, node["expires_at"], node["invite_id"])
except Exception as e:
await _roster(state).cancel_invite(group_id, node["invite_id"])
raise OpError(f"The hub refused the link, so none was made: {e}",
status=502) from e
return {
"link": _invite_url(hub.hub_url, group_id, ticket["ticket"],
pk_to_b64(sk_node.public_key()), node["code"]),
"expires_at": ticket["expires_at"],
"invite_id": node["invite_id"],
"email": email,
}
async def cancel_link_invitation(state: dict, group_id: str, invite_id: str) -> dict:
"""
Take a link back, both halves: the node's code first, which is what stops
anyone joining, then the hub's ticket — attempted even when the first half
finds nothing to cancel, so neither is left behind (the member-removal rule).
"""
roster = _roster(state)
_group_ctx(state, group_id)
node_cancelled = await roster.cancel_invite(group_id, invite_id)
hub_cancelled = False
hub = state.get("hub")
if hub and hub._session:
try:
for link in await hub.list_invite_links(group_id):
if link.get("node_invite_id") == invite_id and link.get("status") == "pending":
await hub.delete_invite_link(group_id, link["link_id"])
hub_cancelled = True
except Exception as e:
log.warning("Invitation link %s: the hub half was not cancelled: %s",
invite_id[:8], e)
if not node_cancelled and not hub_cancelled:
raise OpError("No unredeemed invitation link with that id in this group",
status=404)
log.info("Invitation link cancelled: group=%s invite=%s node=%s hub=%s",
group_id[:8], invite_id[:8], node_cancelled, hub_cancelled)
return {"cancelled": True, "invite_id": invite_id,
"node": node_cancelled, "hub": hub_cancelled}
async def revoke_member(state: dict, user_id: str, group_id: str) -> dict:
"""
Stop serving the group key to someone.
Takes effect on their next connection: the key is wrapped on demand, so there
is no stored bundle left behind that would outlive this. Rotating the group
key is still required — they hold the current one.
**An unredeemed invite is a membership that has not happened yet**, so it is
revoked here too, and on its own it is enough for this to be a removal. A
member row appears only when a code is consumed: somebody invited to the
wrong group has none, this refused them with "no such member", and the
browser's removal — node half first, deliberately — died on that refusal
before it reached the hub half. They stayed a member on the hub, with a live
code, and the interface offered no other way to take either back.
"""
roster = _roster(state)
revoked = await roster.set_status(group_id, user_id, "revoked")
dropped = await roster.drop_invites(group_id, user_id)
if not revoked and not dropped:
raise OpError("No such member in that group", status=404)
log.info("Member revoked: user=%s group=%s member=%s invites_dropped=%d",
user_id[:8], group_id[:8], revoked, dropped)
return {"status": "revoked", "user_id": user_id, "group_id": group_id,
"was_member": revoked, "invites_dropped": dropped,
# Only what is true: somebody who never redeemed a code never held
# the key, and telling an operator to rotate it teaches them that
# the advice is noise.
"reminder": ("rotate the group key: meshbay-node gek rotate"
if revoked else "")}
async def unpin_member(state: dict, user_id: str) -> dict:
"""Forget a pinned identity, so the person can pair again with a new key."""
roster = _roster(state)
if not await roster.unpin(user_id):
raise OpError("No such pinned identity", status=404)
# Drop the stored keypair bundle too. Left behind, it is served to the next
# connection, which then cannot open it (the passphrase may have changed
# since) and dies in the identity step before it ever reaches the join the
# unpin was meant to enable.
bundle_store = state.get("bundle_store")
if bundle_store:
try:
await bundle_store.delete_keypair(user_id)
except Exception:
log.warning("unpin: could not drop keypair bundle for %s", user_id[:8])
log.info("Identity unpinned: user=%s", user_id[:8])
return {"status": "unpinned", "user_id": user_id}
|