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 05:31:05 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 05:31:05 +0200
commit3b2dd318477eb268e6821fb000aeadfe60d85987 (patch)
tree0c46c3ea663490bbee847a0eec5a41c41ad6561b /packages/meshbay-hub/src/meshbay_hub/api/groups.py
parent6d64da7816aec2cc58bb1a6f0aceb9c9a921129f (diff)
downloadmeshbay-3b2dd318477eb268e6821fb000aeadfe60d85987.tar.gz
feat: Phase 6 complete — chat, multi-group, federation, replication, webcrypto
6.1 Double Ratchet (meshbay_common/ratchet.py): Forward secrecy, break-in recovery, out-of-order delivery. Signal-spec KDF_RK/KDF_CK via HKDF-SHA256. 11/11 tests. 6.2 Multi-group node (config.py): [[groups]] TOML array, per-group ports, back-compat [group]. 6.3 MHP federation persistence (db/models.py FederatedGroup + SwarmSource): receive_directory() now persists to federated_groups table. list_public_groups() includes federated results with source attribution. 6.4 Content replication (node/replication.py + hub SwarmSource): ContentReplicator: fetch-index, download, hash-verify, register-swarm. Hub: POST /v1/swarm/register, GET /v1/swarm/{hash} for multi-source. 6.5 Browser private group (webcrypto.py + static/crypto.js): AES-256-GCM variant of GEK for WebCrypto-compatible groups. crypto.js: SubtleCrypto importGEK + deriveChunkKey + decryptChunk. Keys distinct from ChaCha20 via :aes HKDF info suffix. 4/4 tests. 74/74 tests total. 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.py97
1 files changed, 82 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 0092fbd..2ae2c93 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -7,7 +7,10 @@ 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
+from meshbay_hub.db.models import (
+ FederatedGroup, GEKBundle, Group, GroupMember,
+ IPLog, SwarmSource, User,
+)
router = APIRouter(prefix="/v1/groups", tags=["groups"])
@@ -17,27 +20,91 @@ async def list_public_groups(
db: AsyncSession = Depends(get_db),
limit: int = 50,
offset: int = 0,
+ include_federated: bool = True,
):
- """List public groups — browsable without authentication."""
+ """List public groups — local and optionally federated. No auth required."""
result = await db.execute(
select(Group)
.where(Group.visibility == "public", Group.status == "active")
.order_by(Group.created_at.desc())
- .limit(limit)
- .offset(offset)
+ .limit(limit).offset(offset)
)
- groups = result.scalars().all()
+ local = result.scalars().all()
+ groups = [
+ {
+ "id": g.id, "name": g.name, "join_policy": g.join_policy,
+ "created_at": g.created_at.isoformat(), "source": "local",
+ }
+ for g in local
+ ]
+
+ if include_federated:
+ fed_result = await db.execute(
+ select(FederatedGroup)
+ .order_by(FederatedGroup.updated_at.desc())
+ .limit(limit)
+ )
+ for fg in fed_result.scalars().all():
+ groups.append({
+ "id": fg.id, "name": fg.name, "join_policy": fg.join_policy,
+ "updated_at": fg.updated_at.isoformat(), "source": fg.source_hub,
+ })
+
+ return {"groups": groups, "total": len(groups)}
+
+
+# ── Swarm (content replication) ───────────────────────────────────────────────
+
+class SwarmRegisterRequest(BaseModel):
+ content_hash: str # blake3 hex
+ endpoint: str # "ip:port"
+
+
+@router.post("/v1/swarm/register", status_code=201)
+async def swarm_register(
+ body: SwarmRegisterRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Node registers itself as a source for a content hash (public swarm)."""
+ from datetime import datetime, timezone
+ node_result = await db.execute(
+ select(User).where(User.id == current_user.id))
+ # Use current_user.id as node_id for simplicity
+ existing = await db.get(SwarmSource, (body.content_hash, current_user.id))
+ now = datetime.now(timezone.utc)
+ if existing:
+ existing.endpoint = body.endpoint
+ existing.last_seen = now
+ else:
+ db.add(SwarmSource(
+ content_hash=body.content_hash,
+ node_id=current_user.id,
+ endpoint=body.endpoint,
+ ))
+ await db.commit()
+ return {"status": "registered", "hash": body.content_hash}
+
+
+@router.get("/v1/swarm/{content_hash}")
+async def swarm_sources(
+ content_hash: str,
+ db: AsyncSession = Depends(get_db),
+):
+ """Return list of nodes that can serve a content hash."""
+ from datetime import datetime, timezone, timedelta
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=30)
+ result = await db.execute(
+ select(SwarmSource)
+ .where(
+ SwarmSource.content_hash == content_hash,
+ SwarmSource.last_seen > cutoff,
+ )
+ )
+ sources = result.scalars().all()
return {
- "groups": [
- {
- "id": g.id,
- "name": g.name,
- "join_policy": g.join_policy,
- "created_at": g.created_at.isoformat(),
- }
- for g in groups
- ],
- "total": len(groups),
+ "hash": content_hash,
+ "sources": [{"node_id": s.node_id, "endpoint": s.endpoint} for s in sources],
}