users.py
python
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a
init: musehub initial commit
Human
171 days ago
| 1 | """MuseHub user-profile route handlers (JSON API). |
| 2 | |
| 3 | Endpoint summary: |
| 4 | GET /musehub/users/{username} — fetch full profile (public, no auth required) |
| 5 | GET /musehub/users/{username}/forks — list repos forked by this user (public) |
| 6 | POST /musehub/users — create a profile for the authenticated user |
| 7 | PUT /musehub/users/{username} — update bio/avatar/pinned repos (owner only) |
| 8 | GET /musehub/users/{username}/followers-list — list followers as user cards (public) |
| 9 | GET /musehub/users/{username}/following-list — list following as user cards (public) |
| 10 | |
| 11 | Content negotiation: all endpoints return JSON. The browser UI fetches from |
| 12 | these endpoints using the client-side MSign key pair. |
| 13 | |
| 14 | The GET endpoints are intentionally unauthenticated so that profile pages are |
| 15 | publicly discoverable without login — matching the behaviour of GitHub profiles. |
| 16 | """ |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | import logging |
| 20 | from datetime import datetime, timezone |
| 21 | |
| 22 | from fastapi import APIRouter, Depends, HTTPException, Query, status |
| 23 | from sqlalchemy import delete, or_, select |
| 24 | from sqlalchemy import update as sa_update |
| 25 | from sqlalchemy.ext.asyncio import AsyncSession |
| 26 | |
| 27 | from pydantic import Field |
| 28 | |
| 29 | from sqlalchemy import func |
| 30 | |
| 31 | from musehub.auth.dependencies import TokenClaims, optional_token, require_valid_token |
| 32 | from musehub.db import get_db |
| 33 | from musehub.db.musehub_models import MusehubIdentity, MusehubCommit, MusehubRepo |
| 34 | from musehub.db.musehub_auth_models import MusehubAuthKey |
| 35 | from musehub.muse_contracts.json_types import JSONObject |
| 36 | from musehub.models.base import CamelModel |
| 37 | from musehub.models.musehub import AgentCardEntry, AgentFleetResponse, ProfileResponse, ProfileUpdateRequest, UserForksResponse, UserStarredResponse, UserWatchedResponse |
| 38 | from musehub.services import musehub_profile as profile_svc |
| 39 | from musehub.services import musehub_repository as repo_svc |
| 40 | |
| 41 | logger = logging.getLogger(__name__) |
| 42 | |
| 43 | router = APIRouter() |
| 44 | |
| 45 | |
| 46 | # --------------------------------------------------------------------------- |
| 47 | # Request models |
| 48 | # --------------------------------------------------------------------------- |
| 49 | |
| 50 | |
| 51 | class CreateProfileBody(CamelModel): |
| 52 | """Body for POST /api/musehub/users — create a public profile for the authenticated user.""" |
| 53 | |
| 54 | username: str = Field( |
| 55 | ..., |
| 56 | min_length=1, |
| 57 | max_length=64, |
| 58 | pattern=r"^[a-z0-9_-]+$", |
| 59 | description="URL-friendly username (lowercase alphanumeric, hyphens, underscores)", |
| 60 | ) |
| 61 | bio: str | None = Field(None, max_length=500, description="Short bio (Markdown supported)") |
| 62 | avatar_url: str | None = Field(None, max_length=2048, description="Avatar image URL") |
| 63 | |
| 64 | |
| 65 | class UserCardResponse(CamelModel): |
| 66 | """Compact user card returned by followers-list and following-list endpoints. |
| 67 | |
| 68 | Designed for rendering avatar circles, linked usernames, and bio previews |
| 69 | in the Followers / Following tabs on the profile page. |
| 70 | """ |
| 71 | |
| 72 | username: str |
| 73 | bio: str | None = None |
| 74 | avatar_url: str | None = None |
| 75 | |
| 76 | |
| 77 | @router.get( |
| 78 | "/users/{username}", |
| 79 | response_model=ProfileResponse, |
| 80 | operation_id="getUserProfile", |
| 81 | summary="Get a MuseHub user profile (public)", |
| 82 | ) |
| 83 | async def get_user_profile( |
| 84 | username: str, |
| 85 | db: AsyncSession = Depends(get_db), |
| 86 | ) -> ProfileResponse: |
| 87 | """Return the full profile for a user: bio, avatar, pinned repos, public repos, |
| 88 | contribution graph, and session credits. |
| 89 | |
| 90 | No auth required — profiles are publicly accessible. Returns 404 when the |
| 91 | username does not match any registered profile. |
| 92 | """ |
| 93 | profile = await profile_svc.get_full_profile(db, username) |
| 94 | if profile is None: |
| 95 | raise HTTPException( |
| 96 | status_code=status.HTTP_404_NOT_FOUND, |
| 97 | detail=f"No profile found for username '{username}'", |
| 98 | ) |
| 99 | logger.info("✅ Served profile for username=%s", username) |
| 100 | return profile |
| 101 | |
| 102 | |
| 103 | @router.get( |
| 104 | "/users/{username}/forks", |
| 105 | response_model=UserForksResponse, |
| 106 | operation_id="getUserForks", |
| 107 | summary="List repos forked by a user (public)", |
| 108 | ) |
| 109 | async def get_user_forks( |
| 110 | username: str, |
| 111 | db: AsyncSession = Depends(get_db), |
| 112 | ) -> UserForksResponse: |
| 113 | """Return all repos that ``username`` has forked, with source attribution. |
| 114 | |
| 115 | Joins ``musehub_forks`` (where ``forked_by`` matches the given username) |
| 116 | with ``musehub_repos`` twice — once for the fork repo metadata and once |
| 117 | for the source repo's owner/slug so the profile page can render |
| 118 | "forked from {source_owner}/{source_slug}" under each card. |
| 119 | |
| 120 | No auth required — the forked tab is publicly visible on profile pages. |
| 121 | Returns 404 when the username does not exist. |
| 122 | """ |
| 123 | profile = await profile_svc.get_profile_by_username(db, username) |
| 124 | if profile is None: |
| 125 | raise HTTPException( |
| 126 | status_code=status.HTTP_404_NOT_FOUND, |
| 127 | detail=f"No profile found for username '{username}'", |
| 128 | ) |
| 129 | |
| 130 | result = await repo_svc.get_user_forks(db, username) |
| 131 | logger.info("✅ Served %d forks for username=%s", result.total, username) |
| 132 | return result |
| 133 | |
| 134 | |
| 135 | @router.get( |
| 136 | "/users/{username}/starred", |
| 137 | response_model=UserStarredResponse, |
| 138 | operation_id="getUserStarred", |
| 139 | summary="List repos starred by a user (public)", |
| 140 | ) |
| 141 | async def get_user_starred( |
| 142 | username: str, |
| 143 | db: AsyncSession = Depends(get_db), |
| 144 | ) -> UserStarredResponse: |
| 145 | """Return all repos that ``username`` has starred, newest first. |
| 146 | |
| 147 | Joins ``musehub_stars`` (where user_id matches the profile's user_id) |
| 148 | with ``musehub_repos`` to surface full repo metadata for each starred repo. |
| 149 | |
| 150 | No auth required — starred repo lists are publicly accessible. |
| 151 | Returns 404 when the username does not exist. |
| 152 | """ |
| 153 | profile = await profile_svc.get_profile_by_username(db, username) |
| 154 | if profile is None: |
| 155 | raise HTTPException( |
| 156 | status_code=status.HTTP_404_NOT_FOUND, |
| 157 | detail=f"No profile found for username '{username}'", |
| 158 | ) |
| 159 | |
| 160 | result = await repo_svc.get_user_starred(db, username) |
| 161 | logger.info("✅ Served %d starred repos for username=%s", result.total, username) |
| 162 | return result |
| 163 | |
| 164 | |
| 165 | @router.get( |
| 166 | "/users/{username}/watched", |
| 167 | response_model=UserWatchedResponse, |
| 168 | operation_id="getUserWatched", |
| 169 | summary="List repos watched by a user (public)", |
| 170 | ) |
| 171 | async def get_user_watched( |
| 172 | username: str, |
| 173 | db: AsyncSession = Depends(get_db), |
| 174 | ) -> UserWatchedResponse: |
| 175 | """Return all repos that ``username`` is currently watching, newest first. |
| 176 | |
| 177 | Joins ``musehub_watches`` (where user_id matches the profile's user_id) |
| 178 | with ``musehub_repos`` to surface full repo metadata for each watched repo. |
| 179 | |
| 180 | No auth required — watched repo lists are publicly accessible. |
| 181 | Returns 404 when the username does not exist. |
| 182 | """ |
| 183 | profile = await profile_svc.get_profile_by_username(db, username) |
| 184 | if profile is None: |
| 185 | raise HTTPException( |
| 186 | status_code=status.HTTP_404_NOT_FOUND, |
| 187 | detail=f"No profile found for username '{username}'", |
| 188 | ) |
| 189 | |
| 190 | result = await repo_svc.get_user_watched(db, username) |
| 191 | logger.info("✅ Served %d watched repos for username=%s", result.total, username) |
| 192 | return result |
| 193 | |
| 194 | |
| 195 | @router.post( |
| 196 | "/users", |
| 197 | response_model=ProfileResponse, |
| 198 | status_code=status.HTTP_201_CREATED, |
| 199 | operation_id="createUserProfile", |
| 200 | summary="Create a MuseHub user profile", |
| 201 | ) |
| 202 | async def create_user_profile( |
| 203 | body: CreateProfileBody, |
| 204 | db: AsyncSession = Depends(get_db), |
| 205 | claims: TokenClaims = Depends(require_valid_token), |
| 206 | ) -> ProfileResponse: |
| 207 | """Create a public profile for the authenticated user. |
| 208 | |
| 209 | The ``username`` must be globally unique and URL-safe. Returns 409 if the |
| 210 | username is already taken, or if the caller already has a profile. |
| 211 | """ |
| 212 | user_id: str = claims.handle |
| 213 | if not user_id: |
| 214 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no sub") |
| 215 | |
| 216 | existing_by_user = await profile_svc.get_profile_by_user_id(db, user_id) |
| 217 | if existing_by_user is not None: |
| 218 | raise HTTPException( |
| 219 | status_code=status.HTTP_409_CONFLICT, |
| 220 | detail="You already have a profile. Use PUT to update it.", |
| 221 | ) |
| 222 | |
| 223 | existing_by_name = await profile_svc.get_profile_by_username(db, body.username) |
| 224 | if existing_by_name is not None: |
| 225 | raise HTTPException( |
| 226 | status_code=status.HTTP_409_CONFLICT, |
| 227 | detail=f"Username '{body.username}' is already taken.", |
| 228 | ) |
| 229 | |
| 230 | await profile_svc.create_profile( |
| 231 | db, |
| 232 | user_id=user_id, |
| 233 | username=body.username, |
| 234 | bio=body.bio, |
| 235 | avatar_url=body.avatar_url, |
| 236 | ) |
| 237 | await db.commit() |
| 238 | |
| 239 | full = await profile_svc.get_full_profile(db, body.username) |
| 240 | if full is None: |
| 241 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Profile created but not found") |
| 242 | logger.info("✅ Created profile username=%s user_id=%s", body.username, user_id) |
| 243 | return full |
| 244 | |
| 245 | |
| 246 | @router.put( |
| 247 | "/users/{username}", |
| 248 | response_model=ProfileResponse, |
| 249 | operation_id="updateUserProfile", |
| 250 | summary="Update a MuseHub user profile (owner only)", |
| 251 | ) |
| 252 | async def update_user_profile( |
| 253 | username: str, |
| 254 | body: ProfileUpdateRequest, |
| 255 | db: AsyncSession = Depends(get_db), |
| 256 | claims: TokenClaims = Depends(require_valid_token), |
| 257 | ) -> ProfileResponse: |
| 258 | """Partially update the authenticated user's profile: bio, avatar_url, pinned_repo_ids. |
| 259 | |
| 260 | Returns 403 if the caller does not own the profile, 404 if the username |
| 261 | does not exist. |
| 262 | """ |
| 263 | profile = await profile_svc.get_profile_by_username(db, username) |
| 264 | if profile is None: |
| 265 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") |
| 266 | |
| 267 | caller_id: str = claims.handle |
| 268 | if profile.user_id != caller_id: |
| 269 | raise HTTPException( |
| 270 | status_code=status.HTTP_403_FORBIDDEN, |
| 271 | detail="You can only edit your own profile.", |
| 272 | ) |
| 273 | |
| 274 | await profile_svc.update_profile(db, profile, body) |
| 275 | await db.commit() |
| 276 | |
| 277 | full = await profile_svc.get_full_profile(db, username) |
| 278 | if full is None: |
| 279 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Profile updated but not found") |
| 280 | logger.info("✅ Updated profile username=%s", username) |
| 281 | return full |
| 282 | |
| 283 | |
| 284 | # --------------------------------------------------------------------------- |
| 285 | # Followers / following lists |
| 286 | # --------------------------------------------------------------------------- |
| 287 | |
| 288 | |
| 289 | async def _resolve_user_id(db: AsyncSession, username: str) -> str: |
| 290 | """Return the identity id for a given handle, falling back to the handle itself.""" |
| 291 | row = (await db.execute( |
| 292 | select(MusehubIdentity.id).where( |
| 293 | MusehubIdentity.handle == username, |
| 294 | MusehubIdentity.deleted_at.is_(None), |
| 295 | ) |
| 296 | )).scalar_one_or_none() |
| 297 | return row if row else username |
| 298 | |
| 299 | |
| 300 | async def _profile_to_card(db: AsyncSession, id_value: str) -> UserCardResponse | None: |
| 301 | row = (await db.execute( |
| 302 | select(MusehubIdentity).where( |
| 303 | or_(MusehubIdentity.id == id_value, MusehubIdentity.handle == id_value), |
| 304 | MusehubIdentity.deleted_at.is_(None), |
| 305 | ) |
| 306 | )).scalar_one_or_none() |
| 307 | if row is None: |
| 308 | return None |
| 309 | return UserCardResponse(username=row.handle, bio=row.bio, avatar_url=row.avatar_url) |
| 310 | |
| 311 | |
| 312 | @router.get( |
| 313 | "/users/{username}/followers-list", |
| 314 | response_model=list[UserCardResponse], |
| 315 | operation_id="listFollowerCards", |
| 316 | summary="List followers as user cards (public)", |
| 317 | ) |
| 318 | async def list_followers( |
| 319 | username: str, |
| 320 | limit: int = Query(100, ge=1, le=500), |
| 321 | db: AsyncSession = Depends(get_db), |
| 322 | claims: TokenClaims | None = Depends(optional_token), |
| 323 | ) -> list[UserCardResponse]: |
| 324 | """Return user cards for everyone who follows *username*.""" |
| 325 | profile = await profile_svc.get_profile_by_username(db, username) |
| 326 | if profile is None: |
| 327 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No profile found for '{username}'") |
| 328 | return [] |
| 329 | |
| 330 | |
| 331 | @router.get( |
| 332 | "/users/{username}/following-list", |
| 333 | response_model=list[UserCardResponse], |
| 334 | operation_id="listFollowingCards", |
| 335 | summary="List following as user cards (public)", |
| 336 | ) |
| 337 | async def list_following( |
| 338 | username: str, |
| 339 | limit: int = Query(100, ge=1, le=500), |
| 340 | db: AsyncSession = Depends(get_db), |
| 341 | claims: TokenClaims | None = Depends(optional_token), |
| 342 | ) -> list[UserCardResponse]: |
| 343 | """Return user cards for everyone that *username* follows.""" |
| 344 | profile = await profile_svc.get_profile_by_username(db, username) |
| 345 | if profile is None: |
| 346 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No profile found for '{username}'") |
| 347 | return [] |
| 348 | |
| 349 | |
| 350 | # --------------------------------------------------------------------------- |
| 351 | # Agent Fleet |
| 352 | # --------------------------------------------------------------------------- |
| 353 | |
| 354 | def _model_label(model_id: str | None) -> str: |
| 355 | """Derive a short human-readable label from a model_id string. |
| 356 | |
| 357 | Examples: |
| 358 | ``claude-sonnet-4-6`` → ``Sonnet 4.6`` |
| 359 | ``claude-opus-4-6`` → ``Opus 4.6`` |
| 360 | ``claude-haiku-4-5`` → ``Haiku 4.5`` |
| 361 | ``gpt-4o`` → ``gpt-4o`` |
| 362 | """ |
| 363 | if not model_id: |
| 364 | return "unknown" |
| 365 | stripped = model_id.removeprefix("claude-") |
| 366 | parts = stripped.split("-") |
| 367 | if not parts: |
| 368 | return model_id |
| 369 | name = parts[0].capitalize() |
| 370 | version = ".".join(parts[1:]) if len(parts) > 1 else "" |
| 371 | return f"{name} {version}".strip() |
| 372 | |
| 373 | |
| 374 | async def _query_agent_fleet( |
| 375 | db: AsyncSession, handle: str, user_id: str | None |
| 376 | ) -> list[AgentCardEntry]: |
| 377 | """Aggregate agents that have committed to repos owned by *handle*. |
| 378 | |
| 379 | Resolves ownership via *user_id* when available, falls back to repo.owner |
| 380 | string match. Returns entries sorted by commit_count DESC. |
| 381 | """ |
| 382 | # Resolve owned repo IDs |
| 383 | if user_id: |
| 384 | repo_rows = (await db.execute( |
| 385 | select(MusehubRepo.repo_id) |
| 386 | .where(MusehubRepo.owner_user_id == user_id, MusehubRepo.deleted_at.is_(None)) |
| 387 | )).all() |
| 388 | else: |
| 389 | repo_rows = (await db.execute( |
| 390 | select(MusehubRepo.repo_id) |
| 391 | .where(MusehubRepo.owner == handle, MusehubRepo.deleted_at.is_(None)) |
| 392 | )).all() |
| 393 | |
| 394 | repo_ids = [r[0] for r in repo_rows] |
| 395 | if not repo_ids: |
| 396 | return [] |
| 397 | |
| 398 | agent_id_col = MusehubCommit.commit_meta["agent_id"].as_string() |
| 399 | model_id_col = MusehubCommit.commit_meta["model_id"].as_string() |
| 400 | |
| 401 | rows = (await db.execute( |
| 402 | select( |
| 403 | agent_id_col.label("agent_id"), |
| 404 | model_id_col.label("model_id"), |
| 405 | func.count(MusehubCommit.commit_id).label("commit_count"), |
| 406 | func.count(func.distinct(MusehubCommit.repo_id)).label("repo_count"), |
| 407 | func.max(MusehubCommit.timestamp).label("last_seen"), |
| 408 | ) |
| 409 | .where( |
| 410 | MusehubCommit.repo_id.in_(repo_ids), |
| 411 | agent_id_col.isnot(None), |
| 412 | agent_id_col != "", |
| 413 | ) |
| 414 | .group_by(agent_id_col, model_id_col) |
| 415 | .order_by(func.count(MusehubCommit.commit_id).desc()) |
| 416 | )).all() |
| 417 | |
| 418 | return [ |
| 419 | AgentCardEntry( |
| 420 | agent_id=row.agent_id, |
| 421 | model_id=row.model_id or None, |
| 422 | model_label=_model_label(row.model_id), |
| 423 | commit_count=int(row.commit_count), |
| 424 | repo_count=int(row.repo_count), |
| 425 | last_seen=row.last_seen, |
| 426 | ) |
| 427 | for row in rows |
| 428 | ] |
| 429 | |
| 430 | |
| 431 | @router.get( |
| 432 | "/users/{handle}/agent-fleet", |
| 433 | response_model=AgentFleetResponse, |
| 434 | operation_id="getAgentFleet", |
| 435 | summary="List agents deployed by a handle (public)", |
| 436 | ) |
| 437 | async def get_agent_fleet( |
| 438 | handle: str, |
| 439 | db: AsyncSession = Depends(get_db), |
| 440 | ) -> AgentFleetResponse: |
| 441 | """Return all agents that have committed to repos owned by *handle*. |
| 442 | |
| 443 | Aggregated from ``commit_meta.agent_id`` / ``commit_meta.model_id`` across |
| 444 | every commit in repos owned by the identity. Sorted by commit volume DESC. |
| 445 | |
| 446 | No auth required — agent fleet is publicly visible provenance data. |
| 447 | Returns 404 only when the handle has no repos at all. |
| 448 | """ |
| 449 | agents = await _query_agent_fleet(db, handle, None) |
| 450 | |
| 451 | if not agents: |
| 452 | # Verify the handle owns at least one repo so we return 404 for truly unknown handles |
| 453 | exists = (await db.execute( |
| 454 | select(MusehubRepo.repo_id) |
| 455 | .where(MusehubRepo.owner == handle, MusehubRepo.deleted_at.is_(None)) |
| 456 | .limit(1) |
| 457 | )).first() |
| 458 | if exists is None: |
| 459 | raise HTTPException( |
| 460 | status_code=status.HTTP_404_NOT_FOUND, |
| 461 | detail=f"No repos found for handle '{handle}'", |
| 462 | ) |
| 463 | |
| 464 | logger.info("✅ Agent fleet handle=%s count=%d", handle, len(agents)) |
| 465 | return AgentFleetResponse(handle=handle, agents=agents, total=len(agents)) |
| 466 | |
| 467 | |
| 468 | # --------------------------------------------------------------------------- |
| 469 | # GDPR / CCPA: data export and account deletion |
| 470 | # --------------------------------------------------------------------------- |
| 471 | |
| 472 | |
| 473 | @router.get("/me/export", summary="Export all personal data (GDPR Article 20)") |
| 474 | async def export_my_data( |
| 475 | claims: TokenClaims = Depends(require_valid_token), |
| 476 | db: AsyncSession = Depends(get_db), |
| 477 | ) -> JSONObject: |
| 478 | """Return a JSON dump of all data MuseHub holds for the authenticated identity. |
| 479 | |
| 480 | Covers: identity profile, registered public keys, owned repos (metadata only — |
| 481 | not object blobs), and commits authored by this identity. |
| 482 | |
| 483 | This endpoint satisfies the GDPR right to data portability (Article 20) and |
| 484 | CCPA right to know / right to access. No object blobs are included because |
| 485 | those are already accessible via the standard repository API. |
| 486 | """ |
| 487 | identity_id = claims.identity_id |
| 488 | |
| 489 | # Identity profile |
| 490 | identity: MusehubIdentity | None = ( |
| 491 | await db.execute( |
| 492 | select(MusehubIdentity).where(MusehubIdentity.id == identity_id) |
| 493 | ) |
| 494 | ).scalar_one_or_none() |
| 495 | |
| 496 | if identity is None: |
| 497 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found") |
| 498 | |
| 499 | # Registered public keys (no key material — fingerprints only) |
| 500 | keys = ( |
| 501 | await db.execute( |
| 502 | select(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity_id) |
| 503 | ) |
| 504 | ).scalars().all() |
| 505 | |
| 506 | # Owned repos (metadata only) |
| 507 | repos = ( |
| 508 | await db.execute( |
| 509 | select(MusehubRepo).where( |
| 510 | MusehubRepo.owner == identity.handle, |
| 511 | MusehubRepo.deleted_at.is_(None), |
| 512 | ) |
| 513 | ) |
| 514 | ).scalars().all() |
| 515 | |
| 516 | # Commits authored by this identity |
| 517 | commits = ( |
| 518 | await db.execute( |
| 519 | select(MusehubCommit).where(MusehubCommit.author == identity.handle).limit(10_000) |
| 520 | ) |
| 521 | ).scalars().all() |
| 522 | |
| 523 | return { |
| 524 | "schema_version": "1.0", |
| 525 | "exported_at": datetime.now(timezone.utc).isoformat(), |
| 526 | "identity": { |
| 527 | "id": identity.id, |
| 528 | "handle": identity.handle, |
| 529 | "identity_type": identity.identity_type, |
| 530 | "display_name": identity.display_name, |
| 531 | "bio": identity.bio, |
| 532 | "email": identity.email, |
| 533 | "website_url": identity.website_url, |
| 534 | "location": identity.location, |
| 535 | "created_at": identity.created_at.isoformat(), |
| 536 | "tos_accepted_at": identity.tos_accepted_at.isoformat() if identity.tos_accepted_at else None, |
| 537 | "tos_version": identity.tos_version, |
| 538 | }, |
| 539 | "keys": [ |
| 540 | { |
| 541 | "key_id": k.key_id, |
| 542 | "algorithm": k.algorithm, |
| 543 | "fingerprint": k.fingerprint, |
| 544 | "label": k.label, |
| 545 | "created_at": k.created_at.isoformat(), |
| 546 | "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None, |
| 547 | } |
| 548 | for k in keys |
| 549 | ], |
| 550 | "repos": [ |
| 551 | { |
| 552 | "repo_id": r.repo_id, |
| 553 | "name": r.name, |
| 554 | "slug": r.slug, |
| 555 | "visibility": r.visibility, |
| 556 | "description": r.description, |
| 557 | "tags": r.tags, |
| 558 | "training_opt_out": r.training_opt_out, |
| 559 | "created_at": r.created_at.isoformat(), |
| 560 | } |
| 561 | for r in repos |
| 562 | ], |
| 563 | "commits": [ |
| 564 | { |
| 565 | "commit_id": c.commit_id, |
| 566 | "repo_id": c.repo_id, |
| 567 | "branch": c.branch, |
| 568 | "message": c.message, |
| 569 | "timestamp": c.timestamp.isoformat() if c.timestamp else None, |
| 570 | } |
| 571 | for c in commits |
| 572 | ], |
| 573 | } |
| 574 | |
| 575 | |
| 576 | @router.delete( |
| 577 | "/me", |
| 578 | status_code=status.HTTP_204_NO_CONTENT, |
| 579 | summary="Delete account and all associated data (GDPR Article 17)", |
| 580 | ) |
| 581 | async def delete_my_account( |
| 582 | claims: TokenClaims = Depends(require_valid_token), |
| 583 | db: AsyncSession = Depends(get_db), |
| 584 | ) -> None: |
| 585 | """Soft-delete the authenticated identity and hard-delete all auth keys. |
| 586 | |
| 587 | After this call: |
| 588 | - The identity row has ``deleted_at`` set to now. |
| 589 | - All registered public keys are hard-deleted (login is no longer possible). |
| 590 | - Private repos are soft-deleted (hard-deleted after the retention window). |
| 591 | - Public repos are soft-deleted to preserve commit history integrity; they will |
| 592 | be hard-deleted after the retention window unless overridden by an admin. |
| 593 | |
| 594 | This satisfies the GDPR right to erasure (Article 17). |
| 595 | """ |
| 596 | identity_id = claims.identity_id |
| 597 | now = datetime.now(timezone.utc) |
| 598 | |
| 599 | # Load identity |
| 600 | identity: MusehubIdentity | None = ( |
| 601 | await db.execute( |
| 602 | select(MusehubIdentity).where(MusehubIdentity.id == identity_id) |
| 603 | ) |
| 604 | ).scalar_one_or_none() |
| 605 | |
| 606 | if identity is None: |
| 607 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found") |
| 608 | |
| 609 | # Hard-delete all auth keys so the handle cannot be re-authenticated |
| 610 | await db.execute( |
| 611 | delete(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity_id) |
| 612 | ) |
| 613 | |
| 614 | # Soft-delete all owned repos |
| 615 | await db.execute( |
| 616 | sa_update(MusehubRepo) |
| 617 | .where(MusehubRepo.owner == identity.handle, MusehubRepo.deleted_at.is_(None)) |
| 618 | .values(deleted_at=now) |
| 619 | ) |
| 620 | |
| 621 | # Soft-delete the identity |
| 622 | identity.deleted_at = now |
| 623 | |
| 624 | await db.commit() |
| 625 | logger.info("✅ GDPR account deletion: handle=%s identity_id=%s", identity.handle, identity_id) |
File History
1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a
init: musehub initial commit
Human
171 days ago