La galaxia
ÓRBITA I · CONSTELACIÓN

Builders

Enviar producto

Convertir una idea en algo que carga, se ve bien y se despliega. Astro, componentes, multi-tenant, white-label e i18n. El oficio de Espejo y de las webs de XNLAB.

Alimenta EspejoXNLAB
6 módulos · 6 lecciones
MÓDULOS DE LA CONSTELACIÓN Abre cada módulo para la clase completa
BD-01

Astro & Vite que cargan rápido

lección

Montar y comprender un sitio Astro que genera HTML estático por ruta y locale, y un proyecto Vite SPA, sabiendo exactamente qué JS llega al navegador y por qué carga rápido.

El 90% de la lentitud de la web moderna es JavaScript que no hacía falta enviar. Astro y Vite te dan velocidad por defecto, pero solo si entiendes dónde se ejecuta cada cosa: build-time vs runtime. Confundirlos es la causa raíz de sitios estáticos que de repente pesan 400KB de JS.

LA LECCIÓN

Empieza por la distinción que lo gobierna todo: en Astro el código de un componente .astro se ejecuta en BUILD (en tu máquina o en CI), no en el navegador. El resultado es HTML plano. MOONKEY es exactamente esto: `astro.config.mjs` declara `i18n` con `fallbackType: 'rewrite'` y NO usa SSR. Cuando corres `npm run build`, Astro recorre `src/pages/`, ejecuta cada .astro una vez por locale y escribe HTML a `dist/`. No hay servidor en producción: Cloudflare Pages sirve archivos. Eso es lo que hace que `moonkeylab.pages.dev` cargue en milisegundos.

Crea el esqueleto desde cero para verlo sin magia: `npm create astro@latest mi-sitio -- --template minimal --no-install`, luego `cd mi-sitio && npm install && npm run dev`. Abre `http://localhost:4321`. Edita `src/pages/index.astro` y mira que un `const hoy = new Date()` impreso con `{hoy}` se congela en el momento del build cuando haces `npm run build`. Ese es el aha-moment: el JS del frontmatter (entre los `---`) corre una vez, no en cada visita.

Ahora el control fino: las islas. Por defecto un componente de framework (React/Svelte) en Astro se renderiza a HTML estático SIN su JS. Solo si le pones una directiva `client:*` se hidrata. `client:load` hidrata al cargar, `client:visible` cuando entra en viewport, `client:idle` cuando el hilo está libre. Regla de oficio: empieza SIN directiva. Solo añade `client:visible` cuando el componente NECESITA interactividad real. En MOONKEY el `IntersectionObserver` de reveal vive en `Layout.astro` como un `<script>` global, no como una isla por página — eso es la decisión correcta: un script de 15 líneas en vez de hidratar un framework entero.

Vite es el motor que está debajo de Astro y también el de Espejo (que es una SPA React pura: `vite.config.ts`, `@vitejs/plugin-react`). La diferencia: Espejo SÍ envía React al navegador porque necesita estado interactivo en vivo (lecturas de cámara con MediaPipe, navegación con react-router). Esa es la línea divisoria real del oficio: ¿el contenido es mayormente lectura (web de XNLAB, MOONKEY) o es una app con estado denso (Espejo)? Lo primero pide Astro/SSG; lo segundo, Vite+SPA. No mezcles religiones: no metas una SPA entera dentro de Astro cuando el 95% es contenido.

Mide, no opines. Corre `npm run build` y mira el tamaño de `dist/`. En MOONKEY un build de página de contenido debería tener ~0KB de JS de página (solo el script de reveal compartido). Abre DevTools → Network, recarga con caché vacía y filtra por JS: si ves un bundle de framework en una página que es solo texto, has metido una directiva `client:*` de más o un import que arrastra una librería pesada al cliente. El comando `npx astro build --verbose` te lista qué se prerenderiza.

Failure mode clásico: importar una librería de fecha/markdown/iconos en el frontmatter creyendo que es build-only, pero usarla dentro de un `<script>` cliente o pasarla a una isla — y de pronto va al bundle. Otra: olvidar que `import.meta.env` en Astro distingue `PUBLIC_*` (va al cliente) del resto (se queda en build). Si una variable que creías privada aparece en `dist/`, la prefijaste mal. Y la trampa de Vite: cualquier cosa bajo `public/` se copia verbatim sin procesar — no pongas ahí secretos ni esperes que Vite optimice esos assets.

EJERCICIO

