Files
collective-lists/plan/fase-6-deploy-prep.md
Oier Bravo Urtasun 2db5aaac14 Fase 6: deploy readiness (PWA + invitation rate-limit + JWT rotation)
Closes the deferrals from Fase 4 that gated a public deploy of the MVP.

PWA install
- Custom SW at apps/web/src/sw.ts (named sw.ts, not service-worker.ts, so
  SvelteKit doesn't intercept it and block Workbox imports).
- vite.config.ts switched to injectManifest strategy with precache of the
  built shell; no runtime caching of Supabase (offline writes stay on the
  existing $lib/sync pending_ops queue).
- Root +layout.svelte onMount() calls registerSW({ immediate: true }) from
  virtual:pwa-register — the plugin does not auto-register.
- Web manifest with 192/512/512-maskable icons + iOS meta tags
  (apple-mobile-web-app-capable, apple-touch-icon, etc.) in app.html.
- Placeholder icons generated via ImageMagick; replace before public launch
  (noted in apps/web/static/icons/README.md).

Kong rate-limit
- /rest/v1/collective_invitations POST: 20/hour per Authorization header.
  Anti-spam on invitation creation is the only rate-limit that survived —
  auth rate-limits were dropped during execution (see plan §6.3): Keycloak
  already provides bruteForceProtected=true on the realm, and stacking a
  Kong limit on /auth/v1/token throttled legitimate PKCE code exchanges
  during E2E.
- Added 'rate-limiting' to KONG_PLUGINS in docker-compose.dev.yml.
- Discovered: strip_path=true on a full-table path sends "/" upstream and
  PostgREST rejects POST to root with PGRST117. Route carries a
  request-transformer plugin that rewrites the URI back.

JWT rotation + prod template
- infra/scripts/rotate-jwt.sh signs anon + service_role JWTs with a fresh
  48-byte secret via openssl. Prints three values for the operator to
  paste; does not touch files.
- Dev secret rotated as a dry-run. Updated both .env (local, gitignored)
  and apps/web/.env.development. Existing sessions are invalidated — all
  users must re-login once.
- .env.production.example committed with placeholders + rotation notes.

Lighthouse + RLS audit tooling
- `just lighthouse` boots the prod build + Supabase stack and runs
  lighthouse CLI against PWA/A11y/Best-Practices.
- `just rls-audit` runs infra/scripts/rls-audit.sh which signs an Eva JWT
  (non-member) and GETs 8 REST endpoints — all must return []. Complements
  the Vitest rls-audit.test.ts suite.

Tests
- 236 green: 34 pgTAP + 140 Vitest integration + 15 unit + 46 Playwright.
- 1 rate-limit test gated behind RUN_RATE_LIMIT_TESTS=1 (Kong counters are
  shared state); run via `just test-rate-limit` which force-recreates Kong
  first to reset counters.
- 3 skipped in `just test-all` (2 Realtime presence upstream bug, 1 gated
  rate-limit).

Deliberately out of scope: push notifications, CI ephemeral Docker, final
production icons, runtime caching of Supabase (SyncBanner + pending_ops
already covers offline).

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

236 lines
13 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 6 — Deploy readiness (PWA + rate-limit + secret rotation)
**Estado: ✅ Completa (2026-04-14)**
**Duración real: 1 día.**
**Objetivo: cerrar los ítems diferidos de Fase 4 que bloquean un despliegue público del MVP.**
**Resultado:** 236 tests verdes (34 pgTAP + 140 Vitest integración + 15 Vitest unit + 46 Playwright + 1 rate-limit aislado), RLS audit manual OK, dev JWT rotado con éxito. Scope dropped durante la ejecución: rate-limit de `/auth/v1/token` + `/auth/v1/authorize` retirados — Keycloak ya ofrece brute-force protection nativa (`bruteForceProtected=true`, `maxFailureWaitSeconds=900`, `failureFactor=30`) y aplicar rate-limit adicional en Kong interfería con los intercambios PKCE legítimos durante E2E. El rate-limit crítico (anti-spam de invitaciones) sí se mantiene.
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/`):**
- [x] `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/`):**
- [x] `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):**
- [x] `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:**
- [x] Crear `apps/web/src/sw.ts` (no `service-worker.ts` — SvelteKit intercepta ese nombre y bloquea imports externos; ver gotcha #10 en CLAUDE.md):
```ts
/// <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()));
```
- [x] Actualizar `apps/web/vite.config.ts` — cambiar `@vite-pwa/sveltekit` a `strategies: 'injectManifest'`:
```ts
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: '/' }
})
```
- [x] Retirar el comentario en `vite.config.ts` que justifica `generateSW` (ya no aplica).
- [x] Añadir `workbox-precaching` como dep de `apps/web` si no lo trae ya `@vite-pwa/sveltekit`.
- [x] 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
- [x] `apps/web/static/manifest.webmanifest` (o dejar que `SvelteKitPWA` lo genere desde la config):
```json
{
"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" }
]
}
```
- [x] 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.**
- [x] Añadir meta tags iOS en `src/app.html`:
```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 aplicados (final):**
| Endpoint | Límite | Ventana | Notas |
|----------|--------|---------|-------|
| `/rest/v1/collective_invitations` POST | 20 | 1 hora / `Authorization` header | anti-spam real; única ruta donde Kong aplica rate-limit |
| `/auth/v1/token` + `/auth/v1/authorize` | — | — | **dropped** — ver nota abajo |
**Nota sobre el scope dropped de auth rate-limit:** el plan original apilaba rate-limits en Kong sobre `/auth/v1/token` (10/min IP) y `/auth/v1/authorize` (30/min IP). Al integrarlo con el flujo real de Playwright se vio que:
1. El PKCE code exchange también golpea `/auth/v1/token` y 10/min es insuficiente para una batería E2E de ~50 logins. Se llena el cupo → 429 → fallos intermitentes.
2. Brute-force de contraseñas lo protege Keycloak nativamente (`bruteForceProtected=true` en `realm-export.json`, 30 fallos por usuario antes de lockout de 15 min). Esa es la capa correcta.
3. Añadir rate-limit en Kong duplicaría la protección y lo haría mal — Kong no puede distinguir entre "usuario intentando logear" y "cliente PKCE canjeando su code".
→ Se retira Kong rate-limit de `/auth/v1/*`. El test `RL-01` se eliminó; solo queda `RL-02` (invitaciones).
Kong ships with `rate-limiting` plugin; política `local` (sin Redis) es suficiente para single-instance. Config en `infra/kong.yml`:
```yaml
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
```
- [x] 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.
- [x] 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).
- [x] Tests en `packages/test-utils/tests/rate-limit.test.ts` verifican que la respuesta 11/21 es 429 con headers `X-RateLimit-Remaining`.
- [x] 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:**
- [x] Generar nuevo `SUPABASE_JWT_SECRET` (≥ 32 chars):
```bash
openssl rand -base64 48 | tr -d '\n'
```
- [x] 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.
- [x] Actualizar **ambos** ficheros a la vez (memory `project_env_keys_trap`):
- root `.env` → `SUPABASE_JWT_SECRET`, `PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`
- `apps/web/.env.development` → `PUBLIC_SUPABASE_ANON_KEY`
- [x] `docker compose up -d --force-recreate kong auth rest realtime`.
- [x] 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):**
- [x] 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>
```
- [x] 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
- [x] 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
- [x] `just lighthouse` recipe:
```makefile
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'
```
- [x] Añadir `lighthouse` como devDep en `apps/web`.
- [x] Documentar umbrales: PWA ≥ 90, Accessibility ≥ 90, Best Practices ≥ 90.
- [x] 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
- [x] `just test-all` → 0 failures (incluye los nuevos de 6.0: PWA install + rate-limit).
- [x] `just lighthouse` → PWA ≥ 90.
- [x] `infra/scripts/rls-audit.sh` → OK en todos los endpoints.
- [x] Manual — PWA instalable en iOS Safari (Add to Home Screen) y Android Chrome (prompt).
- [x] 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.