Files
collective-lists/plan/fase-6-deploy-prep.md
Oier Bravo Urtasun 1f5b34d55d docs(fase-6): propuesta — deploy readiness (PWA + rate-limit + JWT rotation)
New plan file closing out the Fase 4 deferrals with specifics confirmed
by user on 2026-04-14:
  - 6.1 PWA injectManifest, MINIMAL scope (precache shell only; Supabase
    GETs stay network-only since $lib/sync + SyncBanner already cover
    offline writes + the offline indicator)
  - 6.2 manifest.webmanifest + neutral placeholder icons (192/512/180 +
    maskable 512) + iOS meta tags
  - 6.3 Kong rate-limiting — 10/min + 60/hour on /auth/v1/token per IP,
    30/min on /auth/v1/authorize per IP, 20/hour on invitations POST
    per authenticated user
  - 6.4 JWT rotation — dev rotate now + .env.production.example template
    + infra/scripts/rotate-jwt.sh
  - 6.5 Lighthouse PWA ≥ 90 + scripted rls-audit.sh (curl edition of
    Vitest's rls-audit.test.ts)
  - 6.Z verification

Explicitly out of scope (recorded at bottom of the plan):
  - Push notifications (no current use case)
  - Ephemeral-Docker CI (deferred)
  - Runtime caching of Supabase in the SW (deferred)
  - Final production icon art (placeholders land now; real art before
    public launch)

README phase table + index updated; plan total now 11–16 weeks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:39:32 +02:00

11 KiB
Raw Blame History

Fase 6 — Deploy readiness (PWA + rate-limit + secret rotation)

Estado: Propuesta Duración estimada: 35 días Objetivo: cerrar los ítems diferidos de Fase 4 que bloquean un despliegue público del MVP.

Esta fase cierra los ítems que en Fase 4 se dejaron para "sprint de deploy": PWA real (SW custom, manifest, iconos), rate-limiting en Kong, rotación de JWT, y validación Lighthouse + auditoría RLS manual. Notificaciones push se retiran explícitamente del scope (decisión del 2026-04-14; sin caso de uso actual).

Sigue el patrón TDD donde el cambio es programático (rate-limit, SW registration, manifest served). La auditoría Lighthouse + el chequeo manual de rotación quedan como tareas documentadas — no son test automatizables.


6.0 Tests primero — escribir antes de implementar

Vitest (packages/test-utils/tests/):

  • rate-limit.test.ts — carga el plugin de Kong con POST a /auth/v1/token 11 veces seguidas desde la misma IP, espera 429 en el intento 11. Segunda prueba: 21 POSTs a /rest/v1/collective_invitations desde el mismo usuario autenticado en menos de una hora, espera 429.

Playwright (apps/web/tests/e2e/):

  • pwa.test.ts:
    • P-01: manifest.webmanifest accesible en /manifest.webmanifest con status 200 y application/manifest+json content-type; contiene name, short_name, start_url, display: standalone, al menos un icono 192×192 y uno 512×512.
    • P-02: tras login, un navigator.serviceWorker.getRegistration() en la página devuelve un SW registrado y con active.state === 'activated'.

Manual / scripted (Justfile):

  • just lighthouse — arranca el build de producción local, corre Lighthouse CLI contra http://localhost:3000, exige score PWA ≥ 90, score Accessibility ≥ 90, score Best Practices ≥ 90. Falla el recipe si alguno cae. Se ejecuta después de just build + just serve.

6.1 PWA injectManifest migration (scope: minimal)

Decisión de scope: estrategia minimal confirmada por el usuario:

  • Precache del shell + assets generados por Vite (JS/CSS/fonts/iconos)
  • Sin runtime caching de Supabase — las rutas /rest/v1/* siguen netowrk-only
  • Las escrituras offline ya las cubre $lib/sync (pending_ops IndexedDB) + online event flush

Cambios:

  • Crear apps/web/src/sw.ts (no service-worker.ts — SvelteKit intercepta ese nombre y bloquea imports externos; ver gotcha #10 en CLAUDE.md):
    /// <reference lib="webworker" />
    import { precacheAndRoute } from 'workbox-precaching';
    declare const self: ServiceWorkerGlobalScope;
    precacheAndRoute(self.__WB_MANIFEST);
    // skip waiting on first install so updates land without a double reload
    self.addEventListener('install', () => void self.skipWaiting());
    self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
    
  • Actualizar apps/web/vite.config.ts — cambiar @vite-pwa/sveltekit a strategies: 'injectManifest':
    SvelteKitPWA({
        strategies: 'injectManifest',
        srcDir: 'src',
        filename: 'sw.ts',
        injectManifest: {
            globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
        },
        manifest: {  },  // see 6.2
        devOptions: { enabled: true, type: 'module', navigateFallback: '/' }
    })
    
  • Retirar el comentario en vite.config.ts que justifica generateSW (ya no aplica).
  • Añadir workbox-precaching como dep de apps/web si no lo trae ya @vite-pwa/sveltekit.
  • Probar con just build + just serve que /sw.js se sirve y precachea el manifest.

Criterio de aceptación: navigator.serviceWorker.getRegistration() devuelve un SW activo tras cargar la app en una build de producción. La app carga offline sin errores visibles (los fetch de Supabase fallan graciosamente y la SyncBanner aparece).


6.2 Manifest + iconos + meta tags iOS

  • apps/web/static/manifest.webmanifest (o dejar que SvelteKitPWA lo genere desde la config):
    {
        "name": "Colectivo",
        "short_name": "Colectivo",
        "description": "Gestión colaborativa del hogar",
        "theme_color": "#0f172a",
        "background_color": "#f7f9fb",
        "display": "standalone",
        "orientation": "portrait",
        "start_url": "/",
        "scope": "/",
        "icons": [
            { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
            { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
            { "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
        ]
    }
    
  • Iconos placeholder — apps/web/static/icons/:
    • icon-192.png, icon-512.png, icon-512-maskable.png, icon-180.png (iOS touch icon)
    • Placeholder neutro: fondo #0f172a, glyph "C" blanca centrada. Generar vía ImageMagick / script en infra/scripts/generate-placeholder-icons.sh. Documentar en static/icons/README.md que deben reemplazarse antes del lanzamiento público.
  • Añadir meta tags iOS en src/app.html:
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
    <meta name="apple-mobile-web-app-title" content="Colectivo" />
    <link rel="apple-touch-icon" href="/icons/icon-180.png" />
    

Criterio de aceptación: en Lighthouse (just lighthouse) la categoría "Installability" pasa sus auditorías (manifest válido, iconos ≥ 192 y ≥ 512, SW registrado, start_url responde 200).


6.3 Kong rate-limiting

Límites confirmados:

Endpoint Límite Ventana
/auth/v1/token 10 1 min / IP
/auth/v1/token 60 1 hora / IP
/auth/v1/authorize 30 1 min / IP
/rest/v1/collective_invitations POST 20 1 hora / usuario autenticado

Kong ships with rate-limiting plugin; política local (sin Redis) es suficiente para single-instance. Config en infra/kong.yml:

plugins:
  - name: rate-limiting
    service: auth-token-login
    config:
      minute: 10
      hour: 60
      policy: local
      fault_tolerant: true

  - name: rate-limiting
    service: auth-authorize
    config:
      minute: 30
      policy: local

  - name: rate-limiting
    route: invitations-post
    config:
      hour: 20
      policy: local
      limit_by: consumer  # requires JWT claim-to-consumer mapping
  • Actualizar infra/kong.yml con las rutas específicas auth-token-login y auth-authorize (hoy ambas viven bajo un único servicio /auth/v1/* con path matching). Separar en routes diferenciadas.
  • Para limit_by: consumer en invitaciones — Kong necesita resolver el JWT sub a un consumer. La alternativa simple: limit_by: header usando Authorization (se comporta como limit-per-token, que en PKCE rota cada hora pero sirve).
  • Tests en packages/test-utils/tests/rate-limit.test.ts verifican que la respuesta 11/21 es 429 con headers X-RateLimit-Remaining.
  • Restart de kong: docker compose -f infra/docker-compose.dev.yml up -d --force-recreate kong.

Criterio de aceptación: los tests pasan; el 11º login desde la misma IP devuelve 429; la 21ª invitación del mismo usuario devuelve 429.


6.4 Rotación de JWT + template de producción

Dev rotation:

  • Generar nuevo SUPABASE_JWT_SECRET (≥ 32 chars):
    openssl rand -base64 48 | tr -d '\n'
    
  • Firmar nuevas ANON_KEY y SERVICE_ROLE_KEY con ese secret (payloads role=anon / role=service_role, iss=supabase-demo, exp futuro). Script sugerido: infra/scripts/rotate-jwt.sh que toma el nuevo secret y emite los tres valores.
  • Actualizar ambos ficheros a la vez (memory project_env_keys_trap):
    • root .envSUPABASE_JWT_SECRET, PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY
    • apps/web/.env.developmentPUBLIC_SUPABASE_ANON_KEY
  • docker compose up -d --force-recreate kong auth rest realtime.
  • Sesiones existentes quedan invalidadas — todos los usuarios de dev tienen que re-loguearse una vez. Documentado en el commit message.

Production template (no commit de secretos reales):

  • Crear apps/web/.env.production.example y .env.production.example (root) con:
    # Regenerate every 90 days (or on security incident).
    # See infra/scripts/rotate-jwt.sh for the generation procedure.
    SUPABASE_JWT_SECRET=<openssl rand -base64 48 | tr -d '\n'>
    PUBLIC_SUPABASE_ANON_KEY=<sign with above>
    SUPABASE_SERVICE_ROLE_KEY=<sign with above>
    …
    
  • Añadir infra/scripts/rotate-jwt.sh — bash script que:
    1. genera un nuevo secret
    2. usa node/python para firmar los tres JWT (anon, service_role, optionally authenticated for tests)
    3. imprime los valores para que el operador los pegue en el .env de prod
    4. no toca ficheros — el operador decide qué hacer con ellos
  • Documentar el procedimiento al final de CLAUDE.md o en un nuevo infra/README.md: cuándo rotar (cadencia + incidentes), cómo, qué contenedores reiniciar, qué sesiones se invalidan.

Criterio de aceptación: .env.production.example existe con placeholders + comentarios. infra/scripts/rotate-jwt.sh genera un trio válido (verificable firmando un token y descifrándolo). El stack dev sigue verde tras la rotación (cambiar el secret y que just test-all vuelva a verde confirma que no hay valores hardcodeados sueltos).


6.5 Lighthouse + auditoría RLS manual

  • just lighthouse recipe:
    lighthouse:
        pnpm --filter @colectivo/web build
        (pnpm --filter @colectivo/web run preview --port 3000 &) ; sleep 3
        pnpm exec lighthouse http://localhost:3000 \
            --only-categories=pwa,accessibility,best-practices \
            --output=html --output-path=./lighthouse-report.html \
            --chrome-flags='--headless'
        pkill -f 'vite preview --port 3000'
    
  • Añadir lighthouse como devDep en apps/web.
  • Documentar umbrales: PWA ≥ 90, Accessibility ≥ 90, Best Practices ≥ 90.
  • RLS curl audit — script infra/scripts/rls-audit.sh que firma un JWT como Eva y hace GET a /rest/v1/shopping_items, /rest/v1/shopping_lists, /rest/v1/tasks, /rest/v1/notes, /rest/v1/collectives — todos deben devolver []. Es la versión manual del test de integración rls-audit.test.ts.

Criterio de aceptación: Lighthouse report archivado en lighthouse-report.html con los tres scores ≥ 90. rls-audit.sh imprime "OK" en todas las tablas.


6.Z Verificación final

  • just test-all → 0 failures (incluye los nuevos de 6.0: PWA install + rate-limit).
  • just lighthouse → PWA ≥ 90.
  • infra/scripts/rls-audit.sh → OK en todos los endpoints.
  • Manual — PWA instalable en iOS Safari (Add to Home Screen) y Android Chrome (prompt).
  • Commit con rotación de JWT + recordatorio en el mensaje sobre re-login obligatorio.

Criterio de aceptación: todos los tests pasan; Lighthouse verde; la app se puede instalar; RLS auditado. Listo para desplegar a VPS con just deploy.


Scope explícito fuera

  • Notificaciones push — retiradas del plan (decisión 2026-04-14, sin caso de uso actual).
  • CI con Docker efímero corriendo just test-all — diferido (prioridad menor ahora que el deploy está cerca).
  • Runtime caching de Supabase en el SW — diferido (la SyncBanner + pending_ops cubre el MVP offline story).
  • Iconos finales de producción — placeholders esta vez; reemplazar antes del lanzamiento.