Crea un sitio Astro mínimo con dos páginas: `/` (solo HTML, cero JS) y `/contador` con un componente React `<Counter client:visible />`. Corre `npm run build`. Abre `dist/` y demuestra con DevTools Network que `/` descarga 0KB de JS y `/contador` solo hidrata cuando haces scroll hasta el botón. Documenta el tamaño exacto de ambos bundles.

ENTREGABLE

Carpeta `astro-islas/` con el repo + un `MEDICIONES.md` que liste: tamaño de `dist/`, KB de JS por página (captura de Network), y una frase explicando por qué `/` no envía nada y `/contador` sí.

INSIGHT CLAVE

La pregunta correcta nunca es '¿qué framework uso?' sino '¿qué JS DEBE existir en el navegador?'. Astro invierte el default de la web: parte de cero JS y te obliga a justificar cada byte de interactividad con una directiva explícita. El día que internalizas que el frontmatter corre en build y no en runtime, dejas de enviar basura.

ERRORES A EVITAR

  • ×Poner `client:load` en todo 'por si acaso' — hidratas el sitio entero y pierdes la ventaja de Astro; empieza sin directiva y añade `client:visible` solo donde haya interacción real.
  • ×Creer que el JS del frontmatter (entre `---`) corre en el navegador; corre en build, una sola vez, y su salida queda congelada en el HTML.
  • ×Meter una variable secreta en `import.meta.env.PUBLIC_*` — el prefijo PUBLIC la expone en `dist/`; lo no-público se queda en build.
  • ×Confundir cuándo usar Astro vs Vite-SPA: contenido de lectura → Astro/SSG; app con estado denso y vivo como Espejo → Vite+React.
  • ×Asumir que `public/` se optimiza: se copia verbatim, sin hashing ni minificación, así que no es el sitio para assets que quieres que Vite procese.
BD-02

Componentes y design tokens

lección

Construir un sistema visual coherente con design tokens (CSS custom properties) y componentes reutilizables, en vez de CSS suelto repetido, de forma que un cambio de marca sea una línea y no una cacería.

El CSS suelto no escala: el día que el cliente pide 'el violeta un poco más oscuro' acabas buscando `#7c3aed` en 40 archivos y te dejas tres. Los tokens convierten ese cambio en una edición de una variable. Es la diferencia entre un producto mantenible y deuda visual.

LA LECCIÓN

Un design token es una variable con nombre semántico, no un valor crudo repetido. MOONKEY define los suyos en `src/styles/global.css`: `--surface-base: #f8fafc`, `--text-primary: #0f172a`, más la paleta emerald/violet/amber. La regla de oro: los componentes NUNCA usan el hex directo, usan el token. Así `--surface-base` aparece una vez; si cambia la paleta, cambia un sitio. El antipatrón es escribir `color: #0f172a` en cada card — has acoplado 30 archivos a un valor que debería tener nombre.

El caso más instructivo del oficio está en Espejo, porque ahí los tokens son DINÁMICOS por tenant. Mira `src/brand/BrandProvider.tsx`: la función `applyTheme(brand)` hace `root.style.setProperty('--brand', brand.accent)` y deriva `--brand-soft`, `--brand-line`, `--brand-glow` con una función `withAlpha(hex, a)` que convierte el hex de marca en rgba con transparencia. Toda la UI pública de Espejo pinta con `var(--brand)`. Resultado: el mismo código sirve a Luna Rosa (rosa `#e58fb0`) y a cualquier influencer, cambiando UN campo `accent`. Eso es un design system que es también el motor del white-label.

Componente vs clase suelta: un componente encapsula estructura + tokens + comportamiento bajo un nombre. MOONKEY tiene `PromptBlock`, `AnimalIcon`, `MoonkeyLogo`, `FlowDiagram` en `src/components/`. La señal de que necesitas un componente: copias el mismo bloque de markup+clases una tercera vez. Hasta dos veces, duplica; a la tercera, extrae. No abstraigas antes — la abstracción prematura de un componente con 8 props 'por si acaso' es peor que la duplicación honesta.

Con Tailwind hay una trampa específica que MOONKEY documenta explícitamente: NUNCA interpoles clases como `bg-${theme}`. Tailwind hace tree-shaking escaneando el código en busca de strings de clase COMPLETOS en build; una clase construida en runtime no existe en el CSS generado y se renderiza sin estilo. Por eso `galaxy.ts` tiene un objeto `THEME` que mapea cada constelación a sus clases literales (`violet`, `amber`, `emerald`…) ya escritas. La lección: las clases dinámicas se resuelven con un MAPA de strings literales, no con interpolación.

