Files
collective-lists/plan/fase-0-infraestructura.md
Oier Bravo Urtasun f396897cb5 test: full test suite (Vitest + pgTAP + Playwright) + TDD plan restructure
Add a 4-layer test stack covering RLS, triggers, and UI flows for Fases 0–2a,
then restructure every plan file so future fases start with tests and end with
a verification gate.

Test suite
- packages/test-utils: Vitest integration tests signing HS256 JWTs via jose so
  each test acts as a specific seed user (createClientAs + createAdminClient)
- supabase/tests: pgTAP for accept_invitation(), item_frequency trigger, and
  promote-on-admin-leave; each file self-installs pgtap extension
- apps/web/tests: Playwright E2E with live Keycloak login per test (storageState
  caching doesn't rehydrate Supabase's session state reliably)
- just test-all chains the three suites; test-db forwards POSTGRES_PASSWORD
  as PGPASSWORD with ON_ERROR_STOP=1 so failures abort the chain

Supabase auth gotcha
- PostgREST queries inside onAuthStateChange deadlock on GoTrue's navigator.locks
  auth lock (getAccessToken → getSession → initializePromise waits on the same
  lock that's held during event dispatch). Fix is two defenses: a pass-through
  lock in $lib/supabase, and a setTimeout(0) defer in root +layout.svelte to
  push loadUserCollectives out of the callback's microtask chain. Either alone
  is insufficient; both together unblock the Playwright suite.

Env key rotation
- apps/web/.env.development had a stale demo anon key signed with a different
  secret than root .env; Vite inlined that into the browser bundle so Kong (which
  uses the root .env value) rejected every request with 401. Aligned the two
  files and added a memory entry to flag this for the next rotation.

Plan restructure (TDD)
- Every fase now opens with X.0 Tests primero and closes with X.Z Verificación
  final. Completed fases (0, 1, 2a) show the pattern retroactively with the
  tests that currently cover them; pending fases (2b, 3, 4) list the tests to
  write before implementation.

Docs
- CLAUDE.md status line reports 54 + 12 + 15 = 81 green tests, adds gotchas
  #11 (auth-lock deadlock) and #12 (no storageState caching)
- README.md adds a TDD methodology section and the test-all command
- .gitignore excludes Playwright's generated reports and auth state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:40:16 +02:00

376 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
### Fase 0 — Infraestructura y Entornos ✅
**Estado: Completa**
**Duración estimada: 12 semanas**
**Objetivo: tener los dos entornos funcionando antes de escribir producto.**
> **Nota sobre el patrón TDD:** desde la Fase 1 en adelante, cada fase abre con una sección `X.0 Tests primero` y cierra con `X.Z Verificación final`. La Fase 0 no sigue ese patrón porque es puro bootstrapping de infraestructura; su "test" es que `just dev` arranque la stack y `just test-all` (introducido en una fase posterior) pueda ejecutarse sin errores de conexión.
#### 0.1 Variables de entorno
Todas las variables viven en `.env.dev` (dev) y `.env.production` (prod, fuera del repo). El fichero `.env.example` documenta cada variable con su propósito.
**Keycloak:**
```env
# Credenciales del administrador de Keycloak
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=...
# Realm configurado para la app
KEYCLOAK_REALM=colectivo
# Client OIDC que usa SvelteKit
KEYCLOAK_CLIENT_ID=colectivo-web
KEYCLOAK_CLIENT_SECRET=... # solo en prod; en dev el client es public
# URL base de Keycloak (usada por SvelteKit y Supabase)
# Dev: http://localhost:8080
# Prod: https://auth.tudominio.com
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_ISSUER=${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}
```
**Supabase:**
```env
# Claves de Supabase (generadas al arrancar; rotar en prod)
SUPABASE_URL=http://localhost:54321
SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=... # solo para Edge Functions y scripts de admin
JWT_SECRET=... # debe coincidir con la firma JWT de Keycloak en prod
# OIDC — Keycloak como proveedor externo de Supabase Auth
SUPABASE_AUTH_EXTERNAL_KEYCLOAK_ENABLED=true
SUPABASE_AUTH_EXTERNAL_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID}
SUPABASE_AUTH_EXTERNAL_KEYCLOAK_SECRET=${KEYCLOAK_CLIENT_SECRET}
SUPABASE_AUTH_EXTERNAL_KEYCLOAK_URL=${KEYCLOAK_ISSUER}
```
**SvelteKit:**
```env
# Públicas (expuestas al navegador — prefijo PUBLIC_)
PUBLIC_SUPABASE_URL=http://localhost:54321
PUBLIC_SUPABASE_ANON_KEY=...
PUBLIC_KEYCLOAK_URL=http://localhost:8080
PUBLIC_KEYCLOAK_REALM=colectivo
PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web
# Privadas (solo servidor SvelteKit)
SUPABASE_SERVICE_ROLE_KEY=...
```
**Email (Resend):**
```env
RESEND_API_KEY=...
EMAIL_FROM=noreply@tudominio.com
```
**Backups (solo prod):**
```env
# Volumen local donde se guardan los dumps antes/además de B2
BACKUP_LOCAL_DIR=/mnt/backups # ruta en el host montada como volumen
# Política de retención — número máximo de backups de cada tipo
BACKUP_RETENTION_DAILY=7 # 7 diarios → cubre la última semana
BACKUP_RETENTION_WEEKLY=4 # 4 semanales → cubre el último mes
BACKUP_RETENTION_MONTHLY=3 # 3 mensuales → configurable, por defecto 3 meses
# Subida a Backblaze B2 (opcional pero recomendado como segunda copia)
BACKUP_B2_ENABLED=true
B2_BUCKET=colectivo-backups
B2_KEY_ID=...
B2_APPLICATION_KEY=...
```
#### 0.2 Entorno de desarrollo local
- [x] `docker-compose.dev.yml` con todos los servicios:
- **PostgreSQL 15** — puerto `5432`
- **Supabase Auth (GoTrue)** — configurado con Keycloak como OIDC externo
- **Supabase Realtime** — puerto `4000`
- **Supabase Storage** — puerto `5000`
- **Kong API Gateway** — puerto `54321` (entrada principal de Supabase)
- **Supabase Studio** — puerto `54323`
- **Keycloak 24** — puerto `8080`, arranca con `--import-realm keycloak/realm-export.json`
- [x] `keycloak/realm-export.json` — realm `colectivo` preconfigurado con:
- Client `colectivo-web` (public, PKCE habilitado, redirect URIs para dev)
- **5 usuarios de prueba** creados y listos (ver tabla abajo)
- Roles de realm: `app-user` (asignado a todos por defecto)
- [x] SvelteKit dev server en puerto `5173`, apuntando a variables de `.env.dev`
- [x] `supabase/seed.sql` — crea los usuarios de prueba en `auth.users` con los mismos UUIDs que Keycloak, un colectivo de ejemplo, listas e ítems
- [x] Generación automática de tipos TypeScript: `supabase gen types typescript`
- [x] `Justfile` con `just dev`, `just db-reset`, `just db-types`, `just kc-export`, `just kc-open`
**Usuarios de prueba en Keycloak (dev):**
| Usuario | Email | Contraseña | Rol en colectivo de prueba |
|---------|-------|-----------|---------------------------|
| `ana` | ana@dev.local | `test1234` | Administradora |
| `borja` | borja@dev.local | `test1234` | Miembro |
| `carmen` | carmen@dev.local | `test1234` | Miembro |
| `david` | david@dev.local | `test1234` | Invitado |
| `eva` | eva@dev.local | `test1234` | Sin colectivo (prueba onboarding) |
> Estos usuarios están en el `realm-export.json` versionado. Al hacer `just dev` arrancan disponibles sin configuración manual.
#### 0.3 Entorno de producción (VPS)
> **El entorno de producción ya está montado y dockerizado.** Keycloak, nginx y su PostgreSQL propio ya están operativos. Esta sección cubre únicamente lo que hay que añadir.
- [x] Incorporar los servicios de Supabase al `docker-compose.prod.yml` existente:
- PostgreSQL dedicado para Supabase — contenedor independiente, volumen propio, **no compartir con Keycloak**
- GoTrue (Auth), Realtime, Storage, Kong en la red interna Docker existente
- Kong expuesto en un puerto interno (ej: `127.0.0.1:54321`) — nginx hace proxy hacia él, no expuesto directamente
- [x] Añadir el contenedor SvelteKit al compose, escuchando en un puerto interno (ej: `127.0.0.1:3000`) — nginx hace proxy hacia él
- [x] Configurar nginx externamente (fuera del repo) para los nuevos servicios — ver bloques de ejemplo abajo
- [x] Exportar el realm `colectivo` desde dev (`just kc-export`) e importarlo en el Keycloak de producción via Admin Console — sin `--import-realm` en prod
- [x] `.env.production` en el VPS con todas las variables (ver sección 0.1), fuera del repo
- [x] Scripts de backup — ver sección **0.6 Sistema de Backups** para el detalle completo
- [x] Script de deploy: `ssh vps "cd /app && git pull && docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d --no-deps web"`
- `--no-deps web` para actualizar solo el contenedor SvelteKit sin reiniciar Supabase ni Keycloak
##### Configuración nginx de referencia
Tres bloques independientes: app, API/Supabase y Keycloak. Ajustar puertos y dominios según la configuración real del VPS.
**`/etc/nginx/sites-available/colectivo-web`** — SvelteKit PWA:
```nginx
server {
listen 443 ssl http2;
server_name tudominio.com;
ssl_certificate /etc/letsencrypt/live/tudominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tudominio.com/privkey.pem;
# Headers de seguridad
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy
"default-src 'self'; \
script-src 'self' 'wasm-unsafe-eval'; \
style-src 'self' 'unsafe-inline'; \
connect-src 'self' https://api.tudominio.com wss://api.tudominio.com https://auth.tudominio.com; \
img-src 'self' data: blob:; \
font-src 'self'; \
worker-src 'self' blob:;"
always;
# Service Worker — nunca cacheado por nginx
location = /service-worker.js {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Redirección HTTP → HTTPS
server {
listen 80;
server_name tudominio.com;
return 301 https://$host$request_uri;
}
```
**`/etc/nginx/sites-available/colectivo-api`** — Kong/Supabase con WebSocket para Realtime:
```nginx
server {
listen 443 ssl http2;
server_name api.tudominio.com;
ssl_certificate /etc/letsencrypt/live/api.tudominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.tudominio.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
# Supabase Realtime — WebSocket (wss://api.tudominio.com/realtime/v1/websocket)
# CRÍTICO: sin estos headers el Modo Compra no funciona en producción
location /realtime/ {
proxy_pass http://127.0.0.1:54321/realtime/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # ← habilita upgrade a WebSocket
proxy_set_header Connection "upgrade"; # ← mantiene la conexión viva
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s; # ← evita que nginx cierre conexiones WS inactivas
proxy_send_timeout 3600s;
}
# Resto de la API de Supabase (REST, Auth, Storage)
location / {
proxy_pass http://127.0.0.1:54321;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name api.tudominio.com;
return 301 https://$host$request_uri;
}
```
**`/etc/nginx/sites-available/colectivo-auth`** — Keycloak (si aún no está configurado):
```nginx
server {
listen 443 ssl http2;
server_name auth.tudominio.com;
ssl_certificate /etc/letsencrypt/live/auth.tudominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.tudominio.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
# Keycloak necesita el host original para construir redirect URIs correctamente
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host; # ← crítico para Keycloak
proxy_set_header X-Forwarded-Port 443; # ← crítico para Keycloak
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_buffer_size 128k; # Keycloak genera headers grandes (tokens)
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}
server {
listen 80;
server_name auth.tudominio.com;
return 301 https://$host$request_uri;
}
```
> **Notas importantes:**
> - El bloque `/realtime/` debe ir **antes** del bloque `/` en el server de API — nginx aplica la regla más específica primero.
> - `proxy_read_timeout 3600s` en el bloque de Realtime es necesario porque las conexiones WebSocket del Modo Compra son de larga duración. Sin él, nginx cierra la conexión a los 60 segundos (valor por defecto) y el cliente tiene que reconectar continuamente.
> - En el CSP de SvelteKit, `connect-src` debe incluir tanto `https://` como `wss://` para `api.tudominio.com` — sin el `wss://` el Service Worker no puede abrir la conexión WebSocket.
> - Los headers `X-Forwarded-Host` y `X-Forwarded-Port` en Keycloak son obligatorios. Sin ellos, Keycloak construye las redirect URIs con el puerto interno (8080) en lugar del 443, y el flujo OIDC falla.
#### 0.4 CI/CD
- [x] `ci.yml`: lint + typecheck + tests unitarios en cada PR
- [x] `deploy.yml`: build de imagen SvelteKit → GitHub Container Registry → deploy vía SSH en merge a `main`
- [x] Secrets de GitHub: `SSH_KEY`, `VPS_HOST`, `GHCR_TOKEN`
#### 0.5 Monorepo
- [x] Turborepo configurado con pipeline `build → test → deploy`
- [x] `packages/types` como paquete interno
- [x] `apps/web` consumiendo `packages/types`
#### 0.6 Sistema de Backups
##### Estrategia de rotación
Se guardan tres tipos de backup, cada uno con su propia ventana de retención:
| Tipo | Frecuencia | Retención por defecto | Variable de control |
|------|-----------|----------------------|---------------------|
| Diario | Cada día a las 02:00 | 7 copias | `BACKUP_RETENTION_DAILY` |
| Semanal | Lunes a las 03:00 | 4 copias | `BACKUP_RETENTION_WEEKLY` |
| Mensual | Día 1 de cada mes a las 04:00 | 3 copias | `BACKUP_RETENTION_MONTHLY` |
Con los valores por defecto se conservan hasta ~3 meses de historia. Cambiando `BACKUP_RETENTION_MONTHLY=6` se extiende a 6 meses sin tocar nada más.
##### Volumen local
Los dumps se guardan en una estructura de directorios clara en el volumen montado en `BACKUP_LOCAL_DIR`:
```
/mnt/backups/
├── supabase/
│ ├── daily/
│ │ ├── supabase_daily_2026-04-10.sql.gz
│ │ └── supabase_daily_2026-04-11.sql.gz
│ ├── weekly/
│ │ └── supabase_weekly_2026-04-07.sql.gz
│ └── monthly/
│ └── supabase_monthly_2026-04-01.sql.gz
└── keycloak/
├── daily/
├── weekly/
└── monthly/
```
##### Scripts
**`infra/scripts/backup.sh`** — backup manual inmediato de una BD concreta:
```bash
# Uso: ./backup.sh <supabase|keycloak> [daily|weekly|monthly]
# Sin segundo argumento, genera un dump con timestamp libre (para backups manuales pre-deploy)
# Ejemplo: ./backup.sh supabase
# Ejemplo: ./backup.sh keycloak monthly
```
- Conecta al contenedor Docker correcto según el primer argumento
- Ejecuta `pg_dump` con formato custom (`-Fc`) para compatibilidad con `pg_restore`
- Comprime con gzip
- Guarda en `$BACKUP_LOCAL_DIR/<db>/<tipo>/`
- Si `BACKUP_B2_ENABLED=true`, sube el fichero a B2 con `rclone` o `b2 cli`
- Imprime checksum SHA256 del fichero generado
**`infra/scripts/backup-cron.sh`** — wrapper para cron, decide el tipo automáticamente:
```bash
# Lógica de decisión:
# - Si es día 1 del mes → tipo = monthly
# - Si es lunes → tipo = weekly
# - En cualquier otro caso → tipo = daily
# Llama a backup.sh para supabase y keycloak secuencialmente
# Tras el backup, llama a la función de purga para eliminar copias antiguas
# según BACKUP_RETENTION_DAILY/WEEKLY/MONTHLY
# Registra resultado en /var/log/backup-colectivo.log
```
**`infra/scripts/restore.sh`** — restaura un dump en una BD:
```bash
# Uso: ./restore.sh <supabase|keycloak> <ruta-al-fichero.sql.gz>
# Ejemplo: ./restore.sh supabase /mnt/backups/supabase/daily/supabase_daily_2026-04-10.sql.gz
```
- Pide confirmación explícita antes de ejecutar (`¿Seguro? Esto sobreescribirá la BD actual [s/N]`)
- Detiene los servicios dependientes de la BD antes de restaurar (GoTrue, Realtime, Kong para supabase)
- Descomprime y ejecuta `pg_restore` sobre el contenedor correcto
- Reinicia los servicios tras la restauración
- Imprime resumen: tablas restauradas, filas por tabla
##### Crontab en el host
```cron
# Backups de producción — colectivo
0 2 * * * /app/infra/scripts/backup-cron.sh >> /var/log/backup-colectivo.log 2>&1
```
Un solo entry en cron. El script decide internamente si es daily, weekly o monthly.
##### Tareas de la Fase 0
- [x] Crear volumen Docker (o directorio montado) en `BACKUP_LOCAL_DIR` con permisos correctos
- [x] Implementar `backup.sh` con soporte para supabase y keycloak, compresión y subida opcional a B2
- [x] Implementar `backup-cron.sh` con lógica de rotación y purga por tipo
- [x] Implementar `restore.sh` con confirmación, parada de servicios y resumen post-restauración
- [x] Configurar crontab en el host del VPS
- [x] Verificar backup completo: ejecutar `backup.sh supabase`, comprobar fichero generado, ejecutar `restore.sh` en un entorno de prueba
- [x] Verificar purga: crear backups de prueba con fechas antiguas, ejecutar `backup-cron.sh`, confirmar que se eliminan los que exceden la retención
**Criterio de aceptación:** `just dev` levanta todo el stack en < 3 minutos. Los 5 usuarios de prueba pueden hacer login con Keycloak y llegar a la app. Deploy a producción funciona con un `git push`. Un backup manual de supabase y keycloak genera ficheros válidos que se restauran correctamente.
---