Skip to content

English · Español

Lab 00 — Bootstrap the empty portal

🇪🇸 Antes de cualquier funcionalidad, un esqueleto que arranca, sirve /healthz y devuelve OK. Si esto no es verde, nada más importa. Phase 41 es una app web sobre cimientos ya construidos (Phase 33 = HTTP, Phase 37 = sanitizer). Aquí solo enchufamos el armazón.

Goal

Stand up an empty miniportal FastAPI app that starts in < 3 s on Borja's i5-8250U, serves a JSON /healthz, renders one Jinja base template at /, and ships static assets correctly. Bring it up with just portal-dev. The integration test tests/portal/test_healthz.py must be green before any feature lab opens.

Why this lab exists

Every later Phase 41 lab assumes a running app. If the scaffold is wrong on day one, every later test is debugging a broken foundation instead of the feature under study. This lab is small on purpose — it gates the rest of the phase by isolating "does the app even start" from any real business logic.

It also forces the app factory decision early. A module-level singleton (app = FastAPI()) makes testing painful in lab 04+ when fixtures need their own configured app. The factory pattern (make_app(settings)) is the only correct shape; lab 00 nails it down before the temptation to shortcut sets in.

Prerequisites

  • src/miniportal/BLUEPRINT.md written and approved (see Phase 41 plan §3).
  • Phase 33 already provides src/miniserve/ patterns (request id middleware, structured logging) — reused here, not re-derived.
  • Phase 37 sanitizer module exists at src/sanitizer/ (imported lazily in later labs; not required for lab 00).

Deliverables

  • src/miniportal/__init__.py — exports make_app.
  • src/miniportal/app.pymake_app(settings) factory.
  • src/miniportal/settings.pypydantic-settings PortalSettings (env-driven, no secrets in code).
  • src/miniportal/routes/health.py/healthz route returning {"status": "ok", "version": ...}.
  • src/miniportal/routes/home.py/ route rendering base.html.jinja with a one-line "hello" body.
  • src/miniportal/templates/base.html.jinja — base template with {% block content %}{% endblock %}.
  • src/miniportal/static/portal.css — empty placeholder; proves static mount works.
  • Justfile recipe portal-dev running uv run uvicorn miniportal.app:make_app --factory --reload.
  • tests/portal/__init__.py, tests/portal/conftest.py, tests/portal/test_healthz.py.
  • src/miniportal/README.md — one-paragraph module overview pointing at the BLUEPRINT.

Step 1 — uv add the dependencies

uv add fastapi uvicorn jinja2 argon2-cffi itsdangerous python-multipart sqlmodel alembic
uv add --group dev httpx pytest-asyncio

argon2-cffi and itsdangerous are pulled in here even though lab 00 does not use them — pinning their versions in uv.lock now avoids a version-skew bisect three labs from now. Update pyproject.toml [project] table accordingly.

Commit message: chore(portal): pin web stack for Phase 41 (Conventional Commits per CLAUDE.md §A4).

Step 2 — The app factory

# src/miniportal/app.py
from __future__ import annotations

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from miniportal.settings import PortalSettings
from miniportal.routes import health, home


def make_app(settings: PortalSettings | None = None) -> FastAPI:
    """Build the miniportal ASGI app.

    Pure function: same settings → same app. No globals, no side effects on import.
    """
    raise NotImplementedError("Lab 00 step 2 — wire settings, mounts, routers.")

Constraints on the body Borja writes:

  • No module-level app = .... The factory is the only construction site.
  • Static files mounted at /static, served from src/miniportal/static/.
  • Templates configured with Jinja2Templates(directory=...), exposed via app.state.templates for route handlers.
  • No middleware reaches into Phase 33's miniserve package directly — copy the request-id middleware signature, re-import the helper if reusable, but do not couple the two services.

Step 3 — Settings

