English · Español
Theory 04 — Portal building blocks (forward to Phase 41)¶
🇪🇸 La Fase 33 enseña FastAPI no como un fin, sino como cuatro primitivas reutilizables: lifespan para abrir DB y vault,
Depends()para inyectar la sesión por petición, el orden del middleware ASGI con CSRF después de la sesión, y HTMX para no caer en una SPA. Estas son las piezas que la Fase 41 ensambla en un portal multi-estudiante.
Why this chapter exists¶
The first three theory chapters of Phase 33 stay close to one concern — putting the Phase 32 grammar tutor behind an HTTP endpoint that scales under concurrent load. That is the driving problem and most of Borja's time goes there.
This chapter exists for a different audience: the Phase 41 Learner Portal will reuse Phase 33's FastAPI patterns to deliver the §A13 grammar curriculum to multiple students. Phase 41 is allowed to start its MVP build ahead of its slot (per LYNX_CORTEX_ADDENDUM.md §A14), but it cannot copy patterns Phase 33 has not yet taught. So Phase 33 owes Phase 41 four small lessons — independent of the batching problem — and this chapter pays that debt. Read it as a vocabulary list: when Phase 41 says "lifespan-managed DB", that phrase comes from §1 below.
The cross-link discipline: every section refers to the Phase 41 architecture chapter (docs/phase-41-learner-portal/theory/01-architecture.md) and the auth/vault chapter (docs/phase-41-learner-portal/theory/03-auth-and-vault.md). When in doubt, the portal docs are the canonical place to see the four patterns composed; this chapter teaches them one at a time.
§1 — Lifespan-managed resources (sqlite + vault)¶
FastAPI inherits Starlette's lifespan protocol: an async context manager that runs once on app startup and once on shutdown. It is the right place to open the SQLite connection pool, decrypt the password vault, and stash the handles on app.state. Doing this inside a route handler is wrong — each request would pay re-open cost, and concurrent requests would race on file locks. Doing it at module import time is also wrong — tests cannot inject a fresh in-memory DB without monkey-patching, and the import side-effect breaks uvicorn --reload.
The lifespan body runs in the event loop. Anything that blocks (a synchronous argon2.PasswordHasher().hash("calibrate") call to JIT the Argon2 backend, a sqlite3.connect with check_same_thread=False) is fine because startup is one-shot; what matters is the contract that once yield returns, every dependency the app needs is alive. On shutdown, the same context tears them down in reverse order — connections closed, vault key zeroed, file locks released. This is the discipline src/miniportal/app.py will follow when Borja implements it.
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.engine = create_engine(config.db_url)
init_schema(app.state.engine)
app.state.vault = Vault.open(config.vault_path, key=derive_key())
yield
app.state.vault.close()
app.state.engine.dispose()
app = FastAPI(lifespan=lifespan)
Forward reference: the portal's lifespan opens both sqlite and the minivault handle; see docs/phase-41-learner-portal/theory/01-architecture.md §Level 2.
§2 — Depends() chains and request-scoped objects¶
FastAPI's dependency injection has two scopes that beginners conflate: app-scoped (an object built once and reused — e.g. the engine, the vault) and request-scoped (an object built per request — e.g. a DB session, the current student). The rule is mechanical: an app.state.X attribute is app-scoped; a value returned from a Depends(get_X) factory is request-scoped (unless that factory uses lru_cache, which is a foot-gun for stateful objects). Mixing them — taking a long-lived DB engine and trying to commit a transaction across requests — is the most common FastAPI bug.
Dependency chains compose: require_csrf depends on require_student, which depends on get_db_session, which depends on app.state.engine. FastAPI resolves them once per request, in topological order, and passes the resolved values to the handler. The chain doubles as authorization: routes that bind current_user = Depends(require_admin) are unreachable to non-admins, because require_admin raises 403 before the handler body runs. Phase 41 leans on this heavily — every protected route is a Depends(require_student) away from public.
def get_db_session(request: Request) -> Iterator[Session]:
with Session(request.app.state.engine) as s:
yield s
def require_student(
request: Request,
db: Session = Depends(get_db_session),
) -> Student:
token = request.cookies.get("session")
if not token:
raise HTTPException(401)
return load_student_from_signed_token(token, db)
@app.get("/me")
def me(student: Student = Depends(require_student)):
return {"username": student.username}
Forward reference: the portal's full dependency graph is sketched in docs/phase-41-learner-portal/theory/01-architecture.md §"Components inside miniportal".
§3 — Starlette middleware order and the CSRF-after-session rule¶
ASGI middleware is a stack: the first middleware added is the outermost wrapper of the request, and the innermost of the response on the way back. FastAPI inherits Starlette's add_middleware which prepends to the chain, so the last add_middleware call ends up outermost. This reversal trips everyone the first time they read it, so the convention in miniserve and miniportal is to call add_middleware in outermost-first order and treat the call sequence as the literal pipeline diagram.
The load-bearing rule for Phase 41: CSRF validation must run after session decoding, not before. Why? CSRF tokens are signed with the per-session HMAC key, and the validator needs to know which session it is verifying against. If CSRF runs first, it has no session id and must fall back to a cookie value — which is exactly what CSRF is supposed to not trust. Sequence: rate-limit → body-size → injection-filter → session decode → CSRF validator → schema validator → handler. Reversing CSRF and session decode turns the defence into a no-op; this is a real Phase 37 finding, not a stylistic preference.
app.add_middleware(RateLimitMiddleware, per_min=30) # outermost
app.add_middleware(BodySizeLimitMiddleware, max_bytes=64_000)
app.add_middleware(InjectionFilterMiddleware, fields=["body_markdown"])
app.add_middleware(SessionMiddleware, secret_key=cfg.secret_key)
app.add_middleware(CSRFMiddleware, signer=cfg.csrf_signer) # innermost
Forward reference: the portal's full middleware order, with rationale for each layer's position, lives in docs/phase-41-learner-portal/theory/01-architecture.md §"The canonical submit exam answer sequence".
§4 — HTMX vs SPA tradeoff¶
The portal returns HTML, not JSON. This is a deliberate trade against LYNX_CORTEX.md §10's anti-goal of heavyweight client frameworks. The two viable architectures for a multi-page authenticated app in 2026 are: (a) server-rendered HTML with HTMX for partial updates, or (b) a JSON API consumed by a React/Vue/Svelte SPA. The SPA wins on rich client state (offline mode, optimistic updates, live collaborative cursors); the HTMX path wins on every other axis — bundle size, build complexity, accessibility-by-default, server-side rendering for free, no toolchain.
The grammar-tutor curriculum has no rich client state. A student reads a page, types a note, submits a quiz, sees a result. Every interaction is a request-response. HTMX gives partial-DOM swaps via hx-get / hx-post attributes, which means "click 'next question' and only the question div re-renders" without writing a line of JavaScript. The accessibility story is also strictly better: HTMX pages work with JavaScript disabled (the hx-* attributes degrade to plain <form> posts), which an SPA cannot match. The trade-off is that HTMX cannot do anything while offline — there is no client-side store. For the portal, that's fine: a student without a network connection cannot record progress anyway.
<form hx-post="/phase/13/quiz" hx-target="#quiz-result">
<input name="csrf_token" type="hidden" value="{{ csrf_token }}">
<input name="answer" type="text">
<button>Submit</button>
</form>
<div id="quiz-result"></div>
Forward reference: the portal's HTMX patterns, including the partial-error-response convention, are in docs/phase-41-learner-portal/theory/01-architecture.md §"The canonical submit exam answer sequence" and the auth flow walkthrough in docs/phase-41-learner-portal/theory/03-auth-and-vault.md §"No-password-by-default".
Forward reference to Phase 41¶
| Phase 33 pattern (this chapter) | Phase 41 use site |
|---|---|
| Lifespan opens sqlite + vault (§1) | src/miniportal/app.py::create_app |
Depends(require_student) (§2) |
Every route under routes/journal.py, routes/notes.py, routes/quizzes.py, routes/exams.py |
| Middleware order CSRF-after-session (§3) | src/miniportal/app.py add_middleware block |
| HTMX partials instead of SPA (§4) | src/miniportal/templates/*.html, static/htmx.min.js only |
Read these as the four Phase-33-shaped pieces inside the Phase 41 architecture chapter. When Phase 41 says "the portal is just a FastAPI app", what it means is "the portal is these four patterns plus the Phase 41 auth + audit chrome on top".
Next: lab/00-minimal-fastapi.md returns to Phase 33's driving concern — wrapping the grammar tutor with POST /correct.