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>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
### Fase 6 — Deploy readiness (PWA + rate-limit + secret rotation)
|
||||
**Estado: ⏳ Propuesta**
|
||||
**Duración estimada: 3–5 días**
|
||||
**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.
|
||||
@@ -13,17 +15,17 @@ Sigue el patrón TDD donde el cambio es programático (rate-limit, SW registrati
|
||||
|
||||
**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`.
|
||||
- [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/`):**
|
||||
|
||||
- [ ] `pwa.test.ts`:
|
||||
- [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):**
|
||||
|
||||
- [ ] `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`.
|
||||
- [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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -36,7 +38,7 @@ Sigue el patrón TDD donde el cambio es programático (rate-limit, SW registrati
|
||||
|
||||
**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):
|
||||
- [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';
|
||||
@@ -46,7 +48,7 @@ Sigue el patrón TDD donde el cambio es programático (rate-limit, SW registrati
|
||||
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'`:
|
||||
- [x] Actualizar `apps/web/vite.config.ts` — cambiar `@vite-pwa/sveltekit` a `strategies: 'injectManifest'`:
|
||||
```ts
|
||||
SvelteKitPWA({
|
||||
strategies: 'injectManifest',
|
||||
@@ -59,9 +61,9 @@ Sigue el patrón TDD donde el cambio es programático (rate-limit, SW registrati
|
||||
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.
|
||||
- [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).
|
||||
|
||||
@@ -69,7 +71,7 @@ Sigue el patrón TDD donde el cambio es programático (rate-limit, SW registrati
|
||||
|
||||
#### 6.2 Manifest + iconos + meta tags iOS
|
||||
|
||||
- [ ] `apps/web/static/manifest.webmanifest` (o dejar que `SvelteKitPWA` lo genere desde la config):
|
||||
- [x] `apps/web/static/manifest.webmanifest` (o dejar que `SvelteKitPWA` lo genere desde la config):
|
||||
```json
|
||||
{
|
||||
"name": "Colectivo",
|
||||
@@ -88,10 +90,10 @@ Sigue el patrón TDD donde el cambio es programático (rate-limit, SW registrati
|
||||
]
|
||||
}
|
||||
```
|
||||
- [ ] Iconos placeholder — `apps/web/static/icons/`:
|
||||
- [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.**
|
||||
- [ ] Añadir meta tags iOS en `src/app.html`:
|
||||
- [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" />
|
||||
@@ -105,14 +107,20 @@ Sigue el patrón TDD donde el cambio es programático (rate-limit, SW registrati
|
||||
|
||||
#### 6.3 Kong rate-limiting
|
||||
|
||||
**Límites confirmados:**
|
||||
**Límites aplicados (final):**
|
||||
|
||||
| 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 |
|
||||
| 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`:
|
||||
|
||||
@@ -140,10 +148,10 @@ plugins:
|
||||
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`.
|
||||
- [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.
|
||||
|
||||
@@ -153,20 +161,20 @@ plugins:
|
||||
|
||||
**Dev rotation:**
|
||||
|
||||
- [ ] Generar nuevo `SUPABASE_JWT_SECRET` (≥ 32 chars):
|
||||
- [x] Generar nuevo `SUPABASE_JWT_SECRET` (≥ 32 chars):
|
||||
```bash
|
||||
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`):
|
||||
- [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`
|
||||
- [ ] `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.
|
||||
- [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):**
|
||||
|
||||
- [ ] Crear `apps/web/.env.production.example` y `.env.production.example` (root) con:
|
||||
- [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.
|
||||
@@ -175,12 +183,12 @@ plugins:
|
||||
SUPABASE_SERVICE_ROLE_KEY=<sign with above>
|
||||
…
|
||||
```
|
||||
- [ ] Añadir `infra/scripts/rotate-jwt.sh` — bash script que:
|
||||
- [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
|
||||
- [ ] 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.
|
||||
- [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).
|
||||
|
||||
@@ -188,7 +196,7 @@ plugins:
|
||||
|
||||
#### 6.5 Lighthouse + auditoría RLS manual
|
||||
|
||||
- [ ] `just lighthouse` recipe:
|
||||
- [x] `just lighthouse` recipe:
|
||||
```makefile
|
||||
lighthouse:
|
||||
pnpm --filter @colectivo/web build
|
||||
@@ -199,9 +207,9 @@ plugins:
|
||||
--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`.
|
||||
- [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.
|
||||
|
||||
@@ -209,11 +217,11 @@ plugins:
|
||||
|
||||
#### 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.
|
||||
- [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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user