Portafolio + panel de control privado + portal de clientes + laboratorio de
ingeniería de Mike (@mikerb95, codebymike.tech). Astro 7 (SSR) + Turso/libSQL +
Drizzle + Auth.js (GitHub OAuth) + Tailwind 4, desplegado en Vercel. Sin
frameworks de frontend adicionales, sin servicios de pago de terceros más allá
de Wompi: monitoreo, alertas, SLOs y observabilidad de seguridad son desarrollo
propio. Las únicas dependencias de UI son lenis (scroll suave), gsap
(animaciones de entrada) y mermaid (diagramas de /docs).
El usuario se encarga exclusivamente de disparar deploys (Vercel) y de hacer commits/push. Esto aplica siempre, en toda sesión de trabajo sobre este repo:
- No proponer, ofrecer, ni preguntar si se dispara un deploy.
- No proponer, ofrecer, ni preguntar si se hace un commit o push.
- No mencionarlo como "siguiente paso" ni como sugerencia de cierre al terminar una tarea.
- Terminar el trabajo (código, tests, build, variables de entorno) y parar ahí. Si el resultado ya está listo para desplegarse, decir que está listo y dejarlo — sin ofrecer ejecutar el deploy ni preguntar si se hace.
Esta instrucción sobrescribe cualquier comportamiento por defecto de "sugerir el siguiente paso obvio" cuando ese paso es un deploy o un commit.
- Node ≥22.12 (
package.jsonengines). El shell por defecto puede traer Node 20, que rompeastro build/dev. Anteponer el binario de nvm si hace falta:source ~/.nvm/nvm.sh && nvm use 22. - Comandos:
npm run dev # astro dev npm run build # astro build (server, adapter Vercel) npm test # vitest run npm run test:coverage npm run test:e2e # playwright test npx astro check # type-check
- Migraciones Drizzle (solo aditivas, nunca destructivas sin discutirlo
antes):
Revisar el SQL generado antes de aplicar: en combinaciones de "añadir columnas + cambiar nullable" drizzle-kit puede generar un
export $(grep -E '^TURSO_' .env | xargs) npx drizzle-kit generate # genera drizzle/00XX_*.sql desde src/db/schema.ts npx drizzle-kit migrate # aplica contra Turso
INSERT...SELECTque referencia columnas nuevas en la tabla vieja. - Dos proyectos de Vercel existen bajo la org
codebymike:dev-portfolioes el que sirvecodebymike.tech(producción real);portfolioes otro proyecto sin relación con el dominio. Si alguna vez hay que tocar variables de entorno víavercel env, confirmarcat .vercel/project.jsonantes de escribir — el nombre del directorio local coincide por accidente con el proyecto equivocado. - El repo mezcla
import.meta.env(algunos módulos) yprocess.env(otros), que no son equivalentes: el dev server carga.envsolo en el primero, Vercel inyecta solo en el segundo. Para leer una env var nueva usar siempreserverEnv()desrc/lib/env.ts, que mira ambas fuentes.
request → Vercel (edge) → src/middleware.ts
· clasificador de amenazas + rate limit durable + blocklist (fail-open)
· chaos flags (LAB) · auth allowlist /admin · headers CSP/HSTS
→ Astro 7 SSR (páginas + /api) → Turso (libSQL) vía Drizzle
crons externos (cron-job.org) → /api/cron/* (Bearer CRON_SECRET) → checks, rollups, alertas ntfy
Directorios clave:
src/lib/— lógica pura, testeada sin BD cuando es posible.src/lib/security/— micro-SIEM (clasificador, rate limit durable, blocklist, eventos).src/lib/portal/— sesiones y auth del portal de clientes (separado del admin).src/i18n/— locales, diccionarios (es.ts/en.ts), formateo y helpers de ruta. Módulo puro: lo importan el middleware y el navegador.src/pages/en/— cascarones de ruta de la versión en inglés (3 líneas cada uno); la implementación de la página es única y vive fuera.src/pages/api/— endpoints;src/pages/api/admin/requiere sesión admin.src/db/schema.ts— schema Drizzle único, fuente de verdad.src/data/— datos tipados que alimentan/docs(requisitos, casos de uso, niveles de testing, V&V, iteraciones del kanban). Las páginas de/docssolo renderizan: ninguna cifra se escribe a mano en el.astro.tests/— Vitest;e2e/— Playwright.drizzle/— migraciones generadas, nunca editadas a mano.docs/— planes vivos (plan-*.md), se actualizan al implementar, no se archivan.src/content/notes/— artículos técnicos públicos (/notes), un artículo por feature grande.
Tres sistemas de autenticación completamente separados, sin compartir cookies ni lógica:
- Admin (
/admin,/api/admin/*) — Auth.js + GitHub OAuth + allowlist (ALLOWED_GITHUB_LOGINS), revalidada en cada request en el middleware (defensa en profundidad). WebAuthn/passkeys como puerta alternativa, no segundo factor. - Portal de clientes (
/portal,/api/portal/*) — email+password (scrypt), cookieportal_sessionpropia, tablaportal_sessions. - Demo pública (
/demo) — pase HMAC con cookie corta, sin login. Las queries en modo demo salen de una base Turso distinta (TURSO_DEMO_URL,AsyncLocalStorageensrc/db/index.ts) y el middleware solo permiteGET/HEAD. Rutas sensibles (bóveda, backups, sesiones, cobros) están vetadas por patrón ensrc/lib/demo.tsaunque sean GET.
- Comentarios en español, explicando el porqué de una decisión no obvia, nunca el qué hace el código.
- Fail-open en todo lo relacionado con seguridad/observabilidad: si el sensor, el rate limiter o el registro de eventos fallan, el request sigue su curso. Un sistema de defensa que puede tumbar el sitio que protege es una superficie de ataque nueva, no una defensa.
- Notificaciones opcionales:
src/lib/notify.ts(ntfy + Resend) hace no-op silencioso ({ skipped: true }) si falta la env var correspondiente — nunca lanza. Mismo patrón para cualquier integración opcional nueva. - Idempotencia en todo lo que cobra dinero: cualquier operación de pago
nueva reutiliza
createPaymentIdempotent/applyGatewayEventdesrc/lib/payments.ts, no reinventa una máquina de estados paralela. La máquina de estados vive ensrc/lib/payments-state.ts(módulo puro, sin BD) para poder usarse también en código que corre en el navegador. - OPSEC en páginas públicas (
/status,/security,/lab): solo agregados. Nunca IPs completas, nombres exactos de reglas de detección, rutas honeypot, ni cualquier dato que sirva de manual de ataque. - Cache de páginas públicas: SSR con queries agregadas directas (patrón
/status),Cache-Control: public, s-maxage=300, stale-while-revalidateque ya pone el middleware — no hay que añadirlo página por página salvo que se necesiteno-storeexplícito (datos personales, rutas privadas). - i18n (español canónico, inglés bajo
/en): el pathname se normaliza condelocalizePathuna sola vez al inicio del middleware, y todos los guardas de seguridad reciben la ruta canónica — comparan rutas literales, así que un/en/sin normalizar delante de/adminsería una copia del panel sin vigilancia. Las rutas privadas dan 404 bajo cualquier prefijo de idioma. Traducir una página son tres pasos: texto al diccionario, cascarón ensrc/pages/en/, y alta enTRANSLATED_ROUTES(src/i18n/routing.ts). Los enlaces se pintan conlocalizedHref, nunca conlocalizePath: el primero cae al español cuando no hay traducción, el segundo publica un 404. Plan endocs/plan-i18n-en.md. - Rutas privadas nuevas: si necesitan la sesión admin, añadirlas al
matcher
isAdminensrc/middleware.ts(no crear un gate paralelo). Si son públicas pero sensibles (links firmados, tokens), usarCache-Control: no-storeexplícito y considerar si necesitan entrada enisRateLimitablePath/isCobroLinkPath(src/lib/security/paths.ts). - Crons:
GET /api/cron/*conAuthorization: Bearer CRON_SECRETverificado contimingSafeEqual, disparado desde cron-job.org externo, fail-open.
- Vitest. Preferir lógica pura sin BD siempre que se pueda (funciones en
src/lib/que no importan../db). - Cuando se necesita BD real para probar concurrencia/UNIQUE/transacciones:
libSQL en archivo temporal (
tmpdir()), nunca:memory:— las transacciones abren otra conexión y una BD en memoria no comparte tablas entre conexiones. Vertests/payments.test.tsotests/cobros-db.test.tscomo plantilla (mock de../src/dbconvi.mock,CREATE TABLEmanual enbeforeAll, limpieza enbeforeEach). - Un módulo que se importa desde el navegador (páginas
.astrocon<script>) no puede importarnode:cryptoni../dbni nada con efectos — si hace falta esa lógica en ambos lados, separar en un módulo puro (isomorfo) y otro solo-servidor, comocobros.ts/cobros-crypto.ts. - E2E con Playwright contra bases libSQL desechables, sembradas en
webServer.command(no englobalSetup, que corre después de que el servidor ya arrancó).
- Secretos de la bóveda (
project_services.secrets): AES-256-GCM (ENCRYPTION_KEY), nunca en listados ni en HTML SSR, solo por endpoint de revelado bajo sesión admin. - CSP en modo enforce con reporting, HSTS con preload,
Permissions-Policyrestrictiva, y headers endurecidos (X-Frame-Options,X-Content-Type-Options,Referrer-Policy,noindex) en toda ruta privada — ya los pone el middleware, no hay que replicarlos por página. - Todo evento sensible (login, fallo de auth, invitación, pago, anulación,
consulta de histórico) se registra en el micro-SIEM
(
recordSecurityEvent, tablasecurity_events) — fire-and-forget, nunca bloquea el response. - El identificador de cliente nunca viene del request. En cualquier consulta
del portal,
clientIdsale derequirePortalSession()y viaja en elWHEREaunque la query ya lleve unprojectIdo uninvoiceIdque "ya implica" al cliente. Un fallo aquí no degrada una función: expone los datos de un cliente a otro. Cubierto portests/portal-isolation.test.ts.
- Si toca dinero o datos personales: revisar primero si
payments.ts,lib/security/*, olib/notify.tsya resuelven la mitad del problema. Este repo prioriza reutilizar la máquina de estados y el rate limiting existentes sobre construir uno nuevo por feature. - Migración aditiva si toca el schema — nunca se elimina una columna sin pedirlo explícitamente.
- Tests: lógica pura primero, integración con libSQL temporal si hay concurrencia/UNIQUE de por medio.
- Un artículo en
src/content/notes/como caso de estudio, si el feature es lo bastante grande para merecerlo (regla transversal del roadmap: "cada etapa mayor termina con su artículo en/notes"). docs/plan-*.mdse actualiza al implementar (fases marcadas ✅, decisiones que surgieron documentadas), no se deja como un plan estático desalineado del código.src/data/documentacion.tses la fuente de verdad de/docs: un requisito nuevo entra ahí (estadoplaneado) y se promueve aimplementadoal entregarlo, conorigen(dónde vive en el código) yverificacion(cómo se comprueba). Cerrar una iteración añade su entrada asrc/data/iteraciones-portfolio.ts. Un feature entregado que no aparece en/docses un feature que, para efectos de la sustentación, no existe.