"""Brain of Reese — application entrypoint. Boots logging + conditional debugpy, then creates the FastAPI app: API routes first (so they win over the catch-all), and the static frontend mounted last. No CDN: everything the browser needs is served by this process from local files (see PLAN §UI/UX — No External Dependencies). Phase 16 (A10 revised): before anything is served, admin auth must be configured (fail-loud), and the app wraps every route in Starlette's SessionMiddleware — a signed ``bor_session`` cookie is the only session state in the system. """ from __future__ import annotations import logging from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from starlette.responses import FileResponse, Response from app.api.admin_settings import router as admin_settings_router from app.api.auth import router as auth_router from app.api.chat import reset_runtime_caches from app.api.chat import router as chat_router from app.api.chat_images import router as chat_images_router from app.api.chats import ( public_router as chats_public_router, ) from app.api.chats import ( router as chats_router, ) from app.api.chats import ( shared_page_router as chats_shared_page_router, ) from app.api.config import router as config_router from app.api.doc_drafts import router as doc_drafts_router from app.api.docs import router as docs_router from app.api.git_sources import router as git_sources_router from app.api.health import router as health_router from app.api.steering import router as steering_router from app.api.suggestions import router as suggestions_router from app.api.sync import router as sync_router from app.api.tokens import router as tokens_router from app.api.ui_settings import router as ui_settings_router from app.config import get_settings, invalidate_settings from app.core import theming from app.core.auth import ensure_admin_configured from app.core.caching import configure_caching from app.core.compression import SelectiveGZipMiddleware from app.core.debugging import configure_debugging from app.core.logging import configure_logging from app.core.response_budget import ResponseBudgetMiddleware from app.core.security_headers import SecurityHeadersMiddleware from app.core.settings_migrate import migrate_legacy_docs_creds from app.core.settings_overlay import enabled as settings_db_enabled from app.core.settings_overlay import load_overrides, register_invalidator from app.db import SessionLocal configure_logging() configure_debugging() settings = get_settings() logger = logging.getLogger("app") def _shell_routes(app: FastAPI, static_dir: Path, paths: tuple[str, ...]) -> None: """Phase 76: the navbar views are views of ONE shell document. Every registered path serves ``frontend/index.html`` (the shell) instead of its own page file: the client-side router (``frontend/assets/router.js``) reads ``location.pathname`` at boot and shows the matching view, so a direct load of e.g. ``/tuning.html`` deep-links to the Tuning view. Registered AFTER the API routers and BEFORE the static catch-all mount (routes-first), so the phase-33 caching middleware — which wraps the whole app and already lists every one of these paths in ``HTML_PAGES`` — applies the no-cache + ``?v=`` contract to the response untouched. The list is driven by the caller: tasks 02/03 fold the remaining views in by extending the tuple (task 03 lands History — all four non-chat navbar views are in; the old per-view ``.html`` files are deleted in the same change as their shell route lands — one source of truth). """ shell_file = static_dir / "index.html" async def _shell_view() -> FileResponse: return FileResponse(shell_file, media_type="text/html") for path in paths: # GET (document loads, the browser path) + HEAD — the pre-fold # static file answered both, so the shell route keeps that # method parity (the body is the same FileResponse; HEAD ships # headers only). app.api_route( path, methods=["GET", "HEAD"], include_in_schema=False )(_shell_view) def _favicon() -> Response: """Phase 177 (task 02, D2 — A2/A3 owner-confirmed): the themed favicon over the wire. Renders :func:`app.core.theming.favicon_svg` from the EFFECTIVE palette on EVERY request — one short-lived ``SessionLocal()`` session, the ``caching.py`` middleware's house pattern: NO process cache, so a runtime theme change (the admin Theme tab's Save) is visible on the next request without a restart (a single-row SELECT is negligible at homelab traffic). ANY exception (a DB blip, a pre-migration boot) falls back to the BUILT-IN palette with 200 — never 500 (the theme-tag's never-break contract, ``caching.py``'s DB-blip fallback): the tab icon degrades to the built-in art, the app never errors on it. Deliberately OUTSIDE ``/api/*``: no per-response byte-budget line (AGENTS rule 10 is ``/api/*``-only) and NO auth dependency — the favicon is chrome, public like the login page. ``CachingMiddleware`` passes the response through untouched (``/favicon.svg`` is neither in ``HTML_PAGES`` nor under ``/assets/``) → the route's own ``Cache-Control: no-cache`` is the freshness contract (the palette changes at runtime; the pages are no-cache too, so the browser revalidates the favicon together with them — freshness is revalidation, not a ``?v=`` bust). The route is not under ``/assets/``, so its ```` ref can never be touched by the ``?v=`` rewrite. ``SecurityHeadersMiddleware`` still adds the CSP — no change needed (the phase-123 ``img-src 'self' data:`` carve-out covers task 04's ``data:`` preview; CSP3 governs ``link[rel=icon]`` by ``img-src``). """ try: db = SessionLocal() try: effective = theming.effective_settings(db) finally: db.close() svg = theming.favicon_svg(effective) except Exception: logger.exception( "favicon: theme read failed — serving the built-in favicon" ) svg = theming.favicon_svg(theming.BUILTIN_COLORS) return Response( svg, media_type="image/svg+xml", headers={"Cache-Control": "no-cache"}, ) def create_app() -> FastAPI: # Fail loud BEFORE serving anything (phase 16): missing # BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the # variable(s) — the app never starts in a half-authenticated state. ensure_admin_configured(settings) # Phase 168 (task 02, D2/D3): prime the DB-over-env resolver now # that auth is verified. The module-level ``settings`` above stays # the pre-boot value the middleware wiring below uses (LOCKED A4: # those boot-wired settings apply on the NEXT boot); every consumer # that calls get_settings() from here on resolves DB-over-env — no # restart. invalidate_settings() runs the runtime reset hooks # (task 03) and clears the cache; a boot-time DB miss is fail-soft # (one WARNING, env values in effect) and the next resolution # retries, so the prime can never cache an env-only view forever. invalidate_settings() if settings_db_enabled(): get_settings() logger.info("settings overrides loaded count=%d", len(load_overrides())) else: get_settings() logger.info("settings overrides loaded source=env-only disabled") # Phase 173 (task 04, C3): the one-time startup migration — a # legacy credentials-bearing ``docs_repo`` (env or DB) is split # into ``app_settings`` rows (clean repo + ``docs_user`` + # ``docs_token``); the DB rows shadow the env, ``.env`` is never # rewritten, a second run is a no-op. Called ONCE, after the DB is # available; a migration hiccup logs a WARNING and never blocks # startup — the app boots with the pre-phase behavior (the # migration is a convenience, not a dependency). try: migrate_legacy_docs_creds() except Exception as exc: # noqa: BLE001 — boot must never block logger.warning("settings migration failed (%s)", type(exc).__name__) # Phase 168 (task 03): the settings panel's Save must retune the # running process, and app/core must never import app/api (PLAN §3's # direction) — so create_app(), the composition root, wires the # reset hook. It runs on every invalidate_settings() after the cache # clear: the next chat turn rebuilds the shared LLMClient and the # concurrency semaphore from the CURRENT settings (no restart), while # in-flight turns keep the client they started with (A12 semantics). register_invalidator(reset_runtime_caches) logger.info("settings invalidator registered name=chat_runtime_caches") app = FastAPI(title=settings.app_name, version=settings.app_version) # Signed single-admin session cookie (Starlette middleware, itsdangerous # signer — no server-side store, no new services). Homelab HTTP: same_site # is "lax" and https_only stays off (documented in the README). app.add_middleware( SessionMiddleware, secret_key=settings.session_secret, session_cookie=settings.session_cookie, max_age=settings.session_max_age, same_site="lax", https_only=False, ) # API routes first so they take precedence over the catch-all static mount. app.include_router(health_router, prefix="/api") app.include_router(config_router, prefix="/api") app.include_router(auth_router, prefix="/api") app.include_router(suggestions_router, prefix="/api") app.include_router(docs_router, prefix="/api") app.include_router(git_sources_router, prefix="/api") app.include_router(chat_router, prefix="/api") # Phase 123: the question-image upload/serve pair (POST is # user-gated like the chat turn; GET is public like saved-chat # content — the uuid filename is the credential). app.include_router(chat_images_router, prefix="/api") app.include_router(steering_router, prefix="/api") app.include_router(sync_router, prefix="/api") app.include_router(chats_router, prefix="/api") app.include_router(doc_drafts_router, prefix="/api") # Phase 79: the admin token surface (create/list/revoke) — admin-only # (router-wide require_admin; a token USER stays 403 here, task 03). app.include_router(tokens_router, prefix="/api") # Phase 91 (task 01): the admin UI-settings surface (GET/PUT the # single ui_settings row — the Theme tab's persistence) — admin-only # (router-wide require_admin; anonymous AND token users stay 403). app.include_router(ui_settings_router, prefix="/api") # Phase 168 (task 04): the admin settings panel surface (read/write # the app_settings overrides — DB-over-env, live on Save) — admin-only # (router-wide require_admin; a token user stays 403). app.include_router(admin_settings_router, prefix="/api") # Phase 51: the anonymous shared-chat read — NO admin dependency. # /api/shared/ is the JSON snapshot; /shared/ (the # page route below, registered without a prefix) is the page. app.include_router(chats_public_router, prefix="/api") app.include_router(chats_shared_page_router) # no prefix — /shared/ # Phase 177 (task 02, D2 — A2/A3 owner-confirmed): the themed # favicon route — registered AFTER the router includes and BEFORE # the static catch-all mount (routes-first, the phase-51/76 # pattern): a file named favicon.svg in the static dir could never # shadow it. HEAD joins GET for static-file parity (the # phase-76 ``_shell_routes`` parity idiom — the pre-phase static # file answered both methods, the dynamic route keeps that). # See the handler docstring for the never-break DB-blip fallback # and the /api/*-out rationale. app.api_route( "/favicon.svg", methods=["GET", "HEAD"], include_in_schema=False )(_favicon) # Cache busting (phase 33): the five HTML pages revalidate (no-cache) # with ?v= asset refs; /assets/* becomes immutable for a year. # Added after the session middleware, so it wraps the whole app # (including the static catch-all below); /api/* — the SSE chat # stream in particular — passes through untouched. configure_caching(app) # Phase 167 (task 05, TODO L5): the byte budget — ONE # ``api method=… path=… status=… bytes=… enc=… duration_ms=…`` line per # /api/* response (INFO at/over BOR_LOG_LARGE_RESPONSE_BYTES, DEBUG # below), so "which endpoint is heavy, right now" is a # ``grep ' :: api '`` away. Position: added BEFORE the gzip call below, # i.e. INSIDE it (SecurityHeaders → gzip → response-budget → caching → # session → routes), so ``bytes=`` is the bytes the ROUTE produced # (uncompressed) while ``enc=`` reports what went on the wire — the two # together answer "how fat is it, and how fat did the client see it". # Pure ASGI (never BaseHTTPMiddleware): the SSE stream is counted as it # passes and logged once, when the stream ends. app.add_middleware(ResponseBudgetMiddleware) # Phase 167 (TODO L5): selective gzip — every response except the # POST /api/chat SSE stream (skipped by EXACT path, A15) compresses # when it is >= compress_min_bytes (the 2 MB /api/docs/tree defect). # 0 = the feature is OFF: no middleware is added at all (the # kill-switch convention). Position: added AFTER configure_caching but # BEFORE the SecurityHeaders line below, so SecurityHeaders stays # OUTERMOST (A20, the last add_middleware is outermost) and gzip sits # outside the caching layer — an already-rewritten HTML page compresses # too, and /api/* still passes the caching layer byte-identically # before compression. if settings.compress_min_bytes > 0: app.add_middleware( SelectiveGZipMiddleware, minimum_size=settings.compress_min_bytes ) # Phase 82: security headers (CSP + no-framing + nosniff) — outermost on # purpose (last add_middleware): every response carries them, including # the static catch-all's 404s below (audit SEC-04). app.add_middleware(SecurityHeadersMiddleware) static_dir = Path(settings.static_dir).resolve() if static_dir.is_dir(): # Phase 76: the folded navbar views serve the shell — the # router picks the view from the pathname. Task 01 landed # Tuning; task 02 folds RAG + Sources; task 03 lands History # (list-driven — all four non-chat navbar views are in); # phase 79 task 06 folds the sixth view (Tokens). _shell_routes( app, static_dir, ( "/tuning.html", "/sources.html", "/git-sources.html", "/history.html", "/tokens.html", # phase 79 task 06: the Tokens view "/theme.html", # phase 91 task 04: the Theme view (shell route) "/settings.html", # phase 168 task 05: the Settings view (shell route) ), ) app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") else: logger.warning("static dir %s not found — serving API only", static_dir) return app app = create_app()