La galaxia
ÓRBITA II · CONSTELACIÓN

Ops & Security

La disciplina

La diferencia entre un proyecto y un incidente. Secretos, modelo de amenaza, auditoría adversarial y aislamiento entre proyectos. Disciplina que protege toda la galaxia.

Alimenta Todos los proyectos
6 módulos · 6 lecciones
MÓDULOS DE LA CONSTELACIÓN Abre cada módulo para la clase completa
OS-01

Secretos: público vs privado

lección

Decidir, para cualquier clave o token de tus proyectos, si puede vivir en el cliente o jamás debe salir del servidor, y construir el árbol de decisión que te impide volver a equivocarte.

Una sola service_role key filtrada en un bundle de Astro es acceso total a tu base de datos saltándose RLS. No es un bug que se parchea: es una brecha que obliga a rotar credenciales, auditar accesos y, si hay datos de usuarios, notificar. El coste de equivocarte aquí es el más alto de toda la galaxia.

LA LECCIÓN

Empieza por la regla mental que nunca falla: todo lo que compila en un sitio estático es público. MOONKEY LAB es Astro 4 en modo SSG — el build genera HTML+JS plano que se sirve desde Cloudflare Pages. No hay servidor que guarde secretos en runtime. Cualquier string que importes en un componente .astro que llegue al cliente acaba, literalmente, en un archivo que cualquiera descarga con `view-source` o abriendo DevTools → Sources. Por eso `src/lib/supabase.ts` SOLO contiene la anon key. La anon key está DISEÑADA para ser pública: es un identificador de proyecto + un token con permisos de rol `anon`, cuya autoridad real la limita Postgres RLS. No es un secreto; es una dirección con un portero.

Ahora el contraste que tienes que interiorizar. Hay dos claves Supabase con el mismo aspecto (un JWT largo) pero universos opuestos de poder. La `anon` key opera bajo el rol `anon`/`authenticated` y está SOMETIDA a RLS: ve solo lo que las policies permiten. La `service_role` key opera con `BYPASSRLS`: ignora todas las policies, lee y escribe cualquier fila de cualquier tabla — incluidas las de XHUB IRON (`iron_*`, `world_*`) que comparten la misma instancia `wuchsslgbqlhyxljsmxi`. Si la `service_role` aparece en el cliente, un atacante no solo compromete MOONKEY: pivota a todos los proyectos que viven en esa DB. Regla dura: la `service_role` NUNCA entra en un repo de frontend, ni en un `.astro`, ni en un `import.meta.env.PUBLIC_*`. Su sitio es un backend con sesión (una Edge Function, un proceso server) o tu llavero local — nunca el bundle.

El vector de fuga más común no es pegar la clave en el código: es el prefijo de variable de entorno. En Astro, `import.meta.env.PUBLIC_X` se INLINEA en el bundle del cliente en build-time; `import.meta.env.X` (sin `PUBLIC_`) solo existe en contexto server/build y NO se expone. Vite/Astro hacen esto por diseño. El error clásico: nombrar `PUBLIC_SUPABASE_SERVICE_KEY` 'para que el componente la vea'. Lo logras — y se la regalas al mundo. Tu árbol de decisión para nombrar una env var: ¿esta clave puede ser pública? Sí → `PUBLIC_`. No → sin prefijo, y además pregúntate qué hace en un proyecto SSG (probablemente no debería estar ahí).

Construye el inventario. Abre tu proyecto y clasifica cada credencial en una tabla de tres columnas: nombre · clase (pública/privada) · dónde vive hoy. En MOONKEY las entradas reales son: `PUBLIC_SUPABASE_URL` (pública, identificador), `PUBLIC_SUPABASE_ANON_KEY` (pública, limitada por RLS), y la `service_role` (privada — NO debe existir en este repo en absoluto; si la necesitas para un script de migración, vive en tu shell local o en un secret de CI, jamás en `src/`). Añade cualquier token de terceros que toques: claves de email transaccional, webhooks, tokens de analytics server-side. Cada fila privada que esté en el lado equivocado es un incidente esperando a pasar.

El control que cierra el ciclo es la detección automática, porque la disciplina humana falla. Antes de cada commit quieres que algo grite si una clave privada se cuela. El patrón mínimo: un grep en pre-commit que busque el patrón de una `service_role` (los JWT de Supabase service llevan `"role":"service_role"` en el payload base64) y palabras como `service_role`, `BEGIN PRIVATE KEY`, `sk-`. Para los repos reales usa `gitleaks` o `trufflehog` como hook. Y si una clave YA se filtró en un commit pasado: no basta con borrarla del código — sigue en el historial de git. Hay que ROTARLA en el panel de Supabase (Settings → API → roll key) y reescribir historial si era un secreto duro. Rotar es obligatorio; borrar el archivo no revoca nada.

Cierra con el caso Espejo, que añade un eje ético, no solo técnico. Espejo es multi-tenant: cada influencer-tenant tiene sus datos. Ahí la pregunta 'pública vs privada' se duplica: además de claves de infraestructura, manejas datos personales/consentidos de usuarios finales. La clave de servicio que pudiera leer ACROSS tenants es el secreto más sensible del producto — su fuga no es solo técnica, viola la frontera de datos que la memoria de proyecto marca como innegociable. Lección transversal: una credencial privada no se define por su formato, se define por lo que desbloquea. Mídela siempre por su blast radius.

EJERCICIO

En tu proyecto (usa MOONKEY o uno propio): 1) Crea `SECRETS.md` con la tabla de inventario (nombre · clase · ubicación · blast radius) para cada credencial que el proyecto toca. 2) Ejecuta `grep -rn "service_role\|BEGIN.*PRIVATE KEY\|sk-" src/ .env* 2>/dev/null` y documenta qué encontró (idealmente nada en `src/`). 3) Escribe un hook `.git/hooks/pre-commit` que haga ese grep sobre los archivos staged (`git diff --cached --name-only`) y aborte con exit 1 si hay match. 4) Verifica el hook intentando commitear un archivo de prueba con la string `"role":"service_role"` y confirma que lo bloquea.

