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:
2026-04-14 04:48:04 +02:00
parent 1f5b34d55d
commit 2db5aaac14
24 changed files with 571 additions and 55 deletions

View File

@@ -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 05 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)**