### Fase 0 — Infraestructura y Entornos ✅ **Estado: Completa** **Duración estimada: 1–2 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 [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///` - 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 # 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. ---