ENTREGABLE

`SECRETS.md` con la tabla de inventario completa (cada credencial clasificada por clase y blast radius) + un hook `.git/hooks/pre-commit` ejecutable que bloquea commits con secretos privados, probado contra un caso que debe fallar.

INSIGHT CLAVE

Una credencial no es secreta por cómo se ve, sino por lo que desbloquea: la anon key y la service_role son ambas JWTs idénticos a la vista, y una es pública por diseño mientras la otra compromete toda la galaxia. Clasifica siempre por blast radius, nunca por formato.

ERRORES A EVITAR

  • ×Poner el prefijo `PUBLIC_` a una clave privada 'para que el componente la lea' — Astro la inlinea en el bundle y la publica al mundo.
  • ×Creer que borrar la clave del archivo la revoca: sigue en el historial de git y sigue activa hasta que la ROTES en el panel del proveedor.
  • ×Tratar la anon key como un secreto y ofuscarla: pierdes el tiempo protegiendo algo público mientras la autoridad real (RLS) queda sin revisar.
  • ×Asumir que un sitio SSG 'no tiene secretos expuestos' sin auditar el bundle generado: lo que importa es qué hay en `dist/`, no qué hay en `src/`.
  • ×Meter la service_role en un script de migración versionado en el repo en vez de leerla del shell local o de un secret de CI en tiempo de ejecución.
OS-02

Modelo de amenaza de una SSG

lección

Pensar como el atacante de un sitio estático con anon key: enumerar la superficie de ataque real de una SSG + Supabase y entender por qué la única defensa que cuenta es RLS, no el cliente.

Mucha gente protege la puerta equivocada: oculta el panel admin en el frontend y deja la DB abierta. Un sitio estático no tiene servidor que valide nada — si tu modelo de amenaza no parte de 'el cliente es hostil y controlado por el atacante', construirás defensas decorativas que se saltan con curl.

LA LECCIÓN

Adopta el marco mental correcto: en una SSG, el cliente no es tu aplicación, es territorio enemigo. El atacante tiene tu bundle completo (es público), tu `PUBLIC_SUPABASE_URL`, tu anon key, y conocimiento de que detrás hay Postgres. No necesita 'hackear' tu JavaScript: lo lee. Puede instanciar su propio cliente Supabase con TU anon key desde una consola de Node y hablar directo con tu DB, sin pasar jamás por tu HTML. Por tanto, toda lógica de seguridad que viva en `.astro` o en JS de cliente — un `if (user.role === 'admin')`, un componente que se oculta — es UX, no seguridad. CLAUDE.md de MOONKEY lo dice literal: `admin.astro` es UX; la seguridad NO depende de ese gate.

Enumera la superficie de ataque concreta. Para MOONKEY hay cinco tablas alcanzables con la anon key: `profiles, progress, feedback, leads, proofs`. El atacante, con un cliente autenticado vía magic-link (cualquiera puede registrarse), intentará lo evidente: `supabase.from('profiles').select('*')` para volcar TODOS los perfiles; `supabase.from('leads').select('*')` para robar la lista de leads; un `update` sobre su propia fila de `profiles` poniendo `role = 'admin'` o `founder_badge = true` para auto-escalar; un `insert` en `proofs` con `user_id` ajeno para falsear progreso de otro. Cada una de estas es una hipótesis de ataque que TÚ debes refutar con una policy. Si no la has refutado explícitamente, asume que funciona.

Entiende por qué RLS es la autoridad y cómo se compone la defensa real de MOONKEY. SELECT en `profiles/progress/feedback/proofs` es self-or-admin: la policy permite la fila solo si `user_id = auth.uid()` O `is_admin()`. `is_admin()` es SECURITY DEFINER — corre con los permisos del dueño, consulta si el llamante es admin sin que el llamante pueda manipular esa decisión. Resultado: un no-admin que pide `select('*')` no recibe un error, recibe SUS filas y nada más — el dump masivo devuelve una sola fila, la suya. La escalada de privilegios la corta un trigger (`guard_privileged_profile_columns`): `role` y `founder_badge` son inmutables para no-admins, así que el `update role='admin'` se rechaza a nivel de DB. El insert ajeno lo corta `proofs_insert_self`: exige sesión y liga `user_id = auth.uid()`.

La consecuencia operativa del modelo: verifica por impersonación, no por lectura de código. Una policy que 'parece' correcta puede tener un agujero (un OR mal puesto, un USING sin WITH CHECK que deja pasar updates). La única prueba que vale es ponerte en los zapatos del atacante: autentícate como un usuario normal y EJECUTA los ataques enumerados arriba contra la DB real, confirmando que cada uno devuelve vacío o error. CLAUDE.md menciona que el aislamiento de lectura está 'verificado por impersonación' — ese es el estándar. 'Escribí la policy' no es evidencia; 'intenté el ataque como usuario X y no leyó la fila de usuario Y' sí lo es.

No olvides los vectores que no son RLS. El modelo de amenaza de una SSG incluye también: (a) la diferencia USING vs WITH CHECK — USING filtra qué filas ves/afectas, WITH CHECK valida el estado resultante de un INSERT/UPDATE; olvidar WITH CHECK en un UPDATE deja que muevas una fila a un user_id ajeno. (b) Las RPC: una función SECURITY DEFINER mal escrita es un boquete que ignora RLS por diseño — MOONKEY las restringe a `authenticated` y revoca operaciones peligrosas. (c) TRUNCATE: revocado de anon/authenticated, porque RLS no protege contra TRUNCATE (borra la tabla entera sin evaluar policies por fila). (d) Fuga por errores y por columnas: un SELECT que devuelve una columna sensible que olvidaste excluir.

