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

17 KiB
Raw Blame History

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:

# 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:

# 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:

# 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):

RESEND_API_KEY=...
EMAIL_FROM=noreply@tudominio.com

Backups (solo prod):

# 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

  • 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
  • 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)
  • SvelteKit dev server en puerto 5173, apuntando a variables de .env.dev
  • supabase/seed.sql — crea los usuarios de prueba en auth.users con los mismos UUIDs que Keycloak, un colectivo de ejemplo, listas e ítems
  • Generación automática de tipos TypeScript: supabase gen types typescript
  • 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.

  • 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
  • Añadir el contenedor SvelteKit al compose, escuchando en un puerto interno (ej: 127.0.0.1:3000) — nginx hace proxy hacia él
  • Configurar nginx externamente (fuera del repo) para los nuevos servicios — ver bloques de ejemplo abajo
  • 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
  • .env.production en el VPS con todas las variables (ver sección 0.1), fuera del repo
  • Scripts de backup — ver sección 0.6 Sistema de Backups para el detalle completo
  • 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:

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:

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):

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

  • ci.yml: lint + typecheck + tests unitarios en cada PR
  • deploy.yml: build de imagen SvelteKit → GitHub Container Registry → deploy vía SSH en merge a main
  • Secrets de GitHub: SSH_KEY, VPS_HOST, GHCR_TOKEN

0.5 Monorepo

  • Turborepo configurado con pipeline build → test → deploy
  • packages/types como paquete interno
  • 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:

# 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:

# 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:

# 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
# 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
  • Crear volumen Docker (o directorio montado) en BACKUP_LOCAL_DIR con permisos correctos
  • Implementar backup.sh con soporte para supabase y keycloak, compresión y subida opcional a B2
  • Implementar backup-cron.sh con lógica de rotación y purga por tipo
  • Implementar restore.sh con confirmación, parada de servicios y resumen post-restauración
  • Configurar crontab en el host del VPS
  • Verificar backup completo: ejecutar backup.sh supabase, comprobar fichero generado, ejecutar restore.sh en un entorno de prueba
  • 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.