Files
brain-of-reese/app/main.py
T
ducoterra a86caec821
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 173_docs_push_creds_split
All gates green. Final report:

**Phase 173 — final verification pass (all tasks already complete in `complete/`)**

- Verified the full implementation is in the working tree: `docs_user`/`docs_token` settings + catalogue rows (task 01), `docs_repo` PUT 422 guard + `docs_token` write-only pin (task 02), `run_git(env)` + `push_document(user, token)` credential env (task 03), `settings_migrate.py` wired into `create_app()` (task 04), E2E suite (task 05).
- **Completion criteria:**
  1. GET `/api/admin/settings` lists `docs_user` (plain) + `docs_token` (secret, `value=null`), token never in any body — ✅ `test_docs_token_is_write_only_masked_end_to_end` + E2E sentinel-absent-from-DOM
  2. PUT `docs_repo` with `://user:pass@` → 422 naming the fields; clean URL passes — ✅ integration + E2E UI-side
  3. Token set → `GIT_CURL_USERNAME`/`GIT_CURL_PASSWORD`/`GIT_TERMINAL_PROMPT=0` on every git step; empty → `env=None` — ✅ mocked-git unit tests
  4. Legacy env split once at startup, idempotent, one INFO line, `.env` untouched — ✅ `test_settings_migrate.py` (4 shapes) + `test_docs_push_migrate.py`
  5. Full suite green, coverage >90%, lint clean — ✅
  6. E2E isolated: `test_docs_push_creds.py` 4/4; regression set (panel + layout + response_to_docs) 21/21 — ✅
  7. Commit + phase move — harness's job (per instructions, not mine).
  8. README/AGENTS consistent — ✅ (below)
- Tests/lint: `uv run pytest` exit 0 (4112 passed) · `uv run pytest --cov=app --cov-report=term-missing` TOTAL 99% · `uv run ruff check .` clean · `uv run pyright` 0 errors
- Docs: README.md (incl. Docs-push config section, guard/migration paragraph, `.env.example` block) and AGENTS.md verified consistent in full; fixed two stale docstrings in `app/config.py` + `app/core/settings_catalog.py` (`is_set` → the API's actual `has_value` field).
- No live-infrastructure changes (no live step in this phase). No next pending phase — `todo/` held only 173.
2026-10-10 16:47:39 -04:00

260 lines
12 KiB
Python

"""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
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.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
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=<token>`` 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 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/<token> is the JSON snapshot; /shared/<token> (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/<token>
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
# with ?v=<token> 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()