Aterriza con el contraste de proyectos para fijar el principio. XNLAB es un sitio de marca casi sin backend: su superficie es mínima — formularios de contacto, cabeceras, quizá un endpoint de captación. Ahí el modelo de amenaza es 'spam y XSS en lo poco que acepta input', no escalada de DB. XHUB IRON vive en la MISMA DB que MOONKEY pero es un panel operativo del fundador: sus tablas `iron_*` no deben ser legibles por un usuario de MOONKEY — el mismo motor RLS tiene que mantener DOS productos que comparten Postgres sin que uno lea al otro (eso es OS-05). El modelo de amenaza no es genérico: se deriva de QUÉ datos hay, QUIÉN puede autenticarse, y QUÉ comparten la infraestructura. Empieza siempre por inventariar esos tres ejes.

EJERCICIO

Sobre MOONKEY (o tu proyecto Supabase): 1) Escribe `THREAT-MODEL.md` listando, por cada tabla alcanzable con anon key, los ataques que un usuario autenticado hostil intentaría (dump masivo, escalada de privilegios, escritura sobre fila ajena). 2) Para cada ataque, anota la defensa que DEBERÍA detenerlo (qué policy/trigger) y márcalo como 'verificado' o 'no verificado'. 3) Escribe el snippet de impersonación que ejecutarías para probar el dump de `profiles` como usuario normal (cliente con anon key + sesión de un user de prueba haciendo `.from('profiles').select('*')`) y predice el resultado esperado (solo 1 fila).

ENTREGABLE

`THREAT-MODEL.md`: una tabla ataque→defensa→estado-de-verificación que cubre cada tabla expuesta por la anon key, más el snippet de impersonación concreto para al menos un ataque de dump masivo con su resultado esperado.

INSIGHT CLAVE

En una SSG el cliente es territorio del atacante: tiene tu bundle, tu anon key y habla directo con tu Postgres por curl sin tocar tu HTML. Toda comprobación de seguridad que vive en .astro es UX; la única frontera real es RLS, y solo cuenta cuando la has refutado por impersonación, no por leerla.

ERRORES A EVITAR

  • ×Confundir ocultar el panel admin en el frontend con protegerlo: `admin.astro` esconde la UI, pero la DB sigue accesible por curl con la anon key.
  • ×Verificar policies leyéndolas en vez de atacándolas como usuario impersonado — un OR mal puesto solo se ve ejecutando el ataque.
  • ×Escribir un UPDATE con USING pero sin WITH CHECK, permitiendo reasignar la fila a un `user_id` ajeno aunque 'parezca' que la policy protege.
  • ×Olvidar que TRUNCATE y las RPC SECURITY DEFINER esquivan RLS: protegerlas exige REVOKE explícito, no basta con tener policies de fila.
  • ×Aplicar un modelo de amenaza genérico copiado de un tutorial en vez de derivarlo de tus tres ejes reales: qué datos, quién autentica, qué infraestructura comparte.
OS-03

Auditoría adversarial

lección

Auditar de forma adversarial: tratar cada hallazgo de seguridad como una hipótesis que debes refutar antes de creerla, en vez de aceptar 'parece vulnerable' o 'parece seguro' por inspección.

El sesgo de confirmación arruina auditorías: un escáner dice 'RLS desactivado' y entras en pánico, o tú dices 'la policy se ve bien' y firmas el visto bueno. Ambos errores cuestan caro — falsos positivos queman tu tiempo y credibilidad; falsos negativos dejan brechas abiertas. La disciplina de refutar primero es lo que separa una auditoría real de una opinión.

LA LECCIÓN

El principio rector es popperiano: un hallazgo de seguridad no es verdadero porque lo afirmes, es verdadero porque INTENTASTE refutarlo y no pudiste. Invierte el flujo natural. Cuando crees haber encontrado una vulnerabilidad ('cualquiera puede leer `leads`'), tu siguiente paso no es reportarla — es intentar demostrar que estás equivocado: ¿hay una policy que lo impide? ¿la probé como el rol correcto? ¿el `select` devolvió datos reales o vacío? Solo cuando tu intento de refutación falla, el hallazgo asciende a confirmado. Y al revés: cuando crees que algo es seguro ('la policy self-or-admin protege `profiles`'), tu trabajo es atacarlo hasta que se rompa o sobreviva.

Define los dos errores que persigues con nombres claros. Falso positivo: reportas una vulnerabilidad que no existe — p.ej. gritas 'la anon key está expuesta en el bundle' cuando la anon key es pública por diseño y la DB está protegida por RLS (ver OS-01). Quemas confianza; la próxima vez nadie te cree. Falso negativo: das por seguro algo que no lo es — p.ej. 'el panel admin está oculto, estamos bien' sin probar el acceso directo a la DB. Dejas la brecha abierta. La auditoría adversarial existe para minimizar AMBOS, y la herramienta para ambos es la misma: la prueba ejecutable.

El método concreto: por cada hallazgo, produce una prueba reproducible, no una afirmación. 'Reproducible' significa: un comando o snippet que cualquiera puede correr y ver el mismo resultado. Para confirmar que `profiles` NO se puede dumpear: un script que se autentica como user_A, hace `.from('profiles').select('*')`, e imprime el conteo de filas — esperado 1 (solo la suya). Para confirmar que la escalada de privilegios está cerrada: como user_A, `.from('profiles').update({ role: 'admin' }).eq('id', myId)` e imprimir el error que devuelve el trigger guard. Si el ataque devuelve lo que esperabas (vacío/error), el control está verificado. Si devuelve datos, tienes un hallazgo REAL — y ya viene con su PoC adjunto.

Trabaja con la herramienta del propio Supabase pero sin rendirte a ella. `get_advisors` (security lints) te señala tablas sin RLS, policies permisivas, funciones SECURITY DEFINER sin search_path fijo. Trátalo como generador de HIPÓTESIS, no de veredictos. Un advisor que dice 'función X es SECURITY DEFINER mutable search_path' es una hipótesis de riesgo: ve a la función, mira si un atacante puede plantar un objeto en un schema que ella resuelva primero. A veces es explotable, a veces no por el contexto. El advisor te ahorra el barrido inicial; la refutación la haces tú. CLAUDE.md ya lo institucionaliza: cambios de RLS = migración versionada + `get_advisors` después. Esa segunda parte es la auditoría adversarial hecha rutina.

