summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/groups.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/groups.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/groups.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py130
1 files changed, 126 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 8e3197c..8283276 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -2,7 +2,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user, require_user_scope
@@ -26,10 +26,16 @@ async def my_groups(
db: AsyncSession = Depends(get_db),
):
"""List groups the current user belongs to."""
+ from meshbay_hub.api.revocation import get_online_nodes_for_group
+
result = await db.execute(
select(Group)
.join(GroupMember, Group.id == GroupMember.group_id)
- .where(GroupMember.user_id == current_user.id, Group.status == "active")
+ .where(GroupMember.user_id == current_user.id, Group.status == "active",
+ # A group nobody hosts yet is the owner's business alone. Someone
+ # added to it before a node exists would see a name they cannot
+ # open and cannot be told why.
+ or_(Group.hosted_at.is_not(None), Group.admin_id == current_user.id))
.order_by(Group.name)
)
groups = result.scalars().all()
@@ -48,6 +54,15 @@ async def my_groups(
"created_at": g.created_at.isoformat(),
"is_admin": g.admin_id == current_user.id,
"description": g.description or "",
+ # Presence, from the socket registry the hub already keeps for
+ # signaling — so the sidebar gets it on the request it already
+ # makes, with no poll and no timer. It says a node serving this
+ # group is connected *to the hub*; it does not promise this
+ # browser can reach it, and a hub is free to lie about it. The
+ # client downgrades to offline on its own failed connection,
+ # which is the evidence that actually concerns the user.
+ "node_online": bool(get_online_nodes_for_group(g.id)),
+ "hosted": g.hosted_at is not None,
}
for g in groups
]
@@ -88,7 +103,12 @@ async def list_public_groups(
include_federated: bool = True,
):
"""List/search public groups — local and optionally federated. No auth required."""
- query = select(Group).where(Group.visibility == "public", Group.status == "active")
+ # Unhosted groups are absent from the directory: until a node announces it,
+ # a group has no files, no key and nothing to connect to, so listing it only
+ # produces a dead end. Its owner still sees it in /mine while they set it up.
+ query = select(Group).where(Group.visibility == "public",
+ Group.status == "active",
+ Group.hosted_at.is_not(None))
if q:
query = query.where(Group.name.ilike(f"%{q}%"))
result = await db.execute(
@@ -246,10 +266,50 @@ async def join_group(
class GroupCreateRequest(BaseModel):
name: str
visibility: str = "private" # public|private
- join_policy: str = "invite" # open|request|invite
+ join_policy: str = "invite" # open (public groups) | invite (private)
description: str | None = None
+MAX_PUBLIC_GROUPS = 10
+
+
+async def _check_public_group_quota(db: AsyncSession, user: User) -> None:
+ """Refuse an eleventh live public group from the same owner.
+
+ Public groups are the ones that cost other people something: they appear in
+ Discover and anyone may join them, so a script that opens hundreds fills the
+ directory for everybody. Private groups are invisible to anyone not invited
+ and are not capped.
+
+ Counted: public, still active, owned by this user. A group suspended by
+ moderation does not hold a slot — the owner is already being dealt with, and
+ keeping the slot occupied would punish them twice. Deleting one frees a slot,
+ since the row is gone.
+
+ Creation is the only place this can be checked, and deliberately so: PATCH
+ refuses to change visibility at all, so a private group cannot be flipped
+ public behind the cap. **If visibility ever becomes editable, this check has
+ to move with it.**
+ """
+ if user.role in ("admin", "moderator"):
+ return # the cap is an anti-spam measure, not a rule about operating an instance
+
+ count = (await db.execute(
+ select(func.count())
+ .select_from(Group)
+ .where(Group.admin_id == user.id,
+ Group.visibility == "public",
+ Group.status == "active")
+ )).scalar_one()
+
+ if count >= MAX_PUBLIC_GROUPS:
+ raise HTTPException(
+ status_code=409,
+ detail=f"You already run {count} public groups, which is the limit of "
+ f"{MAX_PUBLIC_GROUPS}. Delete one you no longer use, or create "
+ f"this one as private — private groups are not limited.")
+
+
@router.post("", status_code=201)
async def create_group(
body: GroupCreateRequest,
@@ -257,6 +317,19 @@ async def create_group(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ if body.visibility == "public":
+ # A public group that admits nobody is a contradiction: it is listed in
+ # the directory, so people find it and then discover they cannot get in.
+ # Admission by request was considered and dropped — between strangers the
+ # only channel is the hub, so the one-time code would travel through the
+ # very party it exists to keep out, and would protect nothing.
+ if body.join_policy != "open":
+ raise HTTPException(
+ status_code=422,
+ detail="A public group is open to join. Make it private if you "
+ "want to choose who comes in.")
+ await _check_public_group_quota(db, current_user)
+
desc = (body.description or "")[:512] if body.description else None
group = Group(
name=body.name,
@@ -324,6 +397,55 @@ async def remove_group_member(
return {"status": "removed", "group_id": group_id, "username": username}
+@router.post("/{group_id}/leave")
+async def leave_group(
+ group_id: str,
+ request: Request,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Leave a group you are a member of.
+
+ Deliberately separate from `DELETE /{group_id}/members/{username}`, which is
+ the owner removing somebody else and is refused to everyone else. Reusing it
+ would have meant relaxing that check for the self case, and an authorization
+ rule with an exception in it is the kind that gets read wrong later.
+
+ The owner cannot leave: the group would be left with no one able to admit a
+ member, edit it or delete it. That is the same answer the removal endpoint
+ already gives, and the same one account deletion gives while you still own
+ groups — hand the group over (not yet possible) or delete it.
+
+ This is only the hub's half, exactly as for removal: membership is gone, so
+ signaling will not reach a node and the next token will not name this group.
+ The node keeps what it holds — the identity it pinned, the keypair bundle,
+ and the files uploaded — until its operator unpins them, and whoever left
+ still holds the group key they were served, so the operator should rotate it
+ (`meshbay-node gek-init`) if that matters.
+ """
+ group = await db.get(Group, group_id)
+ if not group:
+ raise HTTPException(status_code=404, detail="Group not found")
+
+ if group.admin_id == current_user.id:
+ raise HTTPException(
+ status_code=409,
+ detail="You own this group, so you cannot leave it — it would be left "
+ "with nobody able to manage it. Delete the group instead.")
+
+ membership = await db.get(GroupMember, (group_id, current_user.id))
+ if not membership:
+ raise HTTPException(status_code=404, detail="You are not a member of this group")
+
+ await db.delete(membership)
+ db.add(IPLog(user_id=current_user.id, event="group_leave",
+ ip_address=client_ip(request),
+ detail=f"left {group.name}"))
+ await db.commit()
+ return {"status": "left", "group_id": group_id}
+
+
class GroupUpdateRequest(BaseModel):
description: str | None = None