Skip to content

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:

  1. miniportal — la propia app FastAPI. Python 3.11, un único event loop de asyncio. Aloja:
  2. Manejadores de rutas (estudiantes, journal, notas, quizzes, exámenes, admin) — ver §rutas.
  3. Renderizador de plantillas Jinja2 + middleware de assets estáticos.
  4. Verificador de contraseña Argon2id (cableado a través de src/minivault/).
  5. Middleware de sesión (cookies firmadas vía itsdangerous).
  6. Scheduler de revisión SM-2 (cableado a través de src/minireview/).
  7. Middlewares de la Fase 37: rate limiter, tope de tamaño de body, filtro de prompt-injection, validador de token CSRF.
  8. Convenciones de la Fase 33: schemas OpenAPI, /health, logs estructurados.
  9. Archivo SQLite (./portal.db) — un solo archivo. Modo WAL. Respaldo por just portal-backup (un cp con timestamp).
  10. Sistema de ficheros docs/phase-NN-*/ — la fuente del currículo. Leída en tiempo de petición; no copiada a la DB.
  11. Reverse proxy (citado, no incluido) — Caddy o nginx delante de miniportal para terminación TLS, servicio de assets estáticos y HTTP/2. El BLUEPRINT entrega un Caddyfile de ejemplo; la demo escucha en localhost y asume que el proxy lo provee algo externo.
  12. Tutor de gramática de la Fase 39 (opcional) — proceso separado del capstone de la Fase 39. Alcanzable vía http://localhost:8001 o 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 de LYNX_CORTEX.md de 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)

  1. 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 de src/miniserve/middlewares/ratelimit.py (Fase 33 + Fase 37).
  2. 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.
  3. 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.
  4. Validador CSRF — comprueba que el campo de formulario csrf_token coincida con el valor firmado en la cookie de sesión. En desajuste: 403.
  5. Validador de schema — el modelo Pydantic comprueba que cada question_id sea uno que el estudiante esté intentando actualmente, que cada answer sea una cadena no vacía de menos de 1 KiB. En inválido: 400 con detalle por campo.
  6. 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.
  7. Manejadorroutes/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 en exam_attempt, encola las preguntas falladas en review_card vía src/minireview/scheduler.next_review_at(...), emite una fila en event_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ón
  • invite_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; el app.py del 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.py si existe al cierre de la Fase 37; en otro caso el portal contribuye el helper aguas arriba a src/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 miniportal si 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

  1. Sobre-dibujar. No hace falta mostrar cada plantilla Jinja2. Los containers son procesos; los componentes son sub-paquetes.
  2. 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.
  3. Tratar el reverse proxy como dentro del sistema. No lo es. Los diagramas muestran al portal escuchando en localhost; el proxy está fuera.
  4. Olvidar el borde de auditoría. El flujo "admin lee notas" tiene un borde en event_log — muéstralo.
  5. 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-2PHASE_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.