Cuida los falsos negativos del propio método: el ataque que 'pasa' puede pasar por la razón equivocada. Si tu `select('*')` sobre `profiles` devuelve vacío, ¿es porque RLS lo bloqueó, o porque tu sesión de prueba caducó / no estabas autenticado / la tabla estaba vacía? Un control nunca se da por bueno sin un caso de CONTRASTE: prueba que el mismo query SÍ devuelve datos cuando debe (como admin, o como el dueño de la fila). Una prueba de seguridad sin su control positivo es un falso negativo disfrazado de éxito. Siempre verifica que tu arma funciona antes de concluir que el blanco es inmune.

Aterriza el rigor en los proyectos donde más duele equivocarse. En XCAP la auditoría adversarial es doctrina central: la memoria del proyecto lo describe como 'anti-fabricación por construcción' — un sistema que registra forecasts y los calibra contra resultados reales, refutando sus propias predicciones en vez de creérselas. Ese mismo músculo aplicas a seguridad: no firmes 'el ledger es read-only' hasta que un intento de escritura con la credencial de ingest falle ante tus ojos. En Espejo, donde la frontera de datos personales es ética además de técnica, una auditoría que dice 'no hay fuga entre tenants' sin un PoC de un tenant intentando leer a otro NO es una auditoría: es un deseo. Refutar primero, siempre, especialmente cuando el resultado que esperas es el cómodo.

EJERCICIO

Toma un control de seguridad real de MOONKEY (p.ej. 'un no-admin no puede leer perfiles ajenos'). 1) Formúlalo como hipótesis refutable. 2) Escribe DOS pruebas: la del ataque (como user_A intento leer la fila de user_B → espero vacío) y la de contraste (como user_A leo MI fila → espero 1 fila; o como admin leo todas → espero N). 3) Ejecuta `get_advisors` (o documenta qué señalaría) y para cada lint clasifícalo como 'hipótesis confirmada como riesgo real' o 'refutada (no explotable porque…)'. 4) Escribe un mini-reporte donde cada afirmación va acompañada de su comando reproducible.

ENTREGABLE

`AUDIT.md`: un reporte donde cada hallazgo (vulnerable o seguro) lleva su PoC reproducible — ataque + caso de contraste positivo — y donde cada lint de `get_advisors` está marcado como confirmado o refutado con su razón.

INSIGHT CLAVE

Una prueba de seguridad que 'pasa' sin caso de contraste positivo es un falso negativo disfrazado: el select vacío puede deberse a RLS o a una sesión caducada. Verifica siempre que tu arma dispara antes de declarar inmune al blanco — refutar primero aplica también a tu propio método.

ERRORES A EVITAR

  • ×Reportar un hallazgo por inspección de código sin un PoC ejecutable: 'la policy parece floja' no es un hallazgo, es una corazonada.
  • ×Tratar los lints de `get_advisors` como veredictos en vez de hipótesis a refutar — generan falsos positivos que, repetidos, queman tu credibilidad.
  • ×Dar por seguro un control porque el ataque devolvió vacío, sin probar que el mismo query SÍ devuelve datos cuando debe (control positivo).
  • ×Gritar 'anon key expuesta' como vulnerabilidad cuando es pública por diseño: el falso positivo clásico que delata no haber entendido el modelo de seguridad.
  • ×Auditar solo en la dirección cómoda (confirmar lo que esperas) en vez de atacar igual de fuerte los controles que crees seguros.
OS-04

Incident response & rollback

lección

Responder a un incidente en producción con un procedimiento frío: detectar, contener, hacer rollback de forma segura, y conducir un post-mortem sin culpa que cierre la causa raíz.

Cuando algo se rompe en producción, el instinto (improvisar, tocar la DB en caliente, ocultar el error) suele empeorar el daño. La diferencia entre un susto de diez minutos y un fin de semana perdido es tener un runbook escrito ANTES del incidente. Sin él, un rollback mal hecho destruye datos que el bug solo había corrompido.

LA LECCIÓN

Interioriza la secuencia DCRP: Detectar → Contener → Recuperar (rollback) → Post-mortem. El error de la mayoría es saltar directo a 'arreglar', tocando producción a ciegas. El orden importa porque cada fase protege a la siguiente: si no detectas bien, contienes lo que no es; si no contienes, el daño crece mientras recuperas; si recuperas sin entender, repites el incidente. Y la regla cero, escrita en cualquier runbook serio: en un incidente NO improvisas sobre producción. Ejecutas pasos predefinidos. La creatividad es para el post-mortem, no para las 3am con el sitio caído.

Detectar es saber QUÉ está roto y desde CUÁNDO. En MOONKEY (Cloudflare Pages + Supabase) tus señales son: el deploy de Cloudflare (¿qué commit está live? ¿el último build pasó?), los logs de Supabase (`get_logs` por servicio: api, postgres, auth — ahí ves errores de policy, queries fallando, picos de 4xx/5xx), y la queja del usuario (a menudo la primera señal). Lo primero que estableces es la línea de tiempo: ¿qué cambió justo antes? Casi siempre el incidente correlaciona con un deploy o una migración reciente. 'Empezó tras el merge de las 16:20' acota el espacio de búsqueda de horas a minutos.

Contener es detener el sangrado antes de curar la herida. Pregunta clave: ¿esto está corrompiendo datos AHORA mismo? Si un bug está escribiendo filas malas en `proofs` o `progress`, cada minuto que tardas multiplica el cleanup. Opciones de contención según severidad: revertir el deploy de Cloudflare al anterior (un click — Cloudflare Pages guarda deploys previos y permite rollback instantáneo, esta es tu palanca más rápida y segura), deshabilitar la feature rota, o en el caso extremo de escritura destructiva, cortar el acceso a nivel de policy. Contener NO es arreglar: es congelar el daño en su estado actual para tener tiempo de pensar.

