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

@@ -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

View File

@@ -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"
}
}

View File

@@ -5,6 +5,11 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0f172a" />
<!-- iOS PWA install hints (Fase 6.2). -->
<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" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -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 {

21
apps/web/src/sw.ts Normal file
View File

@@ -0,0 +1,21 @@
/// <reference lib="webworker" />
// 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());
});

View File

@@ -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
`<link rel="apple-touch-icon">` in `src/app.html` keep working.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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?:\/\//);
});
});

View File

@@ -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: {