Tipografía como token de sistema: MOONKEY fija `font-display` = Space Grotesk para h1–h3, Inter para body, JetBrains Mono para labels (`.label` = mono uppercase tracking-widest emerald-600). Eso no es decoración suelta: es una decisión codificada una vez que toda la app hereda. Si cada heading eligiera su propia fuente, no tendrías marca, tendrías ruido. El sistema vive en la capa de tokens/utilidades, no en cada `<h1>`.

Cómo verificar coherencia: haz un grep de valores crudos que NO deberían existir fuera del archivo de tokens. `grep -rn '#[0-9a-fA-F]\{6\}' src/components/` en MOONKEY debería devolver casi nada — si un hex aparece en un componente, es un token fugado que hay que reemplazar por `var(--token)`. Ese grep es tu linter de coherencia visual hecho a mano, y deberías correrlo antes de cada merge.

EJERCICIO

Toma una landing con 3 secciones que use hex repetidos. Extrae TODOS los colores y radios a tokens en un `:root` (`--surface`, `--text`, `--accent`, `--radius`). Luego replica el patrón de Espejo: añade una función `applyTheme(accent)` que cambie `--accent` en runtime y demuestra que con UNA llamada toda la página cambia de marca. Verifica con `grep` que ningún hex sobrevive fuera del bloque de tokens.

ENTREGABLE

Carpeta `tokens-tenant/` con `styles/tokens.css` (todas las variables), los componentes usando `var(--token)`, y un botón que llama `applyTheme()` con 3 colores distintos. Más un `grep` log probando 0 hex sueltos en componentes.

INSIGHT CLAVE

Un design system no es una librería de componentes bonitos: es la indirección que separa 'qué valor' de 'dónde se usa'. El test definitivo es el de Espejo — si puedes cambiar la marca entera de un producto white-label con un solo `setProperty`, tienes un sistema; si tienes que tocar varios archivos, tienes CSS disfrazado.

ERRORES A EVITAR

  • ×Interpolar clases de Tailwind (`bg-${theme}`) — el JIT no las ve en build y se renderizan sin estilo; usa un mapa de strings literales como el `THEME` de galaxy.ts.
  • ×Escribir hex crudos en componentes en vez de `var(--token)` — acoplas N archivos a un valor que debería tener un solo punto de cambio.
  • ×Abstraer un componente con 8 props 'por si acaso' antes de tener 3 usos reales; la duplicación honesta es mejor que la abstracción prematura.
  • ×Dejar que cada heading elija su fuente en vez de fijar la jerarquía tipográfica una vez en la capa de tokens.
  • ×Olvidar derivar los estados del color de marca (soft/line/glow): un solo accent sin sus variantes con alpha produce sombras y bordes incoherentes — copia el patrón `withAlpha` de Espejo.
BD-03

Multi-tenant & white-label

lección

Diseñar un producto multi-tenant white-label real: un solo código base que sirve a muchos clientes con su propia marca, datos y configuración, usando el patrón de costura (seam) de Store que aísla la UI del almacenamiento.

El white-label es el modelo de negocio que multiplica un build por N clientes sin reescribir nada. Pero solo funciona si el aislamiento entre tenants es real: una fuga de datos de un cliente a otro mata el producto. El seam Store de Espejo es la arquitectura que lo hace posible y a la vez prepara la migración a backend.

LA LECCIÓN

Multi-tenant significa: una instancia del software, muchos clientes (tenants) que la perciben como suya. White-label añade: cada tenant la ve con SU marca, sin rastro de la tuya. Espejo es el caso canónico — `src/brand/types.ts` lo dice en el comentario: 'cada influencer es un tenant con su marca'. El tenant se identifica por un `slug` en la URL (`/r/<slug>`) y todo —nombre, accent, glyph, voz, qué lecturas se ofrecen, el CTA de upsell— sale de un objeto `Brand`. La app pública entera se pinta a partir de ese objeto.

El corazón arquitectónico es el SEAM de datos. Mira `src/brand/store.ts`: define una `interface Store` con firmas async (`getBrand`, `listBrands`, `saveBrand`, `addSubscriber`, `listSubscribers`) y una implementación `LocalStore` sobre localStorage. El comentario lo deja explícito: 'Hoy: localStorage. Mañana: Supabase (mismas firmas). La UI nunca toca el almacenamiento directamente, solo este interfaz.' Esa interfaz es la costura: la UI depende de la ABSTRACCIÓN, no de la implementación. Cambiar de backend = escribir una nueva clase que implemente `Store`, sin tocar un solo componente.

