Record Fase 1 design decisions before implementation

- JWT strategy: Option B (GoTrue as OIDC proxy, not direct Keycloak JWT)
- Self-registration enabled in Keycloak realm
- Multiple collectives per user with sidebar switcher
- Invitations are link-only (no email/Resend integration)
- Avatar upload uses in-browser crop (cropperjs) at 1:1 ratio
- Removed Edge Functions from plan; invitation logic handled in SvelteKit server actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 14:39:11 +02:00
parent 973f9e413c
commit f197a81a42
3 changed files with 31 additions and 31 deletions

View File

@@ -97,13 +97,17 @@ The central organizing unit is the **Collective** (household group), not the ind
- Trash retains deleted items/notes for 7 days before permanent deletion. - Trash retains deleted items/notes for 7 days before permanent deletion.
- If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion. - If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion.
## Auth Architecture (Keycloak → Supabase) ## Auth Architecture (Keycloak → GoTrue → Supabase)
Auth flows through Keycloak as the OIDC IdP. Supabase validates Keycloak-issued JWTs. Supabase RLS uses `auth.uid()` which reads the `sub` claim from the JWT (must be a UUID v4 — Keycloak default). **Strategy: Option B — GoTrue as OIDC proxy.**
The app authenticates with Keycloak (PKCE), then exchanges the Keycloak token with GoTrue for a Supabase JWT. Supabase RLS uses `auth.uid()` which resolves to the GoTrue user UUID (mapped from Keycloak `sub`). GoTrue maintains `auth.users`.
The Keycloak RS256 public key must be set as `JWT_SECRET` in Supabase. **Rotating Keycloak signing keys requires updating Supabase `JWT_SECRET` simultaneously.** Key decisions:
- **Self-registration enabled** in Keycloak — users can create accounts on the Keycloak login screen.
- **Invitations are link-only** — no email sent. Admin generates a link and shares it manually.
- **Multiple collectives per user** — a user can belong to several collectives and switch between them in the sidebar.
Logout must invalidate both sides: call `keycloak.logout()` (invalidates Keycloak session) AND clear the Supabase client locally. Logout must invalidate both sides: call `keycloak.logout()` (invalidates Keycloak session) AND clear the Supabase client session locally.
## Sync Strategy ## Sync Strategy

View File

@@ -4,7 +4,7 @@
"displayName": "Colectivo", "displayName": "Colectivo",
"enabled": true, "enabled": true,
"sslRequired": "external", "sslRequired": "external",
"registrationAllowed": false, "registrationAllowed": true,
"loginWithEmailAllowed": true, "loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false, "duplicateEmailsAllowed": false,
"resetPasswordAllowed": true, "resetPasswordAllowed": true,

View File

