SaaS educativo: IDE colaborativo con editor en tiempo real, desafíos de código que se ejecutan en un sandbox aislado, IA Coach que da feedback automático, y salas de pair programming.
codesync/
├── apps/
│ ├── api/ .NET 8 — Clean Architecture + CQRS (MediatR)
│ │ ├── CodeSync.Api/ Controllers HTTP (ChallengeController, SubmissionController, CollaborationController, etc.)
│ │ ├── CodeSync.Application/ Handlers CQRS (GetChallengesHandler, CreateSubmissionHandler, JoinRoomHandler, etc.)
│ │ ├── CodeSync.Domain/ Entidades puras (Challenge, Submission, User, Feedback, Room) + value objects
│ │ ├── CodeSync.Infrastructure/ Servicios concretos (FirestoreRepository, DockerExecutor, OpenRouterApiClient, etc.)
│ │ └── CodeSync.Tests/ 64 unit + integration tests (Firestore emulator real, no mocks)
│ │
│ └── web/ Angular 20 standalone components
│ ├── src/app/
│ │ ├── core/ AuthService, FirebaseService, HttpClient interceptor
│ │ ├── editor/ MonacoEditorComponent, EditorPageComponent, ejecución
│ │ ├── collaboration/ CollaborationService, ChatComponent, room management
│ │ ├── dashboard/ DashboardComponent, progreso, feedback reciente
│ │ ├── auth/ LoginComponent, SignupComponent, authGuard
│ │ ├── shared/ Componentes reutilizables (buttons, cards, loading, etc.)
│ │ └── layouts/ AppShellComponent, topbar, sidebar
│ ├── styles.css Design tokens (--cs-* custom properties, paleta MD3 dark)
│ └── environments/ Firebase config, API URLs
│
├── docs/
│ ├── design-tokens.md Sistema de tokens: paleta, tipografía, escala, spacing
│ └── screenshots/ Capturas para README / ARCHITECTURE
│
└── LICENSE Propietario — all rights reserved
[Angular Web]
↓ POST /api/submissions + Firebase Auth Token
↓
[.NET API]
├─→ FirebaseAuthenticationHandler valida el token JWT
├─→ SubmissionController → CreateSubmissionHandler
├─→ CodeExecutionService → DockerExecutor
│ └─→ Corre código en contenedor con límites (256MB, 5s, sin red)
├─→ Si test falla → AICoachService → OpenRouterApiClient
│ └─→ Rate limit 1/min + fallback a hints pre-generados
└─→ Persiste resultado + feedback en Firestore
↓
[Firebase Realtime DB] ← sync de editor/cursores/chat (ambos sentidos)
↓
[Angular] recibe resultado, feedback, y actualiza UI
5 proyectos, dependencias apuntando hacia adentro (sin ciclos):
- Controllers:
ChallengeController,SubmissionController,CollaborationController,UserController,DashboardController(el IA Coach no tiene controller propio — se dispara dentro deCreateSubmissionHandlercuando falla un test, ver más abajo) - Auth:
FirebaseAuthenticationHandler(custom ASP.NET Core auth scheme) valida JWT con Firebase Admin SDK - Exception handler global: mapea
KeyNotFoundException→ 404,ValidationException→ 400,InvalidOperationException→ 422,UnauthorizedAccessException→ 401 - Swagger: configurado en
Program.cs, servido en/swagger(dev) con documentación de endpoints
- Queries:
GetChallengesQuery,GetChallengeQuery,GetDashboardQuery,GetUserProfileQuery - Commands:
CreateChallengeCommand,CreateSubmissionCommand,CreateRoomCommand,JoinRoomCommand,UpsertUserProfileCommand - Handlers: implementan la lógica; usan repositorios e inyectados de
Infrastructure - Validators:
CreateSubmissionValidator,CreateRoomValidatorvía FluentValidation - Servicios de aplicación:
CodeExecutionService,AICoachService(orquestación, no raw infra)
- Entidades:
Challenge,Submission,User,Feedback,Room - Value Objects:
TestCase,ExecutionResult,RoomInviteCode - Enums:
DifficultyLevel(Easy/Medium/Hard),ProgrammingLanguage(Python/JavaScript),SubmissionStatus(Pending/Running/Completed) - Repositorios (interfaces):
IChallengRepository,ISubmissionRepository,IUserRepository,IFeedbackRepository,IRoomRepository - Servicios (interfaces):
ICodeExecutor,IAICoachClient,IFirebaseRealtime
- Firestore repositories:
ChallengeFirestoreRepository,UserFirestoreRepository,SubmissionFirestoreRepository,FeedbackFirestoreRepository,RoomFirestoreRepositoryRoomFirestoreRepository.JoinAsyncusaFirestoreDb.RunTransactionAsyncpara garantizar serialización en el cupo máximo de 4 usuarios (previene race condition last-write-wins)
- Docker executor:
DockerExecutorvíaDocker.DotNet; limites:NetworkMode=none,MemorySwap=Memory(256MB),ReadonlyRootfs=true,User=nobody, timeout 5s SIGKILL - Firebase Realtime:
FirebaseRealtimeServicepara sync de editor, cursores, chat - OpenRouter API Client:
OpenRouterApiClientHttpClient tipado (no SDK, REST compatible con OpenAI/chat/completions) con fallback aFallbackHintProvider. Si el modelo gratuito se retira (404) o hay un 5xx reintenta una vez conopenrouter/free; un 429 no se reintenta porque la cuota diaria de los:freees compartida. - Rate limiter:
InMemoryRateLimiter(diccionario con ventana configurable; no es persistente, reset en redeploy) - Seeding:
ChallengeSeedercarga 10 desafíos seed (Python + JavaScript) en desarrollo
- Unit tests:
CodeExecutionService(6 tests: pass/partial-fail/timeout/syntax-error/docker-error/language-routing) - Unit tests CQRS:
GetChallengeHandler(found/not-found),GetChallengesHandler(order),CreateRoomHandler(happy path/challenge missing),JoinRoomHandler(add/idempotent/full/bad-code) - Unit tests utilidades:
InMemoryRateLimiter(5 tests: request/blocked/keys/expiry) - Integration tests: contra Firestore emulator real (no mocks), incluyendo
HtmlAssertionEngineTests(Chromium real víacodesync-html-runner) yDockerExecutorTestsChallenge,User,Roomrepository tests- Prueba crítica:
JoinRoomAsynccon 2 usuarios concurrentes por el último cupo; verifica que Firestore transaction serializa y solo uno gana (sin last-write-wins)
- Requiere Docker corriendo — 3 tests (Docker + HTML runner) fallan con timeout/"Docker error" si el daemon está apagado o si
codesync-html-runner:1.48.0no se buildeó localmente todavía (verRUN.md). No es un bug de código, es un prerequisito de entorno.
auth-challenge.spec.ts (signup, lista de desafíos, resolver con éxito, feedback IA Coach fallback), avatar-upload.spec.ts, change-password.spec.ts, room-collaboration.spec.ts (2 browser contexts). Corre contra Firebase emulators (demo-codesync-test) + Docker real — ver playwright.config.ts.
Quirk de seguridad resuelto (2026-08-25): el webServer de Playwright apuntaba al puerto 4200 con reuseExistingServer: true — el mismo puerto que npm start (en su momento apuntaba al Firebase de producción; hoy environment.ts apunta a los emuladores). Si quedaba un ng serve normal corriendo, Playwright lo reusaba en vez de levantar el server con config de emulators, y los tests escribían cuentas reales en Firestore/Auth de producción (así aparecieron 4 cuentas e2e-*@codesync.test en codesync-95667, limpiadas en esa fecha). Fix: E2E corre en su propio puerto (4210), estructuralmente no puede colisionar con el dev server. Requirió agregar http://localhost:4210 a Cors:AllowedOrigins en appsettings.json.
Cada feature (auth, editor, dashboard, etc.) vive en su propia carpeta con componentes + servicios locales.
core/— servicios singleton (Firebase, HTTP, Auth)AuthService: Firebase Auth (Google, GitHub, Email)FirebaseService: acceso a Realtime DB y FirestoreHttpClientinterceptor automáticamente inyecta el Firebase ID Token en todos los requests
auth/—LoginComponent,SignupComponent,authGuard(redirige a login si no autenticado)editor/—EditorPageComponent(contenedor principal de una sesión)MonacoEditorComponent(wrapper): carga Monaco desde assets via AMD, integra conCollaborationServiceExecutionPanelComponentmostrá resultados + logsCoachPanelComponentmuestra feedback del IA Coach (o hints fallback)
collaboration/—CollaborationServicesincroniza editor/cursores/chatChatComponenten tiempo real- Cursores remotos renderizados como decoraciones Monaco
dashboard/— progreso, desafíos completados, level, feedback recienteshared/— botones, cards, spinners, error message, etc. (sin lógica de negocio)layouts/—AppShellComponent(topbar fijo + sidebar con navegación)
- Tokens:
docs/design-tokens.md(paleta, tipografía, spacing, radios) - Paleta: Material Design 3 dark → azul primario
#adc6ff, verde secundario#4edea3, púrpura para IA Coach#c89cff - Tipografía: Inter (body), JetBrains Mono (code)
- CSS:
styles.cssdefine--cs-*custom properties (reemplazó a variables sueltas) - Accesibilidad: focus-visible global (ring 2px),
aria-liveen paneles dinámicos,.sr-onlypara screen readers, sin emojis decorativos
Un solo tema: Material Design 3 dark (sin toggle light/dark — elegición de diseño para que sea opinado).
/collaborations/{roomId}/
code: string (última versión, último editador sincroniza cada 2s)
cursors:
{userId}: { line, column }
chat[]:
{ userId, message, timestamp }
collections/
challenges/ id: docId, title, description, difficulty, language, testCases[], seed
submissions/ id, userId, challengeId, code, status, executionResult, error, createdAt
feedback/ id, submissionId, userId, challengeId, coachFeedback, isFallback, createdAt
users/ id (uid Firebase), displayName, avatar, level, completedChallenges[]
rooms/ id, ownerId, memberIds[], inviteCode, createdAt
Realtime:
/collaborations/{roomId}/ → read/write si auth.uid ∈ room.memberIds
Firestore:
submissions: create si auth.uid == userId (crear propia)
submissions: read si auth.uid == userId (leer propia)
feedback: read si auth.uid == userId (leer propia)
users: write si auth.uid == uid (editar el propio perfil)
Por qué: El MVP no necesita queries relacionales complejas. Firestore cubre desafíos, submissions, usuarios, feedback. Mantener una sola BD evita sincronización entre dos fuentes de verdad.
Trade-off: Si aparece un reporte complejo (ej. "desafíos más intentados por nivel"), se reconsideraría un data warehouse o replicación a BigQuery.
Por qué: Costo y free tier. Proyecto de portfolio defendible sin quemar presupuesto, y un solo cliente HTTP compatible con OpenAI permite cambiar de modelo con una variable de entorno (OpenRouter__Model). Originalmente se usó la API de Gemini; se reemplazó al archivar el proyecto porque el modelo configurado (gemini-1.5-flash) era viejo y OpenRouter expone modelos gratuitos actuales con la misma interfaz.
Trade-off: los modelos :free tienen cuota diaria baja (50 requests/día sin créditos según el FAQ de OpenRouter) y la lista cambia sin aviso; por eso el fallback a hints pre-generados es parte del diseño, no un parche. Para hints de programación de nivel educativo alcanza.
Por qué: Seguridad. Ejecutar código arbitrario de usuarios en el backend sin aislamiento es suicidio. Docker no es perfecto (VM sería mejor), pero es el trade-off práctico: costo/complejidad vs. seguridad.
Límites aplicados:
- Sin red (
NetworkMode=none) → no puede hacer requests exteriores - 256MB RAM + sin swap (
MemorySwap=Memory) → no puede consumir toda la máquina - Timeout 5s SIGKILL → no puede colgar la API
- Filesystem read-only +
User=nobody→ no puede escribir ni escalar privilegios - PidsLimit=50 → no puede fork bombas
Riesgo residual: Docker escape (parcheable con actualizaciones) o vulnerabilidades en el intérprete (Python/Node, out of scope).
Problema real encontrado en tests de integración: la primera versión de JoinRoomAsync era:
room.MemberIds.Add(userId);
await db.Collection("rooms").Document(roomId).SetAsync(room, SetOptions.Overwrite);Con 2 usuarios intentando unirse al mismo tiempo por el último cupo, ambos veían Count < 4 y ambos se agregaban (last-write-wins, el segundo pisaba al primero pero creía que gané, el primero recibía 422 pero ya se había sumado). Bug crítico de concurrencia.
Solución: Envolver el read-check-write en FirestoreDb.RunTransactionAsync:
await db.RunTransactionAsync(async txn => {
var doc = await txn.GetAsync(roomRef);
if (doc.Exists && doc.GetValue<Room>("memberIds").Count < 4) {
// solo si pasa aquí, se ejecuta el write
var updated = ...;
txn.Update(roomRef, "memberIds", updated);
} else {
throw new InvalidOperationException("Sala llena o no existe");
}
});Firestore serializa la transacción → garantizado que solo uno gana, los demás abortan y reciben excepción → cliente reintenta o rechaza elegantemente. Verificado con test de 2 joins concurrentes en FASE 3. Ese test fue el que cazó el bug; es un talking point en entrevista (muestra debugging de race conditions reales).
Por qué: MVP simple, una sola instancia. Si escala a múltiples servidores, Redis es obligatorio.
Quirk: reset en redeploy. Si Mateo restartea la API, los límites se resetean (no persisten). Para MVP está bien; en producción, usar Redis.
Por qué: Simplicidad. Mateo mantiene DTOs (.NET) y TypeScript interfaces (Angular) a mano. Si creciera, generaría TypeScript desde OpenAPI o usaría NSwag.
- Persistencia del rate limit — migrar
InMemoryRateLimitera Redis para ambiente distribuido - Cola de ejecuciones — si el volumen de submissions sube, ejecutar código via BullMQ (o similar) en vez de sincrónico
- Tipos compartidos — NSwag para generar TypeScript desde OpenAPI, evitar duplicación
- Observabilidad — Application Insights + logs estructurados para producción
- Badges/logros — el leaderboard global por nivel ya se agregó (v0.2.0,
GetLeaderboardHandler); badges siguen en backlog, la DB ya los soporta - Modo profesor — gestionar aulas, asignar desafíos, ver reportes por estudiante
- Más lenguajes — el sandbox ya cubre Python, JavaScript, HTML, CSS, Ruby, Java y C#; Go se evaluó y se descartó por ahora (ver
ProgrammingLanguage.cs— necesita tmpfs ejecutable, incompatible con elnoexec /tmpdel contenedor). Rust sigue pendiente. - CI/CD — hay un workflow (
.github/workflows/ci.yml) que corre los tests unitarios del backend y el build del front; faltan los tests de integración (necesitan emulador de Firestore) y los E2E