aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 15:28:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 15:28:28 +0200
commit0bd3f805ffbd04b40b5150474336a5e5d200e72b (patch)
tree2a92a21e02bae823e9ed52d489d74ba8db3de9b6 /packages/meshbay-hub/src/meshbay_hub/api/revocation.py
parent0231d240b92a2a11042fa62c0222d4c4b96a859d (diff)
downloadmeshbay-0bd3f805ffbd04b40b5150474336a5e5d200e72b.tar.gz
feat(hub): leaving a group, a cap on public ones, and hosting as a precondition
Leaving is its own endpoint rather than a relaxation of the owner's removal check — an authorization rule with an exception in it is the one that gets read wrong later. The owner cannot leave: the group would be left with nobody able to admit, edit or delete it, which is the answer removal and account deletion already give. Public groups are capped at ten live ones per owner. They are the ones that cost other people something — listed in Discover, joinable by anyone — so a script that opens hundreds fills the directory for everybody. Private groups are invisible to non-members and are not capped. Hub staff are exempt; the cap is anti-spam, not a rule about running an instance. Creation is the only place it can be checked, and deliberately so, because PATCH refuses to change visibility at all. A group is now listed only once a node has announced that it hosts it. Before that it has no files, no key and nothing to connect to, so showing it to a member produces a name they cannot open and cannot be told why; its owner still sees it while they set the node up. `meshbay-hub prune-groups` collects the ones that never got a node, meant for cron, with --dry-run. The migration backfills hosted_at from created_at: without that the first run would have deleted every live group. Presence rides on the group list itself, read from the signaling registry the hub already keeps — no poll, no timer. It says a node is connected *to the hub*, which is not a promise that this browser can reach it and not something a dishonest hub could not fake; the client downgrades it on a connection it tried and failed, which is the evidence that concerns the reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/revocation.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py34
1 files changed, 33 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 1f6d7f0..d555f9f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -31,11 +31,12 @@ import json
import logging
import time
import uuid
+from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
import jwt
@@ -67,6 +68,36 @@ def get_online_nodes_for_group(group_id: str) -> list[str]:
return [nid for nid, gids in _node_groups.items() if group_id in gids]
+
+async def _mark_hosted(group_ids: list[str]) -> None:
+ """Stamp the first time a node announced it hosts each of these groups.
+
+ `group_ids` is already narrowed to what this node may claim — the caller
+ derives it from the database and a node can only shrink the set, never widen
+ it (finding C2) — so being announced here is evidence the group has a host.
+
+ Set once. A node going offline does not un-host a group, and re-stamping on
+ every reconnection would make `hosted_at` a "last seen" field, which is what
+ the in-memory registry is already for.
+ """
+ from meshbay_hub.db.engine import get_session_factory
+ from meshbay_hub.db.models import Group
+
+ if not group_ids:
+ return
+ try:
+ async with get_session_factory()() as db:
+ await db.execute(
+ update(Group)
+ .where(Group.id.in_(group_ids), Group.hosted_at.is_(None))
+ .values(hosted_at=datetime.now(timezone.utc)))
+ await db.commit()
+ except Exception as e:
+ # A group that stays unhosted in the table is visible to its owner and
+ # collected later; failing the socket over it would take the node down.
+ log.warning("Could not mark groups hosted: %s", e)
+
+
async def broadcast_revocation(token: str) -> int:
"""Push a signed revocation token to all connected nodes. Returns count sent."""
payload = json.dumps({"type": "revocation", "token": token})
@@ -236,6 +267,7 @@ async def node_websocket(ws: WebSocket):
node_id = resolved_id
_connected_nodes[node_id] = ws
_node_groups[node_id] = group_ids
+ await _mark_hosted(group_ids)
log.info("Node WS connected: %s (user=%s, groups=%d)",
node_id[:8], user_id[:8], len(group_ids))
await ws.send_text(json.dumps({"type": "auth_ok", "node_id": node_id}))