"""GenDesign FastAPI application entrypoint.""" import logging import os import re import threading import time from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager import sentry_sdk from fastapi import FastAPI, Request from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, Response from sentry_sdk.integrations.celery import CeleryIntegration from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.httpx import HttpxIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration from app.api.v1 import ( admin_cadastre, admin_etl, admin_jobs, admin_leads, admin_scrape, admin_weight_profiles, analytics, chat, concepts, custom_pois, insights, landing, locations, market, me, own_projects, parcels, photos, pilot, ping, trade_in, users, ) from app.core import auth_db from app.core.audit_middleware import audit_log_middleware from app.core.auth import get_role from app.core.config import settings from app.observability.sentry_scrub import scrub_sensitive_query from app.services.auth_session import resolve_session_token logger = logging.getLogger(__name__) # App-level логи в stdout контейнера (#1926 observability). По умолчанию у app.* # логгеров нет StreamHandler → их INFO теряется (в `docker logs` видны только # uvicorn-логгеры), поэтому полезные строки (напр. "OSRM road-distance applied", # RBAC, analyze) невидимы при отладке на VPS. Вешаем один StreamHandler на корневой # "app" логгер на уровне APP_LOG_LEVEL (default INFO). propagate оставляем True → # Sentry/GlitchTip LoggingIntegration (хендлер на root) продолжает получать # breadcrumbs/events. Двойного stdout нет: у root своего stdout-хендлера нет. # Идемпотентно (маркер _gd_app_stream — guard против дубля при reload/повторном # импорте). Только API-процесс (main.py); worker (celery_app.py) НЕ трогаем — # там loader'ы чатятся per-sync, INFO бы зашумил. _app_logger = logging.getLogger("app") _app_logger.setLevel(getattr(logging, os.getenv("APP_LOG_LEVEL", "INFO").upper(), logging.INFO)) if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers): _stream = logging.StreamHandler() _stream.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) _stream._gd_app_stream = True # type: ignore[attr-defined] # idempotency marker _app_logger.addHandler(_stream) # Инициализируем SDK до создания FastAPI app, чтобы все инструменты # (middleware, маршруты) видели активный client с самого старта процесса. # GlitchTip не поддерживает profiling — profiles_sample_rate=0.0. if settings.glitchtip_dsn: sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, before_send=scrub_sensitive_query, before_send_transaction=scrub_sensitive_query, integrations=[ StarletteIntegration(), FastApiIntegration(), CeleryIntegration(monitor_beat_tasks=True), SqlalchemyIntegration(), HttpxIntegration(), LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), ], ) logger.info( "GlitchTip SDK initialised (env=%s, traces=%.2f)", settings.environment, settings.glitchtip_traces_sample_rate, ) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: # Эпик «единый вход», fail-fast: AUTH_MODE=dual|db_only обязан иметь РАБОЧИЙ # реестр — проверяется не только разбор DSN, но и живое соединение (`SELECT 1`, # app/core/auth_db.py). Не соединились → контейнер НЕ стартует. Режим `legacy` # (ДЕФОЛТ) → no-op: ни проверки DSN, ни создания engine, ни коннекта. # # Почему именно на старте, а не «разберёмся в рантайме»: неверный пароль, опечатка # в хосте, не созданная БД `auth` иначе ловились бы `except`'ом вокруг резолва # сессии в rbac_guard, и сломанная конфигурация выглядела бы как «ни у кого нет # сессии» — СУТКАМИ, потому что продуктовая БД жива, приложение отвечает 200, а # сигнал остаётся только в логах. Дешевле не стартовать: деплой падает сразу и # громко. auth_db.require_auth_db_configured() yield app = FastAPI(title="GenDesign API", version="0.1.0", lifespan=lifespan) # §19 audit log (#962, EPIC18): аудит sensitive actions (analyze/forecast/export). # Регистрируем ПЕРВЫМ → Starlette применяет HTTP-middleware в LIFO-порядке # (add_middleware insert(0); build_middleware_stack wraps reversed), поэтому # rbac_guard (зарегистрирован НИЖЕ) оказывается ВНЕШНИМ слоем, а audit — внутри: # request flow = rbac_guard → audit → router. Аудитим ТОЛЬКО запросы, которые # rbac_guard уже пропустил (его 401/403 short-circuit'ы не доходят до audit). # Best-effort: сбой записи не ломает запрос (см. app/core/audit_middleware.py). app.middleware("http")(audit_log_middleware) # RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User # (см. app/core/auth.py + auth/roles.yaml). Правила: # 1) Любой non-public path требует X-Authenticated-User — иначе 401 # (Caddy пробрасывает заголовок только для авторизованных юзеров; нет # заголовка = локальный curl мимо Caddy = доступ запрещён). # 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего # не видит»). Это закрывает кейс если Caddy basic_auth пропустил кого-то # из снятой записи, или прокси-подмена заголовка. # 3) /api/v1/admin/* — только role=admin, иначе 403. # Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки — # X-Authenticated-User там просто не приходит из Caddy. # # Эпик «единый вход»: к правилу 1 добавляется ПЕРВЫЙ источник личности — # сессионная кука общего реестра (БД `auth`). Выдаёт её единственная форма входа, у # «Меры» (/trade-in/login); «Птица» сессии только читает. Кука host-only на # gendsgn.ru с path="/" → браузер шлёт её и сюда. Порядок: кука → легаси-заголовок. # Дальше — ВСЁ как раньше: роль из auth/roles.yaml, admin-гейт по _ADMIN_API_RE. # Реестр отвечает на вопрос «кто ты», roles.yaml — «что тебе можно»; продуктовые # роли реестра (auth.users.role) в «Птицу» намеренно не протаскиваются. # # ⚠️ AUTH_MODE=legacy ПО УМОЛЧАНИЮ — popup Caddy basic_auth ещё стоит и снимается # ПОСЛЕДНИМ PR эпика. Пока режим legacy, этот файл ведёт себя бит-в-бит как до эпика: # кука не читается, БД `auth` не открывается. `dual` — переходный режим (кука, при её # отсутствии/сбое реестра фолбэк на заголовок), `db_only` — фолбэка нет вовсе. # # ⚠️ ДОЛГ, КОТОРЫЙ ОБЯЗАН БЫТЬ ЗАКРЫТ ДО СНЯТИЯ POPUP'А (не решается этим PR). # Guard проверяет ровно две вещи: есть ли username в auth/roles.yaml (get_role) и # admin-гейт по _ADMIN_API_RE. Списки `paths`/`deny` из roles.yaml на бэкенде НЕ # применяются — это зафиксировано в самом auth/roles.yaml:33-35 («path-level # enforcement делает frontend RouteGuard»). Следствие: в момент включения режима # «Птицу» получает КАЖДЫЙ аккаунт реестра, чей username совпадает с записью в # roles.yaml, — включая роль `expired` (user2: paths: [], deny: "/**"), которую # сегодня останавливает только фронт. Это не регрессия (те же люди сегодня в # caddy/users.caddy.snippet и добираются туда же через basic_auth), но эпик делает # её несущей: (а) до снятия popup'а отзыв доступа имеет ДВА рубильника — # caddy-snippet и access_state в реестре, их надо держать синхронными; (б) после # снятия roles.yaml остаётся ЕДИНСТВЕННЫМ гейтом, и `expired` в нём станет чисто # фронтовой фикцией. Перед включением: сверить `auth.users.username` на проде с # `users:` в roles.yaml и решить — применять `paths`/`deny` на бэкенде или убрать # `expired` как вводящий в заблуждение. _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"}) def _propagate_authenticated_user(request: Request, username: str) -> None: """Инжектит `X-Authenticated-User` в ASGI-scope — ПЕРЕЗАПИСЫВАЯ, а не дополняя. 🔴 Перезапись, а не «поставить, если отсутствует» — это требование безопасности, а не стилистика. В бэкенде «Птицы» ОДИННАДЦАТЬ мест читают этот заголовок НАПРЯМУЮ, мимо guard'а, и решают по нему, кто автор/кому принадлежат данные: • app/core/audit_middleware.py:169 — атрибуция строки аудита; • app/api/v1/me.py:30 — чей scope отдать (роль + фильтры); • app/api/v1/insights.py:74/124/138 — created_by + _require_user (POST/PUT/DELETE); • app/api/v1/own_projects.py:69/115/131 — created_by + _require_user (POST/PUT/DELETE); • app/api/v1/parcels.py:1481 — GET /{cad_num}/forecast; • app/api/v1/parcels.py:1902 — POST /{cad_num}/analyze (created_by рана, parcels.py:4212, и 3-й аргумент forecast_site_finder_report.delay, :4226); • сам rbac_guard ниже — легаси-ветка. Ни одно из них не знает про сессию: для них истина — сырой заголовок. Оставь мы skip-if-present — клиент с ВАЛИДНОЙ кукой прошёл бы guard как он сам, а во все эти места уехал бы его собственный подставленный `X-Authenticated-User: <кто угодно>` (Caddy шлёт этот заголовок на каждый прод-запрос, так что «просто добавить» его было бы некуда). Ровно этот баг ловили у «Меры» — #2552 post-review, CRITICAL. Резолвнутая сессия ОБЯЗАНА быть единственным источником личности. Механизм: `request.scope` — один и тот же dict, прокинутый ПО ССЫЛКЕ через весь ASGI-стек (Starlette не копирует scope между слоями). Мутация здесь видна: • всей downstream-цепочке — мы мутируем ДО вызова call_next(); • audit-middleware — он ВНУТРЕННИЙ относительно rbac_guard (см. комментарий у app.middleware("http")(audit_log_middleware) выше: LIFO-регистрация даёт порядок rbac_guard → audit → router), т.е. его Request строится уже после мутации. У «Меры» этот слой, наоборот, внешний, и там мутация до него доезжает только потому, что читается ПОСЛЕ call_next. Имена заголовков в ASGI — по спеке всегда lowercase bytes, и uvicorn/TestClient её соблюдают. Фильтр всё равно нормализует ключ сам (`k.lower()`), а не полагается на спеку: попади в scope запись `b"X-Authenticated-User"` (другой ASGI-сервер, самодельный слой, тест-харнесс) — точное сравнение оставило бы её в списке рядом с нашей. Читатели при этом видели бы правильное значение (`Headers.get` лоуэркейсит искомый ключ, но не хранимый, так что смешанный регистр не матчится никогда), то есть дыры нет — но состояние «две записи с одним именем» в scope не должно существовать: оно ложное по построению и ломает любой обход списка глазами. `errors="replace"` в encode: латиницей логины реестра не ограничены, а падать UnicodeEncodeError в auth-пути нельзя. NB: `request.headers` САМОГО этого Request уже закеширован (мы читали cookies) и останется старым. Это не мешает: в session-ветке guard больше не читает заголовок, а нижележащие слои строят свой Request поверх обновлённого scope. """ request.scope["headers"] = [ (k, v) for k, v in request.scope.get("headers", []) if k.lower() != b"x-authenticated-user" ] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))] # Троттлинг алерта «реестр не отвечает». Резолв сессии идёт на КАЖДОМ non-public # запросе с кукой, а `logger.exception` уровня ERROR уезжает событием в GlitchTip # (LoggingIntegration event_level=ERROR, см. sentry_sdk.init выше) — то есть лежащий # реестр давал бы поток событий, пропорциональный трафику: квота/rate-limit выгорают # за минуты, и настоящие ошибки этого же периода теряются. Полный traceback печатаем # не чаще раза в минуту (с числом подавленных за окно), остальное — WARNING без # exc_info, чтобы факт продолжающегося сбоя всё равно был виден в логах. # Лок нужен по-настоящему: функция исполняется в threadpool'е, то есть параллельно. _REGISTRY_FAILURE_ALERT_INTERVAL_S = 60.0 _REGISTRY_FAILURE_LOCK = threading.Lock() _registry_failure_last_alert = 0.0 _registry_failure_suppressed = 0 def _reset_registry_failure_throttle() -> None: """Сбрасывает окно троттлинга. Для тестов: состояние модульное и живёт между ними.""" global _registry_failure_last_alert, _registry_failure_suppressed with _REGISTRY_FAILURE_LOCK: _registry_failure_last_alert = 0.0 _registry_failure_suppressed = 0 def _log_registry_failure(path: str) -> None: """Логирует сбой резолва: раз в окно — ERROR с traceback, иначе WARNING. Зовётся ТОЛЬКО из `except`-блока: `logger.exception` берёт traceback из текущего sys.exc_info(). """ global _registry_failure_last_alert, _registry_failure_suppressed now = time.monotonic() with _REGISTRY_FAILURE_LOCK: alert = (now - _registry_failure_last_alert) >= _REGISTRY_FAILURE_ALERT_INTERVAL_S if alert: suppressed = _registry_failure_suppressed _registry_failure_last_alert = now _registry_failure_suppressed = 0 else: suppressed = 0 _registry_failure_suppressed += 1 if alert: logger.exception( "RBAC: резолв сессии не удался на %s — эти запросы обслуживаются по " "легаси-пути (Caddy basic_auth + X-Authenticated-User); подавлено таких же " "за предыдущее окно: %d", path, suppressed, ) else: logger.warning( "RBAC: резолв сессии не удался на %s (traceback подавлен троттлингом, " "следующий — не раньше чем через %.0f с)", path, _REGISTRY_FAILURE_ALERT_INTERVAL_S, ) def _resolve_session_username(token: str | None, path: str) -> str | None: """Логин из сессионной куки, либо None, если личность по куке не установлена. 🔴 СИНХРОННАЯ и вызывается ТОЛЬКО через `run_in_threadpool` (см. rbac_guard): внутри — psycopg-I/O (checkout из пула + SELECT, раз в 5 минут ещё UPDATE и commit). Позови её напрямую из корутины guard'а — и весь API «Птицы» сериализуется за один round-trip к БД `auth` на каждый запрос, а недоступный реестр (или исчерпанный пул) заморозит event loop целиком, включая /health. Ровно этот инцидент уже был на соседнем middleware — #1202, см. комментарий в app/core/audit_middleware.py:175-181, там он и починен через `run_in_threadpool`. Токен принимается ГОТОВЫМ (а не `Request`) именно поэтому: разбор Cookie-заголовка дёшев и делается на loop'е, в поток уезжает только строка. None означает ровно одно — «личность по куке не установлена», и вызывающий обязан трактовать это одинаково во всех трёх случаях: куки нет, кука невалидна (нет строки / истекла / access_state не active), резолв УПАЛ. Поведение при сбое БД `auth` (осознанный выбор, а не «поймали и забыли»): логируем ERROR с traceback — он уезжает событием в GlitchTip (LoggingIntegration event_level=ERROR, см. sentry_sdk.init выше), т.е. это алерт, а не строчка, которую никто не увидит (частота ограничена окном, `_log_registry_failure`), — и в режиме `dual` деградируем к легаси-ветке, то есть к сегодняшнему поведению: Caddy basic_auth + X-Authenticated-User. В режиме `db_only` деградации нет: guard отвечает 401. Почему НЕ 503/500. Пока идёт переходный период, popup basic_auth стоит перед бэкендом, и легаси-ветка защищена ровно тем же, чем защищён весь продукт сегодня, — множество людей, способных вообще достучаться, не расширяется. Отдавать же 503 значит класть «Птицу» целиком из-за проблемы, которую basic_auth уже покрывает (отозванный пароль роли auth_app, пересозданная БД `auth`, исчерпанный пул её engine — всё это не мешает продуктовой БД gendesign работать). Почему это не «тихий фолбэк на легаси». Опасный сценарий — не «реестр упал», а «реестр не сконфигурирован»: тогда права раздавались бы из roles.yaml в обход реестра (включая аккаунты с access_state disabled/trial_expired) бессрочно и молча. Этот сценарий сюда НЕ доходит: конфигурацию проверяет lifespan, причём НЕ на глазок — `require_auth_db_configured` открывает соединение и делает `SELECT 1`, так что мимо него не проходят ни пустой/битый DSN, ни неверный пароль, ни опечатка в хосте, ни отозванная роль (app/core/auth_db.py). Здесь остаётся только второй рубеж — реестр, отвалившийся ПОСЛЕ успешного старта. ⚠️ Отдельно про отзыв доступа: пароли Caddy basic_auth (caddy/users.caddy.snippet) и `auth.users.access_state` — РАЗНЫЕ списки. Человек, которому в реестре поставили disabled/trial_expired, свой basic_auth-пароль не теряет, поэтому на время недоступности реестра деградация возвращает его в строй. То есть отзыв тут не «строже сегодняшнего», а откатывается к состоянию ДО отзыва — при включении режима caddy-snippet надо прополоть под список активных аккаунтов реестра. ⚠️ Когда последний PR эпика снимет popup, эта деградация обязана уйти вместе с ним: без basic_auth впереди фолбэк на легаси-заголовок превращается в дыру — заголовок станет полностью клиентским. Механика перехода уже готова: `AUTH_MODE=db_only` (см. app/core/config.py), в нём легаси-ветка недостижима и этот возврат None означает 401, а не «попробуем заголовок». """ if not token: # Нет куки — ни одного обращения к БД `auth`. Это весь сегодняшний трафик. return None try: session_user = resolve_session_token(token) except Exception: _log_registry_failure(path) return None if session_user is None: return None return session_user.username @app.middleware("http") async def rbac_guard( request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: # Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User. # СТРОГО gated на settings.testing (default False) — прод RBAC не затронут. # RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware). # # ⚠️ Он ОТКЛЮЧАЕТ ВЕСЬ guard целиком, включая session-ветку ниже, — и это сказано # здесь явно, чтобы не выглядело недосмотром. Следствие для тестов: сессионный путь # НЕЛЬЗЯ проверять запросом к настоящему `app` через TestClient (conftest ставит # settings.testing=True глобально, guard просто не отработает, тест «прошёл бы» ни о # чём). Он и проверяется иначе: tests/test_auth_session_guard.py зовёт ЭТУ САМУЮ # функцию напрямую, сняв settings.testing через monkeypatch, — то есть прод-код, а # не копию. Копия guard'а в tests/test_rbac.py про куку намеренно НЕ знает и # покрывает только режим legacy (там об этом написано). Сдвигать session-ветку ВЫШЕ # bypass'а нельзя: получился бы полуработающий guard (личность резолвится, а 401/403 # не применяются) — состояние, которого нет ни в одном настоящем режиме. if settings.testing: return await call_next(request) path = request.url.path if path in _PUBLIC_PATHS: return await call_next(request) # Внешний `if` по режиму — не дубль проверки внутри resolve_session_token(), а # гарантия инварианта «legacy = поведение не меняется ни на байт»: в нём не # трогается даже request.cookies (разбор Cookie-заголовка). token = ( request.cookies.get(settings.session_cookie_name) if settings.auth_session_enabled else None ) # 🔴 Резолв — В THREADPOOL. Внутри синхронный psycopg-I/O, а мы в корутине: прямой # вызов блокировал бы event loop на каждом запросе с кукой (инцидент #1202, тот же # класс, что чинили в app/core/audit_middleware.py:175-183). `if token` перед # хопом — не микрооптимизация: без куки резолвить нечего, и весь сегодняшний # трафик не платит ни за поток, ни за коннект. session_username = ( await run_in_threadpool(_resolve_session_username, token, path) if token else None ) if session_username is not None: username = session_username # 🔴 До call_next и до всего остального: личность из сессии обязана вытеснить # клиентский заголовок для одиннадцати прямых читателей (см. функцию). _propagate_authenticated_user(request, username) elif settings.auth_mode == "db_only": # Легаси-ветка ОТКЛЮЧЕНА: нет валидной сессии → отказ, даже если # X-Authenticated-User присутствует. Это конечное состояние эпика — режим # включается тем же PR, который снимает `basic_auth` + `header_up` из Caddy и # тем самым делает заголовок полностью клиентским. Отдельный текст ответа: # «no authenticated user» ниже говорит про basic_auth, которого в этот момент # уже нет. return JSONResponse( status_code=401, content={"detail": "valid session required"}, ) else: # ---- легаси trusted-header путь — БИТ-В-БИТ как до эпика ---- header_user = request.headers.get("X-Authenticated-User") if not header_user: # Любой non-public path без auth-header → 401. Локальный curl мимо Caddy # или прокси-фронт без header_up. 401 точнее чем 403 — "сначала # аутентифицируйся". return JSONResponse( status_code=401, content={"detail": "no authenticated user (Caddy basic_auth required)"}, ) username = header_user try: role = get_role(username) except KeyError: # Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ. # Decided 2026-05-25: «человек без ролей вообще ничего не видит». if session_username is not None: # Тот же отказ, но отдельным сообщением: «есть в реестре, нет в roles.yaml» — # это рассинхрон двух списков (типовой при заведении нового аккаунта), а не # подделка заголовка, и чинится он в другом месте. logger.warning( "RBAC: сессия резолвлена в %r, но юзера нет в auth/roles.yaml — отказ на %s", username, path, ) else: logger.warning("RBAC: unknown user %r tried %s", username, path) return JSONResponse( status_code=403, content={"detail": "user not in roles config"}, ) if _ADMIN_API_RE.match(path) and role != "admin": logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path) return JSONResponse( status_code=403, content={"detail": "admin only"}, ) return await call_next(request) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"]) app.include_router(chat.router, prefix="/api/v1/chat", tags=["chat"]) app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"]) app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"]) app.include_router(market.router, prefix="/api/v1/market", tags=["market"]) app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"]) app.include_router(admin_jobs.router, prefix="/api/v1/admin/jobs", tags=["admin"]) app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"]) app.include_router( admin_weight_profiles.router, prefix="/api/v1/admin/site-finder/weight-profiles", tags=["admin", "site-finder"], ) app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"]) app.include_router(custom_pois.router, prefix="/api/v1/custom-pois", tags=["custom-pois"]) app.include_router(locations.router, prefix="/api/v1/locations", tags=["locations"]) app.include_router(insights.router, prefix="/api/v1/insights", tags=["insights"]) app.include_router(own_projects.router, prefix="/api/v1/own-projects", tags=["own-projects"]) app.include_router( admin_cadastre.router, prefix="/api/v1/admin/cadastre", tags=["admin", "cadastre"], ) app.include_router( admin_etl.router, prefix="/api/v1/admin/etl", tags=["admin", "etl"], ) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(landing.router, prefix="/api/v1", tags=["landing"]) app.include_router(pilot.router, prefix="/api/v1/pilot", tags=["pilot"]) app.include_router(users.router, prefix="/api/v1", tags=["users"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) app.include_router(ping.router, prefix="/api/v1", tags=["ping"]) @app.get("/health") async def health() -> dict[str, str]: return { "status": "ok", "environment": settings.environment, "version": app.version, }