Cómo el tenant llega a la UI: `BrandProvider.tsx` recibe el `slug`, llama `store.getBrand(slug)`, y si existe hace `applyTheme(brand)` y mete el brand en un Context React (`useBrand()`). Si no existe, marca `notFound`. Patrón limpio: un punto de entrada resuelve el tenant, aplica su tema, y lo provee a todo el árbol. Cualquier componente hace `const brand = useBrand()` y pinta lo suyo. Nadie más toca el storage ni decide el tema.

El aislamiento entre tenants es la línea roja del white-label, y aquí está el riesgo real: `listSubscribers(slug)` filtra por `brandSlug`. En localStorage eso es trivial pero FRÁGIL — todo vive en el mismo navegador, no hay frontera de seguridad real. El momento crítico es la migración a Supabase: ahí el aislamiento DEBE moverse a Postgres RLS (una fila de subscriber solo es visible para su tenant), exactamente la disciplina que MOONKEY aplica con `is_admin()` y políticas self-or-admin. En multi-tenant real, el filtro `WHERE slug = ...` en el cliente NO es seguridad; la seguridad es RLS en la base.

Configuración por tenant sin ramas de código: fíjate que Espejo no tiene `if (tenant === 'lunarosa')` en ningún sitio. La variación es DATOS (`Brand.readings`, `Brand.voice`, `Brand.offer`), no lógica condicional. Esa es la regla del white-label sano: las diferencias entre clientes viven en configuración (datos), nunca en bifurcaciones de código. En cuanto escribes el primer `if (cliente X)` has empezado a forkear el producto y a perder la economía del modelo.

Hay un guardarraíl ético específico en Espejo que no es opcional: la memoria del proyecto marca que NO se construye sifón encubierto de leads ni venta de datos personales/biométricos — solo datos consentidos de primera parte y agregados anónimos. En un producto multi-tenant que maneja datos de los usuarios finales de tus clientes, el modelo de consentimiento (`types.ts` tiene versión del texto de consentimiento aceptado y `Brand.legal.responsible`) es parte de la arquitectura, no un añadido legal posterior. El responsable del tratamiento es el influencer-tenant, y eso debe estar modelado en los datos.

EJERCICIO

Construye un mini producto white-label: una interfaz `Store` con `getTenant(slug)`/`listItems(slug)`, una `LocalStore` sobre localStorage con 2 tenants seed (distinto accent, distinto nombre). Un `TenantProvider` que resuelva el slug de la URL, aplique el tema y lo provea por Context. Demuestra que cambiando solo el slug en la URL la app entera cambia de marca, sin un solo `if` por cliente.

ENTREGABLE

Carpeta `white-label-seam/` con `store.ts` (interface + LocalStore), `TenantProvider`, y dos rutas `/t/<slug>` que renderizan dos marcas distintas desde el MISMO código. Un `README` que señale dónde está la costura y qué se cambiaría para migrar a backend.

INSIGHT CLAVE

El seam Store de Espejo es la pieza de ingeniería más valiosa del producto: separa 'qué hace la app' de 'dónde viven los datos' detrás de una interfaz de 5 firmas. Esa indirección es lo que convierte una demo en localStorage en un SaaS multi-tenant — y lo que hace que el filtro por slug del cliente nunca pueda confundirse con seguridad real (esa vive en RLS).

ERRORES A EVITAR

  • ×Filtrar por tenant solo en el cliente (`WHERE slug=...` en JS) y creer que eso aísla datos — en backend el aislamiento DEBE ser RLS en Postgres, no un filtro de aplicación.
  • ×Meter `if (cliente === 'X')` en el código: en cuanto bifurcas por cliente has forkeado el producto; la variación va en datos/config, nunca en lógica.
  • ×Acoplar la UI directamente a localStorage/Supabase en vez de a la interfaz `Store` — pierdes la costura y la migración se vuelve una reescritura.
  • ×Olvidar el modelo de consentimiento y el responsable del tratamiento por tenant: en multi-tenant con datos de usuarios finales, eso es arquitectura, no trámite (límite ético de Espejo: nada de sifón encubierto de datos).
  • ×No definir el caso `tenant no encontrado`: sin un `notFound` explícito, un slug inválido rompe la app en vez de mostrar un estado limpio.
BD-04

i18n: una galaxia en 6 idiomas

