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>
The dev stack previously mixed two hostnames (`localhost` in some places,
`keycloak` in others). That only worked because the laptop's /etc/hosts
maps `keycloak` to 127.0.0.1. Phones, tablets, or a second laptop had no
such mapping, so the OAuth flow broke the moment you tried to log in from
any non-laptop device.
Switched the SPA, Kong, GoTrue, and Keycloak client to all agree on a
single external host — the laptop's LAN IP (192.168.1.167) — so the full
Keycloak → GoTrue → Supabase → SvelteKit round-trip works from any device
on the same Wi-Fi.
Changes
.env (root, ungitignored): PUBLIC_SUPABASE_URL / PUBLIC_KEYCLOAK_URL /
PUBLIC_APP_URL now use the LAN IP (updated locally; tracked via
apps/web/.env.development below).
apps/web/.env.development: mirror the LAN IP so `$env/static/public`
resolves the same on laptop and phone.
apps/web/vite.config.ts: `server.host: true` — bind Vite to 0.0.0.0.
apps/web/playwright.config.ts: `baseURL: PUBLIC_APP_URL ?? localhost`
so E2E uses the same origin the stack is configured with.
apps/web/tests/fixtures/login.ts:
- wait for the collective button in the sidebar to render before
returning (LAN-IP latency surfaced a pre-existing race in
`handleCreate` where Enter fired before `$currentCollective`
hydrated, silently no-op'ing).
- derive expected app origin from PUBLIC_APP_URL.
apps/web/tests/e2e/items.test.ts: scope D-04 delete assertion to the
`[role="listitem"]` locator — with undoQueue enabled the toast text
"Deleted <name>" also matches `getByText(itemName)` and shadowed the
original check.
infra/docker-compose.dev.yml:
- GOTRUE_EXTERNAL_KEYCLOAK_URL and REDIRECT_URI now read from
PUBLIC_KEYCLOAK_URL / PUBLIC_SUPABASE_URL so they follow the same
host as the rest of the stack.
- NEW: GOTRUE_URI_ALLOW_LIST with `/auth/callback` on both the LAN
IP and localhost. Without an explicit allow-list GoTrue rejected
`redirect_to=.../auth/callback` and fell back to SITE_URL root,
stranding `?code=` at `/` and re-triggering signIn → infinite loop.
keycloak/realm-export.json: `colectivo-web` client gains LAN-IP
redirectUris and webOrigins (alongside the existing localhost
entries). Persisted + live-applied via admin API.
.gitignore: add .claude/ (per-project scheduler runtime state).
Verification
just test-all → 224 passed, 4 skipped:
34 pgTAP
140 Vitest integration + 2 skipped (Realtime presence, upstream bug)
11 Vitest unit
39 Playwright + 2 skipped (mobile-swipe touch, WebKit-only)
Phone sanity check: open http://192.168.1.167:5173 on a LAN device,
log in as a seed user, full RLS-respecting session.
Caveats
LAN IP (192.168.1.167) is DHCP-assigned — if it rotates, rerun the
Keycloak admin API update (realm-export.json needs a new entry) and
update the three PUBLIC_* URLs. Consider a Tailscale magic-DNS name
as a stable replacement for the prod-deploy sprint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a 4-layer test stack covering RLS, triggers, and UI flows for Fases 0–2a,
then restructure every plan file so future fases start with tests and end with
a verification gate.
Test suite
- packages/test-utils: Vitest integration tests signing HS256 JWTs via jose so
each test acts as a specific seed user (createClientAs + createAdminClient)
- supabase/tests: pgTAP for accept_invitation(), item_frequency trigger, and
promote-on-admin-leave; each file self-installs pgtap extension
- apps/web/tests: Playwright E2E with live Keycloak login per test (storageState
caching doesn't rehydrate Supabase's session state reliably)
- just test-all chains the three suites; test-db forwards POSTGRES_PASSWORD
as PGPASSWORD with ON_ERROR_STOP=1 so failures abort the chain
Supabase auth gotcha
- PostgREST queries inside onAuthStateChange deadlock on GoTrue's navigator.locks
auth lock (getAccessToken → getSession → initializePromise waits on the same
lock that's held during event dispatch). Fix is two defenses: a pass-through
lock in $lib/supabase, and a setTimeout(0) defer in root +layout.svelte to
push loadUserCollectives out of the callback's microtask chain. Either alone
is insufficient; both together unblock the Playwright suite.
Env key rotation
- apps/web/.env.development had a stale demo anon key signed with a different
secret than root .env; Vite inlined that into the browser bundle so Kong (which
uses the root .env value) rejected every request with 401. Aligned the two
files and added a memory entry to flag this for the next rotation.
Plan restructure (TDD)
- Every fase now opens with X.0 Tests primero and closes with X.Z Verificación
final. Completed fases (0, 1, 2a) show the pattern retroactively with the
tests that currently cover them; pending fases (2b, 3, 4) list the tests to
write before implementation.
Docs
- CLAUDE.md status line reports 54 + 12 + 15 = 81 green tests, adds gotchas
#11 (auth-lock deadlock) and #12 (no storageState caching)
- README.md adds a TDD methodology section and the test-all command
- .gitignore excludes Playwright's generated reports and auth state
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Automates the full first-time setup in one command (also safe to re-run):
- Checks docker/pnpm; installs just if missing
- Adds 127.0.0.1 keycloak to /etc/hosts (sudo)
- Generates .env with dev JWT tokens computed from the dev secret
- Fixes supabase/migrations permissions if root-owned
- Starts the Docker stack and waits for postgres/kong/keycloak
- Applies SQL migrations with tracking table (won't re-apply)
- Seeds the database (no-op if already seeded)
- Compiles Paraglide i18n output
Also adds `just setup` recipe and fixes the dev anon key in .env.development.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>