main.py
python
sha256:77fc45e703f90c0d603ecb1a0ce21ff21095728ca7dd0e146eb5e966c8f9fcc9
more passing tests from full test suite fun
Human
patch
40 days ago
| 1 | """ |
| 2 | MuseHub API |
| 3 | |
| 4 | Agent-first, symbol-level intelligence hub for Muse repositories. |
| 5 | Push commits, open merge proposals, track issues, query symbol graphs, coordinate agent swarms. |
| 6 | """ |
| 7 | |
| 8 | import logging |
| 9 | from contextlib import asynccontextmanager |
| 10 | from collections.abc import AsyncIterator |
| 11 | from typing import Awaitable, Callable |
| 12 | |
| 13 | from pathlib import Path |
| 14 | |
| 15 | from fastapi import Depends, FastAPI, Request |
| 16 | from fastapi.middleware.cors import CORSMiddleware |
| 17 | from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html |
| 18 | from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse |
| 19 | from fastapi.staticfiles import StaticFiles |
| 20 | from sqlalchemy import text |
| 21 | from sqlalchemy.ext.asyncio import AsyncSession |
| 22 | from starlette.middleware.base import BaseHTTPMiddleware |
| 23 | from starlette.responses import Response |
| 24 | from slowapi import _rate_limit_exceeded_handler |
| 25 | from slowapi.errors import RateLimitExceeded |
| 26 | |
| 27 | from musehub.config import settings |
| 28 | from musehub.api.routes.musehub import ( |
| 29 | ui as musehub_ui_routes, |
| 30 | ui_blob as musehub_ui_blob_routes, |
| 31 | ui_blame as musehub_ui_blame_routes, |
| 32 | ui_repo_settings as musehub_ui_repo_settings_routes, |
| 33 | ui_topics as musehub_ui_topics_routes, |
| 34 | ui_user_profile as musehub_ui_profile_routes, |
| 35 | ui_new_repo as musehub_ui_new_repo_routes, |
| 36 | ui_domains as musehub_ui_domains_routes, |
| 37 | ui_intel as musehub_ui_intel_routes, |
| 38 | ui_agents as musehub_ui_agents_routes, |
| 39 | ui_releases as musehub_ui_releases_routes, |
| 40 | ui_sessions as musehub_ui_sessions_routes, |
| 41 | ui_tree as musehub_ui_tree_routes, |
| 42 | ui_repo as musehub_ui_repo_routes, |
| 43 | ui_commits as musehub_ui_commits_routes, |
| 44 | ui_proposals as musehub_ui_proposals_routes, |
| 45 | ui_issues as musehub_ui_issues_routes, |
| 46 | ui_symbols as musehub_ui_symbols_routes, |
| 47 | ui_legal as musehub_ui_legal_routes, |
| 48 | ui_docs as musehub_ui_docs_routes, |
| 49 | ui_mists as musehub_ui_mists_routes, |
| 50 | domains as musehub_domains_routes, |
| 51 | discover as musehub_discover_routes, |
| 52 | users as musehub_user_routes, |
| 53 | sitemap as musehub_sitemap_routes, |
| 54 | ) |
| 55 | from musehub.api.routes import musehub as musehub_router_pkg |
| 56 | from musehub.api.routes.mcp import router as mcp_router |
| 57 | from musehub.api.routes.musehub.ui_view import insights_router as musehub_ui_insights_router |
| 58 | from musehub.api.routes.wire import router as wire_router |
| 59 | from musehub.api.routes.coord import router as coord_router |
| 60 | from musehub.api.routes.api.snapshots import router as api_snapshots_router |
| 61 | from musehub.api.routes.api.identities import router as api_identities_router |
| 62 | from musehub.api.routes.api.orgs import router as api_orgs_router |
| 63 | from musehub.api.routes.musehub.mists import router as api_mists_router |
| 64 | from musehub.api.routes.musehub.social import router as api_social_router |
| 65 | from musehub.api.routes.api.search import router as api_search_router |
| 66 | from musehub.api.routes.api.auth import router as api_auth_router |
| 67 | from musehub.api.routes.api.profiles import router as api_profiles_router |
| 68 | from musehub.api.routes.api.admin import router as api_admin_router |
| 69 | from musehub.api.routes.api.caps import router as api_caps_router |
| 70 | from musehub.api.routes.protocol import router as protocol_router |
| 71 | from musehub.api.routes.musehub.install import router as install_router |
| 72 | from musehub.api.routes.musehub.ui_avatars import router as avatar_router |
| 73 | from musehub.db import get_db, init_db, close_db |
| 74 | |
| 75 | |
| 76 | class SecurityHeadersMiddleware(BaseHTTPMiddleware): |
| 77 | """Add security headers to all responses.""" |
| 78 | |
| 79 | async def dispatch( |
| 80 | self, request: Request, call_next: Callable[[Request], Awaitable[Response]] |
| 81 | ) -> Response: |
| 82 | # No per-request nonces: HTMX swaps the <body>, making per-request |
| 83 | # nonces incompatible (the browser would block scripts whose nonce no |
| 84 | # longer matches the current navigation). All inline scripts have been |
| 85 | # removed; external scripts are served from 'self'. |
| 86 | request.state.csp_nonce = "" |
| 87 | |
| 88 | response = await call_next(request) |
| 89 | |
| 90 | if "X-Frame-Options" not in response.headers: |
| 91 | response.headers["X-Frame-Options"] = "DENY" |
| 92 | response.headers["X-Content-Type-Options"] = "nosniff" |
| 93 | response.headers["X-XSS-Protection"] = "1; mode=block" |
| 94 | response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" |
| 95 | response.headers["Permissions-Policy"] = ( |
| 96 | "accelerometer=(), camera=(), geolocation=(), " |
| 97 | "gyroscope=(), magnetometer=(), microphone=(), " |
| 98 | "payment=(), usb=()" |
| 99 | ) |
| 100 | # Alpine CSP build (@alpinejs/csp) eliminates 'unsafe-eval' — it uses |
| 101 | # registered function references instead of new Function() for |
| 102 | # expression evaluation. 'unsafe-inline' has been removed from |
| 103 | # script-src: all JS is in external files served from 'self'. |
| 104 | # style-src keeps 'unsafe-inline' while server-rendered dynamic inline |
| 105 | # styles (avatar colours, label colours, etc.) are still present. |
| 106 | # Cloudflare Web Analytics beacon is loaded via a manual <script> tag |
| 107 | # in base.html — no inline script, just the external domain needed. |
| 108 | response.headers["Content-Security-Policy"] = ( |
| 109 | "default-src 'self'; " |
| 110 | "script-src 'self' " |
| 111 | "https://static.cloudflareinsights.com; " |
| 112 | "style-src 'self' 'unsafe-inline' https://fonts.bunny.net; " |
| 113 | "font-src 'self' https://fonts.bunny.net; " |
| 114 | "img-src 'self' data: https:; " |
| 115 | "connect-src 'self' https://cloudflareinsights.com; " |
| 116 | "frame-ancestors 'none'; " |
| 117 | # Instructs the browser to silently upgrade any HTTP sub-resource |
| 118 | # request to HTTPS — belt-and-suspenders against mixed-content. |
| 119 | "upgrade-insecure-requests" |
| 120 | ) |
| 121 | if not settings.debug: |
| 122 | response.headers["Strict-Transport-Security"] = ( |
| 123 | "max-age=63072000; includeSubDomains; preload" |
| 124 | ) |
| 125 | # Prevent fingerprinting — suppress the default "uvicorn" server banner |
| 126 | response.headers["Server"] = "musehub" |
| 127 | return response |
| 128 | |
| 129 | |
| 130 | from musehub.logging_config import configure_logging |
| 131 | configure_logging(debug=settings.debug) |
| 132 | logger = logging.getLogger(__name__) |
| 133 | |
| 134 | from musehub.rate_limits import limiter # noqa: E402 — after logging setup |
| 135 | |
| 136 | |
| 137 | @asynccontextmanager |
| 138 | async def lifespan(app: FastAPI) -> AsyncIterator[None]: |
| 139 | """Application lifespan handler.""" |
| 140 | import tracemalloc |
| 141 | from musehub.debug.memory import rss_mb |
| 142 | if settings.debug: |
| 143 | tracemalloc.start(10) # keep 10-frame traceback per allocation — debug only, doubles RSS in prod |
| 144 | logger.info("Starting MuseHub v%s rss=%.1f MiB tracemalloc=on", settings.app_version, rss_mb()) |
| 145 | else: |
| 146 | logger.info("Starting MuseHub v%s rss=%.1f MiB", settings.app_version, rss_mb()) |
| 147 | |
| 148 | _RELEASES_DIR.mkdir(parents=True, exist_ok=True) |
| 149 | |
| 150 | try: |
| 151 | await init_db() |
| 152 | logger.info("✅ Database initialized") |
| 153 | except Exception as e: |
| 154 | logger.error(f"❌ Failed to initialize database: {e}") |
| 155 | raise |
| 156 | |
| 157 | if not settings.debug and settings.database_url and "postgres" in settings.database_url: |
| 158 | pw = (settings.db_password or "").strip() |
| 159 | weak = {"", "changeme123", "musehub", "password", "postgres", "secret"} |
| 160 | if not pw or pw in weak: |
| 161 | raise RuntimeError( |
| 162 | "Production requires DB_PASSWORD set to a strong value. " |
| 163 | "Generate with: openssl rand -hex 16" |
| 164 | ) |
| 165 | |
| 166 | yield |
| 167 | |
| 168 | logger.info("Shutting down MuseHub...") |
| 169 | await close_db() |
| 170 | |
| 171 | |
| 172 | app = FastAPI( |
| 173 | title="MuseHub API", |
| 174 | version=settings.app_version, |
| 175 | # OpenAPI schema served in debug/dev and test environments. |
| 176 | # In production (DEBUG=false, MUSE_ENV != "test") it is disabled — agents use /mcp instead. |
| 177 | openapi_url="/api/openapi.json" if (settings.debug or settings.muse_env == "test") else None, |
| 178 | description=( |
| 179 | "**MuseHub** — agent-first, symbol-level intelligence hub for Muse repositories.\n\n" |
| 180 | "Muse is a domain-agnostic version control system. Not just files — any state space " |
| 181 | "where a 'change' is a delta across multiple axes simultaneously. " |
| 182 | "Code (symbol graph), MIDI (21 dimensions), genomics, climate simulation, 3D design.\n\n" |
| 183 | "MuseHub answers: what *meaning* changed, who changed it, what breaks, and what should " |
| 184 | "happen next. AI agents and humans collaborate via merge proposals (symbol-level diff + " |
| 185 | "blast radius), the intelligence hub (hotspots, dead code, coupling, velocity), and the " |
| 186 | "agent coordination hub (reservations, task queue, conflict forecast).\n\n" |
| 187 | "## Authentication\n\n" |
| 188 | "All write endpoints and private-repo reads use **MSign Ed25519 request signing**:\n\n" |
| 189 | "```\nAuthorization: MSign handle=\"gabriel\" ts=<unix> sig=\"<b64url>\"\n```\n\n" |
| 190 | "Register a key pair with `muse auth keygen` + `muse auth register`. " |
| 191 | "Public repo read endpoints accept unauthenticated requests.\n\n" |
| 192 | "## MCP (Model Context Protocol)\n\n" |
| 193 | "Full MCP 2025-11-25 Streamable HTTP at `POST /GET /DELETE /mcp`. " |
| 194 | "37 tools, 27 resource URIs (`muse://...`), and 11 prompts. " |
| 195 | "See `/mcp/docs` for the interactive reference.\n\n" |
| 196 | "## URL Scheme\n\n" |
| 197 | "Repos: `/{owner}/{slug}` · Proposals: `/{owner}/{slug}/proposals` · " |
| 198 | "Domain insights: `/{owner}/{slug}/insights/{ref}` · " |
| 199 | "Intel: `/{owner}/{slug}/intel` · Agents: `/{owner}/{slug}/agents/coord`\n" |
| 200 | "Clone URL: `musehub://{owner}/{slug}`\n" |
| 201 | ), |
| 202 | contact={ |
| 203 | "name": "Muse VCS", |
| 204 | "url": "https://musehub.ai", |
| 205 | "email": "[email protected]", |
| 206 | }, |
| 207 | license_info={ |
| 208 | "name": "Proprietary", |
| 209 | "url": "https://musehub.ai/terms", |
| 210 | }, |
| 211 | lifespan=lifespan, |
| 212 | # Docs are served via custom routes below that use locally-bundled assets, |
| 213 | # so we disable the default CDN-dependent auto-generated routes. |
| 214 | docs_url=None, |
| 215 | redoc_url=None, |
| 216 | ) |
| 217 | |
| 218 | |
| 219 | def _handle_rate_limit(request: Request, exc: Exception) -> Response: |
| 220 | if isinstance(exc, RateLimitExceeded): |
| 221 | result: Response = _rate_limit_exceeded_handler(request, exc) |
| 222 | # slowapi injects X-RateLimit-Reset (unix timestamp) but not Retry-After. |
| 223 | # RFC 6585 §4 requires Retry-After on 429; compute from the reset header. |
| 224 | import time as _time |
| 225 | reset_str = result.headers.get("X-RateLimit-Reset", "") |
| 226 | if reset_str: |
| 227 | try: |
| 228 | retry_after = max(1, int(reset_str) - int(_time.time())) |
| 229 | result.headers["Retry-After"] = str(retry_after) |
| 230 | except ValueError: |
| 231 | result.headers["Retry-After"] = "60" |
| 232 | else: |
| 233 | result.headers["Retry-After"] = "60" |
| 234 | return result |
| 235 | raise exc |
| 236 | |
| 237 | |
| 238 | app.state.limiter = limiter |
| 239 | app.add_exception_handler(RateLimitExceeded, _handle_rate_limit) |
| 240 | |
| 241 | from pydantic import ValidationError as PydanticValidationError |
| 242 | |
| 243 | async def _handle_pydantic_validation(request: Request, exc: Exception) -> JSONResponse: |
| 244 | """Convert manual model_validate() failures to HTTP 422 Unprocessable Entity. |
| 245 | |
| 246 | FastAPI auto-converts ValidationError for typed parameters but not for |
| 247 | explicit model_validate() calls inside route handlers. This handler |
| 248 | closes that gap so clients get 422 (not 500) on malformed wire bodies. |
| 249 | |
| 250 | Use exc.json() then re-parse rather than exc.errors() — the errors() dict |
| 251 | may contain non-serializable objects (e.g. the raw ValueError in the ctx |
| 252 | field) that json.dumps cannot handle. exc.json() serialises correctly. |
| 253 | """ |
| 254 | import json as _json |
| 255 | assert isinstance(exc, PydanticValidationError) |
| 256 | return JSONResponse( |
| 257 | status_code=422, |
| 258 | content={"detail": _json.loads(exc.json())}, |
| 259 | ) |
| 260 | |
| 261 | app.add_exception_handler(PydanticValidationError, _handle_pydantic_validation) |
| 262 | app.add_middleware(SecurityHeadersMiddleware) |
| 263 | |
| 264 | from musehub.middleware.content_size import ContentSizeLimitMiddleware |
| 265 | app.add_middleware(ContentSizeLimitMiddleware) |
| 266 | |
| 267 | from musehub.middleware.bot_throttle import BotThrottleMiddleware |
| 268 | app.add_middleware(BotThrottleMiddleware) |
| 269 | |
| 270 | from musehub.middleware.static_cache import StaticCacheMiddleware |
| 271 | app.add_middleware(StaticCacheMiddleware) |
| 272 | |
| 273 | from musehub.debug.memory import MemoryLogMiddleware |
| 274 | app.add_middleware(MemoryLogMiddleware, warn_above_mb=400) |
| 275 | |
| 276 | if "*" in settings.cors_origins: |
| 277 | logger.warning("SECURITY WARNING: CORS allows all origins. Set CORS_ORIGINS in production.") |
| 278 | app.add_middleware( |
| 279 | CORSMiddleware, |
| 280 | allow_origins=settings.cors_origins, |
| 281 | allow_credentials=True, |
| 282 | # Explicit method list — not ["*"]. HEAD and OPTIONS are required by |
| 283 | # preflight and health-check tooling; PUT is not used by any endpoint. |
| 284 | allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS", "HEAD"], |
| 285 | # Explicit header list — not ["*"]. Authorization carries MSign credentials; |
| 286 | # Content-Type and Accept drive content negotiation (JSON vs msgpack). |
| 287 | allow_headers=["Authorization", "Content-Type", "Accept", "X-Requested-With"], |
| 288 | ) |
| 289 | |
| 290 | # AccessLogMiddleware is outermost — added last so it wraps everything, |
| 291 | # captures all requests (including CORS preflight), injects request_id into |
| 292 | # contextvars, and emits the structured access line after the response is sent. |
| 293 | from musehub.middleware.access_log import AccessLogMiddleware |
| 294 | app.add_middleware(AccessLogMiddleware) |
| 295 | |
| 296 | # Static files mounted FIRST — must come before the /{owner}/{repo_slug} wildcard |
| 297 | # UI routes, otherwise "static" would be matched as an owner name. |
| 298 | _STATIC_DIR = Path(__file__).parent / "templates" / "musehub" / "static" |
| 299 | app.mount( |
| 300 | "/static", |
| 301 | StaticFiles(directory=str(_STATIC_DIR)), |
| 302 | name="static", |
| 303 | ) |
| 304 | |
| 305 | # CLI binary release tarballs — served from the data volume so they persist |
| 306 | # across container restarts and can be uploaded independently of code deploys. |
| 307 | # URL pattern: /releases/muse-{version}-{platform}-{arch}.tar.gz |
| 308 | # This path must be registered before the /{owner}/{slug} wildcard routes. |
| 309 | # check_dir=False: the directory is created in lifespan so it doesn't need to |
| 310 | # exist at import time (avoids OSError in local dev and test environments |
| 311 | # where /data is not mounted). |
| 312 | _RELEASES_DIR = Path(settings.musehub_releases_dir) |
| 313 | app.mount( |
| 314 | "/releases", |
| 315 | StaticFiles(directory=str(_RELEASES_DIR), check_dir=False), |
| 316 | name="releases", |
| 317 | ) |
| 318 | |
| 319 | # Fixed-path endpoints — registered BEFORE all include_router calls so they are |
| 320 | # never shadowed by the /{owner}/{repo_slug} wildcard UI routes. |
| 321 | |
| 322 | |
| 323 | @app.get("/healthz", include_in_schema=False, tags=["ops"]) |
| 324 | async def healthz(db: AsyncSession = Depends(get_db)) -> JSONResponse: |
| 325 | """Liveness + readiness probe for load balancers and deploy scripts. |
| 326 | |
| 327 | Returns 200 {"status": "ok"} only when: |
| 328 | - the DB responds to a lightweight SELECT 1 |
| 329 | - the object store root directory exists (local) or is reachable (S3/R2) |
| 330 | |
| 331 | Returns 503 {"status": "unhealthy", "db": bool, "storage": bool} otherwise. |
| 332 | This endpoint is exempt from rate limiting and authentication — it must be |
| 333 | reachable by the nginx upstream health check before a token is available. |
| 334 | """ |
| 335 | # ── DB probe ───────────────────────────────────────────────────────────── |
| 336 | db_ok = False |
| 337 | try: |
| 338 | await db.execute(text("SELECT 1")) |
| 339 | db_ok = True |
| 340 | except Exception: |
| 341 | logger.warning("healthz: DB probe failed", exc_info=True) |
| 342 | |
| 343 | # ── Storage probe ──────────────────────────────────────────────────────── |
| 344 | storage_ok = False |
| 345 | try: |
| 346 | from musehub.storage.backends import get_backend, BlobBackend |
| 347 | backend = get_backend() |
| 348 | if isinstance(backend, BlobBackend): |
| 349 | import asyncio as _asyncio |
| 350 | _client = backend._get_client() |
| 351 | try: |
| 352 | await _asyncio.to_thread( |
| 353 | _client.head_bucket, Bucket=backend._bucket |
| 354 | ) |
| 355 | storage_ok = True |
| 356 | except Exception: |
| 357 | storage_ok = False |
| 358 | else: |
| 359 | # Non-S3 backends (e.g. MemoryBackend) have no external dependency |
| 360 | # to probe — treat as always healthy. |
| 361 | storage_ok = True |
| 362 | except Exception: |
| 363 | logger.warning("healthz: storage probe failed", exc_info=True) |
| 364 | |
| 365 | if db_ok and storage_ok: |
| 366 | return JSONResponse( |
| 367 | status_code=200, |
| 368 | content={"status": "ok", "db": True, "storage": True}, |
| 369 | ) |
| 370 | return JSONResponse( |
| 371 | status_code=503, |
| 372 | content={"status": "unhealthy", "db": db_ok, "storage": storage_ok}, |
| 373 | ) |
| 374 | |
| 375 | |
| 376 | @app.get("/api/health/schema", tags=["health"]) |
| 377 | async def get_health_schema() -> JSONResponse: |
| 378 | """Check that the ORM models match the live DB schema. |
| 379 | |
| 380 | Returns ``{"ok": true}`` when every ORM table and column is present and |
| 381 | nullable flags match. Returns ``{"ok": false, "drift": [...]}`` listing |
| 382 | each mismatch when drift is detected. |
| 383 | |
| 384 | Skips the check (returns ``{"ok": true, "skipped": true}``) when the |
| 385 | engine is SQLite — SQLite DBs are always created from the ORM directly. |
| 386 | """ |
| 387 | try: |
| 388 | from musehub.db.database import Base, get_engine |
| 389 | from musehub.db.schema_check import assert_schema_matches_orm |
| 390 | |
| 391 | await assert_schema_matches_orm(get_engine(), Base) |
| 392 | return JSONResponse(status_code=200, content={"ok": True}) |
| 393 | except RuntimeError as exc: |
| 394 | drift_lines = [ |
| 395 | line.strip().lstrip("•").strip() |
| 396 | for line in str(exc).splitlines() |
| 397 | if line.strip().startswith("•") |
| 398 | ] |
| 399 | return JSONResponse( |
| 400 | status_code=503, |
| 401 | content={"ok": False, "drift": drift_lines}, |
| 402 | ) |
| 403 | except Exception as exc: |
| 404 | logger.error("❌ schema health check failed unexpectedly: %s", exc) |
| 405 | return JSONResponse( |
| 406 | status_code=503, |
| 407 | content={"ok": False, "drift": [str(exc)]}, |
| 408 | ) |
| 409 | |
| 410 | |
| 411 | @app.get("/_debug/memory", include_in_schema=False) |
| 412 | async def debug_memory() -> JSONResponse: |
| 413 | """Live memory snapshot — RSS + tracemalloc top allocations.""" |
| 414 | import tracemalloc |
| 415 | from musehub.debug.memory import rss_mb, top_allocations |
| 416 | return JSONResponse({ |
| 417 | "rss_mb": round(rss_mb(), 2), |
| 418 | "tracemalloc_active": tracemalloc.is_tracing(), |
| 419 | "top_allocations": top_allocations(25), |
| 420 | }) |
| 421 | |
| 422 | # Wire protocol — /{owner}/{slug}/refs|push|fetch |
| 423 | # Must come before /{owner}/{repo_slug} wildcard. |
| 424 | app.include_router(wire_router) |
| 425 | |
| 426 | # Coordination bus — /{owner}/{slug}/coord/push|pull|watch |
| 427 | # Must come before /{owner}/{slug} wildcard routes. |
| 428 | app.include_router(coord_router) |
| 429 | |
| 430 | # Protocol introspection — /protocol (public, no auth) |
| 431 | app.include_router(protocol_router) |
| 432 | |
| 433 | # Install script generation — /install.sh, /uninstall.sh (public) |
| 434 | app.include_router(install_router) |
| 435 | |
| 436 | # Avatar route — /avatars/{identity_id}.svg — must be before wildcard /{owner}/{repo_slug} |
| 437 | app.include_router(avatar_router, tags=["avatars"]) |
| 438 | |
| 439 | # Clean REST API — /api/v1/repos (snapshots), /api/identities, /api/search |
| 440 | # Registered before the main musehub router so concrete prefixes are matched first. |
| 441 | app.include_router(api_snapshots_router) |
| 442 | app.include_router(api_identities_router) |
| 443 | app.include_router(api_orgs_router) |
| 444 | app.include_router(api_mists_router, prefix="/api", tags=["Mists"]) |
| 445 | app.include_router(api_social_router, prefix="/api", tags=["Social"]) |
| 446 | app.include_router(api_search_router) |
| 447 | app.include_router(api_profiles_router) |
| 448 | app.include_router(api_admin_router) |
| 449 | app.include_router(api_caps_router) |
| 450 | |
| 451 | # Fixed-prefix subrouters registered BEFORE the main musehub router |
| 452 | # so their concrete paths are matched first, not shadowed by /{owner}/{repo_slug}. |
| 453 | app.include_router(musehub_user_routes.router, prefix="/api", tags=["Users"]) |
| 454 | app.include_router(musehub_discover_routes.router, prefix="/api", tags=["Discover"]) |
| 455 | app.include_router(musehub_domains_routes.router, prefix="/api", tags=["Domains"]) |
| 456 | app.include_router(musehub_router_pkg.router, prefix="/api") |
| 457 | app.include_router(musehub_ui_topics_routes.router, tags=["musehub-ui"]) |
| 458 | app.include_router(musehub_ui_new_repo_routes.router, tags=["musehub-ui"]) |
| 459 | app.include_router(musehub_ui_domains_routes.router, tags=["musehub-ui-domains"]) |
| 460 | app.include_router(musehub_ui_routes.fixed_router, tags=["musehub-ui"]) |
| 461 | app.include_router(musehub_ui_repo_settings_routes.router, tags=["musehub-ui-settings"]) |
| 462 | # /muse/* must be before the /{owner}/{repo_slug} wildcard (ui_repo_routes) and |
| 463 | # the /{username} profile catch-all — both would shadow /muse/foundations etc. |
| 464 | app.include_router(musehub_ui_docs_routes.router, tags=["musehub-docs"]) |
| 465 | |
| 466 | # Fixed-path routers that must come BEFORE the /{owner}/{repo_slug} wildcard in ui_routes.router. |
| 467 | # Registering them after would cause /sitemap.xml, /mcp, etc. |
| 468 | # to be shadowed and matched as if "sitemap"/"mcp" were repo owner names. |
| 469 | app.include_router(musehub_sitemap_routes.router, tags=["musehub-sitemap"]) |
| 470 | app.include_router(mcp_router) |
| 471 | |
| 472 | # Wildcard UI routes — /{owner}/{repo_slug} and deeper paths. |
| 473 | # Must come after all fixed-path routers above. |
| 474 | app.include_router(musehub_ui_insights_router, tags=["musehub-ui-insights"]) |
| 475 | app.include_router(musehub_ui_intel_routes.router, tags=["musehub-ui"]) |
| 476 | app.include_router(musehub_ui_agents_routes.router, tags=["musehub-ui"]) |
| 477 | app.include_router(musehub_ui_releases_routes.router, tags=["musehub-ui"]) |
| 478 | app.include_router(musehub_ui_sessions_routes.router, tags=["musehub-ui"]) |
| 479 | app.include_router(musehub_ui_tree_routes.router, tags=["musehub-ui"]) |
| 480 | # ui_symbols MUST come before ui_repo: /{owner}/search must not be shadowed by |
| 481 | # /{owner}/{repo_slug} — Starlette matches routes in insertion order. |
| 482 | app.include_router(musehub_ui_symbols_routes.router, tags=["musehub-ui"]) |
| 483 | app.include_router(musehub_ui_repo_routes.router, tags=["musehub-ui"]) |
| 484 | app.include_router(musehub_ui_commits_routes.router, tags=["musehub-ui"]) |
| 485 | app.include_router(musehub_ui_proposals_routes.router, tags=["musehub-ui"]) |
| 486 | app.include_router(musehub_ui_issues_routes.router, tags=["musehub-ui"]) |
| 487 | app.include_router(musehub_ui_blob_routes.router, tags=["musehub-ui"]) |
| 488 | app.include_router(musehub_ui_blame_routes.router, tags=["musehub-ui"]) |
| 489 | |
| 490 | app.include_router(musehub_ui_legal_routes.router, tags=["musehub-legal"]) |
| 491 | |
| 492 | # Mist routes — /mists/explore (fixed) and /{owner}/mists (semi-fixed) must come |
| 493 | # before the /{owner}/{repo_slug} wildcard already registered via ui_repo_routes. |
| 494 | # Registered here (after fixed-prefix routes, before profile catch-all) so that |
| 495 | # /mists/explore is never matched as owner="mists", repo_slug="explore". |
| 496 | app.include_router(musehub_ui_mists_routes.router, tags=["musehub-ui"]) |
| 497 | |
| 498 | # Profile catch-all MUST be last — /{username} is a single-segment wildcard and |
| 499 | # would shadow fixed routes (e.g. /explore, /feed, /topics, /mcp) if registered earlier. |
| 500 | app.include_router(musehub_ui_profile_routes.router, tags=["musehub-ui"]) |
| 501 | |
| 502 | if settings.debug: |
| 503 | @app.get("/docs", include_in_schema=False) |
| 504 | async def swagger_ui() -> HTMLResponse: |
| 505 | return get_swagger_ui_html( |
| 506 | openapi_url="/api/openapi.json", |
| 507 | title="MuseHub API — Swagger UI", |
| 508 | swagger_js_url="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js", |
| 509 | swagger_css_url="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css", |
| 510 | ) |
| 511 | |
| 512 | @app.get("/redoc", include_in_schema=False) |
| 513 | async def redoc_ui() -> HTMLResponse: |
| 514 | return get_redoc_html( |
| 515 | openapi_url="/api/openapi.json", |
| 516 | title="MuseHub API — ReDoc", |
| 517 | ) |
| 518 | |
| 519 | |
| 520 | @app.get("/", include_in_schema=False) |
| 521 | async def root() -> RedirectResponse: |
| 522 | """Redirect browsers to the UI; agents should use /api/openapi.json.""" |
| 523 | return RedirectResponse(url="/explore") |
| 524 | |
| 525 | app.include_router(api_auth_router) |
File History
1 commit
sha256:77fc45e703f90c0d603ecb1a0ce21ff21095728ca7dd0e146eb5e966c8f9fcc9
more passing tests from full test suite fun
Human
patch
40 days ago