Recuperar exige distinguir DOS rollbacks que la gente confunde y que tienen riesgos opuestos. (1) Rollback de CÓDIGO: volver a un deploy/commit anterior. Es barato, reversible y casi siempre seguro — en Cloudflare Pages es inmediato. Esta es tu primera opción. (2) Rollback de SCHEMA/DATOS: revertir una migración o restaurar datos. Es PELIGROSO e irreversible: un `down` migration que hace `DROP COLUMN` borra datos que quizá solo estaban corruptos, no perdidos. Reglas: nunca corras un rollback de datos sin un backup/PITR confirmado primero; prefiere una migración FORWARD que corrija (un nuevo `UPDATE` que repara las filas malas) sobre un `down` destructivo; toda migración de Supabase es versionada (`apply_migration`) precisamente para que el estado sea reconstruible. Si la duda es 'rollback de código o de datos', empieza SIEMPRE por el de código: a menudo basta y no toca la DB.

El post-mortem es donde el incidente paga su deuda convirtiéndose en aprendizaje. Es SIN CULPA (blameless): el objetivo no es quién rompió, sino qué del SISTEMA permitió que un humano normal rompiera. Estructura: timeline (qué pasó y cuándo, minuto a minuto), impacto (a cuántos usuarios, qué datos, cuánto tiempo), causa raíz (los '5 por qués' hasta llegar al fallo de sistema, no de persona), y acciones correctivas con dueño y fecha. Cada incidente debe producir al menos un control que lo habría PREVENIDO o DETECTADO antes — un test, un advisor en CI, una alerta. Si el post-mortem no cambia nada del sistema, no fue un post-mortem, fue una confesión.

Ancla el runbook en la realidad de cada proyecto, porque la severidad y las palancas cambian. En XCAP existe una invariante de capital sagrada — el capital se acumula entre sesiones y solo se mueve en cierres realizados, nunca se resetea. Un incidente que toque esa invariante NO se arregla con un rollback de datos a ciegas: borrarías historia contable real. Ahí el runbook obliga a corrección forward y verificación de la invariante antes de tocar nada. En Espejo, multi-tenant, un incidente tiene una pregunta extra en la fase de detección: ¿el fallo cruzó la frontera entre tenants? Si un bug expuso datos de un tenant a otro, además del rollback hay obligación de contención de la fuga. El runbook genérico te da la espina dorsal; los invariantes de cada proyecto te dan las reglas que NUNCA violas durante el pánico.

EJERCICIO

Escribe el `RUNBOOK.md` de incidentes para MOONKEY: 1) La sección Detectar (qué comandos/paneles miras: deploy de Cloudflare, `get_logs` de Supabase por servicio, cómo estableces la timeline). 2) La sección Contener con la palanca de rollback de Cloudflare Pages paso a paso. 3) Un árbol de decisión 'rollback de código vs rollback de datos' con la regla 'empieza por código, datos solo con PITR confirmado'. 4) Una plantilla de post-mortem blameless (timeline / impacto / 5-por-qués / acciones correctivas con dueño). 5) Simula un incidente: 'tras el deploy de las 16:20, los usuarios no pueden guardar proofs' y escribe la respuesta paso a paso siguiendo tu propio runbook.

ENTREGABLE

`RUNBOOK.md` con las cuatro fases DCRP operacionalizadas para Cloudflare Pages + Supabase, el árbol de decisión código-vs-datos, una plantilla de post-mortem blameless, y un incidente simulado resuelto end-to-end con tu propio procedimiento.

INSIGHT CLAVE

Hay dos rollbacks con riesgos opuestos: el de código es barato y reversible (empieza siempre por ahí), el de datos es destructivo e irreversible. Ante la duda, una migración forward que repara filas corruptas es casi siempre mejor que un `down` que las borra — porque a menudo los datos estaban dañados, no perdidos.

ERRORES A EVITAR

  • ×Saltar directo a 'arreglar' tocando producción a ciegas en vez de seguir Detectar→Contener→Recuperar: la improvisación a las 3am es cómo un susto se vuelve un desastre.
  • ×Hacer un rollback de datos (down migration con DROP) sin backup/PITR confirmado, destruyendo datos que el bug solo había corrompido.
  • ×No establecer la timeline antes de actuar: sin saber qué deploy o migración disparó el incidente, buscas la causa en todo el sistema en vez de en los últimos cambios.
  • ×Confundir contener con arreglar: dejar el bug escribiendo filas malas mientras 'investigas' multiplica el cleanup posterior.
  • ×Cerrar el incidente con un post-mortem que busca culpable en vez de un fallo de sistema, y que no produce ni un solo control nuevo que lo habría prevenido.
OS-05

Aislamiento entre proyectos

lección

Hacer que varios proyectos compartan una única base de datos Postgres sin que uno pueda leer, escribir o romper los datos de otro — aislamiento multi-proyecto y multi-tenant impuesto por la DB, no por convención.

Compartir una instancia Supabase entre proyectos ahorra coste pero crea el riesgo más silencioso de la galaxia: que el cliente de MOONKEY, con su anon key, lea las tablas de XHUB IRON. El aislamiento por 'acuérdate de no tocar esas tablas' es papel; un día alguien lo toca. El aislamiento real es estructural y se verifica atacándolo.

LA LECCIÓN

Parte del hecho concreto: la instancia `wuchsslgbqlhyxljsmxi` aloja DOS productos. MOONKEY usa `profiles, progress, feedback, leads, proofs`. XHUB IRON usa `iron_*, world_*, focus_*, daily_focus_history`. Comparten el mismo Postgres, el mismo `auth.users`, y — clave — la MISMA anon key se usa para hablar con la DB. Esto significa que el cliente público de MOONKEY técnicamente PUEDE intentar `from('iron_command').select('*')`. La única razón de que no funcione tiene que ser una barrera dura en la DB, no la nota de CLAUDE.md que dice 'no las toques'. Esa nota protege contra que TÚ las edites por error escribiendo código; no protege contra un atacante que ya tiene tu anon key.

