Data & Systems
Backend que aguanta
Postgres, Supabase y RLS: dónde vive el dato y quién puede tocarlo. La seguridad como autoridad del servidor, no del cliente. El backbone de XHUB, Espejo y esta escuela.
DS-01 Modelado en Postgres
lección Modelar un esquema en Postgres con tablas, claves, tipos y relaciones que reflejen invariantes reales del dominio y no te obliguen a migraciones dolorosas tres semanas después.
Modelado en Postgres
lecciónModelar un esquema en Postgres con tablas, claves, tipos y relaciones que reflejen invariantes reales del dominio y no te obliguen a migraciones dolorosas tres semanas después.
El modelo de datos es la decisión más cara de revertir de todo el stack: el código se reescribe en una tarde, pero una tabla mal tipada con 50.000 filas en producción te persigue durante meses. En la DB compartida de MOONKEY LAB (profiles, progress, proofs) un tipo flojo o una FK olvidada es un agujero de integridad que ninguna RLS arregla.
LA LECCIÓN
Postgres no es "una hoja de cálculo con SQL". Es un motor relacional con un sistema de tipos serio, y tu primer trabajo como operador de datos es usar ese sistema de tipos para que estados imposibles sean literalmente irrepresentables. Antes de escribir un solo CREATE TABLE, enumera las entidades del dominio y las relaciones entre ellas. En MOONKEY LAB las entidades reales son: un usuario (profiles), su avance por módulo (progress), las pruebas que sube (proofs), el feedback que deja (feedback) y los leads de captación. Cada una es una tabla. Cada relación 1-a-N entre ellas es una foreign key. No empieces por las columnas; empieza por las flechas entre cajas.
El identificador. En Supabase la tabla profiles cuelga de auth.users, así que su clave primaria NO es un id autogenerado nuevo: es `id uuid primary key references auth.users(id) on delete cascade`. Esto es el patrón canónico de Supabase y es deliberado — el uuid del usuario autenticado ES la identidad en toda tu app, y `auth.uid()` (que usarás en cada policy RLS) devuelve exactamente ese uuid. Para tablas hijas como progress o proofs sí usas una clave propia: `id uuid primary key default gen_random_uuid()`, más una columna `user_id uuid not null references auth.users(id) on delete cascade`. Fíjate en `not null` y en `on delete cascade`: si borras un usuario, sus pruebas se van con él; si permitieras user_id nulo, tendrías filas huérfanas que ninguna policy `user_id = auth.uid()` podría ni mostrar ni proteger.
Los tipos importan y son baratos de acertar al principio. Para timestamps usa SIEMPRE `timestamptz` (with time zone), nunca `timestamp` a secas — un proyecto con usuarios en Chiang Mai, Mallorca y Madrid no puede permitirse ambigüedad de zona horaria, y `timestamptz` guarda en UTC y convierte en la frontera. Para dinero, `numeric`, jamás `float` (los flotantes binarios no representan 0.10 exacto). Para un campo con un conjunto cerrado de valores —el rango del operador: monkey, gorilla, eagle— tienes dos opciones honestas: un `enum` de Postgres, o `text` con un `check (role in ('monkey','gorilla','eagle'))`. Prefiero el CHECK sobre el enum en proyectos jóvenes: añadir un valor a un enum requiere `ALTER TYPE` y tiene fricción transaccional, mientras que cambiar un CHECK es un ALTER de tabla ordinario. Para datos semiestructurados —el payload de una proof, metadatos flexibles— usa `jsonb`, no `json`: jsonb es binario, indexable con GIN y deduplica claves.
Defaults y NOT NULL son tu primera línea de validación, antes que cualquier RLS o cualquier check de la app. `created_at timestamptz not null default now()` significa que es imposible insertar una fila sin fecha. `status text not null default 'forming'` significa que el estado nunca es nulo y por tanto tu código nunca necesita ramas `if status is null`. Cada columna que dejas nullable es una rama condicional que pagas para siempre en el código que la lee. La regla: una columna es NOT NULL por defecto, y solo la haces nullable cuando la ausencia de valor es un estado de dominio legítimo y distinto de un valor vacío.
Unicidad e índices. La relación "un usuario tiene exactamente un registro de progreso por módulo" no es un comentario: es `unique (user_id, module_code)`. Esa restricción única además te da el índice que necesitas para el upsert (`insert ... on conflict (user_id, module_code) do update`), que es justo como MOONKEY persiste el avance. Aparte de las claves, crea índices sobre las columnas por las que filtras de verdad: si consultas proofs por `user_id` constantemente, `create index on proofs (user_id)`. Pero no índices por reflejo — cada índice ralentiza los INSERT y ocupa espacio; índexa lo que mides que se consulta, no lo que imaginas que se consultará.
Aplica el esquema como una migración versionada, nunca a mano en el editor SQL. En Supabase eso es `apply_migration` (un archivo .sql con nombre y timestamp), no `execute_sql` suelto. La diferencia es auditoría: dentro de seis meses querrás saber por qué proofs tiene esa columna, y la respuesta está en el historial de migraciones, no en tu memoria. Tras aplicar, corre `generate_typescript_types` para que el tipo de TypeScript del cliente derive del esquema real — así la DB es la fuente única de verdad y el front no puede mentir sobre la forma del dato.
Un ejemplo concreto y completo para proofs, la tabla donde un operador sube la prueba de que completó un entregable: una clave propia uuid, un user_id NOT NULL con FK y cascade, un module_code con CHECK contra la lista de códigos válidos, un payload jsonb, un created_at timestamptz NOT NULL default now(), y un índice por user_id. Ese diseño hace que (a) toda fila pertenezca a un usuario existente, (b) ninguna fila quede huérfana, (c) ningún module_code inventado entre, y (d) la consulta "mis pruebas" sea rápida. Sobre esa base —y solo sobre ella— tiene sentido poner RLS en el módulo siguiente.
EJERCICIO
En el proyecto Supabase moonkey-lab (o una branch de desarrollo), escribe la migración SQL que crea la tabla `proofs` desde cero: id uuid PK con gen_random_uuid(), user_id uuid NOT NULL references auth.users(id) on delete cascade, module_code text NOT NULL con CHECK contra al menos tres códigos reales ('DS-01','DS-02','DS-03'), payload jsonb NOT NULL default '{}', created_at timestamptz NOT NULL default now(), y un índice sobre user_id. Aplícala con apply_migration. Luego intenta a propósito un INSERT con module_code='XX-99' y otro con user_id de un uuid que no existe en auth.users, y comprueba que Postgres rechaza ambos. Finalmente corre generate_typescript_types y verifica que el tipo Proof generado refleja exactamente tus columnas y nulabilidades.
ENTREGABLE
Un archivo de migración `supabase/migrations/<timestamp>_create_proofs.sql` aplicado en una branch, más una captura del error de Postgres ante el INSERT inválido (violación de CHECK y de FK) y el tipo TypeScript `Proof` generado a partir del esquema.
INSIGHT CLAVE
El modelo de datos correcto es el que convierte un bug de la aplicación en un error de la base de datos. Cada CHECK, cada NOT NULL y cada FK es una clase entera de bugs que tu código de aplicación ya no tiene que defender — porque Postgres los rechaza antes de que existan.
ERRORES A EVITAR
- ×Usar `timestamp` en vez de `timestamptz` y descubrir el desfase horario solo cuando un usuario en Tailandia ve fechas equivocadas; en producción ya es tarde.
- ×Dejar columnas nullable "por si acaso": cada nullable innecesario es una rama `if x is null` que arrastras en todo el código que lee la tabla.
- ×Crear la tabla profiles con un id propio en vez de `references auth.users(id)`, rompiendo la cadena entre auth.uid() y la identidad — y dejando la RLS posterior sin un campo contra el que comparar.
- ×Aplicar el esquema con execute_sql a mano en vez de apply_migration: pierdes el historial versionado y nadie podrá reconstruir por qué el esquema es como es.
- ×Sembrar índices por reflejo sobre columnas que nunca filtras: penalizas cada INSERT y ocupas espacio sin acelerar ninguna consulta real.
DS-02 RLS: la autoridad es el servidor
lección Escribir y verificar políticas Row Level Security que hagan que un usuario solo pueda leer y escribir sus propias filas, entendiendo que el gate del cliente es UX y la RLS es la única seguridad real en un sitio estático.
RLS: la autoridad es el servidor
lecciónEscribir y verificar políticas Row Level Security que hagan que un usuario solo pueda leer y escribir sus propias filas, entendiendo que el gate del cliente es UX y la RLS es la única seguridad real en un sitio estático.
MOONKEY LAB y Espejo son sitios estáticos sin handler server-side: no hay un backend donde meter un `if (user.id === row.user_id)`. El cliente lleva la anon key, que es pública por diseño. Si la RLS está mal, cualquiera con las DevTools abiertas lee la tabla profiles entera. La RLS no es una capa más de defensa: es la ÚNICA capa.
LA LECCIÓN
Interioriza el modelo de amenaza antes de tocar una policy. En una SSG con Supabase, el atacante NO usa tu interfaz. Abre la consola del navegador, coge la anon key (que está en el bundle, porque tiene que estar), instancia su propio cliente Supabase y llama `supabase.from('profiles').select('*')`. Tu archivo admin.astro que "oculta" el panel no existe para él. El botón deshabilitado no existe para él. Lo único que se interpone entre su SELECT y todas las filas de todos los usuarios es la Row Level Security de Postgres. Por eso la frase del módulo: el gate del cliente es UX (mejora la experiencia del usuario legítimo), la RLS es la seguridad (detiene al ilegítimo).
RLS se activa por tabla y por defecto es deny-all. `alter table profiles enable row level security` — y en ese instante, sin ninguna policy, NADIE (salvo el rol de servicio y el dueño de la tabla) puede leer ni escribir nada. Esto es lo correcto: empiezas desde cero permisos y abres exactamente lo que necesitas. El error catastrófico es habilitar RLS y olvidar una operación: si pones policies de SELECT pero ninguna de INSERT, los INSERT fallan silenciosamente; peor, si NO habilitas RLS en una tabla nueva, está completamente abierta a la anon key. Por eso DS-06 introduce los advisors: detectan exactamente la tabla sin RLS que se te olvidó.
Una policy tiene dos cláusulas que confunde todo el mundo: USING y WITH CHECK. USING filtra qué filas existentes ve o toca la operación (SELECT, UPDATE, DELETE). WITH CHECK valida qué filas nuevas o modificadas se permiten escribir (INSERT, UPDATE). Para SELECT solo hay USING. Para INSERT solo hay WITH CHECK. Para UPDATE hay AMBAS: USING dice qué filas puedes actualizar, WITH CHECK dice en qué pueden convertirse. Un error clásico es poner USING en un INSERT y que no haga nada, o poner solo USING en un UPDATE y permitir que el usuario mueva su fila a otro user_id. El patrón self-or-admin de MOONKEY: `using (user_id = auth.uid() or is_admin())`.
El patrón canónico self. Para proofs, la policy de inserción real de MOONKEY es `proofs_insert_self`: `create policy proofs_insert_self on proofs for insert to authenticated with check (user_id = auth.uid())`. Léela en voz alta: un usuario autenticado puede insertar una proof solo si el user_id de la fila que inserta es igual a su propia identidad. No puede crear una proof a nombre de otro, porque WITH CHECK rechazaría cualquier fila con user_id ≠ auth.uid(). Fíjate en `to authenticated`: la policy ni siquiera aplica al rol anon, así que un visitante sin sesión no puede insertar nada. Para SELECT, el patrón self-or-admin: `using (id = auth.uid() or is_admin())` en profiles — ves tu fila, o todas si eres admin.
auth.uid() es el corazón del sistema y conviene saber qué es: una función SECURITY DEFINER de Supabase que extrae el `sub` del JWT que el cliente envía en cada petición. El cliente no puede falsificarlo porque el JWT está firmado por Supabase con un secreto que el cliente no tiene; manipularlo invalida la firma y Postgres rechaza la sesión. Por eso `user_id = auth.uid()` es confiable de un modo que `user_id = <valor que mandó el cliente>` jamás lo sería. La autoridad vive en la firma del token, no en la buena fe del front.
Verificación por impersonación — esto separa a un operador serio de uno que "cree" que su RLS funciona. No basta con leer la policy y asentir. En el editor SQL de Supabase puedes simular ser un usuario concreto fijando el rol y el claim: `set local role authenticated; set local request.jwt.claims to '{"sub":"<uuid-del-usuario-A>"}';` y entonces `select * from profiles`. Si ves filas de otros usuarios, tu RLS está rota. El modelo de seguridad de MOONKEY dice explícitamente "verificado por impersonación": un no-admin no puede leer filas ajenas, y eso se PROBÓ, no se asumió. Haz lo mismo: impersona al usuario A, intenta leer la fila del usuario B, y exige que el resultado sea cero filas.
Dos trampas finales. Primera: el rol `service_role` (la clave de servicio, NUNCA en el cliente) bypassa toda RLS — por eso jamás vive en una SSG y solo se usa en edge functions de confianza. Segunda: las policies son permisivas por defecto y se combinan con OR, no con AND. Si tienes dos policies de SELECT sobre la misma tabla, una fila visible para CUALQUIERA de las dos es visible. Esto sorprende: añadir una policy nunca restringe, solo amplía. Para restringir necesitas policies RESTRICTIVE explícitas, o —más simple y lo habitual— una sola policy bien escrita por operación.
EJERCICIO
En una branch de moonkey-lab, sobre la tabla proofs ya creada: habilita RLS, y escribe tres policies — `proofs_select_self` (SELECT, using user_id=auth.uid() or is_admin()), `proofs_insert_self` (INSERT, with check user_id=auth.uid()), y NINGUNA policy de UPDATE/DELETE (deny por defecto). Crea dos usuarios de prueba A y B con sendas proofs. En el editor SQL, impersona a A (set local role authenticated + jwt claims sub=uuid_A) y verifica: (1) un SELECT devuelve solo la proof de A; (2) un INSERT con user_id=uuid_B es rechazado por WITH CHECK; (3) un DELETE de la proof de A es rechazado porque no hay policy. Documenta cada uno de los tres resultados.
ENTREGABLE
Migración con las tres policies sobre proofs, más un log de la sesión de impersonación que demuestra los tres comportamientos: A no ve a B (SELECT), A no puede escribir como B (INSERT con WITH CHECK), y nadie puede borrar (sin policy DELETE).
INSIGHT CLAVE
Una RLS que no has roto a propósito impersonando a otro usuario es una RLS que no sabes si funciona. La confianza en seguridad no viene de leer la policy; viene de haber intentado el abuso y haber visto a Postgres negarlo.
ERRORES A EVITAR
- ×Confundir el gate de admin.astro con seguridad: ocultar el panel en el front no impide que el atacante llame a la tabla directamente con la anon key desde la consola.
- ×Poner USING donde va WITH CHECK (o al revés): un INSERT con solo USING no valida nada y deja entrar filas con user_id ajeno.
- ×Habilitar RLS y olvidar la policy de alguna operación: los INSERT empiezan a fallar en silencio, o peor, una tabla sin RLS queda abierta de par en par a la anon key.
- ×Asumir que la RLS funciona por leerla, en vez de verificarla por impersonación con set role + jwt claims y exigir cero filas ajenas.
- ×Creer que añadir una segunda policy permisiva restringe el acceso: las policies se combinan con OR, así que cada policy nueva solo amplía lo visible, nunca lo reduce.
DS-03 Auth magic-link & sesiones
lección Implementar autenticación por magic-link con Supabase y manejar la sesión del lado del cliente de forma honesta: saber qué garantiza la sesión, qué no, y por qué eso no debilita tu RLS.
Auth magic-link & sesiones
lecciónImplementar autenticación por magic-link con Supabase y manejar la sesión del lado del cliente de forma honesta: saber qué garantiza la sesión, qué no, y por qué eso no debilita tu RLS.
MOONKEY LAB y Espejo usan login sin contraseña: el usuario pone su email, recibe un enlace, hace clic y queda autenticado. Es la mejor UX y elimina toda una clase de vulnerabilidades (no almacenas contraseñas, no hay fugas de hashes). Pero la sesión vive en el navegador, y un operador que no entiende dónde y cómo se guarda el token confundirá conveniencia con seguridad.
LA LECCIÓN
Cómo funciona el magic-link, paso a paso. El cliente llama `supabase.auth.signInWithOtp({ email })`. Supabase genera un token de un solo uso, lo asocia al email y manda un correo con un enlace que apunta a tu app con ese token en el fragmento de URL. El usuario hace clic; tu app, al cargar, detecta el token, lo canjea con Supabase por un par access_token / refresh_token, y a partir de ahí el cliente está autenticado. El access_token es un JWT firmado con caducidad corta (por defecto una hora); el refresh_token es de larga duración y sirve para obtener nuevos access tokens sin que el usuario vuelva a hacer login. Todo esto lo orquesta el SDK; tu trabajo es entender el flujo, no reimplementarlo.
Dónde vive la sesión. Por defecto, el SDK de Supabase en el navegador persiste la sesión en localStorage. Esto tiene una consecuencia de seguridad que debes decir en voz alta: un token en localStorage es legible por cualquier JavaScript que corra en tu página. Eso significa que tu superficie de ataque número uno es el XSS — si un atacante consigue inyectar JS en tu sitio (un script de terceros comprometido, un innerHTML con input sin sanear), puede leer el token y robar la sesión. La defensa no es esconder el token; es no tener XSS: no metas HTML de usuario sin sanear, audita cada dependencia de front, y trata cada `<script>` de terceros como código que verá tus tokens.
La distinción honesta que da nombre al módulo: la sesión es identidad, no autorización. Que el cliente tenga un access_token válido prueba QUIÉN es (auth.uid() devolverá su uuid), pero NO le da permiso para nada por sí mismo. El permiso lo decide la RLS en cada query. Esto es liberador: no tienes que defender tus datos en el front. Aunque un atacante robe una sesión, solo puede hacer lo que la RLS permite a ESE usuario — ver sus propias filas, no las de otros. La sesión robada de un usuario normal no da acceso de admin, porque is_admin() revalida contra la fila de profiles, no contra una claim que el cliente pueda manipular.
Manejo de estado de sesión en la app. El SDK expone `supabase.auth.getSession()` (lee la sesión actual, posiblemente del localStorage) y `supabase.auth.onAuthStateChange((event, session) => ...)` (te notifica de SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED). En una SSG como MOONKEY no tienes server-side rendering del estado de auth, así que la página carga primero en estado "desconocido" y luego, en cliente, resuelves si hay sesión. Diseña para eso: muestra un estado neutro de carga, no parpadees entre "invitado" y "logueado". El gate visual (mostrar /cuenta solo si hay sesión) es UX legítima — recuerda DS-02: es UX, la seguridad sigue en la RLS.
El trigger que cierra el círculo. Cuando un usuario se registra por primera vez vía magic-link, Supabase crea una fila en auth.users. Pero tu app necesita una fila correspondiente en profiles. Eso NO lo hace el cliente (no debe poder elegir su propio role ni founder_badge). Lo hace un trigger SECURITY DEFINER en la base de datos: `handle_new_user`, que se dispara `after insert on auth.users` y crea la fila de profiles con valores por defecto seguros (role='monkey', nunca admin). Así la creación del perfil es autoritativa del servidor: el usuario no puede nacer admin porque el trigger, no el cliente, decide los valores iniciales. Este patrón lo desarrollas a fondo en DS-04.
Logout y expiración, hechos honestamente. `supabase.auth.signOut()` borra los tokens del localStorage y revoca el refresh_token en el servidor. Importante: si solo borras el localStorage a mano sin llamar signOut, el refresh_token sigue válido en el servidor — hazlo siempre por el SDK. Sobre expiración: no prometas "sesión para siempre". El access_token caduca en una hora y el SDK lo refresca con el refresh_token de forma transparente; si el refresh_token se revoca o caduca, el usuario vuelve a login. Comunica esto al usuario con honestidad en lugar de fingir persistencia eterna.
Configura las URLs de redirección en el panel de Supabase (Auth > URL Configuration). El magic-link redirige a una URL que DEBE estar en la allowlist, o Supabase rechaza el canje — esto previene que un atacante haga que el link redirija a un dominio que controle. En MOONKEY, las redirect URLs incluyen el dominio de producción (moonkeylab.pages.dev) y localhost para desarrollo, y nada más. Una allowlist de redirección laxa es un vector de phishing real.
EJERCICIO
En MOONKEY (o un clon local apuntando a una branch de Supabase) implementa el flujo completo: una página de login que llama signInWithOtp con el email del usuario y muestra "revisa tu correo"; el manejo del canje al volver del enlace; y una página /cuenta que usa getSession + onAuthStateChange para mostrar el email del usuario logueado o redirigir a login si no hay sesión. Verifica tres cosas: (1) tras el clic en el magic-link existe una fila en profiles creada por el trigger handle_new_user con role='monkey'; (2) signOut borra la sesión y revoca el refresh token; (3) configura las redirect URLs en el panel y confirma que un redirect a un dominio NO listado es rechazado.
ENTREGABLE
Un flujo de login por magic-link funcionando contra una branch de Supabase, con captura de: la fila profiles autocreada por el trigger (role='monkey'), el estado de sesión leído en /cuenta, y la pantalla de Auth > URL Configuration mostrando la allowlist de redirect URLs.
INSIGHT CLAVE
La sesión prueba quién eres, no qué puedes hacer. Si tu seguridad se rompe cuando alguien roba una sesión, es que estabas confiando en el cliente para autorizar — y la autorización siempre tiene que vivir en la RLS, donde una sesión robada solo abre lo que ESE usuario ya podía ver.
ERRORES A EVITAR
- ×Tratar el token en localStorage como secreto seguro: es legible por cualquier JS de la página, así que tu defensa real es no tener XSS, no esconder el token.
- ×Confundir tener sesión con tener permiso: la sesión da identidad (auth.uid()), pero cada acceso lo sigue decidiendo la RLS — nunca autorices en el front.
- ×Crear la fila de profiles desde el cliente en vez de con el trigger handle_new_user: dejarías que el usuario eligiera su propio role y abriría auto-escalación a admin.
- ×Borrar el localStorage a mano en vez de llamar signOut: el refresh_token sigue vivo en el servidor y la sesión puede reanudarse.
- ×Dejar la allowlist de redirect URLs abierta o con comodines: convierte el magic-link en un vector de phishing que redirige a un dominio del atacante.
DS-04 SECURITY DEFINER, RPC y triggers
lección Escribir funciones SECURITY DEFINER, RPCs y triggers que ejecuten lógica privilegiada de forma controlada, sin abrir agujeros de escalación de privilegios.
SECURITY DEFINER, RPC y triggers
lecciónEscribir funciones SECURITY DEFINER, RPCs y triggers que ejecuten lógica privilegiada de forma controlada, sin abrir agujeros de escalación de privilegios.
Hay operaciones que la RLS por sí sola no puede expresar: comprobar si alguien es admin (la propia comprobación necesita leer profiles, lo que crearía recursión), ascender el rango de un operador revalidando reglas, o impedir que un usuario se ponga a sí mismo el founder_badge. SECURITY DEFINER es la herramienta — y es exactamente donde, mal usada, abres la puerta trasera que toda tu RLS intentaba cerrar.
LA LECCIÓN
Qué significa SECURITY DEFINER. Una función normal en Postgres corre con los permisos de quien la LLAMA (SECURITY INVOKER, el default). Una función SECURITY DEFINER corre con los permisos de quien la CREÓ (típicamente un rol con privilegios, dueño de las tablas). Esto le permite hacer cosas que el llamante no podría hacer directamente — por ejemplo, leer profiles para comprobar un rol, aunque la RLS le negaría al usuario ese SELECT. Es potente y es peligroso: una SECURITY DEFINER es un pequeño trozo de código que corre por encima de la RLS. Cada una es una excepción a tu modelo de seguridad, así que cada una debe ser auditada como tal.
El caso is_admin(). Necesitas saber si el usuario actual es admin para usarlo en policies (`using (... or is_admin())`). Pero si la policy de SELECT de profiles depende de leer profiles para saber el rol, tienes recursión infinita: para leer tu fila necesitas saber si eres admin, para saberlo lees profiles, lo que dispara la policy otra vez. La solución es is_admin() como SECURITY DEFINER: corre con privilegios del dueño, lee profiles SIN pasar por RLS, devuelve un booleano. Crucialmente, NO devuelve datos sensibles — solo true/false sobre el llamante (`select role = 'admin' from profiles where id = auth.uid()`). Una SECURITY DEFINER segura expone la mínima información: una decisión, no un dataset.
Blindar el search_path — esta es la vulnerabilidad clásica y la que los advisors marcan sin piedad. Una SECURITY DEFINER que no fija su search_path es explotable: un atacante crea una tabla o función con el mismo nombre que una que tu función usa, en un esquema que esté antes en el search_path, y tu función privilegiada ejecuta el código del atacante con permisos de dueño. La defensa es obligatoria: `create function is_admin() ... security definer set search_path = '' as $$ ... $$;` (o `set search_path = pg_catalog, public` cualificando explícitamente). Con search_path vacío, referencias toda tabla con su esquema completo: `public.profiles`, no `profiles`. Sin esto, tu función de seguridad ES el agujero.
RPCs: lógica de negocio invocable desde el cliente. Un RPC en Supabase es una función Postgres expuesta vía `supabase.rpc('nombre', args)`. En MOONKEY los RPCs reales son update_operator_rank (asciende el rango revalidando), my_referral_stats (devuelve estadísticas de referidos del usuario) e is_admin. El patrón de oro: el RPC NO confía en los argumentos del cliente para la identidad. update_operator_rank no recibe "qué usuario ascender" como parámetro libre — usa auth.uid() internamente. Si recibiera un user_id como argumento, un atacante llamaría rpc('update_operator_rank', { user_id: 'el de otro' }). La identidad SIEMPRE sale de auth.uid() dentro de la función, nunca de un parámetro que el cliente controle.
El RPC revalida, no obedece. update_operator_rank no es "pon mi rango a X porque lo pido". Revalida las reglas: ¿completó el usuario las pruebas requeridas para ese rango? El comentario del modelo de seguridad de MOONKEY es explícito — "Rangos solo vía update_operator_rank (revalida rol)". El cliente no puede saltar de monkey a admin pidiéndolo; el RPC comprueba las condiciones reales en la DB y solo entonces escribe. Esta es la diferencia entre un RPC que es una API de negocio (verifica invariantes) y uno que es un agujero (escribe lo que le digan). Otorga EXECUTE solo a `authenticated`, nunca a anon: `grant execute on function update_operator_rank to authenticated`.
Triggers: invariantes que se aplican pase lo que pase. Algunos invariantes no pueden depender de que la app los respete. "Un no-admin nunca puede cambiar su propio role ni su founder_badge" es uno: si dependiera de la app, cualquier UPDATE directo vía anon key lo saltaría. La solución de MOONKEY es el trigger guard_privileged_profile_columns, que se dispara `before update on profiles` y, si el llamante no es admin y está intentando cambiar role o founder_badge, lanza una excepción que aborta la transacción. Combinado con handle_new_user (after insert on auth.users, crea profiles con role='monkey'), el resultado es que un usuario NACE como monkey y NO PUEDE auto-promoverse — ni por la app, ni por un UPDATE crudo con la anon key. El trigger es la red bajo la RLS.
La disciplina de auditoría. Cada SECURITY DEFINER que escribas: ¿tiene search_path fijado? ¿devuelve la mínima información posible? ¿deriva la identidad de auth.uid() y no de un parámetro? ¿está su EXECUTE restringido al rol correcto? Tras crear o cambiar cualquiera de estas funciones, corre get_advisors (DS-06): el advisor de seguridad marca SECURITY DEFINER sin search_path y funciones con permisos laxos. Una SECURITY DEFINER es código privilegiado; trátala con la paranoia que merece código que corre por encima de tu propia seguridad.
EJERCICIO
En una branch de moonkey-lab: (1) Escribe is_admin() como SECURITY DEFINER con `set search_path = ''`, que lea public.profiles y devuelva un booleano sobre auth.uid(). (2) Escribe un trigger before-update en profiles que aborte si un no-admin intenta modificar role o founder_badge, e impleméntalo derivando admin de is_admin(). (3) Prueba el abuso: con una sesión de usuario normal (impersonado), intenta `update profiles set role='admin' where id=auth.uid()` y verifica que el trigger lanza excepción. (4) Corre get_advisors(type='security') y confirma que no aparece ningún warning de search_path mutable sobre tus funciones.
ENTREGABLE
Migración con is_admin() (SECURITY DEFINER, search_path fijado) y el trigger guard sobre profiles, más evidencia de: el UPDATE de auto-escalación rechazado por el trigger, y un get_advisors de seguridad limpio (sin warnings de search_path).
INSIGHT CLAVE
SECURITY DEFINER es la única parte de tu sistema que corre por encima de la RLS, así que es el único sitio donde un descuido escala a brecha total. La regla mínima no negociable: search_path fijado, identidad desde auth.uid() nunca desde parámetros, y permisos de EXECUTE restringidos — porque aquí no hay segunda red.
ERRORES A EVITAR
- ×Crear una SECURITY DEFINER sin `set search_path`: deja que un atacante secuestre nombres de tabla/función y ejecute su código con permisos de dueño — los advisors lo marcan por algo.
- ×Pasar el user_id como argumento del RPC en vez de usar auth.uid() dentro: el cliente llamaría el RPC con el id de otro usuario y operaría en su nombre.
- ×Hacer que update_operator_rank obedezca el rango pedido en vez de revalidar las condiciones: convierte el ascenso en auto-escalación a un clic.
- ×Confiar en la app para impedir que un usuario cambie su role: un UPDATE directo con la anon key lo saltaría; el invariante tiene que vivir en un trigger.
- ×Otorgar EXECUTE de los RPCs a anon o a public en vez de solo a authenticated: expones lógica de negocio privilegiada a peticiones sin sesión.
DS-05 Sync local ↔ nube
lección Diseñar una sincronización local↔nube donde el estado que vive en localStorage sube a Postgres sin perder datos ni crear duplicados, resolviendo conflictos de forma determinista.
Sync local ↔ nube
lecciónDiseñar una sincronización local↔nube donde el estado que vive en localStorage sube a Postgres sin perder datos ni crear duplicados, resolviendo conflictos de forma determinista.
Espejo arranca con su estado en localStorage (su seam Store está pensado para pasar de localStorage a Supabase sin reescribir la app) y MOONKEY persiste el progreso del operador que primero existe en el navegador y luego debe subir a la nube cuando el usuario hace login. Si la sync es ingenua, un usuario que avanzó offline y luego se loguea pierde su progreso, o lo duplica, o sobreescribe lo que tenía en otro dispositivo.
LA LECCIÓN
El problema real no es "copiar datos". Es reconciliar dos fuentes de verdad que evolucionaron por separado: el localStorage de este navegador y la fila en Postgres (que pudo cambiar desde otro dispositivo). Antes de escribir código, decide la política de conflicto explícitamente, porque "lo que pase" es la receta de la pérdida de datos. Las opciones honestas: last-write-wins (gana el timestamp más reciente, simple pero puede perder ediciones concurrentes), merge por campo (combinas campo a campo según reglas), o append-only (nunca sobreescribes, solo añades, y derivas el estado). Para progreso de aprendizaje —que es monótono, solo avanza— la mejor política suele ser "el máximo gana": si local dice módulo 3 completado y nube dice módulo 5, el resultado es 5; nunca retrocedes.
El localStorage como capa, no como verdad. El patrón del seam Store de Espejo es la abstracción correcta: tu app no llama a localStorage ni a Supabase directamente, llama a un Store con una interfaz (get, set, list). Hay una implementación LocalStore (localStorage) y una SupabaseStore (Postgres). La app no sabe cuál usa. Esto convierte "pasar a la nube" de una reescritura a un cambio de implementación detrás de la misma interfaz. La sync, entonces, es una operación entre dos Stores: leer todo de LocalStore, reconciliar con SupabaseStore, escribir el resultado en ambos. Construye el seam ANTES de necesitar la nube; es barato al principio y carísimo de retrofitear.
Idempotencia es la propiedad que te salva. La sync se va a interrumpir: el usuario cierra la pestaña a mitad, la red cae, el SDK reintenta. Si tu sync no es idempotente, una segunda ejecución crea duplicados. La herramienta es el upsert con clave natural: `insert into progress (user_id, module_code, completed_at) values (...) on conflict (user_id, module_code) do update set completed_at = greatest(progress.completed_at, excluded.completed_at)`. Esa unique (user_id, module_code) de DS-01 es justo lo que hace el upsert posible. Ejecuta la sync dos veces seguidas: si el estado final es idéntico, es idempotente. Si la segunda vez duplica filas o cambia algo, tienes un bug que en producción se manifiesta como datos corruptos a medianoche.
El momento crítico: el primer login tras trabajar como invitado. El usuario avanzó en localStorage sin sesión, luego hace magic-link (DS-03) y obtiene un auth.uid(). Ahora hay que adoptar el estado anónimo bajo su identidad. El flujo seguro: al disparar onAuthStateChange con SIGNED_IN, lees el progreso de LocalStore, lo subes con upsert ligando user_id = auth.uid(), y solo entonces marcas el local como sincronizado. NO borres el local hasta confirmar que la nube lo recibió (la confirmación es la respuesta sin error del upsert). Si borras antes y la subida falla, perdiste el dato. Orden: subir, confirmar, marcar sincronizado, opcionalmente limpiar.
La RLS sigue mandando durante la sync. Cuando subes el progreso con la sesión del usuario, el upsert va con su JWT, así que la policy `with check (user_id = auth.uid())` aplica: no puedes subir progreso a nombre de otro, aunque el localStorage dijera otra cosa. Esto es bueno — la sync no es una puerta trasera a la seguridad. Significa que debes ligar user_id a auth.uid() en el momento de subir, no usar un user_id que arrastrabas del estado anónimo (que no tenía identidad real). La sync respeta el modelo: el servidor sigue siendo la autoridad sobre de quién es cada fila.
Conflictos entre dispositivos, el caso que la gente olvida. Usuario avanza en el móvil (sube a nube), luego abre el portátil que tenía estado local viejo. Sin cuidado, el portátil sobreescribe la nube con datos atrasados. La defensa: la reconciliación NO es "local pisa nube", es "reconciliar ambos según la política". Para progreso monótono, traes la nube, haces el merge "máximo gana" con el local, y escribes el resultado a ambos. Así el portátil aprende lo que hizo el móvil en vez de borrarlo. Para datos no monótonos necesitas timestamps por campo (updated_at) y last-write-wins por campo, lo que exige guardar esos timestamps desde el principio — otra razón para los timestamptz de DS-01.
Estados de la sync, visibles y honestos. Modela explícitamente: synced (local == nube), pending (hay cambios locales sin subir), syncing (en curso), error (falló, reintentar). No mientas al usuario con un check verde si la subida falló. Un indicador honesto de "cambios sin guardar" evita que el usuario cierre la pestaña creyendo que estaba a salvo. La sync silenciosa que falla en silencio es peor que no tener sync: el usuario confía y pierde datos sin saberlo.
EJERCICIO
Sobre la tabla progress (user_id, module_code, completed_at, con unique(user_id, module_code)): implementa un seam Store con dos backends, LocalStore (localStorage) y SupabaseStore. Escribe una función sync() que: lea el progreso local, lo reconcilie con el de la nube usando 'máximo completed_at gana' vía upsert con `on conflict do update set completed_at = greatest(...)`, ligando user_id = auth.uid(). Prueba tres escenarios: (1) idempotencia — corre sync() dos veces y verifica estado final idéntico, cero duplicados; (2) primer login — avanza como invitado, loguéate, y confirma que el progreso anónimo aparece bajo tu user_id en Postgres; (3) dos dispositivos — simula nube con módulo 5 y local con módulo 3, corre sync, y verifica que el resultado es 5 en ambos lados (no retrocede).
ENTREGABLE
Un módulo Store con interfaz común y dos implementaciones (LocalStore/SupabaseStore) más una función sync() idempotente basada en upsert, con un log de los tres escenarios: doble ejecución sin duplicados, adopción del estado anónimo al login, y reconciliación máximo-gana entre dos dispositivos sin pérdida.
INSIGHT CLAVE
La sync no es copiar datos, es reconciliar dos fuentes de verdad que divergieron — y la única forma de no perder nada es elegir la política de conflicto explícitamente y hacer la operación idempotente con un upsert sobre clave natural. Si no puedes correr tu sync dos veces seguidas con el mismo resultado, no tienes sync, tienes una bomba de tiempo.
ERRORES A EVITAR
- ×Borrar el localStorage antes de confirmar que la subida a la nube tuvo éxito: si el upsert falla, el dato se pierde para siempre.
- ×Sync no idempotente sin upsert sobre clave natural: una ejecución interrumpida y reintentada duplica filas que aparecen como corrupción a deshoras.
- ×Dejar que el dispositivo con estado viejo pise la nube ('local gana') en vez de reconciliar: el portátil borra lo que el móvil avanzó.
- ×Arrastrar el user_id del estado anónimo en vez de ligarlo a auth.uid() al subir: la policy WITH CHECK lo rechazará, o peor, intentarás escribir bajo una identidad que no es la real.
- ×Mostrar un check verde de 'sincronizado' cuando la subida falló: el usuario confía, cierra la pestaña y pierde el trabajo sin saberlo.
DS-06 Advisors, migraciones y auditoría
lección Operar la base de datos con cambios versionados mediante migraciones y usar los advisors de Supabase como un linter de seguridad continuo que te avisa de tablas sin RLS, funciones sin search_path y otros agujeros antes de que lleguen a producción.
Advisors, migraciones y auditoría
lecciónOperar la base de datos con cambios versionados mediante migraciones y usar los advisors de Supabase como un linter de seguridad continuo que te avisa de tablas sin RLS, funciones sin search_path y otros agujeros antes de que lleguen a producción.
MOONKEY comparte la base de datos con XHUB IRON: un cambio descuidado puede pisar tablas de otro proyecto o dejar una nueva tabla sin RLS abierta a la anon key. Sin migraciones versionadas no hay forma de saber qué cambió ni de revertirlo; sin los advisors, descubres el agujero de seguridad cuando alguien ya lo explotó. Esta es la disciplina que mantiene honesto todo lo anterior.
LA LECCIÓN
Migraciones: la base de datos como código versionado. Cada cambio de esquema —una tabla, una columna, una policy, una función— es un archivo .sql con timestamp en supabase/migrations/, aplicado con apply_migration, nunca con execute_sql a mano. La diferencia es la misma que entre commitear y editar archivos en producción por SSH: una te da historial, revisión y rollback; la otra te da amnesia. El nombre del archivo (`<timestamp>_create_proofs.sql`, `<timestamp>_add_proofs_rls.sql`) cuenta la historia del esquema. Cuando dentro de seis meses te preguntes por qué una columna existe, la respuesta está en la migración que la introdujo, con su nombre y su fecha — no en tu memoria ni en la de nadie.
execute_sql es para LEER, apply_migration es para CAMBIAR. Esta regla operativa evita el error más común. Usa execute_sql para inspeccionar (select, explain, comprobar el estado), para impersonar y verificar RLS (DS-02), para exploración. En el momento en que el SQL altera el esquema o las policies de forma que quieres que persista, va en una migración. Un cambio de seguridad aplicado con execute_sql que funciona pero no está versionado es deuda: nadie sabe que existe, nadie puede revisarlo, y al recrear el proyecto desaparece.
Branches de Supabase para no romper producción. Antes de aplicar una migración con impacto, créala en una branch (create_branch), pruébala allí —incluida la verificación por impersonación de DS-02 y el get_advisors de DS-04— y solo entonces hazle merge a producción (merge_branch). La branch tiene su propia base de datos efímera; rompes lo que quieras sin tocar a usuarios reales. Esto es especialmente crítico en MOONKEY porque la DB es compartida: una migración que toca por error una tabla iron_* o world_* de XHUB se prueba y se descarta en la branch, no en la base viva que da servicio a dos proyectos.
Los advisors son tu linter de seguridad. get_advisors(type='security') corre un conjunto de comprobaciones que detectan exactamente los agujeros que estos módulos enseñan a evitar: tablas con RLS deshabilitada (DS-02), funciones SECURITY DEFINER con search_path mutable (DS-04), policies que exponen datos, columnas sin protección. get_advisors(type='performance') marca lo otro: foreign keys sin índice, índices duplicados, queries sin cubrir. La disciplina no negociable del CLAUDE.md de MOONKEY: 'Cambios de RLS/seguridad: aplicar como migración versionada + get_advisors después'. Cada vez que tocas seguridad, el advisor es el cierre del bucle — no asumes que está bien, lo verificas con la herramienta.
Cómo leer un advisor y actuar. Un warning del advisor no es ruido para silenciar; es una vulnerabilidad concreta con remedio concreto. 'RLS disabled on public.proofs' significa que cualquiera con la anon key lee la tabla — el remedio es enable row level security + policies, en una migración. 'Function public.is_admin has a role mutable search_path' significa que la función es secuestrable — el remedio es `alter function ... set search_path = ''`, en una migración. La rutina madura: tocas algo → migración → get_advisors → si hay warning, otra migración que lo cierra → get_advisors limpio. No hay 'lo arreglo luego' en advisors de seguridad; luego es después de la brecha.
Aislamiento en la DB compartida, el riesgo específico de MOONKEY. La base aloja tablas de MOONKEY (profiles, progress, feedback, leads, proofs) y de XHUB IRON (iron_*, world_*, focus_*, daily_focus_history). Tu disciplina de migraciones debe respetar esa frontera: una migración de MOONKEY NUNCA debe alterar, ni por descuido de un DROP o un ALTER demasiado amplio, una tabla del otro proyecto. Antes de aplicar, lee el diff de la migración como un adversario: ¿toca solo las tablas que dije? El advisor y la revisión del SQL son las dos redes. En una DB compartida, un cambio mal acotado no es un bug tuyo, es un incidente de otro proyecto.
Auditoría como hábito, no como evento. La auditoría no es una cosa que haces antes de un launch; es el estado por defecto de operar datos en serio. list_migrations te da el historial completo de cómo llegó el esquema a donde está. get_logs te muestra qué está fallando en tiempo real. get_advisors es el chequeo de salud que corres tras cada cambio y periódicamente aunque no cambies nada (porque Supabase añade nuevas comprobaciones y porque el contexto cambia). El operador que trata la base de datos como un sistema vivo que se audita continuamente es el que no tiene la llamada de las 3am — porque vio el warning en la branch, una semana antes, con get_advisors.
EJERCICIO
Toma la migración de RLS de proofs que escribiste en DS-02 pero esta vez con disciplina completa: (1) crea una branch de moonkey-lab; (2) aplica en ella, como migraciones versionadas separadas y con nombres descriptivos, la creación de la tabla y sus policies; (3) corre get_advisors(type='security') ANTES de las policies y confirma que aparece el warning 'RLS disabled' sobre proofs; (4) aplica las policies y vuelve a correr get_advisors, confirmando que el warning desaparece; (5) introduce a propósito una función SECURITY DEFINER sin search_path, comprueba que el advisor la marca, arréglala con set search_path='' en otra migración, y confirma advisor limpio; (6) haz merge_branch a producción solo con el advisor en verde. Documenta la lista de migraciones final con list_migrations.
ENTREGABLE
Una branch con migraciones versionadas y nombradas para tabla+policies+función, más una secuencia de salidas de get_advisors que demuestra el ciclo cerrar-warning: 'RLS disabled' presente → ausente tras policies, 'mutable search_path' presente → ausente tras el fix, y un get_advisors final limpio antes del merge a producción.
INSIGHT CLAVE
Los advisors convierten tu modelo de seguridad de algo que crees haber hecho bien en algo que la herramienta confirma que está bien. La regla del operador serio: ningún cambio de seguridad se da por terminado hasta que get_advisors está limpio — porque el coste de un warning ignorado no es un warning, es una brecha que descubres por las malas.
ERRORES A EVITAR
- ×Aplicar cambios de esquema o policies con execute_sql en vez de apply_migration: pierdes historial, revisión y rollback, y el cambio desaparece al recrear el proyecto.
- ×Tocar la base de producción compartida directamente en vez de probar en una branch: un ALTER o DROP demasiado amplio se convierte en un incidente para XHUB IRON.
- ×Saltarse get_advisors tras un cambio de seguridad: dejas viva justo la tabla sin RLS o la función sin search_path que el advisor habría marcado en segundos.
- ×Tratar un warning del advisor como ruido a silenciar en vez de una vulnerabilidad con remedio concreto: 'lo arreglo luego' en seguridad es 'lo arreglo después de la brecha'.
- ×No leer el diff de la migración como adversario antes de aplicar en una DB compartida: un cambio mal acotado pisa tablas iron_*/world_* de otro proyecto sin que te enteres.
Siguiente constelación
Builders
Enviar producto