lección

Arquitectar internacionalización para 6 idiomas sin duplicar páginas, entendiendo los patrones de traducción, el fallback, y por qué los hrefs deben ser locale-aware.

i18n hecho a la fuerza (una carpeta de páginas por idioma) multiplica el mantenimiento por 6 y garantiza que las traducciones se desincronicen. La arquitectura correcta tiene UNA plantilla y los idiomas son datos, no copias. Es la diferencia entre escalar a 6 locales o ahogarse en ellos.

LA LECCIÓN

MOONKEY corre 6 locales — `['es','en','th','fr','it','ch']` (config en `src/i18n/config.ts`). ES vive en la raíz; el resto bajo prefijo (`/en/`, `/th/`, `/fr/`, `/it/`, `/ch/`). La pieza que evita duplicar páginas es Astro i18n con `fallbackType: 'rewrite'` en `astro.config.mjs`: durante el build, Astro REESCRIBE cada página con el contexto de cada locale, así que un único `index.astro` genera 6 HTML traducidos. No hay `pages/en/index.astro`, `pages/fr/index.astro`… hay UNO. Esa es la regla central: una plantilla, N salidas.

El fallback es lo que hace el sistema robusto sin traducirlo todo de golpe: `astro.config.mjs` declara `fallback: { en:'es', th:'es', fr:'es', it:'es', ch:'es' }`, y la función `useTranslations` en `src/i18n/utils.ts` implementa la misma cadena: `dict[key] ?? base[key] ?? key`. Es decir: si falta una clave en francés, cae a español; si tampoco está, devuelve la clave misma (visible, fácil de cazar). Esto te deja LANZAR con traducciones parciales sin romper nada — el francés muestra español donde aún no tradujiste, no una página rota.

MOONKEY usa TRES patrones de i18n y CLAUDE.md avisa de la inconsistencia, lo cual es una lección de oficio en sí: (1) diccionario `src/i18n/ui.ts` con `t('key')` para chrome/nav corto (~240 keys); (2) objetos de contenido inline con `cLocale = (es|th) ? locale : 'en'` que colapsa FR/IT/CH a EN, para contenido pesado como el currículum; (3) páginas `src/pages/en/*.astro` que sobreescriben SOLO en inglés. El patrón 3 es deuda: hay 5 páginas raíz hardcodeadas en español, así que `/th/fr/it/ch` muestran ES en ellas. Lección: elige UN patrón dominante (el diccionario con fallback) y trata los otros como deuda a pagar, no como variedad sana.

El bug más caro de i18n con prefijos es el href crudo. Si escribes `<a href='/cuenta'>` desde una página bajo `/fr/`, mandas al usuario fuera de su idioma. Por eso MOONKEY tiene `localizedPath(pathname, target)` en `utils.ts`: quita el prefijo de locale actual, reconstruye la ruta y le antepone el target (`/fr/cuenta`), dejando ES en raíz. CLAUDE.md lo marca como obligatorio: 'usa `localizedPath('/ruta', locale)`, nunca `/ruta` crudo'. Internaliza esto: en un sitio con prefijos de idioma, CADA enlace pasa por la función de localización, sin excepción.

Detectar el locale activo tiene una sutileza que `utils.ts` resuelve: `resolveLocale(currentLocale, pathname)` prefiere `Astro.currentLocale` sobre la detección por URL, porque durante un rewrite de fallback la URL puede haber perdido el prefijo y solo el contexto de Astro sabe el locale real. Si detectas locale solo por `pathname.split('/')` te equivocas justo en las páginas que usan fallback. Regla: confía en el contexto del framework primero, en la URL como respaldo.

El selector de idioma necesita dos diccionarios separados: `LOCALE_LABELS` (ES, EN, TH…) para el switcher compacto y `LOCALE_NAMES` (Español, ไทย, Italiano…) para el dropdown completo. Y ojo a los casos raros: 'ch' aquí es Deutsch (CH), suizo-alemán, no chino — un comentario en `config.ts` lo aclara. Esa clase de ambigüedad (un código que parece otro idioma) es exactamente lo que documentas en el código para que nadie traduzca al idioma equivocado seis meses después.

EJERCICIO

Monta un sitio Astro con 3 locales (es raíz, en/fr con prefijo). Crea UN `index.astro` que use `t('hero.title')` desde un diccionario `ui.ts`. Implementa `useTranslations` con fallback `dict[key] ?? base[key] ?? key` y `localizedPath`. Deja una clave SIN traducir en FR y demuestra que cae a ES (no rompe). Pon un selector de idioma que use `localizedPath` para no sacar al usuario de su locale.

