From 2db5aaac14de4f45b3f561cb1700cf2f4826c9b4 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Tue, 14 Apr 2026 04:48:04 +0200 Subject: [PATCH] Fase 6: deploy readiness (PWA + invitation rate-limit + JWT rotation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.production.example | 49 ++++++++++ CLAUDE.md | 41 ++++++++- Justfile | 37 ++++++++ README.md | 6 +- apps/web/.env.development | 2 +- apps/web/package.json | 1 + apps/web/src/app.html | 5 ++ apps/web/src/routes/+layout.svelte | 6 ++ apps/web/src/sw.ts | 21 +++++ apps/web/static/icons/README.md | 16 ++++ apps/web/static/icons/icon-180.png | Bin 0 -> 3956 bytes apps/web/static/icons/icon-192.png | Bin 0 -> 4228 bytes apps/web/static/icons/icon-512-maskable.png | Bin 0 -> 9792 bytes apps/web/static/icons/icon-512.png | Bin 0 -> 11654 bytes apps/web/tests/e2e/pwa.test.ts | 55 ++++++++++++ apps/web/vite.config.ts | 17 ++-- infra/docker-compose.dev.yml | 2 +- infra/kong.yml | 30 +++++++ infra/scripts/generate-placeholder-icons.sh | 37 ++++++++ infra/scripts/rls-audit.sh | 74 +++++++++++++++ infra/scripts/rotate-jwt.sh | 50 +++++++++++ packages/test-utils/tests/rate-limit.test.ts | 84 +++++++++++++++++ plan/fase-6-deploy-prep.md | 90 ++++++++++--------- pnpm-lock.yaml | 3 + 24 files changed, 571 insertions(+), 55 deletions(-) create mode 100644 .env.production.example create mode 100644 apps/web/src/sw.ts create mode 100644 apps/web/static/icons/README.md create mode 100644 apps/web/static/icons/icon-180.png create mode 100644 apps/web/static/icons/icon-192.png create mode 100644 apps/web/static/icons/icon-512-maskable.png create mode 100644 apps/web/static/icons/icon-512.png create mode 100644 apps/web/tests/e2e/pwa.test.ts create mode 100755 infra/scripts/generate-placeholder-icons.sh create mode 100755 infra/scripts/rls-audit.sh create mode 100755 infra/scripts/rotate-jwt.sh create mode 100644 packages/test-utils/tests/rate-limit.test.ts diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..3b482e1 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,49 @@ +# Production environment template for Colectivo. +# Copy to /srv/colectivo/.env on the production host and fill in the secrets. +# NEVER commit the filled version. + +# ── Database ────────────────────────────────────────────────────────────── +POSTGRES_PASSWORD= + +# ── Supabase signing key ────────────────────────────────────────────────── +# Regenerate every 90 days (or immediately on a suspected leak). +# Use infra/scripts/rotate-jwt.sh to produce the matching anon + service-role +# JWTs; it signs both with the secret below, so they must stay in sync. +SUPABASE_JWT_SECRET= + +# JWTs signed with the secret above. Produced by infra/scripts/rotate-jwt.sh. +# Don't hand-edit these — run the script, copy its output. +PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# ── Public URLs (production domain + subdomain) ─────────────────────────── +PUBLIC_SUPABASE_URL=https://api.example.com +PUBLIC_KEYCLOAK_URL=https://auth.example.com +PUBLIC_KEYCLOAK_REALM=colectivo +PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web +PUBLIC_APP_URL=https://app.example.com + +# ── Keycloak ────────────────────────────────────────────────────────────── +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD= +# Client secret for the confidential OAuth client; must match the Keycloak +# realm export. +KEYCLOAK_CLIENT_SECRET= + +# ── Email (Resend) ──────────────────────────────────────────────────────── +RESEND_API_KEY=re_... +RESEND_FROM_EMAIL=noreply@example.com + +# ── Backup storage (Backblaze B2) ───────────────────────────────────────── +B2_BUCKET_NAME=colectivo-prod-backups +B2_APPLICATION_KEY_ID= +B2_APPLICATION_KEY= + +# ── Supabase Studio ─────────────────────────────────────────────────────── +# Basic-auth on the admin UI. Narrow to internal IPs via nginx when possible. +DASHBOARD_USERNAME= +DASHBOARD_PASSWORD= + +# ── Deploy target ───────────────────────────────────────────────────────── +DEPLOY_HOST=deploy@prod.example.com +DEPLOY_PATH=/srv/colectivo diff --git a/CLAUDE.md b/CLAUDE.md index 1a6e24e..a905077 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -**Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). Fase 3 complete (Tareas + Notas). Fase 4 partially complete (global search + RLS audit). Fase 5 complete for the automatable scope (mobile UX: responsive shell + masthead + row redesign + selection mode + insecure-context resilience; see `plan/fase-5-mobile-ux.md`). 233 tests green: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 44 Playwright. 2 skipped (Realtime presence — upstream `handle_out/3` bug). PWA install/push/rate-limit/JWT rotation deferred to a prod-deploy sprint. ✅** +**Fase 0–5 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). 236 tests green: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 46 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅** - `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) -- `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 5) +- `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 6) - `design/slate_collective/DESIGN.md` + `design/*/screen.png` — "Monolith Editorial" direction + per-screen Stitch mockups (desktop + mobile variants). Consult before changing UI. ### Fase 1 — what has been built @@ -74,6 +74,43 @@ just test-e2e # Playwright E2E tests (requires just dev running) just test-e2e-headed # Playwright with visible browser (debugging) ``` +### Fase 6 — what has been built + +**6.1 PWA `injectManifest` migration** +- `apps/web/src/sw.ts` — minimal custom SW: `precacheAndRoute(self.__WB_MANIFEST)` + `skipWaiting`/`clients.claim`. Named `sw.ts` (not `service-worker.ts`) to dodge SvelteKit's built-in interception (see gotcha #10). +- `apps/web/vite.config.ts` — switched `@vite-pwa/sveltekit` to `strategies: 'injectManifest'` with `srcDir: 'src'`, `filename: 'sw.ts'`, `injectManifest.globPatterns` for the built shell. +- `apps/web/src/routes/+layout.svelte` — registers the SW via `onMount() → import('virtual:pwa-register').registerSW({ immediate: true })`. `@vite-pwa/sveltekit` does NOT auto-register; without this call the virtual module is a no-op and `navigator.serviceWorker.getRegistration()` returns `undefined`. +- `apps/web/tests/e2e/pwa.test.ts` — P-01 (manifest shape) + P-02 (SW registration via `waitForFunction` poll). + +**6.2 Manifest + iOS meta tags + placeholder icons** +- Manifest driven by `vite.config.ts` → `SvelteKitPWA({ manifest: {…} })`. Icons declared 192×192, 512×512, 512×512 maskable. `theme_color: #0f172a`, `background_color: #f7f9fb`, `display: standalone`. +- `apps/web/static/icons/` — `icon-192.png`, `icon-512.png`, `icon-512-maskable.png`, `icon-180.png` placeholder PNGs (slate background + "C" glyph). Generator: `infra/scripts/generate-placeholder-icons.sh` (ImageMagick). Replace with final artwork before public launch — noted in `static/icons/README.md`. +- `apps/web/src/app.html` — iOS PWA meta tags: `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title`, `apple-touch-icon`. + +**6.3 Kong rate-limit (invitations only)** +- `infra/kong.yml` — `rest-v1-invitations` route: `POST /rest/v1/collective_invitations`, rate-limit `hour: 20, policy: local, limit_by: header (Authorization)`. `strip_path: true` on a full-table path sends `/` upstream which PostgREST rejects with `PGRST117`, so the route also carries a `request-transformer` plugin that rewrites the URI back to `/collective_invitations`. +- `infra/docker-compose.dev.yml` — `KONG_PLUGINS` env var gains `rate-limiting` (was missing). +- `packages/test-utils/tests/rate-limit.test.ts` — RL-02 only (21st invitation POST = 429). Gated behind `RUN_RATE_LIMIT_TESTS=1` so it's excluded from `just test-all` (Kong counters are shared state). Run via `just test-rate-limit`, which also `--force-recreate`s Kong to reset counters first. +- **Scope dropped during execution:** Kong rate-limit on `/auth/v1/token` + `/auth/v1/authorize` removed. Reasoning: Keycloak already offers native brute-force protection on the realm (`bruteForceProtected: true`, `maxFailureWaitSeconds: 900`, `failureFactor: 30` in `keycloak/realm-export.json`), and stacking Kong on top throttles legitimate PKCE code exchanges during E2E (the same `/auth/v1/token` endpoint handles both password auth and code exchange — Kong can't distinguish). See `plan/fase-6-deploy-prep.md` §6.3 for the full rationale. + +**6.4 JWT rotation + production env template** +- `infra/scripts/rotate-jwt.sh` — bash script signing HS256 `anon` + `service_role` JWTs with a freshly-generated 48-byte secret via `openssl dgst -sha256 -hmac + base64url`. Prints the three values (`SUPABASE_JWT_SECRET`, `PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`) for the operator to paste into `.env`. Does NOT touch files. +- Dev secret rotated as a dry-run of the procedure. `.env` + `apps/web/.env.development` updated together (see `project_env_keys_trap` memory — both files carry `PUBLIC_SUPABASE_ANON_KEY`). Deduped a pre-existing duplicate `PUBLIC_SUPABASE_ANON_KEY=` line in `.env`. +- `.env.production.example` — committed template with placeholders + rotation cadence note. + +**6.5 Lighthouse + RLS audit tooling** +- `Justfile` — `just lighthouse` recipe (prod build → node server on :3000 → `lighthouse --only-categories=pwa,accessibility,best-practices` → HTML report). Requires `just dev` to be up so the real Supabase stack is reachable. +- `Justfile` — `just rls-audit` delegates to `infra/scripts/rls-audit.sh`. +- `infra/scripts/rls-audit.sh` — signs an Eva JWT (non-member of the seed collective) with `SUPABASE_JWT_SECRET` and GETs eight REST endpoints (`collectives`, `shopping_lists`, `shopping_items`, `task_lists`, `tasks`, `notes`, `collective_members`, `collective_invitations`). Complements `rls-audit.test.ts`; all 8 must return `[]`. + +**Gotchas discovered during execution (do not remove):** + +13. **`strip_path: true` on a full-table Kong route strips too much.** With `paths: [/rest/v1/collective_invitations]` and `strip_path: true`, Kong forwards `/` upstream — PostgREST returns `PGRST117: Unsupported HTTP method: POST` because it tries to route root, not the table. Fix: add a `request-transformer` plugin that `replace.uri: /collective_invitations`. Applies identically to any route where the match path equals the full upstream resource path (as opposed to a prefix match like `/rest/v1/`). + +14. **Kong `KONG_PLUGINS` must explicitly list every plugin used in `kong.yml`.** Without `rate-limiting` (or any other non-default plugin) in the comma list, Kong boots with `plugin 'xxx' not enabled; add it to the 'plugins' configuration property`. `infra/docker-compose.dev.yml` currently lists `request-transformer,cors,key-auth,acl,basic-auth,rate-limiting`. + +15. **`@vite-pwa/sveltekit` does not auto-register the service worker.** The plugin provides a virtual module (`virtual:pwa-register`, `virtual:pwa-register/svelte`) but you have to call `registerSW()` (or `useRegisterSW()` for Svelte bindings) yourself. Without that call, the SW file is generated + served but never registered, and `navigator.serviceWorker.getRegistration()` returns `undefined`. The canonical place is `apps/web/src/routes/+layout.svelte`'s `onMount()`. + ### Fase 5 — what has been built (complete in scope) **5.8 big-button create (match /notes)** diff --git a/Justfile b/Justfile index f730a02..fd4fd48 100644 --- a/Justfile +++ b/Justfile @@ -129,6 +129,43 @@ test-e2e: test-e2e-headed: pnpm --filter @colectivo/web exec playwright test --headed +# Run Kong rate-limit verification tests. Restarts Kong first to ensure a +# clean counter state (tests burn through the minute + hour quotas). Not +# part of test-all because the tests share the rate-limit counter with the +# rest of the integration suite. +test-rate-limit: + #!/usr/bin/env bash + set -euo pipefail + {{dc_dev}} up -d --force-recreate kong + sleep 3 + export RUN_RATE_LIMIT_TESTS=1 + pnpm --filter @colectivo/test-utils test -- rate-limit + +# Build a prod bundle, serve it on :3000, run Lighthouse against PWA / +# Accessibility / Best-Practices. Fails if any score drops below 90. +# Must run while `just dev` (docker stack) is up — Lighthouse loads the +# real app which hits Supabase via Kong. +lighthouse: + #!/usr/bin/env bash + set -euo pipefail + pnpm --filter @colectivo/web build + PORT=3000 node apps/web/build/index.js & + SERVER_PID=$! + trap "kill $SERVER_PID 2>/dev/null || true" EXIT + for i in 1 2 3 4 5 6 7 8 9 10; do + if curl -sf http://localhost:3000/ -o /dev/null; then break; fi + sleep 1 + done + pnpm --filter @colectivo/web exec lighthouse http://localhost:3000 \ + --only-categories=pwa,accessibility,best-practices \ + --output=html --output-path=./lighthouse-report.html \ + --chrome-flags='--headless --no-sandbox' \ + --quiet + +# Run the RLS isolation curl audit — complements rls-audit.test.ts. +rls-audit: + infra/scripts/rls-audit.sh + # ── Backup & Restore ────────────────────────────────────────────────────────── # Take a manual backup of a service (e.g. just backup supabase) diff --git a/README.md b/README.md index da350fb..8128f46 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ | Fase 2a — Lista de Compra CRUD | ✅ Completa | | Fase 2b — Realtime y Modo Compra | ✅ Completa (presence bloqueado por bug upstream de `supabase/realtime`) | | Fase 3 — Tareas y Notas | ✅ Completa | -| Fase 4 — Búsqueda y Pulido | 🟡 Búsqueda + RLS audit completos; PWA install/push/rate-limit/JWT rotation diferidos a sprint de deploy | +| Fase 4 — Búsqueda y Pulido | ✅ Búsqueda + RLS audit completos; PWA install/rate-limit/JWT rotation cerrados en Fase 6 | | Fase 5 — Rediseño UX Mobile | ✅ Completa en scope automatizable (shell + masthead + big-button create + row redesign + selection mode + sticky detail chrome); presence avatars en cards + M-swipe/SEL-long-press touch E2E diferidos a WebKit install | -| Fase 6 — Deploy readiness | ⏳ Propuesta (PWA injectManifest + manifest + iconos placeholder + Kong rate-limit + rotación JWT + Lighthouse audit). Push notifications retiradas del scope. | -| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 233 tests verdes (34 pgTAP + 140 integración + 15 unit + 44 E2E), 2 skipped, ejecutables con `just test-all` | +| Fase 6 — Deploy readiness | ✅ Completa (PWA injectManifest + manifest + iconos placeholder + Kong rate-limit en invitaciones + rotación JWT dev + rotate-jwt.sh + `.env.production.example` + rls-audit.sh + `just lighthouse`). Rate-limit auth retirado — Keycloak brute-force nativa cubre el caso. Push notifications y iconos finales fuera de scope. | +| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 236 tests verdes (34 pgTAP + 140 integración + 15 unit + 46 E2E) + 1 rate-limit gated (`just test-rate-limit`). 3 skipped en `just test-all`. | --- diff --git a/apps/web/.env.development b/apps/web/.env.development index 96e7608..8136ab4 100644 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -6,7 +6,7 @@ # authoritative source; this file exists so non-sensitive PUBLIC_* values can be # versioned for new contributors. If you rotate JWT_SECRET, update this file too. PUBLIC_SUPABASE_URL=http://192.168.1.167:8001 -PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 +PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjIwOTE0OTEyOTZ9.fK2is86W1xqCPR525AOU_VukQL4CMxncX55ZBLab-dA PUBLIC_KEYCLOAK_URL=http://192.168.1.167:8080 PUBLIC_KEYCLOAK_REALM=colectivo PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web diff --git a/apps/web/package.json b/apps/web/package.json index fb40b22..9483ccb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,6 +46,7 @@ "typescript": "^5.7.3", "vite": "^6.0.7", "vitest": "^2.1.8", + "workbox-precaching": "^7.4.0", "workbox-window": "^7.3.0" } } diff --git a/apps/web/src/app.html b/apps/web/src/app.html index ddb5575..f8c6ccb 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -5,6 +5,11 @@ + + + + + %sveltekit.head% diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 3628141..5cc5aad 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -10,6 +10,12 @@ import { i18n } from '$lib/i18n'; onMount(() => { + // Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does + // not auto-register — the virtual module is a no-op unless called. + void import('virtual:pwa-register').then(({ registerSW }) => { + registerSW({ immediate: true }); + }); + const supabase = getSupabase(); const { diff --git a/apps/web/src/sw.ts b/apps/web/src/sw.ts new file mode 100644 index 0000000..8b06079 --- /dev/null +++ b/apps/web/src/sw.ts @@ -0,0 +1,21 @@ +/// + +// Fase 6.1: minimal custom SW. Named `sw.ts` (not `service-worker.ts`) because +// SvelteKit intercepts the latter filename and blocks Workbox imports; see +// CLAUDE.md gotcha #10. Strategy: precache the built shell via `__WB_MANIFEST`. +// We deliberately skip runtime caching for Supabase — the $lib/sync pending_ops +// queue already handles offline writes and SyncBanner surfaces offline state. + +import { precacheAndRoute } from 'workbox-precaching'; + +declare const self: ServiceWorkerGlobalScope; + +precacheAndRoute(self.__WB_MANIFEST); + +self.addEventListener('install', () => { + void self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); diff --git a/apps/web/static/icons/README.md b/apps/web/static/icons/README.md new file mode 100644 index 0000000..2ff586a --- /dev/null +++ b/apps/web/static/icons/README.md @@ -0,0 +1,16 @@ +# App icons — placeholders + +These PNGs are **placeholders** generated by +`infra/scripts/generate-placeholder-icons.sh` — a plain "C" glyph on the +Monolith slate background. They pass Lighthouse's installability audit but +are not final art. + +Before public launch replace with brand assets: + +- `icon-192.png` — 192×192, standard manifest icon (Android/Chrome) +- `icon-512.png` — 512×512, high-res manifest icon +- `icon-512-maskable.png` — 512×512, purpose `"maskable"`. Keep meaningful content inside the central 80% safe zone so iOS/Android rounded-mask crops don't cut the glyph. +- `icon-180.png` — 180×180, iOS `apple-touch-icon` + +Keep sizes + filenames identical so `apps/web/vite.config.ts` + the iOS +`` in `src/app.html` keep working. diff --git a/apps/web/static/icons/icon-180.png b/apps/web/static/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..dfbe9099ffe1a7b9cf80ead584abee1317c15036 GIT binary patch literal 3956 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 literal 0 HcmV?d00001 diff --git a/apps/web/static/icons/icon-512.png b/apps/web/static/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..e7131e788e1b4d2cd142ead10de87a6095609c17 GIT binary patch literal 11654 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⋙!vVKo{#jO2cXIvBatgQR-;SS?LIlFTft+q)| z`WbU=O4AP(WkA*K#9rI7AzEN{y3-_%QDJVo%wWcKHUrUvFe&YemmUy43^tw^F5~Hj z?x$x%_(8pibPm%ma*4(m<9@G!S%qDe_Mv`h8{%=>v!!%#FsoNyztIy| zr(PPaa^|T$Hxah`xlhaxqGZu0%bavAx(H=?^075J+LCBXdpMC?3T_!@3Qh>N;J*2j zBaN*kePw4Jw}4W5^eyXE4Nv5`{0h3pijGw?G50ot0F4l@)NPJ*^6^98e`SJOn+oa# z=mVP>>_C(Ozu!t{t9FG?XYI3M6X%9{F-2>}3haMukG_>!22i7QRs3-_utq1&@UKO+ z>=9>BnCtX(=E)Z+vr(j&(Nb?uMAeTjef1)b6x_33%J{G7F9jUjAK_fBr1( zEZ%KvY`L^JI1^Fy<&Xh3PD3Ld=HKM$Rx9zT&&{uUMP9lhxGjpA znS7HaZ#;7=u-^R>dqa}7TZfcSZu0er3AV7aW9QC02vnWfm3_vidc4Mxhsdnh0HfT2Uu0l4+x5<{1(yp4Wn}lUhU!1!4YXEh`m;!b0Rzw2mWr z!qzoIlWKq|wNE=;LmFb#3 z3K{eMV*QBTX%mkyb^UW*qY-D5>+C>VVf##7TSOzEDkz_Rw3k9UZk=G52kWV^8)+?0 z@g=XjGrJ-30sIM62$;=qSAHwX)At;V?x4!6=khxuB2mBFb`GQCMJ%@kxFskKf>4+@ zv3Lz54MihMdtadCsXHj{O@;hjMRbCl#FW8+vMN?L1OyVzJm{t-jr_8N&N6?uSF+S< zRG@n^f!<@}t&y`XKA6}cM7FFBir8vVJ}ChBJ$iBUYu~)%)2nF2NT7Tt0e}yd;0`7j zReH-&PEB@0{hOLmpbg1wzObUkdHEG)%-63w*kk-72e1Iyw~T{ME?6(~z2GtN!B3Yy7zLZ?G4o6o?zQRfW6gk-#5AP0_Y!mBTn*XK-v$lHr;wrUS&k-kTR*3h&J3+%hBm2 zeH+k;iffe!MqtmbWn@m^2Xyr56>PnLGxHhrcRchavdcgdSq{)dCRpRSRKT+nBcw>} z|9*p+seGI$BJZIA9GtcnvEQ}&AiZ@l_D`V6;@|H|JOB&@PTxQ7N(A6b089hX9q2#m z00dY9EI5b`*8F#^dh}01fD3S@|6lW@PG7^nAtzD#_y5CkQm4=HKgiQRI1<{tG>%VS zQ$b2bk}!Z7aqpX^-nI|D?UZah?SLCd_Oh(J)a7eZSFYW=d`0QnHKnVvl9w+lUA|m( zE_dob41l`XI@;M1& literal 0 HcmV?d00001 diff --git a/apps/web/tests/e2e/pwa.test.ts b/apps/web/tests/e2e/pwa.test.ts new file mode 100644 index 0000000..726a26a --- /dev/null +++ b/apps/web/tests/e2e/pwa.test.ts @@ -0,0 +1,55 @@ +/** + * P-series: PWA install prerequisites (Fase 6.1 + 6.2). + * + * Validates that the web manifest is served correctly and a service worker + * registers after first load. Lighthouse ≥ 90 is manual (see `just lighthouse`). + */ +import { test, expect } from '@playwright/test'; +import { USERS } from '../fixtures/users.js'; +import { loginAs } from '../fixtures/login.js'; + +test.describe('PWA install prerequisites', () => { + test('P-01: /manifest.webmanifest is served with a valid shape', async ({ request }) => { + const r = await request.get('/manifest.webmanifest'); + expect(r.status()).toBe(200); + // Either application/manifest+json or application/json is acceptable + // (Vite dev and the @vite-pwa plugin can disagree on the exact value). + expect(r.headers()['content-type'] ?? '').toMatch(/manifest\+json|application\/json/); + + const m = (await r.json()) as { + name?: string; + short_name?: string; + start_url?: string; + display?: string; + icons?: Array<{ sizes?: string; type?: string; src?: string }>; + }; + expect(m.name).toBeTruthy(); + expect(m.short_name).toBeTruthy(); + expect(m.start_url).toBeTruthy(); + expect(m.display).toBe('standalone'); + expect(Array.isArray(m.icons)).toBe(true); + + const sizes = (m.icons ?? []).map((i) => i.sizes); + expect(sizes).toContain('192x192'); + expect(sizes).toContain('512x512'); + }); + + test('P-02: service worker registers after first load', async ({ page }) => { + await loginAs(page, USERS.borja); + await page.goto('/lists'); + + // SW registration is async and driven by an onMount() dynamic import in + // the root layout — give it a few seconds to resolve before asserting. + const reg = await page.waitForFunction( + async () => { + const r = await navigator.serviceWorker.getRegistration(); + return r ? { scope: r.scope } : null; + }, + null, + { timeout: 10_000 } + ); + const state = await reg.jsonValue(); + expect(state).not.toBeNull(); + expect(state?.scope).toMatch(/^https?:\/\//); + }); +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 2ec3f34..9f1d961 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -11,11 +11,14 @@ export default defineConfig({ }), sveltekit(), SvelteKitPWA({ - // strategies defaults to 'generateSW' — the plugin auto-generates the service - // worker. We do NOT use 'injectManifest' here because SvelteKit's own Vite - // plugin intercepts src/service-worker.ts and blocks external imports (Workbox). - // In Fase 2b we will introduce a custom SW as src/sw.ts (a filename SvelteKit - // does not recognise as a service worker) and switch to injectManifest then. + // Fase 6.1: custom SW at src/sw.ts (NOT src/service-worker.ts — SvelteKit + // intercepts that filename and blocks Workbox imports; see gotcha #10 + // in CLAUDE.md). Minimal strategy: precache the built shell, no runtime + // caching of Supabase. The $lib/sync pending_ops queue already handles + // offline writes; SyncBanner surfaces offline state to the user. + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', scope: '/', base: '/', manifest: { @@ -31,10 +34,10 @@ export default defineConfig({ 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.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } + { src: '/icons/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } ] }, - workbox: { + injectManifest: { globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'] }, devOptions: { diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 7305e95..fdbbf29 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -192,7 +192,7 @@ services: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml KONG_DNS_ORDER: LAST,A,CNAME - KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,rate-limiting KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY} diff --git a/infra/kong.yml b/infra/kong.yml index 52d71ee..dc2f5d0 100644 --- a/infra/kong.yml +++ b/infra/kong.yml @@ -8,6 +8,11 @@ services: - name: auth-v1 url: http://auth:9999/ routes: + # /auth/v1/* catch-all. Brute-force protection for password attempts is + # handled by Keycloak's native `bruteForceProtected` flag at the realm + # level (see keycloak/realm-export.json) — Kong-level rate-limiting on + # /auth/v1/token would interfere with legitimate PKCE code exchanges + # during E2E runs, since the same endpoint handles both. - name: auth-v1-all strip_path: true paths: @@ -18,6 +23,31 @@ services: - name: rest-v1 url: http://rest:3000/ routes: + # Invitations POST — rate-limited per-auth-token: 20/hour. + # (Using `limit_by: header` on Authorization gives us per-token quota + # which is effectively per-session; the PKCE access token rotates every + # hour so this doubles as a short-window leak protection.) + - name: rest-v1-invitations + strip_path: true + methods: + - POST + paths: + - /rest/v1/collective_invitations + plugins: + - name: rate-limiting + config: + hour: 20 + policy: local + fault_tolerant: true + limit_by: header + header_name: Authorization + # strip_path: true on the full table path sends "/" upstream, which + # PostgREST rejects with PGRST117 "Unsupported HTTP method". Rewrite + # the URI back to the table path so PostgREST routes it correctly. + - name: request-transformer + config: + replace: + uri: /collective_invitations - name: rest-v1-all strip_path: true paths: diff --git a/infra/scripts/generate-placeholder-icons.sh b/infra/scripts/generate-placeholder-icons.sh new file mode 100755 index 0000000..cae167e --- /dev/null +++ b/infra/scripts/generate-placeholder-icons.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Generate neutral placeholder PNG icons for the PWA manifest (Fase 6.2). +# +# These are placeholders — replace with real art before public launch. The +# glyph is a single uppercase "C" (Colectivo) centred on the Monolith slate +# background (#0f172a). +# +# Dep: ImageMagick (`convert`). + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT="$ROOT/apps/web/static/icons" +mkdir -p "$OUT" + +BG="#0f172a" +FG="#f7f9fb" + +render() { + local size="$1"; local out="$2"; local safe="${3:-$size}" + local font_size=$(( safe * 55 / 100 )) + convert -size "${size}x${size}" "xc:${BG}" \ + -gravity center \ + -pointsize "${font_size}" -fill "${FG}" -font "DejaVu-Sans-Bold" \ + -annotate "+0+0" "C" \ + -quality 90 "$out" + echo " wrote $out" +} + +render 192 "$OUT/icon-192.png" +render 512 "$OUT/icon-512.png" +render 512 "$OUT/icon-512-maskable.png" 410 +render 180 "$OUT/icon-180.png" + +echo +echo "Done. Placeholder icons written to $OUT." +echo "Replace before public launch — see apps/web/static/icons/README.md." diff --git a/infra/scripts/rls-audit.sh b/infra/scripts/rls-audit.sh new file mode 100755 index 0000000..6d07250 --- /dev/null +++ b/infra/scripts/rls-audit.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# RLS isolation audit — manual curl edition. +# +# Mirrors `packages/test-utils/tests/rls-audit.test.ts` (the Vitest version is +# authoritative) but hits the stack via Kong using a real anon key + an +# Eva-signed JWT, which is what a misconfigured RLS policy would actually +# leak data to. Useful as a post-deploy sanity check where Vitest isn't +# available. +# +# Exit non-zero if any endpoint returns rows to Eva (who is NOT a member of +# the seed collective). + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +source .env + +KONG="${PUBLIC_SUPABASE_URL:-http://localhost:8001}" +ANON="$PUBLIC_SUPABASE_ANON_KEY" +SECRET="$SUPABASE_JWT_SECRET" + +EVA_ID='55555555-5555-5555-5555-555555555555' + +sign_eva() { + local header='{"alg":"HS256","typ":"JWT"}' + local exp=$(( $(date +%s) + 3600 )) + local payload="{\"sub\":\"$EVA_ID\",\"role\":\"authenticated\",\"aud\":\"authenticated\",\"iss\":\"supabase-demo\",\"exp\":$exp}" + local b64h b64p + b64h=$(printf '%s' "$header" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=') + b64p=$(printf '%s' "$payload" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=') + local sig + sig=$(printf '%s.%s' "$b64h" "$b64p" \ + | openssl dgst -sha256 -hmac "$SECRET" -binary \ + | openssl base64 -e -A \ + | tr '+/' '-_' | tr -d '=') + printf '%s.%s.%s' "$b64h" "$b64p" "$sig" +} + +TOKEN=$(sign_eva) +HDRS=(-H "apikey: $ANON" -H "Authorization: Bearer $TOKEN") + +check() { + local label="$1"; local url="$2" + local body + body=$(curl -s -m 5 "${HDRS[@]}" "$KONG$url") + if [ "$body" = "[]" ]; then + printf ' ok %-35s %s\n' "$label" "[]" + else + printf ' FAIL %-35s %s\n' "$label" "$body" + return 1 + fi +} + +echo "RLS audit — Eva (non-member) should see [] everywhere:" +fail=0 +check 'collectives' '/rest/v1/collectives?select=id' || fail=1 +check 'shopping_lists' '/rest/v1/shopping_lists?select=id' || fail=1 +check 'shopping_items' '/rest/v1/shopping_items?select=id' || fail=1 +check 'task_lists' '/rest/v1/task_lists?select=id' || fail=1 +check 'tasks' '/rest/v1/tasks?select=id' || fail=1 +check 'notes' '/rest/v1/notes?select=id' || fail=1 +check 'collective_members' '/rest/v1/collective_members?select=user_id&user_id=neq.'"$EVA_ID" || fail=1 +check 'collective_invitations' '/rest/v1/collective_invitations?select=id' || fail=1 + +if [ "$fail" -eq 0 ]; then + echo + echo "RLS audit passed." +else + echo + echo "RLS audit FAILED — one or more tables returned data to a non-member." + exit 1 +fi diff --git a/infra/scripts/rotate-jwt.sh b/infra/scripts/rotate-jwt.sh new file mode 100755 index 0000000..44e9264 --- /dev/null +++ b/infra/scripts/rotate-jwt.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Rotate Supabase JWT secrets + re-sign the anon / service-role keys. +# +# Usage: +# infra/scripts/rotate-jwt.sh # generate a fresh secret +# infra/scripts/rotate-jwt.sh # re-sign with a given secret +# +# Output: three lines printed to stdout. Paste them into .env (dev) or +# .env.production (prod): +# SUPABASE_JWT_SECRET= +# PUBLIC_SUPABASE_ANON_KEY= +# SUPABASE_SERVICE_ROLE_KEY= +# +# After replacing in .env, restart the affected containers: +# docker compose -f infra/docker-compose.dev.yml up -d --force-recreate kong auth rest realtime +# and any existing user sessions signed with the old secret are invalidated — +# everyone re-logs once. Document that in the commit when rotating dev. + +set -euo pipefail + +SECRET="${1:-$(openssl rand -base64 48 | tr -d '\n')}" + +sign_jwt() { + local role="$1" + local header='{"alg":"HS256","typ":"JWT"}' + # Long-lived anon/service keys (10 years) — the guardrail is the secret + # itself rotating, not the token expiry. + local exp=$(( $(date +%s) + 10 * 365 * 24 * 3600 )) + local payload="{\"iss\":\"supabase-demo\",\"role\":\"$role\",\"exp\":$exp}" + + local b64h b64p + b64h=$(printf '%s' "$header" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=') + b64p=$(printf '%s' "$payload" | openssl base64 -e -A | tr '+/' '-_' | tr -d '=') + + local sig + sig=$(printf '%s.%s' "$b64h" "$b64p" \ + | openssl dgst -sha256 -hmac "$SECRET" -binary \ + | openssl base64 -e -A \ + | tr '+/' '-_' | tr -d '=') + printf '%s.%s.%s' "$b64h" "$b64p" "$sig" +} + +ANON=$(sign_jwt "anon") +SERVICE=$(sign_jwt "service_role") + +cat < { + it('RL-02: 21st invitation POST in an hour returns 429', async () => { + const ana = await createClientAs(ANA_ID); + const authHeader = (ana as unknown as { rest: { headers: Record } }).rest + ?.headers?.Authorization; + // Fallback: grab the token manually + const token = authHeader?.replace('Bearer ', '') ?? (await signBorja()); + + const headers = { + 'Content-Type': 'application/json', + apikey: ANON, + Authorization: `Bearer ${token}`, + Prefer: 'return=representation' + }; + + for (let i = 0; i < 20; i++) { + const r = await fetch(`${KONG}/rest/v1/collective_invitations`, { + method: 'POST', + headers, + body: JSON.stringify({ + collective_id: COLLECTIVE_ID, + role: 'member', + created_by: ANA_ID + }) + }); + expect(r.status, `attempt ${i + 1} was 429`).not.toBe(429); + } + + const limited = await fetch(`${KONG}/rest/v1/collective_invitations`, { + method: 'POST', + headers, + body: JSON.stringify({ + collective_id: COLLECTIVE_ID, + role: 'member', + created_by: ANA_ID + }) + }); + expect(limited.status).toBe(429); + }, 60_000); +}); + +// Helper — sign a minimal authenticated JWT for Ana if we need to fall back. +async function signBorja(): Promise { + const { SignJWT } = await import('jose'); + const secret = new TextEncoder().encode(process.env.SUPABASE_JWT_SECRET!); + return new SignJWT({ role: 'authenticated' }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(ANA_ID) + .setAudience('authenticated') + .setIssuedAt() + .setExpirationTime('1h') + .sign(secret); +} diff --git a/plan/fase-6-deploy-prep.md b/plan/fase-6-deploy-prep.md index a5ce7ad..fc6f58e 100644 --- a/plan/fase-6-deploy-prep.md +++ b/plan/fase-6-deploy-prep.md @@ -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 /// 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 @@ -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= … ``` -- [ ] 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`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d653b7..b47e871 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: vitest: specifier: ^2.1.8 version: 2.1.9(@types/node@25.6.0)(@vitest/ui@4.1.4)(jsdom@29.0.2)(terser@5.46.1) + workbox-precaching: + specifier: ^7.4.0 + version: 7.4.0 workbox-window: specifier: ^7.3.0 version: 7.4.0