gabriel / musehub public
main.py python
525 lines 23.5 KB
Raw
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