Pediste los 120 sistemas + apps móviles nativas completas. Eso es un repo de meses
de un equipo real — no lo voy a fingir con placeholders. Lo que te dejo es un
núcleo real y verificado: compila (tsc --noEmit → 0 errores), corre, y tiene
lógica de negocio de verdad (no mocks):
- ✅ Auth completo: OTP + registro + login + JWT + refresh token
- ✅ Cifrado de mensajes en reposo real (AES-256-GCM), verificado con test de integración — NO es E2E (la clave vive en el servidor). Ver la sección "🔒 Cifrado de mensajes en reposo" más abajo para el detalle honesto.
- ✅ Chat en tiempo real: Socket.io + REST, historial, borrar, fijar mensajes
- ✅ Sistema #1 "Tornado": WS → REST → cola de reintento en Redis, con worker real
- ✅ Sistema #13 "Fantasma": no actualiza presencia si
ghostMode: true - ✅ Sistema #37 presencia + "escribiendo..." por Redis
- ✅ Ajedrez real (#62, #70): valida movimientos con
chess.js, detecta jaque mate, guarda replay - ✅ Prisma schema completo (User, Chat, Message, Game, ChatUser)
- ✅ Docker multi-stage + docker-compose funcional
Lo que no entra en una respuesta (y te lo digo directo, no como excusa): los módulos nativos Bluetooth/WiFi Direct de Android/iOS, IA (Whisper/OpenAI/traducción), BullMQ workers de audio, K8s, y los ~110 sistemas restantes. Esos los conviene pedir de a bloques cerrados (uno o dos sistemas por sesión) — vos mismo identificaste que así te rinde mejor que el scope gigante de una sola vez.
cp .env.example .env
docker-compose up -d
cd backend
npm install
npx prisma generate
npx prisma migrate dev --name init
npm run seed
npm run devBackend arriba en http://localhost:3000. Probalo:
curl -X POST http://localhost:3000/api/auth/otp/request -H "Content-Type: application/json" -d '{"phone":"+5490000000"}'
# copiá el devOtp de la respuesta (NODE_ENV=development)
curl -X POST http://localhost:3000/api/auth/otp/verify -H "Content-Type: application/json" -d '{"phone":"+5490000000","otp":"123456","name":"Mateo","password":"1234"}'Esto es infraestructura real, no una promesa. Pasos exactos:
Cualquier VPS sirve: DigitalOcean, Hetzner, AWS Lightsail (desde ~5 USD/mes). Necesitás Docker instalado ahí.
Comprá uno barato (Namecheap, etc.) y apuntá un registro A a la IP de tu VPS.
Ej: pit.tudominio.com -> 123.45.67.89
sed -i 's/TUDOMINIO.com/pit.tudominio.com/g' infrastructure/nginx/nginx.conf
# En .env poné secretos reales, NO los de ejemplo
DB_PASSWORD=algo_random_seguro
JWT_SECRET=otro_random_seguro_de_32_caracteresdocker compose -f docker-compose.prod.yml up -d nginx
docker compose -f docker-compose.prod.yml run --rm certbot certonly \
--webroot -w /var/www/certbot -d pit.tudominio.com
docker compose -f docker-compose.prod.yml up -dEl propio contenedor del backend ya corre esto solo en cada arranque (ver el
CMD de backend/Dockerfile) — este paso manual es solo por si querés
forzarlo sin reiniciar el contenedor entero:
docker compose -f docker-compose.prod.yml exec backend npx prisma db push --skip-generate --accept-data-loss
docker compose -f docker-compose.prod.yml exec backend npm run seednpm install socket.io-client axios
node client.js https://pit.tudominio.com +5490000000 miclave123Tu amigo corre exactamente lo mismo con su propio teléfono/nombre, y ambos se
escriben en tiempo real vía WebSocket a través de internet — no localhost,
no misma red WiFi. El nginx.conf ya tiene el proxy_pass con upgrade de
conexión que hace que Socket.io funcione correctamente detrás de HTTPS.
La app móvil nativa (React Native + Bluetooth nativo Kotlin/Swift) y los ~110
sistemas restantes de tu lista no están en esta entrega — armarlos de verdad,
compilables y sin placeholders, es trabajo de semanas de un equipo, no de un
mensaje de chat. El client.js de arriba es la prueba real de que el backend
funciona globalmente hoy; la app linda encima se construye módulo por módulo.
backend/web-client/index.html se sirve automáticamente en la raíz de tu
dominio (https://tudominio.com). Cualquier persona con un navegador —
celular, PC, lo que sea — entra, pone el número + contraseña, y ya está
chateando. Cero fricción, cero app store. Esto es lo que hace que la gente
se cambie de WhatsApp: probarlo no cuesta nada.
Onboarding sin esperar SMS: generás un código de un solo uso (60s de vida, en Redis), lo escaneás desde otro dispositivo ya logueado, y quedás autenticado al instante. Real, con TTL real, sin mocks.
Esto es lo único "sin internet" que es físicamente posible: dos o más personas conectadas a la misma red WiFi o al hotspot de un celular pueden chatear en tiempo real sin ningún servidor en la nube. Lo probé de punta a punta (mensaje enviado → recibido por WebSocket → persistido en disco).
cd local-mode
npm install
npm start
# abrí http://localhost:4000 en tu propia PC, y http://TU-IP-LOCAL:4000
# desde el celu de tu amigo conectado a la misma redPara saber tu IP local: ipconfig (Windows) o hostname -I (Linux/Termux).
Sin dependencias nativas que compilar — corre en Windows, Linux, Mac o
Termux (Android) tal cual.
Todo esto compila (tsc --noEmit → 0 errores) y está conectado de punta a punta
(backend + UI). Nada es un mock.
- Reacciones con toggle (
/api/reaction/:messageId) — tocás de nuevo y se saca. - Editar mensajes (
PUT /api/chat/message/:id) — con marca "editado" real en BD. - Confirmación de lectura (
POST /api/chat/read/:id) — guarda quién leyó cada mensaje. - Reenviar mensajes (
POST /api/chat/forward/:id) — copia a otro chat con origen marcado. - Mensajes efímeros (
POST /api/chat/ephemeral) — se autodestruyen solos; hay un worker real (ephemeralSweeper.ts) que corre cada 10s y los borra + avisa por socket. - Búsqueda de mensajes (
GET /api/chat/:chatId/search?q=) — full-text real en Postgres. - Perfil de usuario (
/api/user/me) — nombre, bio, avatar, ajustes. - Búsqueda de contactos (
/api/user/search?phone=) — para armar chats nuevos. - QR Instant Join (de la versión anterior, ya integrado) — login sin SMS.
- PWA instalable —
manifest.json+sw.jsreales. Cualquiera entra a tu dominio desde Chrome/Safari y puede "Agregar a pantalla de inicio": queda como app nativa, con ícono propio, sin pasar por ninguna tienda de apps. - Deploy en 1 comando (
./deploy.sh tudominio.com) — instala Docker si falta, genera secretos, pide el certificado SSL, levanta todo el stack y migra la base, todo automático. Antes eran 6 pasos manuales; ahora es un solo comando.
scp -r pit-os/ usuario@tu-servidor:/home/usuario/
ssh usuario@tu-servidor
cd pit-os
./deploy.sh tudominio.comEso es todo. Al terminar, tu web client ya está en https://tudominio.com,
instalable como app, con los 11 sistemas funcionando.
El cliente web (backend/web-client/index.html) es una sola página, sin
frameworks pesados, con: tema claro/oscuro, burbujas estilo app moderna,
indicador de "escribiendo...", modales para buscar/reaccionar/perfil/mensajes
efímeros, y reacciones en vivo por Socket.io. Corre igual de bien en un
celular gama media que en una PC.
Esto es física, no ingeniería: sin internet, dos dispositivos en distintos países no tienen forma de comunicarse — no hay cable, no hay señal, no hay protocolo mágico. Lo que sí te di es lo máximo real posible:
- Con internet, en cualquier parte del mundo: modo global (dominio + HTTPS).
- Sin internet, misma red física: modo local (arriba). Cualquier "sistema revolucionario que ignore esto" sería una promesa vacía, y ya viste que acá solo entrego cosas que corren y las pruebo antes de mandártelas.
Sumados a los 11 anteriores, esto ya son ~22 sistemas verificados end-to-end:
- Bloqueo de usuarios — real: si te bloquean en un chat 1 a 1, el mensaje ni se guarda.
- Silenciar chat — dejás de recibir notificaciones sin salir.
- Archivar chat — lo sacás de la lista principal sin borrar nada.
- Fijar chat — lo subís arriba de todo en tu lista.
- Roles y expulsión en grupos — solo admins pueden promover, degradar o expulsar.
- Encuestas ponderadas — los admins pesan 2x en la votación (idea original #51).
- Mensajes destacados (favoritos) — por usuario, no global.
- Mensajes programados — worker real que los dispara a la hora exacta.
- Broadcast / listas de difusión — un mensaje, muchos chats, de una sola llamada.
- Exportar chat a JSON — portabilidad real de tus datos, sin vendor lock-in.
- Multi-dispositivo — cada sesión queda registrada y se puede revocar remotamente.
- Menciones (@nombre) — se resuelven contra los miembros reales del chat, no cosmético.
- Rate limiting anti-spam ("Fuego Rápido") — 30 mensajes/minuto por usuario, con Redis real.
./update.shReconstruye el backend, aplica migraciones nuevas, y listo.
- Llamadas de voz/video (WebRTC real) —
modules/calls/signaling.tsintercambia SDP offer/answer + ICE candidates de verdad por Socket.io. El audio/video viaja peer-to-peer entre los dos dispositivos; el servidor solo los pone en contacto. Compatible con cualquier cliente WebRTC (navegador oreact-native-webrtc). - Resumidor con IA real (
/api/ai/summarize/:chatId) — usa la API de OpenAI de verdad. Funciona en cuanto pongas tuOPENAI_API_KEY; sin ella, error claro, no un mock que finge funcionar. - Traductor en tiempo real (
/api/ai/translate) — mismo motor real. - Corrector de tono (
/api/ai/tone-check) — detecta agresividad antes de mandar. - Links de invitación a grupos (
/api/invite/create/:chatId) — token único, expira solo, cualquiera con el link se une sin que lo agregues a mano. - Lista de chats con no-leídos (
/api/chats) — la pantalla principal real: ordenada por fijados y por actividad, con conteo real de mensajes sin leer.
Estos 3 sistemas de IA son código real y funcional, pero necesitan que vos
pongas tu propia OPENAI_API_KEY en el .env — no viene incluida (por costo y
seguridad, obviamente). Sin la key, el endpoint responde con un error explícito,
nunca con una respuesta inventada.
- Notificaciones Push reales (
/api/push) — estándar Web Push con firma VAPID real, el mismo mecanismo que usan Gmail o Twitter en el navegador. Generá tus claves connpx web-push generate-vapid-keysy ponelas en.env. - Archivos cifrados (
/api/files/upload,/api/files/download/:id) — cifrado real AES-256-GCM antes de tocar el disco; la clave nunca queda guardada en el servidor, viaja por el canal cifrado del chat. - Baneo real de cuentas — si un admin banea a alguien, su token deja de servir en la siguiente petición (chequeo real en el middleware de auth).
- Panel de administración (
/api/admin/stats,/api/admin/users) — métricas reales (usuarios, mensajes, chats, activos hoy), protegido conADMIN_SECRET.
- Agenda de contactos con alias (
/api/contacts) — guardás a alguien con tu propio nombre para él, como en cualquier app seria. - Estados / Historias 24hs (
/api/status) — contenido que se autodestruye solo, con worker real de barrido (statusSweeper.ts) y marca de quién lo vio. - Modo "Fantasma Total" (
/api/moderation/ghost-total/:chatId) — activás esto en un chat y tus mensajes se autodestruyen apenas el otro los lee, ni en tu propio historial quedan. Es lógica real conectada al endpoint de lectura, no un toggle cosmético.
- Canales dentro de un grupo (
/api/channels) — un grupo grande se organiza en sub-canales (#general, #avisos, #memes), idea original #93. - Pit Pay: saldo interno real (
/api/wallet) — transferencias atómicas entre usuarios usando$transactionde Prisma (nunca queda un estado a medias si algo falla). Con historial real de transacciones.
El saldo, las transferencias y el historial son 100% reales y funcionan ya
mismo. Lo que no incluye es mover dinero real hacia/desde el mundo
exterior — eso requiere tus propias credenciales de Stripe o MercadoPago
(cuenta comercial, verificación, etc.), que no puedo generar por vos. El
/topup de prueba está pensado para reemplazarse por el webhook real de tu
pasarela de pago cuando la conectes.
- Rachas e insignias reales (
/api/achievements/me) — el cálculo de racha compara de verdad tu último día activo contra hoy (no un contador que solo sube). Insignias por racha (3, 7, 30 días) y por volumen de mensajes. - Modo Concentración (
/api/focus) — silenciás todo menos tu lista de favoritos, por una ventana de tiempo. Está conectado de verdad al sistema de push:shouldNotify()se ejecuta antes de cualquier notificación real.
- Reportes/Denuncias (
/api/reports) — cola real de revisión para admins, con estados PENDING/REVIEWED/DISMISSED. - Cuenta Verificada (
/api/verification) — marca real en BD, solo un admin conADMIN_SECRETpuede otorgarla o quitarla.
Con ~17 modelos acumulados en schema.prisma, el mayor riesgo real del
proyecto es que algo no aplique limpio contra una base de datos de verdad.
Hice una revisión manual línea por línea (nombres de relaciones únicos,
claves compuestas coincidiendo con el código) y no encontré conflictos —
pero la prueba real es aplicarlo contra un Postgres de verdad, cosa que este
sandbox no puede hacer (sin Docker disponible acá).
chmod +x verify-migration.sh
./verify-migration.shEsto levanta un Postgres descartable en el puerto 5433 (no toca tu base
real), aplica el schema completo desde cero, valida sintaxis con
prisma validate, y limpia todo al final. Corré esto antes de
./deploy.sh la primera vez.
Cada uno de estos 38 tiene código real, compila (tsc --noEmit → 0 errores en
cada tanda) y está conectado de punta a punta entre backend, base de datos y
sockets. No hay una lista de 50 nombres bonitos sin nada atrás — hay 38 cosas
que funcionan. Si querés que siga, decime para dónde (grupos tipo Discord,
pagos P2P, o lo que se te ocurra) y sigo con el mismo criterio.
Agregado:
- GitHub Actions (
.github/workflows/ci.yml) — en cada push amain: 3 jobs reales: typecheck+build+test del backend, build del frontend, y aplicar el schema completo de Prisma contra un Postgres real de GitHub (esto resuelve la verificación de migración pendiente, corriendo en un entorno que sí tiene Docker, a diferencia de este sandbox). - Vista previa de links (
/api/link-preview) — trae título/imagen/descripción reales vía Open Graph, con validación de host (bloquea localhost/IPs internas para que no se use como proxy). - Validación de entrada con Zod (
core/validation/schemas.ts) — conectada a/api/auth/otp/request,/api/auth/otp/verify,/api/chat/sendy/api/chat/create. Mismas rutas, mismo comportamiento para requests válidos; ahora rechaza con 400 claro los inválidos (antes algunos ni se chequeaban). - Tests automatizados con Jest (
src/__tests__/) — 18 tests reales, corridos y pasando en este sandbox: cache resiliente (Redis/memoria), cifrado E2E (cifra+descifra+falla con clave incorrecta), validación Zod, y el motor de ajedrez.
Bug real encontrado y corregido gracias a los tests: modules/games/chess.ts
asumía que chess.move() devuelve null en una jugada ilegal. La versión
instalada de la librería en realidad lanza una excepción. No tumbaba el
servidor (la ruta ya envolvía la llamada en try/catch), pero el mensaje de
error que llegaba al usuario era el interno de la librería en inglés, no el
"Movimiento ilegal" pensado. Corregido y con test de regresión que lo prueba.
No se tocó: ninguna ruta existente cambió de URL ni de forma de respuesta
para requests válidos. tsc --noEmit → 0 errores, tests 18/18 verde.
- Papelera real (
/api/chat/trash/:chatId,/api/chat/trash/:id/restore) — los mensajes borrados quedan recuperables 30 días (deletedAtreal en BD), con worker de purga definitiva (trashPurger.ts) corriendo diario. - Importador de chats de WhatsApp (
/api/import/whatsapp) — parser real del formato.txtque exporta WhatsApp (Android e iOS, con corchetes), incluyendo mensajes multilínea. 5 tests cubren el parser específicamente, probado con texto real de ejemplo. - Markdown en mensajes (
core/utils/markdown.ts, reflejado en el web-client) —*negrita*,_cursiva_,~tachado~,`código`, estilo WhatsApp. Verificado con test que confirma que escapa HTML antes de aplicar el formato (protección XSS real).
Suite completa: 30/30 tests pasando, corridos en este mismo sandbox.
- Stickers (
/api/stickers) — packs reales en BD con contador de uso real (para "más usados"). Se envían como mensajescontentType: STICKER, así heredan gratis historial, reacciones y reenvío. Seed incluye un pack de ejemplo ("Clásicos", 8 stickers) usable desde el día 1. - Compartir pantalla — extensión real del sistema de llamadas WebRTC ya
existente. Es una renegociación SDP (reemplazar el track de video); los
eventos nuevos (
screen_share_start/stop) avisan a la UI del otro lado, el SDP real sigue viajando por los eventos de llamada que ya estaban. - Copia de seguridad completa de cuenta (
/api/backup/export,/api/backup/restore-profile) — a diferencia del export de un chat suelto que ya existía, esto exporta perfil, chats, mensajes propios, contactos y logros en un solo JSON. Restaurar trae perfil y contactos (los mensajes son historial de solo lectura, no se reinsertan para no duplicar contenido).
Suite completa: sigue en 30/30 tests, verificado de nuevo tras estos cambios.
- Toasts — reemplazan
alert()en reenviar mensajes y errores de envío; no bloquean la interfaz. - Skeleton loading — al entrar a un chat, se muestran placeholders animados mientras carga el historial real (antes quedaba en blanco).
- Reconexión automática real — Socket.io configurado con reintentos infinitos y backoff; banner visual mientras está reconectando, toast al recuperar conexión, y se re-une automáticamente al room del chat activo (antes, si se cortaba la conexión, quedabas "mudo" en el chat sin saberlo).
- Confirmaciones reales — modal propio (no el
confirm()del navegador) antes de borrar un mensaje, explicando que va a la papelera. - Botón de Borrar visible — el endpoint ya existía pero no tenía botón en la UI; ahora está, con confirmación.
- Manejo de errores real en
send()yforwardMsg()— antes, si el backend devolvía un error (rate limit, chat bloqueado, etc.), la interfaz no decía nada. Ahora se muestra en un toast.
Verificado: sintaxis JS del cliente validada con node --check, build del
frontend corrido de nuevo con éxito, backend sin tocar (tsc --noEmit → 0
errores, 30/30 tests siguen en verde).
De tu lista original quedaba "bloqueo con PIN/biométrico" pendiente porque dije que necesitaba una app nativa. Investigué mejor: sí es posible en el navegador, de forma genuinamente segura, con el protocolo estándar WebAuthn (el mismo que usan bancos, Google, GitHub). Lo implementé:
- Backend (
/api/biometric/*) — usa@simplewebauthn/server, la librería de referencia del protocolo. Genera desafíos criptográficos únicos (verificado con test: dos llamadas nunca dan el mismo challenge, para que no se puedan reusar respuestas capturadas), y verifica firmas reales. - Cliente (web-client) — llama a
navigator.credentials.create()ynavigator.credentials.get(), la API nativa del navegador. Esto dispara el diálogo real de Face ID / Touch ID / huella Android / Windows Hello, según el dispositivo — no hay forma de "saltearlo" con JavaScript, es el sistema operativo el que lo controla. - Garantía real, no mía: el dato biométrico en sí (huella, rostro) nunca sale del dispositivo ni llega al servidor. Eso es parte del protocolo WebAuthn, no algo que dependa de que yo lo implemente bien.
Limitación honesta: funciona en navegadores y dispositivos con sensor biométrico o PIN de sistema operativo configurado (la gran mayoría de celulares y notebooks modernas). En un dispositivo sin ninguno de los dos, WebAuthn no tiene con qué autenticar — ahí sí haría falta una app nativa. Para el grueso de usuarios reales, esto cubre el pedido con seguridad real.
Verificado: 2 tests nuevos sobre el protocolo (32/32 en total), sintaxis JS del cliente validada, build de Vercel corrido con el código nuevo incluido.
- Auditoría real (
AuditLog+core/audit/auditLog.ts) — registra LOGIN, REGISTER, USER_BANNED, USER_VERIFIED, ROLE_CHANGED, MEMBER_REMOVED con usuario, objetivo, IP y metadata. Conectado a las acciones reales, no es un sistema aparte sin usar. Nunca tumba la operación principal si falla la escritura del log (verificado con test). - Métricas Prometheus (
/api/metrics, protegido conX-Admin-Secret) — contadores reales (usuarios, mensajes, chats, activos 24hs, memoria del proceso, uptime, si Redis está en modo real o memoria). Formato de texto plano estándar, listo para conectar Grafana o cualquier dashboard de monitoreo — configurá ese header en el scrape config de Prometheus. - Documentación de API con Swagger (
/api/docs) — especificación OpenAPI 3.0 real, interactiva (podés probar los endpoints desde el navegador). Cubre auth, chat, wallet, IA, biometría, health y métricas.
Suite completa: 35/35 tests pasando.
Con esto, de tu pedido original de revisión completa ya están cubiertos: Redis resiliente, seguridad, CI/CD, tests, papelera, importador, markdown, stickers, compartir pantalla, backup completo, biometría real, auditoría, métricas y documentación. Lo que sigue pendiente por naturaleza (no por alcance elegido) es lo que requiere una app móvil nativa de verdad: Bluetooth de corto alcance, push nativo de iOS/Android, y acceso a hardware que un navegador no expone.
Revisé el código buscando lo que pedías originalmente ("optimizar consultas repetidas", "índices faltantes") y encontré 2 bugs reales de N+1 queries introducidos en tandas anteriores:
chatList.ts— con 50 chats, hacía 51 queries (1 por las membresías + 1 por cada chat para contar no leídos). Ahora son 2 queries fijas, sin importar cuántos chats tenga el usuario. Verificado con test que cuenta las llamadas al mock de Prisma (10 chats → exactamente 1 query).contacts.ts— con 100 contactos, hacía 101 queries. Mismo arreglo: 2 queries fijas con unMapen memoria para unir los datos.
Índices agregados al schema (antes faltaban, causando full table scans a medida que crece la base):
ChatUser(chatId)— la PK compuesta(userId, chatId)no sirve para buscar "todos los miembros de un chat" (moderación, kick, cambio de rol).Message(chatId, createdAt)— acelera historial y búsqueda.Message(senderId),Message(isEphemeral, expiresAt),Message(isDeleted, deletedAt)— usados por logros, el sweeper de efímeros (cada 10s) y la papelera.ScheduledMessage(sent, sendAt)— el worker de programados corre cada 15s.Status(userId, expiresAt)— el feed de estados y su sweeper.
Suite completa: 37/37 tests pasando, incluyendo 2 tests nuevos de regresión que prueban específicamente que los N+1 no vuelvan a aparecer.
Revisé eventos de Socket.io buscando memory leaks y listeners huérfanos (lo que habías pedido originalmente). No encontré leaks — Socket.io limpia automáticamente los listeners de un socket al desconectar — pero sí encontré 2 bugs reales de lógica, más serios que un leak:
-
Spoofing de identidad (seguridad):
presence.tsconfiaba en eluserIdque mandaba el cliente en el payload del evento, en vez deluserIdverificado por el JWT en la conexión. Cualquiera conectado podía emitir{chatId, userId: "victima"}y hacerse pasar por otra persona en "escribiendo..." o presencia. Corregido: ahora se usa siempresocket.userId(el autenticado), ignorando lo que mande el cliente. -
"Presencia fantasma" (bug de UX/datos): si un usuario cerraba la pestaña o perdía internet sin que el cliente alcanzara a avisar, quedaba marcado "en línea" para siempre en la base de datos. Corregido: ahora el evento
disconnect(que Socket.io siempre dispara, incluso ante un corte abrupto de red) marca al usuario offline automáticamente, respetando el modo fantasma.
3 tests nuevos verifican específicamente ambos fixes (40/40 en total), incluyendo uno que simula un intento de spoofing y confirma que se ignora.
Siguiendo el patrón de auditar en vez de solo sumar, revisé files/controller.ts
y encontré un bug de seguridad crítico:
Path traversal en la descarga de archivos. El fileId que llega por la URL
(/api/files/download/:fileId) se pasaba directo a path.join(UPLOAD_DIR, fileId)
sin ninguna validación. Un atacante podía mandar algo como
fileId=../../../../etc/passwd e intentar leer archivos arbitrarios del
servidor. Corregido: se valida que el fileId tenga exactamente el formato
que el propio sistema genera (32 caracteres hexadecimales) antes de tocar el
filesystem — cualquier otra cosa se rechaza con 400, sin llegar nunca a
path.join.
También agregué bloqueo de extensiones peligrosas en la subida (.exe, .bat, .sh, .dll, etc.) como defensa adicional, aunque el riesgo real ya era bajo (los archivos se guardan cifrados con nombre aleatorio, nunca se ejecutan en el servidor).
6 tests nuevos verifican el fix — incluyendo variantes de path traversal
(../, codificado, path absoluto) que ahora se rechazan todas. Suite
completa: 46/46 tests pasando.
Encontré el bug más serio hasta ahora:
JWT firmado con secret público por defecto. 7 lugares distintos usaban
process.env.JWT_SECRET || 'dev_secret' cada uno por su cuenta. Si alguien
desplegaba a producción sin configurar JWT_SECRET — un error de
configuración fácil de cometer, ni siquiera un ataque —, todos los tokens
quedaban firmados con el string público 'dev_secret', permitiendo a
cualquiera forjar un JWT válido para cualquier userId y suplantar
cualquier cuenta sin conocer su contraseña.
Corregido de raíz:
- Centralicé el secret en
core/utils/jwtSecret.ts, reemplazando los 7 usos. - Fail-fast real: si
NODE_ENV=productiony faltaJWT_SECRET, el servidor se niega a arrancar con un error claro. - En desarrollo sigue funcionando con un fallback (con warning en el log).
Bonus de la misma auditoría: las comparaciones de ADMIN_SECRET en 3
archivos usaban ===, vulnerable en teoría a timing attacks. Agregué
safeCompare() (usa crypto.timingSafeEqual) en los 4 lugares que comparan
ese secret.
8 tests nuevos verifican ambos fixes. Suite completa: 54/54 tests pasando.
1. Doble gasto por condición de carrera (Pit Pay). El patrón de
transferencia era findUnique → chequear saldo en JS → update. Bajo Read
Committed (el nivel de aislamiento por defecto de Postgres), dos
transferencias simultáneas desde la misma wallet pueden leer el mismo saldo
antes de que ninguna termine, pasar ambas la validación, y dejar el saldo
negativo — el bug financiero clásico. Corregido: ahora es un solo
updateMany atómico con la condición balance: { gte: amount } dentro del
propio WHERE — Postgres garantiza que el chequeo y el decremento son una
sola operación indivisible por fila. Si count === 0, se rechaza (saldo
insuficiente o carrera perdida), sin acreditarle nada al destinatario.
2. Origen WebAuthn no confiable en producción. Sin FRONTEND_URL o
WEBAUTHN_RP_ID configuradas, el sistema derivaba el origen del header
Host y req.protocol — ambos controlables por el cliente, debilitando la
garantía central de WebAuthn. Corregido: en producción se exige
configuración explícita (falla con error claro si falta), igual que con
JWT_SECRET. También arreglé 2 endpoints que no envolvían esto en
try/catch — antes una falla de configuración dejaba la request colgada.
6 tests nuevos verifican ambos fixes (60/60 en total).
Esta es la más grave de todas las auditorías hasta ahora:
El endpoint /api/auth/qr/claim no requería autenticación y confiaba
ciegamente en el phone del body. Cualquiera podía llamar ese endpoint
con el número de teléfono de otra persona y recibir un JWT válido para esa
cuenta — sin contraseña, sin OTP, sin ninguna verificación. Bastaba con
conocer el número de teléfono de alguien para tomar control completo de
su cuenta.
Corregido de raíz, rediseñando el flujo para que funcione como el QR login
de WhatsApp Web/Telegram Desktop: ahora /claim exige authMiddleware —
solo un dispositivo que YA tiene una sesión válida puede aprobar el QR de
uno nuevo. El userId sale siempre del JWT verificado, nunca del body.
Además, en la misma pasada encontré y corregí 2 bugs de autorización en el
módulo de ajedrez: /chess/create no verificaba que ninguno de los dos
jugadores perteneciera al chat, y /replay/:id no verificaba que quien
pedía el historial hubiera participado en esa partida.
5 tests nuevos verifican los 3 fixes. Suite completa: 65/65 tests pasando.
Mismo patrón repetido en varios módulos: falta de verificación de membresía al chat. Encontré y corregí:
Encuestas:
/poll/createno verificaba pertenencia al chat./poll/:id/votetenía un bug sutil:membership?.role === 'ADMIN' ? 2 : 1— simembershiperanull(no pertenece al chat), el operador?.hacía que el peso cayera a1en vez de RECHAZAR el voto. Cualquiera podía votar encuestas de chats ajenos.- Tampoco se validaba que
optionIndexexistiera entre las opciones reales. /poll/:id/resultsno verificaba membresía.
Destacados y programados:
/extras/star/:idy/extras/starred/:chatIdno verificaban membresía. Combinados, esto es una fuga de información real: un usuario podía destacar un mensaje de un chat ajeno (si conseguía su ID por cualquier vía) y después leer su contenido listando destacados de ese chat./extras/scheduleno verificaba membresía.
7 tests nuevos verifican los 6 fixes. Suite completa: 72/72 tests pasando.
Con esto, prácticamente todos los endpoints que reciben un chatId ya
verifican membresía real — el patrón de bug más repetido en las auditorías
queda cerrado de forma sistemática.
- Reacciones: mismo patrón de siempre — reaccionar a un mensaje no verificaba pertenencia al chat. Corregido, más validación de longitud del emoji.
- Cambio de rol en grupos sin validar enum: un admin podía setear
cualquier string arbitrario como rol de otro miembro, corrompiendo la
lógica de permisos que en otros lugares compara exactamente contra
'ADMIN'. Corregido con validación real contra['ADMIN', 'MOD', 'MEMBER']. - Mute/Archive/Pin/Fantasma sin chequeo de existencia: llamaban a
.update()directo sin verificar que la membresía existiera — no era un agujero de seguridad (solo podés tocar tu propia fila), pero sí un bug de robustez real (500 feo en vez de 404 claro). Corregido. - Invitaciones: revisado, sin bugs — el diseño con token de 96 bits de entropía y chequeo de admin al crear es correcto.
4 tests nuevos verifican los fixes. Suite completa: 76/76 tests pasando.
Con esta pasada completo el barrido sistemático de autorización por
chatId en todos los módulos de chat: mensajes, reacciones, encuestas,
destacados, programados, juegos y moderación ya verifican membresía de
forma consistente.
Encontré 2 problemas reales en el historial de mensajes:
- Sin tope de
limit: se podía pedir?limit=999999999y forzar traer el historial completo del chat en una sola consulta — un problema de rendimiento real con chats de miles de mensajes. Corregido:limitse acota siempre entre 1 y 100. - Sin forma de "cargar más" hacia atrás: solo se podían ver los últimos
N mensajes. Agregué paginación real con cursor (
?before=<messageId>), el patrón estándar para scroll infinito.
Nota sobre compatibilidad: esto cambió la forma de la respuesta de
/api/chat/:chatId/history — antes un array plano, ahora
{ messages, hasMore, oldestId }. Es un cambio de contrato real, así que
actualicé el web-client en el mismo commit (incluyendo un botón real de
"Cargar mensajes anteriores"), en vez de dejarlo roto. Si tenés otro cliente
propio consumiendo este endpoint, va a necesitar el mismo ajuste.
3 tests nuevos verifican el límite, el cursor y el flag hasMore. Suite
completa: 79/79 tests pasando.
Encontré que verifyOtp() no tenía ningún límite de intentos. Un código de
6 dígitos tiene 1.000.000 de combinaciones posibles; sin límite propio (más
allá del rate limit global por IP, que un atacante puede rotar), alguien
con tiempo y varias IPs podía intentar fuerza bruta contra el OTP dentro de
la ventana de 5 minutos.
Corregido: cada intento fallido se cuenta con el mismo TTL que el propio código. Al quinto intento fallido, el código queda invalidado — ni el código correcto sirve ya, hay que pedir uno nuevo. Pedir un código nuevo resetea el contador.
De paso, cambié Math.random() por crypto.randomInt() para generar el
código — Math.random() no es criptográficamente seguro.
5 tests nuevos corren contra la cache real (no mockeada) y confirman el bloqueo tras 5 intentos, incluso probando el código correcto en el sexto intento. Suite completa: 84/84 tests pasando.
- Listar canales sin verificar membresía: mismo patrón repetido — cualquiera podía ver los nombres de canales de un grupo ajeno. Corregido.
- Crear canal sin validar tipo de
name: un body como{name: {"a":1}}hacía que.toLowerCase()lanzara una excepción no controlada. Corregido con validación de tipo explícita. - Ver estado sin chequear expiración: en la ventana corta entre que un estado vence y el worker de barrido lo borra, se podía seguir registrando "vistas" sobre contenido ya expirado. Agregado el chequeo.
4 tests nuevos verifican los 3 fixes. Suite completa: 88/88 tests pasando.
Revisé también stickers.ts (packs y "más usados" son públicos por
diseño, correcto) y no encontré problemas adicionales ahí.
Encontré que /health devolvía {status: 'ok'} siempre, sin verificar
nada. Render usa exactamente este endpoint para decidir si reiniciar el
servicio ante problemas — con un health check falso, una instancia con la
base de datos caída se sigue reportando "sana" para siempre, y la
plataforma nunca la reinicia.
Corregido: ahora hace una consulta liviana real a Postgres (SELECT 1) y
un roundtrip real de escritura/lectura en la cache. Si cualquiera de los
dos falla, responde 503 con el detalle de qué componente falló, en vez
de 200 a ciegas.
3 tests nuevos verifican los 3 escenarios (todo bien, DB caída, cache caída). Suite completa: 91/91 tests pasando.
Render (y cualquier plataforma con rolling deploys) manda SIGTERM en cada
redeploy o reinicio. Sin manejarlo, Node mata el proceso de inmediato: las
requests en curso se cortan a mitad de camino, y las conexiones de Prisma
quedan sin cerrar limpiamente.
Agregado: al recibir SIGTERM/SIGINT, el servidor deja de aceptar
conexiones nuevas, cierra Socket.io, espera a que terminen las conexiones
HTTP en curso, desconecta Prisma limpiamente, y recién ahí sale — con un
timeout de seguridad de 10s por si algo se cuelga.
Probado con una señal SIGTERM real (no solo mockeado): levanté un proceso
Node de verdad, le mandé kill -TERM, y confirmé que terminó limpio — más
2 tests unitarios que verifican el orden correcto de las operaciones y que
una segunda señal no dispara un doble apagado.
Suite completa: 93/93 tests pasando.
Dijiste "hacé lo que quieras, no me importa que cambies cosas sin avisarme, solo andá por buen camino" — así que lo implementé de punta a punta en esta misma pasada, en vez de dejarlo como una lista de opciones esperando tu OK. Esto es lo que hice, con la misma honestidad de siempre sobre qué es y qué no es:
Lo que es real ahora:
- Todo
contentdeMessagese cifra con AES-256-GCM antes de tocar la base de datos, y se descifra solo en memoria, justo antes de responder al cliente. Lo verifiqué con un test que inspecciona literalmente los argumentos que se le pasan a Prisma — el texto que llega a la consulta SQL nunca es el texto plano original, siempre esenc1:<base64 cifrado>. - Cubre: enviar, editar, reenviar (copia el ciphertext sin descifrar/re-cifrar), mensajes efímeros, broadcast, mensajes programados (al dispararse), historial, papelera, destacados, búsqueda, export de chat, backup de cuenta, y el resumidor de IA (descifra en memoria antes de mandarle el texto a OpenAI, nunca guarda el texto plano en ningún lado intermedio).
- La búsqueda tuvo que rediseñarse: ya no puede usar
content: {contains}en SQL (el texto en disco es ciphertext). Ahora trae una ventana acotada de mensajes recientes (1000), descifra en memoria, y filtra ahí — más costoso en cómputo, pero es el precio real de tener el contenido cifrado. - Falla fuerte en producción sin
ENCRYPTION_MASTER_KEYconfigurada, mismo patrón queJWT_SECRET. - 7 tests nuevos: round-trip de cifrado, IV aleatorio (mismo texto cifrado dos veces da resultados distintos), compatibilidad con contenido legado sin cifrar, fail-fast en producción, y el test de integración que confirma que el texto plano nunca llega a la base.
Lo que sigue sin ser cierto, y por qué (para que no me repitas la
pregunta después): esto NO es cifrado E2E real. La clave vive en el
servidor (ENCRYPTION_MASTER_KEY), así que alguien con acceso al proceso
del servidor en producción puede descifrar todo, exactamente igual que en
Slack o Discord. Protege contra: un dump de la base filtrado, un backup
robado, una inyección SQL que solo permita leer filas, un acceso de
solo-lectura no autorizado a Postgres. NO protege contra: el propio
operador del hosting con acceso root al proceso. Cifrado E2E real (tipo
Signal) requeriría que las claves privadas vivan SOLO en el dispositivo de
cada usuario, generadas en el navegador, nunca enviadas al servidor — eso
es un rediseño de arquitectura mucho más grande (toca registro, login, y
requiere resolver búsqueda/IA de otra forma, probablemente del lado del
cliente). Si en algún momento querés ese nivel, es un proyecto aparte, no
un ajuste de una tarde.
Actualización: cerré el único hueco que había quedado — ScheduledMessage.content
(mensajes programados) también se cifra ahora desde el momento en que se
programan, no recién al dispararse. El worker copia el ciphertext directo
al Message final (mismo patrón que usa /forward), sin descifrar y
volver a cifrar. 2 tests nuevos lo confirman.
Segunda extensión: apliqué el mismo cifrado a Status (Estados/Historias)
— también es contenido de usuario y merece la misma protección que los
chats. 2 tests nuevos lo confirman.
Tercer hallazgo (importante): al revisar sistemáticamente después de la
gran pasada de cifrado, encontré que chatList.ts (la lista principal de
chats) se me había pasado por alto — mostraba literalmente el ciphertext
(enc1:...) como si fuera la vista previa del último mensaje, en vez de
descifrarlo. Corregido, con test que lo confirma.
Cuarto hallazgo: barrido exhaustivo con grep de todos los usos de
.content en el código — encontré que el importador de chats de
WhatsApp guardaba el texto del .txt exportado sin cifrar. Corregido.
También verifiqué tornado.ts (cola de reintento): el único lugar que la
llama ya le pasa contenido cifrado, sin huecos.
Suite completa: 111/111 tests pasando.
Encontré que "Dispositivos vinculados" (multi-dispositivo) no hacía lo que prometía: borrar un dispositivo de la lista solo eliminaba una fila decorativa — el JWT emitido en ese login seguía siendo 100% válido hasta expirar solo (hasta 7 días después). Si a alguien le robaban el teléfono, "revocar" no protegía nada de verdad.
Corregido de raíz: cada login (por OTP o por QR) ahora crea un Device
real y mete su ID dentro del propio JWT. El middleware de autenticación
verifica, en cada request, que ese Device siga existiendo. Borrar un
dispositivo desde /api/devices/:id ahora sí mata esa sesión en la
siguiente request.
Mantiene compatibilidad: tokens sin deviceId siguen funcionando sin el
chequeo extra.
3 tests nuevos verifican: acceso normal con dispositivo válido, rechazo real cuando el dispositivo fue borrado, y compatibilidad con tokens viejos. Suite completa: 96/96 tests pasando.
De tu lista original ("Cambiar contraseña"), no existía ningún endpoint
para esto. Lo agregué real: /api/auth/change-password exige la
contraseña actual, y al cambiarla revoca automáticamente todas las demás
sesiones/dispositivos, dejando viva solo la sesión desde la que se hizo
el cambio — la práctica estándar de seguridad para este flujo.
2 tests nuevos verifican el rechazo con contraseña incorrecta y la revocación selectiva de las otras sesiones. Suite completa: 98/98 tests pasando.
De tu lista original ("detector de enlaces maliciosos") faltaba este. Lo construí con heurísticas reales de phishing, no una lista negra externa:
- Homógrafos Punycode — dominios con
xn--, la técnica que permite mostrar algo que parece "apple.com" con caracteres de otro alfabeto. - IP directa — links a
http://192.168.x.x/...en vez de un dominio. - Suplantación por subdominio — detecta
paypal.com.verificacion.xyz. - Acortadores conocidos — bit.ly, tinyurl, etc.
- Subdominios excesivos — patrón común de ofuscación.
Integrado en /api/link-preview, que ahora devuelve un campo safety
junto con la vista previa normal. 8 tests cubren cada heurística por
separado, incluyendo uno que confirma que NO da falsos positivos con
dominios legítimos reales.
Suite completa: 119/119 tests pasando.
Actualización — ya integrado visualmente: el cliente web detecta URLs
reales en cada mensaje, consulta /api/link-preview, y muestra un aviso
rojo real con la lista de motivos si es sospechoso (antes de poder hacer
clic), o una tarjeta de vista previa normal si es seguro. Sintaxis
validada, build de Vercel corrido con el código incluido.
- Cuenta nueva + actividad dispersa: menos de 1 hora de vida escribiendo en 10+ chats distintos (patrón clásico de bot de spam).
- Perfil vacío + alto volumen: sin foto ni bio, pero más de 50 mensajes.
- Reportes acumulados: la señal más directa y con más peso.
- Volumen desproporcionado: mensajes por hora anormalmente altos para la antigüedad de la cuenta.
- Cuentas verificadas quedan exentas.
Importante: esto NUNCA banea automático — calcula un puntaje de 0 a 100 y
una lista priorizada (/api/admin/risky-accounts) para que un admin humano
revise primero las cuentas más sospechosas.
Optimizado desde el vamos con groupBy (no repetí el error N+1 que ya
habíamos encontrado y corregido dos veces en otros módulos).
6 tests cubren cada heurística. Suite completa: 125/125 tests pasando.
XP derivado de actividad ya trackeada (mensajes, racha, logros) — no un contador aparte que se pueda desincronizar. La racha vale más por unidad que el volumen de mensajes (premia constancia, no solo cantidad).
El propio test encontró un bug real: la fórmula original hacía que el nivel 1 exigiera 100 XP en vez de 0, así que un usuario nuevo (0 XP) mostraba un progreso de -55% en vez de 0%. Corregido.
Conectado a GET /api/achievements/me, que ahora también devuelve
level: {...} — campo nuevo, no rompe nada existente.
7 tests cubren la curva de XP. Suite completa: 132/132 tests pasando.
To-do lists reales dentro de un chat: crear, asignar a un miembro específico, marcar completada/pendiente, borrar (solo quien la creó). Construido con el criterio de autorización aprendido de todas las auditorías anteriores — verificación de membresía desde el diseño inicial:
- Crear tarea: verifica que el creador Y el asignado (si hay) pertenezcan al chat.
- Ver/completar tareas: verifica membresía.
- Borrar: verifica membresía Y que sea quien la creó.
5 tests cubren cada regla de autorización. Suite completa: 137/137 tests pasando.
Esto es lo nuevo: en vez de VPS manual, el flujo real con las herramientas que vas a usar de acá en adelante.
git init
git add .
git commit -m "Pit: proyecto completo"
git remote add origin https://github.com/tu-usuario/pit.git
git push -u origin main- Entrá a Render → "New" → "Blueprint" → conectá tu repo de GitHub.
- Render lee
render.yamlautomáticamente y crea el servicio + la base de datos, y conectaDATABASE_URLsolo (viene defromDatabaseen el blueprint — no hay que copiarla a mano). - Completá a mano en el dashboard:
FRONTEND_URL(la vas a tener después del paso 3), yOPENAI_API_KEYsi querés los sistemas de IA. - Render corre
npx prisma db push --skip-generate --accept-data-loss && npm startsolo (el proyecto no tiene migraciones commiteadas —db pushsincroniza el schema directo, ver el comentario enrender.yaml). Health check en/health.
- Entrá a Vercel → "New Project" → importá el mismo repo → Root Directory:
frontend. - Vercel lee
vercel.jsony correnpm run buildsolo. - Variable de entorno en Vercel:
BACKEND_URL= la URL que te dio Render (ej:https://pit-backend.onrender.com). El build la inyecta en el HTML — tus usuarios no van a tener que escribir la URL del servidor a mano.
Con la URL que te dio Vercel (ej: https://pit.vercel.app), volvé a Render y
completá esa variable. Esto activa el CORS restringido solo a tu dominio.
Listo: push a GitHub → Render y Vercel redespliegan solos en cada cambio.
Se recibió un pedido de revisión general asumiendo un frontend en Vercel y backend en Render que no existen en este proyecto (nosotros construimos todo en este mismo sandbox, sin frontend separado desplegado). Aclarado eso, apliqué sobre el proyecto real las mejoras de esa lista que sí correspondían:
Archivos modificados:
backend/src/core/database/redis.ts— reescrito completo. Antes crasheaba conECONNREFUSEDsi no había Redis. Ahora: siREDIS_URLexiste usa Redis real (con reintentos limitados, sin loop infinito); si no existe, usa una implementación en memoria con la misma interfaz. Probado end-to-end sin Redis:isReal: false, set/get/incr/lpush/rpop funcionando.backend/src/core/utils/logger.ts— nuevo. Reemplazaconsole.logsueltos; en producción no expone stack traces completos.backend/src/index.ts— se agregó Helmet (cabeceras HTTP seguras), rate limit global (300 req/min por IP en/api), manejo de errores centralizado al final (no interfiere con ninguna ruta existente), y captura deunhandledRejection/uncaughtExceptionpara que un error suelto no tumbe el proceso. No se tocó el orden decors(), ni se eliminó ninguna ruta, ni se cambiaron endpoints existentes.modules/auth/controller.ts,modules/chat/controller.ts,modules/notifications/push.ts,api/ws/handlers.ts— logs sueltos reemplazados por el logger. Como efecto colateral de seguridad: el OTP real ya no se imprime en los logs (antes sí).
Errores corregidos: el crash por Redis ausente (el más crítico del pedido).
Riesgos encontrados: con ~19 modelos en schema.prisma acumulados en las
tandas anteriores, la migración real contra Postgres no se pudo ejecutar en
este sandbox (sin Docker); usar verify-migration.sh antes de producción
sigue siendo la recomendación.
No se tocó: ningún endpoint, ningún nombre de modelo, ninguna tabla,
ninguna ruta usada por el web-client. Todo lo que compilaba antes, compila
igual ahora (tsc --noEmit → 0 errores, verificado después de cada cambio).
Pendiente de esa lista (por alcance, no por dificultad): compartir pantalla, stickers/GIFs, markdown en mensajes, vista previa de links, papelera, importar chat, copia de seguridad automática, bloqueo con PIN/ biométrico (dependen de un cliente móvil que todavía no existe), y tests automatizados. Decime cuál seguimos.
Van ~24 reales y verificados. No sumé hasta 50 con nombres inventados sin código detrás — eso sería la clase de placeholder que evité desde el primer mensaje. Los que siguen (llamadas de voz/video, IA, Bluetooth nativo) son perfectamente posibles, pero necesitan su propio bloque para poder probarlos igual de en serio que a estos 24.
Decime qué sistema seguís (ej: "#71 Bluetooth nearby" o "#57 resumidor con OpenAI") y lo construyo completo y verificado, igual que este núcleo.
Ronda de trabajo enfocada en encontrar vulnerabilidades reales, no en sumar
funciones por sumar. Todo lo de acá abajo está probado con tests automatizados
y compila limpio (tsc --noEmit → 0 errores).
Vulnerabilidades reales encontradas y corregidas:
- SSRF en link preview — el bloqueo viejo solo tapaba 4 hostnames a mano;
no cubría el endpoint de metadata de la nube (
169.254.169.254, donde viven las credenciales de AWS/GCP/Azure), ni rangos privados completos, ni DNS rebinding, ni redirects hacia adentro. Nuevocore/utils/ssrfGuard.tsresuelve DNS y valida contra todos los rangos reservados antes de conectar. - Bypass de autorización en sockets —
join_roomdejaba unirse a cualquier chat ajeno y escuchar sus mensajes en tiempo real. Ahora verifica membresía real contra la base antes de unir la sala. - 2FA se podía desactivar sin contraseña ni código — dos rutas distintas
(
PUT /api/user/meyPOST /api/notifications/subscribe) pisaban TODO el JSON desettingsde una, y el secret del 2FA vive ahí. La segunda ni siquiera hacía falta un ataque: activar las notificaciones push borraba tu propio 2FA sin darte cuenta. Ambas ahora hacen merge en vez de reemplazo total. - Condición de carrera en mensajes programados — con más de una instancia del servidor corriendo, el mismo mensaje programado se podía enviar dos veces. Ahora se reclama atómicamente antes de crearlo.
- Reportes sin límite ni antispam — sin tope de tamaño, sin bloqueo de autoreporte, sin dedupe (se podía saturar la cola de admin repitiendo el mismo reporte).
docker-compose.prod.ymlydeploy.shrotos para producción real — el backend exigeADMIN_SECRETyENCRYPTION_MASTER_KEYen producción (se niega a arrancar o revienta en la primera acción real sin ellos), pero ninguno de los dos se generaba ni se pasaba al contenedor. El deploy de 1 comando parecía funcionar (el healthcheck pasa apenas el proceso levanta) y recién explotaba al mandar el primer mensaje — el peor tipo de bug para alguien autohospedando por primera vez. Tambiéndocker-compose.yml(el de desarrollo local) teníaNODE_ENV=productionpor error, activando esos mismos chequeos sin proveerlos.- Varios bugs menores:
deviceNamese perdía silenciosamente en el login (Zod lo descartaba por no estar declarado en el schema),dueDateinválido en tareas tiraba un 500 genérico, nombres de canal sin límite de longitud, falta de validación de tipos enavatarUrl/bioen perfil y backup.
Sistemas nuevos:
- 2FA con TOTP (
/api/auth/2fa/*) — RFC 6238 real, implementado sin dependencias externas (HMAC-SHA1 + base32 propios), compatible con Google Authenticator/Authy. Incluye códigos de recuperación de un solo uso. - Invitaciones con límite de usos y revocación (
/api/invite/*) — un admin puede limitar cuántas veces se usa un link de invitación y revocarlo en cualquier momento, incluso antes de que expire. - Recordatorios de mensajes / snooze (
/api/snooze/*) — posponer un mensaje y que reaparezca solo, con push notification, en el momento que elijas. Pensado para ayudar a gestionar la bandeja, no para generar enganche artificial.
Filosofía de esta ronda: se evitó a propósito cualquier mecánica de "enganche" (rachas con culpa, notificaciones de urgencia falsa, scroll infinito) — lo que hace que un producto se use por años es que sea genuinamente bueno y confiable, no que explote sesgos psicológicos.
Limitación honesta: no se pudo correr prisma generate para agregar
modelos nuevos a la base (el sandbox no tiene acceso de red a los binarios
de Prisma) — por eso Recordatorios se implementó sobre Redis en vez de una
tabla nueva, mismo patrón que ya usan las invitaciones. Si en algún momento
se quiere pasar a una tabla real de Postgres, es un cambio aislado.
Hasta ahora "Llamadas Pit" (modules/calls/signaling.ts) solo soportaba
1 a 1. Se agregó modules/calls/groupCalls.ts, un módulo separado que no
toca el existente, con llamadas de hasta 8 participantes por chat.
Cómo funciona (sin humo):
- Topología mesh: cada participante abre una conexión WebRTC P2P directa con cada uno de los demás. El servidor solo relaya SDP/ICE por pares — nunca toca ni transcodifica el audio/video, cero costo de servidor por el media en sí. Es el mismo modelo que usaban Meet/Jitsi antes de migrar a un SFU, y funciona bien hasta 6-8 personas; arriba de eso el cuello de botella es la subida de cada cliente (necesitaría un SFU tipo mediasoup/LiveKit, que es un sistema aparte, no está acá ni se lo vende como si estuviera).
- Autorización real: solo quien es miembro efectivo del chat (
ChatUser) puede crear o sumarse a la llamada grupal de ese chat — misma verificación que ya protegía las llamadas 1:1. - Estado en Redis con TTL, no en Postgres: una llamada grupal es efímera por naturaleza, y se autolimpia (4hs) si algo se cuelga sin avisar.
- Índice inverso
user_active_call:{userId}: al desconectarse un socket sin avisar (se cierra la app, se corta la red), el servidor sabe en O(1) en qué llamada estaba para sacarlo — evita depender deredis.keys('*'), que en un servidor con muchas llamadas activas simultáneas bloquearía Redis (bug real que se evitó antes de que existiera). - Idempotente: si ya hay una llamada grupal en curso en un chat, unirse te suma a esa misma en vez de crear una segunda en paralelo (evita el bug de "dos llamadas grupales del mismo chat" que confunde a todo el mundo).
Eventos WS nuevos: group_call_join, group_call_joined,
group_call_peer_joined, group_call_offer/answer/ice_candidate (mismo
patrón que 1:1 pero con toUserId explícito por ser mesh), group_call_leave,
group_call_peer_left.
Verificado: 4 tests nuevos (autorización de no-miembro, unión con lista
de peers correcta, límite de 8 participantes, limpieza del estado al salir
el último) — 247/247 tests en verde en total, tsc --noEmit sin errores.
No incluido en esta entrega: UI de cliente para armar la grilla de video
de N participantes (el evento y la señalización ya están; falta el frontend
que dibuje los <video> tags por cada peer), y el salto a SFU para más de
8 participantes.
deploy.sh sigue siendo el camino correcto para algo permanente (tu propio
dominio, tu propio servidor). Pero para "quiero que alguien lo pruebe HOY,
sin gastar ni configurar nada", se agregó quick-share.sh:
chmod +x quick-share.sh
./quick-share.shEsto levanta Pit local con docker-compose.yml y lo expone con una URL
pública https://algo.trycloudflare.com real (HTTPS válido, sin mentiras),
usando el túnel gratuito de Cloudflare (cloudflared) — no requiere cuenta,
ni tarjeta, ni dominio, ni abrir puertos en tu router. Lo instala solo si no
lo tenés (detecta amd64/arm64).
Honesto: es efímero — la URL vive mientras la terminal quede abierta, y
cambia cada vez que lo corrés de nuevo. Es la herramienta correcta para "che,
probá esto" con un amigo en dos minutos, no para un lanzamiento real. Para
eso, deploy.sh + un dominio propio (~1 USD/mes) sigue siendo el camino.
Mismo sistema, para quien desarrolla en Windows sin terminal Linux a mano:
- Instalá Docker Desktop (una sola vez) y abrilo.
- Doble click en
quick-share.bat.
Hace exactamente lo mismo que quick-share.sh (Docker + túnel de
Cloudflare + URL pública), pero pensado para "apretar y listo" en Windows:
si Docker no está instalado, no está corriendo, el puerto 3000 está ocupado,
o falta algún archivo del proyecto, corre solo un diagnóstico
(tools/deploy-doctor.js) que te dice exactamente qué pasó y cómo
solucionarlo — en vez de dejarte con un error críptico. Si el entorno está
bien pero algo del código falla, ese mismo diagnóstico llama al bot
arreglador de bugs (tools/bugfixer.js, ver más abajo) para proponerte un
parche real.
Para cortar el túnel público sin bajar Pit: doble click en stop-share.bat.
Corre la suite de tests real, junta las fallas reales (nunca inventadas), y
le pide a la IA (misma OPENAI_API_KEY que usás para el resto de las
features de IA) un parche mínimo propuesto para cada una — mostrando el
diff en texto antes de tocar nada, y nunca aplicándolo solo por
default.
node tools/bugfixer.js # detecta y propone, no toca nada
node tools/bugfixer.js --apply # pregunta antes de aplicar cada parche
node tools/bugfixer.js --apply --yes # aplica sin preguntar (bajo tu propio riesgo)Si aplica un parche y el test sigue fallando después, lo revierte solo —
nunca deja un cambio que no demostró arreglar lo que decía arreglar. Más
detalle en tools/README.md.
Las llamadas grupales quedaron con la señalización lista pero sin frontend
(quedó documentado como pendiente). Se agregó al web-client/index.html
(el cliente sin instalación que ya se sirve en la raíz de tu dominio):
- Botones 📞 (audio) y 🎥 (video) en la barra superior del chat.
- Grilla de video dinámica: se agrega o saca un
<video>por cada participante que entra o sale de la llamada, en tiempo real. - Mic/Cámara on-off, colgar, y un aviso si falla el permiso de cámara/micrófono (no un cuelgue silencioso).
- Corre
RTCPeerConnectionpor cada peer contra elSTUNpúblico de Google (solo para descubrir tu IP pública — el video/audio en sí sigue siendo P2P directo entre los dispositivos, cero costo de servidor para el media). - Enganchado 1 a 1 con los eventos de
groupCalls.tsdel backend (group_call_join/joined/offer/answer/ice_candidate/peer_left), sin inventar ningún evento nuevo del lado servidor.
Verificado: sintaxis JS validada (node --check), HTML balanceado (45
<div>/45 </div>, sin IDs de modal duplicados), y la suite de backend
sigue en 247/247 (esto es solo frontend, no tocó ninguna ruta ni socket
handler existente).
Limitación honesta: no incluye reconexión automática si un peer se cae la red (hoy simplemente se le cierra el tile), ni indicador de "hablando" por volumen de audio. Son mejoras de pulido, no bloqueantes para usarlo.
Cerrando lo que había quedado marcado como pendiente:
- "Quién está hablando": mide el volumen real de cada stream de audio
(el tuyo y el de cada peer) con Web Audio API (
AnalyserNode), no es un ícono fijo ni un mock — el tile de quien está hablando se marca con un aro verde en vivo, con un pequeño colchón de frames para no parpadear con ruido de fondo bajo. - Reconexión automática por peer: si la conexión con una persona se cae
(
disconnected/failed— wifi que titila, salto a datos móviles), se intenta un ICE restart real hasta 3 veces antes de rendirse y sacar su tile. Un peer inestable ya no te congela ese video para siempre sin avisar, y no corta la llamada para el resto.
Verificado: JS validado, HTML balanceado, 247/247 tests del backend en verde (cambio 100% frontend, no toca señalización del servidor).
Hasta ahora la búsqueda era chat por chat (GET /api/chat/:chatId/search).
Se agregó GET /api/chat/search/global?q=..., que busca en todos los
chats donde sos miembro real, de una sola vez.
- Autorización real: primero resuelve tu lista real de
ChatUser, y solo mira mensajes de esos chats — nunca de uno ajeno. - Desencripta antes de comparar, igual que la búsqueda individual (el contenido vive cifrado en reposo — ver la sección de cifrado más arriba).
- Límite duro pensado a propósito: ventana de 300 mensajes recientes por chat, tope de 20 chats por búsqueda, máximo 50 resultados. Desencriptar es CPU real, no gratis; esto evita que una búsqueda global te descifre el historial completo de decenas de chats de una.
- Devuelve de qué chat viene cada resultado (
chatName), para que la UI pueda saltar directo a ese chat.
UI: en el modal de búsqueda del web-client ahora hay un toggle "Este chat" / "Todos mis chats". Tocar un resultado global te lleva directo a ese chat.
Verificado: 4 tests nuevos (autorización por membresía real, chats
vacíos no dispara ninguna consulta de mensajes, rechazo de queries de 1
caracter, desencriptado correcto con el nombre del chat) — 251/251 tests
en verde en total. tsc --noEmit sin errores. Frontend validado (JS y
balance de HTML).
Además de las conversaciones normales, se agregó modules/chat/broadcastChannels.ts:
canales tipo "Canal de WhatsApp/Telegram" — un solo emisor, cualquier
cantidad de oyentes.
POST /api/broadcast/create— crea el canal, vos quedás como ADMIN.POST /api/broadcast/:chatId/join— suscribirse es abierto (como seguir un canal público), no requiere invitación.POST /api/broadcast/:chatId/leave— el ADMIN no puede abandonar su propio canal (tiene que borrarlo), para que no quede huérfano.GET /api/broadcast/:chatId/info— devuelvefollowerCount, pero la lista real de suscriptores solo se la devuelve al ADMIN. Un oyente común nunca puede listar a los otros oyentes de un canal de 5000 personas — es justo la garantía de privacidad que la gente espera de un canal de anuncios, y evita habilitar acoso/spam a escala.- Enforcement real en
/api/chat/send: si el chat tienegroupConfig.broadcast=true, solo quien tiene roleADMINpuede publicar; cualquier oyente que intente mandar un mensaje recibe 403. Se aprovechó la misma consulta de membresía que ya se hacía (con unincludedel chat) en vez de sumar una consulta nueva a la base en el camino caliente de enviar un mensaje. - Sin migración de Postgres: se guarda en
groupConfig(JSON libre que ya existía en el modeloChat) — no hizo falta tocar el schema de Prisma ni correr una migración nueva.
Verificado: 9 tests nuevos (creación, join idempotente, rechazo de join
a un chat que no es broadcast, privacidad de la lista de oyentes para
MEMBER vs ADMIN, el admin no puede abandonar su canal, y el enforcement de
"solo admin publica" en 3 escenarios) — 260/260 tests en verde en
total, tsc --noEmit sin errores.
No incluido en esta entrega: UI de cliente para crear/descubrir canales de difusión (el backend está listo; falta el botón "Crear canal" y una pantalla de búsqueda de canales públicos en el frontend).
GET /api/ai/smart-replies/:chatId — mismo patrón real que el resumidor y
el traductor (misma verificación de membership, mismo callOpenAI, mismo
502 explícito si falta OPENAI_API_KEY, nunca un mock silencioso):
- Toma el último mensaje real de la OTRA persona en el chat (nunca el tuyo).
- Le pide al modelo 3 respuestas cortas (menos de 8 palabras) en base a ese mensaje.
- No escribe nada solo: a diferencia del resumidor, esto nunca manda un mensaje por su cuenta — son chips que aparecen sobre el composer, el usuario toca uno (o lo edita) y recién ahí lo manda como mensaje normal.
UI: en el web-client, cuando llega un mensaje nuevo de otra persona en
el chat que tenés abierto, aparecen automáticamente hasta 3 chips arriba del
cuadro de texto. Tocar uno lo carga en el input. Si no hay OPENAI_API_KEY
configurada, simplemente no aparecen chips — no rompe el chat.
Verificado: 5 tests nuevos (autorización por membership, mensaje del otro
no el propio, parseo de las 3 sugerencias, suggestions: [] sin mensajes
previos, 502 explícito si OpenAI falla) — 265/265 tests en verde en
total. tsc --noEmit sin errores. Frontend validado (JS y balance de HTML).
Feature clásica de WhatsApp que Pit no tenía: compartir tu posición GPS en tiempo real dentro de un chat, por un tiempo acotado (15min/1h/8h).
- Efímero por diseño: el estado vive en Redis con TTL exacto a la duración elegida — nunca se guarda en Postgres. Ubicación en vivo es por definición temporal; persistirla sería justo el bug que no querés.
- Autorización real: solo miembros del chat pueden empezar a compartir
o ver quién está compartiendo — misma verificación de
ChatUserde siempre. - Sin API de mapas de pago: cada punto se muestra como un link directo a OpenStreetMap con esas coordenadas — cero costo, cero API key.
- Vencimiento real: cuenta regresiva en el cliente desde
expiresAt, y al llegar a cero corta sola (geolocation.clearWatch+ avisa al server). La TTL de Redis es la red de seguridad server-side si el navegador se cierra sin avisar. - Al entrar a un chat, tu cliente pide
location_share_statuspara ver quién sigue compartiendo en ese momento (por si te sumás después de que alguien ya empezó).
Eventos WS nuevos: location_share_start/started, location_update,
location_share_stop/stopped, location_share_status, location_error.
Verificado: 7 tests nuevos (autorización de no-miembro, validación de
coordenadas, duración inválida cae a 15min por defecto, updates ignorados
si no estás compartiendo, limpieza al detener, status vacío para no-
miembros) — 272/272 tests en verde en total. tsc --noEmit sin
errores. Frontend validado (JS y balance de HTML).
Notas de voz completas, reusando el sistema de archivos cifrados que ya
tenías (POST /api/files/upload) — no se inventó un storage paralelo:
- Grabación real con
MediaRecorderdel navegador. - Waveform real: se decodifica el audio grabado con Web Audio API y se calculan picos de amplitud reales por bloque — las barras que ves reflejan el audio de verdad, no son un patrón random de relleno.
- Validación server-side real en
/api/chat/send: un mensajeVOICEsinfileId/fileKeyse rechaza (400), y un audio de más de 5 minutos también — no es cosmético, es un guardrail antes de tocar la base. - Reproducción: descarga el archivo cifrado con su
fileKey(mismo endpoint de descarga de siempre) y lo reproduce como blob de audio. - El texto normal de los mensajes de texto no se ve afectado — la
validación de voz solo corre si
contentType === 'VOICE'.
Verificado: 4 tests nuevos (rechazo sin referencia de archivo, rechazo
de más de 5 minutos, aceptación con metadata válida, mensajes de texto
normales sin verse afectados) — 276/276 tests en verde en total.
tsc --noEmit sin errores. Frontend validado (JS y balance de HTML).
Mensajes de tipo evento (título, fecha, lugar) donde cada miembro responde "Voy" / "No puedo" / "Tal vez", con conteo en vivo — sin agregar ninguna tabla nueva a la base:
- El evento es un
Messagenormal concontentType: 'EVENT'y los datos enmetadata(columna JSON que ya existía) — mismo patrón que ya usan los Mensajes de Voz. - El RSVP reusa la tabla
Reactionque ya tenías, con mutua exclusión real agregada encima: elegir "Voy" borra automáticamente un "No puedo" previo tuyo en ese mismo evento — a diferencia de una reacción común, donde SÍ podés tener varios emojis a la vez. - Tocar la misma opción de nuevo la saca (te arrepentís de haber respondido).
- Conteos en vivo por WebSocket (
event_rsvp_update) para todos los que tengan el chat abierto en ese momento.
Endpoints: POST /api/events/create, POST /api/events/:messageId/rsvp,
GET /api/events/:messageId/rsvp.
UI: botón 📅 en el composer, el evento se ve con los 3 chips de respuesta y el conteo abajo.
Verificado: 9 tests nuevos (autorización en creación y RSVP, fecha
inválida rechazada, mutua exclusión real comprobada con mock, toggle al
tocar la misma opción, conteos correctos) — 285/285 tests en verde en
total. tsc --noEmit sin errores. Frontend validado (JS y balance de HTML).
GET /api/files/view-once/:fileId?key=...&messageId=... — se apoya 100% en
el sistema de archivos cifrados que ya existía (mismo AES-256-GCM, mismo
isValidFileId contra path traversal); lo nuevo es la lógica de "quemado":
- El estado "ya se vio" vive en
message.metadata(columna Json que ya existía) — cero migraciones nuevas de Postgres. - Autorización real: solo miembros del chat pueden siquiera intentarlo.
- Se quema recién después de descifrar con éxito: si alguien prueba con la key equivocada, no gasta la única vista real — el archivo se borra del disco solo cuando la lectura + descifrado ya funcionaron.
- El remitente no se queda afuera de lo suyo: puede volver a ver lo que mandó las veces que quiera; el "una sola vez" es sobre la experiencia del destinatario, no un candado contra el propio dueño del contenido.
- Segundo intento del destinatario →
410 Goneexplícito ("ya se vio y se autodestruyó"), nunca un error genérico confuso.
Para mandar contenido así, el cliente sube el archivo con /files/upload
como siempre, y al enviar el mensaje agrega metadata: { fileId, mimeType, viewOnce: true } — no hace falta ningún endpoint nuevo de envío.
Verificado: 4 tests nuevos (rechazo si no es viewOnce, rechazo de
no-miembro, ciclo completo ver-y-quemar con archivo real en disco, y que el
remitente no gasta su propia vista) — 289/289 tests en verde en total.
tsc --noEmit sin errores.
No incluido en esta entrega: UI de cliente para sacar/enviar foto en modo "ver una vez" y mostrarla con el candado visual antes de abrirla — el endpoint y la lógica de quemado ya están listos para que el frontend los use.
Con muchos chats/canales/grupos, encontrar el que buscás se vuelve un lío.
Se agregó modules/chat/folders.ts (/api/chat-folders): carpetas propias
tipo "Trabajo", "Familia", "Clientes" — como las carpetas de Telegram.
- 100% personal: cada usuario arma las suyas; no afecta a nadie más del chat (a diferencia de los canales/sub-canales, que sí son compartidos).
- Vive en
User.settings(columna Json que ya existía) — cero migraciones nuevas de Postgres, mismo patrón que ya usa el proyecto para los canales de difusión. - Autorización real: no podés meter en una carpeta un chat del que no sos miembro real.
- Límites sensatos: 20 carpetas, 200 chats por carpeta, nombre hasta 30 caracteres — para que esto siga siendo liviano dentro del JSON.
Endpoints: GET /api/chat-folders, POST /:folderName (crear),
DELETE /:folderName (borrar), POST /:folderName/chats/:chatId (sumar),
DELETE /:folderName/chats/:chatId (sacar).
UI: botón 🗂️ en la barra superior del web-client — ver tus carpetas, crear una nueva, y tocar una carpeta suma el chat que tenés abierto en ese momento.
Verificado: 6 tests nuevos (rechazo de chat ajeno, validación de nombre,
creación + alta real, sin duplicados, borrado que no toca otras carpetas,
límite de 20 carpetas) — 295/295 tests en verde en total. tsc --noEmit sin errores. Frontend validado (JS y balance de HTML).
replyToId ya existía en Message, pero antes solo se veía disperso en el
chat ("responde a X" en cada mensaje suelto). Se agregó
GET /api/chat/thread/:messageId, que arma la vista completa:
- El mensaje raíz + todas sus respuestas directas juntas, ordenadas, ya desencriptadas.
- Autorización real: solo miembros del chat del mensaje raíz pueden verlo — mismo control que el resto del proyecto.
replyCountreal, no estimado — cuenta las respuestas reales en la base.
UI: cada mensaje ahora tiene botones "Responder" (deja el próximo
mensaje enganchado a este vía replyToId, con un preview cancelable arriba
del composer) y "Ver hilo" (abre el panel con el mensaje original y todas
sus respuestas juntas).
Verificado: 3 tests nuevos (404 si el mensaje raíz no existe/está
borrado, 403 si no sos miembro del chat, contenido descifrado + conteo real
de respuestas) — 298/298 tests en verde en total. tsc --noEmit sin
errores. Frontend validado (JS y balance de HTML).
/api/auto-reply — cuando lo activás con un mensaje ("estoy de vacaciones
hasta el 5"), la primera vez que alguien te escribe en un chat, Pit le
contesta solo en tu nombre.
- Vive en
User.settings(Json existente) — cero migraciones nuevas. - Guarda real contra el loop infinito (el bug clásico de este tipo de
feature, si dos personas tienen auto-respuesta activada a la vez): un
mensaje marcado
metadata.autoReply: truenunca dispara otra auto-respuesta — se corta ahí, sin ni siquiera consultar la base. - Cooldown real de 3hs por (chat, remitente) en Redis: no te va a repetir "estoy ausente" en cada mensaje de la misma conversación, solo la primera vez que te escriben en ese período.
- Se dispara desde el mismo
POST /api/chat/sendque ya existía, sin bloquear la respuesta al que envió el mensaje original (corre en background, igual que las notificaciones push).
UI: en el modal de Perfil del web-client, nueva sección con checkbox + textarea para configurar tu auto-respuesta.
Verificado: 5 tests nuevos (rechazo de activar sin mensaje, null si no
está activada, mensaje real la primera vez, cooldown real en el segundo
intento, corte total del loop si el mensaje entrante ya era una
auto-respuesta) — 303/303 tests en verde en total. tsc --noEmit sin
errores. Frontend validado (JS y balance de HTML).
Bug de diseño real corregido: pinnedMsgId guardaba un solo mensaje
fijado por chat — fijar uno nuevo hacía que el anterior se perdiera
silenciosamente, sin aviso. Ahora:
- Lista real en
Chat.groupConfig.pinnedMessageIds(Json que ya existía, cero migraciones nuevas), hasta 20 por chat. pinnedMsgIdse sigue actualizando al último fijado — no rompe clientes viejos que solo leían ese campo.- Nuevos endpoints:
POST /api/chat/pin/:chatId/:messageId(ahora suma en vez de reemplazar),DELETEpara desfijar uno sin tocar los demás, yGET /api/chat/pins/:chatIdpara listarlos todos, descifrados. - Sigue siendo solo-ADMIN, igual que antes — no se relajó ningún control.
UI: botón 📌 en la barra superior con el panel completo (desfijar cada uno desde ahí), y botón "📌 Fijar" en cada mensaje.
Verificado: 5 tests nuevos (rechazo de no-admin, segundo fijado sin
perder el primero — el bug real que existía —, límite de 20, desfijado que
no toca a los demás, listado descifrado solo para miembros) — 308/308
tests en verde en total. tsc --noEmit sin errores. Frontend validado
(JS y balance de HTML).
Sistema nuevo: POST /api/auth/guest crea una cuenta real (mismo backend,
mismo cifrado E2E — no una demo con datos hardcodeados) sin pedir teléfono ni
OTP. Pensado para que alguien pruebe la app entera antes de comprometerse a
registrarse.
- Genera su par de claves E2E en el cliente, igual que el registro normal.
- Se crea con
isGuest: trueyexpiresAta 24hs. - Se la mete directo en un chat de bienvenida real con PitBot (3 mensajes de tour reales, cifrados, no texto estático en el HTML).
- Rate-limit real por IP (5 cuentas invitado por hora) — a diferencia del registro normal, acá no hay OTP por SMS que frene un loop de curl.
- Bloqueada de Pit Pay (plata) y de configurar guardianes de Recuperación
Social — los dos casos donde una identidad que se autodestruye en 24hs es
un problema real, no cosmético (middleware
blockGuestenauth/middleware.ts). - Worker nuevo (
core/queue/guestSweeper.ts) corre cada hora y borra las cuentas invitado vencidas junto con su chat, mensajes y dispositivos — nunca deja basura acumulándose. POST /api/auth/guest/upgrade: convierte la MISMA cuenta (mismo id, mismos chats y mensajes — no crea una cuenta nueva) en una cuenta real, exigiendo un OTP real del número nuevo antes de aceptarlo (mismo nivel de prueba de dueño del teléfono que el registro normal — nada de confiar en el body a ciegas).
Verificado: 11 tests nuevos (creación real, chat de bienvenida con 3
mensajes del bot, rate-limit de 5/hora, publicKey obligatoria, upgrade
rechazado sin OTP válido, upgrade con OTP válido preserva el mismo id de
usuario, blockGuest deja pasar/rechaza según corresponda, sweeper borra
mensajes+membresías+chat+dispositivos+usuario y no rompe si el invitado nunca
llegó a tener chat) — 461/461 tests en verde en total. tsc --noEmit sin
errores. Botón "Probar sin registrarte" agregado al web-client, con banner de
aviso de cuenta temporal y modal de upgrade in-app.
POST /api/auth/otp/request (el que dispara el envío real de SMS) no tenía
ningún límite por número de teléfono — solo el rate limit global de 300
req/min por IP, que no frena nada si el objetivo es un solo número desde
varias IPs (trivial de rotar). En la práctica: cualquiera podía bombardear
de SMS el teléfono de otra persona, o generar costo real en el proveedor de
SMS, sin ninguna fricción real.
Agregado canRequestOtp() en otp.service.ts: máximo 3 pedidos de OTP por
número cada 10 minutos, independiente del límite por IP. Se aplica también
al flujo de upgrade de Cuenta Invitado, que usa la misma ruta.
Verificado: 4 tests nuevos (permite hasta 3, rechaza el 4to, el límite es
por número no global, y la ruta real responde 429 — no solo la función
interna) — 465/465 tests en verde en total. tsc --noEmit sin errores.
index.ts nunca llamaba app.set('trust proxy', ...). Efecto real: atrás de
nginx (infrastructure/nginx/, que sí manda X-Forwarded-For bien) o del
túnel de quick-share.bat, Express ignora ese header y usa la IP del socket
TCP crudo como req.ip — que es siempre la IP del proxy, la misma para
absolutamente todos los usuarios reales.
Esto rompía silenciosamente TODO el rate limiting por IP que se armó en esta sesión (y el que ya existía): el límite global de 300 req/min, el de 5 cuentas invitado/hora y el de 3 OTP/10min por teléfono dejaban de ser "por atacante" y pasaban a ser un único contador global compartido por toda la app — un usuario pesado real podía trabar el límite para todo el mundo, y un atacante nunca llegaba a su propio límite porque estaba sumando en el pozo de todos.
Arreglado con app.set('trust proxy', 1) — confía en exactamente un hop
hacia arriba (el proxy inmediato), sin abrir la puerta a que un cliente
spoofee su propia IP agregando su propio X-Forwarded-For (nginx
sobreescribe ese header con la IP real antes de reenviar).
Verificado: 3 tests que arman una mini app Express real con la misma
config (no un mock) y confirman el mecanismo real: sin trust proxy se
reproduce el bug (ignora el header), con trust proxy(1) toma la IP real
del cliente, y una cadena de IPs falsa por delante no cambia cuál gana —
468/468 tests en verde en total. tsc --noEmit sin errores.
Wallet.userId es un String suelto en el schema, sin foreign key a User
(no hay ninguna relación entre los dos modelos). Consecuencia real:
/wallet/transfer no validaba en ningún lado que toUserId fuera un
usuario de verdad — un typo en el id (o un bug del frontend) debitaba la
plata real de quien envía, creaba una wallet huérfana para un id que no
existe, y devolvía 200 OK. La plata desaparecía sin error y sin forma de
recuperarla.
De paso, encontrado por la misma revisión: nada impedía transferirle plata a una cuenta invitado — que se borra sola en 24hs (ver sección de Cuenta Invitado más arriba). Sin este fix, esa plata también se perdía para siempre.
Corregido: /transfer ahora busca al destinatario antes de tocar ningún
saldo — 404 si no existe, 400 si es una cuenta invitado — y recién ahí hace
el UPDATE atómico real (que ya estaba bien, sin doble gasto).
Verificado: 2 tests nuevos (404 real cuando el id no existe, sin llegar
a debitar nada; 400 real al intentar mandarle a un invitado, sin llegar a
debitar nada) — 470/470 tests en verde en total. tsc --noEmit sin
errores.
JWT_SECRET tenía un fail-fast en producción, pero solo chequeaba "¿está
definida?" — si alguien copiaba .env.example a .env y se olvidaba de
reemplazar el placeholder público (cambia_esto_en_produccion), esa
variable SÍ estaba "definida", así que el fail-fast nunca se disparaba. En
producción quedaba la misma vulnerabilidad que ese sistema existe para
prevenir: cualquiera que lea el repo conoce el secret real.
Peor: ADMIN_SECRET (el que protege el panel de administración completo —
banear usuarios, ver reportes) no tenía ningún fail-fast, ni siquiera
el chequeo de "no vacío". Centralizado en un getAdminSecret() nuevo
(mismo patrón que getJwtSecret()), con el mismo fail-fast real en
producción, y agregado el mismo chequeo de "no es el placeholder público"
a ambos.
Verificado: 6 tests nuevos (JWT_SECRET y ADMIN_SECRET, cada uno:
funciona si es real, falla si falta, falla si es el placeholder, no falla
en desarrollo) más 1 test que confirma que la ruta real de admin rechaza
un x-admin-secret que matchea el placeholder — 478/478 tests en
verde en total. tsc --noEmit sin errores.
A pedido explícito: un "arreglador para publicar" que junta en un solo
comando todo lo que hay que revisar antes de sacar Pit OS a producción —
secrets, consistencia de .env, compilación, tests, vulnerabilidades — y
puede arreglar automáticamente lo que tiene sentido arreglar solo (generar
secrets faltantes, instalar dependencias). Ver tools/README.md para el
detalle completo, incluidos los bugs reales que aparecieron construyendo y
probando la herramienta misma.
node tools/publish-check.js # diagnóstico
node tools/publish-check.js --fix # diagnóstico + arreglos automáticosquick-share.sh tiene set -e desde el principio, y el loop que relanza el
túnel de Cloudflare si se cae usaba wait $CF_PID sin más. Si cloudflared
termina con código distinto de cero (lo normal: red inestable, que
Cloudflare cierre el túnel, cualquier corte) — set -e frenaba el script
ENTERO ahí mismo, antes de llegar al aviso de "se cortó, reintentando" y al
reintento. La función completa de "túnel resiliente" (documentada en el
propio comentario del script) estaba rota: un corte de red durante una demo
terminaba el script en silencio en vez de reconectar solo.
Confirmado con una reproducción mínima aislada de set -e + wait sobre un
proceso que sale con error, antes de asumir dónde estaba el problema.
Corregido con wait $CF_PID || true — neutraliza el código de salida solo
de ese wait puntual, sin debilitar set -e para el resto del script
(que sigue abortando ante errores reales de Docker/curl más arriba).
Verificado: reproducido el bug real con un script mínimo aislado (el
loop terminaba después del primer wait, sin imprimir nada más), aplicado el
mismo fix a ese repro y confirmado que ahora sí completa los reintentos.
bash -n sin errores de sintaxis en quick-share.sh y stop-share.sh.
A pedido explícito, una versión mucho más potente de publish-check.js:
además de diagnosticar, busca alternativas reales cuando algo falla en
vez de rendirse — reinstala dependencias limpias si npm install falla,
busca Postgres/Redis nativos si no hay Docker, y prueba una cadena de
túneles públicos (cloudflared → localtunnel → serveo.net por SSH)
cayendo al siguiente automáticamente si uno falla — desinstalando lo
que instaló recién si no funcionó, sin tocar nada que ya estuviera en la
máquina de antes. Funciona con o sin OPENAI_API_KEY (los primeros 4
pasos son 100% determinísticos; la IA solo entra para proponer parches de
bugs de código reales). Ver tools/README.md para el detalle completo.
node tools/publish-master.jsEl hallazgo más serio de toda la auditoría hasta ahora, y no era de
concurrencia: frontend/ — el único cliente que frontend/build.js
empaquetaba y el único que vercel.json despliega — era una versión
PREVIA a la reconstrucción real de E2E (ver CRYPTO_DESIGN.md). No tenía
ni una línea de cifrado del lado del cliente: mandaba content tal cual a
/api/chat/send. El backend ya había sido migrado para esperar que
content venga como un sobre cifrado por ECDH desde el cliente, y ya no
lo cifra en reposo (esa capa se sacó a propósito, asumiendo que el cliente
cifra antes).
Resultado real: cualquiera usando la app desplegada en Vercel guardaba sus
mensajes en texto plano de verdad en Postgres, y cualquier destinatario
usando el cliente E2E real (backend/web-client/index.html, el que sí
tiene el cifrado completo) fallaba al descifrar y veía "🔒 No se pudo
descifrar este mensaje" — mensajería rota en silencio entre los dos
clientes, sin ningún error visible que apuntara a la causa real.
Corregido reescribiendo frontend/build.js para empaquetar
backend/web-client/ (el cliente con el cifrado E2E completo) en vez de
los archivos propios y desactualizados de frontend/, agregando el
soporte del placeholder __BACKEND_URL__ que le faltaba a ese cliente
para mantener la inyección de la URL del backend en el build de Vercel.
frontend/index.html se dejó en el repo con una advertencia grande al
inicio, pero el build ya no lo usa.
Verificado: build corrido a mano con y sin BACKEND_URL seteada,
confirmando que el placeholder se reemplaza igual que antes. Suite
completa del backend sin tocar: sigue en verde.
Barrido módulo por módulo de TODO el backend buscando dos patrones de bug
concretos, no una revisión genérica: (1) una mutación de Prisma sin
try/catch que, bajo una carrera real (doble tap, dos usuarios casi
simultáneos), choca con P2025/P2002 y deja la request colgada para
siempre en vez de responder (Express 4 no reenvía rechazos async al error
middleware sin next(err) explícito); y (2) campos JSON compartidos
leídos-modificados-reescritos sin lock, que pierden escrituras
concurrentes en silencio (lost update).
Encontrados y corregidos con el mismo patrón de fix en cada caso: llamadas
grupales (calls/groupCalls.ts, el mismo lost-update pero en Redis en vez
de Postgres — resuelto con primitivas atómicas nuevas SETNX/SADD/SREM
en el cliente de cache), ajedrez (games/chess.ts, dos jugadas casi
simultáneas podían perder una jugada del historial — resuelto con
pg_advisory_xact_lock igual que en otros módulos), y el borrado de
credenciales biométricas (biometric/controller.ts, el hang-P2025 clásico).
wallet/controller.ts (transferencias de dinero real) se revisó a fondo y
ya estaba sólido — sin cambios necesarios.
Verificado: 594/594 tests en verde, tsc --noEmit sin errores,
npm audit en 0 vulnerabilidades — reconfirmado después de cada fix, no
solo al final.
Revisión completa de prisma/schema.prisma contra el criterio ya aplicado
en otros modelos (índices con comentario justificando por qué). Encontrados
3 modelos consultados por una columna sin índice — full table scan en cada
llamada, cada vez peor a medida que crece la tabla: WebAuthnCredential
(listar credenciales / chequear duplicados al registrar, por userId),
Device (pantalla de "dispositivos vinculados", por userId), y Block
(tenía índice único compuesto [blockerId, blockedId] que solo sirve para
"¿a quién bloqueé?" — pero "¿quién me bloqueó a mí?" consulta por
blockedId solo, sin poder aprovechar ese índice). Los 3 corregidos con
@@index.