ENTREGABLE

Carpeta `i18n-3locales/` con `i18n/config.ts`, `ui.ts`, `utils.ts` (useTranslations + localizedPath), un `index.astro` único que genera 3 HTML, y una captura mostrando FR cayendo a ES en la clave sin traducir.

INSIGHT CLAVE

i18n bien hecho convierte el idioma en un parámetro de datos sobre UNA plantilla, no en copias de páginas. El fallback en cadena (`dict ?? base ?? key`) es lo que te permite lanzar con traducciones a medias sin páginas rotas — y `localizedPath` en cada href es la diferencia invisible entre un usuario que se queda en su idioma y uno que se cae a la raíz al primer clic.

ERRORES A EVITAR

  • ×Duplicar páginas por idioma (`pages/fr/index.astro`, `pages/en/index.astro`…) en vez de una plantilla con rewrite — multiplicas el mantenimiento por 6 y las traducciones se desincronizan.
  • ×Escribir hrefs crudos (`/cuenta`) bajo un prefijo de locale: sacas al usuario de su idioma; todo enlace pasa por `localizedPath`.
  • ×Detectar el locale solo por la URL: durante un rewrite de fallback la URL pierde el prefijo; prefiere `Astro.currentLocale` (`resolveLocale`).
  • ×Mezclar tres patrones de i18n sin uno dominante — MOONKEY ya arrastra deuda por eso (5 páginas hardcodeadas en ES que se muestran a th/fr/it/ch).
  • ×Asumir el significado de un código de locale: 'ch' aquí es suizo-alemán, no chino; documenta los ambiguos en el config o alguien traducirá mal.
BD-05

Deploy: Cloudflare, dominios, env

lección

Llevar un sitio de localhost a una URL real en Cloudflare Pages con dominio propio y variables de entorno gestionadas de forma segura, distinguiendo qué env va al cliente y qué jamás.

Un proyecto que no está desplegado no existe para nadie. Y el deploy es donde más secretos se filtran: la línea entre una variable pública y una privada es invisible hasta que tu service_role key aparece en el bundle de producción. Saber desplegar bien es la mitad de shippear.

LA LECCIÓN

MOONKEY se despliega en Cloudflare Pages (`moonkeylab.pages.dev`) desde el repo `garciafradepablo-pixel/xop`. El flujo base: conectas el repo de GitHub a Cloudflare Pages, defines el build command (`npm run build`) y el output directory (`dist/` en Astro). Cada push a main dispara un build en CI y publica. No subes archivos a mano: el deploy es una consecuencia de git push. Eso te da reproducibilidad — lo que está en producción es exactamente lo que está en el commit.

Para una SSG pura como MOONKEY, Cloudflare solo sirve los HTML estáticos del build; no hay runtime de servidor. Esto es clave para el modelo de seguridad (lo verás en Ops): como no hay handler server-side, el único env que importa en producción es el que se inyectó EN BUILD. Si tu app necesita lógica server (no es el caso de MOONKEY ni XNLAB de contenido), ahí entran Cloudflare Functions/Workers — pero no las metas si no las necesitas.

La frontera de los secretos es LA lección de este módulo. En Astro/Vite, las variables `PUBLIC_*` (Astro) o `VITE_*` (Vite puro como Espejo) se inyectan en el bundle del cliente — son VISIBLES para cualquiera que abra DevTools. Todo lo demás se queda en build. MOONKEY pone la Supabase anon key como pública a propósito: CLAUDE.md dice 'solo anon key' en `src/lib/supabase.ts`, porque la anon key está DISEÑADA para ser pública (su poder lo limita RLS, no el secreto). La regla absoluta: la `service_role` key NUNCA lleva prefijo público, NUNCA va a un sitio estático. Si la pones, está en el bundle, y es game over.

En Cloudflare configuras las env vars en el dashboard (Settings → Environment variables), separando Production de Preview. Las que tu build necesita exponer al cliente llevan el prefijo correcto; las que solo usa el proceso de build (tokens de API de terceros para generar contenido) no lo llevan y no acaban en `dist/`. Verificación obligatoria post-deploy: descarga tu JS de producción y haz `grep -ri 'service_role\|secret\|sk_'` sobre `dist/`. Si algo aparece, tienes una fuga que rotar inmediatamente.

