aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/groups.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 04:39:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 04:39:34 +0200
commitfb91c4545c757711e1b5fd354ca4b311c89fd2c0 (patch)
treeeb1aee6cc0fb5fc020eed2763009dea5a32eb8ee /packages/meshbay-hub/src/meshbay_hub/api/groups.py
parent77d76421829161df6b1ef628b4e6e051a2c3c2ee (diff)
downloadmeshbay-fb91c4545c757711e1b5fd354ca4b311c89fd2c0.tar.gz
feat(hub): add production hub — config, auth, API routers, tests
config.py: TOML + env var priority. auth.py: Argon2id passwords, JWT EdDSA with jti, refresh token hashed (blake3). Routers: hub (info/pubkey), users (register/login/refresh/pubkeys), nodes (announce/get), groups (create/gek-bundle/gek-retrieve). Rate limiting via slowapi. app.py factory with lifespan. All 40 tests pass (SQLite in-memory, no PostgreSQL required). Fix: remove tests/__init__.py to resolve namespace conflicts. Co-Authored-By: Claude Sonnet 4.6 (1M context) <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.py118
1 files changed, 118 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
new file mode 100644
index 0000000..942a88e
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -0,0 +1,118 @@
+"""Group endpoints — /v1/groups/*"""
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import GEKBundle, Group, GroupMember, IPLog, User
+
+router = APIRouter(prefix="/v1/groups", tags=["groups"])
+
+
+class GroupCreateRequest(BaseModel):
+ name: str
+ visibility: str = "private" # public|private
+ join_policy: str = "invite" # open|request|invite
+
+
+class GEKBundleRequest(BaseModel):
+ pk_eph_b64: str
+ nonce_b64: str
+ wrapped_b64: str
+
+
+@router.post("", status_code=201)
+async def create_group(
+ body: GroupCreateRequest,
+ request: Request,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ group = Group(
+ name=body.name,
+ admin_id=current_user.id,
+ visibility=body.visibility,
+ join_policy=body.join_policy,
+ )
+ db.add(group)
+ await db.flush() # get group.id
+
+ db.add(GroupMember(group_id=group.id, user_id=current_user.id))
+ db.add(IPLog(user_id=current_user.id, event="group_create",
+ ip_address=_ip(request), detail=body.name))
+ await db.commit()
+ await db.refresh(group)
+ return {"group_id": group.id, "name": group.name}
+
+
+@router.post("/{group_id}/members/{username}/gek", status_code=201)
+async def store_gek_bundle(
+ group_id: str,
+ username: str,
+ body: GEKBundleRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ 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=403, detail="Only admin can add members")
+
+ result = await db.execute(select(User).where(User.username == username))
+ target = result.scalar_one_or_none()
+ if not target:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Upsert GEK bundle
+ existing = await db.get(GEKBundle, (group_id, target.id))
+ if existing:
+ existing.pk_eph_b64 = body.pk_eph_b64
+ existing.nonce_b64 = body.nonce_b64
+ existing.wrapped_b64 = body.wrapped_b64
+ else:
+ db.add(GEKBundle(
+ group_id=group_id,
+ user_id=target.id,
+ pk_eph_b64=body.pk_eph_b64,
+ nonce_b64=body.nonce_b64,
+ wrapped_b64=body.wrapped_b64,
+ ))
+ # Add member if not already in group
+ mem = await db.get(GroupMember, (group_id, target.id))
+ if not mem:
+ db.add(GroupMember(group_id=group_id, user_id=target.id))
+
+ await db.commit()
+ return {"status": "stored", "group_id": group_id, "username": username}
+
+
+@router.get("/{group_id}/gek")
+async def get_my_gek_bundle(
+ group_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ group = await db.get(Group, group_id)
+ if not group:
+ raise HTTPException(status_code=404, detail="Group not found")
+
+ bundle = await db.get(GEKBundle, (group_id, current_user.id))
+ if not bundle:
+ raise HTTPException(status_code=404, detail="No GEK bundle for this user in this group")
+
+ return {
+ "group_id": group_id,
+ "pk_eph_b64": bundle.pk_eph_b64,
+ "nonce_b64": bundle.nonce_b64,
+ "wrapped_b64": bundle.wrapped_b64,
+ }
+
+
+def _ip(request: Request) -> str:
+ fwd = request.headers.get("X-Forwarded-For")
+ return fwd.split(",")[0].strip() if fwd else (
+ request.client.host if request.client else "unknown")