English · Español
Lab 00 — Bootstrap the empty portal¶
🇪🇸 Antes de cualquier funcionalidad, un esqueleto que arranca, sirve
/healthzy devuelveOK. 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.mdwritten 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— exportsmake_app.src/miniportal/app.py—make_app(settings)factory.src/miniportal/settings.py—pydantic-settingsPortalSettings(env-driven, no secrets in code).src/miniportal/routes/health.py—/healthzroute returning{"status": "ok", "version": ...}.src/miniportal/routes/home.py—/route renderingbase.html.jinjawith 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.Justfilerecipeportal-devrunninguv 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 fromsrc/miniportal/static/. - Templates configured with
Jinja2Templates(directory=...), exposed viaapp.state.templatesfor route handlers. - No middleware reaches into Phase 33's
miniservepackage 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¶
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 --frozensucceeds; lockfile committed. -
just portal-devbrings the server up in < 3 s on Borja's machine; logged inexperiments/41-bootstrap/timing.txt. -
curl :8001/healthzreturns{"status": "ok", ...}. -
curl :8001/returns HTML containing the base template. -
curl :8001/static/portal.cssreturns 200. -
pytest tests/portal/test_healthz.pyis green (all three tests pass). -
mypy --strict src/miniportal/is green. -
bandit -r src/miniportal/reports zero high findings. -
src/miniportal/README.mdexists and links to the BLUEPRINT.
Common pitfalls¶
- Module-level
app = FastAPI(). Looks shorter. Breaks every test fixture two labs from now when each test wants its own settings. Factory only. - 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
/healthzdepends on DB readiness. Construct lazily inside the factory. - Hardcoding the port. Phase 41 may run alongside
miniservein the samedocker compose. Read the port from settings. - 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. - 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.