Dominio propio: en Cloudflare Pages, Custom domains → añades tu dominio, Cloudflare crea los registros DNS (si el dominio está en Cloudflare es automático; si no, añades un CNAME al `*.pages.dev`). HTTPS es automático. El `.pages.dev` sigue funcionando como URL de respaldo. Para previews, cada PR/rama genera su propia URL efímera — úsalas para revisar cambios antes de mergear a main, sin tocar producción.

Failure modes de deploy reales: (1) el build pasa en local pero falla en CI porque una dependencia estaba en `node_modules` local pero no en `package.json` — corre `npm ci` (no `install`) en limpio para reproducir CI. (2) Variables que existen en local (`.env`) pero no configuraste en el dashboard → build pasa pero la app falla en runtime con undefined. (3) El warning cosmético de Astro sobre `Astro.request.headers` en builds estáticos con i18n rewrite — `astro.config.mjs` lo documenta como inofensivo, el build pasa. No persigas warnings que la config ya marcó como esperados.

EJERCICIO

Despliega un sitio Astro a Cloudflare Pages desde un repo de GitHub: configura build command `npm run build`, output `dist/`, y una variable `PUBLIC_API_BASE` y otra NO pública `BUILD_TOKEN`. Tras el deploy, descarga el JS de producción y demuestra con `grep` que `PUBLIC_API_BASE` aparece en el bundle y `BUILD_TOKEN` NO. Añade un dominio (o subdominio) y verifica HTTPS.

ENTREGABLE

Una URL pública viva + un `DEPLOY.md` con: el build config, la tabla de qué variable es pública y por qué, y el log de `grep` probando que el token de build no se filtró al cliente.

INSIGHT CLAVE

El deploy no es 'subir archivos', es congelar un commit en una URL con CI reproducible. Y el único error irreparable es de secretos: como un sitio estático no tiene servidor, todo lo que necesita en producción se inyecta en build — por eso la anon key (limitada por RLS) puede ser pública y la service_role jamás. El `grep` sobre `dist/` es tu última red antes de filtrar.

ERRORES A EVITAR

  • ×Poner una clave privada (service_role, secret de API) con prefijo `PUBLIC_`/`VITE_` o en un sitio estático — queda en el bundle visible; solo claves diseñadas para ser públicas (anon key limitada por RLS) van al cliente.
  • ×Subir el build a mano en vez de conectar el repo: pierdes reproducibilidad y no sabes qué commit está en producción.
  • ×Configurar env vars en `.env` local pero olvidarlas en el dashboard de Cloudflare → el build pasa y la app revienta en runtime.
  • ×Usar `npm install` para reproducir CI; usa `npm ci` en limpio, que respeta el lockfile y reproduce el fallo real de dependencias.
  • ×No verificar con `grep` sobre `dist/` tras el deploy: la fuga de un secreto es silenciosa hasta que alguien la encuentra.
BD-06

De localStorage a backend

lección

Migrar la capa de datos de localStorage a un backend (Supabase) sin reescribir la aplicación, aprovechando la costura Store para que el cambio sea swap de implementación, no refactor.

Toda app empieza con datos locales para ir rápido, pero localStorage no comparte entre dispositivos, no persiste de verdad y no tiene seguridad real. El salto a backend es inevitable — y si tu arquitectura no lo previó, ese salto es una reescritura dolorosa. Con la costura correcta, es una tarde.

LA LECCIÓN

Recupera el seam de BD-03: en Espejo `src/brand/store.ts` define `interface Store` con firmas async y `LocalStore` la implementa sobre localStorage. El comentario es la tesis entera del módulo: 'Hoy: localStorage. Mañana: Supabase (mismas firmas).' Porque la interfaz YA es async (`Promise<Brand | null>`), la UI no nota la diferencia entre leer de memoria o de la red — el `await` ya está ahí. Una API síncrona habría hecho la migración imposible sin tocar cada llamada. Diseñar la costura async desde el día uno es la decisión que paga aquí.

El swap concreto: escribes una clase `SupabaseStore implements Store`. `getBrand(slug)` pasa de leer localStorage a `await supabase.from('brands').select().eq('slug', slug).single()`. `addSubscriber` pasa de `write()` a `.insert()`. Las firmas son IDÉNTICAS. Al final, una línea cambia el mundo: `export const store: Store = new SupabaseStore()` en vez de `new LocalStore()`. Cero componentes tocados. Eso es lo que significa 'cambiar la capa de datos sin reescribir la app': el resto del código depende de `Store`, no de localStorage.