# src/miniportal/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class PortalSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="LYNX_PORTAL_", env_file=".env.portal")

    database_url: str = "sqlite:///./var/portal.db"
    session_secret: str = "dev-only-do-not-use-in-prod"  # noqa: S105 — replaced at deploy
    static_dir: str = "src/miniportal/static"
    templates_dir: str = "src/miniportal/templates"

    def __post_init__(self) -> None:
        raise NotImplementedError("Lab 00 step 3 — validate that session_secret is set when not in dev mode.")

The __post_init__ validator must reject the default session_secret whenever LYNX_PORTAL_ENV != "dev". This is a Phase 37 lesson re-applied: secrets never default to a known value in production.

Step 4 — /healthz

# src/miniportal/routes/health.py
from fastapi import APIRouter

router = APIRouter()


@router.get("/healthz")
async def healthz() -> dict[str, str]:
    """Return liveness. Independent of DB so a broken DB does not hide app-up signal."""
    raise NotImplementedError("Lab 00 step 4 — return status, version (from `importlib.metadata`), timestamp.")

The response shape is fixed by lab 00 — later labs assume {"status": "ok", "version": "<x.y.z>", "ts": "<iso8601>"}. If you change it here, every downstream monitor breaks.

Step 5 — Base template + home route

src/miniportal/templates/base.html.jinja:

<!doctype html>
<html lang="{{ locale|default('en') }}">
  <head>
    <meta charset="utf-8" />
    <title>{% block title %}lynx-cortex portal{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', path='/portal.css') }}" />
  </head>
  <body>
    <main>{% block content %}{% endblock %}</main>
  </body>
</html>

Home route renders the template with a single visible line so the smoke test has something to assert.

Step 6 — just portal-dev

portal-dev:
    uv run uvicorn miniportal.app:make_app --factory --reload --host 127.0.0.1 --port 8001

Port 8001 is chosen so it never collides with Phase 33's miniserve on :8080. Document the port choice in src/miniportal/README.md.

Step 7 — The smoke test

# tests/portal/test_healthz.py
from fastapi.testclient import TestClient

from miniportal.app import make_app
from miniportal.settings import PortalSettings


def test_healthz_returns_ok():
    raise NotImplementedError("Lab 00 step 7 — build app via factory, hit /healthz, assert 200 + shape.")


def test_static_assets_served():
    raise NotImplementedError("Lab 00 step 7 — GET /static/portal.css returns 200.")


def test_home_renders_base_template():
    raise NotImplementedError("Lab 00 step 7 — GET / returns 200 and contains <html lang=...>.")

What "done" looks like

  • uv sync --frozen succeeds; lockfile committed.
  • just portal-dev brings the server up in < 3 s on Borja's machine; logged in experiments/41-bootstrap/timing.txt.
  • curl :8001/healthz returns {"status": "ok", ...}.
  • curl :8001/ returns HTML containing the base template.
  • curl :8001/static/portal.css returns 200.
  • pytest tests/portal/test_healthz.py is green (all three tests pass).
  • mypy --strict src/miniportal/ is green.
  • bandit -r src/miniportal/ reports zero high findings.
  • src/miniportal/README.md exists and links to the BLUEPRINT.

Common pitfalls

  1. Module-level app = FastAPI(). Looks shorter. Breaks every test fixture two labs from now when each test wants its own settings. Factory only.
  2. Importing the DB engine at module top. SQLModel pulls SQLAlchemy which is heavy; if you eagerly construct the engine on import, the smoke test starts slow and /healthz depends on DB readiness. Construct lazily inside the factory.
  3. Hardcoding the port. Phase 41 may run alongside miniserve in the same docker compose. Read the port from settings.
  4. Skipping the static-files test. When Jinja templates load CSS via url_for('static', ...), a broken mount silently produces 404s the browser shrugs at. The test catches it now, not after lab 02's avatar work.
  5. Treating this lab as "trivial" and not writing the BLUEPRINT first. The BLUEPRINT is the only place where "why a factory" lives; without it, the next maintainer (or future Borja) re-derives the decision from scratch.

Next: lab/01-passwordless-onboarding.md — the no-password-by-default first-login flow.