Entiende las capas de aislamiento de menor a mayor fuerza. (1) Convención (nombres `iron_*` vs sin prefijo): organiza, no aísla — un atacante ignora convenciones. (2) RLS por tabla: cada tabla de XHUB debe tener policies que solo concedan acceso a su propio rol/usuario; una tabla `iron_*` sin RLS o con una policy permisiva es una fuga directa. (3) Schemas separados: poner XHUB en su propio schema Postgres y NO exponerlo en la API de PostgREST (la `db.schema` que Supabase publica) es una barrera más fuerte — lo que no está en el schema expuesto, el cliente ni lo ve. (4) Roles/grants: REVOKE de `anon`/`authenticated` sobre las tablas/objetos ajenos. La defensa robusta APILA estas capas; no confíes en una sola.

Para el caso MOONKEY↔XHUB, la pregunta de auditoría es directa: ¿puede un usuario autenticado de MOONKEY leer una fila de `iron_*`? Refútalo (OS-03): autentícate como user normal de MOONKEY y ejecuta `supabase.from('iron_command').select('*')`. Lo correcto es que devuelva error de permiso o vacío por RLS. Si devuelve datos, tienes una fuga de aislamiento crítica. La memoria de proyecto dice que la salud se computa y que el modelo es 'open RLS, computed health' para el HUB — eso significa que debes verificar EXPLÍCITAMENTE que 'open' para el HUB no significa 'abierto a clientes de MOONKEY'. Aislamiento entre proyectos = cada tabla solo responde a su dueño legítimo, probado por impersonación cruzada.

El segundo eje es el multi-tenant DENTRO de un proyecto, que es Espejo. Aquí no son dos productos, son N tenants (cada influencer) en las mismas tablas. El aislamiento se vuelve por-fila: cada fila lleva un `tenant_id`, y la policy RLS exige que `tenant_id` coincida con el tenant del llamante. El ataque a refutar: el tenant A intenta leer/escribir filas con el `tenant_id` de B. La trampa clásica es el UPDATE sin WITH CHECK: USING limita qué filas A ve, pero sin WITH CHECK, A podría reasignar una fila a B o crear una con `tenant_id` ajeno. Otra trampa: una RPC SECURITY DEFINER que recibe `tenant_id` como parámetro y no valida que coincide con `auth.uid()` — boquete que cruza todos los tenants. El aislamiento multi-tenant solo es real si CADA policy de cada operación (SELECT/INSERT/UPDATE/DELETE) ancla el tenant al identidad del llamante, no a un parámetro que el cliente controla.

Hay vectores de cruce que no son SELECT y que la gente olvida. `auth.users` es compartido: un usuario es el mismo en MOONKEY y en XHUB — asegúrate de que tener cuenta en uno no concede roles en el otro (en MOONKEY, `role` y `founder_badge` son inmutables para no-admins precisamente para que registrarse no escale privilegios cruzados). Las RPC y funciones SECURITY DEFINER son objetos globales: una función de XHUB ejecutable por `authenticated` es invocable desde el cliente de MOONKEY — restríngela por rol o valida el contexto dentro. Triggers y secuencias compartidas, vistas que hacen JOIN entre tablas de ambos productos, y `get_advisors` que señala RLS faltante en cualquier tabla del proyecto: todo eso es superficie de cruce. El aislamiento no es una policy, es una propiedad del sistema entero.

Cierra con la disciplina operativa que mantiene el aislamiento vivo en el tiempo. Cada cambio de schema entra como migración versionada (`apply_migration`), nunca como edición manual en el panel — así el estado de seguridad es reconstruible y auditable. Tras cada migración que toque tablas o policies, corre `get_advisors` para cazar RLS desactivado o policies permisivas introducidas sin querer. Y mantén un test de aislamiento como parte del runbook: un script que, como usuario de MOONKEY, intenta leer cada prefijo ajeno (`iron_*`, `world_*`) y falla el build si alguno devuelve filas. El aislamiento entre proyectos no se 'configura una vez'; se re-verifica en cada cambio, porque una migración inocente puede abrir una puerta que llevaba meses cerrada.

EJERCICIO

Sobre la DB compartida `wuchsslgbqlhyxljsmxi` (o una réplica de prueba): 1) Lista en `ISOLATION.md` las tablas de cada producto (MOONKEY vs XHUB) y la capa de aislamiento que protege a cada una (RLS / schema no expuesto / REVOKE). 2) Escribe el test de impersonación cruzada: como usuario normal de MOONKEY, intenta `select('*')` sobre `iron_command` (o cualquier `iron_*`/`world_*`) y documenta el resultado esperado (error/vacío). 3) Para el caso multi-tenant de Espejo, escribe las cuatro policies (SELECT/INSERT/UPDATE/DELETE) de una tabla con `tenant_id`, asegurando que UPDATE lleva WITH CHECK y que ninguna confía en un `tenant_id` pasado por el cliente. 4) Documenta cómo `get_advisors` entra en tu rutina post-migración.

ENTREGABLE

`ISOLATION.md`: el mapa de tablas→capa-de-aislamiento para los dos productos de la DB compartida, un test ejecutable de impersonación cruzada MOONKEY→XHUB con resultado esperado, y el set de cuatro policies RLS por-tenant de Espejo con WITH CHECK correcto en las operaciones de escritura.

INSIGHT CLAVE

Compartir Postgres entre proyectos es seguro solo cuando el aislamiento es estructural (RLS + schema no expuesto + REVOKE), no documental: la nota 'no toques esas tablas' protege contra tus errores de código, jamás contra un atacante que ya tiene tu anon key y enumera prefijos ajenos.