Pero migrar el almacenamiento es la mitad fácil. La mitad seria es la SEGURIDAD, y aquí cambia el modelo entero. En localStorage no hay fronteras: todo vive en el navegador del usuario. En Supabase, como el cliente solo lleva la anon key (MOONKEY: `src/lib/supabase.ts`, 'solo anon key'), la autoridad NO es el cliente — es Postgres RLS. MOONKEY lo formula sin ambigüedad: 'autoridad = RLS, no el cliente'. Cuando Espejo migre los subscribers, el filtro `WHERE brandSlug = slug` que hoy hace en JS DEBE convertirse en una política RLS que garantice que un tenant solo lee SUS subscribers. El filtro de cliente era conveniencia; RLS es seguridad.

Patrón de referencia de RLS en MOONKEY: las tablas `profiles/progress/feedback/proofs` tienen SELECT 'self-or-admin' vía `is_admin()` (función SECURITY DEFINER), verificado por impersonación — un no-admin no puede leer filas ajenas. `proofs` INSERT exige sesión y liga `user_id = auth.uid()` (política `proofs_insert_self`). Columnas sensibles (`role`, `founder_badge`) son inmutables para no-admins vía trigger guard, así que nadie se auto-escala. Esa es la plantilla mental para cualquier migración: cada operación (SELECT/INSERT/UPDATE) necesita una política que se cumpla AUNQUE el atacante controle el cliente.

Higiene de migración de datos: necesitas un esquema (tabla `brands`, tabla `subscribers` con `brand_slug`), aplicado como MIGRACIÓN versionada (no SQL suelto en la consola — MOONKEY: 'cambios de RLS/seguridad: migración versionada + `get_advisors` después'). Luego un script de una sola vez que lea el localStorage existente y haga `insert` masivo al backend, idempotente (que puedas re-correr sin duplicar). Y un periodo de doble escritura opcional si no puedes parar la app: escribes a ambos hasta confirmar paridad, luego cortas localStorage.

Ojo a la frontera ética que arrastra Espejo: la memoria del proyecto prohíbe sifón encubierto de datos personales/biométricos; solo datos consentidos de primera parte y agregados anónimos. Migrar a backend AUMENTA el poder de los datos (ahora persisten, se cruzan, se exportan), así que el modelo de consentimiento que en localStorage era casi teórico se vuelve real y auditable. La migración técnica y la responsabilidad sobre los datos suben juntas — no migres el almacenamiento sin migrar también las garantías de privacidad.

EJERCICIO

Toma el `white-label-seam` de BD-03 (interface Store + LocalStore). Escribe una `SupabaseStore implements Store` con las MISMAS firmas contra una tabla real de Supabase. Migra cambiando UNA línea (`new LocalStore()` → `new SupabaseStore()`) sin tocar componentes. Aplica una política RLS que garantice que `listItems(slug)` solo devuelve filas del tenant correcto, y verifícalo impersonando otro usuario.

ENTREGABLE

Carpeta `store-migration/` con `LocalStore` y `SupabaseStore` lado a lado, el diff de UNA línea que hace el swap, la migración SQL versionada con la política RLS, y un log probando que un tenant NO puede leer los datos de otro (test de impersonación).

INSIGHT CLAVE

La costura Store convierte una migración de backend de reescritura a swap de una línea — pero solo si la interfaz fue async desde el principio. Y la parte que de verdad importa no es mover los bytes: es que al pasar a un cliente con anon key, la autoridad se traslada de la app a Postgres RLS. El filtro por slug que tenías en JS no era seguridad; ahora tiene que serlo, en la base.

ERRORES A EVITAR

  • ×Diseñar la interfaz Store síncrona y descubrir en la migración que cada llamada necesita `await` — hazla async (Promise) desde el día uno aunque localStorage no lo necesite.
  • ×Creer que migrar el almacenamiento es el trabajo: la parte seria es trasladar el aislamiento del filtro JS de cliente a políticas RLS en Postgres ('autoridad = RLS, no el cliente').
  • ×Aplicar el esquema y las políticas como SQL suelto en la consola en vez de migraciones versionadas + `get_advisors` después.
  • ×Script de migración de datos no idempotente: si no puedes re-correrlo sin duplicar, una interrupción te deja datos corruptos.
  • ×Migrar a backend sin reforzar el consentimiento: persistir y cruzar datos personales eleva el riesgo; en Espejo el límite es datos consentidos de primera parte, nada de sifón encubierto.

Siguiente constelación

Signal

Quant & research