summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 19:01:08 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 19:01:08 +0200
commit05f4feab641740c944d636f29a03f8c0dd1328c7 (patch)
tree9a41cbaac3c36db854d6a1eb7750404ff79fa4e4 /packages/meshbay-hub
parentdd3927a661273734493f65a593755b95aecf5f09 (diff)
downloadmeshbay-05f4feab641740c944d636f29a03f8c0dd1328c7.tar.gz
fix: stop a stream on close, count only real users, record where a node is
**Closing the viewer left the node working.** Nothing told it to stop: the player dropped its handlers, which only made the browser deaf. ffmpeg kept running and held one of the node's two transcode slots until the credit timeout expired two minutes later — which is why the next video answered "server busy". `stream_stop` ends it at once, and the viewer also drops its queue, ends the MediaSource and revokes the object URL on the way out, any of which could be holding megabytes of decrypted video. While there: `file_chunk` replies were matched to their requests by arrival order, which was true by luck rather than by construction. The reply now names the file it belongs to and is matched on that and the chunk index; a chunk nobody is waiting for is dropped instead of being handed to whatever request happens to be oldest. **The administration panel counted its own history.** A deleted account is tombstoned so the connection log stays readable, and every count and list treated that row as a user — including a group's member count, and the member list of the group itself. They do not any more. **Where a node is.** `endpoint_hint` is what a node believes its address to be, learned from a STUN server and sent to us: useful for reaching it, and a claim. The announcement that carries it is signed with the node key over a fresh timestamp, so the address that request *arrives from* is the address of whoever holds that key — that is now recorded on the node row and shown in a Nodes tab, next to the hint, with the difference spelled out. Clients get the same treatment: `webrtc_offer` is logged with the address the hub saw when a browser starts a peer connection. Verified against the live deployment: the node's row reads 90.112.206.172 after a restart, and in e2e a stopped stream goes quiet in one message and the next one starts immediately instead of being refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py66
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d1f47a90c3b2_node_observed_ip.py35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js35
-rw-r--r--packages/meshbay-hub/tests/test_admin_views.py150
11 files changed, 383 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 785731d..7ee05ca 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import decrypt_email
from meshbay_hub.api.deps import require_admin, require_moderator
-from meshbay_hub.api.revocation import get_connected_node_count
+from meshbay_hub.api.revocation import get_connected_node_count, is_node_connected
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
@@ -42,8 +42,18 @@ async def admin_stats(
current_user: User = Depends(require_moderator),
db: AsyncSession = Depends(get_db),
):
- user_count = (await db.execute(select(func.count()).select_from(User))).scalar_one()
- group_count = (await db.execute(select(func.count()).select_from(Group))).scalar_one()
+ # Deleted accounts are tombstoned rather than dropped, so that the
+ # connection log stays readable. They are not users any more and must not be
+ # counted as any: a hub whose user count only ever rises is measuring its
+ # own history, not its population.
+ user_count = (await db.execute(
+ select(func.count()).select_from(User)
+ .where(User.status != "deleted"))).scalar_one()
+ # Groups are not tombstoned — deleting one removes the row — so every group
+ # here is a group. A revoked one is suspended by moderation and still shown
+ # in the list, so counting it keeps the two consistent.
+ group_count = (await db.execute(
+ select(func.count()).select_from(Group))).scalar_one()
node_count = (await db.execute(select(func.count()).select_from(Node))).scalar_one()
return {
"users": user_count,
@@ -63,14 +73,16 @@ async def admin_list_users(
offset: int = 0,
limit: int = Query(default=50, le=200),
):
- query = select(User).order_by(User.created_at.desc())
+ query = (select(User).where(User.status != "deleted")
+ .order_by(User.created_at.desc()))
if q:
query = query.where(User.username.ilike(f"%{q}%"))
query = query.offset(offset).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
- total_query = select(func.count()).select_from(User)
+ total_query = (select(func.count()).select_from(User)
+ .where(User.status != "deleted"))
if q:
total_query = total_query.where(User.username.ilike(f"%{q}%"))
total = (await db.execute(total_query)).scalar_one()
@@ -222,9 +234,13 @@ async def admin_list_groups(
query = (
select(
Group,
- func.count(GroupMember.user_id).label("member_count"),
+ func.count(User.id).label("member_count"),
)
+ # Members, not rows: a deleted account's membership is removed with it,
+ # but joining through User keeps the count honest if one ever survives.
.outerjoin(GroupMember, Group.id == GroupMember.group_id)
+ .outerjoin(User, (User.id == GroupMember.user_id)
+ & (User.status != "deleted"))
.group_by(Group.id)
.order_by(Group.created_at.desc())
.offset(offset)
@@ -288,6 +304,44 @@ async def admin_patch_group(
# ── IP Audit Logs ────────────────────────────────────────────────────────────
+@router.get("/nodes")
+async def admin_list_nodes(
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+ limit: int = Query(default=100, le=200),
+):
+ """
+ Registered nodes, with the address the hub saw them announce from.
+
+ `observed_ip` is the one to answer a question with: it comes from the
+ connection that carried a valid Ed25519 signature over a fresh timestamp, so
+ it is the address of whoever holds the node key. `endpoint_hint` is what the
+ node believes its own address to be, discovered through a STUN server and
+ sent to us — useful for reaching it, and not evidence of anything.
+ """
+ rows = (await db.execute(
+ select(Node, User.username)
+ .outerjoin(User, User.id == Node.user_id)
+ .order_by(Node.announced_at.desc())
+ .limit(limit))).all()
+ return {
+ "nodes": [
+ {
+ "id": n.id,
+ "user_id": n.user_id,
+ "username": uname or "",
+ "pk_node": n.pk_node,
+ "observed_ip": n.observed_ip or "",
+ "endpoint_hint": n.endpoint_hint or "",
+ "last_seen": n.last_seen.isoformat() if n.last_seen else "",
+ "announced_at": n.announced_at.isoformat(),
+ "online": is_node_connected(n.id),
+ }
+ for n, uname in rows
+ ],
+ }
+
+
@router.get("/logs")
async def admin_list_logs(
current_user: User = Depends(require_moderator),
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 74c6c9e..8e3197c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -207,7 +207,7 @@ async def group_members(
result = await db.execute(
select(User.id, User.username)
.join(GroupMember, User.id == GroupMember.user_id)
- .where(GroupMember.group_id == group_id)
+ .where(GroupMember.group_id == group_id, User.status != "deleted")
)
members = [{"user_id": uid, "username": uname} for uid, uname in result.all()]
return {
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 0770148..67e65f2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -1,5 +1,6 @@
"""Node endpoints — /v1/nodes/*"""
+from datetime import datetime, timezone
import base64
import time
@@ -130,10 +131,18 @@ async def announce_node(
select(Node).where(Node.user_id == current_user.id,
Node.pk_node == body.pk_node))
node = existing.scalar_one_or_none()
+ # The address is taken from the connection, never from the body: the
+ # signature above proves who is announcing, and this is where they are
+ # announcing from. What the node believes its address to be — endpoint_hint,
+ # learned from a STUN server — is kept separately and is not evidence.
+ seen_from = client_ip(request)
+
if node is not None:
node.endpoint_hint = body.endpoint_hint
+ node.observed_ip = seen_from
+ node.last_seen = datetime.now(timezone.utc)
db.add(IPLog(user_id=current_user.id, event="node_announce",
- ip_address=client_ip(request), detail=body.endpoint_hint))
+ ip_address=seen_from, detail=body.endpoint_hint))
await db.commit()
return {"node_id": node.id}
@@ -141,12 +150,14 @@ async def announce_node(
user_id=current_user.id,
pk_node=body.pk_node,
endpoint_hint=body.endpoint_hint,
+ observed_ip=seen_from,
+ last_seen=datetime.now(timezone.utc),
)
db.add(node)
db.add(IPLog(
user_id=current_user.id,
event="node_announce",
- ip_address=client_ip(request),
+ ip_address=seen_from,
detail=body.endpoint_hint,
))
await db.commit()
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 58ebf50..1f6d7f0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -55,6 +55,10 @@ _node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...]
_punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event
+def is_node_connected(node_id: str) -> bool:
+ return node_id in _connected_nodes
+
+
def get_connected_node_count() -> int:
return len(_connected_nodes)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index 8f84163..cb00a67 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -25,7 +25,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user
from meshbay_hub.api.middleware import limiter
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import Group, GroupMember, User
+from meshbay_hub.api.netutil import client_ip
+from meshbay_hub.db.models import Group, GroupMember, IPLog, User
log = logging.getLogger(__name__)
@@ -77,6 +78,14 @@ async def webrtc_offer(
if len(body.sdp) > MAX_SDP_BYTES:
raise HTTPException(status_code=413, detail="SDP too large")
+ # Logged here because this is the moment a browser starts a peer connection,
+ # and the address it starts it from is this one — the hub's own view of the
+ # TCP connection. Whatever address the peers then discover through STUN is
+ # theirs to negotiate and is not what a log should record.
+ db.add(IPLog(user_id=current_user.id, event="webrtc_offer",
+ ip_address=client_ip(request), detail=node_id[:8]))
+ await db.commit()
+
ws = _connected_nodes.get(node_id)
if not ws:
raise HTTPException(status_code=404, detail="Node not connected")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d1f47a90c3b2_node_observed_ip.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d1f47a90c3b2_node_observed_ip.py
new file mode 100644
index 0000000..c720cab
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d1f47a90c3b2_node_observed_ip.py
@@ -0,0 +1,35 @@
+"""node_observed_ip
+
+Where a node was actually seen, as opposed to where it says it is.
+
+`endpoint_hint` is discovered by the node through a STUN server and sent to the
+hub in its announcement — useful for reaching it, and a claim. `observed_ip` is
+the address the announcement itself arrived from, on a request carrying a valid
+Ed25519 signature over a fresh timestamp, so it is the address of whoever holds
+the node key. That is the one an administrator's question is about.
+
+Revision ID: d1f47a90c3b2
+Revises: c5e93b1a2f60
+Create Date: 2026-08-15
+
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = 'd1f47a90c3b2'
+down_revision: Union[str, Sequence[str], None] = 'c5e93b1a2f60'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column('nodes', sa.Column('observed_ip', sa.String(45), nullable=True))
+ op.add_column('nodes',
+ sa.Column('last_seen', sa.DateTime(timezone=True), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column('nodes', 'last_seen')
+ op.drop_column('nodes', 'observed_ip')
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 2e36989..7d5a3e3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -72,6 +72,13 @@ class Node(Base):
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
pk_node: Mapped[str] = mapped_column(String(64), nullable=False) # Ed25519 b64
endpoint_hint: Mapped[str | None] = mapped_column(String(128)) # "ip:port" or null
+ # The address the hub saw this node connect from, recorded on an announce
+ # that carried a valid Ed25519 signature over a fresh timestamp. Unlike
+ # endpoint_hint — which the node discovers through STUN and sends us — this
+ # is observed rather than claimed, and it is the one to answer questions
+ # with. IPv4 or IPv6.
+ observed_ip: Mapped[str | None] = mapped_column(String(45))
+ last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
announced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
user: Mapped["User"] = relationship(back_populates="nodes")
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 18ce83a..cf33d72 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -2587,10 +2587,25 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
videoRef.current.removeEventListener('seeking', onSeeking);
}
if (transport) {
+ // Tell the node first: dropping the handlers only makes us deaf, and a
+ // stream nobody is listening to still occupies a transcode slot.
+ transport.stopStream();
transport.onStreamInit = null;
transport.onStreamData = null;
transport.onStreamEnd = null;
}
+ // The queue can hold several megabytes of decrypted video.
+ queueRef.current = [];
+ const ms = msRef.current;
+ if (ms && ms.readyState === 'open') {
+ try { ms.endOfStream(); } catch { /* already ended */ }
+ }
+ if (blobUrlRef.current) {
+ URL.revokeObjectURL(blobUrlRef.current);
+ blobUrlRef.current = null;
+ }
+ sbRef.current = null;
+ msRef.current = null;
};
}, [entry, flushQueue]);
@@ -3056,6 +3071,7 @@ function AdminPage({ token }) {
const [logEvent, setLogEvent] = useState('');
const [logOffset, setLogOffset] = useState(0);
const [blocklist, setBlocklist] = useState([]);
+ const [nodes, setNodes] = useState([]);
const [detailUser, setDetailUser] = useState(null);
const [error, setError] = useState('');
@@ -3105,6 +3121,10 @@ function AdminPage({ token }) {
if (tab === 'stats') loadStats();
else if (tab === 'users') loadUsers(userSearch);
else if (tab === 'groups') loadGroups();
+ else if (tab === 'nodes') {
+ hubFetch('/v1/admin/nodes', { token })
+ .then(d => setNodes(d.nodes || [])).catch(e => setError(e.message));
+ }
else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); }
else if (tab === 'blocklist') loadBlocklist();
}, [tab]);
@@ -3157,7 +3177,7 @@ function AdminPage({ token }) {
} catch (e) { setError(e.message); }
}, [token]);
- const TABS = ['stats', 'users', 'groups', 'logs', 'blocklist'];
+ const TABS = ['stats', 'users', 'groups', 'nodes', 'logs', 'blocklist'];
return html`
<div>
@@ -3267,6 +3287,41 @@ function AdminPage({ token }) {
</table>
`}
+ ${tab === 'nodes' && html`
+ <p class="settings-hint" style="margin-bottom:10px">
+ ${t('admin.nodes_hint')}
+ </p>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('admin.col_observed_ip')}</th>
+ <th>${t('admin.col_hint')}</th>
+ <th>${t('admin.col_last_seen')}</th>
+ <th>${t('admin.col_status')}</th>
+ </tr></thead>
+ <tbody>
+ ${nodes.length === 0 && html`
+ <tr><td colspan="5" class="admin-empty">${t('admin.no_nodes')}</td></tr>
+ `}
+ ${nodes.map(n => html`
+ <tr key=${n.id}>
+ <td>${n.username || n.user_id.slice(0, 8)}</td>
+ <td style="font-family:monospace">${n.observed_ip || '—'}</td>
+ <td style="font-family:monospace;color:var(--text-dim)">
+ ${n.endpoint_hint || '—'}
+ </td>
+ <td>${n.last_seen ? new Date(n.last_seen).toLocaleString() : '—'}</td>
+ <td>
+ <span class="badge ${n.online ? 'badge-ok' : ''}">
+ ${n.online ? t('admin.node_online') : t('admin.node_offline')}
+ </span>
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
${tab === 'logs' && html`
<div class="admin-toolbar">
<select class="admin-select" value=${logEvent} onChange=${e => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index 71f6cce..affa7c8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -195,7 +195,19 @@ const en = {
'admin.tab_stats': 'Stats',
'admin.tab_users': 'Users',
'admin.tab_groups': 'Groups',
+ 'admin.tab_nodes': 'Nodes',
'admin.tab_logs': 'Logs',
+ 'admin.col_observed_ip': 'Seen from',
+ 'admin.col_hint': 'Announced hint',
+ 'admin.col_last_seen': 'Last announce',
+ 'admin.node_online': 'online',
+ 'admin.node_offline': 'offline',
+ 'admin.no_nodes': 'No nodes registered',
+ 'admin.nodes_hint': '"Seen from" is the address the announcement arrived from, '
+ + 'on a request signed with the node key — that is the one to answer a '
+ + 'question with. The hint is what the node believes its own address to be, '
+ + 'learned from a STUN server and sent to us; it is useful for reaching the '
+ + 'node and is not evidence of anything.',
'admin.tab_blocklist': 'Blocklist',
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 844a201..df2abb0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -562,6 +562,18 @@ class MeshBayTransport {
}
/**
+ * Nobody is watching any more.
+ *
+ * Closing the viewer used to say nothing to the node, which went on
+ * transcoding and holding one of its two slots until the credit timeout — so
+ * the next video answered "server busy".
+ */
+ stopStream() {
+ if (!this._connected) return;
+ try { this._send({ type: 'stream_stop', v: '0.1' }); } catch { /* gone */ }
+ }
+
+ /**
* Push a whole file, several chunks in flight at once.
*
* One chunk per round trip is 48 KB of throughput per RTT no matter how much
@@ -778,6 +790,11 @@ class MeshBayTransport {
}, 30000);
this._pending.set(id, {
_reqType: obj.type,
+ // Chunks are the one request that runs several at a time and can be
+ // interleaved with anything else on the channel. Matching them by
+ // arrival order was only ever true by luck; this makes it true.
+ _key: obj.type === 'file_req'
+ ? `chunk:${obj.file_id}:${obj.chunk_index}` : null,
resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); },
reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); },
});
@@ -857,6 +874,24 @@ class MeshBayTransport {
return;
}
+ if (msg.type === 'file_chunk') {
+ const key = `chunk:${msg.file_id}:${msg.chunk_index}`;
+ for (const [, handler] of this._pending) {
+ // A node from before the reply carried a file_id: fall back to the
+ // index, which is still better than the oldest pending request.
+ const match = msg.file_id
+ ? handler._key === key
+ : handler._key && handler._key.endsWith(`:${msg.chunk_index}`);
+ if (match) {
+ handler.resolve(msg);
+ return;
+ }
+ }
+ // Nobody asked for it any more — a cancelled download, most likely. It
+ // must not be handed to whatever request happens to be waiting.
+ return;
+ }
+
const oldest = this._pending.entries().next();
if (!oldest.done) {
const [, handler] = oldest.value;
diff --git a/packages/meshbay-hub/tests/test_admin_views.py b/packages/meshbay-hub/tests/test_admin_views.py
new file mode 100644
index 0000000..29f41d8
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_admin_views.py
@@ -0,0 +1,150 @@
+"""
+What the administration panel counts and lists.
+
+Deleting an account leaves a tombstone so the connection log stays readable
+(see test_account_deletion). That row is not a user any more, and every place
+that counts or lists people has to agree — a hub whose user count only ever
+rises is measuring its own history rather than its population.
+"""
+
+import base64
+import hashlib
+
+import pytest
+from sqlalchemy import select
+
+from meshbay_hub.db.models import User
+
+
+def _auth_key(password: str, username: str) -> str:
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+async def _user(client, username, password="a-long-enough-passphrase"):
+ await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": _auth_key(password, username)})
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key(password, username)})
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
+
+
+async def _admin(client, db_session, username="root"):
+ headers = await _user(client, username)
+ user = (await db_session.execute(
+ select(User).where(User.username == username))).scalar_one()
+ user.role = "admin"
+ await db_session.commit()
+ return headers
+
+
+@pytest.mark.asyncio
+async def test_a_deleted_account_stops_being_counted(client, db_session):
+ admin = await _admin(client, db_session)
+ leaver = await _user(client, "ghost")
+
+ before = (await client.get("/v1/admin/stats", headers=admin)).json()["users"]
+ await client.request("DELETE", "/v1/users/me", headers=leaver,
+ json={"auth_key": _auth_key(
+ "a-long-enough-passphrase", "ghost")})
+ after = (await client.get("/v1/admin/stats", headers=admin)).json()["users"]
+
+ assert after == before - 1, "a tombstone is still being counted as a user"
+
+
+@pytest.mark.asyncio
+async def test_a_deleted_account_is_not_listed(client, db_session):
+ admin = await _admin(client, db_session, "root2")
+ leaver = await _user(client, "vanishing")
+
+ await client.request("DELETE", "/v1/users/me", headers=leaver,
+ json={"auth_key": _auth_key(
+ "a-long-enough-passphrase", "vanishing")})
+
+ r = await client.get("/v1/admin/users", headers=admin)
+ names = [u["username"] for u in r.json()["users"]]
+ assert "vanishing" not in names
+ assert not any(n.startswith("deleted-") for n in names), \
+ "the tombstone is showing under its placeholder name"
+ assert r.json()["total"] == len(names)
+
+
+@pytest.mark.asyncio
+async def test_the_member_list_of_a_group_skips_them(client, db_session):
+ admin = await _admin(client, db_session, "root3")
+ owner = await _user(client, "host3")
+ leaver = await _user(client, "quitter")
+
+ g = await client.post("/v1/groups", json={"name": "party"}, headers=owner)
+ gid = g.json()["group_id"]
+ await client.post(f"/v1/groups/{gid}/members/quitter", json={}, headers=owner)
+
+ await client.request("DELETE", "/v1/users/me", headers=leaver,
+ json={"auth_key": _auth_key(
+ "a-long-enough-passphrase", "quitter")})
+
+ members = (await client.get(f"/v1/groups/{gid}/members",
+ headers=owner)).json()["members"]
+ assert [m["username"] for m in members] == ["host3"]
+
+ groups = (await client.get("/v1/admin/groups", headers=admin)).json()["groups"]
+ party = next(g for g in groups if g["name"] == "party")
+ assert party["member_count"] == 1, \
+ f"the group still counts its departed member ({party['member_count']})"
+
+
+# ── Where a node actually is ────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_node_is_recorded_at_the_address_it_announced_from(
+ client, db_session):
+ """
+ The hub sees the connection; the node only tells us what a STUN server told
+ it. The observed address is the evidence, and the announcement carries an
+ Ed25519 signature over a fresh timestamp, so it is the address of whoever
+ holds the node key.
+ """
+ import base64
+ import time
+
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+ from cryptography.hazmat.primitives import serialization
+
+ from meshbay_hub.db.models import Node
+
+ admin = await _admin(client, db_session, "root4")
+ owner = await _user(client, "nodeowner")
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "nodeowner"))).scalar_one()
+
+ sk = Ed25519PrivateKey.generate()
+ pk_b64 = base64.b64encode(sk.public_key().public_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PublicFormat.Raw)).decode()
+ ts = int(time.time())
+ message = f"meshbay:node_announce:{uid}:{pk_b64}:{ts}".encode()
+
+ r = await client.post("/v1/nodes/announce", headers=owner, json={
+ "pk_node": pk_b64,
+ # A claim, and a deliberately implausible one.
+ "endpoint_hint": "203.0.113.42:19001",
+ "timestamp": ts,
+ "signature": base64.b64encode(sk.sign(message)).decode(),
+ })
+ assert r.status_code in (200, 201), r.text
+
+ node = (await db_session.execute(
+ select(Node).where(Node.pk_node == pk_b64))).scalar_one()
+ assert node.observed_ip, "no address was recorded for the announcement"
+ assert node.observed_ip != "203.0.113.42", \
+ "the hub recorded what the node claimed instead of what it saw"
+ assert node.last_seen is not None
+
+ listed = (await client.get("/v1/admin/nodes", headers=admin)).json()["nodes"]
+ row = next(n for n in listed if n["pk_node"] == pk_b64)
+ assert row["observed_ip"] == node.observed_ip
+ assert row["endpoint_hint"] == "203.0.113.42:19001", \
+ "the claim is still shown, just not as the answer"
+ assert row["username"] == "nodeowner"