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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
21
apps/web/src/sw.ts
Normal 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());
|
||||
});
|
||||
16
apps/web/static/icons/README.md
Normal file
16
apps/web/static/icons/README.md
Normal 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.
|
||||
BIN
apps/web/static/icons/icon-180.png
Normal file
BIN
apps/web/static/icons/icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/web/static/icons/icon-192.png
Normal file
BIN
apps/web/static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
apps/web/static/icons/icon-512-maskable.png
Normal file
BIN
apps/web/static/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
BIN
apps/web/static/icons/icon-512.png
Normal file
BIN
apps/web/static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
55
apps/web/tests/e2e/pwa.test.ts
Normal file
55
apps/web/tests/e2e/pwa.test.ts
Normal 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?:\/\//);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user