refactor: complete bootstrap of ARNES agent harness framework

- Add complete agent harness structure with 8 roles (leader, triager, architect, implementer, reviewer, security, qa, documenter)
- Implement strict workflow with 9 stages and mandatory gates
- Add comprehensive verification script and runtime status tracking
- Create artifact-based evidence system with contracts and schemas
- Add agent policy matrix with permissions and anti-cheat rules
- Include test suite (44 tests passing) and CI-ready structure
- Add documentation: README, HOWTO, CHECKPOINTS, templates
- Configure model routing policies and token-aware task assignment
- Add BDD/SDD specification guides and feature templates
- Include starter pack for quick project onboarding

All verification checks pass. Framework ready for production use.
This commit is contained in:
rikrdo
2026-05-17 23:25:35 +02:00
parent 622e5df382
commit 3ff9b70e4c
104 changed files with 8534 additions and 187 deletions

67
spec/sdd/README.md Normal file
View File

@@ -0,0 +1,67 @@
# SDD — System Design Document
## Índice
- [Architecture Overview](#architecture-overview)
- [Components](#components)
- [Decisions](#decisions)
---
## Architecture Overview
```mermaid
graph TD
subgraph Frontend
F[Client App]
end
subgraph Backend
A[API Gateway]
S[Services]
D[(Database)]
end
F --> A
A --> S
S --> D
```
### Contexto
_Describir el propósito del sistema y su alcance._
### Restricciones
- _Lista de restricciones técnicas o de negocio_
---
## Components
### Component Template
Ver `spec/sdd/components/.template.md` para el formato.
---
## Decisions
Ver `spec/sdd/decisions/` para ADRs.
---
## Diagrama de secuencia (ejemplo)
```mermaid
sequenceDiagram
actor U as User
participant API
participant SVC as Service
participant DB as Database
U->>API: Request
API->>SVC: Process
SVC->>DB: Query
DB-->>SVC: Result
SVC-->>API: Response
API-->>U: Data
```

View File

@@ -0,0 +1,74 @@
# Component: <Nombre>
## Responsabilidad
Descripción clara de qué hace este componente.
## Tipo
- [ ] Microservicio
- [ ] Library/Biblioteca
- [ ] Shared Component
- [ ] External Integration
## Interfaces
### API (si aplica)
```
Method: GET/POST/PUT/DELETE /endpoint
Input: { ... }
Output: { ... }
Errors: 400, 401, 404, 500
```
### Eventos (si aplica)
- `topic.name.v1` — descripción del evento
## Dependencias
| Servicio/Biblioteca | Tipo | Notas |
|---------------------|------|-------|
| | | |
## Límites
### Alcance
- ✅ Qué hace
- ❌ Qué NO hace
### Constraints
- Timeout máximo: Xms
- Rate limit: Y req/min
## Criterios de éxito
| Criterio | Métrica | Target |
|----------|---------|--------|
| Disponibilidad | uptime | 99.9% |
| Latencia | p99 | < 200ms |
## Diagrama
```mermaid
graph LR
A[Input] --> B[Component]
B --> C[Output]
```
## Estados
| Estado | Trigger | Acción |
|--------|---------|--------|
| Initial | created | ... |
| Active | running | ... |
| Error | failure | ... |
## Seguridad
- Authentication: ...
- Authorization: ...
- Rate limiting: ...
## Observabilidad
- Metrics: ...
- Logs: ...
- Traces: ...

View File

@@ -0,0 +1,65 @@
# AuthService Component
## Purpose
Handle user authentication (login/logout) with JWT tokens.
## Public API
### Methods
#### login(email: str, password: str) -> AuthResult
Authenticate user with email and password.
**Parameters:**
- `email`: User email address
- `password`: User password
**Returns:**
- `AuthResult` with access_token, refresh_token, expires_in
**Raises:**
- `InvalidCredentialsError`: Email or password incorrect
- `AccountLockedError`: Account temporarily locked
- `ValidationError`: Invalid input format
#### logout(user_id: str, token_id: str) -> bool
Invalidate a specific session/token.
**Parameters:**
- `user_id`: User ID
- `token_id`: JWT jti (token identifier)
**Returns:** True if successful
#### logout_all(user_id: str) -> int
Invalidate all sessions for a user.
**Parameters:**
- `user_id`: User ID
**Returns:** Number of sessions invalidated
#### refresh(refresh_token: str) -> AuthResult
Get new access token from refresh token.
**Parameters:**
- `refresh_token`: Valid refresh token
**Returns:** New AuthResult with access_token
**Raises:**
- `InvalidTokenError`: Token expired or invalid
---
## Dependencies
- `TokenService`: JWT generation/validation
- `SessionStore`: Track active sessions
- `UserRepository`: Fetch user data
- `PasswordService`: Verify password (from F-003)
## Configuration
```python
LOGIN_RATE_LIMIT = 10 # attempts per window
RATE_LIMIT_WINDOW = 900 # 15 minutes
ACCOUNT_LOCKOUT_DURATION = 1800 # 30 minutes

View File

@@ -0,0 +1,114 @@
# Component: PasswordService
## Responsabilidad
Gestionar el cambio de contraseña de usuarios autenticados. Validar contraseña actual, verificar requisitos de seguridad de la nueva contraseña, y invalidar sesiones existentes.
## Tipo
- [x] Microservicio
- [ ] Library/Biblioteca
- [ ] Shared Component
- [ ] External Integration
## Interfaces
### API REST
```
POST /api/v1/users/{user_id}/change-password
Authorization: Bearer <token>
Input: {
"current_password": string,
"new_password": string,
"confirm_password": string
}
Output: { "success": true, "message": "Contraseña actualizada" }
Errors:
- 400: Validation errors (password too weak, mismatch)
- 401: Current password incorrect
- 403: Not owner
- 404: User not found
```
## Dependencias
| Servicio/Biblioteca | Tipo | Notas |
|---------------------|------|-------|
| PostgreSQL | Database | Almacenamiento de usuarios |
| Redis | Cache | Invalidation de sesiones |
| AuthService | Internal | Verificación de token |
## Validaciones de contraseña
| Regla | Requisito | Mensaje de error |
|-------|-----------|------------------|
| Longitud mínima | 8 caracteres | "La contraseña debe tener al menos 8 caracteres" |
| Longitud máxima | 128 caracteres | "La contraseña debe tener máximo 128 caracteres" |
| Mayúsculas | Al menos 1 | "La contraseña debe contener al menos una mayúscula" |
| Minúsculas | Al menos 1 | "La contraseña debe contener al menos una minúscula" |
| Números | Al menos 1 | "La contraseña debe contener al menos un número" |
| Caracteres especiales | Al menos 1 | "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" |
| No usar password anterior | Diferente | "La nueva contraseña no puede ser igual a la anterior" |
## Límites
### Alcance
- ✅ Cambio de contraseña con validación
- ✅ Requisitos de seguridad
- ✅ Invalidación de sesiones
- ❌ NO maneja recuperación de contraseña (ver ForgotPasswordService)
- ❌ NO maneja reset forzado por admin (ver AdminService)
### Constraints
- Rate limit: 5 intentos por hora por usuario
- Timeout máximo: 1 segundo
- Máximo 3 passwords válidas en historial (evitar reutilización inmediata)
## Criterios de éxito
| Criterio | Métrica | Target |
|----------|---------|--------|
| Disponibilidad | uptime | 99.9% |
| Latencia | p99 change_password | < 500ms |
| Rate limit | blocked attempts | 100% |
| Sesiones invalidées | después de cambio | 100% |
## Diagrama
```mermaid
graph LR
A[Client] -->|POST /change-password| B[PasswordService]
B -->|validate current| C[(PostgreSQL)]
B -->|validate new| D[PasswordValidator]
D -->|strong enough?| E{Valid}
E -->|yes| F[Hash + Save]
E -->|no| G[Return error]
F -->|invalidate| H[(Redis)]
H -->|remove sessions| I[All user tokens]
```
## Estados
| Estado | Trigger | Acción |
|--------|---------|--------|
| Initial | started | Connect to DB |
| Ready | db_connected | Accept requests |
| RateLimited | >5 attempts/hour | Return 429 |
| Error | db_failure | Return 503 |
## Seguridad
- **Password hashing**: bcrypt, cost 12 (nuevo), verificar contra hash existente
- **Timing attack prevention**: usar constant-time comparison
- **Rate limiting**: 5 req/hour por user_id
- **Sesiones**: invalidar TODAS las sesiones del usuario tras cambio
- **Logs**: NO registrar passwords, solo intentos fallidos (user_id anonymized)
## Observabilidad
- Metrics: `password_change_total`, `password_change_failed`, `password_change_latency`
- Logs: structured JSON con request_id
- Traces: OpenTelemetry span por request
## Tests BDD
- Ver `spec/bdd/features/password/change-password.feature`

View File

@@ -0,0 +1,75 @@
# SessionStore Component
## Purpose
Manage active user sessions in Redis for fast authentication and revocation.
## Public API
### Methods
#### create_session(user_id: str, token_id: str, metadata: dict) -> bool
Store a new active session.
**Parameters:**
- `user_id`: User identifier
- `token_id`: JWT jti (unique token ID)
- `metadata`: Optional data (IP, user agent, device)
**Returns:** True if created
#### get_session(token_id: str) -> Session | None
Retrieve active session info.
**Parameters:**
- `token_id`: JWT jti
**Returns:** Session object or None if expired/revoked
#### revoke_session(token_id: str) -> bool
Invalidate a specific session.
**Parameters:**
- `token_id`: JWT jti
**Returns:** True if revoked
#### revoke_all_user_sessions(user_id: str) -> int
Invalidate all sessions for a user.
**Parameters:**
- `user_id`: User identifier
**Returns:** Count of sessions revoked
#### get_user_session_count(user_id: str) -> int
Count active sessions for a user.
**Parameters:**
- `user_id`: User identifier
**Returns:** Number of active sessions
---
## Redis Keys Structure
```
session:{user_id}:{token_id} -> JSON session metadata
user_sessions:{user_id} -> SET of active token_ids
rate_limit:login:{ip} -> COUNT with TTL
```
## TTL
- Session tokens: 15 minutes (synced with access token)
- Rate limit counters: 15 minutes
## Dependencies
- Redis connection (via aioredis)
- TokenService (for token ID generation)
## Configuration
```python
SESSION_TTL = 900 # 15 minutes
MAX_SESSIONS_PER_USER = 10
RATE_LIMIT_WINDOW = 900 # 15 minutes
```

View File

@@ -0,0 +1,69 @@
# TokenService Component
## Purpose
Generate, validate, and manage JWT tokens.
## Public API
### Methods
#### create_access_token(user: User) -> str
Generate a new JWT access token.
**Parameters:**
- `user`: User object with id, email, role
**Returns:** JWT token string
**Token claims:**
```json
{
"sub": user.id,
"email": user.email,
"role": user.role,
"iat": current_timestamp,
"exp": current_timestamp + 900, # 15 min
"jti": uuid4()
}
```
#### create_refresh_token(user: User) -> str
Generate a new refresh token.
**Returns:** JWT refresh token (7 day expiration)
#### verify_token(token: str) -> TokenPayload
Validate and decode a JWT token.
**Parameters:**
- `token`: JWT token string
**Returns:** TokenPayload with claims
**Raises:**
- `ExpiredSignatureError`: Token expired
- `InvalidTokenError`: Token invalid/malformed
#### revoke_token(token_id: str, user_id: str) -> bool
Mark a token as revoked in session store.
**Parameters:**
- `token_id`: JWT jti claim
- `user_id`: User ID
**Returns:** True if revoked
---
## Configuration
```python
ACCESS_TOKEN_EXPIRE = 900 # 15 minutes
REFRESH_TOKEN_EXPIRE = 604800 # 7 days
ALGORITHM = "HS256" # or RS256 with key pair
SECRET_KEY = os.getenv("JWT_SECRET")
```
## Security
- Tokens include unique `jti` claim for revocation tracking
- Short access token duration minimizes theft window
- Refresh tokens stored in Redis for fast revocation

View File

@@ -0,0 +1,111 @@
# Component: UserProfileService
## Responsabilidad
Gestionar el perfil de usuario: consulta, actualización de datos básicos (nombre, avatar) y preferencias (idioma).
## Tipo
- [x] Microservicio
- [ ] Library/Biblioteca
- [ ] Shared Component
- [ ] External Integration
## Interfaces
### API REST
```
GET /api/v1/users/{user_id}/profile
Authorization: Bearer <token>
Output: {
"id": string,
"name": string,
"avatar_url": string,
"language": "en" | "es" | "fr" | "de",
"created_at": ISO8601,
"updated_at": ISO8601
}
Errors: 401 (unauthorized), 404 (user not found)
PUT /api/v1/users/{user_id}/profile
Authorization: Bearer <token>
Input: {
"name": string (optional),
"avatar_url": string (optional),
"language": string (optional)
}
Output: { perfil actualizado }
Errors: 400 (validation), 401, 403 (not owner), 404
```
### Eventos (si aplica)
- `profile.updated.v1` — publicado cuando perfil se actualiza
## Dependencias
| Servicio/Biblioteca | Tipo | Notas |
|---------------------|------|-------|
| PostgreSQL | Database | Datos de usuarios y perfiles |
| Redis | Cache | Cache de perfil (TTL 5min) |
| Storage Service | External | Almacenamiento de avatares |
## Límites
### Alcance
- ✅ CRUD de perfil de usuario
- ✅ Cambio de idioma
- ❌ NO maneja autenticación (AuthService)
- ❌ NO maneja permisos de otros usuarios
### Constraints
- Timeout máximo: 300ms
- Rate limit: 50 req/min por usuario
- name: 2-50 caracteres, solo letras y espacios
- avatar_url: max 500 caracteres, URL válida (http/https)
- language: uno de ['en', 'es', 'fr', 'de']
## Criterios de éxito
| Criterio | Métrica | Target |
|----------|---------|--------|
| Disponibilidad | uptime | 99.9% |
| Latencia | p99 get_profile | < 100ms |
| Latencia | p99 update_profile | < 200ms |
| Cache hit rate | | > 80% |
## Diagrama
```mermaid
graph LR
A[Client] -->|GET /profile| B[UserProfileService]
B -->|cache| C[(Redis)]
B -->|fetch| D[(PostgreSQL)]
E[Client] -->|PUT /profile| B
B -->|validate| F[Storage]
```
## Estados
| Estado | Trigger | Acción |
|--------|---------|--------|
| Initial | started | Connect to DB, Redis |
| Ready | all_connected | Accept requests |
| Degraded | redis_down | Fallback to DB-only |
| Error | db_failure | Return 503 + alert |
## Seguridad
- Authentication: JWT Bearer token required
- Authorization: Solo el dueño puede modificar su perfil
- Input validation: Pydantic, sanitización XSS
- Rate limiting: 50 req/min por user_id
## Observabilidad
- Metrics: `profile_get_total`, `profile_update_total`, `profile_latency_ms`
- Logs: structured JSON con user_id (masked)
- Traces: OpenTelemetry span por request
## Tests BDD
- Ver `spec/bdd/features/profile/user-profile.feature`

View File

@@ -0,0 +1,48 @@
# ADR-XXX: Título de la Decisión
## Estado
Aceptado | Propuesto | Deprecado
## Fecha
YYYY-MM-DD
## Contexto
_Descripción del problema o situación que motiva esta decisión._
## Decisión
_Qué se decidió y por qué._
## Justificación
_Razones que fundamentan la decisión._
## Consecuencias
### ✅ Positivas
- ...
### ❌ Negativas
- ...
### 🔄 Neutrales
- ...
## Alternativas Consideradas
### Opción A
- **Descripción**: ...
- **Pros**: ...
- **Contras**: ...
- **Razón de descarte**: ...
### Opción B
- **Descripción**: ...
- **Pros**: ...
- **Contras**: ...
- **Razón de descarte**: ...
## Notas
_Información adicional o follow-ups._
## Relacionado con
- ADR-YYY
- Feature F-XXX

View File

@@ -0,0 +1,63 @@
# ADR-001: Selección de Stack Técnico
## Estado
Aceptado
## Fecha
2026-05-06
## Contexto
Necesitamos seleccionar el stack tecnológico inicial para el proyecto. El equipo tiene experiencia en Python y JavaScript/TypeScript, y requiere:
- Rápido bootstrap
- Testing BDD nativo
- Compatibilidad con el framework ARNES
## Decisión
Usar **Python + Behave** para BDD y **FastAPI** para el backend.
## Justificación
1. **Behave** tiene sintaxis Gherkin nativa y integración simple con Python
2. **FastAPI** ofrece validación automática con Pydantic y tests con pytest
3. Ambos tienen ecosistema maduro y documentación extensa
4. Comunidad activa y soporte a largo plazo
## Consecuencias
### ✅ Positivas
- Curva de aprendizaje baja (Python)
- BDD nativo con Behave (Gherkin)
- Type hints en todo el stack
- FastAPI: auto-generated docs (Swagger/ReDoc)
- Testing integrado con pytest
### ❌ Negativas
- GIL限制了多线程性能 (puede mitigated with async)
- Menos opciones de hosting que Node.js
### 🔄 Neutrales
- Requiere Python 3.10+ mínimo
## Alternativas Consideradas
### Opción A: Node.js + Cucumber
- **Pros**: Más opciones de hosting, JSON nativo, ecosistema npm enorme
- **Contras**: TypeScript requiere más setup, testing E2E más complejo
- **Razón de descarte**: Mayor complejidad inicial, menor familiaridad del equipo con TS
### Opción B: Java + Cucumber-JVM
- **Pros**: Tipo estático, robusto, enterprise-grade
- **Contras**: Verbose, setup pesado, curva de aprendizaje alta
- **Razón de descarte**: Over-engineering para MVP
### Opción C: Go + Godog
- **Pros**: Binarios estáticos, excelente performance
- **Contras**: BDD tooling inmaduro, less ecosystem para testing
- **Razón de descarte**: BDD ecosystem no maduro
## Notas
- Re-evaluar si el proyecto escala a más de 50 servicios
- Considerar microservices framework si es necesario
## Relacionado con
- Feature F-001
- Stack: Python 3.11+, FastAPI, Behave, PostgreSQL

View File

@@ -0,0 +1,69 @@
# ADR-002: Almacenamiento de Avatares
## Estado
Aceptado
## Fecha
2026-05-06
## Contexto
Los usuarios pueden subir avatares personalizados. Necesitamos decidir dónde y cómo almacenar las imágenes de perfil para optimizar costo, rendimiento y mantenimiento.
## Decisión
Usar **Storage Service externo (S3-compatible)** con URLs firmadas para avatares.
## Justificación
1. **Simplicidad**: No requerimos procesar imágenes en nuestro servidor
2. **Costo**: S3-like storage es económico ($0.023/GB)
3. **CDN**: Los avatares se sirven desde CDN automáticamente
4. **Seguridad**: URLs firmadas con expiración evitan hotlinking
5. **Mantenimiento**: No requiere gestión de sistema de archivos
## Consecuencias
### ✅ Positivas
- No hay infraestructura de archivos que mantener
- Escalabilidad automática
- URLs firmadas = más seguridad
- Cache CDN = mejor performance
### ❌ Negativas
- Dependencia de proveedor externo
- Costo de storage + egress
- Latencia extra por redirect a CDN
### 🔄 Neutrales
- Requiere configuración de CORS
## Alternativas Consideradas
### Opción A: Almacenamiento local en servidor
- **Pros**: Sin dependencia externa, rápido para lecturas
- **Contras**: No escala horizontalmente, requiere backup, problemas de disco
- **Razón de descarte**: No escala bien con múltiples instancias
### Opción B: Base de datos como BLOB
- **Pros**: Todo en un lugar, transacciones integradas
- **Contras**: PostgreSQL no optimizado para archivos grandes, backup lento
- **Razón de descarte**: degrada performance de DB, backups muy pesados
### Opción C: Servicio dedicado de imágenes (Cloudinary/Imgix)
- **Pros**: Transformación de imágenes, CDN incluido, optimización automática
- **Contras**: Más costoso ($50+/mes), vendor lock-in
- **Razón de descarte**: Over-engineering para avatares simples
## Implementación
1. Cliente sube imagen a `/api/v1/profile/upload` (multipart)
2. Servicio valida tipo (jpg/png/webp) y tamaño (<5MB)
3. Servicio sube a S3 con nombre `avatars/{user_id}/{timestamp}.{ext}`
4. Servicio genera URL firmada (7 días validez)
5. URL se guarda en campo `avatar_url` del perfil
## Notas
- Considerar WebP en el futuro para optimización
- Implementar cleanup de avatares huérfanos (job semanal)
## Relacionado con
- Feature F-002
- Componente: UserProfileService

View File

@@ -0,0 +1,83 @@
# ADR-003: Hashing de Contraseñas
## Estado
Aceptado
## Fecha
2026-05-06
## Contexto
Necesitamos guardar contraseñas de usuarios de forma segura. La decisión debe considerar:
- Resistencia a ataques de fuerza bruta y rainbow tables
- Performance (se ejecuta en cada login y cambio de password)
- Compatibilidad con estándares de la industria
## Decisión
Usar **bcrypt** con cost factor 12 para hashing de contraseñas.
## Justificación
1. **bcrypt** es diseñado específicamente para password hashing lento
2. **Cost factor configurable**: permite aumentar resistencia en el futuro
3. **Resistente a GPU/rainbow attacks**: diseñado para ser lento intencionalmente
4. **Incorpora salt**: cada password tiene salt único, evitando rainbow tables
5. **Estándar de industria**: ampliamente usado (Django, Rails, bcrypt)
## Consecuencias
### ✅ Positivas
- Resistente a ataques de fuerza bruta
- Salt automático evitar rainbow tables
- Configurable (cost factor)
- Librerías maduras en todos los lenguajes
### ❌ Negativas
- Más lento que MD5/SHA (es el punto, pero afecta latency)
- Enorme payload si se guarda en cookies/token
### 🔄 Neutrales
- Requiere Python 3.11+ para bcrypt moderno
## Implementación
```python
import bcrypt
def hash_password(password: str) -> str:
"""Hash password with bcrypt, cost 12."""
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt(rounds=12)
).decode('utf-8')
def verify_password(password: str, hashed: str) -> bool:
"""Verify password using constant-time comparison."""
return bcrypt.checkpw(
password.encode('utf-8'),
hashed.encode('utf-8')
)
```
## Alternativas Consideradas
### Opción A: SHA-256 (con salt)
- **Pros**: Rápido, simple
- **Contras**: No es lento, vulnerable a GPU attacks, diseñado para speed no security
- **Razón de descarte**: No es resistente a hardware moderno
### Opción B: Argon2
- **Pros**: Ganador PHC 2015, configurable memory/CPU
- **Contras**: Más complejo de implementar, menos soporte de librerías
- **Razón de descarte**: bcrypt es más simple y suficiente para nuestro caso de uso
### Opción C: scrypt
- **Pros**: Diseñado para ser memory-hard
- **Contras**: Más lento de configurar, configuración compleja
- **Razón de descarte**: bcrypt es más simple y ampliamente soportado
## Notas
- Si en el futuro我们需要 mayor seguridad, migrar a Argon2
- No guardar passwords en logs bajo ninguna circunstancia
## Relacionado con
- Feature F-003
- Componente: PasswordService

View File

@@ -0,0 +1,68 @@
# ADR-004: JWT Authentication Strategy
## Status
ACCEPTED
## Context
We need a stateless authentication mechanism for the API that:
1. Allows users to login with email/password
2. Provides secure token-based sessions
3. Supports token revocation (logout)
4. Handles token refresh without re-login
## Decision
We will use **JWT (JSON Web Tokens)** with the following configuration:
### Token Structure
- **Access Token**: 15 minute expiration, contains user identity
- **Refresh Token**: 7 day expiration, used to obtain new access tokens
### Algorithm
- **HS256** for signing (symmetric, simpler setup)
- Secret key loaded from environment variable `JWT_SECRET`
### Claims
```json
{
"sub": "user_uuid",
"email": "user@example.com",
"role": "user",
"iat": 1715030400,
"exp": 1715031300,
"jti": "unique-token-id"
}
```
### Session Management
- Active sessions tracked in **Redis** (keyed by `jti`)
- Sessions invalidated on logout
- All user sessions invalidated on password change (from F-003)
## Consequences
### Positive
- Stateless = horizontal scaling friendly
- Short-lived access tokens limit damage if compromised
- Refresh tokens allow long sessions without storing passwords
- Redis-based session tracking enables instant revocation
### Negative
- Cannot revoke individual refresh tokens (need blocklist)
- Token size larger than session IDs
- Clock sync required between services
## Alternatives Considered
| Alternative | Why Rejected |
|-------------|--------------|
| Session cookies | Not API-friendly, CSRF issues |
| OAuth2/OIDC | Overkill for simple auth |
| PASETO | Less battle-tested |
| opaque tokens | Requires DB lookup on every request |
## Implementation Notes
- JWT library: PyJWT
- Redis client: aioredis for async
- Both tokens stored in HttpOnly cookies for browser clients
- Access token in Authorization header for API clients