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