ERRORES A EVITAR

  • ×Confiar el aislamiento a convenciones de nombres o a una nota en CLAUDE.md: un atacante con la anon key ignora ambas y prueba `from('iron_*').select('*')` directamente.
  • ×Dejar una tabla ajena (`iron_*`, `world_*`) sin RLS o con policy permisiva en una DB cuya anon key comparten varios proyectos — fuga directa entre productos.
  • ×En multi-tenant, escribir UPDATE/INSERT con USING pero sin WITH CHECK, permitiendo que un tenant reasigne o cree filas con el `tenant_id` de otro.
  • ×Construir una RPC SECURITY DEFINER que confía en un `tenant_id` (o `user_id`) pasado como parámetro por el cliente en vez de derivarlo de `auth.uid()`.
  • ×Tratar el aislamiento como configuración de una vez y no re-correr `get_advisors` tras cada migración: un cambio inocente puede reabrir una puerta cerrada hace meses.
OS-06

Observabilidad y coste

lección

Montar un panel de observabilidad y un presupuesto con alertas para un sistema vivo (Astro en Cloudflare Pages + Supabase): definir qué logs y métricas vigilar, qué dispara una alerta, y qué techos de uso y de gasto en tokens te avisan ANTES de que llegue la factura sorpresa.

Lanzar no es terminar: es el momento en que empiezas a operar. Un sistema en producción genera tres flujos que casi nadie mira hasta que duelen: logs (qué está pasando), errores (qué se rompe sin que nadie te lo diga) y coste (cuánto te cobran por seguir vivo). MOONKEY LAB, XHUB, Espejo y XCAP comparten infraestructura real con planes free/pro y cuotas reales de Supabase y Cloudflare, y todo el uso de IA se factura por token. El operador que no monitoriza descubre los problemas por dos vías, ambas caras: un usuario que se queja, o un cargo en la tarjeta. La observabilidad convierte 'no sé qué pasa en producción' en 'tengo un tablero que me lo dice'. El coste es seguridad: un endpoint abierto que alguien martillea no solo es un agujero, es una factura. Esta es la última disciplina de la constelación porque cierra el ciclo: construiste, aseguraste, ahora operas con los ojos abiertos.

LA LECCIÓN

Los tres flujos de un sistema vivo. Logs = el diario de qué ocurre (peticiones, queries, auth). Errores = lo que se rompe en silencio del lado del cliente, donde tus logs de servidor no llegan. Coste = el contador que corre aunque nadie use el sistema. Son tres tuberías distintas con tres herramientas distintas; confundirlas es por qué la gente cree estar monitorizando y no lo está.

Observabilidad en una SSG no es lo mismo que en un servidor. MOONKEY es estático: NO hay handler server-side donde poner un logger. Tu telemetría vive en tres sitios ajenos a tu HTML: (1) Cloudflare Pages Analytics (requests, ancho de banda, errores de edge), (2) los logs de Supabase (Dashboard → Logs: API, Postgres, Auth — cada query que pasa por PostgREST y cada login deja rastro), y (3) el navegador del usuario (errores de JS que solo tú no ves). Acéptalo: en una SSG, gran parte de lo que falla ocurre en una máquina que no controlas.

Qué vigilar de verdad, no todo. La tentación es loguearlo todo y no leer nada. Define un puñado de señales que importan: tasa de error 4xx/5xx en el edge (un pico de 401/403 = RLS rechazando o un atacante probando), errores de Auth en Supabase (intentos fallidos de magic-link), latencia de las queries lentas (Supabase marca las que tardan), y volumen de filas leídas (una query sin filtro que de pronto devuelve 10.000 filas es un bug de coste). Una métrica que no dispara ninguna decisión es ruido; bórrala.

Error tracking del lado del cliente: el punto ciego. En un sitio estático, un TypeError en tu JS rompe la experiencia del usuario y a ti no te llega NADA — ni log de servidor ni alerta. Necesitas capturar errores en el navegador y mandarlos fuera. Lo mínimo honesto: un window.addEventListener('error', ...) y ('unhandledrejection', ...) que haga un fetch a un endpoint propio (una Edge Function de Supabase que inserta en una tabla de logs con RLS de solo-insert). Lo robusto: Sentry o similar con su SDK gratuito hasta cierto volumen. La regla: si no capturas errores de cliente, estás operando a ciegas en la mitad de tu sistema.

El coste real de operar, desglosado. Supabase free tier tiene techos concretos que, al cruzarlos, o te cortan o te cobran: filas de DB, ancho de banda de egreso, almacenamiento, Monthly Active Users de Auth, e invocaciones de Edge Functions. Cloudflare Pages es generoso pero tiene límites de builds/mes y de requests en funciones. Y el gasto que más escala y menos se ve: los tokens de IA. Cada llamada a la API de Claude se factura por tokens de entrada y de salida, con tarifas distintas; un prompt con un CLAUDE.md gigante o un bucle de agente sin tope puede multiplicar el coste sin que cambie 'lo que hace' la app. El operador conoce sus tres facturas: DB, edge y tokens.

Por qué llegan las facturas sorpresa, y cómo no recibirlas. Tres causas clásicas: (1) un endpoint o Edge Function sin rate-limit que alguien descubre y martillea — uso = dinero; (2) una query sin LÍMITE ni índice que escanea toda la tabla en cada carga; (3) un loop de agente IA sin tope de tokens ni de iteraciones (el XCAP capital invariant existe en parte por esto: bucles que acumulan aprendizaje, no coste). La defensa no es vigilar la factura a fin de mes, es poner los frenos ANTES: presupuestos con alerta al 50/80/100%, límites duros donde la plataforma los ofrezca, y techos explícitos de max_tokens y de iteraciones en cada llamada de IA.

