diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
| commit | fb91c4545c757711e1b5fd354ca4b311c89fd2c0 (patch) | |
| tree | eb1aee6cc0fb5fc020eed2763009dea5a32eb8ee /packages/meshbay-hub/src/meshbay_hub/api/groups.py | |
| parent | 77d76421829161df6b1ef628b4e6e051a2c3c2ee (diff) | |
| download | meshbay-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.py | 118 |
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") |