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>
7.2 KiB
Fase 1 — Autenticación y Colectivo ✅
Estado: Completa (pendiente: detección automática de idioma desde Accept-Language)
Duración estimada: 2–3 semanas
Objetivo: la base del dominio. Sin esto no existe nada más.
1.0 Tests — escritos y verdes ✅
A-series (auth), B-series (colectivo), F-series (aislamiento) están cubiertas por:
- Vitest integration (
packages/test-utils/tests/rls-{collective,isolation}.test.ts) — lectura por rol, invitación,accept_invitation(), aislamiento no-miembro - pgTAP (
supabase/tests/001_accept_invitation.sql,003_promote_on_admin_leave.sql) — happy path y casos de error deaccept_invitation, promoción automática al último admin - Playwright E2E (
apps/web/tests/e2e/auth.test.ts) — redirect a Keycloak, login full OAuth, ver colectivo en sidebar, sign-out, login como guest
1.1 Base de datos
- Migración: tabla
users— espejo deauth.usersde Supabase, poblada por trigger al primer login OIDC:display_name,emaillanguage: enum(en | es)— defaulten, sobrescrito por preferencia de navegador en primer loginavatar_type: enum(initials | emoji | upload)— defaultinitialsavatar_emoji: text | nullavatar_url: text | null— URL firmada de Supabase Storage
- Migración: tabla
collectivescon camposid,name,emoji,created_by,created_at - Migración: tabla
collective_membersconrole: enum(admin | member | guest) - Migración: tabla
collective_invitationscon token, expiración y estado - RLS completo — políticas críticas:
- Un usuario solo puede leer colectivos a los que pertenece
- Solo administradores pueden insertar en
collective_invitations - Solo administradores pueden actualizar roles en
collective_members - Un usuario solo puede actualizar su propia fila en
users
- Trigger:
on_auth_user_created— sincroniza el perfil de Keycloak (display_name, email) ausersen cada login - Trigger: promoción automática al miembro más antiguo si el último admin elimina su cuenta (RN-10)
- Función SQL:
is_active_member(collective_id, user_id)— usada en todas las políticas RLS - Bucket Supabase Storage:
avatars— privado, acceso vía signed URLs, un fichero por usuario ({user_id}/avatar)
1.2 Integración Keycloak → Supabase
✅ DECISIÓN: Opción B — GoTrue como proxy OIDC. GoTrue recibe el token de Keycloak, lo valida y emite su propio JWT de Supabase.
auth.uid()resuelve al UUID de GoTrue (que mapea alsubde Keycloak). Esto mantieneauth.usersde Supabase poblado y permite usar todas las helpers de GoTrue.
- Configurar Keycloak como OIDC Identity Provider en GoTrue (
infra/docker-compose.dev.yml—GOTRUE_EXTERNAL_KEYCLOAK_*) - Flujo:
signInWithOAuth({ provider: 'keycloak' })→ Keycloak PKCE → GoTrue callback → Supabase session - Verificar que
auth.uid()en las políticas RLS resuelve correctamente — ✅ curl acceptance test: los 5 usuarios autenticados vía Keycloak→GoTrue tienensubcorrecto y las políticas RLS devuelven solo sus datos - Test de integración: token de Keycloak → exchange con GoTrue → query a Supabase con RLS → respuesta correcta — ✅ todos los 5 usuarios pasan
1.3 Invitaciones (sin email)
✅ DECISIÓN: sin email. Las invitaciones se crean como un link que el admin copia manualmente y comparte por cualquier canal (WhatsApp, etc.). No se integra Resend ni se envían emails.
collective_invitationstienetokenúnico (UUID),expires_at,role, yaccepted_at- Pantalla
/collective/managegenera el link de invitación y muestra un botón "Copy link" /invitation/[token]— el invitado abre el link, se autentica si no lo está, y acepta- Lógica de aceptación via función SQL
accept_invitation(p_token)SECURITY DEFINER (no Edge Function)
1.4 Internacionalización (i18n)
Librería: Paraglide JS (@inlang/paraglide-sveltekit). Compile-time, tree-shaken por idioma, sin overhead en runtime.
- Instalar y configurar
@inlang/paraglide-sveltekitcon el plugin de Vite - Crear ficheros de mensajes:
messages/en.jsonymessages/es.json - Configurar detección de idioma:
Accept-Languageheader en primer load → guardado enusers.languagetras login - Cambio de idioma en runtime: actualizar
users.languageen Supabase + llamar asetLanguageTag()de Paraglide — sin recarga de página - Traducir todas las cadenas UI de la Fase 1 (onboarding, gestión de colectivo, settings)
Las cadenas de UI van en
messages/. Nunca hardcodear texto visible al usuario directamente en componentes.
1.5 Frontend
- Integración de Supabase OAuth en SvelteKit (PKCE, no keycloak-js):
supabase.auth.signInWithOAuth({ provider: 'keycloak' })— compatible con Safari sin iframes- Store Svelte
auth— exponecurrentUser,authLoading,isAuthenticated,displayName login()redirige a Keycloak vía GoTrue; callback en/auth/callbackintercambia el code- Refresco automático de sesión por el cliente de Supabase (localStorage)
- Rutas protegidas:
(app)/+layout.tsconssr: falseque redirige si no hay sesión - Flujo de onboarding post-primer-login:
- Detectar si el usuario no pertenece a ningún colectivo → redirigir a
/onboarding - Opción A: Crear nuevo colectivo (nombre + emoji)
- Opción B: Pegar link de invitación recibido
- Detectar si el usuario no pertenece a ningún colectivo → redirigir a
- Pantalla
/collective/manage— miembros, roles, botón "Generate invite link" (copia al clipboard), expulsar - Pantalla
/invitation/[token]— aceptar invitación (el usuario debe estar autenticado) - Selector de colectivo activo en la barra lateral — un usuario puede pertenecer a varios colectivos y cambiar entre ellos
- Store Svelte
collective— colectivo activo, lista de miembros, rol del usuario actual, lista de todos los colectivos del usuario - Pantalla
/settings— configuración de usuario:- Campo display name (input de texto, guardado automático con debounce)
- Selector de avatar con tres modos:
- Initials — preview generado en tiempo real desde el display name
- Emoji — grid de 32 emoji curados, tap para seleccionar
- Upload — input de fichero; recorte en el navegador 1:1 con
cropperjs; subida a Supabase Storage bucketavatars
- Selector de idioma (English / Español) con efecto inmediato
- Enlace externo a Keycloak Account Console para cambio de email/contraseña
- Componente
Avatar.svelte— renderiza segúnavatar_type: initials (color determinista por hash del nombre), emoji, o<img>con fallback a initials
Auto-registro: Self-registration está habilitado en Keycloak. Un usuario sin cuenta que recibe un link de invitación puede registrarse en la pantalla de Keycloak y volver al callback de invitación automáticamente.
1.Z Verificación final ✅
just test-all— 0 failures (los 5 usuarios hacen login, A/B/F-series verdes)
Criterio de aceptación: los 5 usuarios de prueba pueden autenticarse contra Keycloak, llegar a la app con sesión activa, y auth.uid() en Supabase resuelve correctamente para las políticas RLS.