@@ -25,28 +25,24 @@
#### 1.2 Integración Keycloak → Supabase #### 1.2 Integración Keycloak → Supabase
El JWT que emite Keycloak debe ser validable por Supabase RLS. Hay dos estrategias; elegir una antes de implementar: > **✅ 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 al `sub` de Keycloak).
> Esto mantiene `auth.users` de Supabase poblado y permite usar todas las helpers de GoTrue.
**Opción A — JWT de Keycloak directo en Supabase (recomendada para self-hosted):** - [ ] Configurar Keycloak como OIDC Identity Provider en GoTrue (`supabase/config.toml`)
- Configurar `JWT_SECRET` de Supabase con la clave pública de Keycloak (RS256) - [ ] Flujo: app obtiene token de Keycloak → lo intercambia por token de GoTrue → usa token de GoTrue para Supabase
- Supabase valida el token de Keycloak directamente en RLS via `auth.uid()` - [ ] Verificar que `auth.uid()` en las políticas RLS resuelve correctamente
- Sin intermediarios, latencia mínima - [ ] Test de integración: token de Keycloak → exchange con GoTrue → query a Supabase con RLS → respuesta correcta
- Requiere que el claim `sub` de Keycloak sea el UUID que identifica al usuario en Supabase
**Opción B — GoTrue como proxy OIDC:** #### 1.3 Invitaciones (sin email)
- Supabase Auth recibe el token de Keycloak, lo valida y emite su propio JWT
- Más aislamiento, pero un salto extra en cada request
> **Decisión recomendada: Opción A.** Más simple operacionalmente en self-hosted. El `sub` de Keycloak se convierte en el `auth.uid()` de Supabase. > **✅ 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.
- [ ] Configurar `supabase/config.toml` con Keycloak como OIDC externo - [ ] `collective_invitations` tiene `token` único (UUID), `expires_at`, `role`, y `accepted_at`
- [ ] Verificar que `auth.uid()` en las políticas RLS resuelve al `sub` de Keycloak - [ ] Pantalla `/collective/manage` genera el link de invitación y muestra un botón "Copy link"
- [ ] Test de integración: token de Keycloak → query a Supabase con RLS activo → respuesta correcta - [ ] `/invitation/[token]` — el invitado abre el link, se autentica si no lo está, y acepta
- [ ] Lógica de aceptación en un server action de SvelteKit (no Edge Function): validar token + expiración, insertar en `collective_members`, marcar `accepted_at`
#### 1.3 Edge Functions
- [ ] `invite-member`: genera token único, inserta invitación, envía email vía Resend
- [ ] `accept-invitation`: valida token + expiración, une al usuario al colectivo, invalida el token
#### 1.4 Internacionalización (i18n) #### 1.4 Internacionalización (i18n)
@@ -69,24 +65,24 @@ Librería: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, tree-
- Refresco silencioso del token antes de expiración (`updateToken(60)`) - Refresco silencioso del token antes de expiración (`updateToken(60)`)
- [ ] Rutas protegidas: hook `+layout.server.ts` que verifica sesión y redirige si no hay token válido - [ ] Rutas protegidas: hook `+layout.server.ts` que verifica sesión y redirige si no hay token válido
- [ ] Flujo de onboarding post-primer-login: - [ ] Flujo de onboarding post-primer-login:
- Detectar si el usuario no tiene colectivo → redirigir a `/onboarding` - Detectar si el usuario no pertenece a ningún colectivo → redirigir a `/onboarding`
- Opción A: Crear nuevo colectivo (nombre + emoji) - Opción A: Crear nuevo colectivo (nombre + emoji)
- Opción B: Introducir link de invitación - Opción B: Pegar link de invitación recibido
- [ ] Pantalla `/collective/manage` — miembros, roles, botón invitar, expulsar - [ ] 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 en Keycloak) - [ ] Pantalla `/invitation/[token]` — aceptar invitación (el usuario debe estar autenticado)
- [ ] Selector de colectivo activo en la barra de navegación principal - [ ] **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 - [ ] 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: - [ ] Pantalla `/settings` — configuración de usuario:
- Campo display name (input de texto, guardado automático con debounce) - Campo display name (input de texto, guardado automático con debounce)
- Selector de avatar con tres modos: - Selector de avatar con tres modos:
- *Initials* — preview generado en tiempo real desde el display name - *Initials* — preview generado en tiempo real desde el display name
- *Emoji* — grid de ~40 emoji curados, tap para seleccionar - *Emoji* — grid de ~40 emoji curados, tap para seleccionar
- *Upload* — input de fichero; preview antes de subir; subida a Supabase Storage bucket `avatars`; soporte para recortar en proporción 1:1 - *Upload* — input de fichero; **recorte en el navegador en proporción 1:1** (usar `cropperjs` o similar); subida a Supabase Storage bucket `avatars`
- Selector de idioma (English / Español) con efecto inmediato - Selector de idioma (English / Español) con efecto inmediato
- Enlace externo a Keycloak Account Console para cambio de email/contraseña - Enlace externo a Keycloak Account Console para cambio de email/contraseña
- [ ] Componente `Avatar.svelte` — renderiza según `avatar_type`: initials (letra sobre fondo slate), emoji, o `<img>` con fallback a initials si la URL falla - [ ] Componente `Avatar.svelte` — renderiza según `avatar_type`: initials (letra sobre fondo slate), emoji, o `<img>` con fallback a initials si la URL falla
> **Nota sobre registro de nuevos usuarios:** Con Keycloak, el registro lo gestiona Keycloak (su propia pantalla o self-registration habilitado en el realm). No hay pantalla de registro en SvelteKit. Si un usuario llega desde un link de invitación sin cuenta, Keycloak gestiona el registro y luego redirige de vuelta al callback de invitación. > **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.
**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. **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.