Alertar sobre lo que importa, sin fatiga de alertas. Una alerta que salta cada hora se ignora a la semana. El criterio: alerta solo lo accionable y lo urgente. Bueno: 'el gasto del mes superó el 80% del presupuesto', 'la tasa de 5xx pasó del 1%', 'errores de cliente > X en 10 min'. Malo: 'hubo una petición'. Configura los budget alerts que ya te dan Supabase, Cloudflare y la consola de Anthropic — son gratis y son tu primera línea. Cada alerta debe responder a 'qué hago cuando salta'; si no hay acción, no es una alerta, es ansiedad.

Cerrar el bucle con la respuesta a incidentes (OS-04). Observar sin un plan de reacción es solo mirar arder. El panel y las alertas de este módulo son los sensores; el rollback y el incident response de OS-04 son los actuadores. Un sistema bien operado conecta los dos: la alerta te despierta, los logs te dicen qué pasó, y el plan de respuesta te dice qué hacer. Observabilidad + coste + respuesta = operar de verdad, no rezar.

EJERCICIO

Operas MOONKEY LAB (Astro SSG en Cloudflare Pages + Supabase moonkey-lab + posibles llamadas de IA). Monta su observabilidad real y su presupuesto en cuatro pasos. PASO 1 — Mapa de señales (entregable escrito). Abre el Dashboard de Supabase → Logs y revisa los tres streams (API, Postgres, Auth). Documenta en una tabla qué señal vigilas en cada uno (ej. Auth: tasa de magic-links fallidos; API: ratio de 401/403; Postgres: queries > 500ms), y de cada una: umbral, qué significa cruzarlo, y qué acción dispara. Mínimo 6 señales. Una sin acción se descarta. PASO 2 — Captura de errores de cliente. Implementa un script global (en Layout.astro, junto al IntersectionObserver ya global — no lo dupliques) con window.addEventListener('error') y ('unhandledrejection') que haga fetch a una Edge Function de Supabase. Crea la Edge Function y una tabla client_errors con RLS de SOLO insert para anon (nunca SELECT para anon: los logs no se leen desde el cliente). Aplícala como migración versionada y corre get_advisors después (regla de coste del proyecto). Provoca un error a propósito y verifica que la fila aparece en la tabla. PASO 3 — Las tres facturas y sus topes. Documenta los límites actuales y dónde se ven: (a) Supabase — uso de DB, egreso, Auth MAU, invocaciones de funciones (Dashboard → Reports/Usage); (b) Cloudflare — builds y requests (Pages → Analytics); (c) tokens de IA — si hay llamadas a la API de Claude, calcula el coste de una llamada típica (tokens de entrada del CLAUDE.md + prompt, tokens de salida esperados, por la tarifa vigente del modelo). Para cada factura, anota el techo gratuito y a qué % estás hoy. PASO 4 — Frenos y alertas. Activa budget alerts en la consola de Anthropic y en Supabase (o documenta exactamente dónde se configuran si tu plan no los expone). Para cualquier llamada de IA en el sistema, fija un max_tokens explícito y un tope de iteraciones si es un bucle. Entrega un runbook de una página: 'cuando salte la alerta X → mira el log Y → ejecuta la acción Z (que enlaza con el rollback de OS-04)'. No entregues capturas de 'parece que funciona'. Entrega: la tabla de señales, la Edge Function + migración corriendo con una fila de error real capturada, el desglose de las tres facturas con porcentajes, y el runbook que conecta alerta → log → acción.

ENTREGABLE

Un panel de observabilidad operativo para MOONKEY LAB con: (1) una tabla de 6+ señales (umbral · significado · acción) sobre los logs reales de Supabase y Cloudflare; (2) captura de errores de cliente funcionando — Edge Function + tabla client_errors con RLS solo-insert, aplicada como migración versionada y verificada con una fila de error real; (3) el desglose de las tres facturas (Supabase, Cloudflare, tokens de IA) con el techo gratuito y el % de uso actual de cada una; y (4) un runbook de una página con budget alerts activos que conecta cada alerta con su log y su acción de respuesta (enlazando con el rollback de OS-04).

INSIGHT CLAVE

En una SSG no hay servidor donde loguear, así que la mitad de lo que falla ocurre en el navegador del usuario y nunca te llega: si no capturas errores de cliente, no estás monitorizando, estás adivinando. Y el coste es una superficie de ataque, no solo una línea contable — un endpoint sin freno que alguien martillea es a la vez un agujero de seguridad y una factura sorpresa, así que los topes (max_tokens, rate-limit, budget alerts al 80%) son seguridad, no contabilidad. La factura no se vigila a fin de mes; se pone el freno antes.

ERRORES A EVITAR

  • ×Creer que loguear = observar. Acumular logs que nadie lee no es observabilidad; observabilidad es tener un puñado de señales que disparan decisiones. Una métrica que no acciona nada es ruido: bórrala.
  • ×Ignorar los errores de cliente porque 'el servidor no reporta nada'. En una SSG el servidor no PUEDE reportar el TypeError que rompe la pantalla del usuario. Sin captura en el navegador, operas a ciegas en media app.
  • ×Abrir una tabla de logs a SELECT para anon. Los logs se insertan desde el cliente pero JAMÁS se leen desde él (filtrarías datos de otros usuarios). RLS de solo-insert para anon, lectura solo admin — y pasa get_advisors.
  • ×Lanzar un bucle de agente IA o una Edge Function sin max_tokens ni tope de iteraciones. Es la causa nº1 de factura sorpresa: el coste escala invisible mientras 'lo que hace' la app no cambia.
  • ×Vigilar la factura a fin de mes en vez de poner budget alerts al 50/80/100% antes. Cuando ves el cargo, ya lo pagaste; la alerta existe para frenar, no para lamentar.
  • ×Fatiga de alertas: configurar avisos para todo hasta que el equipo los silencia. Alerta solo lo accionable y urgente; cada alerta debe responder a 'qué hago cuando salta' o no debería existir.
  • ×Tocar tablas de otros proyectos en la DB compartida (iron_*, world_*, focus_*) al montar logging. La tabla de errores es de MOONKEY; créala con prefijo propio y no pises XHUB IRON.

Siguiente constelación

Operator Core

Operar la IA