English · Español
Teoría 01 — Arquitectura del portal del learner¶
🇪🇸 La arquitectura es deliberadamente pequeña: un proceso FastAPI, plantillas Jinja2, HTMX para interactividad progresiva, SQLite como almacén, Argon2id para contraseñas, cookies firmadas para sesiones, y los middlewares de la fase 37 ya aplicados. Sin React, sin Vue, sin colas, sin microservicios. Por diseño.
Por qué C4 otra vez¶
Usamos la misma lente C4 que la Fase 39 — System Context → Container → Component, más un diagrama de secuencia para el flujo que soporta la carga. Dos diagramas estáticos, uno dinámico. El portal es incluso más pequeño que el tutor; si acaso, los diagramas son más fáciles de dibujar.
Nivel 1 — System Context¶
┌──────────────────────────────┐
│ │
┌────────────────┐ HTTPS │ lynx-cortex-portal │
│ Estudiante │──────────────►│ (proceso FastAPI único) │
│ (navegador) │◄──────────────│ │
└────────────────┘ HTML/JSON │ │
└──┬──────────┬──────────┬─────┘
│ │ │
┌────────────────┐ HTTPS │ │ │
│ Profesor / │─────────────────►│ │ │
│ Admin │◄─────────────────│ │ │
└────────────────┘ HTML/JSON │ │
│ │
┌──────────▼───┐ ┌───▼──────────┐
│ SQLite │ │ Fase 39 │
│ (archivo) │ │ grammar │
│ │ │ tutor │
└──────────────┘ │ (opcional) │
└──────────────┘
┌──────────────────────┐
│ docs/phase-NN-*/ │
│ (sistema de │
│ ficheros; lectura │
│ en tiempo de │
│ petición) │
└──────────────────────┘
El portal tiene dos tipos de usuario humano (estudiante, profesor/admin), una dependencia de almacenamiento (un archivo SQLite en el mismo host), una dependencia de solo lectura del sistema de ficheros (los markdown del currículo) y un sistema externo opcional (el tutor de gramática de la Fase 39, usado solo para enlaces de "drill"). Sin servidor de DB, sin Redis, sin Kafka, sin S3.
Renderiza esto en docs/phase-41-learner-portal/diagrams/c4-context.mmd.
Nivel 2 — Container¶
En el nivel Container, el portal es un único proceso. Listamos los contenedores visibles en la demo:
miniportal— la propia app FastAPI. Python 3.11, un único event loop de asyncio. Aloja:- Manejadores de rutas (estudiantes, journal, notas, quizzes, exámenes, admin) — ver §rutas.
- Renderizador de plantillas Jinja2 + middleware de assets estáticos.
- Verificador de contraseña Argon2id (cableado a través de
src/minivault/). - Middleware de sesión (cookies firmadas vía
itsdangerous). - Scheduler de revisión SM-2 (cableado a través de
src/minireview/). - Middlewares de la Fase 37: rate limiter, tope de tamaño de body, filtro de prompt-injection, validador de token CSRF.
- Convenciones de la Fase 33: schemas OpenAPI,
/health, logs estructurados. - Archivo SQLite (
./portal.db) — un solo archivo. Modo WAL. Respaldo porjust portal-backup(uncpcon timestamp). - Sistema de ficheros
docs/phase-NN-*/— la fuente del currículo. Leída en tiempo de petición; no copiada a la DB. - Reverse proxy (citado, no incluido) — Caddy o nginx delante de
miniportalpara terminación TLS, servicio de assets estáticos y HTTP/2. El BLUEPRINT entrega unCaddyfilede ejemplo; la demo escucha enlocalhosty asume que el proxy lo provee algo externo. - Tutor de gramática de la Fase 39 (opcional) — proceso separado del capstone de la Fase 39. Alcanzable vía
http://localhost:8001o similar. Usado solo para enlaces de "drill" desde la UI de examen del portal.
Renderiza en docs/phase-41-learner-portal/diagrams/c4-container.mmd.
Por qué cada elección de tecnología¶
- FastAPI — el framework contra el que ya construye la Fase 33. Reutilizarlo cuesta cero dependencias nuevas; cambiarlo forzaría dos historias HTTP en un mismo repo.
- Jinja2 — el templating recomendado por defecto en FastAPI. HTML renderizado en el servidor. Sin templating en el cliente, sin hidratación.
- HTMX — proporciona swaps de DOM parciales, formularios inline, fragmentos cargados de forma perezosa mediante atributos HTML
hx-*simples. Las páginas funcionan sin JS en absoluto; HTMX solo afina la UX. Esta es la forma explícita de sortear el anti-objetivo §10 deLYNX_CORTEX.mdde los frameworks JS pesados: obtenemos la mayoría de la ergonomía SPA con cero toolchain JS. El compromiso se documenta a voces en el lab 00 (sin estado en el cliente, sin modo offline). - SQLite — basado en archivo, modo WAL. Los escritores concurrentes se serializan en un único lock de escritura; para ≤ 50 estudiantes con baja tasa de escritura, es más que suficiente. El BLUEPRINT registra el disparador de migración a Postgres (p. ej., > 50 escritores concurrentes, o despliegue cross-host).
- Argon2id (vía
argon2-cffi) — KDF moderno memory-hard; ganador de la Password Hashing Competition de 2015; RFC 9106. Parámetros ajustados en el lab 02 a ~250–500 ms por verificación en la CPU objetivo. itsdangerous— cookies firmadas en Python puro, sin tabla de sesiones en DB para el camino común. La rotación del pepper (un cambio periódico de clave) invalida todas las sesiones, por diseño.- Tokens CSRF — patrón double-submit cookie, también vía
itsdangerous. Las peticiones HTMX renderizadas en formulario incluyen el token como cabecera. - Reverse proxy: Caddy o nginx (citado, no requerido) — terminación TLS, servicio de assets estáticos. El portal no es dueño del TLS; habla HTTP en localhost.
Nivel 3 — Componentes dentro de miniportal¶
src/miniportal/
├── app.py # Factoría de la app FastAPI, stack de middleware
├── auth.py # Login, logout, primer login sin contraseña,
│ # emisión/verificación de sesión, helpers de CSRF
├── models.py # SQLModel/Pydantic — dejando la elección abierta
│ # hasta el BLUEPRINT; tablas: student, session,
│ # phase_progress, note, quiz_attempt,
│ # exam_attempt, review_card, event_log
├── events.py # Conjunto cerrado de tipos de evento; helper de emisión
├── routes/
│ ├── students.py # /me, /me/preferences, /me/dashboard
│ ├── journal.py # /phase/{nn} → renderiza markdown; registra evento
│ ├── notes.py # /phase/{nn}/notes (CRUD; filas por estudiante)
│ ├── quizzes.py # /phase/{nn}/quiz (puntuación por pregunta)
│ ├── exams.py # /phase/{nn}/exam (registra intento; encola
│ │ # las preguntas falladas en el mazo de revisión)
│ └── admin.py # /admin/students (crear/invitar/listar/auditar)
├── templates/ # Plantillas Jinja2 + partials HTMX
└── static/ # CSS (Pico.css o similar minimalista), sin JS
Dos módulos hermanos (paquete separado, BLUEPRINT separado, tests separados) de los que depende el portal:
src/minivault/ # Vault de contraseñas basado en Argon2id
├── BLUEPRINT.md
├── hash.py # hash(password, pepper) → str
├── verify.py # verify(hash, password, pepper) → bool
├── pepper.py # load_pepper() lee de env o de archivo;
│ # rotate_pepper() invalida sesiones
└── kdf.py # KDF para cifrado en reposo de secretos
# pequeños (p. ej. nonces de tokens de
# invitación), derivado del pepper
src/minireview/ # Scheduler de repetición espaciada (spaced repetition)
├── BLUEPRINT.md
├── sm2.py # Regla de actualización SM-2 (lab 04)
├── fsrs.py # Variante FSRS tras feature flag (lab 04)
├── scheduler.py # next_review_at(card, grade, now)
└── queue.py # SQL: SELECT cards WHERE next_review_at<=now
# ORDER BY next_review_at ASC LIMIT N
Cada uno recibe su propio BLUEPRINT.md y README.md según CLAUDE.md §1.
Inventario de rutas (canónico)¶
| Método | Ruta | Auth | Descripción |
|---|---|---|---|
| GET | / |
ninguna | Landing: enlace a login o "pide invitación a tu profesor". |
| GET | /login |
ninguna | Formulario de login. |
| POST | /login |
ninguna | Envía credenciales; emite sesión; protegido por CSRF. |
| POST | /logout |
sesión | Invalida la sesión; protegido por CSRF. |
| GET | /invite/{token} |
ninguna | Renderiza el formulario de establecer contraseña si el token es válido. |
| POST | /invite/{token} |
ninguna | Establece la contraseña; revoca el token; emite sesión. |
| GET | /me |
estudiante | Dashboard: progreso, tamaño del mazo de revisión, última actividad. |
| GET | /me/preferences |
estudiante | Alternar visibilidad del resumen en español, etc. |
| POST | /me/preferences |
estudiante | Guarda preferencias; protegido por CSRF. |
| GET | /phase/{nn} |
estudiante | Renderiza el README de la fase + índice de teoría. Registra event_log. |
| GET | /phase/{nn}/theory/{slug} |
estudiante | Renderiza el archivo de teoría. Registra event_log. |
| GET | /phase/{nn}/lab/{slug} |
estudiante | Renderiza el enunciado del lab. Registra event_log. |
| GET | /phase/{nn}/notes |
estudiante | Lista + formulario de creación para las notas propias del estudiante. |
| POST | /phase/{nn}/notes |
estudiante | Crear / actualizar nota; protegido por CSRF. |
| GET | /phase/{nn}/quiz |
estudiante | UI de quiz (materia: tutor de gramática según §A13). |
| POST | /phase/{nn}/quiz |
estudiante | Envía quiz; puntuación por pregunta; protegido por CSRF. |
| GET | /phase/{nn}/exam |
estudiante | UI de examen (con vigilancia: temporizador, sin reenvío). |
| POST | /phase/{nn}/exam |
estudiante | Envía examen; encola preguntas falladas; protegido por CSRF. |
| GET | /me/reviews |
estudiante | El mazo de revisión para "hoy". |
| POST | /me/reviews/{card_id} |
estudiante | Envía una respuesta de revisión; calendario SM-2 actualizado; protegido por CSRF. |
| GET | /admin/students |
admin | Lista de todos los estudiantes + last-active + tamaño del mazo. |
| POST | /admin/students |
admin | Crea un nuevo estudiante; responde con enlace de invitación; protegido por CSRF. |
| GET | /admin/students/{id} |
admin | Detalle por estudiante: progreso, notas, historial de exámenes, event log. La lectura se audita. |
| GET | /health |
ninguna | Liveness. |
Cada ruta que cambia estado está protegida por CSRF. Cada ruta autenticada comprueba la cookie de sesión y resuelve el id de estudiante. Cada ruta autenticada emite una fila de event_log por petición.
La secuencia canónica de "enviar respuesta de examen"¶
El flujo de usuario que soporta la carga. Renderizado en docs/phase-41-learner-portal/diagrams/sequence-submit-exam.mmd.
Forma de la petición¶
POST /phase/13/exam HTTP/1.1
Host: portal.example
Cookie: session=<itsdangerous-signed>
Content-Type: application/x-www-form-urlencoded
Content-Length: 184
csrf_token=<itsdangerous-signed>
&question_id=q-013-04
&answer=Yesterday%20I%20went%20to%20the%20store
&question_id=q-013-05
&answer=She%20has%20eaten
&question_id=q-013-06
&answer=I%20will%20write%20it
Cadena de middleware (en orden)¶
- Middleware de rate-limit (Fase 37) — token bucket por sesión + por IP. Límite: 30 req/min/sesión. Al exceder: 429 con
Retry-After. Implementación reutilizada desrc/miniserve/middlewares/ratelimit.py(Fase 33 + Fase 37). - Tope de tamaño de body (Fase 37) — 64 KiB de tope duro para cualquier POST del portal (los payloads de examen son diminutos). Al exceder: 413 Payload Too Large.
- Middleware de filtro de inyección (Fase 37) — aplicado solo a campos de texto libre (notas, respuestas); rechaza peticiones que contengan marcadores conocidos de prompt-injection (
ignore previous instructions,system:, etc.). En coincidencia: 400 con un error estructurado. El filtro es conservador; la tasa de falsos positivos se mide en el lab 05. - Validador CSRF — comprueba que el campo de formulario
csrf_tokencoincida con el valor firmado en la cookie de sesión. En desajuste: 403. - Validador de schema — el modelo Pydantic comprueba que cada
question_idsea uno que el estudiante esté intentando actualmente, que cadaanswersea una cadena no vacía de menos de 1 KiB. En inválido: 400 con detalle por campo. - Middleware de auth — verifica la firma de la cookie de sesión, resuelve el id del estudiante, lo adjunta al scope de la petición. En inválido: 401.
- Manejador —
routes/exams.py:submit_exam(...)puntúa cada respuesta (igualdad de string módulo whitespace + case; la rúbrica del tutor de gramática es determinista, §A13), registra filas enexam_attempt, encola las preguntas falladas enreview_cardvíasrc/minireview/scheduler.next_review_at(...), emite una fila enevent_log, devuelve un fragmento HTML parcial (swap HTMX) con el resultado por pregunta.
Forma de la respuesta (partial HTMX)¶
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<div id="exam-result" class="result">
<h2>Exam — Phase 13 — submitted</h2>
<table class="grades">
<tr><td>q-013-04</td><td class="ok">correct</td></tr>
<tr><td>q-013-05</td><td class="wrong">expected: "She <em>has</em> eaten"</td></tr>
<tr><td>q-013-06</td><td class="ok">correct</td></tr>
</table>
<p>2 of 3 correct. 1 question added to your review deck;
next review: tomorrow.</p>
<a hx-get="/me/reviews" hx-target="#main">Open review deck</a>
</div>
Forma del error (CSRF fallido, por ejemplo)¶
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
<div id="exam-result" class="result error">
<h2>Submission rejected</h2>
<p>Your form was tampered with or expired. Please reload and
try again.</p>
</div>
Los errores también son partials HTML — HTMX los inserta en su sitio para que el usuario vea un mensaje inline en lugar de un error genérico del navegador.
El esquema de la base de datos (esbozo)¶
El DDL completo vive en el BLUEPRINT; el modelo conceptual:
student(id, username, email_optional, role, password_hash, created_at, last_active_at)session(token_signature, student_id, issued_at, last_seen_at)— opcional; para revocacióninvite_token(nonce, student_id, issued_at, expires_at, used_at_nullable)phase_progress(student_id, phase_nn, first_seen_at, last_seen_at, quiz_passed_bool, exam_passed_bool)note(id, student_id, phase_nn, body, marker_tags, created_at, updated_at)quiz_attempt(id, student_id, phase_nn, question_id, answer, correct_bool, submitted_at)exam_attempt(id, student_id, phase_nn, question_id, answer, correct_bool, submitted_at)review_card(id, student_id, question_id, ease_factor, interval_days, repetitions, next_review_at)event_log(id, student_id, actor_id, event_type, target, sha_optional, occurred_at)
Índices en (student_id, phase_nn), (student_id, next_review_at) y (occurred_at).
Cómo se compone, no se reimplementa, la Fase 37¶
El portal no reinventa ninguna primitiva de seguridad que haya construido la Fase 37. En concreto:
- Rate limiter — importado de
src/miniserve/middlewares/ratelimit.py; elapp.pydel portal lo incluye con los parámetros de token-bucket del portal. - Filtro de inyección — importado de
src/miniserve/middlewares/injection.py; aplicado a un conjunto configurado de campos, no a todos los bodies. - Tope de tamaño de body — importado de
src/miniserve/middlewares/bodysize.py; configurado más pequeño (64 KiB) que el default de la Fase 39 (256 KiB). - Helper de CSRF — importado de
src/miniserve/middlewares/csrf.pysi existe al cierre de la Fase 37; en otro caso el portal contribuye el helper aguas arriba asrc/miniserve/(para que el tutor de gramática se beneficie también) — según la cláusula de flexibilidad de A12 esto es una revisión de la Fase 37, no un fork de la Fase 41.
Lo que el portal añade encima:
- Vault de contraseñas (
src/minivault/) — wrapper de Argon2id, con pepper, con rotación. - Middleware de sesión — sesiones solo de cookie firmada; sin tabla de sesiones en DB en el camino común.
- Flujo del token de invitación — un solo uso, firmado, con tiempo limitado.
El modelo de amenazas del portal amplía security/THREATS.md con tres filas nuevas (redactadas en el lab 05): "hijack de sesión por cookie robada", "replay del token de invitación", "abuso del rol-dios del admin".
Lo que esta arquitectura excluye deliberadamente¶
- Framework de JavaScript. Anti-objetivo. HTMX + Jinja2.
- GraphQL. REST + form-encoded. Schemas en Pydantic.
- Workers / colas en background. La actualización del mazo de revisión se computa síncronamente en el
POST— es un solo upsert SQL. - Índice de búsqueda. SQLite FTS5 está disponible pero no habilitado en v1; la búsqueda de texto completo en notas es candidata para Phase 41+.
- Aislamiento multi-tenant de cohortes. Un archivo SQLite por cohorte = ejecuta un segundo proceso
miniportalsi necesitas una segunda cohorte. - WebSockets / SSE. Sin streaming en vivo. HTMX
hx-trigger="every 30s"basta para el auto-refresco del mazo de revisión.
Lo que esta arquitectura incluye deliberadamente (y por qué)¶
- Registro de proceso en el camino de la petición. Sí, cada petición autenticada escribe una fila en
event_log. Sí, eso es una escritura en el camino caliente. El volumen está acotado (≤ 100 estudiantes × ≤ 1 req/min promedio ≪ la capacidad de escritura de SQLite), y el valor (teoría 00 §visibilidad-del-proceso) lo justifica. - Partials HTMX por encima de endpoints JSON. Una futura petición "expón una API para un cliente móvil" es una enmienda Phase 41+; la v1 devuelve HTML.
- Matemática de mazo de revisión síncrona. La actualización SM-2 es barata (tiempo constante por tarjeta); hacerla inline evita una cola, un worker y una clase de bugs de tiempo.
- CSRF en cada ruta que cambia estado. Incluidas las de solo admin. El coste es una cookie firmada; el valor es la corrección.
Errores comunes al dibujar los diagramas¶
- Sobre-dibujar. No hace falta mostrar cada plantilla Jinja2. Los containers son procesos; los componentes son sub-paquetes.
- Dibujar el currículo como un sistema externo. El currículo es el sistema de ficheros — mismo host, sin red. Dibújalo como dependencia de almacenamiento, no como sistema aparte.
- Tratar el reverse proxy como dentro del sistema. No lo es. Los diagramas muestran al portal escuchando en localhost; el proxy está fuera.
- Olvidar el borde de auditoría. El flujo "admin lee notas" tiene un borde en
event_log— muéstralo. - Dibujar la Fase 39 como requerida. Es opcional. Haz el borde discontinuo.
Qué NO cubre esta teoría¶
- Por qué FastAPI — Fase 33.
- Por qué el middleware de filtro de inyección tiene la forma que tiene — Fase 37.
- Por qué Argon2id sobre bcrypt / scrypt — cubierto en
src/minivault/BLUEPRINT.md(lab 02). - La matemática de SM-2 —
PHASE_41_PLAN.md§2 + lab 04. - Envío de email, SSO, pagos — explícitamente fuera de alcance.
Siguiente: docs/phase-41-learner-portal/lab/00-bring-up-and-first-student.md (pre-escrito por separado) — bootstrap de la app FastAPI, crear el primer admin vía just portal-admin, canjear la invitación, crear el primer estudiante.